mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
201
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
238750c025 | ||
|
|
6fdb1559aa | ||
|
|
fbe3db74d7 | ||
|
|
d1f4cb615b | ||
|
|
19f1086492 | ||
|
|
8ab3aeba7c | ||
|
|
6423b352d6 | ||
|
|
7b942b363d | ||
|
|
8b8d65b509 | ||
|
|
23187b0b6a | ||
|
|
f66cdbfeb2 | ||
|
|
99137fe493 | ||
|
|
79dcec8a2e | ||
|
|
97f8d1968f | ||
|
|
ae1292a14b | ||
|
|
6ea5a156b6 | ||
|
|
401cc35743 | ||
|
|
b8d08df244 | ||
|
|
2ee0a44735 | ||
|
|
a9b9cf6bf5 | ||
|
|
d6622dc6b3 | ||
|
|
6ec818fc11 | ||
|
|
6daa69635b | ||
|
|
6b1f38a9a3 | ||
|
|
56ca09692a | ||
|
|
0b7597b31f | ||
|
|
e6c6193638 | ||
|
|
685d902740 | ||
|
|
eea6d43c82 | ||
|
|
6a22a3ecbd | ||
|
|
0dffefdd59 | ||
|
|
cceb835a84 | ||
|
|
f1b45b4328 | ||
|
|
7428dc75cf | ||
|
|
1301d027e9 | ||
|
|
f5f31b43df | ||
|
|
a709af0059 | ||
|
|
75eebf52bc | ||
|
|
1582423dbf | ||
|
|
148d38d7c9 | ||
|
|
3e89d29985 | ||
|
|
729d10592f | ||
|
|
9da365bc01 | ||
|
|
8bdc894690 | ||
|
|
0387bd4c80 | ||
|
|
b1d0b10354 | ||
|
|
94eed96cad | ||
|
|
794d4ae149 | ||
|
|
011f51051c | ||
|
|
c46d28891f | ||
|
|
66e4367bc4 | ||
|
|
8161de00e6 | ||
|
|
eb28942dab | ||
|
|
68f175d3be | ||
|
|
d5c1b7bf0e | ||
|
|
0868d9cf13 | ||
|
|
8a3577d2e7 | ||
|
|
ac43a5a73f | ||
|
|
6312fc5b07 | ||
|
|
2da93aa98f | ||
|
|
f4bd689f51 | ||
|
|
4f7dc5ee3a | ||
|
|
4e8b7d3745 | ||
|
|
ee372266ca | ||
|
|
e10b0a3355 | ||
|
|
3914ea9b71 | ||
|
|
370944f443 | ||
|
|
c839db9234 | ||
|
|
769a7e708a | ||
|
|
1fa5442f21 | ||
|
|
dc0a8e7c96 | ||
|
|
3db30c72e7 | ||
|
|
eca13e3e71 | ||
|
|
1943a4d18f | ||
|
|
3eccc2f9e3 | ||
|
|
368a20ddc2 | ||
|
|
64588ceeea | ||
|
|
8fe66090e7 | ||
|
|
91e1232112 | ||
|
|
f726d95295 | ||
|
|
78a0a27a72 | ||
|
|
b68bf00454 | ||
|
|
cd19a4409f | ||
|
|
f1ed84006a | ||
|
|
8d82ea9c0b | ||
|
|
6aa4ffe462 | ||
|
|
8a54e5d884 | ||
|
|
5e6c26f3c1 | ||
|
|
520c0b3264 | ||
|
|
607eaeacc7 | ||
|
|
6de6c7fd95 | ||
|
|
34c67ef8e8 | ||
|
|
5f2f00dde0 | ||
|
|
6db7ad0696 | ||
|
|
24a0d10a92 | ||
|
|
e3d4f3fa92 | ||
|
|
d85566ab8d | ||
|
|
3191e038d8 | ||
|
|
ba994d8bf9 | ||
|
|
270e2951b1 | ||
|
|
496f7d690d | ||
|
|
11ee0dbf27 | ||
|
|
2c344fa01d | ||
|
|
3d9d669cb9 | ||
|
|
7feb3b238c | ||
|
|
7d4e166678 | ||
|
|
cad88ee4d1 | ||
|
|
36efdf66e7 | ||
|
|
2272598d42 | ||
|
|
5a2aaf3601 | ||
|
|
108d3f4eca | ||
|
|
8230faf082 | ||
|
|
dbd68ff139 | ||
|
|
0d0d737563 | ||
|
|
f06a930fc1 | ||
|
|
532f050a64 | ||
|
|
d422b602c9 | ||
|
|
cbd1b82123 | ||
|
|
4f7d401a97 | ||
|
|
30709d7134 | ||
|
|
93a0afdf64 | ||
|
|
b8979c65d4 | ||
|
|
f47e85c3bb | ||
|
|
e43b859439 | ||
|
|
e4b67094e7 | ||
|
|
79df736431 | ||
|
|
c411edb7c1 | ||
|
|
2776adaae1 | ||
|
|
964ac82d5e | ||
|
|
05243616ea | ||
|
|
ccd552a1c0 | ||
|
|
fcc09e80e2 | ||
|
|
dedb13546a | ||
|
|
c56d6f8188 | ||
|
|
e62227914a | ||
|
|
44d8c4c876 | ||
|
|
20235d0724 | ||
|
|
0997466331 | ||
|
|
1bd9fac203 | ||
|
|
69da5bee0c | ||
|
|
cb99925d75 | ||
|
|
78c6c497f1 | ||
|
|
22725f71fa | ||
|
|
1a8a1e889a | ||
|
|
3516bd69f4 | ||
|
|
57fc0fd323 | ||
|
|
bd9ec023ee | ||
|
|
f4b9f40ab1 | ||
|
|
93cc5d50f3 | ||
|
|
8a1d37cf3e | ||
|
|
e579ae1202 | ||
|
|
b048b739bd | ||
|
|
dc82826951 | ||
|
|
608dc1a62f | ||
|
|
f1d5932c54 | ||
|
|
cc150c7cfb | ||
|
|
f8fb238b22 | ||
|
|
0488a6be45 | ||
|
|
0f5dca4f12 | ||
|
|
154b78c7d1 | ||
|
|
d23be079cc | ||
|
|
70a6d58b84 | ||
|
|
dcf54c1250 | ||
|
|
5cc6abc3a4 | ||
|
|
77a14b1023 | ||
|
|
1c3b22edec | ||
|
|
41562baeb5 | ||
|
|
df207e0f83 | ||
|
|
dc5956900e | ||
|
|
b095862cc0 | ||
|
|
0a6ede73ae | ||
|
|
7ce0018c9b | ||
|
|
9528d0053b | ||
|
|
689853312d | ||
|
|
5a1d9ef3f4 | ||
|
|
eb66e03f0e | ||
|
|
a2418751e6 | ||
|
|
5777a555f6 | ||
|
|
220ec757d4 | ||
|
|
585e28d49b | ||
|
|
1261e663e1 | ||
|
|
c34e01c63e | ||
|
|
3e75852ffe | ||
|
|
e95992b406 | ||
|
|
6df9d70b9b | ||
|
|
f8a0390827 | ||
|
|
d862e56e34 | ||
|
|
d08fbd73e1 | ||
|
|
4d127a038b | ||
|
|
82817cb50c | ||
|
|
a54b3ef076 | ||
|
|
6dd8191f4b | ||
|
|
722dcdd88f | ||
|
|
8db3bead13 | ||
|
|
39ea298a9c | ||
|
|
2c0fd4f86b | ||
|
|
5e2b829f8a | ||
|
|
b09635064a | ||
|
|
6018efc5ac | ||
|
|
7e2a913e13 | ||
|
|
63496d05a2 |
@@ -45,10 +45,9 @@ Thank you for your interest in contributing to OpenCut! This document provides g
|
|||||||
5. Install dependencies: `bun install`
|
5. Install dependencies: `bun install`
|
||||||
6. Start the development server: `bun run dev`
|
6. Start the development server: `bun run dev`
|
||||||
|
|
||||||
> **Note:** If you see an error like `Unsupported URL Type "workspace:*"` when running `npm install`, you have two options:
|
> **Note:** Web development uses the published `opencut-wasm` package by default, so a fresh clone does not need a local WASM build.
|
||||||
>
|
>
|
||||||
> 1. Upgrade to a recent npm version (v9 or later), which has full workspace protocol support.
|
> If you are editing `rust/wasm`, run `bun run build:wasm`, then `cd rust/wasm/pkg && bun link`, then `cd ../../../apps/web && bun link opencut-wasm`.
|
||||||
> 2. Use an alternative package manager such as **bun** or **pnpm**.
|
|
||||||
|
|
||||||
### Desktop setup
|
### Desktop setup
|
||||||
|
|
||||||
@@ -169,7 +168,7 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README
|
|||||||
2. Make your changes
|
2. Make your changes
|
||||||
3. Run the relevant checks for the area you touched:
|
3. Run the relevant checks for the area you touched:
|
||||||
|
|
||||||
- Web changes: from `apps/web`, run `bun run lint` and `bunx biome format --write .`
|
- Web changes: from `apps/web`, run `bun run lint` and `bun run format`
|
||||||
- Desktop changes: run `./apps/desktop/script/setup` if your environment isn't set up yet
|
- Desktop changes: run `./apps/desktop/script/setup` if your environment isn't set up yet
|
||||||
|
|
||||||
4. Commit your changes with a descriptive message
|
4. Commit your changes with a descriptive message
|
||||||
@@ -177,8 +176,8 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README
|
|||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
- We use Biome for code formatting and linting
|
- We use ESLint for linting and Prettier for formatting
|
||||||
- Run `bunx biome format --write .` from the `apps/web` directory to format code
|
- Run `bun run format` from the `apps/web` directory to format code
|
||||||
- Run `bun run lint` from the `apps/web` directory to check for linting issues
|
- Run `bun run lint` from the `apps/web` directory to check for linting issues
|
||||||
- Follow the existing code patterns
|
- Follow the existing code patterns
|
||||||
|
|
||||||
|
|||||||
+342
-344
@@ -1,344 +1,342 @@
|
|||||||
---
|
---
|
||||||
applyTo: "**/*.{ts,tsx,js,jsx}"
|
applyTo: "**/*.{ts,tsx,js,jsx}"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project Context
|
# Project Context
|
||||||
|
|
||||||
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter.
|
Strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects, enforced via ESLint (linting) and Prettier (formatting).
|
||||||
|
|
||||||
## Key Principles
|
## Key Principles
|
||||||
|
|
||||||
- Zero configuration required
|
- Maximum type safety
|
||||||
- Subsecond performance
|
- AI-friendly code generation
|
||||||
- Maximum type safety
|
|
||||||
- AI-friendly code generation
|
## Before Writing Code
|
||||||
|
|
||||||
## Before Writing Code
|
1. Analyze existing patterns in the codebase
|
||||||
|
2. Consider edge cases and error scenarios
|
||||||
1. Analyze existing patterns in the codebase
|
3. Follow the rules below strictly
|
||||||
2. Consider edge cases and error scenarios
|
4. Validate accessibility requirements
|
||||||
3. Follow the rules below strictly
|
|
||||||
4. Validate accessibility requirements
|
## Rules
|
||||||
|
|
||||||
## Rules
|
### Accessibility (a11y)
|
||||||
|
|
||||||
### Accessibility (a11y)
|
- Don't use `accessKey` attribute on any HTML element.
|
||||||
|
- Don't set `aria-hidden="true"` on focusable elements.
|
||||||
- Don't use `accessKey` attribute on any HTML element.
|
- Don't add ARIA roles, states, and properties to elements that don't support them.
|
||||||
- Don't set `aria-hidden="true"` on focusable elements.
|
- Don't use distracting elements like `<marquee>` or `<blink>`.
|
||||||
- Don't add ARIA roles, states, and properties to elements that don't support them.
|
- Only use the `scope` prop on `<th>` elements.
|
||||||
- Don't use distracting elements like `<marquee>` or `<blink>`.
|
- Don't assign non-interactive ARIA roles to interactive HTML elements.
|
||||||
- Only use the `scope` prop on `<th>` elements.
|
- Make sure label elements have text content and are associated with an input.
|
||||||
- Don't assign non-interactive ARIA roles to interactive HTML elements.
|
- Don't assign interactive ARIA roles to non-interactive HTML elements.
|
||||||
- Make sure label elements have text content and are associated with an input.
|
- Don't assign `tabIndex` to non-interactive HTML elements.
|
||||||
- Don't assign interactive ARIA roles to non-interactive HTML elements.
|
- Don't use positive integers for `tabIndex` property.
|
||||||
- Don't assign `tabIndex` to non-interactive HTML elements.
|
- Don't include "image", "picture", or "photo" in img alt prop.
|
||||||
- Don't use positive integers for `tabIndex` property.
|
- Don't use explicit role property that's the same as the implicit/default role.
|
||||||
- Don't include "image", "picture", or "photo" in img alt prop.
|
- Make static elements with click handlers use a valid role attribute.
|
||||||
- Don't use explicit role property that's the same as the implicit/default role.
|
- Always include a `title` element for SVG elements.
|
||||||
- Make static elements with click handlers use a valid role attribute.
|
- Give all elements requiring alt text meaningful information for screen readers.
|
||||||
- Always include a `title` element for SVG elements.
|
- Make sure anchors have content that's accessible to screen readers.
|
||||||
- Give all elements requiring alt text meaningful information for screen readers.
|
- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
|
||||||
- Make sure anchors have content that's accessible to screen readers.
|
- Include all required ARIA attributes for elements with ARIA roles.
|
||||||
- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
|
- Make sure ARIA properties are valid for the element's supported roles.
|
||||||
- Include all required ARIA attributes for elements with ARIA roles.
|
- Always include a `type` attribute for button elements.
|
||||||
- Make sure ARIA properties are valid for the element's supported roles.
|
- Make elements with interactive roles and handlers focusable.
|
||||||
- Always include a `type` attribute for button elements.
|
- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
|
||||||
- Make elements with interactive roles and handlers focusable.
|
- Always include a `lang` attribute on the html element.
|
||||||
- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
|
- Always include a `title` attribute for iframe elements.
|
||||||
- Always include a `lang` attribute on the html element.
|
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
|
||||||
- Always include a `title` attribute for iframe elements.
|
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
|
||||||
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
|
- Include caption tracks for audio and video elements.
|
||||||
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
|
- Use semantic elements instead of role attributes in JSX.
|
||||||
- Include caption tracks for audio and video elements.
|
- Make sure all anchors are valid and navigable.
|
||||||
- Use semantic elements instead of role attributes in JSX.
|
- Ensure all ARIA properties (`aria-*`) are valid.
|
||||||
- Make sure all anchors are valid and navigable.
|
- Use valid, non-abstract ARIA roles for elements with ARIA roles.
|
||||||
- Ensure all ARIA properties (`aria-*`) are valid.
|
- Use valid ARIA state and property values.
|
||||||
- Use valid, non-abstract ARIA roles for elements with ARIA roles.
|
- Use valid values for the `autocomplete` attribute on input elements.
|
||||||
- Use valid ARIA state and property values.
|
- Use correct ISO language/country codes for the `lang` attribute.
|
||||||
- Use valid values for the `autocomplete` attribute on input elements.
|
|
||||||
- Use correct ISO language/country codes for the `lang` attribute.
|
### Code Complexity and Quality
|
||||||
|
|
||||||
### Code Complexity and Quality
|
- Don't use consecutive spaces in regular expression literals.
|
||||||
|
- Don't use the `arguments` object.
|
||||||
- Don't use consecutive spaces in regular expression literals.
|
- Don't use primitive type aliases or misleading types.
|
||||||
- Don't use the `arguments` object.
|
- Don't use the comma operator.
|
||||||
- Don't use primitive type aliases or misleading types.
|
- Don't use empty type parameters in type aliases and interfaces.
|
||||||
- Don't use the comma operator.
|
- Don't write functions that exceed a given Cognitive Complexity score.
|
||||||
- Don't use empty type parameters in type aliases and interfaces.
|
- Don't nest describe() blocks too deeply in test files.
|
||||||
- Don't write functions that exceed a given Cognitive Complexity score.
|
- Don't use unnecessary boolean casts.
|
||||||
- Don't nest describe() blocks too deeply in test files.
|
- Don't use unnecessary callbacks with flatMap.
|
||||||
- Don't use unnecessary boolean casts.
|
- Use for...of statements instead of Array.forEach.
|
||||||
- Don't use unnecessary callbacks with flatMap.
|
- Don't create classes that only have static members (like a static namespace).
|
||||||
- Use for...of statements instead of Array.forEach.
|
- Don't use this and super in static contexts.
|
||||||
- Don't create classes that only have static members (like a static namespace).
|
- Don't use unnecessary catch clauses.
|
||||||
- Don't use this and super in static contexts.
|
- Don't use unnecessary constructors.
|
||||||
- Don't use unnecessary catch clauses.
|
- Don't use unnecessary continue statements.
|
||||||
- Don't use unnecessary constructors.
|
- Don't export empty modules that don't change anything.
|
||||||
- Don't use unnecessary continue statements.
|
- Don't use unnecessary escape sequences in regular expression literals.
|
||||||
- Don't export empty modules that don't change anything.
|
- Don't use unnecessary fragments.
|
||||||
- Don't use unnecessary escape sequences in regular expression literals.
|
- Don't use unnecessary labels.
|
||||||
- Don't use unnecessary fragments.
|
- Don't use unnecessary nested block statements.
|
||||||
- Don't use unnecessary labels.
|
- Don't rename imports, exports, and destructured assignments to the same name.
|
||||||
- Don't use unnecessary nested block statements.
|
- Don't use unnecessary string or template literal concatenation.
|
||||||
- Don't rename imports, exports, and destructured assignments to the same name.
|
- Don't use String.raw in template literals when there are no escape sequences.
|
||||||
- Don't use unnecessary string or template literal concatenation.
|
- Don't use useless case statements in switch statements.
|
||||||
- Don't use String.raw in template literals when there are no escape sequences.
|
- Don't use ternary operators when simpler alternatives exist.
|
||||||
- Don't use useless case statements in switch statements.
|
- Don't use useless `this` aliasing.
|
||||||
- Don't use ternary operators when simpler alternatives exist.
|
- Don't use any or unknown as type constraints.
|
||||||
- Don't use useless `this` aliasing.
|
- Don't initialize variables to undefined.
|
||||||
- Don't use any or unknown as type constraints.
|
- Don't use the void operators (they're not familiar).
|
||||||
- Don't initialize variables to undefined.
|
- Use arrow functions instead of function expressions.
|
||||||
- Don't use the void operators (they're not familiar).
|
- Use Date.now() to get milliseconds since the Unix Epoch.
|
||||||
- Use arrow functions instead of function expressions.
|
- Use .flatMap() instead of map().flat() when possible.
|
||||||
- Use Date.now() to get milliseconds since the Unix Epoch.
|
- Use literal property access instead of computed property access.
|
||||||
- Use .flatMap() instead of map().flat() when possible.
|
- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
|
||||||
- Use literal property access instead of computed property access.
|
- Use concise optional chaining instead of chained logical expressions.
|
||||||
- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
|
- Use regular expression literals instead of the RegExp constructor when possible.
|
||||||
- Use concise optional chaining instead of chained logical expressions.
|
- Don't use number literal object member names that aren't base 10 or use underscore separators.
|
||||||
- Use regular expression literals instead of the RegExp constructor when possible.
|
- Remove redundant terms from logical expressions.
|
||||||
- Don't use number literal object member names that aren't base 10 or use underscore separators.
|
- Use while loops instead of for loops when you don't need initializer and update expressions.
|
||||||
- Remove redundant terms from logical expressions.
|
- Don't pass children as props.
|
||||||
- Use while loops instead of for loops when you don't need initializer and update expressions.
|
- Don't reassign const variables.
|
||||||
- Don't pass children as props.
|
- Don't use constant expressions in conditions.
|
||||||
- Don't reassign const variables.
|
- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
|
||||||
- Don't use constant expressions in conditions.
|
- Don't return a value from a constructor.
|
||||||
- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
|
- Don't use empty character classes in regular expression literals.
|
||||||
- Don't return a value from a constructor.
|
- Don't use empty destructuring patterns.
|
||||||
- Don't use empty character classes in regular expression literals.
|
- Don't call global object properties as functions.
|
||||||
- Don't use empty destructuring patterns.
|
- Don't declare functions and vars that are accessible outside their block.
|
||||||
- Don't call global object properties as functions.
|
- Make sure builtins are correctly instantiated.
|
||||||
- Don't declare functions and vars that are accessible outside their block.
|
- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
|
||||||
- Make sure builtins are correctly instantiated.
|
- Don't use variables and function parameters before they're declared.
|
||||||
- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
|
- Don't use 8 and 9 escape sequences in string literals.
|
||||||
- Don't use variables and function parameters before they're declared.
|
- Don't use literal numbers that lose precision.
|
||||||
- Don't use 8 and 9 escape sequences in string literals.
|
|
||||||
- Don't use literal numbers that lose precision.
|
### React and JSX Best Practices
|
||||||
|
|
||||||
### React and JSX Best Practices
|
- Don't use the return value of React.render.
|
||||||
|
- Make sure all dependencies are correctly specified in React hooks.
|
||||||
- Don't use the return value of React.render.
|
- Make sure all React hooks are called from the top level of component functions.
|
||||||
- Make sure all dependencies are correctly specified in React hooks.
|
- Don't forget key props in iterators and collection literals.
|
||||||
- Make sure all React hooks are called from the top level of component functions.
|
- Don't destructure props inside JSX components in Solid projects.
|
||||||
- Don't forget key props in iterators and collection literals.
|
- Don't define React components inside other components.
|
||||||
- Don't destructure props inside JSX components in Solid projects.
|
- Don't use event handlers on non-interactive elements.
|
||||||
- Don't define React components inside other components.
|
- Don't assign to React component props.
|
||||||
- Don't use event handlers on non-interactive elements.
|
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
|
||||||
- Don't assign to React component props.
|
- Don't use dangerous JSX props.
|
||||||
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
|
- Don't use Array index in keys.
|
||||||
- Don't use dangerous JSX props.
|
- Don't insert comments as text nodes.
|
||||||
- Don't use Array index in keys.
|
- Don't assign JSX properties multiple times.
|
||||||
- Don't insert comments as text nodes.
|
- Don't add extra closing tags for components without children.
|
||||||
- Don't assign JSX properties multiple times.
|
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
|
||||||
- Don't add extra closing tags for components without children.
|
- Watch out for possible "wrong" semicolons inside JSX elements.
|
||||||
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
|
|
||||||
- Watch out for possible "wrong" semicolons inside JSX elements.
|
### Correctness and Safety
|
||||||
|
|
||||||
### Correctness and Safety
|
- Don't assign a value to itself.
|
||||||
|
- Don't return a value from a setter.
|
||||||
- Don't assign a value to itself.
|
- Don't compare expressions that modify string case with non-compliant values.
|
||||||
- Don't return a value from a setter.
|
- Don't use lexical declarations in switch clauses.
|
||||||
- Don't compare expressions that modify string case with non-compliant values.
|
- Don't use variables that haven't been declared in the document.
|
||||||
- Don't use lexical declarations in switch clauses.
|
- Don't write unreachable code.
|
||||||
- Don't use variables that haven't been declared in the document.
|
- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass.
|
||||||
- Don't write unreachable code.
|
- Don't use control flow statements in finally blocks.
|
||||||
- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass.
|
- Don't use optional chaining where undefined values aren't allowed.
|
||||||
- Don't use control flow statements in finally blocks.
|
- Don't have unused function parameters.
|
||||||
- Don't use optional chaining where undefined values aren't allowed.
|
- Don't have unused imports.
|
||||||
- Don't have unused function parameters.
|
- Don't have unused labels.
|
||||||
- Don't have unused imports.
|
- Don't have unused private class members.
|
||||||
- Don't have unused labels.
|
- Don't have unused variables.
|
||||||
- Don't have unused private class members.
|
- Make sure void (self-closing) elements don't have children.
|
||||||
- Don't have unused variables.
|
- Don't return a value from a function with the return type 'void'
|
||||||
- Make sure void (self-closing) elements don't have children.
|
- Use isNaN() when checking for NaN.
|
||||||
- Don't return a value from a function with the return type 'void'
|
- Make sure "for" loop update clauses move the counter in the right direction.
|
||||||
- Use isNaN() when checking for NaN.
|
- Make sure typeof expressions are compared to valid values.
|
||||||
- Make sure "for" loop update clauses move the counter in the right direction.
|
- Make sure generator functions contain yield.
|
||||||
- Make sure typeof expressions are compared to valid values.
|
- Don't use await inside loops.
|
||||||
- Make sure generator functions contain yield.
|
- Don't use bitwise operators.
|
||||||
- Don't use await inside loops.
|
- Don't use expressions where the operation doesn't change the value.
|
||||||
- Don't use bitwise operators.
|
- Make sure Promise-like statements are handled appropriately.
|
||||||
- Don't use expressions where the operation doesn't change the value.
|
- Don't use **dirname and **filename in the global scope.
|
||||||
- Make sure Promise-like statements are handled appropriately.
|
- Prevent import cycles.
|
||||||
- Don't use **dirname and **filename in the global scope.
|
- Don't use configured elements.
|
||||||
- Prevent import cycles.
|
- Don't hardcode sensitive data like API keys and tokens.
|
||||||
- Don't use configured elements.
|
- Don't let variable declarations shadow variables from outer scopes.
|
||||||
- Don't hardcode sensitive data like API keys and tokens.
|
- Don't use the TypeScript directive @ts-ignore.
|
||||||
- Don't let variable declarations shadow variables from outer scopes.
|
- Prevent duplicate polyfills from Polyfill.io.
|
||||||
- Don't use the TypeScript directive @ts-ignore.
|
- Don't use useless backreferences in regular expressions that always match empty strings.
|
||||||
- Prevent duplicate polyfills from Polyfill.io.
|
- Don't use unnecessary escapes in string literals.
|
||||||
- Don't use useless backreferences in regular expressions that always match empty strings.
|
- Don't use useless undefined.
|
||||||
- Don't use unnecessary escapes in string literals.
|
- Make sure getters and setters for the same property are next to each other in class and object definitions.
|
||||||
- Don't use useless undefined.
|
- Make sure object literals are declared consistently (defaults to explicit definitions).
|
||||||
- Make sure getters and setters for the same property are next to each other in class and object definitions.
|
- Use static Response methods instead of new Response() constructor when possible.
|
||||||
- Make sure object literals are declared consistently (defaults to explicit definitions).
|
- Make sure switch-case statements are exhaustive.
|
||||||
- Use static Response methods instead of new Response() constructor when possible.
|
- Make sure the `preconnect` attribute is used when using Google Fonts.
|
||||||
- Make sure switch-case statements are exhaustive.
|
- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
|
||||||
- Make sure the `preconnect` attribute is used when using Google Fonts.
|
- Make sure iterable callbacks return consistent values.
|
||||||
- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
|
- Use `with { type: "json" }` for JSON module imports.
|
||||||
- Make sure iterable callbacks return consistent values.
|
- Use numeric separators in numeric literals.
|
||||||
- Use `with { type: "json" }` for JSON module imports.
|
- Use object spread instead of `Object.assign()` when constructing new objects.
|
||||||
- Use numeric separators in numeric literals.
|
- Always use the radix argument when using `parseInt()`.
|
||||||
- Use object spread instead of `Object.assign()` when constructing new objects.
|
- Make sure JSDoc comment lines start with a single asterisk, except for the first one.
|
||||||
- Always use the radix argument when using `parseInt()`.
|
- Include a description parameter for `Symbol()`.
|
||||||
- Make sure JSDoc comment lines start with a single asterisk, except for the first one.
|
- Don't use spread (`...`) syntax on accumulators.
|
||||||
- Include a description parameter for `Symbol()`.
|
- Don't use the `delete` operator.
|
||||||
- Don't use spread (`...`) syntax on accumulators.
|
- Don't access namespace imports dynamically.
|
||||||
- Don't use the `delete` operator.
|
- Don't use namespace imports.
|
||||||
- Don't access namespace imports dynamically.
|
- Declare regex literals at the top level.
|
||||||
- Don't use namespace imports.
|
- Don't use `target="_blank"` without `rel="noopener"`.
|
||||||
- Declare regex literals at the top level.
|
|
||||||
- Don't use `target="_blank"` without `rel="noopener"`.
|
### TypeScript Best Practices
|
||||||
|
|
||||||
### TypeScript Best Practices
|
- Don't use TypeScript enums.
|
||||||
|
- Don't export imported variables.
|
||||||
- Don't use TypeScript enums.
|
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
|
||||||
- Don't export imported variables.
|
- Don't use TypeScript namespaces.
|
||||||
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
|
- Don't use non-null assertions with the `!` postfix operator.
|
||||||
- Don't use TypeScript namespaces.
|
- Don't use parameter properties in class constructors.
|
||||||
- Don't use non-null assertions with the `!` postfix operator.
|
- Don't use user-defined types.
|
||||||
- Don't use parameter properties in class constructors.
|
- Use `as const` instead of literal types and type annotations.
|
||||||
- Don't use user-defined types.
|
- Use either `T[]` or `Array<T>` consistently.
|
||||||
- Use `as const` instead of literal types and type annotations.
|
- Initialize each enum member value explicitly.
|
||||||
- Use either `T[]` or `Array<T>` consistently.
|
- Use `export type` for types.
|
||||||
- Initialize each enum member value explicitly.
|
- Use `import type` for types.
|
||||||
- Use `export type` for types.
|
- Make sure all enum members are literal values.
|
||||||
- Use `import type` for types.
|
- Don't use TypeScript const enum.
|
||||||
- Make sure all enum members are literal values.
|
- Don't declare empty interfaces.
|
||||||
- Don't use TypeScript const enum.
|
- Don't let variables evolve into any type through reassignments.
|
||||||
- Don't declare empty interfaces.
|
- Don't use the any type.
|
||||||
- Don't let variables evolve into any type through reassignments.
|
- Don't misuse the non-null assertion operator (!) in TypeScript files.
|
||||||
- Don't use the any type.
|
- Don't use implicit any type on variable declarations.
|
||||||
- Don't misuse the non-null assertion operator (!) in TypeScript files.
|
- Don't merge interfaces and classes unsafely.
|
||||||
- Don't use implicit any type on variable declarations.
|
- Don't use overload signatures that aren't next to each other.
|
||||||
- Don't merge interfaces and classes unsafely.
|
- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
|
||||||
- Don't use overload signatures that aren't next to each other.
|
|
||||||
- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
|
### Style and Consistency
|
||||||
|
|
||||||
### Style and Consistency
|
- Don't use global `eval()`.
|
||||||
|
- Don't use callbacks in asynchronous tests and hooks.
|
||||||
- Don't use global `eval()`.
|
- Don't use negation in `if` statements that have `else` clauses.
|
||||||
- Don't use callbacks in asynchronous tests and hooks.
|
- Don't use nested ternary expressions.
|
||||||
- Don't use negation in `if` statements that have `else` clauses.
|
- Don't reassign function parameters.
|
||||||
- Don't use nested ternary expressions.
|
- This rule lets you specify global variable names you don't want to use in your application.
|
||||||
- Don't reassign function parameters.
|
- Don't use specified modules when loaded by import or require.
|
||||||
- This rule lets you specify global variable names you don't want to use in your application.
|
- Don't use constants whose value is the upper-case version of their name.
|
||||||
- Don't use specified modules when loaded by import or require.
|
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
|
||||||
- Don't use constants whose value is the upper-case version of their name.
|
- Don't use template literals if you don't need interpolation or special-character handling.
|
||||||
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
|
- Don't use `else` blocks when the `if` block breaks early.
|
||||||
- Don't use template literals if you don't need interpolation or special-character handling.
|
- Don't use yoda expressions.
|
||||||
- Don't use `else` blocks when the `if` block breaks early.
|
- Don't use Array constructors.
|
||||||
- Don't use yoda expressions.
|
- Use `at()` instead of integer index access.
|
||||||
- Don't use Array constructors.
|
- Follow curly brace conventions.
|
||||||
- Use `at()` instead of integer index access.
|
- Use `else if` instead of nested `if` statements in `else` clauses.
|
||||||
- Follow curly brace conventions.
|
- Use single `if` statements instead of nested `if` clauses.
|
||||||
- Use `else if` instead of nested `if` statements in `else` clauses.
|
- Use `new` for all builtins except `String`, `Number`, and `Boolean`.
|
||||||
- Use single `if` statements instead of nested `if` clauses.
|
- Use consistent accessibility modifiers on class properties and methods.
|
||||||
- Use `new` for all builtins except `String`, `Number`, and `Boolean`.
|
- Use `const` declarations for variables that are only assigned once.
|
||||||
- Use consistent accessibility modifiers on class properties and methods.
|
- Put default function parameters and optional function parameters last.
|
||||||
- Use `const` declarations for variables that are only assigned once.
|
- Include a `default` clause in switch statements.
|
||||||
- Put default function parameters and optional function parameters last.
|
- Use the `**` operator instead of `Math.pow`.
|
||||||
- Include a `default` clause in switch statements.
|
- Use `for-of` loops when you need the index to extract an item from the iterated array.
|
||||||
- Use the `**` operator instead of `Math.pow`.
|
- Use `node:assert/strict` over `node:assert`.
|
||||||
- Use `for-of` loops when you need the index to extract an item from the iterated array.
|
- Use the `node:` protocol for Node.js builtin modules.
|
||||||
- Use `node:assert/strict` over `node:assert`.
|
- Use Number properties instead of global ones.
|
||||||
- Use the `node:` protocol for Node.js builtin modules.
|
- Use assignment operator shorthand where possible.
|
||||||
- Use Number properties instead of global ones.
|
- Use function types instead of object types with call signatures.
|
||||||
- Use assignment operator shorthand where possible.
|
- Use template literals over string concatenation.
|
||||||
- Use function types instead of object types with call signatures.
|
- Use `new` when throwing an error.
|
||||||
- Use template literals over string concatenation.
|
- Don't throw non-Error values.
|
||||||
- Use `new` when throwing an error.
|
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
|
||||||
- Don't throw non-Error values.
|
- Use standard constants instead of approximated literals.
|
||||||
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
|
- Don't assign values in expressions.
|
||||||
- Use standard constants instead of approximated literals.
|
- Don't use async functions as Promise executors.
|
||||||
- Don't assign values in expressions.
|
- Don't reassign exceptions in catch clauses.
|
||||||
- Don't use async functions as Promise executors.
|
- Don't reassign class members.
|
||||||
- Don't reassign exceptions in catch clauses.
|
- Don't compare against -0.
|
||||||
- Don't reassign class members.
|
- Don't use labeled statements that aren't loops.
|
||||||
- Don't compare against -0.
|
- Don't use void type outside of generic or return types.
|
||||||
- Don't use labeled statements that aren't loops.
|
- Don't use console.
|
||||||
- Don't use void type outside of generic or return types.
|
- Don't use control characters and escape sequences that match control characters in regular expression literals.
|
||||||
- Don't use console.
|
- Don't use debugger.
|
||||||
- Don't use control characters and escape sequences that match control characters in regular expression literals.
|
- Don't assign directly to document.cookie.
|
||||||
- Don't use debugger.
|
- Use `===` and `!==`.
|
||||||
- Don't assign directly to document.cookie.
|
- Don't use duplicate case labels.
|
||||||
- Use `===` and `!==`.
|
- Don't use duplicate class members.
|
||||||
- Don't use duplicate case labels.
|
- Don't use duplicate conditions in if-else-if chains.
|
||||||
- Don't use duplicate class members.
|
- Don't use two keys with the same name inside objects.
|
||||||
- Don't use duplicate conditions in if-else-if chains.
|
- Don't use duplicate function parameter names.
|
||||||
- Don't use two keys with the same name inside objects.
|
- Don't have duplicate hooks in describe blocks.
|
||||||
- Don't use duplicate function parameter names.
|
- Don't use empty block statements and static blocks.
|
||||||
- Don't have duplicate hooks in describe blocks.
|
- Don't let switch clauses fall through.
|
||||||
- Don't use empty block statements and static blocks.
|
- Don't reassign function declarations.
|
||||||
- Don't let switch clauses fall through.
|
- Don't allow assignments to native objects and read-only global variables.
|
||||||
- Don't reassign function declarations.
|
- Use Number.isFinite instead of global isFinite.
|
||||||
- Don't allow assignments to native objects and read-only global variables.
|
- Use Number.isNaN instead of global isNaN.
|
||||||
- Use Number.isFinite instead of global isFinite.
|
- Don't assign to imported bindings.
|
||||||
- Use Number.isNaN instead of global isNaN.
|
- Don't use irregular whitespace characters.
|
||||||
- Don't assign to imported bindings.
|
- Don't use labels that share a name with a variable.
|
||||||
- Don't use irregular whitespace characters.
|
- Don't use characters made with multiple code points in character class syntax.
|
||||||
- Don't use labels that share a name with a variable.
|
- Make sure to use new and constructor properly.
|
||||||
- Don't use characters made with multiple code points in character class syntax.
|
- Don't use shorthand assign when the variable appears on both sides.
|
||||||
- Make sure to use new and constructor properly.
|
- Don't use octal escape sequences in string literals.
|
||||||
- Don't use shorthand assign when the variable appears on both sides.
|
- Don't use Object.prototype builtins directly.
|
||||||
- Don't use octal escape sequences in string literals.
|
- Don't redeclare variables, functions, classes, and types in the same scope.
|
||||||
- Don't use Object.prototype builtins directly.
|
- Don't have redundant "use strict".
|
||||||
- Don't redeclare variables, functions, classes, and types in the same scope.
|
- Don't compare things where both sides are exactly the same.
|
||||||
- Don't have redundant "use strict".
|
- Don't let identifiers shadow restricted names.
|
||||||
- Don't compare things where both sides are exactly the same.
|
- Don't use sparse arrays (arrays with holes).
|
||||||
- Don't let identifiers shadow restricted names.
|
- Don't use template literal placeholder syntax in regular strings.
|
||||||
- Don't use sparse arrays (arrays with holes).
|
- Don't use the then property.
|
||||||
- Don't use template literal placeholder syntax in regular strings.
|
- Don't use unsafe negation.
|
||||||
- Don't use the then property.
|
- Don't use var.
|
||||||
- Don't use unsafe negation.
|
- Don't use with statements in non-strict contexts.
|
||||||
- Don't use var.
|
- Make sure async functions actually use await.
|
||||||
- Don't use with statements in non-strict contexts.
|
- Make sure default clauses in switch statements come last.
|
||||||
- Make sure async functions actually use await.
|
- Make sure to pass a message value when creating a built-in error.
|
||||||
- Make sure default clauses in switch statements come last.
|
- Make sure get methods always return a value.
|
||||||
- Make sure to pass a message value when creating a built-in error.
|
- Use a recommended display strategy with Google Fonts.
|
||||||
- Make sure get methods always return a value.
|
- Make sure for-in loops include an if statement.
|
||||||
- Use a recommended display strategy with Google Fonts.
|
- Use Array.isArray() instead of instanceof Array.
|
||||||
- Make sure for-in loops include an if statement.
|
- Make sure to use the digits argument with Number#toFixed().
|
||||||
- Use Array.isArray() instead of instanceof Array.
|
- Make sure to use the "use strict" directive in script files.
|
||||||
- Make sure to use the digits argument with Number#toFixed().
|
|
||||||
- Make sure to use the "use strict" directive in script files.
|
### Next.js Specific Rules
|
||||||
|
|
||||||
### Next.js Specific Rules
|
- Don't use `<img>` elements in Next.js projects.
|
||||||
|
- Don't use `<head>` elements in Next.js projects.
|
||||||
- Don't use `<img>` elements in Next.js projects.
|
- Don't import next/document outside of pages/\_document.jsx in Next.js projects.
|
||||||
- Don't use `<head>` elements in Next.js projects.
|
- Don't use the next/head module in pages/\_document.js on Next.js projects.
|
||||||
- Don't import next/document outside of pages/\_document.jsx in Next.js projects.
|
|
||||||
- Don't use the next/head module in pages/\_document.js on Next.js projects.
|
### Testing Best Practices
|
||||||
|
|
||||||
### Testing Best Practices
|
- Don't use export or module.exports in test files.
|
||||||
|
- Don't use focused tests.
|
||||||
- Don't use export or module.exports in test files.
|
- Make sure the assertion function, like expect, is placed inside an it() function call.
|
||||||
- Don't use focused tests.
|
- Don't use disabled tests.
|
||||||
- Make sure the assertion function, like expect, is placed inside an it() function call.
|
|
||||||
- Don't use disabled tests.
|
## Common Tasks
|
||||||
|
|
||||||
## Common Tasks
|
- `npx ultracite init` - Initialize Ultracite in your project
|
||||||
|
- `npx ultracite format` - Format and fix code automatically
|
||||||
- `npx ultracite init` - Initialize Ultracite in your project
|
- `npx ultracite lint` - Check for issues without fixing
|
||||||
- `npx ultracite format` - Format and fix code automatically
|
|
||||||
- `npx ultracite lint` - Check for issues without fixing
|
## Example: Error Handling
|
||||||
|
|
||||||
## Example: Error Handling
|
```typescript
|
||||||
|
// ✅ Good: Comprehensive error handling
|
||||||
```typescript
|
try {
|
||||||
// ✅ Good: Comprehensive error handling
|
const result = await fetchData();
|
||||||
try {
|
return { success: true, data: result };
|
||||||
const result = await fetchData();
|
} catch (error) {
|
||||||
return { success: true, data: result };
|
console.error("API call failed:", error);
|
||||||
} catch (error) {
|
return { success: false, error: error.message };
|
||||||
console.error("API call failed:", error);
|
}
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
// ❌ Bad: Swallowing errors
|
||||||
|
try {
|
||||||
// ❌ Bad: Swallowing errors
|
return await fetchData();
|
||||||
try {
|
} catch (e) {
|
||||||
return await fetchData();
|
console.log(e);
|
||||||
} catch (e) {
|
}
|
||||||
console.log(e);
|
```
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -31,16 +31,31 @@ jobs:
|
|||||||
MARBLE_WORKSPACE_KEY: "placeholder"
|
MARBLE_WORKSPACE_KEY: "placeholder"
|
||||||
FREESOUND_CLIENT_ID: "placeholder"
|
FREESOUND_CLIENT_ID: "placeholder"
|
||||||
FREESOUND_API_KEY: "placeholder"
|
FREESOUND_API_KEY: "placeholder"
|
||||||
CLOUDFLARE_ACCOUNT_ID: "placeholder"
|
|
||||||
R2_ACCESS_KEY_ID: "placeholder"
|
|
||||||
R2_SECRET_ACCESS_KEY: "placeholder"
|
|
||||||
R2_BUCKET_NAME: "placeholder"
|
|
||||||
MODAL_TRANSCRIPTION_URL: "https://placeholder.example.com"
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust wasm target
|
||||||
|
run: rustup target add wasm32-unknown-unknown
|
||||||
|
|
||||||
|
- name: Install wasm-pack
|
||||||
|
uses: jetli/wasm-pack-action@v0.4.0
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- name: Cache Rust build artifacts
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Build WASM
|
||||||
|
run: wasm-pack build rust/wasm --target bundler --out-dir pkg
|
||||||
|
|
||||||
- name: Install Bun
|
- name: Install Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -22,3 +22,7 @@ bun.lockb
|
|||||||
.open-next
|
.open-next
|
||||||
|
|
||||||
target/
|
target/
|
||||||
|
|
||||||
|
.cargo-tools/
|
||||||
|
|
||||||
|
rust-migration-notes.md
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
target
|
||||||
|
rust/wasm/pkg
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
*.json
|
||||||
|
*.svg
|
||||||
|
*.xml
|
||||||
|
*.lock
|
||||||
|
**/dist/**
|
||||||
|
**/build/**
|
||||||
|
**/node_modules/**
|
||||||
Vendored
+2
-9
@@ -7,14 +7,7 @@
|
|||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.formatOnPaste": true,
|
"editor.formatOnPaste": true,
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.biome": "explicit",
|
"source.fixAll.eslint": "explicit"
|
||||||
"source.organizeImports.biome": "explicit"
|
|
||||||
},
|
},
|
||||||
"emmet.showExpandedAbbreviation": "never",
|
"emmet.showExpandedAbbreviation": "never"
|
||||||
"[typescriptreact]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
|
||||||
},
|
|
||||||
"[typescript]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,121 +1,23 @@
|
|||||||
# AGENTS.md
|
# Agents.md
|
||||||
|
|
||||||
## Overview
|
## Architecture
|
||||||
|
|
||||||
Privacy-first video editor, with a focus on simplicity and ease of use.
|
An ongoing migration is moving all business logic into `rust/`. Each app under `apps/` is a UI shell — it owns rendering, interaction, and platform-specific concerns, but never owns logic. The UI framework for any given app is a replaceable detail.
|
||||||
|
|
||||||
## Lib vs Utils
|
### `rust/`
|
||||||
|
|
||||||
- `lib/` - domain logic (specific to this app)
|
The single source of truth for all non-UI code. Everything platform-agnostic belongs here: no components, no hooks, no framework imports.
|
||||||
- `utils/` - small helper utils (generic, could be copy-pasted into any other app)
|
|
||||||
|
|
||||||
## Core Editor System
|
### `apps/`
|
||||||
|
|
||||||
The editor uses a **singleton EditorCore** that manages all editor state through specialized managers.
|
Each app is a frontend that calls into Rust. Logic is never duplicated between apps — only UI is, because each platform may use an entirely different framework and language to build it.
|
||||||
|
|
||||||
### Architecture
|
- `web/` — Next.js
|
||||||
|
- `desktop/` — GPUI
|
||||||
|
|
||||||
```
|
## Web
|
||||||
EditorCore (singleton)
|
|
||||||
├── playback: PlaybackManager
|
|
||||||
├── timeline: TimelineManager
|
|
||||||
├── scene: SceneManager
|
|
||||||
├── project: ProjectManager
|
|
||||||
├── media: MediaManager
|
|
||||||
└── renderer: RendererManager
|
|
||||||
```
|
|
||||||
|
|
||||||
### When to Use What
|
### React
|
||||||
|
|
||||||
#### In React Components
|
- Read components before using them. They may already apply classes, which affects what you need to pass and how to override them.
|
||||||
|
|
||||||
**Always use the `useEditor()` hook:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useEditor } from '@/hooks/use-editor';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const editor = useEditor();
|
|
||||||
const tracks = editor.timeline.getTracks();
|
|
||||||
|
|
||||||
// Call methods
|
|
||||||
editor.timeline.addTrack({ type: 'media' });
|
|
||||||
|
|
||||||
// Display data (auto re-renders on changes)
|
|
||||||
return <div>{tracks.length} tracks</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The hook:
|
|
||||||
|
|
||||||
- Returns the singleton instance
|
|
||||||
- Subscribes to all manager changes
|
|
||||||
- Automatically re-renders when state changes
|
|
||||||
|
|
||||||
#### Outside React Components
|
|
||||||
|
|
||||||
**Use `EditorCore.getInstance()` directly:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In utilities, event handlers, or non-React code
|
|
||||||
import { EditorCore } from "@/core";
|
|
||||||
|
|
||||||
const editor = EditorCore.getInstance();
|
|
||||||
await editor.export({ format: "mp4", quality: "high" });
|
|
||||||
```
|
|
||||||
|
|
||||||
## Actions System
|
|
||||||
|
|
||||||
Actions are the trigger layer for user-initiated operations. The single source of truth is `@/lib/actions/definitions.ts`.
|
|
||||||
|
|
||||||
**To add a new action:**
|
|
||||||
|
|
||||||
1. Add it to `ACTIONS` in `@/lib/actions/definitions.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const ACTIONS = {
|
|
||||||
"my-action": {
|
|
||||||
description: "What the action does",
|
|
||||||
category: "editing",
|
|
||||||
defaultShortcuts: ["ctrl+m"],
|
|
||||||
},
|
|
||||||
// ...
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Add handler in `@/hooks/use-editor-actions.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
useActionHandler(
|
|
||||||
"my-action",
|
|
||||||
() => {
|
|
||||||
// implementation
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**In components, use `invokeAction()` for user-triggered operations:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { invokeAction } from '@/lib/actions';
|
|
||||||
|
|
||||||
// Good - uses action system
|
|
||||||
const handleSplit = () => invokeAction("split-selected");
|
|
||||||
|
|
||||||
// Avoid - bypasses UX layer (toasts, validation feedback)
|
|
||||||
const handleSplit = () => editor.timeline.splitElements({ ... });
|
|
||||||
```
|
|
||||||
|
|
||||||
Direct `editor.xxx()` calls are for internal use (commands, tests, complex multi-step operations).
|
|
||||||
|
|
||||||
## Commands System
|
|
||||||
|
|
||||||
Commands handle undo/redo. They live in `@/lib/commands/` organized by domain (timeline, media, scene).
|
|
||||||
|
|
||||||
Each command extends `Command` from `@/lib/commands/base-command` and implements:
|
|
||||||
|
|
||||||
- `execute()` - saves current state, then does the mutation
|
|
||||||
- `undo()` - restores the saved state
|
|
||||||
|
|
||||||
Actions and commands work together: actions are "what triggered this", commands are "how to do it (and undo it)".
|
|
||||||
|
|||||||
Generated
+704
-34
File diff suppressed because it is too large
Load Diff
@@ -2,4 +2,10 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"apps/desktop",
|
"apps/desktop",
|
||||||
|
"rust/crates/time",
|
||||||
|
"rust/crates/bridge",
|
||||||
|
"rust/crates/effects",
|
||||||
|
"rust/crates/gpu",
|
||||||
|
"rust/crates/masks",
|
||||||
|
"rust/wasm", "rust/crates/compositor",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
Copyright 2025 OpenCut
|
Copyright 2025-2026 OpenCut
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|||||||
@@ -1,129 +1,44 @@
|
|||||||
<table width="100%">
|
<table width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" width="120">
|
<td align="left" width="120">
|
||||||
<img src="apps/web/public/logos/opencut/1k/logo-white-black.png" alt="OpenCut Logo" width="100" />
|
<img src="https://assets.opencut.app/branding/symbol.svg" alt="OpenCut Logo" width="100" />
|
||||||
</td>
|
</td>
|
||||||
<td align="right">
|
<td align="right">
|
||||||
<h1>OpenCut</span></h1>
|
<h1>OpenCut</h1>
|
||||||
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
|
<h3 style="margin-top: -10px;">A free and open source video editor for web, desktop, and mobile.</h3>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
[](https://discord.gg/zmR9N35cjK)
|
||||||
|
[](https://x.com/opencutapp)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**OpenCut is being rewritten from the ground up.** What's coming:
|
||||||
|
|
||||||
|
- An Editor API
|
||||||
|
- First-class third party plugins (made possible by a plugin-first architecture)
|
||||||
|
- Desktop, mobile, and browser from one codebase (Rust core)
|
||||||
|
- MCP server (for AI agents)
|
||||||
|
- Headless mode (automation, batch rendering)
|
||||||
|
- A scripting tab directly in the editor
|
||||||
|
|
||||||
|
You can still find the previous version at [opencut-app/opencut-classic](https://github.com/opencut-app/opencut-classic), which is the one to reach for today. [opencut.app](https://opencut.app) still runs the classic version; the rewrite will live at [new.opencut.app](https://new.opencut.app) until it's ready to take over.
|
||||||
|
|
||||||
## Sponsors
|
## 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.
|
OpenCut is supported by companies that believe in open source creator tools.
|
||||||
|
|
||||||
<a href="https://vercel.com/oss">
|
- [**fal.ai**](https://fal.ai?utm_source=github-opencut&utm_campaign=oss): Generative image, video, and audio models all in one place.
|
||||||
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://fal.ai">
|
Want your logo here? Reach out at [sponsor@opencut.app](mailto:sponsor@opencut.app).
|
||||||
<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
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Timeline-based editing
|
|
||||||
- Multi-track support
|
|
||||||
- Real-time preview
|
|
||||||
- No watermarks or subscriptions
|
|
||||||
- Analytics provided by [Databuddy](https://www.databuddy.cc?utm_source=opencut), 100% Anonymized & Non-invasive.
|
|
||||||
- Blog powered by [Marble](https://marblecms.com?utm_source=opencut), Headless CMS.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
- `apps/web/` – Main Next.js web application
|
|
||||||
- `apps/desktop/` – Native desktop app (in progress)
|
|
||||||
- `src/components/` – UI and editor components
|
|
||||||
- `src/hooks/` – Custom React hooks
|
|
||||||
- `src/lib/` – Utility and API logic
|
|
||||||
- `src/stores/` – State management (Zustand, etc.)
|
|
||||||
- `src/types/` – TypeScript types
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
### 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
|
## Contributing
|
||||||
|
|
||||||
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
|
We're not set up to take outside contributions yet while the architecture is being designed. If you want to follow along, ask questions, or just hang out, [join the Discord](https://discord.gg/zmR9N35cjK) or [open an issue](https://github.com/opencut-app/opencut/issues).
|
||||||
|
|
||||||
**🎯 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
|
## License
|
||||||
|
|
||||||
[MIT LICENSE](LICENSE)
|
[MIT](LICENSE)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||

|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
App, Application, Bounds, Context, SharedString, Window, WindowBounds, WindowOptions, div,
|
div, prelude::*, px, rgb, size, App, Application, Bounds, Context, SharedString, Window,
|
||||||
prelude::*, px, rgb, size,
|
WindowBounds, WindowOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct AppWindow {
|
struct AppWindow {
|
||||||
|
|||||||
@@ -19,10 +19,3 @@ MARBLE_WORKSPACE_KEY=your_workspace_key_here
|
|||||||
|
|
||||||
FREESOUND_CLIENT_ID=your_client_id_here
|
FREESOUND_CLIENT_ID=your_client_id_here
|
||||||
FREESOUND_API_KEY=your_api_key_here
|
FREESOUND_API_KEY=your_api_key_here
|
||||||
|
|
||||||
CLOUDFLARE_ACCOUNT_ID=your_account_id_here
|
|
||||||
R2_ACCESS_KEY_ID=your_access_key_here
|
|
||||||
R2_SECRET_ACCESS_KEY=your_secret_key_here
|
|
||||||
R2_BUCKET_NAME=opencut-transcription # whatever you named your r2 bucket
|
|
||||||
|
|
||||||
MODAL_TRANSCRIPTION_URL=your_modal_url_here
|
|
||||||
+5
-1
@@ -7,4 +7,8 @@
|
|||||||
|
|
||||||
.next/
|
.next/
|
||||||
|
|
||||||
.font-cache/
|
.font-cache/
|
||||||
|
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -14,14 +14,9 @@ COPY bun.lock bun.lock
|
|||||||
COPY turbo.json turbo.json
|
COPY turbo.json turbo.json
|
||||||
|
|
||||||
COPY apps/web/package.json apps/web/package.json
|
COPY apps/web/package.json apps/web/package.json
|
||||||
COPY packages/env/package.json packages/env/package.json
|
|
||||||
COPY packages/ui/package.json packages/ui/package.json
|
|
||||||
|
|
||||||
RUN bun install
|
RUN bun install
|
||||||
|
|
||||||
COPY apps/web/ apps/web/
|
COPY apps/web/ apps/web/
|
||||||
COPY packages/env/ packages/env/
|
|
||||||
COPY packages/ui/ packages/ui/
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
@@ -34,11 +29,6 @@ ENV UPSTASH_REDIS_REST_TOKEN="example_token"
|
|||||||
ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||||
ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL
|
ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL
|
||||||
ENV MARBLE_WORKSPACE_KEY=$MARBLE_WORKSPACE_KEY
|
ENV MARBLE_WORKSPACE_KEY=$MARBLE_WORKSPACE_KEY
|
||||||
ENV CLOUDFLARE_ACCOUNT_ID="build-placeholder"
|
|
||||||
ENV R2_ACCESS_KEY_ID="build-placeholder"
|
|
||||||
ENV R2_SECRET_ACCESS_KEY="build-placeholder"
|
|
||||||
ENV R2_BUCKET_NAME="build-placeholder"
|
|
||||||
ENV MODAL_TRANSCRIPTION_URL="http://localhost:0"
|
|
||||||
|
|
||||||
ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID
|
ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID
|
||||||
ENV FREESOUND_API_KEY=$FREESOUND_API_KEY
|
ENV FREESOUND_API_KEY=$FREESOUND_API_KEY
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const changelog = defineCollection({
|
const changelog = defineCollection({
|
||||||
name: "changelog",
|
name: "changelog",
|
||||||
directory: "content/changelog",
|
directory: "src/lib/changelog/entries",
|
||||||
include: "*.md",
|
include: "*.md",
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
content: z.string(),
|
content: z.string(),
|
||||||
version: z.string(),
|
version: z.string(),
|
||||||
date: z.string(),
|
date: z.string(),
|
||||||
|
published: z.boolean().default(true),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
|
summary: z.string().optional(),
|
||||||
changes: z.array(
|
changes: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
@@ -20,10 +22,12 @@ const changelog = defineCollection({
|
|||||||
}),
|
}),
|
||||||
transform: async (doc, { collection }) => {
|
transform: async (doc, { collection }) => {
|
||||||
const allDocs = await collection.documents();
|
const allDocs = await collection.documents();
|
||||||
const sorted = [...allDocs].sort((a, b) =>
|
const publishedDocs = allDocs.filter((entry) => entry.published !== false);
|
||||||
|
const sorted = [...publishedDocs].sort((a, b) =>
|
||||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
||||||
);
|
);
|
||||||
const isLatest = sorted[0]?.version === doc.version;
|
const isLatest =
|
||||||
|
doc.published !== false && sorted[0]?.version === doc.version;
|
||||||
return { ...doc, isLatest };
|
return { ...doc, isLatest };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+26
-23
@@ -1,23 +1,26 @@
|
|||||||
import type { Config } from "drizzle-kit";
|
import type { Config } from "drizzle-kit";
|
||||||
import * as dotenv from "dotenv";
|
import * as dotenv from "dotenv";
|
||||||
import { webEnv } from "@opencut/env/web";
|
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
// Load the right env file based on environment
|
dotenv.config({ path: ".env.production" });
|
||||||
if (webEnv.NODE_ENV === "production") {
|
} else {
|
||||||
dotenv.config({ path: ".env.production" });
|
dotenv.config({ path: ".env.local" });
|
||||||
} else {
|
}
|
||||||
dotenv.config({ path: ".env.local" });
|
|
||||||
}
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
if (!databaseUrl) {
|
||||||
export default {
|
throw new Error("DATABASE_URL is not set");
|
||||||
schema: "./src/schema.ts",
|
}
|
||||||
dialect: "postgresql",
|
|
||||||
migrations: {
|
export default {
|
||||||
table: "drizzle_migrations",
|
schema: "./src/lib/db/schema.ts",
|
||||||
},
|
dialect: "postgresql",
|
||||||
dbCredentials: {
|
migrations: {
|
||||||
url: webEnv.DATABASE_URL,
|
table: "drizzle_migrations",
|
||||||
},
|
},
|
||||||
out: "./migrations",
|
dbCredentials: {
|
||||||
strict: webEnv.NODE_ENV === "production",
|
url: databaseUrl,
|
||||||
} satisfies Config;
|
},
|
||||||
|
out: "./migrations",
|
||||||
|
strict: process.env.NODE_ENV === "production",
|
||||||
|
} satisfies Config;
|
||||||
|
|||||||
Vendored
-6
@@ -1,6 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
import "./.next/dev/types/routes.d.ts";
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
||||||
@@ -1,19 +1,8 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import { withBotId } from "botid/next/config";
|
import { withBotId } from "botid/next/config";
|
||||||
import { withContentCollections } from "@content-collections/next";
|
import { withContentCollections } from "@content-collections/next";
|
||||||
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
|
|
||||||
|
|
||||||
initOpenNextCloudflareForDev();
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
turbopack: {
|
|
||||||
rules: {
|
|
||||||
"*.glsl": {
|
|
||||||
loaders: [require.resolve("raw-loader")],
|
|
||||||
as: "*.js",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
compiler: {
|
compiler: {
|
||||||
removeConsole: process.env.NODE_ENV === "production",
|
removeConsole: process.env.NODE_ENV === "production",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
||||||
|
|
||||||
|
export default defineCloudflareConfig();
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
||||||
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
||||||
"lint": "biome check src/",
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
"lint:fix": "biome check src/ --write",
|
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
||||||
"format": "biome format src/ --write",
|
"format": "prettier src --write",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
|
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
|
||||||
@@ -19,12 +19,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@hugeicons/core-free-icons": "^3.1.1",
|
"@hugeicons/core-free-icons": "^3.3.0",
|
||||||
"@hugeicons/react": "^1.1.6",
|
"@hugeicons/react": "^1.1.6",
|
||||||
"@huggingface/transformers": "^3.8.1",
|
"@huggingface/transformers": "^3.8.1",
|
||||||
"@opencut/env": "workspace:*",
|
"@opennextjs/cloudflare": "^1.18.0",
|
||||||
"@opencut/ui": "workspace:*",
|
|
||||||
"@opennextjs/cloudflare": "^1.17.3",
|
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -54,6 +52,7 @@
|
|||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"next": "16.1.3",
|
"next": "16.1.3",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
"opencut-wasm": "^0.2.10",
|
||||||
"pg": "^8.16.2",
|
"pg": "^8.16.2",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
@@ -77,7 +76,7 @@
|
|||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"use-deep-compare-effect": "^1.8.1",
|
"use-deep-compare-effect": "^1.8.1",
|
||||||
"wavesurfer.js": "^7.9.8",
|
"wavesurfer.js": "^7.9.8",
|
||||||
"zod": "^3.25.67",
|
"zod": "4.3.6",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -95,7 +94,6 @@
|
|||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"raw-loader": "^4.0.2",
|
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
|||||||
<svg width="588" height="512" viewBox="0 0 588 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M294 0L588 512H0L294 0Z" fill="black"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 159 B |
+10
-7
@@ -5,8 +5,8 @@ import { toast } from "sonner";
|
|||||||
import {
|
import {
|
||||||
type KeyboardShortcut,
|
type KeyboardShortcut,
|
||||||
useKeyboardShortcutsHelp,
|
useKeyboardShortcutsHelp,
|
||||||
} from "@/hooks/use-keyboard-shortcuts-help";
|
} from "@/actions/use-keyboard-shortcuts-help";
|
||||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
import { useKeybindingsStore } from "@/actions/keybindings-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -51,10 +51,10 @@ export function ShortcutsDialog({
|
|||||||
|
|
||||||
const keyString = getKeybindingString(e);
|
const keyString = getKeybindingString(e);
|
||||||
if (keyString) {
|
if (keyString) {
|
||||||
const conflict = validateKeybinding(
|
const conflict = validateKeybinding({
|
||||||
keyString,
|
key: keyString,
|
||||||
recordingShortcut.action,
|
action: recordingShortcut.action,
|
||||||
);
|
});
|
||||||
if (conflict) {
|
if (conflict) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`Key "${keyString}" is already bound to "${conflict.existingAction}"`,
|
`Key "${keyString}" is already bound to "${conflict.existingAction}"`,
|
||||||
@@ -68,7 +68,10 @@ export function ShortcutsDialog({
|
|||||||
removeKeybinding(key);
|
removeKeybinding(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateKeybinding(keyString, recordingShortcut.action);
|
updateKeybinding({
|
||||||
|
key: keyString,
|
||||||
|
action: recordingShortcut.action,
|
||||||
|
});
|
||||||
|
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
setRecordingShortcut(null);
|
setRecordingShortcut(null);
|
||||||
@@ -1,205 +1,210 @@
|
|||||||
import type {
|
import type { ShortcutKey } from "@/actions/keybinding";
|
||||||
KeybindingConfig,
|
import type { TActionWithOptionalArgs } from "./types";
|
||||||
ShortcutKey,
|
|
||||||
} from "@/lib/actions/keybinding";
|
export type TActionCategory =
|
||||||
import type { TActionWithOptionalArgs } from "./types";
|
| "playback"
|
||||||
|
| "navigation"
|
||||||
export type TActionCategory =
|
| "editing"
|
||||||
| "playback"
|
| "selection"
|
||||||
| "navigation"
|
| "history"
|
||||||
| "editing"
|
| "timeline"
|
||||||
| "selection"
|
| "controls"
|
||||||
| "history"
|
| "assets";
|
||||||
| "timeline"
|
|
||||||
| "controls"
|
export interface TActionBaseDefinition {
|
||||||
| "assets";
|
description: string;
|
||||||
|
category: TActionCategory;
|
||||||
export interface TActionBaseDefinition {
|
args?: Record<string, unknown>;
|
||||||
description: string;
|
}
|
||||||
category: TActionCategory;
|
|
||||||
args?: Record<string, unknown>;
|
export interface TActionDefinition extends TActionBaseDefinition {
|
||||||
}
|
defaultShortcuts?: readonly ShortcutKey[];
|
||||||
|
}
|
||||||
export interface TActionDefinition extends TActionBaseDefinition {
|
|
||||||
defaultShortcuts?: readonly ShortcutKey[];
|
export const ACTIONS = {
|
||||||
}
|
"toggle-play": {
|
||||||
|
description: "Play/Pause",
|
||||||
export const ACTIONS = {
|
category: "playback",
|
||||||
"toggle-play": {
|
},
|
||||||
description: "Play/Pause",
|
"stop-playback": {
|
||||||
category: "playback",
|
description: "Stop playback",
|
||||||
},
|
category: "playback",
|
||||||
"stop-playback": {
|
},
|
||||||
description: "Stop playback",
|
"seek-forward": {
|
||||||
category: "playback",
|
description: "Seek forward 1 second",
|
||||||
},
|
category: "playback",
|
||||||
"seek-forward": {
|
args: { seconds: "number" },
|
||||||
description: "Seek forward 1 second",
|
},
|
||||||
category: "playback",
|
"seek-backward": {
|
||||||
args: { seconds: "number" },
|
description: "Seek backward 1 second",
|
||||||
},
|
category: "playback",
|
||||||
"seek-backward": {
|
args: { seconds: "number" },
|
||||||
description: "Seek backward 1 second",
|
},
|
||||||
category: "playback",
|
"frame-step-forward": {
|
||||||
args: { seconds: "number" },
|
description: "Frame step forward",
|
||||||
},
|
category: "navigation",
|
||||||
"frame-step-forward": {
|
},
|
||||||
description: "Frame step forward",
|
"frame-step-backward": {
|
||||||
category: "navigation",
|
description: "Frame step backward",
|
||||||
},
|
category: "navigation",
|
||||||
"frame-step-backward": {
|
},
|
||||||
description: "Frame step backward",
|
"jump-forward": {
|
||||||
category: "navigation",
|
description: "Jump forward 5 seconds",
|
||||||
},
|
category: "navigation",
|
||||||
"jump-forward": {
|
args: { seconds: "number" },
|
||||||
description: "Jump forward 5 seconds",
|
},
|
||||||
category: "navigation",
|
"jump-backward": {
|
||||||
args: { seconds: "number" },
|
description: "Jump backward 5 seconds",
|
||||||
},
|
category: "navigation",
|
||||||
"jump-backward": {
|
args: { seconds: "number" },
|
||||||
description: "Jump backward 5 seconds",
|
},
|
||||||
category: "navigation",
|
"goto-start": {
|
||||||
args: { seconds: "number" },
|
description: "Go to timeline start",
|
||||||
},
|
category: "navigation",
|
||||||
"goto-start": {
|
},
|
||||||
description: "Go to timeline start",
|
"goto-end": {
|
||||||
category: "navigation",
|
description: "Go to timeline end",
|
||||||
},
|
category: "navigation",
|
||||||
"goto-end": {
|
},
|
||||||
description: "Go to timeline end",
|
split: {
|
||||||
category: "navigation",
|
description: "Split elements at playhead",
|
||||||
},
|
category: "editing",
|
||||||
split: {
|
},
|
||||||
description: "Split elements at playhead",
|
"split-left": {
|
||||||
category: "editing",
|
description: "Split and remove left",
|
||||||
},
|
category: "editing",
|
||||||
"split-left": {
|
},
|
||||||
description: "Split and remove left",
|
"split-right": {
|
||||||
category: "editing",
|
description: "Split and remove right",
|
||||||
},
|
category: "editing",
|
||||||
"split-right": {
|
},
|
||||||
description: "Split and remove right",
|
"delete-selected": {
|
||||||
category: "editing",
|
description: "Delete current selection",
|
||||||
},
|
category: "editing",
|
||||||
"delete-selected": {
|
},
|
||||||
description: "Delete selected elements",
|
"copy-selected": {
|
||||||
category: "editing",
|
description: "Copy selected elements",
|
||||||
},
|
category: "editing",
|
||||||
"copy-selected": {
|
},
|
||||||
description: "Copy selected elements",
|
"paste-copied": {
|
||||||
category: "editing",
|
description: "Paste elements at playhead",
|
||||||
},
|
category: "editing",
|
||||||
"paste-copied": {
|
},
|
||||||
description: "Paste elements at playhead",
|
"toggle-snapping": {
|
||||||
category: "editing",
|
description: "Toggle snapping",
|
||||||
},
|
category: "editing",
|
||||||
"toggle-snapping": {
|
},
|
||||||
description: "Toggle snapping",
|
"toggle-ripple-editing": {
|
||||||
category: "editing",
|
description: "Toggle ripple editing",
|
||||||
},
|
category: "editing",
|
||||||
"toggle-ripple-editing": {
|
},
|
||||||
description: "Toggle ripple editing",
|
"toggle-source-audio": {
|
||||||
category: "editing",
|
description: "Extract or recover source audio",
|
||||||
},
|
category: "editing",
|
||||||
"select-all": {
|
},
|
||||||
description: "Select all elements",
|
"select-all": {
|
||||||
category: "selection",
|
description: "Select all elements",
|
||||||
},
|
category: "selection",
|
||||||
"cancel-interaction": {
|
},
|
||||||
description: "Cancel current interaction",
|
"cancel-interaction": {
|
||||||
category: "controls",
|
description: "Cancel current interaction",
|
||||||
},
|
category: "controls",
|
||||||
"deselect-all": {
|
},
|
||||||
description: "Deselect all elements",
|
"deselect-all": {
|
||||||
category: "selection",
|
description: "Deselect all elements",
|
||||||
},
|
category: "selection",
|
||||||
"duplicate-selected": {
|
},
|
||||||
description: "Duplicate selected element",
|
"duplicate-selected": {
|
||||||
category: "selection",
|
description: "Duplicate selected element",
|
||||||
},
|
category: "selection",
|
||||||
"toggle-elements-muted-selected": {
|
},
|
||||||
description: "Mute/unmute selected elements",
|
"toggle-elements-muted-selected": {
|
||||||
category: "selection",
|
description: "Mute/unmute selected elements",
|
||||||
},
|
category: "selection",
|
||||||
"toggle-elements-visibility-selected": {
|
},
|
||||||
description: "Show/hide selected elements",
|
"toggle-elements-visibility-selected": {
|
||||||
category: "selection",
|
description: "Show/hide selected elements",
|
||||||
},
|
category: "selection",
|
||||||
"toggle-bookmark": {
|
},
|
||||||
description: "Toggle bookmark at playhead",
|
"toggle-bookmark": {
|
||||||
category: "timeline",
|
description: "Toggle bookmark at playhead",
|
||||||
},
|
category: "timeline",
|
||||||
undo: {
|
},
|
||||||
description: "Undo",
|
undo: {
|
||||||
category: "history",
|
description: "Undo",
|
||||||
},
|
category: "history",
|
||||||
redo: {
|
},
|
||||||
description: "Redo",
|
redo: {
|
||||||
category: "history",
|
description: "Redo",
|
||||||
},
|
category: "history",
|
||||||
"remove-media-asset": {
|
},
|
||||||
description: "Remove media asset",
|
"remove-media-asset": {
|
||||||
category: "assets",
|
description: "Remove media asset",
|
||||||
args: { projectId: "string", assetId: "string" },
|
category: "assets",
|
||||||
},
|
args: { projectId: "string", assetId: "string" },
|
||||||
"remove-media-assets": {
|
},
|
||||||
description: "Remove media assets",
|
"remove-media-assets": {
|
||||||
category: "assets",
|
description: "Remove media assets",
|
||||||
args: { projectId: "string", assetIds: "string[]" },
|
category: "assets",
|
||||||
},
|
args: { projectId: "string", assetIds: "string[]" },
|
||||||
} as const satisfies Record<string, TActionBaseDefinition>;
|
},
|
||||||
|
} as const satisfies Record<string, TActionBaseDefinition>;
|
||||||
export type TAction = keyof typeof ACTIONS;
|
|
||||||
|
export type TAction = keyof typeof ACTIONS;
|
||||||
const ACTION_DEFAULT_SHORTCUTS = {
|
|
||||||
"toggle-play": ["space", "k"],
|
const ACTION_DEFAULT_SHORTCUTS = [
|
||||||
"seek-forward": ["l"],
|
["toggle-play", ["space", "k"]],
|
||||||
"seek-backward": ["j"],
|
["seek-forward", ["l"]],
|
||||||
"frame-step-forward": ["right"],
|
["seek-backward", ["j"]],
|
||||||
"frame-step-backward": ["left"],
|
["frame-step-forward", ["right"]],
|
||||||
"jump-forward": ["shift+right"],
|
["frame-step-backward", ["left"]],
|
||||||
"jump-backward": ["shift+left"],
|
["jump-forward", ["shift+right"]],
|
||||||
"goto-start": ["home", "enter"],
|
["jump-backward", ["shift+left"]],
|
||||||
"goto-end": ["end"],
|
["goto-start", ["home", "enter"]],
|
||||||
split: ["s"],
|
["goto-end", ["end"]],
|
||||||
"split-left": ["q"],
|
["split", ["s"]],
|
||||||
"split-right": ["w"],
|
["split-left", ["q"]],
|
||||||
"delete-selected": ["backspace", "delete"],
|
["split-right", ["w"]],
|
||||||
"copy-selected": ["ctrl+c"],
|
["delete-selected", ["backspace", "delete"]],
|
||||||
"paste-copied": ["ctrl+v"],
|
["copy-selected", ["ctrl+c"]],
|
||||||
"toggle-snapping": ["n"],
|
["paste-copied", ["ctrl+v"]],
|
||||||
"select-all": ["ctrl+a"],
|
["toggle-snapping", ["n"]],
|
||||||
"cancel-interaction": ["escape"],
|
["select-all", ["ctrl+a"]],
|
||||||
"duplicate-selected": ["ctrl+d"],
|
["cancel-interaction", ["escape"]],
|
||||||
undo: ["ctrl+z"],
|
["duplicate-selected", ["ctrl+d"]],
|
||||||
redo: ["ctrl+shift+z", "ctrl+y"],
|
["undo", ["ctrl+z"]],
|
||||||
} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
|
["redo", ["ctrl+shift+z", "ctrl+y"]],
|
||||||
|
] as const satisfies ReadonlyArray<
|
||||||
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
|
readonly [TActionWithOptionalArgs, readonly ShortcutKey[]]
|
||||||
Record<TAction, readonly ShortcutKey[]>
|
>;
|
||||||
> = ACTION_DEFAULT_SHORTCUTS;
|
|
||||||
|
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION = new Map<
|
||||||
export function getActionDefinition({
|
TAction,
|
||||||
action,
|
readonly ShortcutKey[]
|
||||||
}: {
|
>(ACTION_DEFAULT_SHORTCUTS);
|
||||||
action: TAction;
|
|
||||||
}): TActionDefinition {
|
export function getActionDefinition({
|
||||||
return {
|
action,
|
||||||
...ACTIONS[action],
|
}: {
|
||||||
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
|
action: TAction;
|
||||||
};
|
}): TActionDefinition {
|
||||||
}
|
return {
|
||||||
|
...ACTIONS[action],
|
||||||
export function getDefaultShortcuts(): KeybindingConfig {
|
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION.get(action),
|
||||||
const shortcuts: KeybindingConfig = {};
|
};
|
||||||
|
}
|
||||||
for (const [action, defaultShortcuts] of Object.entries(
|
|
||||||
ACTION_DEFAULT_SHORTCUTS,
|
export function getDefaultShortcuts(): Map<
|
||||||
) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
|
ShortcutKey,
|
||||||
for (const shortcut of defaultShortcuts) {
|
TActionWithOptionalArgs
|
||||||
shortcuts[shortcut] = action;
|
> {
|
||||||
}
|
const shortcuts = new Map<ShortcutKey, TActionWithOptionalArgs>();
|
||||||
}
|
|
||||||
|
for (const [action, defaultShortcuts] of ACTION_DEFAULT_SHORTCUTS) {
|
||||||
return shortcuts;
|
for (const shortcut of defaultShortcuts) {
|
||||||
}
|
shortcuts.set(shortcut, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return shortcuts;
|
||||||
|
}
|
||||||
@@ -1,79 +1,43 @@
|
|||||||
import type { TActionWithOptionalArgs } from "./types";
|
import type { TActionWithOptionalArgs } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alt is also regarded as macOS OPTION (⌥) key
|
* Alt is also regarded as macOS OPTION (⌥) key
|
||||||
* Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
|
* Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
|
||||||
*/
|
*/
|
||||||
export type ModifierKeys =
|
export type ModifierKeys =
|
||||||
| "ctrl"
|
| "ctrl"
|
||||||
| "alt"
|
| "alt"
|
||||||
| "shift"
|
| "shift"
|
||||||
| "ctrl+shift"
|
| "ctrl+shift"
|
||||||
| "alt+shift"
|
| "alt+shift"
|
||||||
| "ctrl+alt"
|
| "ctrl+alt"
|
||||||
| "ctrl+alt+shift";
|
| "ctrl+alt+shift";
|
||||||
|
|
||||||
export type Key =
|
const KEYS = [
|
||||||
| "a"
|
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
|
||||||
| "b"
|
"k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
|
||||||
| "c"
|
"u", "v", "w", "x", "y", "z",
|
||||||
| "d"
|
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
|
||||||
| "e"
|
"up", "down", "left", "right",
|
||||||
| "f"
|
"/", "?", ".",
|
||||||
| "g"
|
"enter", "tab", "space", "escape", "esc",
|
||||||
| "h"
|
"backspace", "delete", "home", "end",
|
||||||
| "i"
|
] as const;
|
||||||
| "j"
|
|
||||||
| "k"
|
export type Key = (typeof KEYS)[number];
|
||||||
| "l"
|
|
||||||
| "m"
|
const KEY_SET: ReadonlySet<string> = new Set(KEYS);
|
||||||
| "n"
|
|
||||||
| "o"
|
export function isKey(value: string): value is Key {
|
||||||
| "p"
|
return KEY_SET.has(value);
|
||||||
| "q"
|
}
|
||||||
| "r"
|
|
||||||
| "s"
|
export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
|
||||||
| "t"
|
// Singular keybindings (these will be disabled when an input-ish area has been focused)
|
||||||
| "u"
|
export type SingleCharacterShortcutKey = `${Key}`;
|
||||||
| "v"
|
|
||||||
| "w"
|
export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
|
||||||
| "x"
|
|
||||||
| "y"
|
export type KeybindingConfig = {
|
||||||
| "z"
|
[key in ShortcutKey]?: TActionWithOptionalArgs;
|
||||||
| "0"
|
};
|
||||||
| "1"
|
|
||||||
| "2"
|
|
||||||
| "3"
|
|
||||||
| "4"
|
|
||||||
| "5"
|
|
||||||
| "6"
|
|
||||||
| "7"
|
|
||||||
| "8"
|
|
||||||
| "9"
|
|
||||||
| "up"
|
|
||||||
| "down"
|
|
||||||
| "left"
|
|
||||||
| "right"
|
|
||||||
| "/"
|
|
||||||
| "?"
|
|
||||||
| "."
|
|
||||||
| "enter"
|
|
||||||
| "tab"
|
|
||||||
| "space"
|
|
||||||
| "escape"
|
|
||||||
| "esc"
|
|
||||||
| "backspace"
|
|
||||||
| "delete"
|
|
||||||
| "home"
|
|
||||||
| "end";
|
|
||||||
/* eslint-enable */
|
|
||||||
|
|
||||||
export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
|
|
||||||
// Singular keybindings (these will be disabled when an input-ish area has been focused)
|
|
||||||
export type SingleCharacterShortcutKey = `${Key}`;
|
|
||||||
|
|
||||||
export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
|
|
||||||
|
|
||||||
export type KeybindingConfig = {
|
|
||||||
[key in ShortcutKey]?: TActionWithOptionalArgs;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import type { TActionWithOptionalArgs } from "@/actions";
|
||||||
|
import { getDefaultShortcuts } from "@/actions";
|
||||||
|
import { isTypableDOMElement } from "@/utils/browser";
|
||||||
|
import { isAppleDevice } from "@/utils/platform";
|
||||||
|
import type {
|
||||||
|
Key,
|
||||||
|
KeybindingConfig,
|
||||||
|
ModifierKeys,
|
||||||
|
ShortcutKey,
|
||||||
|
} from "@/actions/keybinding";
|
||||||
|
import { isKey } from "@/actions/keybinding";
|
||||||
|
import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations";
|
||||||
|
|
||||||
|
export interface KeybindingConflict {
|
||||||
|
key: ShortcutKey;
|
||||||
|
existingAction: TActionWithOptionalArgs;
|
||||||
|
newAction: TActionWithOptionalArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeybindingsState {
|
||||||
|
keybindings: Map<ShortcutKey, TActionWithOptionalArgs>;
|
||||||
|
isCustomized: boolean;
|
||||||
|
overlayDepth: number;
|
||||||
|
openOverlayIds: string[];
|
||||||
|
isLoadingProject: boolean;
|
||||||
|
isRecording: boolean;
|
||||||
|
|
||||||
|
updateKeybinding: (params: {
|
||||||
|
key: ShortcutKey;
|
||||||
|
action: TActionWithOptionalArgs;
|
||||||
|
}) => void;
|
||||||
|
removeKeybinding: (key: ShortcutKey) => void;
|
||||||
|
resetToDefaults: () => void;
|
||||||
|
importKeybindings: (config: KeybindingConfig) => void;
|
||||||
|
exportKeybindings: () => Record<string, TActionWithOptionalArgs>;
|
||||||
|
openOverlay: (overlayId: string) => void;
|
||||||
|
closeOverlay: (overlayId: string) => void;
|
||||||
|
setLoadingProject: (loading: boolean) => void;
|
||||||
|
setIsRecording: (isRecording: boolean) => void;
|
||||||
|
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: getDefaultShortcuts(),
|
||||||
|
isCustomized: false,
|
||||||
|
overlayDepth: 0,
|
||||||
|
openOverlayIds: [],
|
||||||
|
isLoadingProject: false,
|
||||||
|
isRecording: false,
|
||||||
|
|
||||||
|
openOverlay: (overlayId) =>
|
||||||
|
set((s) => {
|
||||||
|
const openOverlayIds = s.openOverlayIds.includes(overlayId)
|
||||||
|
? s.openOverlayIds
|
||||||
|
: [...s.openOverlayIds, overlayId];
|
||||||
|
return {
|
||||||
|
openOverlayIds,
|
||||||
|
overlayDepth: openOverlayIds.length,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
closeOverlay: (overlayId) =>
|
||||||
|
set((s) => {
|
||||||
|
const openOverlayIds = s.openOverlayIds.filter(
|
||||||
|
(id) => id !== overlayId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
openOverlayIds,
|
||||||
|
overlayDepth: openOverlayIds.length,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
setLoadingProject: (loading) => {
|
||||||
|
set({ isLoadingProject: loading });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateKeybinding: ({ key, action }) => {
|
||||||
|
set((state) => {
|
||||||
|
const next = new Map(state.keybindings);
|
||||||
|
next.set(key, action);
|
||||||
|
return {
|
||||||
|
keybindings: next,
|
||||||
|
isCustomized: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeKeybinding: (key) => {
|
||||||
|
set((state) => {
|
||||||
|
const next = new Map(state.keybindings);
|
||||||
|
next.delete(key);
|
||||||
|
return {
|
||||||
|
keybindings: next,
|
||||||
|
isCustomized: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
resetToDefaults: () => {
|
||||||
|
set({
|
||||||
|
keybindings: getDefaultShortcuts(),
|
||||||
|
isCustomized: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
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: next,
|
||||||
|
isCustomized: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
exportKeybindings: () => {
|
||||||
|
return Object.fromEntries(get().keybindings);
|
||||||
|
},
|
||||||
|
|
||||||
|
validateKeybinding: ({ key, action }) => {
|
||||||
|
const existingAction = get().keybindings.get(key);
|
||||||
|
if (existingAction && existingAction !== action) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
existingAction,
|
||||||
|
newAction: action,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
setIsRecording: (isRecording) => {
|
||||||
|
set({ isRecording });
|
||||||
|
},
|
||||||
|
|
||||||
|
getKeybindingsForAction: (action) => {
|
||||||
|
const result: ShortcutKey[] = [];
|
||||||
|
for (const [key, mapped] of get().keybindings) {
|
||||||
|
if (mapped === action) result.push(key);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
getKeybindingString: (ev) => generateKeybindingString(ev),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "opencut-keybindings",
|
||||||
|
version: CURRENT_VERSION,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
|
||||||
|
const target = ev.target;
|
||||||
|
const modifierKey = getActiveModifier(ev);
|
||||||
|
const key = getPressedKey(ev);
|
||||||
|
if (!key) return null;
|
||||||
|
|
||||||
|
if (modifierKey) {
|
||||||
|
if (
|
||||||
|
modifierKey === "shift" &&
|
||||||
|
isDOMElement(target) &&
|
||||||
|
isTypableDOMElement({ element: target })
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${modifierKey}+${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDOMElement(target) && isTypableDOMElement({ element: target })) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPressedKey(ev: KeyboardEvent): Key | null {
|
||||||
|
const raw = (ev.key ?? "").toLowerCase();
|
||||||
|
const code = ev.code ?? "";
|
||||||
|
|
||||||
|
if (code === "Space" || raw === " " || raw === "spacebar" || raw === "space")
|
||||||
|
return "space";
|
||||||
|
|
||||||
|
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 (isKey(letter)) return letter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use physical key position for AZERTY and other non-QWERTY layouts.
|
||||||
|
if (code.startsWith("Digit")) {
|
||||||
|
const digit = code.slice(5);
|
||||||
|
if (isKey(digit)) return digit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isKey(raw)) return raw;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null {
|
||||||
|
const ctrl = isAppleDevice() ? ev.metaKey : ev.ctrlKey;
|
||||||
|
const alt = ev.altKey;
|
||||||
|
const shift = ev.shiftKey;
|
||||||
|
|
||||||
|
if (ctrl && alt && shift) return "ctrl+alt+shift";
|
||||||
|
if (ctrl && alt) return "ctrl+alt";
|
||||||
|
if (ctrl && shift) return "ctrl+shift";
|
||||||
|
if (alt && shift) return "alt+shift";
|
||||||
|
if (ctrl) return "ctrl";
|
||||||
|
if (alt) return "alt";
|
||||||
|
if (shift) return "shift";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
mock,
|
||||||
|
test,
|
||||||
|
} from "bun:test";
|
||||||
|
import {
|
||||||
|
decodePersistedKeybindingsState,
|
||||||
|
migratePersistedKeybindingsState,
|
||||||
|
parseImportedKeybindings,
|
||||||
|
serializeKeybindingsState,
|
||||||
|
} from "../persistence";
|
||||||
|
|
||||||
|
describe("keybinding persistence", () => {
|
||||||
|
let warnSpy: ReturnType<typeof mock>;
|
||||||
|
let originalWarn: typeof console.warn;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalWarn = console.warn;
|
||||||
|
warnSpy = mock(() => {});
|
||||||
|
console.warn = warnSpy;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
console.warn = originalWarn;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("migrates legacy persisted keybindings before decoding them", () => {
|
||||||
|
const migrated = migratePersistedKeybindingsState({
|
||||||
|
state: {
|
||||||
|
keybindings: {
|
||||||
|
s: "split-selected",
|
||||||
|
"ctrl+v": "paste-selected",
|
||||||
|
},
|
||||||
|
isCustomized: true,
|
||||||
|
},
|
||||||
|
fromVersion: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const decoded = decodePersistedKeybindingsState({ state: migrated });
|
||||||
|
expect(decoded).not.toBeNull();
|
||||||
|
if (!decoded) throw new Error("Expected migrated keybindings to decode");
|
||||||
|
|
||||||
|
expect(decoded.isCustomized).toBe(true);
|
||||||
|
expect(decoded.keybindings.get("s")).toBe("split");
|
||||||
|
expect(decoded.keybindings.get("ctrl+v")).toBe("paste-copied");
|
||||||
|
expect(decoded.keybindings.get("escape")).toBe("cancel-interaction");
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters invalid persisted entries at the boundary and warns", () => {
|
||||||
|
const decoded = decodePersistedKeybindingsState({
|
||||||
|
state: {
|
||||||
|
keybindings: {
|
||||||
|
space: "toggle-play",
|
||||||
|
"shift+bogus": "toggle-play",
|
||||||
|
"ctrl+v": "not-an-action",
|
||||||
|
},
|
||||||
|
isCustomized: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decoded).not.toBeNull();
|
||||||
|
if (!decoded) throw new Error("Expected persisted keybindings to decode");
|
||||||
|
|
||||||
|
expect(Array.from(decoded.keybindings.entries())).toEqual([
|
||||||
|
["space", "toggle-play"],
|
||||||
|
]);
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null and warns when persisted shape is unrecognizable", () => {
|
||||||
|
const decoded = decodePersistedKeybindingsState({ state: "garbage" });
|
||||||
|
expect(decoded).toBeNull();
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("round-trips actions that have no default shortcut", () => {
|
||||||
|
// `stop-playback` is a valid `TActionWithOptionalArgs` but is not in the
|
||||||
|
// defaults table; the validator must still accept it.
|
||||||
|
const serialized = serializeKeybindingsState({
|
||||||
|
keybindings: new Map([
|
||||||
|
["space", "toggle-play"],
|
||||||
|
["x", "stop-playback"],
|
||||||
|
["b", "toggle-bookmark"],
|
||||||
|
]),
|
||||||
|
isCustomized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const decoded = decodePersistedKeybindingsState({ state: serialized });
|
||||||
|
expect(decoded).not.toBeNull();
|
||||||
|
if (!decoded) throw new Error("Expected round-tripped keybindings");
|
||||||
|
|
||||||
|
expect(decoded.keybindings.get("space")).toBe("toggle-play");
|
||||||
|
expect(decoded.keybindings.get("x")).toBe("stop-playback");
|
||||||
|
expect(decoded.keybindings.get("b")).toBe("toggle-bookmark");
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseImportedKeybindings", () => {
|
||||||
|
test("accepts a valid configuration", () => {
|
||||||
|
const result = parseImportedKeybindings({
|
||||||
|
config: {
|
||||||
|
space: "toggle-play",
|
||||||
|
x: "stop-playback",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.get("space")).toBe("toggle-play");
|
||||||
|
expect(result.get("x")).toBe("stop-playback");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws on non-object input", () => {
|
||||||
|
expect(() => parseImportedKeybindings({ config: null })).toThrow(
|
||||||
|
/JSON object/,
|
||||||
|
);
|
||||||
|
expect(() => parseImportedKeybindings({ config: [] })).toThrow(
|
||||||
|
/JSON object/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws on non-string action values", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseImportedKeybindings({ config: { space: 42 } }),
|
||||||
|
).toThrow(/expected string/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws on invalid shortcut keys", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseImportedKeybindings({
|
||||||
|
config: { "shift+bogus": "toggle-play" },
|
||||||
|
}),
|
||||||
|
).toThrow(/shift\+bogus/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws on invalid actions", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseImportedKeybindings({ config: { space: "not-an-action" } }),
|
||||||
|
).toThrow(/not-an-action/);
|
||||||
|
});
|
||||||
|
});
|
||||||
+3
-1
@@ -2,6 +2,7 @@ import { v2ToV3 } from "./v2-to-v3";
|
|||||||
import { v3ToV4 } from "./v3-to-v4";
|
import { v3ToV4 } from "./v3-to-v4";
|
||||||
import { v4ToV5 } from "./v4-to-v5";
|
import { v4ToV5 } from "./v4-to-v5";
|
||||||
import { v5ToV6 } from "./v5-to-v6";
|
import { v5ToV6 } from "./v5-to-v6";
|
||||||
|
import { v6ToV7 } from "./v6-to-v7";
|
||||||
|
|
||||||
type MigrationFn = ({ state }: { state: unknown }) => unknown;
|
type MigrationFn = ({ state }: { state: unknown }) => unknown;
|
||||||
|
|
||||||
@@ -10,9 +11,10 @@ const migrations: Record<number, MigrationFn> = {
|
|||||||
3: v3ToV4,
|
3: v3ToV4,
|
||||||
4: v4ToV5,
|
4: v4ToV5,
|
||||||
5: v5ToV6,
|
5: v5ToV6,
|
||||||
|
6: v6ToV7,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CURRENT_VERSION = 6;
|
export const CURRENT_VERSION = 7;
|
||||||
|
|
||||||
export function runMigrations({
|
export function runMigrations({
|
||||||
state,
|
state,
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { getPersistedKeybindingsState } from "../persisted-state";
|
||||||
|
|
||||||
|
export function v2ToV3({ state }: { state: unknown }): unknown {
|
||||||
|
const v2 = getPersistedKeybindingsState({ state });
|
||||||
|
if (!v2) return state;
|
||||||
|
|
||||||
|
const renames: Record<string, string> = {
|
||||||
|
"split-selected": "split",
|
||||||
|
"split-selected-left": "split-left",
|
||||||
|
"split-selected-right": "split-right",
|
||||||
|
};
|
||||||
|
|
||||||
|
const migrated = { ...v2.keybindings };
|
||||||
|
for (const [key, action] of Object.entries(migrated)) {
|
||||||
|
const renamedAction = action ? renames[action] : undefined;
|
||||||
|
if (renamedAction) {
|
||||||
|
migrated[key] = renamedAction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...v2, keybindings: migrated };
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { getPersistedKeybindingsState } from "../persisted-state";
|
||||||
|
|
||||||
|
export function v3ToV4({ state }: { state: unknown }): unknown {
|
||||||
|
const v3 = getPersistedKeybindingsState({ state });
|
||||||
|
if (!v3) return state;
|
||||||
|
|
||||||
|
const renames: Record<string, string> = {
|
||||||
|
"paste-selected": "paste-copied",
|
||||||
|
};
|
||||||
|
|
||||||
|
const migrated = { ...v3.keybindings };
|
||||||
|
for (const [key, action] of Object.entries(migrated)) {
|
||||||
|
const renamedAction = action ? renames[action] : undefined;
|
||||||
|
if (renamedAction) {
|
||||||
|
migrated[key] = renamedAction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...v3, keybindings: migrated };
|
||||||
|
}
|
||||||
+3
-7
@@ -1,12 +1,8 @@
|
|||||||
import type { KeybindingConfig } from "@/lib/actions/keybinding";
|
import { getPersistedKeybindingsState } from "../persisted-state";
|
||||||
|
|
||||||
interface V4State {
|
|
||||||
keybindings: KeybindingConfig;
|
|
||||||
isCustomized: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function v4ToV5({ state }: { state: unknown }): unknown {
|
export function v4ToV5({ state }: { state: unknown }): unknown {
|
||||||
const v4 = state as V4State;
|
const v4 = getPersistedKeybindingsState({ state });
|
||||||
|
if (!v4) return state;
|
||||||
const keybindings = { ...v4.keybindings };
|
const keybindings = { ...v4.keybindings };
|
||||||
|
|
||||||
if (!keybindings.escape) {
|
if (!keybindings.escape) {
|
||||||
+3
-7
@@ -1,12 +1,8 @@
|
|||||||
import type { KeybindingConfig } from "@/lib/actions/keybinding";
|
import { getPersistedKeybindingsState } from "../persisted-state";
|
||||||
|
|
||||||
interface V5State {
|
|
||||||
keybindings: KeybindingConfig;
|
|
||||||
isCustomized: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function v5ToV6({ state }: { state: unknown }): unknown {
|
export function v5ToV6({ state }: { state: unknown }): unknown {
|
||||||
const v5 = state as V5State;
|
const v5 = getPersistedKeybindingsState({ state });
|
||||||
|
if (!v5) return state;
|
||||||
const keybindings = { ...v5.keybindings };
|
const keybindings = { ...v5.keybindings };
|
||||||
|
|
||||||
if (keybindings.escape === "deselect-all") {
|
if (keybindings.escape === "deselect-all") {
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { getPersistedKeybindingsState } from "../persisted-state";
|
||||||
|
|
||||||
|
export function v6ToV7({ state }: { state: unknown }): unknown {
|
||||||
|
const v6 = getPersistedKeybindingsState({ state });
|
||||||
|
if (!v6) return state;
|
||||||
|
const keybindings = { ...v6.keybindings };
|
||||||
|
|
||||||
|
for (const [key, action] of Object.entries(keybindings)) {
|
||||||
|
if (action === "split-element") {
|
||||||
|
keybindings[key] = "split";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...v6, keybindings };
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export type PersistedKeybindingConfig = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
export interface PersistedKeybindingsState {
|
||||||
|
keybindings: PersistedKeybindingConfig;
|
||||||
|
isCustomized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPersistedKeybindingsState({
|
||||||
|
state,
|
||||||
|
}: {
|
||||||
|
state: unknown;
|
||||||
|
}): PersistedKeybindingsState | null {
|
||||||
|
if (!isRecord(state)) return null;
|
||||||
|
|
||||||
|
const { keybindings, isCustomized } = state;
|
||||||
|
if (!isRecord(keybindings) || typeof isCustomized !== "boolean") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedKeybindings: PersistedKeybindingConfig = {};
|
||||||
|
for (const [key, action] of Object.entries(keybindings)) {
|
||||||
|
if (action !== undefined && typeof action !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedKeybindings[key] = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
keybindings: normalizedKeybindings,
|
||||||
|
isCustomized,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { ShortcutKey } from "@/actions/keybinding";
|
||||||
|
import { isShortcutKey } from "@/actions/keybinding";
|
||||||
|
import type { TActionWithOptionalArgs } from "@/actions";
|
||||||
|
import { isActionWithOptionalArgs } from "@/actions";
|
||||||
|
import { runMigrations } from "./migrations";
|
||||||
|
import {
|
||||||
|
getPersistedKeybindingsState,
|
||||||
|
type PersistedKeybindingsState,
|
||||||
|
} from "./persisted-state";
|
||||||
|
|
||||||
|
export interface DecodedKeybindingsState {
|
||||||
|
keybindings: Map<ShortcutKey, TActionWithOptionalArgs>;
|
||||||
|
isCustomized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeKeybindingsState({
|
||||||
|
keybindings,
|
||||||
|
isCustomized,
|
||||||
|
}: DecodedKeybindingsState): PersistedKeybindingsState {
|
||||||
|
return {
|
||||||
|
keybindings: Object.fromEntries(keybindings),
|
||||||
|
isCustomized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migratePersistedKeybindingsState({
|
||||||
|
state,
|
||||||
|
fromVersion,
|
||||||
|
}: {
|
||||||
|
state: unknown;
|
||||||
|
fromVersion: number;
|
||||||
|
}): unknown {
|
||||||
|
return runMigrations({ state, fromVersion });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a persisted/migrated keybindings blob into the in-memory shape.
|
||||||
|
*
|
||||||
|
* Lossy by design: invalid entries are dropped and a warning is emitted, so the
|
||||||
|
* user falls back to (mostly) sensible defaults instead of a broken store.
|
||||||
|
* Returns `null` if the top-level shape is unrecognizable, in which case the
|
||||||
|
* caller should keep its current state.
|
||||||
|
*/
|
||||||
|
export function decodePersistedKeybindingsState({
|
||||||
|
state,
|
||||||
|
}: {
|
||||||
|
state: unknown;
|
||||||
|
}): DecodedKeybindingsState | null {
|
||||||
|
const persisted = getPersistedKeybindingsState({ state });
|
||||||
|
if (!persisted) {
|
||||||
|
console.warn(
|
||||||
|
"[keybindings] Persisted state has unexpected shape; keeping current keybindings.",
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keybindings = new Map<ShortcutKey, TActionWithOptionalArgs>();
|
||||||
|
const dropped: Array<{ key: string; action: string | undefined }> = [];
|
||||||
|
for (const [key, action] of Object.entries(persisted.keybindings)) {
|
||||||
|
if (action === undefined) continue;
|
||||||
|
if (!isShortcutKey(key) || !isActionWithOptionalArgs(action)) {
|
||||||
|
dropped.push({ key, action });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
keybindings.set(key, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dropped.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
"[keybindings] Dropped invalid persisted entries:",
|
||||||
|
dropped,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
keybindings,
|
||||||
|
isCustomized: persisted.isCustomized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a user-supplied keybindings configuration (typically the output of
|
||||||
|
* `JSON.parse` on an imported file).
|
||||||
|
*
|
||||||
|
* Strict by design: throws on the first invalid entry so the caller can surface
|
||||||
|
* the failure to the user instead of silently producing a half-applied import.
|
||||||
|
* Accepts `unknown` because the input has already crossed a trust boundary.
|
||||||
|
*/
|
||||||
|
export function parseImportedKeybindings({
|
||||||
|
config,
|
||||||
|
}: {
|
||||||
|
config: unknown;
|
||||||
|
}): Map<ShortcutKey, TActionWithOptionalArgs> {
|
||||||
|
if (typeof config !== "object" || config === null || Array.isArray(config)) {
|
||||||
|
throw new Error("Imported keybindings must be a JSON object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Map<ShortcutKey, TActionWithOptionalArgs>();
|
||||||
|
for (const [key, action] of Object.entries(config)) {
|
||||||
|
if (action === undefined) continue;
|
||||||
|
if (typeof action !== "string") {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid action for "${key}": expected string, got ${typeof action}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isShortcutKey(key)) {
|
||||||
|
throw new Error(`Invalid shortcut key: ${JSON.stringify(key)}`);
|
||||||
|
}
|
||||||
|
if (!isActionWithOptionalArgs(action)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid action for "${key}": ${JSON.stringify(action)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result.set(key, action);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void;
|
type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void;
|
||||||
const boundActions: Partial<Record<TAction, ActionHandler[]>> = {};
|
const boundActions: Partial<Record<TAction, ActionHandler[]>> = {};
|
||||||
|
|
||||||
|
// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler).
|
||||||
export function bindAction<A extends TAction>(
|
export function bindAction<A extends TAction>(
|
||||||
action: A,
|
action: A,
|
||||||
handler: TActionFunc<A>,
|
handler: TActionFunc<A>,
|
||||||
@@ -24,6 +25,7 @@ export function bindAction<A extends TAction>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler).
|
||||||
export function unbindAction<A extends TAction>(
|
export function unbindAction<A extends TAction>(
|
||||||
action: A,
|
action: A,
|
||||||
handler: TActionFunc<A>,
|
handler: TActionFunc<A>,
|
||||||
@@ -52,34 +54,12 @@ type InvokeActionFunc = {
|
|||||||
): void;
|
): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line opencut/prefer-object-params -- dispatchers conventionally separate action, payload, and trigger.
|
||||||
export const invokeAction: InvokeActionFunc = <A extends TAction>(
|
export const invokeAction: InvokeActionFunc = <A extends TAction>(
|
||||||
action: A,
|
action: A,
|
||||||
args?: TArgOfAction<A>,
|
args?: TArgOfAction<A>,
|
||||||
trigger?: TInvocationTrigger,
|
trigger?: TInvocationTrigger,
|
||||||
) => {
|
) => {
|
||||||
if (trigger === "keypress") {
|
|
||||||
// #region agent log
|
|
||||||
fetch("http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Debug-Session-Id": "3997d9",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
sessionId: "3997d9",
|
|
||||||
runId: "initial",
|
|
||||||
hypothesisId: "H4",
|
|
||||||
location: "actions/registry.ts:invokeAction",
|
|
||||||
message: "Action invoked from keypress",
|
|
||||||
data: {
|
|
||||||
action,
|
|
||||||
handlerCount: boundActions[action]?.length ?? 0,
|
|
||||||
},
|
|
||||||
timestamp: Date.now(),
|
|
||||||
}),
|
|
||||||
}).catch(() => {});
|
|
||||||
// #endregion
|
|
||||||
}
|
|
||||||
boundActions[action]?.forEach((handler) => {
|
boundActions[action]?.forEach((handler) => {
|
||||||
handler(args, trigger);
|
handler(args, trigger);
|
||||||
});
|
});
|
||||||
@@ -1,44 +1,44 @@
|
|||||||
import type { MutableRefObject } from "react";
|
import type { MutableRefObject } from "react";
|
||||||
import type { TAction } from "./definitions";
|
import type { TAction } from "./definitions";
|
||||||
|
|
||||||
export type { TAction };
|
export type { TAction };
|
||||||
|
|
||||||
export type TActionArgsMap = {
|
export type TActionArgsMap = {
|
||||||
"seek-forward": { seconds: number } | undefined;
|
"seek-forward": { seconds: number } | undefined;
|
||||||
"seek-backward": { seconds: number } | undefined;
|
"seek-backward": { seconds: number } | undefined;
|
||||||
"jump-forward": { seconds: number } | undefined;
|
"jump-forward": { seconds: number } | undefined;
|
||||||
"jump-backward": { seconds: number } | undefined;
|
"jump-backward": { seconds: number } | undefined;
|
||||||
"remove-media-asset": { projectId: string; assetId: string };
|
"remove-media-asset": { projectId: string; assetId: string };
|
||||||
"remove-media-assets": { projectId: string; assetIds: string[] };
|
"remove-media-assets": { projectId: string; assetIds: string[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
type TKeysWithValueUndefined<T> = {
|
type TKeysWithValueUndefined<T> = {
|
||||||
[K in keyof T]: undefined extends T[K] ? K : never;
|
[K in keyof T]: undefined extends T[K] ? K : never;
|
||||||
}[keyof T];
|
}[keyof T];
|
||||||
|
|
||||||
export type TActionWithArgs = keyof TActionArgsMap;
|
export type TActionWithArgs = keyof TActionArgsMap;
|
||||||
|
|
||||||
export type TActionWithOptionalArgs =
|
export type TActionWithOptionalArgs =
|
||||||
| TActionWithNoArgs
|
| TActionWithNoArgs
|
||||||
| TKeysWithValueUndefined<TActionArgsMap>;
|
| TKeysWithValueUndefined<TActionArgsMap>;
|
||||||
|
|
||||||
export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
|
export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
|
||||||
|
|
||||||
export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
|
export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
|
||||||
? TActionArgsMap[A]
|
? TActionArgsMap[A]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
export type TActionFunc<A extends TAction> = A extends TActionWithArgs
|
export type TActionFunc<A extends TAction> = A extends TActionWithArgs
|
||||||
? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
|
? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
|
||||||
: (_?: undefined, trigger?: TInvocationTrigger) => void;
|
: (_?: undefined, trigger?: TInvocationTrigger) => void;
|
||||||
|
|
||||||
export type TInvocationTrigger = "keypress" | "mouseclick";
|
export type TInvocationTrigger = "keypress" | "mouseclick";
|
||||||
|
|
||||||
export type TBoundActionList = {
|
export type TBoundActionList = {
|
||||||
[A in TAction]?: Array<TActionFunc<A>>;
|
[A in TAction]?: Array<TActionFunc<A>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TActionHandlerOptions =
|
export type TActionHandlerOptions =
|
||||||
| MutableRefObject<boolean>
|
| MutableRefObject<boolean>
|
||||||
| boolean
|
| boolean
|
||||||
| undefined;
|
| undefined;
|
||||||
+3
-2
@@ -5,9 +5,10 @@ import type {
|
|||||||
TActionHandlerOptions,
|
TActionHandlerOptions,
|
||||||
TArgOfAction,
|
TArgOfAction,
|
||||||
TInvocationTrigger,
|
TInvocationTrigger,
|
||||||
} from "@/lib/actions";
|
} from "@/actions";
|
||||||
import { bindAction, unbindAction } from "@/lib/actions";
|
import { bindAction, unbindAction } from "@/actions";
|
||||||
|
|
||||||
|
// eslint-disable-next-line opencut/prefer-object-params -- action subscriptions read best as (action, handler, isActive).
|
||||||
export function useActionHandler<A extends TAction>(
|
export function useActionHandler<A extends TAction>(
|
||||||
action: A,
|
action: A,
|
||||||
handler: TActionFunc<A>,
|
handler: TActionFunc<A>,
|
||||||
+182
-91
@@ -1,62 +1,70 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/timeline/timeline-store";
|
||||||
import { useActionHandler } from "@/hooks/actions/use-action-handler";
|
import { useActionHandler } from "@/actions/use-action-handler";
|
||||||
import { useEditor } from "../use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { useElementSelection } from "../timeline/element/use-element-selection";
|
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||||
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
|
import {
|
||||||
import { getElementsAtTime } from "@/lib/timeline";
|
addMediaTime,
|
||||||
import { cancelInteraction } from "@/lib/cancel-interaction";
|
maxMediaTime,
|
||||||
import { invokeAction } from "@/lib/actions";
|
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";
|
||||||
|
import { invokeAction } from "@/actions";
|
||||||
|
import { canToggleSourceAudio } from "@/timeline/audio-separation";
|
||||||
import {
|
import {
|
||||||
activateScope,
|
activateScope,
|
||||||
clearActiveScope,
|
clearActiveScope,
|
||||||
type ScopeEntry,
|
type ScopeEntry,
|
||||||
} from "@/lib/selection/scope";
|
} from "@/selection/scope";
|
||||||
|
import { useCommittedRef } from "@/hooks/use-committed-ref";
|
||||||
|
|
||||||
export function useEditorActions() {
|
export function useEditorActions() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const { selectedElements, setElementSelection } = useElementSelection();
|
const { selectedElements, setElementSelection } = useElementSelection();
|
||||||
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
|
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
|
||||||
const clipboard = useTimelineStore((s) => s.clipboard);
|
const selectedMaskPointSelection = useEditor((e) =>
|
||||||
const setClipboard = useTimelineStore((s) => s.setClipboard);
|
e.selection.getSelectedMaskPointSelection(),
|
||||||
|
);
|
||||||
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
||||||
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
||||||
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
||||||
const hasTimelineSelectionRef = useRef(false);
|
|
||||||
const clearTimelineSelectionRef = useRef(() => {});
|
|
||||||
const timelineScopeRef = useRef<ScopeEntry | null>(null);
|
|
||||||
const hasTimelineSelection =
|
const hasTimelineSelection =
|
||||||
selectedElements.length > 0 || selectedKeyframes.length > 0;
|
selectedElements.length > 0 ||
|
||||||
|
selectedKeyframes.length > 0 ||
|
||||||
hasTimelineSelectionRef.current = hasTimelineSelection;
|
selectedMaskPointSelection !== null;
|
||||||
clearTimelineSelectionRef.current = () => {
|
const hasTimelineSelectionRef = useCommittedRef(hasTimelineSelection);
|
||||||
setElementSelection({ elements: [] });
|
const clearTimelineSelectionRef = useCommittedRef(() => {
|
||||||
clearKeyframeSelection();
|
editor.selection.clearSelection();
|
||||||
};
|
});
|
||||||
|
const clearTimelineActiveSelectionRef = useCommittedRef(() => {
|
||||||
if (!timelineScopeRef.current) {
|
editor.selection.clearMostSpecificSelection();
|
||||||
timelineScopeRef.current = {
|
});
|
||||||
hasSelection: () => hasTimelineSelectionRef.current,
|
const [timelineScope] = useState<ScopeEntry>(() => ({
|
||||||
clear: () => {
|
hasSelection: () => hasTimelineSelectionRef.current,
|
||||||
clearTimelineSelectionRef.current();
|
clear: () => {
|
||||||
},
|
clearTimelineSelectionRef.current();
|
||||||
};
|
},
|
||||||
}
|
clearActive: () => {
|
||||||
|
clearTimelineActiveSelectionRef.current();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasTimelineSelection) {
|
if (!hasTimelineSelection) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelineScope = timelineScopeRef.current;
|
|
||||||
if (!timelineScope) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return activateScope({ entry: timelineScope });
|
return activateScope({ entry: timelineScope });
|
||||||
}, [hasTimelineSelection]);
|
}, [hasTimelineSelection, timelineScope]);
|
||||||
|
|
||||||
useActionHandler(
|
useActionHandler(
|
||||||
"toggle-play",
|
"toggle-play",
|
||||||
@@ -72,7 +80,7 @@ export function useEditorActions() {
|
|||||||
if (editor.playback.getIsPlaying()) {
|
if (editor.playback.getIsPlaying()) {
|
||||||
editor.playback.toggle();
|
editor.playback.toggle();
|
||||||
}
|
}
|
||||||
editor.playback.seek({ time: 0 });
|
editor.playback.seek({ time: ZERO_MEDIA_TIME });
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -81,11 +89,15 @@ export function useEditorActions() {
|
|||||||
"seek-forward",
|
"seek-forward",
|
||||||
(args) => {
|
(args) => {
|
||||||
const seconds = args?.seconds ?? 1;
|
const seconds = args?.seconds ?? 1;
|
||||||
|
const delta = mediaTimeFromSeconds({ seconds });
|
||||||
editor.playback.seek({
|
editor.playback.seek({
|
||||||
time: Math.min(
|
time: minMediaTime({
|
||||||
editor.timeline.getTotalDuration(),
|
a: editor.timeline.getTotalDuration(),
|
||||||
editor.playback.getCurrentTime() + seconds,
|
b: addMediaTime({
|
||||||
),
|
a: editor.playback.getCurrentTime(),
|
||||||
|
b: delta,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
@@ -95,8 +107,15 @@ export function useEditorActions() {
|
|||||||
"seek-backward",
|
"seek-backward",
|
||||||
(args) => {
|
(args) => {
|
||||||
const seconds = args?.seconds ?? 1;
|
const seconds = args?.seconds ?? 1;
|
||||||
|
const delta = mediaTimeFromSeconds({ seconds });
|
||||||
editor.playback.seek({
|
editor.playback.seek({
|
||||||
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
|
time: maxMediaTime({
|
||||||
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: subMediaTime({
|
||||||
|
a: editor.playback.getCurrentTime(),
|
||||||
|
b: delta,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
@@ -106,12 +125,20 @@ export function useEditorActions() {
|
|||||||
"frame-step-forward",
|
"frame-step-forward",
|
||||||
() => {
|
() => {
|
||||||
const fps = editor.project.getActive().settings.fps;
|
const fps = editor.project.getActive().settings.fps;
|
||||||
editor.playback.seek({
|
const ticksPerFrame = mediaTime({
|
||||||
time: Math.min(
|
ticks: Math.round(
|
||||||
editor.timeline.getTotalDuration(),
|
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||||
editor.playback.getCurrentTime() + 1 / fps,
|
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
editor.playback.seek({
|
||||||
|
time: minMediaTime({
|
||||||
|
a: editor.timeline.getTotalDuration(),
|
||||||
|
b: addMediaTime({
|
||||||
|
a: editor.playback.getCurrentTime(),
|
||||||
|
b: ticksPerFrame,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -120,8 +147,19 @@ export function useEditorActions() {
|
|||||||
"frame-step-backward",
|
"frame-step-backward",
|
||||||
() => {
|
() => {
|
||||||
const fps = editor.project.getActive().settings.fps;
|
const fps = editor.project.getActive().settings.fps;
|
||||||
|
const ticksPerFrame = mediaTime({
|
||||||
|
ticks: Math.round(
|
||||||
|
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||||
|
),
|
||||||
|
});
|
||||||
editor.playback.seek({
|
editor.playback.seek({
|
||||||
time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps),
|
time: maxMediaTime({
|
||||||
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: subMediaTime({
|
||||||
|
a: editor.playback.getCurrentTime(),
|
||||||
|
b: ticksPerFrame,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
@@ -131,11 +169,15 @@ export function useEditorActions() {
|
|||||||
"jump-forward",
|
"jump-forward",
|
||||||
(args) => {
|
(args) => {
|
||||||
const seconds = args?.seconds ?? 5;
|
const seconds = args?.seconds ?? 5;
|
||||||
|
const delta = mediaTimeFromSeconds({ seconds });
|
||||||
editor.playback.seek({
|
editor.playback.seek({
|
||||||
time: Math.min(
|
time: minMediaTime({
|
||||||
editor.timeline.getTotalDuration(),
|
a: editor.timeline.getTotalDuration(),
|
||||||
editor.playback.getCurrentTime() + seconds,
|
b: addMediaTime({
|
||||||
),
|
a: editor.playback.getCurrentTime(),
|
||||||
|
b: delta,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
@@ -145,8 +187,15 @@ export function useEditorActions() {
|
|||||||
"jump-backward",
|
"jump-backward",
|
||||||
(args) => {
|
(args) => {
|
||||||
const seconds = args?.seconds ?? 5;
|
const seconds = args?.seconds ?? 5;
|
||||||
|
const delta = mediaTimeFromSeconds({ seconds });
|
||||||
editor.playback.seek({
|
editor.playback.seek({
|
||||||
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
|
time: maxMediaTime({
|
||||||
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: subMediaTime({
|
||||||
|
a: editor.playback.getCurrentTime(),
|
||||||
|
b: delta,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
@@ -155,7 +204,7 @@ export function useEditorActions() {
|
|||||||
useActionHandler(
|
useActionHandler(
|
||||||
"goto-start",
|
"goto-start",
|
||||||
() => {
|
() => {
|
||||||
editor.playback.seek({ time: 0 });
|
editor.playback.seek({ time: ZERO_MEDIA_TIME });
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -172,11 +221,12 @@ export function useEditorActions() {
|
|||||||
"split",
|
"split",
|
||||||
() => {
|
() => {
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const elementsToSplit =
|
const elementsToSplit =
|
||||||
selectedElements.length > 0
|
selectedElements.length > 0
|
||||||
? selectedElements
|
? selectedElements
|
||||||
: getElementsAtTime({
|
: getElementsAtTime({
|
||||||
tracks: editor.timeline.getTracks(),
|
tracks,
|
||||||
time: currentTime,
|
time: currentTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,11 +244,12 @@ export function useEditorActions() {
|
|||||||
"split-left",
|
"split-left",
|
||||||
() => {
|
() => {
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const elementsToSplit =
|
const elementsToSplit =
|
||||||
selectedElements.length > 0
|
selectedElements.length > 0
|
||||||
? selectedElements
|
? selectedElements
|
||||||
: getElementsAtTime({
|
: getElementsAtTime({
|
||||||
tracks: editor.timeline.getTracks(),
|
tracks,
|
||||||
time: currentTime,
|
time: currentTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -208,7 +259,6 @@ export function useEditorActions() {
|
|||||||
elements: elementsToSplit,
|
elements: elementsToSplit,
|
||||||
splitTime: currentTime,
|
splitTime: currentTime,
|
||||||
retainSide: "right",
|
retainSide: "right",
|
||||||
rippleEnabled: rippleEditingEnabled,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (rippleEditingEnabled && rightSideElements.length > 0) {
|
if (rippleEditingEnabled && rightSideElements.length > 0) {
|
||||||
@@ -227,11 +277,12 @@ export function useEditorActions() {
|
|||||||
"split-right",
|
"split-right",
|
||||||
() => {
|
() => {
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const elementsToSplit =
|
const elementsToSplit =
|
||||||
selectedElements.length > 0
|
selectedElements.length > 0
|
||||||
? selectedElements
|
? selectedElements
|
||||||
: getElementsAtTime({
|
: getElementsAtTime({
|
||||||
tracks: editor.timeline.getTracks(),
|
tracks,
|
||||||
time: currentTime,
|
time: currentTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -249,19 +300,74 @@ export function useEditorActions() {
|
|||||||
useActionHandler(
|
useActionHandler(
|
||||||
"delete-selected",
|
"delete-selected",
|
||||||
() => {
|
() => {
|
||||||
if (selectedKeyframes.length > 0) {
|
switch (editor.selection.getActiveSelectionKind()) {
|
||||||
editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
|
case "mask-points":
|
||||||
clearKeyframeSelection();
|
if (!selectedMaskPointSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.timeline.deleteFreeformPathMaskPoints({
|
||||||
|
trackId: selectedMaskPointSelection.trackId,
|
||||||
|
elementId: selectedMaskPointSelection.elementId,
|
||||||
|
maskId: selectedMaskPointSelection.maskId,
|
||||||
|
pointIds: selectedMaskPointSelection.pointIds,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case "keyframes":
|
||||||
|
if (selectedKeyframes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
|
||||||
|
clearKeyframeSelection();
|
||||||
|
return;
|
||||||
|
case "elements":
|
||||||
|
if (selectedElements.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.timeline.deleteElements({
|
||||||
|
elements: selectedElements,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
useActionHandler(
|
||||||
|
"toggle-source-audio",
|
||||||
|
() => {
|
||||||
|
if (selectedElements.length !== 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedElements.length === 0) {
|
|
||||||
return;
|
const selectedElement = editor.timeline.getElementsWithTracks({
|
||||||
}
|
|
||||||
editor.timeline.deleteElements({
|
|
||||||
elements: selectedElements,
|
elements: selectedElements,
|
||||||
rippleEnabled: rippleEditingEnabled,
|
})[0];
|
||||||
|
if (!selectedElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaAsset = (() => {
|
||||||
|
const { element } = selectedElement;
|
||||||
|
if (!hasMediaId(element)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
editor.media
|
||||||
|
.getAssets()
|
||||||
|
.find((asset) => asset.id === element.mediaId) ?? null
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.timeline.toggleSourceAudioSeparation({
|
||||||
|
trackId: selectedElement.track.id,
|
||||||
|
elementId: selectedElement.element.id,
|
||||||
});
|
});
|
||||||
editor.selection.clearSelection();
|
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -269,7 +375,12 @@ export function useEditorActions() {
|
|||||||
useActionHandler(
|
useActionHandler(
|
||||||
"select-all",
|
"select-all",
|
||||||
() => {
|
() => {
|
||||||
const allElements = editor.timeline.getTracks().flatMap((track) =>
|
const scene = editor.scenes.getActiveScene();
|
||||||
|
const allElements = [
|
||||||
|
...scene.tracks.overlay,
|
||||||
|
scene.tracks.main,
|
||||||
|
...scene.tracks.audio,
|
||||||
|
].flatMap((track) =>
|
||||||
track.elements.map((element) => ({
|
track.elements.map((element) => ({
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
@@ -294,8 +405,7 @@ export function useEditorActions() {
|
|||||||
"deselect-all",
|
"deselect-all",
|
||||||
() => {
|
() => {
|
||||||
if (!clearActiveScope()) {
|
if (!clearActiveScope()) {
|
||||||
setElementSelection({ elements: [] });
|
editor.selection.clearMostSpecificSelection();
|
||||||
clearKeyframeSelection();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
@@ -338,21 +448,7 @@ export function useEditorActions() {
|
|||||||
useActionHandler(
|
useActionHandler(
|
||||||
"copy-selected",
|
"copy-selected",
|
||||||
() => {
|
() => {
|
||||||
if (selectedElements.length === 0) return;
|
editor.clipboard.copy();
|
||||||
|
|
||||||
const results = editor.timeline.getElementsWithTracks({
|
|
||||||
elements: selectedElements,
|
|
||||||
});
|
|
||||||
const items = results.map(({ track, element }) => {
|
|
||||||
const { id: _, ...elementWithoutId } = element;
|
|
||||||
return {
|
|
||||||
trackId: track.id,
|
|
||||||
trackType: track.type,
|
|
||||||
element: elementWithoutId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setClipboard({ items });
|
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -360,12 +456,7 @@ export function useEditorActions() {
|
|||||||
useActionHandler(
|
useActionHandler(
|
||||||
"paste-copied",
|
"paste-copied",
|
||||||
() => {
|
() => {
|
||||||
if (!clipboard?.items.length) return;
|
editor.clipboard.paste();
|
||||||
|
|
||||||
editor.timeline.pasteAtTime({
|
|
||||||
time: editor.playback.getCurrentTime(),
|
|
||||||
clipboardItems: clipboard.items,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { invokeAction } from "@/actions";
|
||||||
|
import { useEditor } from "@/editor/use-editor";
|
||||||
|
import { useKeybindingsStore } from "@/actions/keybindings-store";
|
||||||
|
import { isTypableDOMElement } from "@/utils/browser";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a composable that hooks to the caller component's
|
||||||
|
* lifecycle and hooks to the keyboard events to fire
|
||||||
|
* the appropriate actions based on keybindings
|
||||||
|
*/
|
||||||
|
export function useKeybindingsListener() {
|
||||||
|
const editor = useEditor();
|
||||||
|
const {
|
||||||
|
keybindings,
|
||||||
|
getKeybindingString,
|
||||||
|
overlayDepth,
|
||||||
|
isLoadingProject,
|
||||||
|
isRecording,
|
||||||
|
} = useKeybindingsStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const eventOptions: AddEventListenerOptions = { capture: true };
|
||||||
|
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||||
|
const normalizedKey = (ev.key ?? "").toLowerCase();
|
||||||
|
|
||||||
|
if (overlayDepth > 0 || isLoadingProject || isRecording) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const binding = getKeybindingString(ev);
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
const isTextInput =
|
||||||
|
activeElement instanceof HTMLElement &&
|
||||||
|
isTypableDOMElement({ element: activeElement });
|
||||||
|
const boundAction = binding ? keybindings.get(binding) : undefined;
|
||||||
|
|
||||||
|
if (normalizedKey === "escape" && isTextInput) {
|
||||||
|
activeElement.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!binding) return;
|
||||||
|
if (!boundAction) return;
|
||||||
|
|
||||||
|
if (isTextInput) return;
|
||||||
|
if (boundAction === "paste-copied") {
|
||||||
|
if (!editor.clipboard.hasEntry()) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
invokeAction("paste-copied", undefined, "keypress");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
switch (boundAction) {
|
||||||
|
case "seek-forward":
|
||||||
|
invokeAction("seek-forward", { seconds: 1 }, "keypress");
|
||||||
|
break;
|
||||||
|
case "seek-backward":
|
||||||
|
invokeAction("seek-backward", { seconds: 1 }, "keypress");
|
||||||
|
break;
|
||||||
|
case "jump-forward":
|
||||||
|
invokeAction("jump-forward", { seconds: 5 }, "keypress");
|
||||||
|
break;
|
||||||
|
case "jump-backward":
|
||||||
|
invokeAction("jump-backward", { seconds: 5 }, "keypress");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
invokeAction(boundAction, undefined, "keypress");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown, eventOptions);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown, eventOptions);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
keybindings,
|
||||||
|
getKeybindingString,
|
||||||
|
overlayDepth,
|
||||||
|
isLoadingProject,
|
||||||
|
isRecording,
|
||||||
|
editor,
|
||||||
|
]);
|
||||||
|
}
|
||||||
+12
-15
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
import { useKeybindingsStore } from "@/actions/keybindings-store";
|
||||||
import { ACTIONS, type TActionWithOptionalArgs } from "@/lib/actions";
|
import { ACTIONS, type TActionWithOptionalArgs } from "@/actions";
|
||||||
import {
|
import {
|
||||||
getPlatformAlternateKey,
|
getPlatformAlternateKey,
|
||||||
getPlatformSpecialKey,
|
getPlatformSpecialKey,
|
||||||
@@ -39,24 +39,21 @@ export function useKeyboardShortcutsHelp() {
|
|||||||
const { keybindings } = useKeybindingsStore();
|
const { keybindings } = useKeybindingsStore();
|
||||||
|
|
||||||
const shortcuts = useMemo(() => {
|
const shortcuts = useMemo(() => {
|
||||||
const result: KeyboardShortcut[] = [];
|
const actionToKeys = new Map<TActionWithOptionalArgs, string[]>();
|
||||||
const actionToKeys: Partial<Record<TActionWithOptionalArgs, string[]>> = {};
|
|
||||||
|
|
||||||
for (const [key, action] of Object.entries(keybindings) as Array<
|
for (const [key, action] of keybindings) {
|
||||||
[string, TActionWithOptionalArgs | undefined]
|
const existing = actionToKeys.get(action);
|
||||||
>) {
|
if (existing) {
|
||||||
if (action) {
|
existing.push(formatKey({ key }));
|
||||||
if (!actionToKeys[action]) {
|
} else {
|
||||||
actionToKeys[action] = [];
|
actionToKeys.set(action, [formatKey({ key })]);
|
||||||
}
|
|
||||||
actionToKeys[action].push(formatKey({ key }));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [action, keys] of Object.entries(actionToKeys) as Array<
|
const result: KeyboardShortcut[] = [];
|
||||||
[TActionWithOptionalArgs, string[]]
|
for (const [action, keys] of actionToKeys) {
|
||||||
>) {
|
|
||||||
const actionDef = ACTIONS[action];
|
const actionDef = ACTIONS[action];
|
||||||
|
if (!actionDef) continue;
|
||||||
result.push({
|
result.push({
|
||||||
id: action,
|
id: action,
|
||||||
keys,
|
keys,
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
coerceParamValue,
|
||||||
|
getParamDefaultInterpolation,
|
||||||
|
getParamNumericRange,
|
||||||
|
getParamValueKind,
|
||||||
|
} from "@/params";
|
||||||
|
|
||||||
|
describe("animated params", () => {
|
||||||
|
test("snaps and clamps number params", () => {
|
||||||
|
expect(
|
||||||
|
coerceParamValue({
|
||||||
|
param: {
|
||||||
|
key: "intensity",
|
||||||
|
label: "Intensity",
|
||||||
|
type: "number",
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.25,
|
||||||
|
},
|
||||||
|
value: 0.62,
|
||||||
|
}),
|
||||||
|
).toBe(0.5);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
coerceParamValue({
|
||||||
|
param: {
|
||||||
|
key: "intensity",
|
||||||
|
label: "Intensity",
|
||||||
|
type: "number",
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.25,
|
||||||
|
},
|
||||||
|
value: 1.2,
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects NaN and non-number values for number params", () => {
|
||||||
|
const param = {
|
||||||
|
key: "intensity",
|
||||||
|
label: "Intensity",
|
||||||
|
type: "number" as const,
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.25,
|
||||||
|
};
|
||||||
|
expect(coerceParamValue({ param, value: Number.NaN })).toBeNull();
|
||||||
|
expect(coerceParamValue({ param, value: "0.5" })).toBeNull();
|
||||||
|
expect(coerceParamValue({ param, value: true })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passthrough with step <= 0 guard", () => {
|
||||||
|
expect(
|
||||||
|
coerceParamValue({
|
||||||
|
param: {
|
||||||
|
key: "x",
|
||||||
|
label: "X",
|
||||||
|
type: "number",
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
step: 0,
|
||||||
|
},
|
||||||
|
value: 0.123,
|
||||||
|
}),
|
||||||
|
).toBe(0.123);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts valid select values", () => {
|
||||||
|
const param = {
|
||||||
|
key: "blend",
|
||||||
|
label: "Blend",
|
||||||
|
type: "select" as const,
|
||||||
|
default: "normal",
|
||||||
|
options: [
|
||||||
|
{ value: "normal", label: "Normal" },
|
||||||
|
{ value: "multiply", label: "Multiply" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(coerceParamValue({ param, value: "normal" })).toBe("normal");
|
||||||
|
expect(coerceParamValue({ param, value: "multiply" })).toBe("multiply");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects select values outside the allowed options", () => {
|
||||||
|
expect(
|
||||||
|
coerceParamValue({
|
||||||
|
param: {
|
||||||
|
key: "blend",
|
||||||
|
label: "Blend",
|
||||||
|
type: "select",
|
||||||
|
default: "normal",
|
||||||
|
options: [
|
||||||
|
{ value: "normal", label: "Normal" },
|
||||||
|
{ value: "multiply", label: "Multiply" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
value: "screen",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects non-string select values", () => {
|
||||||
|
const param = {
|
||||||
|
key: "blend",
|
||||||
|
label: "Blend",
|
||||||
|
type: "select" as const,
|
||||||
|
default: "normal",
|
||||||
|
options: [{ value: "normal", label: "Normal" }],
|
||||||
|
};
|
||||||
|
expect(coerceParamValue({ param, value: 42 })).toBeNull();
|
||||||
|
expect(coerceParamValue({ param, value: null })).toBeNull();
|
||||||
|
expect(coerceParamValue({ param, value: undefined })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("boolean params accept booleans and reject other types", () => {
|
||||||
|
const param = {
|
||||||
|
key: "visible",
|
||||||
|
label: "Visible",
|
||||||
|
type: "boolean" as const,
|
||||||
|
default: true,
|
||||||
|
};
|
||||||
|
expect(coerceParamValue({ param, value: true })).toBe(true);
|
||||||
|
expect(coerceParamValue({ param, value: false })).toBe(false);
|
||||||
|
expect(coerceParamValue({ param, value: 1 })).toBeNull();
|
||||||
|
expect(coerceParamValue({ param, value: "true" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("color params accept strings and reject other types", () => {
|
||||||
|
const param = {
|
||||||
|
key: "fill",
|
||||||
|
label: "Fill",
|
||||||
|
type: "color" as const,
|
||||||
|
default: "#ffffff",
|
||||||
|
};
|
||||||
|
expect(coerceParamValue({ param, value: "#ff0000" })).toBe("#ff0000");
|
||||||
|
expect(coerceParamValue({ param, value: 0xff0000 })).toBeNull();
|
||||||
|
expect(coerceParamValue({ param, value: null })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getAnimationParamValueKind maps param type to binding kind", () => {
|
||||||
|
expect(
|
||||||
|
getParamValueKind({
|
||||||
|
param: {
|
||||||
|
key: "n",
|
||||||
|
label: "N",
|
||||||
|
type: "number",
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe("number");
|
||||||
|
expect(
|
||||||
|
getParamValueKind({
|
||||||
|
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||||
|
}),
|
||||||
|
).toBe("color");
|
||||||
|
expect(
|
||||||
|
getParamValueKind({
|
||||||
|
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||||
|
}),
|
||||||
|
).toBe("discrete");
|
||||||
|
expect(
|
||||||
|
getParamValueKind({
|
||||||
|
param: {
|
||||||
|
key: "s",
|
||||||
|
label: "S",
|
||||||
|
type: "select",
|
||||||
|
default: "a",
|
||||||
|
options: [{ value: "a", label: "A" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe("discrete");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => {
|
||||||
|
expect(
|
||||||
|
getParamDefaultInterpolation({
|
||||||
|
param: {
|
||||||
|
key: "n",
|
||||||
|
label: "N",
|
||||||
|
type: "number",
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe("linear");
|
||||||
|
expect(
|
||||||
|
getParamDefaultInterpolation({
|
||||||
|
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||||
|
}),
|
||||||
|
).toBe("linear");
|
||||||
|
expect(
|
||||||
|
getParamDefaultInterpolation({
|
||||||
|
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||||
|
}),
|
||||||
|
).toBe("hold");
|
||||||
|
expect(
|
||||||
|
getParamDefaultInterpolation({
|
||||||
|
param: {
|
||||||
|
key: "s",
|
||||||
|
label: "S",
|
||||||
|
type: "select",
|
||||||
|
default: "a",
|
||||||
|
options: [{ value: "a", label: "A" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe("hold");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => {
|
||||||
|
expect(
|
||||||
|
getParamNumericRange({
|
||||||
|
param: {
|
||||||
|
key: "intensity",
|
||||||
|
label: "Intensity",
|
||||||
|
type: "number",
|
||||||
|
default: 0.5,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({ min: 0, max: 1, step: 0.1 });
|
||||||
|
expect(
|
||||||
|
getParamNumericRange({
|
||||||
|
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
getParamNumericRange({
|
||||||
|
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { ScalarAnimationKey } from "@/animation/types";
|
||||||
|
|
||||||
|
const BEZIER_SOLVE_ITERATIONS = 20;
|
||||||
|
|
||||||
|
export function getBezierPoint({
|
||||||
|
progress,
|
||||||
|
p0,
|
||||||
|
p1,
|
||||||
|
p2,
|
||||||
|
p3,
|
||||||
|
}: {
|
||||||
|
progress: number;
|
||||||
|
p0: number;
|
||||||
|
p1: number;
|
||||||
|
p2: number;
|
||||||
|
p3: number;
|
||||||
|
}) {
|
||||||
|
const mt = 1 - progress;
|
||||||
|
return (
|
||||||
|
mt * mt * mt * p0 +
|
||||||
|
3 * mt * mt * progress * p1 +
|
||||||
|
3 * mt * progress * progress * p2 +
|
||||||
|
progress * progress * progress * p3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultRightHandle({
|
||||||
|
leftKey,
|
||||||
|
rightKey,
|
||||||
|
}: {
|
||||||
|
leftKey: ScalarAnimationKey;
|
||||||
|
rightKey: ScalarAnimationKey;
|
||||||
|
}) {
|
||||||
|
const span = rightKey.time - leftKey.time;
|
||||||
|
const valueDelta = rightKey.value - leftKey.value;
|
||||||
|
return {
|
||||||
|
dt: span / 3,
|
||||||
|
dv: valueDelta / 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultLeftHandle({
|
||||||
|
leftKey,
|
||||||
|
rightKey,
|
||||||
|
}: {
|
||||||
|
leftKey: ScalarAnimationKey;
|
||||||
|
rightKey: ScalarAnimationKey;
|
||||||
|
}) {
|
||||||
|
const span = rightKey.time - leftKey.time;
|
||||||
|
const valueDelta = rightKey.value - leftKey.value;
|
||||||
|
return {
|
||||||
|
dt: -span / 3,
|
||||||
|
dv: -valueDelta / 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function solveBezierProgressForTime({
|
||||||
|
time,
|
||||||
|
leftKey,
|
||||||
|
rightKey,
|
||||||
|
}: {
|
||||||
|
time: number;
|
||||||
|
leftKey: ScalarAnimationKey;
|
||||||
|
rightKey: ScalarAnimationKey;
|
||||||
|
}) {
|
||||||
|
let lower = 0;
|
||||||
|
let upper = 1;
|
||||||
|
const rightHandle =
|
||||||
|
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
|
||||||
|
const leftHandle =
|
||||||
|
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
|
||||||
|
|
||||||
|
for (let iteration = 0; iteration < BEZIER_SOLVE_ITERATIONS; iteration++) {
|
||||||
|
const mid = (lower + upper) / 2;
|
||||||
|
const estimate = getBezierPoint({
|
||||||
|
progress: mid,
|
||||||
|
p0: leftKey.time,
|
||||||
|
p1: leftKey.time + rightHandle.dt,
|
||||||
|
p2: rightKey.time + leftHandle.dt,
|
||||||
|
p3: rightKey.time,
|
||||||
|
});
|
||||||
|
if (estimate < time) {
|
||||||
|
lower = mid;
|
||||||
|
} else {
|
||||||
|
upper = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (lower + upper) / 2;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
getDefaultLeftHandle,
|
||||||
|
getDefaultRightHandle,
|
||||||
|
} from "@/animation/bezier";
|
||||||
|
import type {
|
||||||
|
CurveHandle,
|
||||||
|
NormalizedCubicBezier,
|
||||||
|
ScalarAnimationKey,
|
||||||
|
} from "@/animation/types";
|
||||||
|
import { roundMediaTime } from "@/wasm";
|
||||||
|
|
||||||
|
const VALUE_EPSILON = 1e-6;
|
||||||
|
|
||||||
|
function clamp01({ value }: { value: number }): number {
|
||||||
|
return Math.max(0, Math.min(1, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNormalizedCubicBezierForScalarSegment({
|
||||||
|
leftKey,
|
||||||
|
rightKey,
|
||||||
|
referenceSpanValue,
|
||||||
|
}: {
|
||||||
|
leftKey: ScalarAnimationKey;
|
||||||
|
rightKey: ScalarAnimationKey;
|
||||||
|
/** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */
|
||||||
|
referenceSpanValue?: number;
|
||||||
|
}): NormalizedCubicBezier | null {
|
||||||
|
const spanTime = rightKey.time - leftKey.time;
|
||||||
|
const spanValue = rightKey.value - leftKey.value;
|
||||||
|
const effectiveSpanValue =
|
||||||
|
Math.abs(spanValue) > VALUE_EPSILON
|
||||||
|
? spanValue
|
||||||
|
: referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON
|
||||||
|
? referenceSpanValue
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (spanTime === 0 || effectiveSpanValue === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightHandle =
|
||||||
|
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
|
||||||
|
const leftHandle =
|
||||||
|
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
|
||||||
|
|
||||||
|
return [
|
||||||
|
clamp01({ value: rightHandle.dt / spanTime }),
|
||||||
|
rightHandle.dv / effectiveSpanValue,
|
||||||
|
clamp01({ value: 1 + leftHandle.dt / spanTime }),
|
||||||
|
1 + leftHandle.dv / effectiveSpanValue,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurveHandlesForNormalizedCubicBezier({
|
||||||
|
leftKey,
|
||||||
|
rightKey,
|
||||||
|
cubicBezier,
|
||||||
|
referenceSpanValue,
|
||||||
|
}: {
|
||||||
|
leftKey: ScalarAnimationKey;
|
||||||
|
rightKey: ScalarAnimationKey;
|
||||||
|
cubicBezier: NormalizedCubicBezier;
|
||||||
|
/** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */
|
||||||
|
referenceSpanValue?: number;
|
||||||
|
}): {
|
||||||
|
rightHandle: CurveHandle;
|
||||||
|
leftHandle: CurveHandle;
|
||||||
|
} | null {
|
||||||
|
const spanTime = rightKey.time - leftKey.time;
|
||||||
|
const spanValue = rightKey.value - leftKey.value;
|
||||||
|
const effectiveSpanValue =
|
||||||
|
Math.abs(spanValue) > VALUE_EPSILON
|
||||||
|
? spanValue
|
||||||
|
: referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON
|
||||||
|
? referenceSpanValue
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (spanTime === 0 || effectiveSpanValue === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rawX1, y1, rawX2, y2] = cubicBezier;
|
||||||
|
const x1 = clamp01({ value: rawX1 });
|
||||||
|
const x2 = clamp01({ value: rawX2 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
rightHandle: {
|
||||||
|
dt: roundMediaTime({ time: spanTime * x1 }),
|
||||||
|
dv: effectiveSpanValue * y1,
|
||||||
|
},
|
||||||
|
leftHandle: {
|
||||||
|
dt: roundMediaTime({ time: spanTime * (x2 - 1) }),
|
||||||
|
dv: effectiveSpanValue * (y2 - 1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type {
|
||||||
|
ElementAnimations,
|
||||||
|
EffectParamPath,
|
||||||
|
} from "@/animation/types";
|
||||||
|
import type { ParamValues } from "@/params";
|
||||||
|
import { removeElementKeyframe } from "./keyframes";
|
||||||
|
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||||
|
|
||||||
|
export const EFFECT_PARAM_PATH_PREFIX = "effects.";
|
||||||
|
export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
|
||||||
|
|
||||||
|
export function buildEffectParamPath({
|
||||||
|
effectId,
|
||||||
|
paramKey,
|
||||||
|
}: {
|
||||||
|
effectId: string;
|
||||||
|
paramKey: string;
|
||||||
|
}): EffectParamPath {
|
||||||
|
return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEffectParamPath(
|
||||||
|
propertyPath: string,
|
||||||
|
): propertyPath is EffectParamPath {
|
||||||
|
return (
|
||||||
|
propertyPath.startsWith(EFFECT_PARAM_PATH_PREFIX) &&
|
||||||
|
propertyPath.includes(EFFECT_PARAM_PATH_SUFFIX)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEffectParamPath({
|
||||||
|
propertyPath,
|
||||||
|
}: {
|
||||||
|
propertyPath: string;
|
||||||
|
}): { effectId: string; paramKey: string } | null {
|
||||||
|
if (!isEffectParamPath(propertyPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withoutPrefix = propertyPath.slice(EFFECT_PARAM_PATH_PREFIX.length);
|
||||||
|
const separatorIndex = withoutPrefix.indexOf(EFFECT_PARAM_PATH_SUFFIX);
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectId = withoutPrefix.slice(0, separatorIndex);
|
||||||
|
const paramKey = withoutPrefix.slice(
|
||||||
|
separatorIndex + EFFECT_PARAM_PATH_SUFFIX.length,
|
||||||
|
);
|
||||||
|
if (!effectId || !paramKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { effectId, paramKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEffectParamsAtTime({
|
||||||
|
effectId,
|
||||||
|
params,
|
||||||
|
animations,
|
||||||
|
localTime,
|
||||||
|
}: {
|
||||||
|
effectId: string;
|
||||||
|
params: ParamValues;
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
localTime: number;
|
||||||
|
}): ParamValues {
|
||||||
|
const safeLocalTime = Math.max(0, localTime);
|
||||||
|
const resolved: ParamValues = {};
|
||||||
|
|
||||||
|
for (const [paramKey, staticValue] of Object.entries(params)) {
|
||||||
|
const path = buildEffectParamPath({ effectId, paramKey });
|
||||||
|
resolved[paramKey] = animations?.[path]
|
||||||
|
? resolveAnimationPathValueAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath: path,
|
||||||
|
localTime: safeLocalTime,
|
||||||
|
fallbackValue: staticValue,
|
||||||
|
})
|
||||||
|
: staticValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeEffectParamKeyframe({
|
||||||
|
animations,
|
||||||
|
effectId,
|
||||||
|
paramKey,
|
||||||
|
keyframeId,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
effectId: string;
|
||||||
|
paramKey: string;
|
||||||
|
keyframeId: string;
|
||||||
|
}): ElementAnimations | undefined {
|
||||||
|
return removeElementKeyframe({
|
||||||
|
animations,
|
||||||
|
propertyPath: buildEffectParamPath({ effectId, paramKey }),
|
||||||
|
keyframeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type {
|
||||||
|
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 {
|
||||||
|
easingMode: ChannelEasingMode;
|
||||||
|
channels: ScalarGraphChannel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isScalarAnimationChannel(
|
||||||
|
channel: ChannelData | undefined,
|
||||||
|
): channel is ScalarAnimationChannel {
|
||||||
|
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({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
}): EditableScalarChannels | null {
|
||||||
|
const data = animations?.[propertyPath];
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelEntries = isLeafChannelData(data)
|
||||||
|
? [["value", data] as const]
|
||||||
|
: Object.entries(data);
|
||||||
|
const channels = channelEntries.flatMap(([componentKey, channel]) => {
|
||||||
|
if (!isScalarAnimationChannel(channel)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
propertyPath,
|
||||||
|
componentKey,
|
||||||
|
channel,
|
||||||
|
} satisfies ScalarGraphChannel,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return { easingMode: getEasingModeForChannelData({ data }), channels };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEditableScalarChannel({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
componentKey,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
componentKey: string;
|
||||||
|
}): ScalarGraphChannel | null {
|
||||||
|
const result = getEditableScalarChannels({ animations, propertyPath });
|
||||||
|
return result?.channels.find((channel) => channel.componentKey === componentKey) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScalarKeyframeContext({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
componentKey,
|
||||||
|
keyframeId,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
componentKey: string;
|
||||||
|
keyframeId: string;
|
||||||
|
}): ScalarGraphKeyframeContext | null {
|
||||||
|
const scalarChannel = getEditableScalarChannel({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
componentKey,
|
||||||
|
});
|
||||||
|
if (!scalarChannel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyframeIndex = scalarChannel.channel.keys.findIndex(
|
||||||
|
(keyframe) => keyframe.id === keyframeId,
|
||||||
|
);
|
||||||
|
if (keyframeIndex < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...scalarChannel,
|
||||||
|
keyframe: scalarChannel.channel.keys[keyframeIndex],
|
||||||
|
keyframeIndex,
|
||||||
|
previousKey: scalarChannel.channel.keys[keyframeIndex - 1] ?? null,
|
||||||
|
nextKey: scalarChannel.channel.keys[keyframeIndex + 1] ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type {
|
||||||
|
ElementAnimations,
|
||||||
|
GraphicParamPath,
|
||||||
|
} from "@/animation/types";
|
||||||
|
import type { ParamDefinition, ParamValues } from "@/params";
|
||||||
|
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||||
|
|
||||||
|
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
||||||
|
|
||||||
|
export function buildGraphicParamPath({
|
||||||
|
paramKey,
|
||||||
|
}: {
|
||||||
|
paramKey: string;
|
||||||
|
}): GraphicParamPath {
|
||||||
|
return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGraphicParamPath(
|
||||||
|
propertyPath: string,
|
||||||
|
): propertyPath is GraphicParamPath {
|
||||||
|
return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGraphicParamPath({
|
||||||
|
propertyPath,
|
||||||
|
}: {
|
||||||
|
propertyPath: string;
|
||||||
|
}): { paramKey: string } | null {
|
||||||
|
if (!isGraphicParamPath(propertyPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
|
||||||
|
return paramKey.length > 0 ? { paramKey } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGraphicParamsAtTime({
|
||||||
|
params,
|
||||||
|
definitions,
|
||||||
|
animations,
|
||||||
|
localTime,
|
||||||
|
}: {
|
||||||
|
params: ParamValues;
|
||||||
|
definitions: ParamDefinition[];
|
||||||
|
animations?: ElementAnimations;
|
||||||
|
localTime: number;
|
||||||
|
}): ParamValues {
|
||||||
|
const resolved: ParamValues = { ...params };
|
||||||
|
|
||||||
|
for (const param of definitions) {
|
||||||
|
const path = buildGraphicParamPath({ paramKey: param.key });
|
||||||
|
if (!animations?.[path]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved[param.key] = resolveAnimationPathValueAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath: path,
|
||||||
|
localTime: Math.max(0, localTime),
|
||||||
|
fallbackValue: params[param.key] ?? param.default,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
@@ -1,71 +1,72 @@
|
|||||||
export {
|
export {
|
||||||
getChannelValueAtTime,
|
getChannelValueAtTime,
|
||||||
getNumberChannelValueAtTime,
|
getDiscreteChannelValueAtTime,
|
||||||
getVectorChannelValueAtTime,
|
getScalarChannelValueAtTime,
|
||||||
normalizeChannel,
|
getScalarSegmentInterpolation,
|
||||||
} from "./interpolation";
|
normalizeChannel,
|
||||||
|
} from "./interpolation";
|
||||||
export {
|
|
||||||
clampAnimationsToDuration,
|
export {
|
||||||
cloneAnimations,
|
clampAnimationsToDuration,
|
||||||
getChannel,
|
cloneAnimations,
|
||||||
removeElementKeyframe,
|
getChannel,
|
||||||
retimeElementKeyframe,
|
removeElementKeyframe,
|
||||||
setChannel,
|
retimeElementKeyframe,
|
||||||
splitAnimationsAtTime,
|
setBindingComponentChannel,
|
||||||
upsertElementKeyframe,
|
setChannel,
|
||||||
upsertPathKeyframe,
|
splitAnimationsAtTime,
|
||||||
} from "./keyframes";
|
updateScalarKeyframeCurve,
|
||||||
|
upsertPathKeyframe,
|
||||||
export {
|
} from "./keyframes";
|
||||||
getElementLocalTime,
|
|
||||||
resolveColorAtTime,
|
export {
|
||||||
resolveNumberAtTime,
|
getElementLocalTime,
|
||||||
resolveOpacityAtTime,
|
resolveAnimationPathValueAtTime,
|
||||||
resolveTransformAtTime,
|
} from "./resolve";
|
||||||
} from "./resolve";
|
|
||||||
|
export {
|
||||||
export {
|
getElementKeyframes,
|
||||||
coerceAnimationValueForProperty,
|
getKeyframeById,
|
||||||
getAnimationPropertyDefinition,
|
getKeyframeAtTime,
|
||||||
getDefaultInterpolationForProperty,
|
hasKeyframesForPath,
|
||||||
getElementBaseValueForProperty,
|
} from "./keyframe-query";
|
||||||
isAnimationPropertyPath,
|
|
||||||
supportsAnimationProperty,
|
export {
|
||||||
type AnimationPropertyDefinition,
|
type EditableScalarChannels,
|
||||||
type NumericSpec,
|
getEditableScalarChannel,
|
||||||
type NumericRange,
|
getEditableScalarChannels,
|
||||||
withElementBaseValueForProperty,
|
getScalarKeyframeContext,
|
||||||
} from "./property-registry";
|
} from "./graph-channels";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getElementKeyframes,
|
getCurveHandlesForNormalizedCubicBezier,
|
||||||
getKeyframeAtTime,
|
getNormalizedCubicBezierForScalarSegment,
|
||||||
hasKeyframesForPath,
|
} from "./curve-bridge";
|
||||||
} from "./keyframe-query";
|
|
||||||
|
export {
|
||||||
export {
|
buildGraphicParamPath,
|
||||||
buildGraphicParamPath,
|
isGraphicParamPath,
|
||||||
isGraphicParamPath,
|
parseGraphicParamPath,
|
||||||
parseGraphicParamPath,
|
resolveGraphicParamsAtTime,
|
||||||
resolveGraphicParamsAtTime,
|
} from "./graphic-param-channel";
|
||||||
} from "./graphic-param-channel";
|
|
||||||
|
export {
|
||||||
export {
|
buildEffectParamPath,
|
||||||
isAnimationPath,
|
isEffectParamPath,
|
||||||
resolveAnimationTarget,
|
parseEffectParamPath,
|
||||||
getParamValueKind,
|
removeEffectParamKeyframe,
|
||||||
getParamDefaultInterpolation,
|
resolveEffectParamsAtTime,
|
||||||
type AnimationPathDescriptor,
|
} from "./effect-param-channel";
|
||||||
} from "./target-resolver";
|
|
||||||
|
export {
|
||||||
export {
|
getGroupKeyframesAtTime,
|
||||||
getGroupKeyframesAtTime,
|
hasGroupKeyframeAtTime,
|
||||||
hasGroupKeyframeAtTime,
|
type GroupKeyframeRef,
|
||||||
type GroupKeyframeRef,
|
} from "./property-groups";
|
||||||
} from "./property-groups";
|
|
||||||
|
export {
|
||||||
export {
|
isAnimationPath,
|
||||||
getVectorChannelForPath,
|
isAnimationPropertyPath,
|
||||||
isVectorValue,
|
} from "./path";
|
||||||
} from "./vector-channel";
|
|
||||||
|
export type { NumericSpec } from "./types";
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import type {
|
||||||
|
AnimationChannel,
|
||||||
|
AnimationInterpolation,
|
||||||
|
Channel,
|
||||||
|
DiscreteAnimationChannel,
|
||||||
|
DiscreteValue,
|
||||||
|
ScalarAnimationChannel,
|
||||||
|
ScalarAnimationKey,
|
||||||
|
ScalarSegmentType,
|
||||||
|
} from "@/animation/types";
|
||||||
|
import type { ParamValue } from "@/params";
|
||||||
|
import { mediaTime } from "@/wasm";
|
||||||
|
import {
|
||||||
|
getBezierPoint,
|
||||||
|
getDefaultLeftHandle,
|
||||||
|
getDefaultRightHandle,
|
||||||
|
solveBezierProgressForTime,
|
||||||
|
} from "./bezier";
|
||||||
|
import { clamp } from "@/utils/math";
|
||||||
|
|
||||||
|
function byTimeAscending({
|
||||||
|
leftTime,
|
||||||
|
rightTime,
|
||||||
|
}: {
|
||||||
|
leftTime: number;
|
||||||
|
rightTime: number;
|
||||||
|
}): number {
|
||||||
|
return leftTime - rightTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinTimePair({
|
||||||
|
time,
|
||||||
|
leftTime,
|
||||||
|
rightTime,
|
||||||
|
}: {
|
||||||
|
time: number;
|
||||||
|
leftTime: number;
|
||||||
|
rightTime: number;
|
||||||
|
}): boolean {
|
||||||
|
return time >= leftTime && time <= rightTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerpNumber({
|
||||||
|
leftValue,
|
||||||
|
rightValue,
|
||||||
|
progress,
|
||||||
|
}: {
|
||||||
|
leftValue: number;
|
||||||
|
rightValue: number;
|
||||||
|
progress: number;
|
||||||
|
}): number {
|
||||||
|
return leftValue + (rightValue - leftValue) * progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRightHandle({
|
||||||
|
handle,
|
||||||
|
leftKey,
|
||||||
|
rightKey,
|
||||||
|
}: {
|
||||||
|
handle: ScalarAnimationKey["rightHandle"];
|
||||||
|
leftKey: ScalarAnimationKey;
|
||||||
|
rightKey: ScalarAnimationKey;
|
||||||
|
}) {
|
||||||
|
if (!handle) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = mediaTime({
|
||||||
|
ticks: Math.max(1, rightKey.time - leftKey.time),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
dt: mediaTime({
|
||||||
|
ticks: Math.min(span, Math.max(0, handle.dt)),
|
||||||
|
}),
|
||||||
|
dv: handle.dv,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLeftHandle({
|
||||||
|
handle,
|
||||||
|
leftKey,
|
||||||
|
rightKey,
|
||||||
|
}: {
|
||||||
|
handle: ScalarAnimationKey["leftHandle"];
|
||||||
|
leftKey: ScalarAnimationKey;
|
||||||
|
rightKey: ScalarAnimationKey;
|
||||||
|
}) {
|
||||||
|
if (!handle) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = mediaTime({
|
||||||
|
ticks: Math.max(1, rightKey.time - leftKey.time),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
dt: mediaTime({
|
||||||
|
ticks: Math.max(-span, Math.min(0, handle.dt)),
|
||||||
|
}),
|
||||||
|
dv: handle.dv,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScalarKey({
|
||||||
|
key,
|
||||||
|
}: {
|
||||||
|
key: ScalarAnimationKey;
|
||||||
|
}): ScalarAnimationKey {
|
||||||
|
return {
|
||||||
|
...key,
|
||||||
|
tangentMode: key.tangentMode ?? "flat",
|
||||||
|
segmentToNext: key.segmentToNext ?? "linear",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeScalarChannel({
|
||||||
|
channel,
|
||||||
|
}: {
|
||||||
|
channel: ScalarAnimationChannel;
|
||||||
|
}): ScalarAnimationChannel {
|
||||||
|
const sortedKeys = [...channel.keys]
|
||||||
|
.map((key) => normalizeScalarKey({ key }))
|
||||||
|
.sort((leftKey, rightKey) =>
|
||||||
|
byTimeAscending({
|
||||||
|
leftTime: leftKey.time,
|
||||||
|
rightTime: rightKey.time,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const nextKeys = sortedKeys.map((key, index) => {
|
||||||
|
const previousKey = sortedKeys[index - 1];
|
||||||
|
const nextKey = sortedKeys[index + 1];
|
||||||
|
return {
|
||||||
|
...key,
|
||||||
|
leftHandle:
|
||||||
|
previousKey != null
|
||||||
|
? normalizeLeftHandle({
|
||||||
|
handle: key.leftHandle,
|
||||||
|
leftKey: previousKey,
|
||||||
|
rightKey: key,
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
rightHandle:
|
||||||
|
nextKey != null
|
||||||
|
? normalizeRightHandle({
|
||||||
|
handle: key.rightHandle,
|
||||||
|
leftKey: key,
|
||||||
|
rightKey: nextKey,
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...channel,
|
||||||
|
keys: nextKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDiscreteChannel({
|
||||||
|
channel,
|
||||||
|
}: {
|
||||||
|
channel: DiscreteAnimationChannel;
|
||||||
|
}): DiscreteAnimationChannel {
|
||||||
|
return {
|
||||||
|
...channel,
|
||||||
|
keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
|
||||||
|
byTimeAscending({
|
||||||
|
leftTime: leftKeyframe.time,
|
||||||
|
rightTime: rightKeyframe.time,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
||||||
|
mode,
|
||||||
|
edgeKey,
|
||||||
|
neighborKey,
|
||||||
|
time,
|
||||||
|
}: {
|
||||||
|
mode: "hold" | "linear";
|
||||||
|
edgeKey: ScalarAnimationKey;
|
||||||
|
neighborKey: ScalarAnimationKey | undefined;
|
||||||
|
time: number;
|
||||||
|
}) {
|
||||||
|
if (mode === "hold" || !neighborKey) {
|
||||||
|
return edgeKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = neighborKey.time - edgeKey.time;
|
||||||
|
if (span === 0) {
|
||||||
|
return edgeKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScalarSegmentInterpolation({
|
||||||
|
segment,
|
||||||
|
}: {
|
||||||
|
segment: ScalarSegmentType;
|
||||||
|
}): AnimationInterpolation {
|
||||||
|
if (segment === "step") {
|
||||||
|
return "hold";
|
||||||
|
}
|
||||||
|
|
||||||
|
return segment === "bezier" ? "bezier" : "linear";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScalarChannelValueAtTime({
|
||||||
|
channel,
|
||||||
|
time,
|
||||||
|
fallbackValue,
|
||||||
|
}: {
|
||||||
|
channel: Channel<number> | undefined;
|
||||||
|
time: number;
|
||||||
|
fallbackValue: number;
|
||||||
|
}): number {
|
||||||
|
if (!channel || channel.keys.length === 0) {
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedChannel = normalizeScalarChannel({ channel });
|
||||||
|
const firstKey = normalizedChannel.keys[0];
|
||||||
|
const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
|
||||||
|
if (!firstKey || !lastKey) {
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time <= firstKey.time) {
|
||||||
|
if (time < firstKey.time) {
|
||||||
|
return extrapolateScalarEdge({
|
||||||
|
mode: normalizedChannel.extrapolation?.before ?? "hold",
|
||||||
|
edgeKey: firstKey,
|
||||||
|
neighborKey: normalizedChannel.keys[1],
|
||||||
|
time,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time >= lastKey.time) {
|
||||||
|
if (time > lastKey.time) {
|
||||||
|
return extrapolateScalarEdge({
|
||||||
|
mode: normalizedChannel.extrapolation?.after ?? "hold",
|
||||||
|
edgeKey: lastKey,
|
||||||
|
neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2],
|
||||||
|
time,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (
|
||||||
|
let keyIndex = 0;
|
||||||
|
keyIndex < normalizedChannel.keys.length - 1;
|
||||||
|
keyIndex++
|
||||||
|
) {
|
||||||
|
const leftKey = normalizedChannel.keys[keyIndex];
|
||||||
|
const rightKey = normalizedChannel.keys[keyIndex + 1];
|
||||||
|
if (time === rightKey.time) {
|
||||||
|
return rightKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isWithinTimePair({
|
||||||
|
time,
|
||||||
|
leftTime: leftKey.time,
|
||||||
|
rightTime: rightKey.time,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftKey.segmentToNext === "step") {
|
||||||
|
return leftKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = rightKey.time - leftKey.time;
|
||||||
|
if (span === 0) {
|
||||||
|
return rightKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = clamp({
|
||||||
|
value: (time - leftKey.time) / span,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
});
|
||||||
|
if (leftKey.segmentToNext === "linear") {
|
||||||
|
return lerpNumber({
|
||||||
|
leftValue: leftKey.value,
|
||||||
|
rightValue: rightKey.value,
|
||||||
|
progress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const curveProgress = solveBezierProgressForTime({
|
||||||
|
time,
|
||||||
|
leftKey,
|
||||||
|
rightKey,
|
||||||
|
});
|
||||||
|
const rightHandle =
|
||||||
|
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
|
||||||
|
const leftHandle =
|
||||||
|
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
|
||||||
|
return getBezierPoint({
|
||||||
|
progress: curveProgress,
|
||||||
|
p0: leftKey.value,
|
||||||
|
p1: leftKey.value + rightHandle.dv,
|
||||||
|
p2: rightKey.value + leftHandle.dv,
|
||||||
|
p3: rightKey.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDiscreteChannelValueAtTime({
|
||||||
|
channel,
|
||||||
|
time,
|
||||||
|
fallbackValue,
|
||||||
|
}: {
|
||||||
|
channel: Channel<DiscreteValue> | undefined;
|
||||||
|
time: number;
|
||||||
|
fallbackValue: DiscreteValue;
|
||||||
|
}): DiscreteValue {
|
||||||
|
if (!channel || channel.keys.length === 0) {
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedChannel = normalizeDiscreteChannel({ channel });
|
||||||
|
let currentValue = fallbackValue;
|
||||||
|
for (const key of normalizedChannel.keys) {
|
||||||
|
if (time < key.time) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentValue = key.value;
|
||||||
|
}
|
||||||
|
return currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChannelValueAtTime({
|
||||||
|
channel,
|
||||||
|
time,
|
||||||
|
fallbackValue,
|
||||||
|
}: {
|
||||||
|
channel: Channel<number> | undefined;
|
||||||
|
time: number;
|
||||||
|
fallbackValue: number;
|
||||||
|
}): number;
|
||||||
|
export function getChannelValueAtTime<TValue extends DiscreteValue>({
|
||||||
|
channel,
|
||||||
|
time,
|
||||||
|
fallbackValue,
|
||||||
|
}: {
|
||||||
|
channel: DiscreteAnimationChannel | undefined;
|
||||||
|
time: number;
|
||||||
|
fallbackValue: TValue;
|
||||||
|
}): TValue;
|
||||||
|
export function getChannelValueAtTime({
|
||||||
|
channel,
|
||||||
|
time,
|
||||||
|
fallbackValue,
|
||||||
|
}: {
|
||||||
|
channel: AnimationChannel | undefined;
|
||||||
|
time: number;
|
||||||
|
fallbackValue: ParamValue;
|
||||||
|
}): ParamValue {
|
||||||
|
if (!channel || channel.keys.length === 0) {
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof fallbackValue === "number") {
|
||||||
|
return isScalarChannel(channel)
|
||||||
|
? getScalarChannelValueAtTime({
|
||||||
|
channel,
|
||||||
|
time,
|
||||||
|
fallbackValue,
|
||||||
|
})
|
||||||
|
: fallbackValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDiscreteChannelValueAtTime({
|
||||||
|
channel: isScalarChannel(channel) ? undefined : channel,
|
||||||
|
time,
|
||||||
|
fallbackValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import type {
|
||||||
|
AnimationChannel,
|
||||||
|
AnimationPath,
|
||||||
|
ChannelData,
|
||||||
|
ElementAnimations,
|
||||||
|
ElementKeyframe,
|
||||||
|
} from "@/animation/types";
|
||||||
|
import { formatLinearRgba } from "@/params";
|
||||||
|
import {
|
||||||
|
getChannelEntriesFromData,
|
||||||
|
isAnimationStorageKey,
|
||||||
|
} from "./channel-data";
|
||||||
|
import {
|
||||||
|
getChannelValueAtTime,
|
||||||
|
getScalarSegmentInterpolation,
|
||||||
|
isScalarChannel,
|
||||||
|
} from "./interpolation";
|
||||||
|
import { isAnimationPath } from "./path";
|
||||||
|
|
||||||
|
function getChannelFallbackValue({
|
||||||
|
channel,
|
||||||
|
}: {
|
||||||
|
channel: AnimationChannel;
|
||||||
|
}) {
|
||||||
|
if (channel.keys.length === 0) {
|
||||||
|
return isScalarChannel(channel) ? 0 : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel.keys[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelKeyframeMatch {
|
||||||
|
componentKey: string;
|
||||||
|
componentIndex: number;
|
||||||
|
channel: AnimationChannel;
|
||||||
|
keyframe: AnimationChannel["keys"][number];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChannelKeyframeMatches({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: ChannelData | undefined;
|
||||||
|
}): ChannelKeyframeMatch[] {
|
||||||
|
return getChannelEntriesFromData({ data }).flatMap(
|
||||||
|
([componentKey, channel], componentIndex) => {
|
||||||
|
if (channel.keys.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel.keys.map((keyframe) => ({
|
||||||
|
componentKey,
|
||||||
|
componentIndex,
|
||||||
|
channel,
|
||||||
|
keyframe,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUniqueChannelKeyframeMatches({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: ChannelData | undefined;
|
||||||
|
}): ChannelKeyframeMatch[] {
|
||||||
|
const sortedMatches = getChannelKeyframeMatches({
|
||||||
|
data,
|
||||||
|
}).sort(
|
||||||
|
(leftMatch, rightMatch) =>
|
||||||
|
leftMatch.keyframe.time - rightMatch.keyframe.time ||
|
||||||
|
leftMatch.componentIndex - rightMatch.componentIndex,
|
||||||
|
);
|
||||||
|
const uniqueMatches: ChannelKeyframeMatch[] = [];
|
||||||
|
|
||||||
|
for (const match of sortedMatches) {
|
||||||
|
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
|
||||||
|
if (
|
||||||
|
!previousMatch ||
|
||||||
|
previousMatch.keyframe.time !== match.keyframe.time
|
||||||
|
) {
|
||||||
|
uniqueMatches.push(match);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
previousMatch.componentIndex !== 0 &&
|
||||||
|
match.componentIndex === 0
|
||||||
|
) {
|
||||||
|
uniqueMatches[uniqueMatches.length - 1] = match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreferredChannelKeyframeMatch({
|
||||||
|
matches,
|
||||||
|
}: {
|
||||||
|
matches: ChannelKeyframeMatch[];
|
||||||
|
}): ChannelKeyframeMatch | null {
|
||||||
|
return (
|
||||||
|
matches.find((match) => match.componentIndex === 0) ??
|
||||||
|
matches[0] ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChannelValue({
|
||||||
|
channel,
|
||||||
|
time,
|
||||||
|
}: {
|
||||||
|
channel: AnimationChannel;
|
||||||
|
time: number;
|
||||||
|
}) {
|
||||||
|
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,
|
||||||
|
}: {
|
||||||
|
channel: AnimationChannel;
|
||||||
|
keyframe: AnimationChannel["keys"][number];
|
||||||
|
}) {
|
||||||
|
return isScalarChannel(channel) && "segmentToNext" in keyframe
|
||||||
|
? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
|
||||||
|
: "hold";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toElementKeyframe({
|
||||||
|
data,
|
||||||
|
propertyPath,
|
||||||
|
keyframeMatch,
|
||||||
|
}: {
|
||||||
|
data: ChannelData | undefined;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
keyframeMatch: ChannelKeyframeMatch;
|
||||||
|
}): ElementKeyframe | null {
|
||||||
|
const value = getComposedChannelDataValueAtTime({
|
||||||
|
data,
|
||||||
|
time: keyframeMatch.keyframe.time,
|
||||||
|
});
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
propertyPath,
|
||||||
|
id: keyframeMatch.keyframe.id,
|
||||||
|
time: keyframeMatch.keyframe.time,
|
||||||
|
value,
|
||||||
|
interpolation: getKeyframeInterpolation({
|
||||||
|
channel: keyframeMatch.channel,
|
||||||
|
keyframe: keyframeMatch.keyframe,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElementKeyframes({
|
||||||
|
animations,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
}): ElementKeyframe[] {
|
||||||
|
if (!animations) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(animations).filter(([key]) =>
|
||||||
|
isAnimationStorageKey({ key }),
|
||||||
|
).flatMap(
|
||||||
|
([propertyPath, data]) => {
|
||||||
|
if (!data || !isAnimationPath(propertyPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return getUniqueChannelKeyframeMatches({
|
||||||
|
data,
|
||||||
|
}).flatMap((keyframeMatch) => {
|
||||||
|
const keyframe = toElementKeyframe({
|
||||||
|
data,
|
||||||
|
propertyPath,
|
||||||
|
keyframeMatch,
|
||||||
|
});
|
||||||
|
if (!keyframe) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [keyframe];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasKeyframesForPath({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
}): boolean {
|
||||||
|
return getChannelEntriesFromData({ data: animations?.[propertyPath] }).some(
|
||||||
|
([, channel]) => channel.keys.length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKeyframeAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
time,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
time: number;
|
||||||
|
}): ElementKeyframe | null {
|
||||||
|
const data = animations?.[propertyPath];
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyframeMatch = getPreferredChannelKeyframeMatch({
|
||||||
|
matches: getChannelKeyframeMatches({ data }).filter(
|
||||||
|
({ keyframe }) => keyframe.time === time,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!keyframeMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toElementKeyframe({
|
||||||
|
data,
|
||||||
|
propertyPath,
|
||||||
|
keyframeMatch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKeyframeById({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
keyframeId,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
keyframeId: string;
|
||||||
|
}): ElementKeyframe | null {
|
||||||
|
const data = animations?.[propertyPath];
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyframeMatch = getPreferredChannelKeyframeMatch({
|
||||||
|
matches: getChannelKeyframeMatches({ data }).filter(
|
||||||
|
({ keyframe }) => keyframe.id === keyframeId,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!keyframeMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toElementKeyframe({
|
||||||
|
data,
|
||||||
|
propertyPath,
|
||||||
|
keyframeMatch,
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
import type { AnimationPath, AnimationPropertyPath } from "@/animation/types";
|
||||||
|
import { ANIMATION_PROPERTY_PATHS } from "./types";
|
||||||
|
import { isEffectParamPath } from "./effect-param-channel";
|
||||||
|
import { isGraphicParamPath } from "./graphic-param-channel";
|
||||||
|
|
||||||
|
const ANIMATION_PROPERTY_PATH_SET = new Set<string>(ANIMATION_PROPERTY_PATHS);
|
||||||
|
|
||||||
|
export function isAnimationPropertyPath(
|
||||||
|
propertyPath: string,
|
||||||
|
): propertyPath is AnimationPropertyPath {
|
||||||
|
return ANIMATION_PROPERTY_PATH_SET.has(propertyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAnimationPath(
|
||||||
|
propertyPath: string,
|
||||||
|
): propertyPath is AnimationPath {
|
||||||
|
return (
|
||||||
|
isAnimationPropertyPath(propertyPath) ||
|
||||||
|
isGraphicParamPath(propertyPath) ||
|
||||||
|
isEffectParamPath(propertyPath)
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-2
@@ -2,8 +2,8 @@ import type {
|
|||||||
AnimationPropertyGroup,
|
AnimationPropertyGroup,
|
||||||
AnimationPropertyPath,
|
AnimationPropertyPath,
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
} from "@/lib/animation/types";
|
} from "@/animation/types";
|
||||||
import { ANIMATION_PROPERTY_GROUPS } from "@/lib/animation/types";
|
import { ANIMATION_PROPERTY_GROUPS } from "@/animation/types";
|
||||||
import { getKeyframeAtTime } from "./keyframe-query";
|
import { getKeyframeAtTime } from "./keyframe-query";
|
||||||
|
|
||||||
export interface GroupKeyframeRef {
|
export interface GroupKeyframeRef {
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import type {
|
||||||
|
AnimationPath,
|
||||||
|
ElementAnimations,
|
||||||
|
} from "@/animation/types";
|
||||||
|
import { formatLinearRgba, parseColorToLinearRgba } from "@/params";
|
||||||
|
import type { ParamValue } from "@/params";
|
||||||
|
import { isCompositeChannelData, isLeafChannelData } from "./channel-data";
|
||||||
|
import {
|
||||||
|
getChannelValueAtTime,
|
||||||
|
isScalarChannel,
|
||||||
|
} from "./interpolation";
|
||||||
|
|
||||||
|
export function getElementLocalTime({
|
||||||
|
timelineTime,
|
||||||
|
elementStartTime,
|
||||||
|
elementDuration,
|
||||||
|
}: {
|
||||||
|
timelineTime: number;
|
||||||
|
elementStartTime: number;
|
||||||
|
elementDuration: number;
|
||||||
|
}): number {
|
||||||
|
const localTime = timelineTime - elementStartTime;
|
||||||
|
if (localTime <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localTime >= elementDuration) {
|
||||||
|
return elementDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
return localTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: AnimationPath;
|
||||||
|
localTime: number;
|
||||||
|
fallbackValue: ParamValue;
|
||||||
|
}): ParamValue {
|
||||||
|
const data = animations?.[propertyPath];
|
||||||
|
if (!data) {
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
if (isLeafChannelData(data)) {
|
||||||
|
if (typeof fallbackValue === "number") {
|
||||||
|
return getChannelValueAtTime({
|
||||||
|
channel: isScalarChannel(data) ? data : undefined,
|
||||||
|
time: localTime,
|
||||||
|
fallbackValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof fallbackValue === "string" || typeof fallbackValue === "boolean") {
|
||||||
|
return getChannelValueAtTime({
|
||||||
|
channel: !isScalarChannel(data) ? data : undefined,
|
||||||
|
time: localTime,
|
||||||
|
fallbackValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
if (!isCompositeChannelData(data)) {
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof fallbackValue !== "string" ||
|
||||||
|
!("r" in data) ||
|
||||||
|
!("g" in data) ||
|
||||||
|
!("b" in data) ||
|
||||||
|
!("a" in data)
|
||||||
|
) {
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackComponents = parseColorToLinearRgba({ color: fallbackValue });
|
||||||
|
if (fallbackComponents === null) {
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatLinearRgba({
|
||||||
|
color: {
|
||||||
|
r: getChannelValueAtTime({
|
||||||
|
channel: data.r && isScalarChannel(data.r) ? data.r : undefined,
|
||||||
|
time: localTime,
|
||||||
|
fallbackValue: fallbackComponents.r,
|
||||||
|
}),
|
||||||
|
g: getChannelValueAtTime({
|
||||||
|
channel: data.g && isScalarChannel(data.g) ? data.g : undefined,
|
||||||
|
time: localTime,
|
||||||
|
fallbackValue: fallbackComponents.g,
|
||||||
|
}),
|
||||||
|
b: getChannelValueAtTime({
|
||||||
|
channel: data.b && isScalarChannel(data.b) ? data.b : undefined,
|
||||||
|
time: localTime,
|
||||||
|
fallbackValue: fallbackComponents.b,
|
||||||
|
}),
|
||||||
|
a: getChannelValueAtTime({
|
||||||
|
channel: data.a && isScalarChannel(data.a) ? data.a : undefined,
|
||||||
|
time: localTime,
|
||||||
|
fallbackValue: fallbackComponents.a,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const MIN_TRANSFORM_SCALE = 0.01;
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
import type { ParamValue } from "@/params";
|
||||||
|
|
||||||
|
export const ANIMATION_PROPERTY_PATHS = [
|
||||||
|
"transform.positionX",
|
||||||
|
"transform.positionY",
|
||||||
|
"transform.scaleX",
|
||||||
|
"transform.scaleY",
|
||||||
|
"transform.rotate",
|
||||||
|
"opacity",
|
||||||
|
"volume",
|
||||||
|
"color",
|
||||||
|
"background.color",
|
||||||
|
"background.paddingX",
|
||||||
|
"background.paddingY",
|
||||||
|
"background.offsetX",
|
||||||
|
"background.offsetY",
|
||||||
|
"background.cornerRadius",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
|
||||||
|
export type GraphicParamPath = `params.${string}`;
|
||||||
|
export type EffectParamPath = `effects.${string}.params.${string}`;
|
||||||
|
export type AnimationPath = string;
|
||||||
|
|
||||||
|
export const ANIMATION_PROPERTY_GROUPS = {
|
||||||
|
"transform.scale": ["transform.scaleX", "transform.scaleY"],
|
||||||
|
} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
|
||||||
|
|
||||||
|
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
|
||||||
|
|
||||||
|
export type DiscreteValue = boolean | string;
|
||||||
|
|
||||||
|
export interface NumericSpec {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
}
|
||||||
|
export type AnimationColorPropertyPath = Extract<
|
||||||
|
AnimationPropertyPath,
|
||||||
|
"color" | "background.color"
|
||||||
|
>;
|
||||||
|
export type AnimationNumericPropertyPath = Exclude<
|
||||||
|
AnimationPropertyPath,
|
||||||
|
AnimationColorPropertyPath
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
|
||||||
|
export type DiscreteKeyframeInterpolation = "hold";
|
||||||
|
export type AnimationInterpolation =
|
||||||
|
| ContinuousKeyframeInterpolation
|
||||||
|
| DiscreteKeyframeInterpolation;
|
||||||
|
|
||||||
|
export type ScalarSegmentType = "step" | "linear" | "bezier";
|
||||||
|
export type TangentMode = "auto" | "aligned" | "broken" | "flat";
|
||||||
|
export type ChannelExtrapolationMode = "hold" | "linear";
|
||||||
|
|
||||||
|
export interface CurveHandle {
|
||||||
|
dt: MediaTime;
|
||||||
|
dv: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseAnimationKeyframe<TValue extends ParamValue> {
|
||||||
|
id: string;
|
||||||
|
time: MediaTime; // relative to element start time
|
||||||
|
value: TValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
|
||||||
|
leftHandle?: CurveHandle;
|
||||||
|
rightHandle?: CurveHandle;
|
||||||
|
segmentToNext: ScalarSegmentType;
|
||||||
|
tangentMode: TangentMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiscreteAnimationKey = BaseAnimationKeyframe<DiscreteValue>;
|
||||||
|
|
||||||
|
export type Keyframe<TValue extends ParamValue = ParamValue> =
|
||||||
|
TValue extends number
|
||||||
|
? ScalarAnimationKey
|
||||||
|
: TValue extends DiscreteValue
|
||||||
|
? DiscreteAnimationKey
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export interface ScalarChannel {
|
||||||
|
keys: ScalarAnimationKey[];
|
||||||
|
extrapolation?: {
|
||||||
|
before: ChannelExtrapolationMode;
|
||||||
|
after: ChannelExtrapolationMode;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscreteChannel {
|
||||||
|
keys: DiscreteAnimationKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Channel<TValue extends ParamValue = ParamValue> =
|
||||||
|
TValue extends number
|
||||||
|
? ScalarChannel
|
||||||
|
: TValue extends DiscreteValue
|
||||||
|
? DiscreteChannel
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type ScalarAnimationChannel = Channel<number>;
|
||||||
|
export type DiscreteAnimationChannel = Channel<DiscreteValue>;
|
||||||
|
export type AnimationChannel = Channel;
|
||||||
|
|
||||||
|
export type CompositeChannelData = Record<string, AnimationChannel | undefined>;
|
||||||
|
export type ChannelData = AnimationChannel | CompositeChannelData;
|
||||||
|
|
||||||
|
export interface ElementAnimations {
|
||||||
|
[propertyPath: AnimationPath]: ChannelData | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NormalizedCubicBezier = [number, number, number, number];
|
||||||
|
|
||||||
|
export interface ScalarGraphChannelTarget {
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
componentKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScalarGraphChannel extends ScalarGraphChannelTarget {
|
||||||
|
channel: ScalarAnimationChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScalarGraphKeyframeRef extends ScalarGraphChannelTarget {
|
||||||
|
keyframeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScalarGraphKeyframeContext extends ScalarGraphChannel {
|
||||||
|
keyframe: ScalarAnimationKey;
|
||||||
|
keyframeIndex: number;
|
||||||
|
previousKey: ScalarAnimationKey | null;
|
||||||
|
nextKey: ScalarAnimationKey | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScalarCurveKeyframePatch {
|
||||||
|
leftHandle?: CurveHandle | null;
|
||||||
|
rightHandle?: CurveHandle | null;
|
||||||
|
segmentToNext?: ScalarSegmentType;
|
||||||
|
tangentMode?: TangentMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElementKeyframe {
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
id: string;
|
||||||
|
time: MediaTime;
|
||||||
|
value: ParamValue;
|
||||||
|
interpolation: AnimationInterpolation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectedKeyframeRef {
|
||||||
|
trackId: string;
|
||||||
|
elementId: string;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
keyframeId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type {
|
||||||
|
AnimationColorPropertyPath,
|
||||||
|
AnimationNumericPropertyPath,
|
||||||
|
ElementAnimations,
|
||||||
|
} from "./types";
|
||||||
|
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||||
|
|
||||||
|
export function resolveOpacityAtTime({
|
||||||
|
baseOpacity,
|
||||||
|
animations,
|
||||||
|
localTime,
|
||||||
|
}: {
|
||||||
|
baseOpacity: number;
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
localTime: number;
|
||||||
|
}): number {
|
||||||
|
return resolveAnimationPathValueAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath: "opacity",
|
||||||
|
localTime: Math.max(0, localTime),
|
||||||
|
fallbackValue: baseOpacity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNumberAtTime({
|
||||||
|
baseValue,
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
localTime,
|
||||||
|
}: {
|
||||||
|
baseValue: number;
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationNumericPropertyPath;
|
||||||
|
localTime: number;
|
||||||
|
}): number {
|
||||||
|
return resolveAnimationPathValueAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
localTime: Math.max(0, localTime),
|
||||||
|
fallbackValue: baseValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveColorAtTime({
|
||||||
|
baseColor,
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
localTime,
|
||||||
|
}: {
|
||||||
|
baseColor: string;
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationColorPropertyPath;
|
||||||
|
localTime: number;
|
||||||
|
}): string {
|
||||||
|
return resolveAnimationPathValueAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
localTime: Math.max(0, localTime),
|
||||||
|
fallbackValue: baseColor,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/auth/server";
|
||||||
import { toNextJsHandler } from "better-auth/next-js";
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
|
||||||
export const { POST, GET } = toNextJsHandler(auth);
|
export const { POST, GET } = toNextJsHandler(auth);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { checkRateLimit } from "@/auth/rate-limit";
|
||||||
|
import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/feedback";
|
||||||
|
|
||||||
|
const submitSchema = z.object({
|
||||||
|
message: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Message is required")
|
||||||
|
.max(MAX_MESSAGE_LENGTH, "Message too long"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const { limited } = await checkRateLimit({ request });
|
||||||
|
if (limited) {
|
||||||
|
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const result = submitSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid input", details: result.error.flatten().fieldErrors },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await submitFeedback(result.data);
|
||||||
|
return NextResponse.json({ entry }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -1,280 +1,280 @@
|
|||||||
import { webEnv } from "@opencut/env/web";
|
import { webEnv } from "@/env/web";
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { checkRateLimit } from "@/lib/rate-limit";
|
import { checkRateLimit } from "@/auth/rate-limit";
|
||||||
|
|
||||||
const searchParamsSchema = z.object({
|
const searchParamsSchema = z.object({
|
||||||
q: z.string().max(500, "Query too long").optional(),
|
q: z.string().max(500, "Query too long").optional(),
|
||||||
type: z.enum(["songs", "effects"]).optional(),
|
type: z.enum(["songs", "effects"]).optional(),
|
||||||
page: z.coerce.number().int().min(1).max(1000).default(1),
|
page: z.coerce.number().int().min(1).max(1000).default(1),
|
||||||
page_size: z.coerce.number().int().min(1).max(150).default(20),
|
page_size: z.coerce.number().int().min(1).max(150).default(20),
|
||||||
sort: z
|
sort: z
|
||||||
.enum(["downloads", "rating", "created", "score"])
|
.enum(["downloads", "rating", "created", "score"])
|
||||||
.default("downloads"),
|
.default("downloads"),
|
||||||
min_rating: z.coerce.number().min(0).max(5).default(3),
|
min_rating: z.coerce.number().min(0).max(5).default(3),
|
||||||
commercial_only: z.coerce.boolean().default(true),
|
commercial_only: z.coerce.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
const freesoundResultSchema = z.object({
|
const freesoundResultSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
url: z.string().url(),
|
url: z.string().url(),
|
||||||
previews: z
|
previews: z
|
||||||
.object({
|
.object({
|
||||||
"preview-hq-mp3": z.string().url(),
|
"preview-hq-mp3": z.string().url(),
|
||||||
"preview-lq-mp3": z.string().url(),
|
"preview-lq-mp3": z.string().url(),
|
||||||
"preview-hq-ogg": z.string().url(),
|
"preview-hq-ogg": z.string().url(),
|
||||||
"preview-lq-ogg": z.string().url(),
|
"preview-lq-ogg": z.string().url(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
download: z.string().url().optional(),
|
download: z.string().url().optional(),
|
||||||
duration: z.number(),
|
duration: z.number(),
|
||||||
filesize: z.number(),
|
filesize: z.number(),
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
channels: z.number(),
|
channels: z.number(),
|
||||||
bitrate: z.number(),
|
bitrate: z.number(),
|
||||||
bitdepth: z.number(),
|
bitdepth: z.number(),
|
||||||
samplerate: z.number(),
|
samplerate: z.number(),
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
tags: z.array(z.string()),
|
tags: z.array(z.string()),
|
||||||
license: z.string(),
|
license: z.string(),
|
||||||
created: z.string(),
|
created: z.string(),
|
||||||
num_downloads: z.number().optional(),
|
num_downloads: z.number().optional(),
|
||||||
avg_rating: z.number().optional(),
|
avg_rating: z.number().optional(),
|
||||||
num_ratings: z.number().optional(),
|
num_ratings: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const freesoundResponseSchema = z.object({
|
const freesoundResponseSchema = z.object({
|
||||||
count: z.number(),
|
count: z.number(),
|
||||||
next: z.string().url().nullable(),
|
next: z.string().url().nullable(),
|
||||||
previous: z.string().url().nullable(),
|
previous: z.string().url().nullable(),
|
||||||
results: z.array(freesoundResultSchema),
|
results: z.array(freesoundResultSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const transformedResultSchema = z.object({
|
const transformedResultSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
previewUrl: z.string().optional(),
|
previewUrl: z.string().optional(),
|
||||||
downloadUrl: z.string().optional(),
|
downloadUrl: z.string().optional(),
|
||||||
duration: z.number(),
|
duration: z.number(),
|
||||||
filesize: z.number(),
|
filesize: z.number(),
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
channels: z.number(),
|
channels: z.number(),
|
||||||
bitrate: z.number(),
|
bitrate: z.number(),
|
||||||
bitdepth: z.number(),
|
bitdepth: z.number(),
|
||||||
samplerate: z.number(),
|
samplerate: z.number(),
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
tags: z.array(z.string()),
|
tags: z.array(z.string()),
|
||||||
license: z.string(),
|
license: z.string(),
|
||||||
created: z.string(),
|
created: z.string(),
|
||||||
downloads: z.number().optional(),
|
downloads: z.number().optional(),
|
||||||
rating: z.number().optional(),
|
rating: z.number().optional(),
|
||||||
ratingCount: z.number().optional(),
|
ratingCount: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const apiResponseSchema = z.object({
|
const apiResponseSchema = z.object({
|
||||||
count: z.number(),
|
count: z.number(),
|
||||||
next: z.string().nullable(),
|
next: z.string().nullable(),
|
||||||
previous: z.string().nullable(),
|
previous: z.string().nullable(),
|
||||||
results: z.array(transformedResultSchema),
|
results: z.array(transformedResultSchema),
|
||||||
query: z.string().optional(),
|
query: z.string().optional(),
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
page: z.number(),
|
page: z.number(),
|
||||||
pageSize: z.number(),
|
pageSize: z.number(),
|
||||||
sort: z.string(),
|
sort: z.string(),
|
||||||
minRating: z.number().optional(),
|
minRating: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
|
function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
|
||||||
if (!query) return `${sort}_desc`;
|
if (!query) return `${sort}_desc`;
|
||||||
return sort === "score" ? "score" : `${sort}_desc`;
|
return sort === "score" ? "score" : `${sort}_desc`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyEffectsFilters({
|
function applyEffectsFilters({
|
||||||
params,
|
params,
|
||||||
min_rating,
|
min_rating,
|
||||||
commercial_only,
|
commercial_only,
|
||||||
}: {
|
}: {
|
||||||
params: URLSearchParams;
|
params: URLSearchParams;
|
||||||
min_rating: number;
|
min_rating: number;
|
||||||
commercial_only: boolean;
|
commercial_only: boolean;
|
||||||
}) {
|
}) {
|
||||||
params.append("filter", "duration:[* TO 30.0]");
|
params.append("filter", "duration:[* TO 30.0]");
|
||||||
params.append("filter", `avg_rating:[${min_rating} TO *]`);
|
params.append("filter", `avg_rating:[${min_rating} TO *]`);
|
||||||
|
|
||||||
if (commercial_only) {
|
if (commercial_only) {
|
||||||
params.append(
|
params.append(
|
||||||
"filter",
|
"filter",
|
||||||
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
|
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
params.append(
|
params.append(
|
||||||
"filter",
|
"filter",
|
||||||
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
|
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformFreesoundResult(
|
function transformFreesoundResult(
|
||||||
result: z.infer<typeof freesoundResultSchema>,
|
result: z.infer<typeof freesoundResultSchema>,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
id: result.id,
|
id: result.id,
|
||||||
name: result.name,
|
name: result.name,
|
||||||
description: result.description,
|
description: result.description,
|
||||||
url: result.url,
|
url: result.url,
|
||||||
previewUrl:
|
previewUrl:
|
||||||
result.previews?.["preview-hq-mp3"] ||
|
result.previews?.["preview-hq-mp3"] ||
|
||||||
result.previews?.["preview-lq-mp3"],
|
result.previews?.["preview-lq-mp3"],
|
||||||
downloadUrl: result.download,
|
downloadUrl: result.download,
|
||||||
duration: result.duration,
|
duration: result.duration,
|
||||||
filesize: result.filesize,
|
filesize: result.filesize,
|
||||||
type: result.type,
|
type: result.type,
|
||||||
channels: result.channels,
|
channels: result.channels,
|
||||||
bitrate: result.bitrate,
|
bitrate: result.bitrate,
|
||||||
bitdepth: result.bitdepth,
|
bitdepth: result.bitdepth,
|
||||||
samplerate: result.samplerate,
|
samplerate: result.samplerate,
|
||||||
username: result.username,
|
username: result.username,
|
||||||
tags: result.tags,
|
tags: result.tags,
|
||||||
license: result.license,
|
license: result.license,
|
||||||
created: result.created,
|
created: result.created,
|
||||||
downloads: result.num_downloads || 0,
|
downloads: result.num_downloads || 0,
|
||||||
rating: result.avg_rating || 0,
|
rating: result.avg_rating || 0,
|
||||||
ratingCount: result.num_ratings || 0,
|
ratingCount: result.num_ratings || 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { limited } = await checkRateLimit({ request });
|
const { limited } = await checkRateLimit({ request });
|
||||||
if (limited) {
|
if (limited) {
|
||||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
const validationResult = searchParamsSchema.safeParse({
|
const validationResult = searchParamsSchema.safeParse({
|
||||||
q: searchParams.get("q") || undefined,
|
q: searchParams.get("q") || undefined,
|
||||||
type: searchParams.get("type") || undefined,
|
type: searchParams.get("type") || undefined,
|
||||||
page: searchParams.get("page") || undefined,
|
page: searchParams.get("page") || undefined,
|
||||||
page_size: searchParams.get("page_size") || undefined,
|
page_size: searchParams.get("page_size") || undefined,
|
||||||
sort: searchParams.get("sort") || undefined,
|
sort: searchParams.get("sort") || undefined,
|
||||||
min_rating: searchParams.get("min_rating") || undefined,
|
min_rating: searchParams.get("min_rating") || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validationResult.success) {
|
if (!validationResult.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: "Invalid parameters",
|
error: "Invalid parameters",
|
||||||
details: validationResult.error.flatten().fieldErrors,
|
details: validationResult.error.flatten().fieldErrors,
|
||||||
},
|
},
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
q: query,
|
q: query,
|
||||||
type,
|
type,
|
||||||
page,
|
page,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
sort,
|
sort,
|
||||||
min_rating,
|
min_rating,
|
||||||
commercial_only,
|
commercial_only,
|
||||||
} = validationResult.data;
|
} = validationResult.data;
|
||||||
|
|
||||||
if (type === "songs") {
|
if (type === "songs") {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: "Songs are not available yet",
|
error: "Songs are not available yet",
|
||||||
message:
|
message:
|
||||||
"Song search functionality is coming soon. Try searching for sound effects instead.",
|
"Song search functionality is coming soon. Try searching for sound effects instead.",
|
||||||
},
|
},
|
||||||
{ status: 501 },
|
{ status: 501 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = "https://freesound.org/apiv2/search/text/";
|
const baseUrl = "https://freesound.org/apiv2/search/text/";
|
||||||
|
|
||||||
const sortParam = buildSortParameter({ query, sort });
|
const sortParam = buildSortParameter({ query, sort });
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
query: query || "",
|
query: query || "",
|
||||||
token: webEnv.FREESOUND_API_KEY,
|
token: webEnv.FREESOUND_API_KEY,
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
page_size: pageSize.toString(),
|
page_size: pageSize.toString(),
|
||||||
sort: sortParam,
|
sort: sortParam,
|
||||||
fields:
|
fields:
|
||||||
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
|
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
|
||||||
});
|
});
|
||||||
|
|
||||||
const isEffectsSearch = type === "effects" || !type;
|
const isEffectsSearch = type === "effects" || !type;
|
||||||
if (isEffectsSearch) {
|
if (isEffectsSearch) {
|
||||||
applyEffectsFilters({ params, min_rating, commercial_only });
|
applyEffectsFilters({ params, min_rating, commercial_only });
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}?${params.toString()}`);
|
const response = await fetch(`${baseUrl}?${params.toString()}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
console.error("Freesound API error:", response.status, errorText);
|
console.error("Freesound API error:", response.status, errorText);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to search sounds" },
|
{ error: "Failed to search sounds" },
|
||||||
{ status: response.status },
|
{ status: response.status },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawData = await response.json();
|
const rawData = await response.json();
|
||||||
|
|
||||||
const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
|
const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
|
||||||
if (!freesoundValidation.success) {
|
if (!freesoundValidation.success) {
|
||||||
console.error(
|
console.error(
|
||||||
"Invalid Freesound API response:",
|
"Invalid Freesound API response:",
|
||||||
freesoundValidation.error,
|
freesoundValidation.error,
|
||||||
);
|
);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Invalid response from Freesound API" },
|
{ error: "Invalid response from Freesound API" },
|
||||||
{ status: 502 },
|
{ status: 502 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = freesoundValidation.data;
|
const data = freesoundValidation.data;
|
||||||
|
|
||||||
const transformedResults = data.results.map(transformFreesoundResult);
|
const transformedResults = data.results.map(transformFreesoundResult);
|
||||||
|
|
||||||
const responseData = {
|
const responseData = {
|
||||||
count: data.count,
|
count: data.count,
|
||||||
next: data.next,
|
next: data.next,
|
||||||
previous: data.previous,
|
previous: data.previous,
|
||||||
results: transformedResults,
|
results: transformedResults,
|
||||||
query: query || "",
|
query: query || "",
|
||||||
type: type || "effects",
|
type: type || "effects",
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
sort,
|
sort,
|
||||||
minRating: min_rating,
|
minRating: min_rating,
|
||||||
};
|
};
|
||||||
|
|
||||||
const responseValidation = apiResponseSchema.safeParse(responseData);
|
const responseValidation = apiResponseSchema.safeParse(responseData);
|
||||||
if (!responseValidation.success) {
|
if (!responseValidation.success) {
|
||||||
console.error(
|
console.error(
|
||||||
"Invalid API response structure:",
|
"Invalid API response structure:",
|
||||||
responseValidation.error,
|
responseValidation.error,
|
||||||
);
|
);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal response formatting error" },
|
{ error: "Internal response formatting error" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(responseValidation.data);
|
return NextResponse.json(responseValidation.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error searching sounds:", error);
|
console.error("Error searching sounds:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal server error" },
|
{ error: "Internal server error" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { notFound } from "next/navigation";
|
|||||||
import { BasePage } from "@/app/base-page";
|
import { BasePage } from "@/app/base-page";
|
||||||
import Prose from "@/components/ui/prose";
|
import Prose from "@/components/ui/prose";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
|
import { getPosts, getSinglePost, processHtmlContent } from "@/blog/query";
|
||||||
import type { Author, Post } from "@/lib/blog/types";
|
import type { Author, Post } from "@/blog/types";
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BasePage } from "@/app/base-page";
|
import { BasePage } from "@/app/base-page";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { getPosts } from "@/lib/blog/query";
|
import { getPosts } from "@/blog/query";
|
||||||
import type { Post } from "@/lib/blog/types";
|
import type { Post } from "@/blog/types";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Blog - OpenCut",
|
title: "Blog - OpenCut",
|
||||||
|
|||||||
+268
-268
@@ -1,268 +1,268 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Check, Copy, Download } from "lucide-react";
|
import { Check, Copy, Download } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { BasePage } from "@/app/base-page";
|
import { BasePage } from "@/app/base-page";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
|
||||||
function downloadAsset(src: string) {
|
function downloadAsset(src: string) {
|
||||||
const filename = src.split("/").pop() ?? "asset.svg";
|
const filename = src.split("/").pop() ?? "asset.svg";
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = src;
|
a.href = src;
|
||||||
a.download = filename;
|
a.download = filename;
|
||||||
a.click();
|
a.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyAsset(src: string) {
|
async function copyAsset(src: string) {
|
||||||
const res = await fetch(src);
|
const res = await fetch(src);
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
|
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
|
||||||
|
|
||||||
type AssetTheme = "dark" | "light" | "icon";
|
type AssetTheme = "dark" | "light" | "icon";
|
||||||
|
|
||||||
interface AssetVariant {
|
interface AssetVariant {
|
||||||
src: string;
|
src: string;
|
||||||
theme: AssetTheme;
|
theme: AssetTheme;
|
||||||
label: string;
|
label: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AssetSection {
|
interface AssetSection {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
cols: "1" | "2";
|
cols: "1" | "2";
|
||||||
assets: AssetVariant[];
|
assets: AssetVariant[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ASSET_SECTIONS: AssetSection[] = [
|
const ASSET_SECTIONS: AssetSection[] = [
|
||||||
{
|
{
|
||||||
title: "Symbol",
|
title: "Symbol",
|
||||||
description:
|
description:
|
||||||
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
|
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
|
||||||
cols: "2",
|
cols: "2",
|
||||||
assets: [
|
assets: [
|
||||||
{
|
{
|
||||||
src: "/logos/opencut/symbol.svg",
|
src: "/logos/opencut/symbol.svg",
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
label: "Symbol",
|
label: "Symbol",
|
||||||
width: 400,
|
width: 400,
|
||||||
height: 400,
|
height: 400,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "/logos/opencut/symbol-light.svg",
|
src: "/logos/opencut/symbol-light.svg",
|
||||||
theme: "light",
|
theme: "light",
|
||||||
label: "Symbol",
|
label: "Symbol",
|
||||||
width: 400,
|
width: 400,
|
||||||
height: 400,
|
height: 400,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Lockup",
|
title: "Lockup",
|
||||||
description:
|
description:
|
||||||
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
|
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
|
||||||
cols: "2",
|
cols: "2",
|
||||||
assets: [
|
assets: [
|
||||||
{
|
{
|
||||||
src: "/logos/opencut/logo.svg",
|
src: "/logos/opencut/logo.svg",
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
label: "Logo",
|
label: "Logo",
|
||||||
width: 1809,
|
width: 1809,
|
||||||
height: 400,
|
height: 400,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "/logos/opencut/logo-light.svg",
|
src: "/logos/opencut/logo-light.svg",
|
||||||
theme: "light",
|
theme: "light",
|
||||||
label: "Logo",
|
label: "Logo",
|
||||||
width: 1809,
|
width: 1809,
|
||||||
height: 400,
|
height: 400,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "/logos/opencut/text.svg",
|
src: "/logos/opencut/text.svg",
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
label: "Text",
|
label: "Text",
|
||||||
width: 1760,
|
width: 1760,
|
||||||
height: 400,
|
height: 400,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "/logos/opencut/text-light.svg",
|
src: "/logos/opencut/text-light.svg",
|
||||||
theme: "light",
|
theme: "light",
|
||||||
label: "Text",
|
label: "Text",
|
||||||
width: 1760,
|
width: 1760,
|
||||||
height: 400,
|
height: 400,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function BrandPage() {
|
export default function BrandPage() {
|
||||||
return (
|
return (
|
||||||
<BasePage
|
<BasePage
|
||||||
maxWidth="6xl"
|
maxWidth="6xl"
|
||||||
title="Brand"
|
title="Brand"
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
Download OpenCut brand assets for use in your projects.{" "}
|
Download OpenCut brand assets for use in your projects.{" "}
|
||||||
<Link
|
<Link
|
||||||
href="#guidelines"
|
href="#guidelines"
|
||||||
className="underline underline-offset-4"
|
className="underline underline-offset-4"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
document
|
document
|
||||||
.getElementById("guidelines")
|
.getElementById("guidelines")
|
||||||
?.scrollIntoView({ behavior: "smooth" })
|
?.scrollIntoView({ behavior: "smooth" })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Read the brand guidelines.
|
Read the brand guidelines.
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="mx-auto gap-2"
|
className="mx-auto gap-2"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
ALL_ASSETS().forEach((asset, i) => {
|
ALL_ASSETS().forEach((asset, i) => {
|
||||||
setTimeout(() => downloadAsset(asset.src), i * 200);
|
setTimeout(() => downloadAsset(asset.src), i * 200);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Download />
|
<Download />
|
||||||
Download all
|
Download all
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-10">
|
<div className="flex flex-col gap-10">
|
||||||
{ASSET_SECTIONS.map((section) => (
|
{ASSET_SECTIONS.map((section) => (
|
||||||
<div key={section.title} className="flex flex-col gap-4">
|
<div key={section.title} className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h2 className="font-semibold text-lg">{section.title}</h2>
|
<h2 className="font-semibold text-lg">{section.title}</h2>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{section.description}
|
{section.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid gap-3",
|
"grid gap-3",
|
||||||
section.cols === "2"
|
section.cols === "2"
|
||||||
? "grid-cols-1 sm:grid-cols-2"
|
? "grid-cols-1 sm:grid-cols-2"
|
||||||
: "grid-cols-1",
|
: "grid-cols-1",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{section.assets.map((variant) => (
|
{section.assets.map((variant) => (
|
||||||
<AssetCard key={variant.src} variant={variant} />
|
<AssetCard key={variant.src} variant={variant} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div id="guidelines" className="flex flex-col gap-8 text-sm">
|
<div id="guidelines" className="flex flex-col gap-8 text-sm">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h2 className="font-semibold text-lg">Usage</h2>
|
<h2 className="font-semibold text-lg">Usage</h2>
|
||||||
<p className="text-muted-foreground text-base leading-relaxed">
|
<p className="text-muted-foreground text-base leading-relaxed">
|
||||||
OpenCut is open source — the code is free to use under its license.
|
OpenCut is open source — the code is free to use under its license.
|
||||||
That license does not cover the name or logo. You can say you use
|
That license does not cover the name or logo. You can say you use
|
||||||
OpenCut, that your project integrates with OpenCut, or that it was
|
OpenCut, that your project integrates with OpenCut, or that it was
|
||||||
built on top of OpenCut. You cannot name your product OpenCut, imply
|
built on top of OpenCut. You cannot name your product OpenCut, imply
|
||||||
we made or endorse your product, or use the marks commercially
|
we made or endorse your product, or use the marks commercially
|
||||||
without asking first. For anything unclear, reach out at{" "}
|
without asking first. For anything unclear, reach out at{" "}
|
||||||
<Link
|
<Link
|
||||||
href="mailto:brand@opencut.app"
|
href="mailto:brand@opencut.app"
|
||||||
className="underline underline-offset-4"
|
className="underline underline-offset-4"
|
||||||
>
|
>
|
||||||
brand@opencut.app
|
brand@opencut.app
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
||||||
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
|
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
|
||||||
{[
|
{[
|
||||||
"Using OpenCut in the name of your product, service, or domain.",
|
"Using OpenCut in the name of your product, service, or domain.",
|
||||||
"Implying that OpenCut made, sponsors, or endorses your work.",
|
"Implying that OpenCut made, sponsors, or endorses your work.",
|
||||||
"Using the logo or name on merchandise or commercial marketing.",
|
"Using the logo or name on merchandise or commercial marketing.",
|
||||||
"Modifying the marks.",
|
"Modifying the marks.",
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<li key={item} className="flex gap-2">
|
<li key={item} className="flex gap-2">
|
||||||
<span className="mt-0.5 shrink-0">-</span>
|
<span className="mt-0.5 shrink-0">-</span>
|
||||||
<span>{item}</span>
|
<span>{item}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BasePage>
|
</BasePage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
|
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
|
||||||
light: {
|
light: {
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
"linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
|
"linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
|
||||||
backgroundSize: "18px 18px",
|
backgroundSize: "18px 18px",
|
||||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
"linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
|
"linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
|
||||||
backgroundSize: "18px 18px",
|
backgroundSize: "18px 18px",
|
||||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||||
backgroundColor: "#f5f5f5",
|
backgroundColor: "#f5f5f5",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function AssetCard({ variant }: { variant: AssetVariant }) {
|
function AssetCard({ variant }: { variant: AssetVariant }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
await copyAsset(variant.src);
|
await copyAsset(variant.src);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="group relative overflow-hidden"
|
className="group relative overflow-hidden"
|
||||||
style={
|
style={
|
||||||
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
|
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex h-56 items-center justify-center px-12 py-8">
|
<div className="flex h-56 items-center justify-center px-12 py-8">
|
||||||
<Image
|
<Image
|
||||||
src={variant.src}
|
src={variant.src}
|
||||||
alt={variant.label}
|
alt={variant.label}
|
||||||
width={variant.width}
|
width={variant.width}
|
||||||
height={variant.height}
|
height={variant.height}
|
||||||
className="max-h-16 w-auto select-none object-contain"
|
className="max-h-16 w-auto select-none object-contain"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
|
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
>
|
>
|
||||||
{copied ? <Check /> : <Copy />}
|
{copied ? <Check /> : <Copy />}
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,27 +2,26 @@ import type { Metadata } from "next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { BasePage } from "@/app/base-page";
|
import { BasePage } from "@/app/base-page";
|
||||||
import { allChangelogs } from "content-collections";
|
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||||
import { getSortedReleases } from "../utils";
|
import { getReleaseByVersion, getSortedReleases } from "@/changelog/utils";
|
||||||
import {
|
import {
|
||||||
ReleaseArticle,
|
ReleaseArticle,
|
||||||
ReleaseMeta,
|
ReleaseMeta,
|
||||||
ReleaseTitle,
|
ReleaseTitle,
|
||||||
ReleaseDescription,
|
ReleaseDescription,
|
||||||
ReleaseChanges,
|
ReleaseChanges,
|
||||||
} from "../components/release";
|
} from "@/changelog/components/release";
|
||||||
import { CopyMarkdownButton } from "../components/copy-markdown-button";
|
import { CopyMarkdownButton } from "@/changelog/components/copy-markdown-button";
|
||||||
|
|
||||||
type Props = { params: Promise<{ version: string }> };
|
type Props = { params: Promise<{ version: string }> };
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
return allChangelogs.map((r) => ({ version: r.version }));
|
return getSortedReleases().map((release) => ({ version: release.version }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
const { version } = await params;
|
const { version } = await params;
|
||||||
const release = allChangelogs.find((r) => r.version === version);
|
const release = getReleaseByVersion({ version });
|
||||||
if (!release) return {};
|
if (!release) return {};
|
||||||
return {
|
return {
|
||||||
title: `${release.title} (${release.version}) - OpenCut Changelog`,
|
title: `${release.title} (${release.version}) - OpenCut Changelog`,
|
||||||
@@ -33,10 +32,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
export default async function ReleaseDetailPage({ params }: Props) {
|
export default async function ReleaseDetailPage({ params }: Props) {
|
||||||
const { version } = await params;
|
const { version } = await params;
|
||||||
const releases = getSortedReleases();
|
const releases = getSortedReleases();
|
||||||
const index = releases.findIndex((r) => r.version === version);
|
const index = releases.findIndex((entry) => entry.version === version);
|
||||||
|
|
||||||
if (index === -1) notFound();
|
if (index === -1) notFound();
|
||||||
|
|
||||||
const release = releases[index];
|
const release = releases[index];
|
||||||
const newer = index > 0 ? releases[index - 1] : null;
|
const newer = index > 0 ? releases[index - 1] : null;
|
||||||
const older = index < releases.length - 1 ? releases[index + 1] : null;
|
const older = index < releases.length - 1 ? releases[index + 1] : null;
|
||||||
@@ -52,20 +49,20 @@ export default async function ReleaseDetailPage({ params }: Props) {
|
|||||||
All releases
|
All releases
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<ReleaseArticle variant="detail">
|
<ReleaseArticle variant="detail">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<ReleaseMeta release={release} />
|
<ReleaseMeta release={release} />
|
||||||
<CopyMarkdownButton
|
<CopyMarkdownButton
|
||||||
description={release.description}
|
description={release.description}
|
||||||
changes={release.changes}
|
changes={release.changes}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
|
||||||
|
{release.description && (
|
||||||
|
<ReleaseDescription>{release.description}</ReleaseDescription>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
|
|
||||||
{release.description && (
|
|
||||||
<ReleaseDescription>{release.description}</ReleaseDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ReleaseChanges release={release} />
|
<ReleaseChanges release={release} />
|
||||||
</ReleaseArticle>
|
</ReleaseArticle>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { BasePage } from "@/app/base-page";
|
import { BasePage } from "@/app/base-page";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { type Release as ReleaseType, getSortedReleases } from "./utils";
|
import {
|
||||||
|
type Release as ReleaseType,
|
||||||
|
getSortedReleases,
|
||||||
|
} from "@/changelog/utils";
|
||||||
import {
|
import {
|
||||||
ReleaseArticle,
|
ReleaseArticle,
|
||||||
ReleaseMeta,
|
ReleaseMeta,
|
||||||
ReleaseTitle,
|
ReleaseTitle,
|
||||||
ReleaseDescription,
|
ReleaseDescription,
|
||||||
ReleaseChanges,
|
ReleaseChanges,
|
||||||
} from "./components/release";
|
} from "@/changelog/components/release";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Changelog - OpenCut",
|
title: "Changelog - OpenCut",
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { allChangelogs } from "content-collections";
|
|
||||||
|
|
||||||
export type Change = { type: string; text: string };
|
|
||||||
export type Release = (typeof allChangelogs)[number];
|
|
||||||
|
|
||||||
const knownSectionOrder = ["new", "improved", "fixed", "breaking"];
|
|
||||||
|
|
||||||
const knownSectionTitles: Record<string, string> = {
|
|
||||||
new: "Features",
|
|
||||||
improved: "Improvements",
|
|
||||||
fixed: "Fixes",
|
|
||||||
breaking: "Breaking Changes",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getSectionTitle(type: string): string {
|
|
||||||
return (
|
|
||||||
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
|
|
||||||
const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
|
|
||||||
if (!acc[change.type]) acc[change.type] = [];
|
|
||||||
acc[change.type].push(change);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const customTypes = Object.keys(grouped).filter(
|
|
||||||
(type) => !knownSectionOrder.includes(type),
|
|
||||||
);
|
|
||||||
const orderedTypes = [
|
|
||||||
...knownSectionOrder.filter((type) => grouped[type]?.length > 0),
|
|
||||||
...customTypes,
|
|
||||||
];
|
|
||||||
|
|
||||||
return { grouped, orderedTypes };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSortedReleases() {
|
|
||||||
return [...allChangelogs].sort((a, b) =>
|
|
||||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
|||||||
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
|
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { EXTERNAL_TOOLS } from "@/constants/site-constants";
|
import { EXTERNAL_TOOLS } from "@/site/external-tools";
|
||||||
import { BasePage } from "../base-page";
|
import { BasePage } from "../base-page";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
|||||||
@@ -1,113 +1,209 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
import { AssetsPanel } from "@/components/editor/panels/assets";
|
import { AssetsPanel } from "@/components/editor/panels/assets";
|
||||||
import { PropertiesPanel } from "@/components/editor/panels/properties";
|
import { PropertiesPanel } from "@/components/editor/panels/properties";
|
||||||
import { Timeline } from "@/components/editor/panels/timeline";
|
import { Timeline } from "@/timeline/components";
|
||||||
import { PreviewPanel } from "@/components/editor/panels/preview";
|
import { PreviewPanel } from "@/preview/components";
|
||||||
import { EditorHeader } from "@/components/editor/editor-header";
|
import { EditorHeader } from "@/components/editor/editor-header";
|
||||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||||
import { Onboarding } from "@/components/editor/onboarding";
|
import { Onboarding } from "@/components/editor/onboarding";
|
||||||
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
|
import { MigrationDialog } from "@/project/components/migration-dialog";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/editor/panel-store";
|
||||||
import { usePasteMedia } from "@/hooks/use-paste-media";
|
import { usePasteMedia } from "@/media/use-paste-media";
|
||||||
import { MobileGate } from "@/components/editor/mobile-gate";
|
import { MobileGate } from "@/components/editor/mobile-gate";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
export default function Editor() {
|
import { useEditor } from "@/editor/use-editor";
|
||||||
const params = useParams();
|
import { Cancel01Icon } from "@hugeicons/core-free-icons";
|
||||||
const projectId = params.project_id as string;
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
return (
|
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
|
||||||
<MobileGate>
|
import {
|
||||||
<EditorProvider projectId={projectId}>
|
createPreviewOverlayControl,
|
||||||
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
isPreviewOverlayVisible,
|
||||||
<EditorHeader />
|
mergePreviewOverlaySources,
|
||||||
<div className="min-h-0 min-w-0 flex-1">
|
} from "@/preview/overlays";
|
||||||
<EditorLayout />
|
import { usePreviewStore } from "@/preview/preview-store";
|
||||||
</div>
|
import { getGuidePreviewOverlaySource } from "@/guides";
|
||||||
<Onboarding />
|
import {
|
||||||
<MigrationDialog />
|
bookmarkNotesPreviewOverlay,
|
||||||
</div>
|
getBookmarkPreviewOverlaySource,
|
||||||
</EditorProvider>
|
} from "@/timeline/bookmarks/index";
|
||||||
</MobileGate>
|
|
||||||
);
|
export default function Editor() {
|
||||||
}
|
const params = useParams();
|
||||||
|
const projectId = params.project_id as string;
|
||||||
function EditorLayout() {
|
|
||||||
usePasteMedia();
|
return (
|
||||||
const { panels, setPanel } = usePanelStore();
|
<MobileGate>
|
||||||
|
<EditorProvider projectId={projectId}>
|
||||||
return (
|
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
||||||
<ResizablePanelGroup
|
<DegradedRendererBanner />
|
||||||
direction="vertical"
|
<EditorHeader />
|
||||||
className="size-full gap-[0.18rem]"
|
<div className="min-h-0 min-w-0 flex-1">
|
||||||
onLayout={(sizes) => {
|
<EditorLayout />
|
||||||
setPanel("mainContent", sizes[0] ?? panels.mainContent);
|
</div>
|
||||||
setPanel("timeline", sizes[1] ?? panels.timeline);
|
<Onboarding />
|
||||||
}}
|
<MigrationDialog />
|
||||||
>
|
<ChangelogNotification />
|
||||||
<ResizablePanel
|
</div>
|
||||||
defaultSize={panels.mainContent}
|
</EditorProvider>
|
||||||
minSize={30}
|
</MobileGate>
|
||||||
maxSize={85}
|
);
|
||||||
className="min-h-0"
|
}
|
||||||
>
|
|
||||||
<ResizablePanelGroup
|
function DegradedRendererBanner() {
|
||||||
direction="horizontal"
|
const isDegraded = useEditor((e) => e.renderer.isDegraded);
|
||||||
className="size-full gap-[0.19rem] px-3"
|
const [dismissed, setDismissed] = useState(false);
|
||||||
onLayout={(sizes) => {
|
if (!isDegraded || dismissed) return null;
|
||||||
setPanel("tools", sizes[0] ?? panels.tools);
|
|
||||||
setPanel("preview", sizes[1] ?? panels.preview);
|
return (
|
||||||
setPanel("properties", sizes[2] ?? panels.properties);
|
<div className="bg-accent border-b h-9 flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||||
}}
|
<span>For the best experience, open OpenCut in Chrome.</span>
|
||||||
>
|
<Button
|
||||||
<ResizablePanel
|
variant="text"
|
||||||
defaultSize={panels.tools}
|
size="icon"
|
||||||
minSize={15}
|
className="p-0 w-auto [&_svg]:size-3.5"
|
||||||
maxSize={40}
|
onClick={() => setDismissed(true)}
|
||||||
className="min-w-0"
|
aria-label="Dismiss"
|
||||||
>
|
>
|
||||||
<AssetsPanel />
|
<HugeiconsIcon icon={Cancel01Icon} />
|
||||||
</ResizablePanel>
|
</Button>
|
||||||
|
</div>
|
||||||
<ResizableHandle withHandle />
|
);
|
||||||
|
}
|
||||||
<ResizablePanel
|
|
||||||
defaultSize={panels.preview}
|
function EditorLayout() {
|
||||||
minSize={30}
|
usePasteMedia();
|
||||||
className="min-h-0 min-w-0 flex-1"
|
const { panels, setPanel } = usePanelStore();
|
||||||
>
|
const activeScene = useEditor((editor) =>
|
||||||
<PreviewPanel />
|
editor.scenes.getActiveSceneOrNull(),
|
||||||
</ResizablePanel>
|
);
|
||||||
|
const currentTime = useEditor((editor) => editor.playback.getCurrentTime());
|
||||||
<ResizableHandle withHandle />
|
const activeGuide = usePreviewStore((state) => state.activeGuide);
|
||||||
|
const overlays = usePreviewStore((state) => state.overlays);
|
||||||
<ResizablePanel
|
const setOverlayVisibility = usePreviewStore(
|
||||||
defaultSize={panels.properties}
|
(state) => state.setOverlayVisibility,
|
||||||
minSize={15}
|
);
|
||||||
maxSize={40}
|
const showBookmarkNotes = isPreviewOverlayVisible({
|
||||||
className="min-w-0"
|
overlay: bookmarkNotesPreviewOverlay,
|
||||||
>
|
overlays,
|
||||||
<PropertiesPanel />
|
});
|
||||||
</ResizablePanel>
|
|
||||||
</ResizablePanelGroup>
|
const overlaySource = useMemo(
|
||||||
</ResizablePanel>
|
() =>
|
||||||
|
mergePreviewOverlaySources({
|
||||||
<ResizableHandle withHandle />
|
sources: [
|
||||||
|
getGuidePreviewOverlaySource({
|
||||||
<ResizablePanel
|
guideId: activeGuide,
|
||||||
defaultSize={panels.timeline}
|
}),
|
||||||
minSize={15}
|
activeScene
|
||||||
maxSize={70}
|
? getBookmarkPreviewOverlaySource({
|
||||||
className="min-h-0 px-3 pb-3"
|
bookmarks: activeScene.bookmarks,
|
||||||
>
|
time: currentTime,
|
||||||
<Timeline />
|
isVisible: showBookmarkNotes,
|
||||||
</ResizablePanel>
|
})
|
||||||
</ResizablePanelGroup>
|
: {
|
||||||
);
|
definitions: [bookmarkNotesPreviewOverlay],
|
||||||
}
|
instances: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[activeGuide, activeScene, currentTime, showBookmarkNotes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const overlayControls = useMemo(
|
||||||
|
() =>
|
||||||
|
overlaySource.definitions.map((overlay) =>
|
||||||
|
createPreviewOverlayControl({ overlay, overlays }),
|
||||||
|
),
|
||||||
|
[overlaySource.definitions, overlays],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResizablePanelGroup
|
||||||
|
direction="vertical"
|
||||||
|
className="size-full gap-[0.18rem]"
|
||||||
|
onLayout={(sizes) => {
|
||||||
|
setPanel({
|
||||||
|
panel: "mainContent",
|
||||||
|
size: sizes[0] ?? panels.mainContent,
|
||||||
|
});
|
||||||
|
setPanel({
|
||||||
|
panel: "timeline",
|
||||||
|
size: sizes[1] ?? panels.timeline,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ResizablePanel
|
||||||
|
defaultSize={panels.mainContent}
|
||||||
|
minSize={30}
|
||||||
|
maxSize={85}
|
||||||
|
className="min-h-0"
|
||||||
|
>
|
||||||
|
<ResizablePanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
className="size-full gap-[0.19rem] px-3"
|
||||||
|
onLayout={(sizes) => {
|
||||||
|
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
|
||||||
|
defaultSize={panels.tools}
|
||||||
|
minSize={15}
|
||||||
|
maxSize={40}
|
||||||
|
className="min-w-0"
|
||||||
|
>
|
||||||
|
<AssetsPanel />
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
<ResizablePanel
|
||||||
|
defaultSize={panels.preview}
|
||||||
|
minSize={30}
|
||||||
|
className="min-h-0 min-w-0 flex-1"
|
||||||
|
>
|
||||||
|
<PreviewPanel
|
||||||
|
overlayControls={overlayControls}
|
||||||
|
overlayInstances={overlaySource.instances}
|
||||||
|
onOverlayVisibilityChange={setOverlayVisibility}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
<ResizablePanel
|
||||||
|
defaultSize={panels.properties}
|
||||||
|
minSize={15}
|
||||||
|
maxSize={40}
|
||||||
|
className="min-w-0"
|
||||||
|
>
|
||||||
|
<PropertiesPanel />
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
<ResizablePanel
|
||||||
|
defaultSize={panels.timeline}
|
||||||
|
minSize={15}
|
||||||
|
maxSize={70}
|
||||||
|
className="min-h-0 px-3 pb-3"
|
||||||
|
>
|
||||||
|
<Timeline />
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+291
-284
@@ -1,284 +1,291 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* Custom variant for dark mode */
|
/* Custom variant for dark mode */
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
/* Plugins */
|
/* Plugins */
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
@plugin "tailwindcss-animate";
|
@plugin "tailwindcss-animate";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: hsl(0, 0%, 100%);
|
--background: hsl(0, 0%, 100%);
|
||||||
--foreground: hsl(0 0% 11%);
|
--foreground: hsl(0 0% 11%);
|
||||||
--card: hsl(0, 0%, 100%);
|
--card: hsl(0, 0%, 100%);
|
||||||
--card-foreground: hsl(0 0% 11%);
|
--card-foreground: hsl(0 0% 11%);
|
||||||
--popover: hsl(0, 0%, 100%);
|
--popover: hsl(0, 0%, 100%);
|
||||||
--popover-hover: hsl(0, 0%, 96%);
|
--popover-hover: hsl(0, 0%, 96%);
|
||||||
--popover-foreground: hsl(0 0% 2%);
|
--popover-foreground: hsl(0 0% 2%);
|
||||||
--primary: hsl(200, 90%, 52%);
|
--primary: hsl(200, 90%, 52%);
|
||||||
--primary-foreground: hsl(0, 0%, 100%);
|
--primary-foreground: hsl(0, 0%, 100%);
|
||||||
--secondary: hsl(204, 100%, 97%);
|
--secondary: hsl(204, 100%, 97%);
|
||||||
--secondary-border: hsl(204, 100%, 94%);
|
--secondary-border: hsl(204, 100%, 94%);
|
||||||
--secondary-foreground: hsl(200, 98%, 39%);
|
--secondary-foreground: hsl(200, 98%, 39%);
|
||||||
--muted: hsl(0 0% 85.1%);
|
--muted: hsl(0 0% 85.1%);
|
||||||
--muted-foreground: hsl(0 0% 50%);
|
--muted-foreground: hsl(0 0% 50%);
|
||||||
--accent: hsl(0, 0%, 96%);
|
--accent: hsl(0, 0%, 96%);
|
||||||
--accent-foreground: hsl(0 0% 2%);
|
--accent-foreground: hsl(0 0% 2%);
|
||||||
--destructive: hsl(0, 83%, 50%);
|
--destructive: hsl(0, 83%, 50%);
|
||||||
--destructive-foreground: hsl(0, 0%, 100%);
|
--destructive-foreground: hsl(0, 0%, 100%);
|
||||||
--constructive: hsl(141, 71%, 48%);
|
--constructive: hsl(141, 71%, 48%);
|
||||||
--constructive-foreground: hsl(0, 0%, 100%);
|
--constructive-foreground: hsl(0, 0%, 100%);
|
||||||
--border: hsl(0 0% 91%);
|
--caution: hsl(38, 92%, 50%);
|
||||||
--input: hsl(0, 0%, 100%);
|
--caution-foreground: hsl(0, 0%, 10%);
|
||||||
--ring: hsl(0, 0%, 55%);
|
--border: hsl(0 0% 91%);
|
||||||
--chart-1: hsl(220 70% 50%);
|
--input: hsl(0, 0%, 100%);
|
||||||
--chart-2: hsl(160 60% 45%);
|
--ring: hsl(0, 0%, 55%);
|
||||||
--chart-3: hsl(30 80% 55%);
|
--chart-1: hsl(220 70% 50%);
|
||||||
--chart-4: hsl(280 65% 60%);
|
--chart-2: hsl(160 60% 45%);
|
||||||
--chart-5: hsl(340 75% 55%);
|
--chart-3: hsl(30 80% 55%);
|
||||||
--sidebar-background: hsl(0 0% 96.1%);
|
--chart-4: hsl(280 65% 60%);
|
||||||
--sidebar-foreground: hsl(0 0% 2%);
|
--chart-5: hsl(340 75% 55%);
|
||||||
--sidebar-primary: hsl(0 0% 2%);
|
--sidebar-background: hsl(0 0% 96.1%);
|
||||||
--sidebar-primary-foreground: hsl(0 0% 91%);
|
--sidebar-foreground: hsl(0 0% 2%);
|
||||||
--sidebar-accent: hsl(0 0% 85.1%);
|
--sidebar-primary: hsl(0 0% 2%);
|
||||||
--sidebar-accent-foreground: hsl(0 0% 2%);
|
--sidebar-primary-foreground: hsl(0 0% 91%);
|
||||||
--sidebar-border: hsl(0 0% 85.1%);
|
--sidebar-accent: hsl(0 0% 85.1%);
|
||||||
--sidebar-ring: hsl(0 0% 16.9%);
|
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||||
--sidebar: hsl(0 0% 98%);
|
--sidebar-border: hsl(0 0% 85.1%);
|
||||||
}
|
--sidebar-ring: hsl(0 0% 16.9%);
|
||||||
|
--sidebar: hsl(0 0% 98%);
|
||||||
.panel {
|
}
|
||||||
--background: hsl(210, 20%, 98%);
|
|
||||||
--foreground: hsl(0 0% 13%);
|
.panel {
|
||||||
--card: hsl(0, 0%, 98%);
|
--background: hsl(210, 20%, 98%);
|
||||||
--card-foreground: hsl(0 0% 13%);
|
--foreground: hsl(0 0% 13%);
|
||||||
--primary-foreground: hsl(0, 0%, 98%);
|
--card: hsl(0, 0%, 98%);
|
||||||
--secondary: hsl(204, 100%, 95%);
|
--card-foreground: hsl(0 0% 13%);
|
||||||
--secondary-border: hsl(204, 100%, 92%);
|
--primary-foreground: hsl(0, 0%, 98%);
|
||||||
--secondary-foreground: hsl(200, 98%, 37%);
|
--secondary: hsl(204, 100%, 95%);
|
||||||
--muted: hsl(0 0% 83.1%);
|
--secondary-border: hsl(204, 100%, 92%);
|
||||||
--muted-foreground: hsl(0 0% 48%);
|
--secondary-foreground: hsl(200, 98%, 37%);
|
||||||
--accent: hsl(0, 0%, 93%);
|
--muted: hsl(0 0% 83.1%);
|
||||||
--accent-foreground: hsl(0 0% 5%);
|
--muted-foreground: hsl(0 0% 48%);
|
||||||
--destructive-foreground: hsl(0, 0%, 98%);
|
--accent: hsl(0, 0%, 93%);
|
||||||
--constructive-foreground: hsl(0, 0%, 98%);
|
--accent-foreground: hsl(0 0% 5%);
|
||||||
--border: hsl(0 0% 87%);
|
--destructive-foreground: hsl(0, 0%, 98%);
|
||||||
--input: hsl(0 0% 93%);
|
--constructive-foreground: hsl(0, 0%, 98%);
|
||||||
--ring: hsl(0, 0%, 53%);
|
--border: hsl(0 0% 87%);
|
||||||
}
|
--input: hsl(0 0% 93%);
|
||||||
|
--ring: hsl(0, 0%, 53%);
|
||||||
.dark {
|
}
|
||||||
--background: hsl(0, 0%, 5%);
|
|
||||||
--foreground: hsl(0 0% 87%);
|
.dark {
|
||||||
--card: hsl(0, 0%, 5%);
|
--background: hsl(0, 0%, 5%);
|
||||||
--card-foreground: hsl(0 0% 87%);
|
--foreground: hsl(0 0% 87%);
|
||||||
--popover: hsl(0, 0%, 5%);
|
--card: hsl(0, 0%, 5%);
|
||||||
--popover-hover: hsl(0, 0%, 13%);
|
--card-foreground: hsl(0 0% 87%);
|
||||||
--popover-foreground: hsl(0 0% 95%);
|
--popover: hsl(0, 0%, 5%);
|
||||||
--secondary: hsl(204, 100%, 12%);
|
--popover-hover: hsl(0, 0%, 13%);
|
||||||
--secondary-border: hsl(204, 100%, 15%);
|
--popover-foreground: hsl(0 0% 95%);
|
||||||
--secondary-foreground: hsl(200, 98%, 61%);
|
--secondary: hsl(204, 100%, 12%);
|
||||||
--muted: hsl(0 0% 20%);
|
--secondary-border: hsl(204, 100%, 15%);
|
||||||
--accent: hsl(0, 0%, 14%);
|
--secondary-foreground: hsl(200, 98%, 61%);
|
||||||
--accent-foreground: hsl(0 0% 95%);
|
--muted: hsl(0 0% 20%);
|
||||||
--border: hsl(0 0% 16%);
|
--accent: hsl(0, 0%, 14%);
|
||||||
--input: hsl(0 0% 5%);
|
--accent-foreground: hsl(0 0% 95%);
|
||||||
--ring: hsl(0, 0%, 50%);
|
--border: hsl(0 0% 16%);
|
||||||
--sidebar-background: hsl(0 0% 8%);
|
--input: hsl(0 0% 5%);
|
||||||
--sidebar-foreground: hsl(0 0% 95%);
|
--ring: hsl(0, 0%, 50%);
|
||||||
--sidebar-primary: hsl(0 0% 95%);
|
--caution: hsl(38, 92%, 60%);
|
||||||
--sidebar-primary-foreground: hsl(0 0% 15%);
|
--caution-foreground: hsl(0, 0%, 10%);
|
||||||
--sidebar-accent: hsl(0 0% 20%);
|
--sidebar-background: hsl(0 0% 8%);
|
||||||
--sidebar-accent-foreground: hsl(0 0% 95%);
|
--sidebar-foreground: hsl(0 0% 95%);
|
||||||
--sidebar-border: hsl(0 0% 20%);
|
--sidebar-primary: hsl(0 0% 95%);
|
||||||
--sidebar-ring: hsl(0 0% 83.1%);
|
--sidebar-primary-foreground: hsl(0 0% 15%);
|
||||||
--sidebar: hsl(0 0% 6%);
|
--sidebar-accent: hsl(0 0% 20%);
|
||||||
}
|
--sidebar-accent-foreground: hsl(0 0% 95%);
|
||||||
|
--sidebar-border: hsl(0 0% 20%);
|
||||||
.dark .panel {
|
--sidebar-ring: hsl(0 0% 83.1%);
|
||||||
--background: hsl(0 0% 10%);
|
--sidebar: hsl(0 0% 6%);
|
||||||
--foreground: hsl(0 0% 85%);
|
}
|
||||||
--card: hsl(0, 0%, 10%);
|
|
||||||
--card-foreground: hsl(0 0% 85%);
|
.dark .panel {
|
||||||
--secondary: hsl(204, 67%, 9%);
|
--background: hsl(0 0% 10%);
|
||||||
--secondary-border: hsl(204, 100%, 14%);
|
--foreground: hsl(0 0% 85%);
|
||||||
--secondary-foreground: hsl(200, 98%, 63%);
|
--card: hsl(0, 0%, 10%);
|
||||||
--muted: hsl(0 0% 22%);
|
--card-foreground: hsl(0 0% 85%);
|
||||||
--accent: hsl(0, 0%, 15%);
|
--secondary: hsl(204, 67%, 9%);
|
||||||
--accent-foreground: hsl(0 0% 93%);
|
--secondary-border: hsl(204, 100%, 14%);
|
||||||
--border: hsl(0 0% 18%);
|
--secondary-foreground: hsl(200, 98%, 63%);
|
||||||
--input: hsl(0 0% 22%);
|
--muted: hsl(0 0% 22%);
|
||||||
--ring: hsl(0, 0%, 52%);
|
--accent: hsl(0, 0%, 15%);
|
||||||
}
|
--accent-foreground: hsl(0 0% 93%);
|
||||||
|
--border: hsl(0 0% 18%);
|
||||||
@layer base {
|
--input: hsl(0 0% 22%);
|
||||||
/*
|
--ring: hsl(0, 0%, 52%);
|
||||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
}
|
||||||
so we've added these compatibility styles to make sure everything still
|
|
||||||
looks the same as it did with Tailwind CSS v3.
|
@layer base {
|
||||||
|
/*
|
||||||
If we ever want to remove these styles, we need to add an explicit border
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
color utility to any element that depends on these defaults.
|
so we've added these compatibility styles to make sure everything still
|
||||||
*/
|
looks the same as it did with Tailwind CSS v3.
|
||||||
*,
|
|
||||||
::after,
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
::before,
|
color utility to any element that depends on these defaults.
|
||||||
::backdrop,
|
*/
|
||||||
::file-selector-button {
|
*,
|
||||||
border-color: var(--color-gray-200, currentcolor);
|
::after,
|
||||||
}
|
::before,
|
||||||
/* Other default base styles */
|
::backdrop,
|
||||||
* {
|
::file-selector-button {
|
||||||
@apply border-border;
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
}
|
}
|
||||||
body {
|
/* Other default base styles */
|
||||||
@apply bg-background text-foreground;
|
* {
|
||||||
/* Prevent back/forward swipe */
|
@apply border-border;
|
||||||
overscroll-behavior-x: contain;
|
}
|
||||||
}
|
body {
|
||||||
::selection {
|
@apply bg-background text-foreground;
|
||||||
@apply bg-primary/35 selection:text-primary-foreground;
|
/* Prevent back/forward swipe */
|
||||||
}
|
overscroll-behavior-x: contain;
|
||||||
}
|
}
|
||||||
|
::selection {
|
||||||
@theme inline {
|
@apply bg-primary/35 selection:text-primary-foreground;
|
||||||
/* Responsive breakpoints */
|
}
|
||||||
--breakpoint-xs: 30rem;
|
}
|
||||||
|
|
||||||
/* Typography */
|
@theme inline {
|
||||||
--font-sans: var(--font-inter), sans-serif;
|
/* Responsive breakpoints */
|
||||||
|
--breakpoint-xs: 30rem;
|
||||||
/* Font sizes */
|
|
||||||
--text-xs: 0.72rem;
|
/* Typography */
|
||||||
--text-sm: 0.79rem;
|
--font-sans: var(--font-inter), sans-serif;
|
||||||
--text-base: 0.92rem;
|
|
||||||
--text-base--line-height: calc(1.5 / 0.95);
|
/* Font sizes */
|
||||||
--text-xs--line-height: calc(1 / 0.8);
|
--text-xs: 0.72rem;
|
||||||
|
--text-sm: 0.79rem;
|
||||||
/* Border radius */
|
--text-base: 0.92rem;
|
||||||
--radius-lg: 0.82rem;
|
--text-base--line-height: calc(1.5 / 0.95);
|
||||||
--radius-md: 0.65rem;
|
--text-xs--line-height: calc(1 / 0.8);
|
||||||
--radius-sm: 0.35rem;
|
|
||||||
|
/* Border radius */
|
||||||
/* Palette mapped to root design tokens */
|
--radius-lg: 0.82rem;
|
||||||
--color-background: var(--background);
|
--radius-md: 0.65rem;
|
||||||
--color-foreground: var(--foreground);
|
--radius-sm: 0.35rem;
|
||||||
|
|
||||||
--color-card: var(--card);
|
/* Palette mapped to root design tokens */
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-popover-hover: var(--popover-hover);
|
--color-card: var(--card);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
|
||||||
--color-primary: var(--primary);
|
--color-popover: var(--popover);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-popover-hover: var(--popover-hover);
|
||||||
--color-secondary: var(--secondary);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-secondary-border: var(--secondary-border);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-muted: var(--muted);
|
--color-secondary: var(--secondary);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-secondary-border: var(--secondary-border);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-constructive: var(--constructive);
|
|
||||||
--color-constructive-foreground: var(--constructive-foreground);
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
--color-border: var(--border);
|
|
||||||
--color-input: var(--input);
|
--color-constructive: var(--constructive);
|
||||||
--color-ring: var(--ring);
|
--color-constructive-foreground: var(--constructive-foreground);
|
||||||
|
|
||||||
/* Chart colors */
|
--color-caution: var(--caution);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-caution-foreground: var(--caution-foreground);
|
||||||
--color-chart-2: var(--chart-2);
|
|
||||||
--color-chart-3: var(--chart-3);
|
--color-border: var(--border);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-input: var(--input);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
/* Sidebar */
|
/* Chart colors */
|
||||||
--color-sidebar: var(--sidebar-background);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
--color-chart-5: var(--chart-5);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
/* Sidebar */
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar: var(--sidebar-background);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
/* Animations */
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@keyframes accordion-down {
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
from {
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
height: 0;
|
|
||||||
}
|
/* Animations */
|
||||||
to {
|
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||||
height: var(--radix-accordion-content-height);
|
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||||
}
|
|
||||||
}
|
@keyframes accordion-down {
|
||||||
|
from {
|
||||||
@keyframes accordion-up {
|
height: 0;
|
||||||
from {
|
}
|
||||||
height: var(--radix-accordion-content-height);
|
to {
|
||||||
}
|
height: var(--radix-accordion-content-height);
|
||||||
to {
|
}
|
||||||
height: 0;
|
}
|
||||||
}
|
|
||||||
}
|
@keyframes accordion-up {
|
||||||
}
|
from {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
@utility scrollbar-hidden {
|
}
|
||||||
-ms-overflow-style: none;
|
to {
|
||||||
scrollbar-width: none;
|
height: 0;
|
||||||
&::-webkit-scrollbar {
|
}
|
||||||
display: none;
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@utility scrollbar-hidden {
|
||||||
@utility scrollbar-x-hidden {
|
-ms-overflow-style: none;
|
||||||
-ms-overflow-style: none;
|
scrollbar-width: none;
|
||||||
scrollbar-width: none;
|
&::-webkit-scrollbar {
|
||||||
&::-webkit-scrollbar:horizontal {
|
display: none;
|
||||||
display: none;
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@utility scrollbar-x-hidden {
|
||||||
@utility scrollbar-y-hidden {
|
-ms-overflow-style: none;
|
||||||
-ms-overflow-style: none;
|
scrollbar-width: none;
|
||||||
scrollbar-width: none;
|
&::-webkit-scrollbar:horizontal {
|
||||||
&::-webkit-scrollbar:vertical {
|
display: none;
|
||||||
display: none;
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@utility scrollbar-y-hidden {
|
||||||
@utility scrollbar-thin {
|
-ms-overflow-style: none;
|
||||||
&::-webkit-scrollbar {
|
scrollbar-width: none;
|
||||||
width: 7px;
|
&::-webkit-scrollbar:vertical {
|
||||||
height: 7px;
|
display: none;
|
||||||
}
|
}
|
||||||
&::-webkit-scrollbar-track {
|
}
|
||||||
background: transparent;
|
|
||||||
}
|
@utility scrollbar-thin {
|
||||||
&::-webkit-scrollbar-thumb {
|
&::-webkit-scrollbar {
|
||||||
background: var(--border);
|
width: 7px;
|
||||||
border-radius: 4px;
|
height: 7px;
|
||||||
}
|
}
|
||||||
&::-webkit-scrollbar-thumb:hover {
|
&::-webkit-scrollbar-track {
|
||||||
background: var(--muted-foreground);
|
background: transparent;
|
||||||
}
|
}
|
||||||
}
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
@layer base {
|
border-radius: 4px;
|
||||||
* {
|
}
|
||||||
@apply border-border outline-ring/50;
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
}
|
background: var(--muted-foreground);
|
||||||
body {
|
}
|
||||||
@apply bg-background text-foreground;
|
}
|
||||||
}
|
|
||||||
}
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { ThemeProvider } from "next-themes";
|
|||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
|
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
|
||||||
import { TooltipProvider } from "../components/ui/tooltip";
|
import { TooltipProvider } from "../components/ui/tooltip";
|
||||||
import { baseMetaData } from "./metadata";
|
import { baseMetaData } from "./metadata";
|
||||||
import { BotIdClient } from "botid/client";
|
import { BotIdClient } from "botid/client";
|
||||||
import { webEnv } from "@opencut/env/web";
|
import { webEnv } from "@/env/web";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
const siteFont = Inter({ subsets: ["latin"] });
|
const siteFont = Inter({ subsets: ["latin"] });
|
||||||
@@ -35,13 +36,6 @@ export default function RootLayout({
|
|||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
strategy="beforeInteractive"
|
strategy="beforeInteractive"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* code to figma */}
|
|
||||||
{/* <script
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `(function(){var s=document.createElement('script');s.src='https://mcp.figma.com/mcp/html-to-design/capture.js';document.head.appendChild(s);})();`,
|
|
||||||
}}
|
|
||||||
/> */}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
|
import { SITE_INFO, SITE_URL } from "@/site/brand";
|
||||||
|
|
||||||
export const baseMetaData: Metadata = {
|
export const baseMetaData: Metadata = {
|
||||||
metadataBase: new URL(SITE_URL),
|
metadataBase: new URL(SITE_URL),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Hero } from "@/components/landing/hero";
|
|||||||
import { Header } from "@/components/header";
|
import { Header } from "@/components/header";
|
||||||
import { Footer } from "@/components/footer";
|
import { Footer } from "@/components/footer";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { SITE_URL } from "@/constants/site-constants";
|
import { SITE_URL } from "@/site/brand";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
alternates: {
|
alternates: {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
import { SOCIAL_LINKS } from "@/site/social";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Privacy Policy - OpenCut",
|
title: "Privacy Policy - OpenCut",
|
||||||
@@ -57,7 +57,10 @@ export default function PrivacyPage() {
|
|||||||
content is tracked
|
content is tracked
|
||||||
</li>
|
</li>
|
||||||
<li>You can clear local data from your browser at any time</li>
|
<li>You can clear local data from your browser at any time</li>
|
||||||
<li>We don't sell or share your data with anyone (we don't even have it)</li>
|
<li>
|
||||||
|
We don't sell or share your data with anyone (we don't
|
||||||
|
even have it)
|
||||||
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p className="mt-4">
|
<p className="mt-4">
|
||||||
Questions? Email us at{" "}
|
Questions? Email us at{" "}
|
||||||
@@ -172,7 +175,7 @@ export default function PrivacyPage() {
|
|||||||
<a
|
<a
|
||||||
href={SOCIAL_LINKS.github}
|
href={SOCIAL_LINKS.github}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
GitHub
|
GitHub
|
||||||
@@ -189,7 +192,7 @@ export default function PrivacyPage() {
|
|||||||
<a
|
<a
|
||||||
href={`${SOCIAL_LINKS.github}/issues`}
|
href={`${SOCIAL_LINKS.github}/issues`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
GitHub repository
|
GitHub repository
|
||||||
@@ -205,7 +208,7 @@ export default function PrivacyPage() {
|
|||||||
<a
|
<a
|
||||||
href={SOCIAL_LINKS.x}
|
href={SOCIAL_LINKS.x}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
X (Twitter)
|
X (Twitter)
|
||||||
|
|||||||
+1017
-1015
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import type { TProjectSortKey } from "@/lib/project/types";
|
import type { TProjectSortKey } from "@/project/types";
|
||||||
|
|
||||||
export type ProjectsViewMode = "grid" | "list";
|
export type ProjectsViewMode = "grid" | "list";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
import { SITE_URL } from "@/constants/site-constants";
|
import { SITE_URL } from "@/site/brand";
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Feed } from "feed";
|
import { Feed } from "feed";
|
||||||
import { getPosts } from "@/lib/blog/query";
|
import { getPosts } from "@/blog/query";
|
||||||
import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
|
import { SITE_INFO, SITE_URL } from "@/site/brand";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SITE_URL } from "@/constants/site-constants";
|
import { SITE_URL } from "@/site/brand";
|
||||||
import { getPosts } from "@/lib/blog/query";
|
import { getPosts } from "@/blog/query";
|
||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BasePage } from "@/app/base-page";
|
import { BasePage } from "@/app/base-page";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { SPONSORS, type Sponsor } from "@/constants/site-constants";
|
import { SPONSORS, type Sponsor } from "@/site/sponsors";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
|
import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
import { SOCIAL_LINKS } from "@/site/social";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Terms of Service - OpenCut",
|
title: "Terms of Service - OpenCut",
|
||||||
@@ -51,9 +51,13 @@ export default function TermsPage() {
|
|||||||
Free for personal and commercial use with no watermarks or
|
Free for personal and commercial use with no watermarks or
|
||||||
restrictions
|
restrictions
|
||||||
</li>
|
</li>
|
||||||
<li>You're responsible for how you use it - don't break the law</li>
|
|
||||||
<li>
|
<li>
|
||||||
Service provided "as is" - we can't guarantee perfect uptime
|
You'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>
|
||||||
<li>
|
<li>
|
||||||
Open source means you can review our code and self-host if
|
Open source means you can review our code and self-host if
|
||||||
@@ -109,8 +113,8 @@ export default function TermsPage() {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
You're responsible for how you use OpenCut and the content you create.
|
You're responsible for how you use OpenCut and the content you
|
||||||
Don't use it for anything illegal in your jurisdiction.
|
create. Don't use it for anything illegal in your jurisdiction.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -127,8 +131,8 @@ export default function TermsPage() {
|
|||||||
<h2 className="text-2xl font-semibold">Service</h2>
|
<h2 className="text-2xl font-semibold">Service</h2>
|
||||||
<p>
|
<p>
|
||||||
OpenCut does not currently require an account. The service is provided
|
OpenCut does not currently require an account. The service is provided
|
||||||
"as is" without warranties. While we strive for reliability, we can't
|
"as is" without warranties. While we strive for
|
||||||
guarantee uninterrupted service.
|
reliability, we can't guarantee uninterrupted service.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -146,7 +150,7 @@ export default function TermsPage() {
|
|||||||
<a
|
<a
|
||||||
href={SOCIAL_LINKS.github}
|
href={SOCIAL_LINKS.github}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
GitHub
|
GitHub
|
||||||
@@ -161,12 +165,12 @@ export default function TermsPage() {
|
|||||||
OpenCut is provided free of charge. To the extent permitted by law:
|
OpenCut is provided free of charge. To the extent permitted by law:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc space-y-2 pl-6">
|
<ul className="list-disc space-y-2 pl-6">
|
||||||
<li>We're not liable for any loss of data or content</li>
|
<li>We're not liable for any loss of data or content</li>
|
||||||
<li>
|
<li>
|
||||||
Projects are stored in your browser and may be lost if you clear
|
Projects are stored in your browser and may be lost if you clear
|
||||||
browser data
|
browser data
|
||||||
</li>
|
</li>
|
||||||
<li>We're not responsible for how you use the service</li>
|
<li>We're not responsible for how you use the service</li>
|
||||||
<li>Our liability is limited to the maximum extent allowed by law</li>
|
<li>Our liability is limited to the maximum extent allowed by law</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
@@ -180,7 +184,7 @@ export default function TermsPage() {
|
|||||||
<h2 className="text-2xl font-semibold">Service Changes</h2>
|
<h2 className="text-2xl font-semibold">Service Changes</h2>
|
||||||
<p>We may update OpenCut and these terms:</p>
|
<p>We may update OpenCut and these terms:</p>
|
||||||
<ul className="list-disc space-y-2 pl-6">
|
<ul className="list-disc space-y-2 pl-6">
|
||||||
<li>We'll notify you of significant changes to these terms</li>
|
<li>We'll notify you of significant changes to these terms</li>
|
||||||
<li>Continued use means you accept any updates</li>
|
<li>Continued use means you accept any updates</li>
|
||||||
<li>You can always self-host an older version if you prefer</li>
|
<li>You can always self-host an older version if you prefer</li>
|
||||||
<li>Major changes will be discussed with the community on GitHub</li>
|
<li>Major changes will be discussed with the community on GitHub</li>
|
||||||
@@ -203,7 +207,7 @@ export default function TermsPage() {
|
|||||||
<a
|
<a
|
||||||
href={`${SOCIAL_LINKS.github}/issues`}
|
href={`${SOCIAL_LINKS.github}/issues`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
GitHub repository
|
GitHub repository
|
||||||
@@ -219,7 +223,7 @@ export default function TermsPage() {
|
|||||||
<a
|
<a
|
||||||
href={SOCIAL_LINKS.x}
|
href={SOCIAL_LINKS.x}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
X (Twitter)
|
X (Twitter)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
import { webEnv } from "@opencut/env/web";
|
import { webEnv } from "@/env/web";
|
||||||
|
|
||||||
export const { signIn, signUp, useSession } = createAuthClient({
|
export const { signIn, signUp, useSession } = createAuthClient({
|
||||||
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
|
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
|
||||||
});
|
});
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
import { Ratelimit } from "@upstash/ratelimit";
|
import { Ratelimit } from "@upstash/ratelimit";
|
||||||
import { Redis } from "@upstash/redis";
|
import { Redis } from "@upstash/redis";
|
||||||
import { webEnv } from "@opencut/env/web";
|
import { webEnv } from "@/env/web";
|
||||||
|
|
||||||
const redis = new Redis({
|
const redis = new Redis({
|
||||||
url: webEnv.UPSTASH_REDIS_REST_URL,
|
url: webEnv.UPSTASH_REDIS_REST_URL,
|
||||||
token: webEnv.UPSTASH_REDIS_REST_TOKEN,
|
token: webEnv.UPSTASH_REDIS_REST_TOKEN,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const baseRateLimit = new Ratelimit({
|
export const baseRateLimit = new Ratelimit({
|
||||||
redis,
|
redis,
|
||||||
limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
|
limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
|
||||||
analytics: true,
|
analytics: true,
|
||||||
prefix: "rate-limit",
|
prefix: "rate-limit",
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function checkRateLimit({ request }: { request: Request }) {
|
export async function checkRateLimit({ request }: { request: Request }) {
|
||||||
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
|
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
|
||||||
const { success } = await baseRateLimit.limit(ip);
|
const { success } = await baseRateLimit.limit(ip);
|
||||||
return { success, limited: !success };
|
return { success, limited: !success };
|
||||||
}
|
}
|
||||||
@@ -1,43 +1,43 @@
|
|||||||
import { betterAuth, type RateLimit } from "better-auth";
|
import { betterAuth, type RateLimit } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { Redis } from "@upstash/redis";
|
import { Redis } from "@upstash/redis";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/db";
|
||||||
import { webEnv } from "@opencut/env/web";
|
import { webEnv } from "@/env/web";
|
||||||
|
|
||||||
const redis = new Redis({
|
const redis = new Redis({
|
||||||
url: webEnv.UPSTASH_REDIS_REST_URL,
|
url: webEnv.UPSTASH_REDIS_REST_URL,
|
||||||
token: webEnv.UPSTASH_REDIS_REST_TOKEN,
|
token: webEnv.UPSTASH_REDIS_REST_TOKEN,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
usePlural: true,
|
usePlural: true,
|
||||||
}),
|
}),
|
||||||
secret: webEnv.BETTER_AUTH_SECRET,
|
secret: webEnv.BETTER_AUTH_SECRET,
|
||||||
user: {
|
user: {
|
||||||
deleteUser: {
|
deleteUser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
storage: "secondary-storage",
|
storage: "secondary-storage",
|
||||||
customStorage: {
|
customStorage: {
|
||||||
get: async (key) => {
|
get: async (key) => {
|
||||||
const value = await redis.get(key);
|
const value = await redis.get(key);
|
||||||
return value as RateLimit | undefined;
|
return value as RateLimit | undefined;
|
||||||
},
|
},
|
||||||
set: async (key, value) => {
|
set: async (key, value) => {
|
||||||
await redis.set(key, value);
|
await redis.set(key, value);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
|
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
|
||||||
appName: "OpenCut",
|
appName: "OpenCut",
|
||||||
trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL],
|
trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL],
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Auth = typeof auth;
|
export type Auth = typeof auth;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}> = [
|
||||||
|
{ label: "Light", value: 100 },
|
||||||
|
{ label: "Medium", value: 200 },
|
||||||
|
{ label: "Heavy", value: 500 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const DEFAULT_BACKGROUND_COLOR = "#000000";
|
||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
MarblePost,
|
MarblePost,
|
||||||
MarblePostList,
|
MarblePostList,
|
||||||
MarbleTagList,
|
MarbleTagList,
|
||||||
} from "@/lib/blog/types";
|
} from "@/blog/types";
|
||||||
import { unified } from "unified";
|
import { unified } from "unified";
|
||||||
import rehypeParse from "rehype-parse";
|
import rehypeParse from "rehype-parse";
|
||||||
import rehypeStringify from "rehype-stringify";
|
import rehypeStringify from "rehype-stringify";
|
||||||
@@ -47,7 +47,12 @@ const EMPTY_AUTHORS: MarbleAuthorList = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function isMarbleConfigured() {
|
function isMarbleConfigured() {
|
||||||
return !["", "placeholder", "build-placeholder", "your_workspace_key_here"].includes(key);
|
return ![
|
||||||
|
"",
|
||||||
|
"placeholder",
|
||||||
|
"build-placeholder",
|
||||||
|
"your_workspace_key_here",
|
||||||
|
].includes(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchFromMarble<T>({
|
async function fetchFromMarble<T>({
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { TCanvasSize } from "@/project/types";
|
||||||
|
|
||||||
|
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
|
||||||
|
{ width: 1920, height: 1080 },
|
||||||
|
{ width: 1080, height: 1920 },
|
||||||
|
{ width: 1080, height: 1080 },
|
||||||
|
{ width: 1440, height: 1080 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 };
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Cancel01Icon } from "@hugeicons/core-free-icons";
|
||||||
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { getSortedReleases } from "../utils";
|
||||||
|
import type { Release } from "../utils";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "last-seen-version";
|
||||||
|
|
||||||
|
export function ChangelogNotification() {
|
||||||
|
const [release, setRelease] = useState<Release | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const releases = getSortedReleases();
|
||||||
|
const latest = releases[0];
|
||||||
|
if (!latest) return;
|
||||||
|
|
||||||
|
let storedVersion: string | null = null;
|
||||||
|
try {
|
||||||
|
storedVersion = localStorage.getItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOutdated =
|
||||||
|
storedVersion === null ||
|
||||||
|
storedVersion.localeCompare(latest.version, undefined, {
|
||||||
|
numeric: true,
|
||||||
|
}) < 0;
|
||||||
|
|
||||||
|
// TODO(v0.4): revert to the standard "null = first-time visitor, record silently"
|
||||||
|
// path. The null case intentionally shows the card for this release so existing
|
||||||
|
// users who never had the key get the 0.3.0 announcement.
|
||||||
|
if (!isOutdated) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, latest.version);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
setRelease(latest);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!release) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-5 left-5 z-50 flex w-72 flex-col gap-3 rounded-xl border bg-card p-4 shadow-lg">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-semibold leading-snug">
|
||||||
|
{release.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
v{release.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="-mr-1 -mt-1 shrink-0"
|
||||||
|
onClick={() => setRelease(null)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{release.summary && (
|
||||||
|
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{release.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button asChild size="sm">
|
||||||
|
<Link href="/changelog" onClick={() => setRelease(null)}>
|
||||||
|
See full changelog
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+98
-68
@@ -1,68 +1,98 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CheckIcon, ClipboardIcon } from "lucide-react";
|
import { CheckIcon, ClipboardIcon } from "lucide-react";
|
||||||
import { getSectionTitle, groupAndOrderChanges } from "../utils";
|
import {
|
||||||
import type { Change } from "../utils";
|
getSectionTitle,
|
||||||
import { cn } from "@/utils/ui";
|
groupAndOrderChanges,
|
||||||
import { Button } from "@/components/ui/button";
|
isSectionCollapsible,
|
||||||
|
} from "../utils";
|
||||||
function buildMarkdown({
|
import type { Change } from "../utils";
|
||||||
description,
|
import { cn } from "@/utils/ui";
|
||||||
changes,
|
import { Button } from "@/components/ui/button";
|
||||||
}: {
|
|
||||||
description?: string;
|
function buildMarkdown({
|
||||||
changes: Change[];
|
description,
|
||||||
}): string {
|
changes,
|
||||||
const lines: string[] = [];
|
}: {
|
||||||
|
description?: string;
|
||||||
if (description) {
|
changes: Change[];
|
||||||
lines.push(description, "");
|
}): string {
|
||||||
}
|
const lines: string[] = [];
|
||||||
|
|
||||||
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
|
if (description) {
|
||||||
|
lines.push(description, "");
|
||||||
for (const type of orderedTypes) {
|
}
|
||||||
lines.push(`## ${getSectionTitle(type)}`);
|
|
||||||
for (const change of grouped[type]) {
|
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
|
||||||
lines.push(`- ${change.text}`);
|
|
||||||
}
|
for (const type of orderedTypes) {
|
||||||
lines.push("");
|
if (isSectionCollapsible({ type })) {
|
||||||
}
|
lines.push(
|
||||||
|
buildCollapsibleMarkdownSection({
|
||||||
return lines.join("\n").trimEnd();
|
title: getSectionTitle({ type }),
|
||||||
}
|
changes: grouped[type],
|
||||||
|
}),
|
||||||
export function CopyMarkdownButton({
|
"",
|
||||||
description,
|
);
|
||||||
changes,
|
continue;
|
||||||
}: {
|
}
|
||||||
description?: string;
|
|
||||||
changes: Change[];
|
lines.push(`## ${getSectionTitle({ type })}`);
|
||||||
}) {
|
for (const change of grouped[type]) {
|
||||||
const [copied, setCopied] = useState(false);
|
lines.push(`- ${change.text}`);
|
||||||
|
}
|
||||||
const handleCopy = async () => {
|
lines.push("");
|
||||||
const markdown = buildMarkdown({ description, changes });
|
}
|
||||||
await navigator.clipboard.writeText(markdown);
|
|
||||||
setCopied(true);
|
return lines.join("\n").trimEnd();
|
||||||
setTimeout(() => setCopied(false), 2000);
|
}
|
||||||
};
|
|
||||||
|
function buildCollapsibleMarkdownSection({
|
||||||
return (
|
title,
|
||||||
<Button
|
changes,
|
||||||
size="sm"
|
}: {
|
||||||
variant="text"
|
title: string;
|
||||||
onClick={handleCopy}
|
changes: Change[];
|
||||||
className={cn("flex items-center gap-1.5", copied && "pointer-events-none")}
|
}): string {
|
||||||
title="Copy as markdown"
|
const bulletLines = changes.map((change) => `- ${change.text}`).join("\n");
|
||||||
>
|
|
||||||
{copied ? (
|
return `<details>\n<summary>${title}</summary>\n\n${bulletLines}\n\n</details>`;
|
||||||
<CheckIcon className="size-4" />
|
}
|
||||||
) : (
|
|
||||||
<ClipboardIcon className="size-4" />
|
export function CopyMarkdownButton({
|
||||||
)}
|
description,
|
||||||
{copied ? "Copied!" : "Copy markdown"}
|
changes,
|
||||||
</Button>
|
}: {
|
||||||
);
|
description?: string;
|
||||||
}
|
changes: Change[];
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
const markdown = buildMarkdown({ description, changes });
|
||||||
|
await navigator.clipboard.writeText(markdown);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="text"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5",
|
||||||
|
copied && "pointer-events-none",
|
||||||
|
)}
|
||||||
|
title="Copy as markdown"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<ClipboardIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
{copied ? "Copied!" : "Copy markdown"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
+153
-106
@@ -1,106 +1,153 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import { getSectionTitle, groupAndOrderChanges } from "../utils";
|
import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
|
||||||
import type { Release } from "../utils";
|
import {
|
||||||
|
getSectionTitle,
|
||||||
export function ReleaseArticle({
|
groupAndOrderChanges,
|
||||||
variant,
|
isSectionCollapsible,
|
||||||
isLatest,
|
type Change,
|
||||||
children,
|
type Release,
|
||||||
}: {
|
} from "../utils";
|
||||||
variant: "list" | "detail";
|
import { ArrowRightIcon } from "@hugeicons/core-free-icons";
|
||||||
isLatest?: boolean;
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
export function ReleaseArticle({
|
||||||
if (variant === "list") {
|
variant,
|
||||||
return (
|
isLatest,
|
||||||
<article className="relative sm:pl-10">
|
children,
|
||||||
<div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
|
}: {
|
||||||
<div
|
variant: "list" | "detail";
|
||||||
className={cn(
|
isLatest?: boolean;
|
||||||
"size-[11px] rounded-full border-[1.5px]",
|
children: ReactNode;
|
||||||
isLatest
|
}) {
|
||||||
? "border-foreground bg-foreground"
|
if (variant === "list") {
|
||||||
: "border-muted-foreground/30 bg-background",
|
return (
|
||||||
)}
|
<article className="relative sm:pl-10">
|
||||||
/>
|
<div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
|
||||||
</div>
|
<div
|
||||||
<div className="flex flex-col gap-5">{children}</div>
|
className={cn(
|
||||||
</article>
|
"size-[11px] rounded-full border-[1.5px]",
|
||||||
);
|
isLatest
|
||||||
}
|
? "border-foreground bg-foreground"
|
||||||
|
: "border-muted-foreground/30 bg-background",
|
||||||
return <article className="flex flex-col gap-8">{children}</article>;
|
)}
|
||||||
}
|
/>
|
||||||
|
</div>
|
||||||
export function ReleaseMeta({ release }: { release: Release }) {
|
<div className="flex flex-col gap-5">{children}</div>
|
||||||
return (
|
</article>
|
||||||
<span className="text-sm font-medium tracking-widest text-muted-foreground">
|
);
|
||||||
{release.version} — {release.date}
|
}
|
||||||
</span>
|
|
||||||
);
|
return <article className="flex flex-col gap-8">{children}</article>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const titleSizes: Record<"h1" | "h2", string> = {
|
export function ReleaseMeta({ release }: { release: Release }) {
|
||||||
h1: "text-4xl",
|
return (
|
||||||
h2: "text-2xl",
|
<span className="text-sm font-medium tracking-widest text-muted-foreground">
|
||||||
};
|
{release.version} — {release.date}
|
||||||
|
</span>
|
||||||
export function ReleaseTitle({
|
);
|
||||||
as: As,
|
}
|
||||||
href,
|
|
||||||
children,
|
const titleSizes: Record<"h1" | "h2", string> = {
|
||||||
}: {
|
h1: "text-4xl",
|
||||||
as: "h1" | "h2";
|
h2: "text-2xl",
|
||||||
href?: string;
|
};
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
export function ReleaseTitle({
|
||||||
return (
|
as: As,
|
||||||
<As className={cn("font-bold tracking-tight", titleSizes[As])}>
|
href,
|
||||||
{href ? (
|
children,
|
||||||
<Link href={href} className="hover:underline underline-offset-4">
|
}: {
|
||||||
{children}
|
as: "h1" | "h2";
|
||||||
</Link>
|
href?: string;
|
||||||
) : (
|
children: ReactNode;
|
||||||
children
|
}) {
|
||||||
)}
|
return (
|
||||||
</As>
|
<As className={cn("font-bold tracking-tight", titleSizes[As])}>
|
||||||
);
|
{href ? (
|
||||||
}
|
<Link href={href} className="hover:underline underline-offset-4">
|
||||||
|
{children}
|
||||||
export function ReleaseDescription({ children }: { children: ReactNode }) {
|
</Link>
|
||||||
return (
|
) : (
|
||||||
<p className="text-base text-foreground leading-relaxed max-w-xl">
|
children
|
||||||
{children}
|
)}
|
||||||
</p>
|
</As>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReleaseChanges({ release }: { release: Release }) {
|
export function ReleaseDescription({ children }: { children: ReactNode }) {
|
||||||
const { grouped, orderedTypes } = groupAndOrderChanges({
|
return (
|
||||||
changes: release.changes,
|
<p className="text-base text-foreground leading-relaxed max-w-xl">
|
||||||
});
|
{children}
|
||||||
|
</p>
|
||||||
return (
|
);
|
||||||
<div className="flex flex-col gap-4">
|
}
|
||||||
{orderedTypes.map((type) => (
|
|
||||||
<div key={type} className="flex flex-col gap-1.5">
|
export function ReleaseChanges({ release }: { release: Release }) {
|
||||||
<h3 className="text-base font-semibold text-foreground">
|
const { grouped, orderedTypes } = groupAndOrderChanges({
|
||||||
{getSectionTitle(type)}:
|
changes: release.changes,
|
||||||
</h3>
|
});
|
||||||
<ul className="list-disc pl-5 space-y-1.5">
|
|
||||||
{grouped[type].map((change) => (
|
return (
|
||||||
<li
|
<div className="flex flex-col gap-4">
|
||||||
key={change.text}
|
{orderedTypes.map((type) => (
|
||||||
className="text-base text-foreground leading-relaxed"
|
<ReleaseChangeSection key={type} type={type} changes={grouped[type]} />
|
||||||
>
|
))}
|
||||||
{change.text}
|
</div>
|
||||||
</li>
|
);
|
||||||
))}
|
}
|
||||||
</ul>
|
|
||||||
</div>
|
function ReleaseChangeSection({
|
||||||
))}
|
type,
|
||||||
</div>
|
changes,
|
||||||
);
|
}: {
|
||||||
}
|
type: string;
|
||||||
|
changes: Change[];
|
||||||
|
}) {
|
||||||
|
const title = getSectionTitle({ type });
|
||||||
|
|
||||||
|
if (isSectionCollapsible({ type })) {
|
||||||
|
return (
|
||||||
|
<details className="group flex flex-col gap-1.5">
|
||||||
|
<summary className="flex w-fit cursor-pointer list-none items-center gap-1.5 text-base font-semibold text-foreground [&::-webkit-details-marker]:hidden">
|
||||||
|
<span>{title}:</span>
|
||||||
|
<HugeiconsIcon
|
||||||
|
icon={ArrowRightIcon}
|
||||||
|
className="size-3 shrink-0 text-muted-foreground group-open:rotate-90"
|
||||||
|
/>
|
||||||
|
</summary>
|
||||||
|
<ReleaseChangeList changes={changes} />
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<h3 className="text-base font-semibold text-foreground">{title}:</h3>
|
||||||
|
<ReleaseChangeList changes={changes} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReleaseChangeList({
|
||||||
|
changes,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
changes: Change[];
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ul className={cn("list-disc space-y-1.5 pl-5", className)}>
|
||||||
|
{changes.map((change) => (
|
||||||
|
<li
|
||||||
|
key={change.text}
|
||||||
|
className="text-base leading-relaxed text-foreground"
|
||||||
|
>
|
||||||
|
<ReactMarkdownWrapper inline>{change.text}</ReactMarkdownWrapper>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
---
|
---
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
date: "2026-02-23"
|
date: "2026-02-23"
|
||||||
|
published: true
|
||||||
title: "Editor foundation"
|
title: "Editor foundation"
|
||||||
description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use."
|
description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use."
|
||||||
|
summary: "Properties panel overhaul, 1,000+ fonts, blend modes, a new color picker, and direct manipulation in the preview."
|
||||||
changes:
|
changes:
|
||||||
- type: new
|
- type: new
|
||||||
text: "Rebuilt the properties panel from scratch. Number inputs now support math expressions and click-drag scrubbing instead of just typing values in."
|
text: "Rebuilt the properties panel from scratch. Number inputs now support math expressions and click-drag scrubbing instead of just typing values in."
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
---
|
---
|
||||||
version: "0.2.0"
|
version: "0.2.0"
|
||||||
date: "2026-03-01"
|
date: "2026-03-01"
|
||||||
|
published: true
|
||||||
title: "Motion & effects"
|
title: "Motion & effects"
|
||||||
description: "This release adds the foundation for motion and effects, alongside many improvements & fixes."
|
description: "This release adds the foundation for motion and effects, alongside many improvements & fixes."
|
||||||
|
summary: "Keyframe animation, a new effects system with per-clip blur, and ripple editing mode."
|
||||||
changes:
|
changes:
|
||||||
- type: new
|
- type: new
|
||||||
text: "Keyframe animation system for animating element properties over time."
|
text: "Keyframe animation system for animating element properties over time."
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user