Compare commits

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 03:27:23 +02:00
Maze Winther 99137fe493 docs: capture mask rust migration notes
Made-with: Cursor
2026-05-02 03:27:23 +02:00
Maze Winther 79dcec8a2e refactor: split builtin and freeform masks
Made-with: Cursor
2026-05-02 03:27:23 +02:00
Maze Winther 97f8d1968f refactor: type mask handle identifiers
Made-with: Cursor
2026-05-02 03:27:23 +02:00
Maze Winther ae1292a14b refactor: make mask renderer dispatch explicit
Made-with: Cursor
2026-05-02 03:27:23 +02:00
Maze Winther 6ea5a156b6 refactor: split mask snap dispatch
Made-with: Cursor
2026-05-02 03:27:23 +02:00
Maze Winther 401cc35743 refactor: animate params over time 2026-05-02 03:27:23 +02:00
Maze Winther b8d08df244 chore: cleanup 2026-05-02 03:27:23 +02:00
Maze Winther 2ee0a44735 refactor: unify element params 2026-05-02 03:27:23 +02:00
Maze Winther a9b9cf6bf5 chore: switch from biome to eslint + prettier; fix ton of lint issues 2026-05-02 03:27:23 +02:00
Maze Winther d6622dc6b3 chore: untrack cargo-tools files 2026-05-02 03:10:08 +02:00
Maze Winther 6ec818fc11 chore: remove accidental file
Made-with: Cursor
2026-04-26 21:31:12 +02:00
Maze Winther 6daa69635b fix: restore README header, badges, links, and star chart
Made-with: Cursor
2026-04-26 20:51:32 +02:00
Maze Winther 6b1f38a9a3 refactor: split animation helpers by domain 2026-04-26 20:44:07 +02:00
Maze Winther 56ca09692a refactor: thinner hooks 2026-04-26 17:31:28 +02:00
Maze Winther 0b7597b31f refactor: split animation helpers by domain 2026-04-26 04:04:30 +02:00
Maze Winther e6c6193638 fix: linter 2026-04-26 03:06:13 +02:00
Maze Winther 685d902740 fix: follow-up to prev commit 2026-04-26 02:59:30 +02:00
Maze Winther eea6d43c82 fix: model editor time as wasm MediaTime ticks end-to-end 2026-04-26 02:50:04 +02:00
Maze Winther 6a22a3ecbd refactor: centralize editor interaction state
Move timeline and preview gestures into dedicated controllers so hooks stay thin around editor state and DOM events.

This also routes drag and playback synchronization through manager APIs and reuses persistent wasm surfaces so scrubbing, seeking, and preview rendering stay in sync.

Made-with: Cursor
2026-04-25 03:56:22 +02:00
Maze Winther 0dffefdd59 fix: surface unsupported video codec on import, fix Input leaks 2026-04-25 02:46:31 +02:00
Maze Winther cceb835a84 perf: improve rendering performance by ~20x 2026-04-22 16:48:37 +02:00
Maze Winther f1b45b4328 chore: remove stale files 2026-04-22 14:36:51 +02:00
Maze Winther 7428dc75cf chore: bump opencut-wasm version 2026-04-22 14:24:28 +02:00
Maze Winther 1301d027e9 fix: wasm WebGL only until WebGPU is fixed 2026-04-22 14:20:40 +02:00
Maze Winther f5f31b43df refactor: migration run() and pure v1-to-v2 transform 2026-04-22 02:41:18 +02:00
Maze Winther a709af0059 chore: formatting 2026-04-22 02:05:41 +02:00
Maze Winther 75eebf52bc fix: project loading thing 2026-04-21 20:45:33 +02:00
Maze Winther 1582423dbf chore: delete ffmpeg public assets 2026-04-21 13:00:26 +02:00
Maze Winther 148d38d7c9 docs: ts conventions to agents.md 2026-04-21 12:47:21 +02:00
Maze Winther 3e89d29985 refactor: restructure files to their domains, new preview overlay system, and dep graph 2026-04-20 11:31:17 +02:00
Maze Winther 729d10592f fix: WebGL frame rendering and opencut-wasm 0.2.8 2026-04-19 07:42:52 +02:00
Maze Winther 9da365bc01 docs: add prerequisites for rust 2026-04-19 07:23:15 +02:00
Maze Winther 8bdc894690 fix: migrate commands to CommandResult selection contract 2026-04-18 16:01:35 +02:00
Maze Winther 0387bd4c80 delete legacy folder 2026-04-18 15:35:04 +02:00
Maze Winther b1d0b10354 fix: default audio waveform color from timeline theme 2026-04-18 15:11:52 +02:00
Maze Winther 94eed96cad chore: update opencut-wasm version to 0.2.6 in Cargo.lock 2026-04-18 15:03:00 +02:00
Maze Winther 794d4ae149 fix: align LayerUniformBuffer to 16 bytes for WebGL compat 2026-04-18 15:01:59 +02:00
Maze Winther 011f51051c feat: clip volume line and timeline waveform improvements 2026-04-18 01:40:18 +02:00
Maze Winther c46d28891f feat: text / custom masks, fix preview pan, refactor selection 2026-04-15 15:45:50 +02:00
Maze Winther 66e4367bc4 feat: history view in feedback popover 2026-04-15 06:39:52 +02:00
Maze Winther 8161de00e6 chore: formatting 2026-04-15 06:26:55 +02:00
Maze Winther eb28942dab fix: audio waveform height in timeline 2026-04-15 06:05:13 +02:00
Maze Winther 68f175d3be fix: correct schema path in drizzle config 2026-04-15 06:04:29 +02:00
Maze Winther d5c1b7bf0e fix: load dotenv before env validation in drizzle config 2026-04-15 06:03:23 +02:00
Maze Winther 0868d9cf13 tests: delete 2026-04-15 05:53:06 +02:00
Maze Winther 8a3577d2e7 review: fixes 2026-04-15 05:52:33 +02:00
Maze Winther ac43a5a73f feat: move/resize multiple elements 2026-04-15 05:43:29 +02:00
Maze Winther 6312fc5b07 fix: repair scene tracks broken by v23-to-v24 migration 2026-04-15 04:45:15 +02:00
Maze Winther 2da93aa98f changelog: rephrasing 2026-04-15 04:34:36 +02:00
Maze Winther f4bd689f51 chore: force deployment 2026-04-15 03:59:52 +02:00
Maze Winther 4f7dc5ee3a fix: remap split-element keybinding to split 2026-04-15 03:57:51 +02:00
Maze Winther 4e8b7d3745 fix: skip unknown actions in keyboard shortcuts help 2026-04-15 03:47:57 +02:00
Maze Winther ee372266ca chore: force deployment 2026-04-15 03:35:07 +02:00
Maze Winther e10b0a3355 tests: remove 2026-04-15 02:44:52 +02:00
Maze Winther 3914ea9b71 fix: show changelog notice when stored version is missing or older 2026-04-15 02:13:42 +02:00
Maze Winther 370944f443 feat: new-release notice and colocate changelog under domain 2026-04-15 02:05:41 +02:00
Maze Winther c839db9234 fix: remove redundant "opencut" prefix from storage key 2026-04-15 01:50:30 +02:00
Maze Winther 769a7e708a changelog: another accuracy fix 2026-04-15 01:02:12 +02:00
Maze Winther 1fa5442f21 chore: remove debugging throughout codebase 2026-04-15 01:00:05 +02:00
Maze Winther dc0a8e7c96 feat: graph editor supports multiple properties at once 2026-04-15 00:59:54 +02:00
Maze Winther 3db30c72e7 fix: turbo 2026-04-15 00:41:52 +02:00
Maze Winther eca13e3e71 changelog: update to be more accurate 2026-04-15 00:40:22 +02:00
Maze Winther 1943a4d18f feat: changelog now supports markdown within entries 2026-04-15 00:33:59 +02:00
Maze Winther 3eccc2f9e3 fix: copy to markdown formatting 2026-04-15 00:29:04 +02:00
Maze Winther 368a20ddc2 changelog: fix date 2026-04-15 00:26:12 +02:00
Maze Winther 64588ceeea docs: fill in v0.3.0 technical changelog entries 2026-04-15 00:22:20 +02:00
Maze Winther 8fe66090e7 feat: add support for technical details in changelog entries 2026-04-14 23:55:20 +02:00
Maze Winther 91e1232112 feat: transcription diagnostics 2026-04-14 23:34:57 +02:00
Maze Winther f726d95295 chore: add .tokeignore 2026-04-14 22:10:15 +02:00
Maze Winther 78a0a27a72 feat: editor feedback 2026-04-14 21:45:11 +02:00
Maze Winther b68bf00454 docs: formatting 2026-04-14 20:49:17 +02:00
Maze Winther cd19a4409f docs: update file structure to be accurate 2026-04-14 20:47:30 +02:00
Maze Winther f1ed84006a docs: remove features section 2026-04-14 20:43:18 +02:00
Maze Winther 8d82ea9c0b feat: draft persistence on Form via persistKey 2026-04-14 19:13:06 +02:00
Maze Winther 6aa4ffe462 style: export button icon size 2026-04-14 18:38:59 +02:00
Maze Winther 8a54e5d884 test: remove keyframe query 2026-04-14 18:34:33 +02:00
Maze Winther 5e6c26f3c1 fix: keyframe removal state 2026-04-14 18:32:07 +02:00
Maze Winther 520c0b3264 chore: remove unused import 2026-04-14 18:30:34 +02:00
Maze Winther 607eaeacc7 fix: timeline element resizing 2026-04-14 18:29:23 +02:00
Maze Winther 6de6c7fd95 refactor: move constants to domain modules 2026-04-14 18:17:14 +02:00
Maze Winther 34c67ef8e8 chore: fix comment 2026-04-14 15:27:24 +02:00
Maze Winther 5f2f00dde0 chore: hide logos sticker category 2026-04-14 15:14:30 +02:00
Maze Winther 6db7ad0696 chore: hide preview grid guide until v0.4.0 2026-04-14 15:08:06 +02:00
Maze Winther 24a0d10a92 changelog 2026-04-14 14:57:40 +02:00
e3d4f3fa92 Add support for basic SRT subtitle import to Captions panel (#756)
* Add SRT subtitle import to the captions panel

[Modified from 0bd641b4102c827ef8d218ada099229f73396077, 2026-03-28 08:46:23 -0400]

Parse .srt files into chunks and insert them as text elements on a new text track using the existing caption styling defaults. Displays a warning when malformed cues are skipped during import.

* Batch caption text insertion

[Modified from eece3bbc5f0578a24a4f90f0ab87fe5ca37e94e9, 2026-03-28 09:13:18 -0400]

Batch caption insertion in a BatchCommand so that undo will batch undo all subtitle insertion from either srt file or ai transcriptions.

* Move subtitle parsing into a shared interface

[Modified from e84404b7857f8c611eda7a65b5d61ee6c9823d75, 2026-03-28 09:52:31 -0400]

Extracted the srt specific extraction behind a generic shared interface to allow easier adding of other subtitle formats in the future.

* Apply better default styling for subtitles.

[Modified from b5deec4285f99873ba5bf57971f7bcab26e8c743, 2026-03-31 11:49:34 -0400]

Shrink font size, aim to layout 5% above bottom, keep subtitle width 80% of video, wrap text on new lines if too long.

* Update Captions Panel UI

Split captions panel into Generate and Import tabs as they are two different approaches for adding captions.

* Add biome formatting fixes

* Add .ass subtitle import support

Extend subtitle import to accept .ass files alongside .srt, map supported .ass styles onto imported text elements, and display warnings when unsupported .ass features are skipped.

* Change “Import subtitles” button style

* refactor: review fixes

* fix: convert subtitle times from seconds to ticks

* refactor: replace captions tab switcher with import action button

---------

Co-authored-by: Maze Winther <mazewinther@gmail.com>
2026-04-14 03:19:12 +02:00
Maze Winther d85566ab8d changelog: publish 0.3.0 2026-04-14 00:50:18 +02:00
Maze Winther 3191e038d8 changelog 2026-04-14 00:33:28 +02:00
Maze Winther ba994d8bf9 fix: linter 2026-04-13 22:00:29 +02:00
Maze Winther 270e2951b1 feat: graph editor for flat keyframe segments 2026-04-13 21:59:50 +02:00
Maze Winther 496f7d690d chore: remove filters tab from assets panel 2026-04-13 21:56:47 +02:00
Maze Winther 11ee0dbf27 fix: stable background blur previews in settings 2026-04-13 21:56:18 +02:00
Maze Winther 2c344fa01d fix: use published opencut-wasm package 2026-04-13 05:08:29 +02:00
Maze Winther 3d9d669cb9 chore: bump opencut-wasm to 0.2.5 2026-04-13 05:08:29 +02:00
Maze Winther 7feb3b238c changelog: 0.3.0
Made-with: Cursor
2026-04-13 05:08:29 +02:00
Maze Winther 7d4e166678 fix: text measurement, rotation handle, video cache races, playback sync
Made-with: Cursor
2026-04-13 05:08:29 +02:00
Maze Winther cad88ee4d1 refactor: rendering pipeline
Made-with: Cursor
2026-04-13 05:08:29 +02:00
Maze Winther 36efdf66e7 feat: expandable keyframe lanes in timeline
Made-with: Cursor
2026-04-13 05:08:29 +02:00
Maze Winther 2272598d42 feat: clipboard with keyframe paste
Made-with: Cursor
2026-04-13 05:03:10 +02:00
Maze Winther 5a2aaf3601 feat: cinematic bars, diamond, heart, and star masks
Made-with: Cursor
2026-04-13 05:01:51 +02:00
Maze Winther 108d3f4eca feat: position animated independently per axis
Made-with: Cursor
2026-04-13 05:01:51 +02:00
Maze Winther 8230faf082 feat: wasm compositor
Made-with: Cursor
2026-04-13 05:01:51 +02:00
Maze Winther dbd68ff139 refactor: gpu webgl fallback and batched command encoding
Made-with: Cursor
2026-04-13 05:01:51 +02:00
Maze Winther 0d0d737563 fix: one more 2026-04-13 05:01:51 +02:00
Maze Winther f06a930fc1 fix: convert media asset duration to ticks on insert 2026-04-13 05:01:51 +02:00
Maze Winther 532f050a64 changelog: add entry 2026-04-13 05:00:19 +02:00
Maze Winther d422b602c9 fix: clicking a selected element collapses multi-selection 2026-04-13 05:00:19 +02:00
Maze Winther cbd1b82123 refactor: structured track layout per scene 2026-04-07 04:41:27 +02:00
Maze Winther 4f7d401a97 style: compact dropdown and context menu styling 2026-04-07 01:55:21 +02:00
Maze Winther 30709d7134 fix: fix timeline width calculation for tick-based time 2026-04-07 01:45:52 +02:00
Maze Winther 93a0afdf64 fix: use line-height-based bounds for text elements 2026-04-07 01:39:35 +02:00
Maze Winther b8979c65d4 feat: select element on insert 2026-04-07 01:20:07 +02:00
Maze Winther f47e85c3bb fix: respect scale lock when editing keyframed scale 2026-04-07 01:19:46 +02:00
Maze Winther e43b859439 fix: place new overlay tracks at the top of the timeline 2026-04-07 01:18:57 +02:00
Maze Winther e4b67094e7 refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate 2026-04-07 01:09:13 +02:00
Maze Winther 79df736431 fix: clean up graph editor 2026-04-06 13:15:50 +02:00
Maze Winther c411edb7c1 fix: clean up 2026-04-06 12:59:07 +02:00
Maze Winther 2776adaae1 fix: whitespace 2026-04-06 12:42:46 +02:00
Maze Winther 964ac82d5e refactor: split presets into data definitions and storage store 2026-04-06 12:40:47 +02:00
Maze Winther 05243616ea refactor: use useShiftKey hook in bezier graph 2026-04-06 01:51:09 +02:00
Maze Winther ccd552a1c0 chore: addition to last merge 2026-04-06 01:24:09 +02:00
Maze Winther fcc09e80e2 Merge branch 'temp' 2026-04-06 01:23:47 +02:00
Maze dedb13546a feat: graphs popover 2026-04-06 01:13:14 +02:00
Maze Winther c56d6f8188 fix: preserve negative start times during timeline drag 2026-04-06 00:57:54 +02:00
Maze Winther e62227914a docs: document rust/apps architecture in AGENTS.md 2026-04-06 00:24:21 +02:00
Maze Winther 44d8c4c876 refactor: unify element updates into pipeline and centralize ripple 2026-04-06 00:02:23 +02:00
Maze Winther 20235d0724 chore: remove comment 2026-04-05 19:04:39 +02:00
Maze 0997466331 fix: subscribe to playback time so keyframe button updates on frame change
Made-with: Cursor
2026-04-05 13:37:29 +02:00
Maze 1bd9fac203 refactor: rewrite animation system to use bindings and scalar channels 2026-04-05 13:15:58 +02:00
Maze 69da5bee0c style: tabs 2026-04-04 11:59:04 +02:00
Maze cb99925d75 refactor: swap out manual tabs withthe shared tabs component in stickers 2026-04-04 11:58:55 +02:00
Maze 78c6c497f1 chore: remove transcription envs 2026-04-04 08:53:10 +02:00
Maze Winther 22725f71fa chore: update opencut-wasm to 0.1.3 2026-04-03 03:37:41 +02:00
Maze Winther 1a8a1e889a refactor: update command execute methods to return CommandResult | undefined 2026-04-03 03:13:49 +02:00
Maze Winther 3516bd69f4 fix: use published opencut-wasm package 2026-04-01 21:30:26 +02:00
Maze Winther 57fc0fd323 chore: sort root package.json scripts 2026-04-01 19:29:12 +02:00
Maze Winther bd9ec023ee refactor: split constants by domain and isolate timeline panel from core 2026-04-01 19:27:49 +02:00
Maze Winther f4b9f40ab1 fix: update tooltip text for graph editor button 2026-04-01 18:17:23 +02:00
Maze Winther 93cc5d50f3 fix: don't crash when GPU isn't available 2026-04-01 18:13:27 +02:00
Maze Winther 8a1d37cf3e fix: align background blur with effect blur intensity scale 2026-04-01 15:15:41 +02:00
Maze Winther e579ae1202 feat: migrate GPU renderer from WebGL to wgpu/WASM 2026-04-01 13:57:32 +02:00
795 changed files with 60126 additions and 37798 deletions
+5 -6
View File
@@ -45,10 +45,9 @@ Thank you for your interest in contributing to OpenCut! This document provides g
5. Install dependencies: `bun install`
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.
> 2. Use an alternative package manager such as **bun** or **pnpm**.
> 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`.
### Desktop setup
@@ -169,7 +168,7 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README
2. Make your changes
3. Run the relevant checks for the area you touched:
- Web changes: from `apps/web`, run `bun run lint` and `bunx biome format --write .`
- Web changes: from `apps/web`, run `bun run lint` and `bun run format`
- Desktop changes: run `./apps/desktop/script/setup` if your environment isn't set up yet
4. Commit your changes with a descriptive message
@@ -177,8 +176,8 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README
## Code Style
- We use Biome for code formatting and linting
- Run `bunx biome format --write .` from the `apps/web` directory to format code
- We use ESLint for linting and Prettier for formatting
- Run `bun run format` from the `apps/web` directory to format code
- Run `bun run lint` from the `apps/web` directory to check for linting issues
- Follow the existing code patterns
+342 -344
View File
@@ -1,344 +1,342 @@
---
applyTo: "**/*.{ts,tsx,js,jsx}"
---
# Project Context
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter.
## Key Principles
- Zero configuration required
- Subsecond performance
- Maximum type safety
- AI-friendly code generation
## Before Writing Code
1. Analyze existing patterns in the codebase
2. Consider edge cases and error scenarios
3. Follow the rules below strictly
4. Validate accessibility requirements
## Rules
### Accessibility (a11y)
- Don't use `accessKey` attribute on any HTML element.
- Don't set `aria-hidden="true"` on focusable elements.
- Don't add ARIA roles, states, and properties to elements that don't support them.
- Don't use distracting elements like `<marquee>` or `<blink>`.
- Only use the `scope` prop on `<th>` elements.
- Don't assign non-interactive ARIA roles to interactive HTML elements.
- Make sure label elements have text content and are associated with an input.
- Don't assign interactive ARIA roles to non-interactive HTML elements.
- Don't assign `tabIndex` to non-interactive HTML elements.
- Don't use positive integers for `tabIndex` property.
- Don't include "image", "picture", or "photo" in img alt prop.
- Don't use explicit role property that's the same as the implicit/default role.
- Make static elements with click handlers use a valid role attribute.
- Always include a `title` element for SVG elements.
- Give all elements requiring alt text meaningful information for screen readers.
- Make sure anchors have content that's accessible to screen readers.
- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
- Include all required ARIA attributes for elements with ARIA roles.
- Make sure ARIA properties are valid for the element's supported roles.
- Always include a `type` attribute for button elements.
- Make elements with interactive roles and handlers focusable.
- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
- Always include a `lang` attribute on the html element.
- Always include a `title` attribute for iframe elements.
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
- Include caption tracks for audio and video elements.
- Use semantic elements instead of role attributes in JSX.
- Make sure all anchors are valid and navigable.
- Ensure all ARIA properties (`aria-*`) are valid.
- Use valid, non-abstract ARIA roles for elements with ARIA roles.
- Use valid ARIA state and property values.
- Use valid values for the `autocomplete` attribute on input elements.
- Use correct ISO language/country codes for the `lang` attribute.
### Code Complexity and Quality
- Don't use consecutive spaces in regular expression literals.
- Don't use the `arguments` object.
- Don't use primitive type aliases or misleading types.
- Don't use the comma operator.
- Don't use empty type parameters in type aliases and interfaces.
- Don't write functions that exceed a given Cognitive Complexity score.
- Don't nest describe() blocks too deeply in test files.
- Don't use unnecessary boolean casts.
- Don't use unnecessary callbacks with flatMap.
- Use for...of statements instead of Array.forEach.
- Don't create classes that only have static members (like a static namespace).
- Don't use this and super in static contexts.
- Don't use unnecessary catch clauses.
- Don't use unnecessary constructors.
- Don't use unnecessary continue statements.
- Don't export empty modules that don't change anything.
- Don't use unnecessary escape sequences in regular expression literals.
- Don't use unnecessary fragments.
- Don't use unnecessary labels.
- Don't use unnecessary nested block statements.
- Don't rename imports, exports, and destructured assignments to the same name.
- Don't use unnecessary string or template literal concatenation.
- Don't use String.raw in template literals when there are no escape sequences.
- Don't use useless case statements in switch statements.
- Don't use ternary operators when simpler alternatives exist.
- Don't use useless `this` aliasing.
- Don't use any or unknown as type constraints.
- Don't initialize variables to undefined.
- Don't use the void operators (they're not familiar).
- Use arrow functions instead of function expressions.
- Use Date.now() to get milliseconds since the Unix Epoch.
- Use .flatMap() instead of map().flat() when possible.
- Use literal property access instead of computed property access.
- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
- Use concise optional chaining instead of chained logical expressions.
- Use regular expression literals instead of the RegExp constructor when possible.
- Don't use number literal object member names that aren't base 10 or use underscore separators.
- Remove redundant terms from logical expressions.
- Use while loops instead of for loops when you don't need initializer and update expressions.
- Don't pass children as props.
- Don't reassign const variables.
- Don't use constant expressions in conditions.
- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
- Don't return a value from a constructor.
- Don't use empty character classes in regular expression literals.
- Don't use empty destructuring patterns.
- Don't call global object properties as functions.
- Don't declare functions and vars that are accessible outside their block.
- Make sure builtins are correctly instantiated.
- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
- Don't use variables and function parameters before they're declared.
- Don't use 8 and 9 escape sequences in string literals.
- Don't use literal numbers that lose precision.
### React and JSX Best Practices
- Don't use the return value of React.render.
- Make sure all dependencies are correctly specified in React hooks.
- Make sure all React hooks are called from the top level of component functions.
- Don't forget key props in iterators and collection literals.
- Don't destructure props inside JSX components in Solid projects.
- Don't define React components inside other components.
- Don't use event handlers on non-interactive elements.
- Don't assign to React component props.
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
- Don't use dangerous JSX props.
- Don't use Array index in keys.
- Don't insert comments as text nodes.
- Don't assign JSX properties multiple times.
- Don't add extra closing tags for components without children.
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
- Watch out for possible "wrong" semicolons inside JSX elements.
### Correctness and Safety
- Don't assign a value to itself.
- Don't return a value from a setter.
- Don't compare expressions that modify string case with non-compliant values.
- Don't use lexical declarations in switch clauses.
- Don't use variables that haven't been declared in the document.
- Don't write unreachable code.
- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass.
- Don't use control flow statements in finally blocks.
- Don't use optional chaining where undefined values aren't allowed.
- Don't have unused function parameters.
- Don't have unused imports.
- Don't have unused labels.
- Don't have unused private class members.
- Don't have unused variables.
- Make sure void (self-closing) elements don't have children.
- Don't return a value from a function with the return type 'void'
- Use isNaN() when checking for NaN.
- Make sure "for" loop update clauses move the counter in the right direction.
- Make sure typeof expressions are compared to valid values.
- Make sure generator functions contain yield.
- Don't use await inside loops.
- Don't use bitwise operators.
- Don't use expressions where the operation doesn't change the value.
- Make sure Promise-like statements are handled appropriately.
- Don't use **dirname and **filename in the global scope.
- Prevent import cycles.
- Don't use configured elements.
- Don't hardcode sensitive data like API keys and tokens.
- Don't let variable declarations shadow variables from outer scopes.
- Don't use the TypeScript directive @ts-ignore.
- Prevent duplicate polyfills from Polyfill.io.
- Don't use useless backreferences in regular expressions that always match empty strings.
- Don't use unnecessary escapes in string literals.
- Don't use useless undefined.
- Make sure getters and setters for the same property are next to each other in class and object definitions.
- Make sure object literals are declared consistently (defaults to explicit definitions).
- Use static Response methods instead of new Response() constructor when possible.
- Make sure switch-case statements are exhaustive.
- Make sure the `preconnect` attribute is used when using Google Fonts.
- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
- Make sure iterable callbacks return consistent values.
- Use `with { type: "json" }` for JSON module imports.
- Use numeric separators in numeric literals.
- Use object spread instead of `Object.assign()` when constructing new objects.
- Always use the radix argument when using `parseInt()`.
- Make sure JSDoc comment lines start with a single asterisk, except for the first one.
- Include a description parameter for `Symbol()`.
- Don't use spread (`...`) syntax on accumulators.
- Don't use the `delete` operator.
- Don't access namespace imports dynamically.
- Don't use namespace imports.
- Declare regex literals at the top level.
- Don't use `target="_blank"` without `rel="noopener"`.
### TypeScript Best Practices
- Don't use TypeScript enums.
- Don't export imported variables.
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
- Don't use TypeScript namespaces.
- Don't use non-null assertions with the `!` postfix operator.
- Don't use parameter properties in class constructors.
- Don't use user-defined types.
- Use `as const` instead of literal types and type annotations.
- Use either `T[]` or `Array<T>` consistently.
- Initialize each enum member value explicitly.
- Use `export type` for types.
- Use `import type` for types.
- Make sure all enum members are literal values.
- Don't use TypeScript const enum.
- Don't declare empty interfaces.
- Don't let variables evolve into any type through reassignments.
- Don't use the any type.
- Don't misuse the non-null assertion operator (!) in TypeScript files.
- Don't use implicit any type on variable declarations.
- Don't merge interfaces and classes unsafely.
- Don't use overload signatures that aren't next to each other.
- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
### Style and Consistency
- Don't use global `eval()`.
- Don't use callbacks in asynchronous tests and hooks.
- Don't use negation in `if` statements that have `else` clauses.
- Don't use nested ternary expressions.
- Don't reassign function parameters.
- This rule lets you specify global variable names you don't want to use in your application.
- Don't use specified modules when loaded by import or require.
- Don't use constants whose value is the upper-case version of their name.
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
- Don't use template literals if you don't need interpolation or special-character handling.
- Don't use `else` blocks when the `if` block breaks early.
- Don't use yoda expressions.
- Don't use Array constructors.
- Use `at()` instead of integer index access.
- Follow curly brace conventions.
- Use `else if` instead of nested `if` statements in `else` clauses.
- Use single `if` statements instead of nested `if` clauses.
- Use `new` for all builtins except `String`, `Number`, and `Boolean`.
- Use consistent accessibility modifiers on class properties and methods.
- Use `const` declarations for variables that are only assigned once.
- Put default function parameters and optional function parameters last.
- Include a `default` clause in switch statements.
- Use the `**` operator instead of `Math.pow`.
- Use `for-of` loops when you need the index to extract an item from the iterated array.
- Use `node:assert/strict` over `node:assert`.
- Use the `node:` protocol for Node.js builtin modules.
- Use Number properties instead of global ones.
- Use assignment operator shorthand where possible.
- Use function types instead of object types with call signatures.
- Use template literals over string concatenation.
- Use `new` when throwing an error.
- Don't throw non-Error values.
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
- Use standard constants instead of approximated literals.
- Don't assign values in expressions.
- Don't use async functions as Promise executors.
- Don't reassign exceptions in catch clauses.
- Don't reassign class members.
- Don't compare against -0.
- Don't use labeled statements that aren't loops.
- Don't use void type outside of generic or return types.
- Don't use console.
- Don't use control characters and escape sequences that match control characters in regular expression literals.
- Don't use debugger.
- Don't assign directly to document.cookie.
- Use `===` and `!==`.
- Don't use duplicate case labels.
- Don't use duplicate class members.
- Don't use duplicate conditions in if-else-if chains.
- Don't use two keys with the same name inside objects.
- Don't use duplicate function parameter names.
- Don't have duplicate hooks in describe blocks.
- Don't use empty block statements and static blocks.
- Don't let switch clauses fall through.
- Don't reassign function declarations.
- Don't allow assignments to native objects and read-only global variables.
- Use Number.isFinite instead of global isFinite.
- Use Number.isNaN instead of global isNaN.
- Don't assign to imported bindings.
- Don't use irregular whitespace characters.
- Don't use labels that share a name with a variable.
- Don't use characters made with multiple code points in character class syntax.
- Make sure to use new and constructor properly.
- Don't use shorthand assign when the variable appears on both sides.
- Don't use octal escape sequences in string literals.
- Don't use Object.prototype builtins directly.
- Don't redeclare variables, functions, classes, and types in the same scope.
- Don't have redundant "use strict".
- Don't compare things where both sides are exactly the same.
- Don't let identifiers shadow restricted names.
- Don't use sparse arrays (arrays with holes).
- Don't use template literal placeholder syntax in regular strings.
- Don't use the then property.
- Don't use unsafe negation.
- Don't use var.
- Don't use with statements in non-strict contexts.
- Make sure async functions actually use await.
- Make sure default clauses in switch statements come last.
- Make sure to pass a message value when creating a built-in error.
- Make sure get methods always return a value.
- Use a recommended display strategy with Google Fonts.
- Make sure for-in loops include an if statement.
- Use Array.isArray() instead of instanceof Array.
- Make sure to use the digits argument with Number#toFixed().
- Make sure to use the "use strict" directive in script files.
### Next.js Specific Rules
- Don't use `<img>` elements in Next.js projects.
- Don't use `<head>` elements in Next.js projects.
- Don't import next/document outside of pages/\_document.jsx in Next.js projects.
- Don't use the next/head module in pages/\_document.js on Next.js projects.
### Testing Best Practices
- Don't use export or module.exports in test files.
- Don't use focused tests.
- Make sure the assertion function, like expect, is placed inside an it() function call.
- Don't use disabled tests.
## Common Tasks
- `npx ultracite init` - Initialize Ultracite in your project
- `npx ultracite format` - Format and fix code automatically
- `npx ultracite lint` - Check for issues without fixing
## Example: Error Handling
```typescript
// ✅ Good: Comprehensive error handling
try {
const result = await fetchData();
return { success: true, data: result };
} catch (error) {
console.error("API call failed:", error);
return { success: false, error: error.message };
}
// ❌ Bad: Swallowing errors
try {
return await fetchData();
} catch (e) {
console.log(e);
}
```
---
applyTo: "**/*.{ts,tsx,js,jsx}"
---
# Project Context
Strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects, enforced via ESLint (linting) and Prettier (formatting).
## Key Principles
- Maximum type safety
- AI-friendly code generation
## Before Writing Code
1. Analyze existing patterns in the codebase
2. Consider edge cases and error scenarios
3. Follow the rules below strictly
4. Validate accessibility requirements
## Rules
### Accessibility (a11y)
- Don't use `accessKey` attribute on any HTML element.
- Don't set `aria-hidden="true"` on focusable elements.
- Don't add ARIA roles, states, and properties to elements that don't support them.
- Don't use distracting elements like `<marquee>` or `<blink>`.
- Only use the `scope` prop on `<th>` elements.
- Don't assign non-interactive ARIA roles to interactive HTML elements.
- Make sure label elements have text content and are associated with an input.
- Don't assign interactive ARIA roles to non-interactive HTML elements.
- Don't assign `tabIndex` to non-interactive HTML elements.
- Don't use positive integers for `tabIndex` property.
- Don't include "image", "picture", or "photo" in img alt prop.
- Don't use explicit role property that's the same as the implicit/default role.
- Make static elements with click handlers use a valid role attribute.
- Always include a `title` element for SVG elements.
- Give all elements requiring alt text meaningful information for screen readers.
- Make sure anchors have content that's accessible to screen readers.
- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
- Include all required ARIA attributes for elements with ARIA roles.
- Make sure ARIA properties are valid for the element's supported roles.
- Always include a `type` attribute for button elements.
- Make elements with interactive roles and handlers focusable.
- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
- Always include a `lang` attribute on the html element.
- Always include a `title` attribute for iframe elements.
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
- Include caption tracks for audio and video elements.
- Use semantic elements instead of role attributes in JSX.
- Make sure all anchors are valid and navigable.
- Ensure all ARIA properties (`aria-*`) are valid.
- Use valid, non-abstract ARIA roles for elements with ARIA roles.
- Use valid ARIA state and property values.
- Use valid values for the `autocomplete` attribute on input elements.
- Use correct ISO language/country codes for the `lang` attribute.
### Code Complexity and Quality
- Don't use consecutive spaces in regular expression literals.
- Don't use the `arguments` object.
- Don't use primitive type aliases or misleading types.
- Don't use the comma operator.
- Don't use empty type parameters in type aliases and interfaces.
- Don't write functions that exceed a given Cognitive Complexity score.
- Don't nest describe() blocks too deeply in test files.
- Don't use unnecessary boolean casts.
- Don't use unnecessary callbacks with flatMap.
- Use for...of statements instead of Array.forEach.
- Don't create classes that only have static members (like a static namespace).
- Don't use this and super in static contexts.
- Don't use unnecessary catch clauses.
- Don't use unnecessary constructors.
- Don't use unnecessary continue statements.
- Don't export empty modules that don't change anything.
- Don't use unnecessary escape sequences in regular expression literals.
- Don't use unnecessary fragments.
- Don't use unnecessary labels.
- Don't use unnecessary nested block statements.
- Don't rename imports, exports, and destructured assignments to the same name.
- Don't use unnecessary string or template literal concatenation.
- Don't use String.raw in template literals when there are no escape sequences.
- Don't use useless case statements in switch statements.
- Don't use ternary operators when simpler alternatives exist.
- Don't use useless `this` aliasing.
- Don't use any or unknown as type constraints.
- Don't initialize variables to undefined.
- Don't use the void operators (they're not familiar).
- Use arrow functions instead of function expressions.
- Use Date.now() to get milliseconds since the Unix Epoch.
- Use .flatMap() instead of map().flat() when possible.
- Use literal property access instead of computed property access.
- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
- Use concise optional chaining instead of chained logical expressions.
- Use regular expression literals instead of the RegExp constructor when possible.
- Don't use number literal object member names that aren't base 10 or use underscore separators.
- Remove redundant terms from logical expressions.
- Use while loops instead of for loops when you don't need initializer and update expressions.
- Don't pass children as props.
- Don't reassign const variables.
- Don't use constant expressions in conditions.
- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
- Don't return a value from a constructor.
- Don't use empty character classes in regular expression literals.
- Don't use empty destructuring patterns.
- Don't call global object properties as functions.
- Don't declare functions and vars that are accessible outside their block.
- Make sure builtins are correctly instantiated.
- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
- Don't use variables and function parameters before they're declared.
- Don't use 8 and 9 escape sequences in string literals.
- Don't use literal numbers that lose precision.
### React and JSX Best Practices
- Don't use the return value of React.render.
- Make sure all dependencies are correctly specified in React hooks.
- Make sure all React hooks are called from the top level of component functions.
- Don't forget key props in iterators and collection literals.
- Don't destructure props inside JSX components in Solid projects.
- Don't define React components inside other components.
- Don't use event handlers on non-interactive elements.
- Don't assign to React component props.
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
- Don't use dangerous JSX props.
- Don't use Array index in keys.
- Don't insert comments as text nodes.
- Don't assign JSX properties multiple times.
- Don't add extra closing tags for components without children.
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
- Watch out for possible "wrong" semicolons inside JSX elements.
### Correctness and Safety
- Don't assign a value to itself.
- Don't return a value from a setter.
- Don't compare expressions that modify string case with non-compliant values.
- Don't use lexical declarations in switch clauses.
- Don't use variables that haven't been declared in the document.
- Don't write unreachable code.
- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass.
- Don't use control flow statements in finally blocks.
- Don't use optional chaining where undefined values aren't allowed.
- Don't have unused function parameters.
- Don't have unused imports.
- Don't have unused labels.
- Don't have unused private class members.
- Don't have unused variables.
- Make sure void (self-closing) elements don't have children.
- Don't return a value from a function with the return type 'void'
- Use isNaN() when checking for NaN.
- Make sure "for" loop update clauses move the counter in the right direction.
- Make sure typeof expressions are compared to valid values.
- Make sure generator functions contain yield.
- Don't use await inside loops.
- Don't use bitwise operators.
- Don't use expressions where the operation doesn't change the value.
- Make sure Promise-like statements are handled appropriately.
- Don't use **dirname and **filename in the global scope.
- Prevent import cycles.
- Don't use configured elements.
- Don't hardcode sensitive data like API keys and tokens.
- Don't let variable declarations shadow variables from outer scopes.
- Don't use the TypeScript directive @ts-ignore.
- Prevent duplicate polyfills from Polyfill.io.
- Don't use useless backreferences in regular expressions that always match empty strings.
- Don't use unnecessary escapes in string literals.
- Don't use useless undefined.
- Make sure getters and setters for the same property are next to each other in class and object definitions.
- Make sure object literals are declared consistently (defaults to explicit definitions).
- Use static Response methods instead of new Response() constructor when possible.
- Make sure switch-case statements are exhaustive.
- Make sure the `preconnect` attribute is used when using Google Fonts.
- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
- Make sure iterable callbacks return consistent values.
- Use `with { type: "json" }` for JSON module imports.
- Use numeric separators in numeric literals.
- Use object spread instead of `Object.assign()` when constructing new objects.
- Always use the radix argument when using `parseInt()`.
- Make sure JSDoc comment lines start with a single asterisk, except for the first one.
- Include a description parameter for `Symbol()`.
- Don't use spread (`...`) syntax on accumulators.
- Don't use the `delete` operator.
- Don't access namespace imports dynamically.
- Don't use namespace imports.
- Declare regex literals at the top level.
- Don't use `target="_blank"` without `rel="noopener"`.
### TypeScript Best Practices
- Don't use TypeScript enums.
- Don't export imported variables.
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
- Don't use TypeScript namespaces.
- Don't use non-null assertions with the `!` postfix operator.
- Don't use parameter properties in class constructors.
- Don't use user-defined types.
- Use `as const` instead of literal types and type annotations.
- Use either `T[]` or `Array<T>` consistently.
- Initialize each enum member value explicitly.
- Use `export type` for types.
- Use `import type` for types.
- Make sure all enum members are literal values.
- Don't use TypeScript const enum.
- Don't declare empty interfaces.
- Don't let variables evolve into any type through reassignments.
- Don't use the any type.
- Don't misuse the non-null assertion operator (!) in TypeScript files.
- Don't use implicit any type on variable declarations.
- Don't merge interfaces and classes unsafely.
- Don't use overload signatures that aren't next to each other.
- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
### Style and Consistency
- Don't use global `eval()`.
- Don't use callbacks in asynchronous tests and hooks.
- Don't use negation in `if` statements that have `else` clauses.
- Don't use nested ternary expressions.
- Don't reassign function parameters.
- This rule lets you specify global variable names you don't want to use in your application.
- Don't use specified modules when loaded by import or require.
- Don't use constants whose value is the upper-case version of their name.
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
- Don't use template literals if you don't need interpolation or special-character handling.
- Don't use `else` blocks when the `if` block breaks early.
- Don't use yoda expressions.
- Don't use Array constructors.
- Use `at()` instead of integer index access.
- Follow curly brace conventions.
- Use `else if` instead of nested `if` statements in `else` clauses.
- Use single `if` statements instead of nested `if` clauses.
- Use `new` for all builtins except `String`, `Number`, and `Boolean`.
- Use consistent accessibility modifiers on class properties and methods.
- Use `const` declarations for variables that are only assigned once.
- Put default function parameters and optional function parameters last.
- Include a `default` clause in switch statements.
- Use the `**` operator instead of `Math.pow`.
- Use `for-of` loops when you need the index to extract an item from the iterated array.
- Use `node:assert/strict` over `node:assert`.
- Use the `node:` protocol for Node.js builtin modules.
- Use Number properties instead of global ones.
- Use assignment operator shorthand where possible.
- Use function types instead of object types with call signatures.
- Use template literals over string concatenation.
- Use `new` when throwing an error.
- Don't throw non-Error values.
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
- Use standard constants instead of approximated literals.
- Don't assign values in expressions.
- Don't use async functions as Promise executors.
- Don't reassign exceptions in catch clauses.
- Don't reassign class members.
- Don't compare against -0.
- Don't use labeled statements that aren't loops.
- Don't use void type outside of generic or return types.
- Don't use console.
- Don't use control characters and escape sequences that match control characters in regular expression literals.
- Don't use debugger.
- Don't assign directly to document.cookie.
- Use `===` and `!==`.
- Don't use duplicate case labels.
- Don't use duplicate class members.
- Don't use duplicate conditions in if-else-if chains.
- Don't use two keys with the same name inside objects.
- Don't use duplicate function parameter names.
- Don't have duplicate hooks in describe blocks.
- Don't use empty block statements and static blocks.
- Don't let switch clauses fall through.
- Don't reassign function declarations.
- Don't allow assignments to native objects and read-only global variables.
- Use Number.isFinite instead of global isFinite.
- Use Number.isNaN instead of global isNaN.
- Don't assign to imported bindings.
- Don't use irregular whitespace characters.
- Don't use labels that share a name with a variable.
- Don't use characters made with multiple code points in character class syntax.
- Make sure to use new and constructor properly.
- Don't use shorthand assign when the variable appears on both sides.
- Don't use octal escape sequences in string literals.
- Don't use Object.prototype builtins directly.
- Don't redeclare variables, functions, classes, and types in the same scope.
- Don't have redundant "use strict".
- Don't compare things where both sides are exactly the same.
- Don't let identifiers shadow restricted names.
- Don't use sparse arrays (arrays with holes).
- Don't use template literal placeholder syntax in regular strings.
- Don't use the then property.
- Don't use unsafe negation.
- Don't use var.
- Don't use with statements in non-strict contexts.
- Make sure async functions actually use await.
- Make sure default clauses in switch statements come last.
- Make sure to pass a message value when creating a built-in error.
- Make sure get methods always return a value.
- Use a recommended display strategy with Google Fonts.
- Make sure for-in loops include an if statement.
- Use Array.isArray() instead of instanceof Array.
- Make sure to use the digits argument with Number#toFixed().
- Make sure to use the "use strict" directive in script files.
### Next.js Specific Rules
- Don't use `<img>` elements in Next.js projects.
- Don't use `<head>` elements in Next.js projects.
- Don't import next/document outside of pages/\_document.jsx in Next.js projects.
- Don't use the next/head module in pages/\_document.js on Next.js projects.
### Testing Best Practices
- Don't use export or module.exports in test files.
- Don't use focused tests.
- Make sure the assertion function, like expect, is placed inside an it() function call.
- Don't use disabled tests.
## Common Tasks
- `npx ultracite init` - Initialize Ultracite in your project
- `npx ultracite format` - Format and fix code automatically
- `npx ultracite lint` - Check for issues without fixing
## Example: Error Handling
```typescript
// ✅ Good: Comprehensive error handling
try {
const result = await fetchData();
return { success: true, data: result };
} catch (error) {
console.error("API call failed:", error);
return { success: false, error: error.message };
}
// ❌ Bad: Swallowing errors
try {
return await fetchData();
} catch (e) {
console.log(e);
}
```
-5
View File
@@ -31,11 +31,6 @@ jobs:
MARBLE_WORKSPACE_KEY: "placeholder"
FREESOUND_CLIENT_ID: "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:
- name: Checkout repository
+3 -1
View File
@@ -23,4 +23,6 @@ bun.lockb
target/
.cargo-tools/
.cargo-tools/
rust-migration-notes.md
+6
View File
@@ -0,0 +1,6 @@
.next
node_modules
dist
build
target
rust/wasm/pkg
+3
View File
@@ -0,0 +1,3 @@
{
"useTabs": true
}
+7
View File
@@ -0,0 +1,7 @@
*.json
*.svg
*.xml
*.lock
**/dist/**
**/build/**
**/node_modules/**
+2 -9
View File
@@ -7,14 +7,7 @@
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
"source.fixAll.eslint": "explicit"
},
"emmet.showExpandedAbbreviation": "never",
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
"emmet.showExpandedAbbreviation": "never"
}
+11 -5
View File
@@ -1,13 +1,19 @@
# Agents.md
## Apps
## Architecture
- Web
- Desktop
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.
## Rust
### `rust/`
Shared code between apps live in `rust/`, not in `packages/`
The single source of truth for all non-UI code. Everything platform-agnostic belongs here: no components, no hooks, no framework imports.
### `apps/`
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.
- `web/` — Next.js
- `desktop/` — GPUI
## Web
Generated
+628 -31
View File
@@ -60,6 +60,21 @@ dependencies = [
"equator",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -136,7 +151,7 @@ checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82"
dependencies = [
"ash",
"raw-window-handle",
"raw-window-metal",
"raw-window-metal 0.4.0",
]
[[package]]
@@ -502,7 +517,16 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
"bit-vec 0.8.0",
]
[[package]]
name = "bit-set"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd"
dependencies = [
"bit-vec 0.9.1",
]
[[package]]
@@ -511,6 +535,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
[[package]]
name = "bit_field"
version = "0.10.3"
@@ -548,8 +578,8 @@ dependencies = [
"ash-window",
"bitflags 2.11.0",
"bytemuck",
"codespan-reporting",
"glow",
"codespan-reporting 0.12.0",
"glow 0.16.0",
"gpu-alloc",
"gpu-alloc-ash",
"hidden-trait",
@@ -558,7 +588,7 @@ dependencies = [
"libloading",
"log",
"mint",
"naga",
"naga 25.0.1",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
@@ -898,6 +928,17 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "codespan-reporting"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"serde",
"termcolor",
"unicode-width",
]
[[package]]
name = "color_quant"
version = "1.1.0"
@@ -914,6 +955,18 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "compositor"
version = "0.1.0"
dependencies = [
"bytemuck",
"effects",
"gpu",
"masks",
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "compression-codecs"
version = "0.4.37"
@@ -941,6 +994,16 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "const-random"
version = "0.1.18"
@@ -1332,6 +1395,15 @@ dependencies = [
"libloading",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
@@ -1377,6 +1449,16 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "effects"
version = "0.1.0"
dependencies = [
"bytemuck",
"gpu",
"thiserror 2.0.18",
"wgpu",
]
[[package]]
name = "either"
version = "1.15.0"
@@ -1665,6 +1747,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "font-types"
version = "0.11.1"
@@ -1954,6 +2042,17 @@ dependencies = [
"weezl",
]
[[package]]
name = "gl_generator"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
dependencies = [
"khronos_api",
"log",
"xml-rs",
]
[[package]]
name = "glob"
version = "0.3.3"
@@ -2010,6 +2109,38 @@ dependencies = [
"web-sys",
]
[[package]]
name = "glow"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
dependencies = [
"js-sys",
"slotmap",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "glutin_wgl_sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
dependencies = [
"gl_generator",
]
[[package]]
name = "gpu"
version = "0.1.0"
dependencies = [
"bytemuck",
"thiserror 2.0.18",
"wasm-bindgen",
"web-sys",
"wgpu",
]
[[package]]
name = "gpu-alloc"
version = "0.6.0"
@@ -2040,6 +2171,40 @@ dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "gpu-allocator"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795"
dependencies = [
"ash",
"hashbrown 0.16.1",
"log",
"presser",
"thiserror 2.0.18",
"windows 0.62.2",
]
[[package]]
name = "gpu-descriptor"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca"
dependencies = [
"bitflags 2.11.0",
"gpu-descriptor-types",
"hashbrown 0.15.5",
]
[[package]]
name = "gpu-descriptor-types"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "gpui"
version = "0.2.2"
@@ -2091,7 +2256,7 @@ dependencies = [
"log",
"lyon",
"metal",
"naga",
"naga 25.0.1",
"num_cpus",
"objc",
"oo7",
@@ -2126,7 +2291,7 @@ dependencies = [
"wayland-protocols-plasma",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-numerics",
"windows-numerics 0.2.0",
"windows-registry 0.5.3",
"x11-clipboard",
"x11rb",
@@ -2348,7 +2513,7 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
"foldhash 0.1.5",
]
[[package]]
@@ -2356,6 +2521,11 @@ name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
]
[[package]]
name = "heck"
@@ -2789,6 +2959,34 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "jobserver"
version = "0.1.34"
@@ -2801,9 +2999,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.92"
version = "0.3.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d"
dependencies = [
"cfg-if",
"futures-util",
@@ -2819,8 +3017,15 @@ checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
dependencies = [
"libc",
"libloading",
"pkg-config",
]
[[package]]
name = "khronos_api"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kurbo"
version = "0.11.3"
@@ -2939,6 +3144,12 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -3040,6 +3251,15 @@ dependencies = [
"libc",
]
[[package]]
name = "masks"
version = "0.1.0"
dependencies = [
"bytemuck",
"gpu",
"wgpu",
]
[[package]]
name = "maybe-rayon"
version = "0.1.1"
@@ -3165,10 +3385,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632"
dependencies = [
"arrayvec",
"bit-set",
"bit-set 0.8.0",
"bitflags 2.11.0",
"cfg_aliases",
"codespan-reporting",
"codespan-reporting 0.12.0",
"half",
"hashbrown 0.15.5",
"hexf-parse",
@@ -3177,12 +3397,38 @@ dependencies = [
"num-traits",
"once_cell",
"rustc-hash 1.1.0",
"spirv",
"spirv 0.3.0+sdk-1.3.268.0",
"strum 0.26.3",
"thiserror 2.0.18",
"unicode-ident",
]
[[package]]
name = "naga"
version = "29.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa2630921705b9b01dcdd0b6864b9562ca3c1951eecd0f0c4f5f04f61e412647"
dependencies = [
"arrayvec",
"bit-set 0.9.1",
"bitflags 2.11.0",
"cfg-if",
"cfg_aliases",
"codespan-reporting 0.13.1",
"half",
"hashbrown 0.16.1",
"hexf-parse",
"indexmap",
"libm",
"log",
"num-traits",
"once_cell",
"rustc-hash 1.1.0",
"spirv 0.4.0+sdk-1.4.341.0",
"thiserror 2.0.18",
"unicode-ident",
]
[[package]]
name = "nanorand"
version = "0.7.0"
@@ -3192,6 +3438,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@@ -3565,9 +3820,22 @@ dependencies = [
[[package]]
name = "opencut-wasm"
version = "0.1.2"
version = "0.2.10"
dependencies = [
"bridge",
"compositor",
"console_error_panic_hook",
"effects",
"gpu",
"js-sys",
"masks",
"num-traits",
"serde",
"serde-wasm-bindgen",
"time",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
@@ -3582,6 +3850,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-float"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e"
dependencies = [
"num-traits",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -3781,6 +4058,21 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7"
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
dependencies = [
"portable-atomic",
]
[[package]]
name = "postage"
version = "0.5.0"
@@ -3816,6 +4108,12 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "presser"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -4069,6 +4367,12 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "range-alloc"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
[[package]]
name = "rangemap"
version = "1.7.1"
@@ -4143,6 +4447,18 @@ dependencies = [
"raw-window-handle",
]
[[package]]
name = "raw-window-metal"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
dependencies = [
"objc2",
"objc2-core-foundation",
"objc2-foundation",
"objc2-quartz-core",
]
[[package]]
name = "rayon"
version = "1.11.0"
@@ -4260,6 +4576,12 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "renderdoc-sys"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "resvg"
version = "0.45.1"
@@ -4882,6 +5204,15 @@ dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "spirv"
version = "0.4.0+sdk-1.4.341.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -5299,6 +5630,7 @@ name = "time"
version = "0.1.0"
dependencies = [
"bridge",
"num-traits",
"serde",
"tsify-next",
"wasm-bindgen",
@@ -5927,9 +6259,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.115"
version = "0.2.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631"
dependencies = [
"cfg-if",
"once_cell",
@@ -5940,9 +6272,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.65"
version = "0.4.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
checksum = "19280959e2844181895ef62f065c63e0ca07ece4771b53d89bfdb967d97cbf05"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5950,9 +6282,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.115"
version = "0.2.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5960,9 +6292,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.115"
version = "0.2.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -5973,9 +6305,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.115"
version = "0.2.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633"
dependencies = [
"unicode-ident",
]
@@ -6126,9 +6458,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.92"
version = "0.3.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
checksum = "749466a37ee189057f54748b200186b59a03417a117267baf3fd89cecc9fb837"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -6150,6 +6482,183 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "wgpu"
version = "29.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72c239a9a747bbd379590985bac952c2e53cb19873f7072b3370c6a6a8e06837"
dependencies = [
"arrayvec",
"bitflags 2.11.0",
"bytemuck",
"cfg-if",
"cfg_aliases",
"document-features",
"hashbrown 0.16.1",
"js-sys",
"log",
"naga 29.0.1",
"parking_lot",
"portable-atomic",
"profiling",
"raw-window-handle",
"smallvec",
"static_assertions",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"wgpu-core",
"wgpu-hal",
"wgpu-types",
]
[[package]]
name = "wgpu-core"
version = "29.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e80ac6cf1895df6342f87d975162108f9d98772a0d74bc404ab7304ac29469e"
dependencies = [
"arrayvec",
"bit-set 0.9.1",
"bit-vec 0.9.1",
"bitflags 2.11.0",
"bytemuck",
"cfg_aliases",
"document-features",
"hashbrown 0.16.1",
"indexmap",
"log",
"naga 29.0.1",
"once_cell",
"parking_lot",
"portable-atomic",
"profiling",
"raw-window-handle",
"rustc-hash 1.1.0",
"smallvec",
"thiserror 2.0.18",
"wgpu-core-deps-apple",
"wgpu-core-deps-emscripten",
"wgpu-core-deps-wasm",
"wgpu-core-deps-windows-linux-android",
"wgpu-hal",
"wgpu-naga-bridge",
"wgpu-types",
]
[[package]]
name = "wgpu-core-deps-apple"
version = "29.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43acd053312501689cd92a01a9638d37f3e41a5fd9534875efa8917ee2d11ac0"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-emscripten"
version = "29.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef043bf135cc68b6f667c55ff4e345ce2b5924d75bad36a47921b0287ca4b24a"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-wasm"
version = "29.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f7b75e72f49035f000dd5262e4126242e92a090a4fd75931ecfe7e60784e6fa"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-windows-linux-android"
version = "29.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "725d5c006a8c02967b6d93ef04f6537ec4593313e330cfe86d9d3f946eb90f28"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-hal"
version = "29.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a47aef47636562f3937285af4c44b4b5b404b46577471411cc5313a921da7e"
dependencies = [
"android_system_properties",
"arrayvec",
"ash",
"bit-set 0.9.1",
"bitflags 2.11.0",
"block2",
"bytemuck",
"cfg-if",
"cfg_aliases",
"glow 0.17.0",
"glutin_wgl_sys",
"gpu-allocator",
"gpu-descriptor",
"hashbrown 0.16.1",
"js-sys",
"khronos-egl",
"libc",
"libloading",
"log",
"naga 29.0.1",
"ndk-sys",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
"objc2-metal",
"objc2-quartz-core",
"once_cell",
"ordered-float",
"parking_lot",
"portable-atomic",
"portable-atomic-util",
"profiling",
"range-alloc",
"raw-window-handle",
"raw-window-metal 1.1.0",
"renderdoc-sys",
"smallvec",
"thiserror 2.0.18",
"wasm-bindgen",
"wayland-sys",
"web-sys",
"wgpu-naga-bridge",
"wgpu-types",
"windows 0.62.2",
"windows-core 0.62.2",
]
[[package]]
name = "wgpu-naga-bridge"
version = "29.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4684f4410da0cf95a4cb63bb5edaac022461dedb6adf0b64d0d9b5f6890d51"
dependencies = [
"naga 29.0.1",
"wgpu-types",
]
[[package]]
name = "wgpu-types"
version = "29.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec2675540fb1a5cfa5ef122d3d5f390e2c75711a0b946410f2d6ac3a0f77d1f6"
dependencies = [
"bitflags 2.11.0",
"bytemuck",
"js-sys",
"log",
"raw-window-handle",
"web-sys",
]
[[package]]
name = "which"
version = "6.0.3"
@@ -6209,11 +6718,23 @@ version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-collections 0.2.0",
"windows-core 0.61.2",
"windows-future",
"windows-future 0.2.1",
"windows-link 0.1.3",
"windows-numerics",
"windows-numerics 0.2.0",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections 0.3.2",
"windows-core 0.62.2",
"windows-future 0.3.2",
"windows-numerics 0.3.1",
]
[[package]]
@@ -6226,7 +6747,7 @@ dependencies = [
"rayon",
"thiserror 2.0.18",
"windows 0.61.3",
"windows-future",
"windows-future 0.2.1",
]
[[package]]
@@ -6238,6 +6759,15 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.57.0"
@@ -6263,6 +6793,19 @@ dependencies = [
"windows-strings 0.4.2",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-future"
version = "0.2.1"
@@ -6271,7 +6814,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
"windows-threading",
"windows-threading 0.1.0",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
"windows-threading 0.2.1",
]
[[package]]
@@ -6340,6 +6894,16 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
]
[[package]]
name = "windows-registry"
version = "0.4.0"
@@ -6380,6 +6944,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-strings"
version = "0.3.1"
@@ -6398,6 +6971,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
@@ -6500,6 +7082,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -6878,6 +7469,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xml-rs"
version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]]
name = "xmlwriter"
version = "0.1.0"
+4 -1
View File
@@ -4,5 +4,8 @@ members = [
"apps/desktop",
"rust/crates/time",
"rust/crates/bridge",
"rust/wasm",
"rust/crates/effects",
"rust/crates/gpu",
"rust/crates/masks",
"rust/wasm", "rust/crates/compositor",
]
+2 -2
View File
@@ -1,7 +1,7 @@
Copyright 2025 OpenCut
Copyright 2025-2026 OpenCut
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+44 -129
View File
@@ -1,129 +1,44 @@
<table width="100%">
<tr>
<td align="left" width="120">
<img src="apps/web/public/logos/opencut/icon.svg" alt="OpenCut Logo" width="100" />
</td>
<td align="right">
<h1>OpenCut</h1>
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
</td>
</tr>
</table>
## Sponsors
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
<a href="https://vercel.com/oss">
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
</a>
<a href="https://fal.ai">
<img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" />
</a>
## Why?
- **Privacy**: Your videos stay on your device
- **Free features**: Most basic CapCut features are now paywalled
- **Simple**: People want editors that are easy to use - CapCut proved that
## 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
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
**Quick start for contributors:**
- Fork the repo and clone locally
- Follow the setup instructions in CONTRIBUTING.md
- Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup
- Create a feature branch and submit a PR
## License
[MIT LICENSE](LICENSE)
---
![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date)
<table width="100%">
<tr>
<td align="left" width="120">
<img src="https://assets.opencut.app/branding/symbol.svg" alt="OpenCut Logo" width="100" />
</td>
<td align="right">
<h1>OpenCut</h1>
<h3 style="margin-top: -10px;">A free and open source video editor for web, desktop, and mobile.</h3>
</td>
</tr>
</table>
[![Discord](https://img.shields.io/discord/1386309140057690133?label=Discord&logo=discord&logoColor=fff&color=5865F2&style=flat)](https://discord.gg/zmR9N35cjK)
[![X](https://img.shields.io/badge/follow-%40opencutapp-000?logo=x&logoColor=fff&style=flat)](https://x.com/opencutapp)
[![License: MIT](https://img.shields.io/badge/license-MIT-green?style=flat)](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
OpenCut is supported by companies that believe in open source creator tools.
- [**fal.ai**](https://fal.ai?utm_source=github-opencut&utm_campaign=oss): Generative image, video, and audio models all in one place.
Want your logo here? Reach out at [sponsor@opencut.app](mailto:sponsor@opencut.app).
## Contributing
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).
## License
[MIT](LICENSE)
+2 -2
View File
@@ -1,6 +1,6 @@
use gpui::{
App, Application, Bounds, Context, SharedString, Window, WindowBounds, WindowOptions, div,
prelude::*, px, rgb, size,
div, prelude::*, px, rgb, size, App, Application, Bounds, Context, SharedString, Window,
WindowBounds, WindowOptions,
};
struct AppWindow {
-7
View File
@@ -19,10 +19,3 @@ MARBLE_WORKSPACE_KEY=your_workspace_key_here
FREESOUND_CLIENT_ID=your_client_id_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
View File
@@ -29,11 +29,6 @@ ENV UPSTASH_REDIS_REST_TOKEN="example_token"
ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000"
ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL
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_API_KEY=$FREESOUND_API_KEY
+4 -2
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
const changelog = defineCollection({
name: "changelog",
directory: "content/changelog",
directory: "src/lib/changelog/entries",
include: "*.md",
schema: z.object({
content: z.string(),
@@ -12,6 +12,7 @@ const changelog = defineCollection({
published: z.boolean().default(true),
title: z.string(),
description: z.string().optional(),
summary: z.string().optional(),
changes: z.array(
z.object({
type: z.string(),
@@ -25,7 +26,8 @@ const changelog = defineCollection({
const sorted = [...publishedDocs].sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }),
);
const isLatest = doc.published !== false && sorted[0]?.version === doc.version;
const isLatest =
doc.published !== false && sorted[0]?.version === doc.version;
return { ...doc, isLatest };
},
});
+9 -6
View File
@@ -1,23 +1,26 @@
import type { Config } from "drizzle-kit";
import * as dotenv from "dotenv";
import { webEnv } from "@/lib/env/web";
// Load the right env file based on environment
if (webEnv.NODE_ENV === "production") {
if (process.env.NODE_ENV === "production") {
dotenv.config({ path: ".env.production" });
} else {
dotenv.config({ path: ".env.local" });
}
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("DATABASE_URL is not set");
}
export default {
schema: "./src/schema.ts",
schema: "./src/lib/db/schema.ts",
dialect: "postgresql",
migrations: {
table: "drizzle_migrations",
},
dbCredentials: {
url: webEnv.DATABASE_URL,
url: databaseUrl,
},
out: "./migrations",
strict: webEnv.NODE_ENV === "production",
strict: process.env.NODE_ENV === "production",
} satisfies Config;
-8
View File
@@ -3,14 +3,6 @@ import { withBotId } from "botid/next/config";
import { withContentCollections } from "@content-collections/next";
const nextConfig: NextConfig = {
turbopack: {
rules: {
"*.glsl": {
loaders: [require.resolve("raw-loader")],
as: "*.js",
},
},
},
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
+5 -6
View File
@@ -9,9 +9,9 @@
"start": "next start",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"lint": "biome check src/",
"lint:fix": "biome check src/ --write",
"format": "biome format src/ --write",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
"format": "prettier src --write",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
@@ -19,10 +19,9 @@
},
"dependencies": {
"@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",
"@huggingface/transformers": "^3.8.1",
"opencut-wasm": "workspace:*",
"@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -53,6 +52,7 @@
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
"opencut-wasm": "^0.2.10",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
@@ -94,7 +94,6 @@
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.4",
"postcss": "^8",
"raw-loader": "^4.0.2",
"sharp": "^0.34.5",
"tailwindcss": "^4.2.1",
"typescript": "^5.8.3",
File diff suppressed because one or more lines are too long
Binary file not shown.
-3
View File
@@ -1,3 +0,0 @@
<svg width="588" height="512" viewBox="0 0 588 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M294 0L588 512H0L294 0Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 159 B

@@ -5,8 +5,8 @@ import { toast } from "sonner";
import {
type KeyboardShortcut,
useKeyboardShortcutsHelp,
} from "@/hooks/use-keyboard-shortcuts-help";
import { useKeybindingsStore } from "@/stores/keybindings-store";
} from "@/actions/use-keyboard-shortcuts-help";
import { useKeybindingsStore } from "@/actions/keybindings-store";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -51,10 +51,10 @@ export function ShortcutsDialog({
const keyString = getKeybindingString(e);
if (keyString) {
const conflict = validateKeybinding(
keyString,
recordingShortcut.action,
);
const conflict = validateKeybinding({
key: keyString,
action: recordingShortcut.action,
});
if (conflict) {
toast.error(
`Key "${keyString}" is already bound to "${conflict.existingAction}"`,
@@ -68,7 +68,10 @@ export function ShortcutsDialog({
removeKeybinding(key);
}
updateKeybinding(keyString, recordingShortcut.action);
updateKeybinding({
key: keyString,
action: recordingShortcut.action,
});
setIsRecording(false);
setRecordingShortcut(null);
@@ -1,209 +1,210 @@
import type {
KeybindingConfig,
ShortcutKey,
} from "@/lib/actions/keybinding";
import type { TActionWithOptionalArgs } from "./types";
export type TActionCategory =
| "playback"
| "navigation"
| "editing"
| "selection"
| "history"
| "timeline"
| "controls"
| "assets";
export interface TActionBaseDefinition {
description: string;
category: TActionCategory;
args?: Record<string, unknown>;
}
export interface TActionDefinition extends TActionBaseDefinition {
defaultShortcuts?: readonly ShortcutKey[];
}
export const ACTIONS = {
"toggle-play": {
description: "Play/Pause",
category: "playback",
},
"stop-playback": {
description: "Stop playback",
category: "playback",
},
"seek-forward": {
description: "Seek forward 1 second",
category: "playback",
args: { seconds: "number" },
},
"seek-backward": {
description: "Seek backward 1 second",
category: "playback",
args: { seconds: "number" },
},
"frame-step-forward": {
description: "Frame step forward",
category: "navigation",
},
"frame-step-backward": {
description: "Frame step backward",
category: "navigation",
},
"jump-forward": {
description: "Jump forward 5 seconds",
category: "navigation",
args: { seconds: "number" },
},
"jump-backward": {
description: "Jump backward 5 seconds",
category: "navigation",
args: { seconds: "number" },
},
"goto-start": {
description: "Go to timeline start",
category: "navigation",
},
"goto-end": {
description: "Go to timeline end",
category: "navigation",
},
split: {
description: "Split elements at playhead",
category: "editing",
},
"split-left": {
description: "Split and remove left",
category: "editing",
},
"split-right": {
description: "Split and remove right",
category: "editing",
},
"delete-selected": {
description: "Delete selected elements",
category: "editing",
},
"copy-selected": {
description: "Copy selected elements",
category: "editing",
},
"paste-copied": {
description: "Paste elements at playhead",
category: "editing",
},
"toggle-snapping": {
description: "Toggle snapping",
category: "editing",
},
"toggle-ripple-editing": {
description: "Toggle ripple editing",
category: "editing",
},
"toggle-source-audio": {
description: "Extract or recover source audio",
category: "editing",
},
"select-all": {
description: "Select all elements",
category: "selection",
},
"cancel-interaction": {
description: "Cancel current interaction",
category: "controls",
},
"deselect-all": {
description: "Deselect all elements",
category: "selection",
},
"duplicate-selected": {
description: "Duplicate selected element",
category: "selection",
},
"toggle-elements-muted-selected": {
description: "Mute/unmute selected elements",
category: "selection",
},
"toggle-elements-visibility-selected": {
description: "Show/hide selected elements",
category: "selection",
},
"toggle-bookmark": {
description: "Toggle bookmark at playhead",
category: "timeline",
},
undo: {
description: "Undo",
category: "history",
},
redo: {
description: "Redo",
category: "history",
},
"remove-media-asset": {
description: "Remove media asset",
category: "assets",
args: { projectId: "string", assetId: "string" },
},
"remove-media-assets": {
description: "Remove media assets",
category: "assets",
args: { projectId: "string", assetIds: "string[]" },
},
} as const satisfies Record<string, TActionBaseDefinition>;
export type TAction = keyof typeof ACTIONS;
const ACTION_DEFAULT_SHORTCUTS = {
"toggle-play": ["space", "k"],
"seek-forward": ["l"],
"seek-backward": ["j"],
"frame-step-forward": ["right"],
"frame-step-backward": ["left"],
"jump-forward": ["shift+right"],
"jump-backward": ["shift+left"],
"goto-start": ["home", "enter"],
"goto-end": ["end"],
split: ["s"],
"split-left": ["q"],
"split-right": ["w"],
"delete-selected": ["backspace", "delete"],
"copy-selected": ["ctrl+c"],
"paste-copied": ["ctrl+v"],
"toggle-snapping": ["n"],
"select-all": ["ctrl+a"],
"cancel-interaction": ["escape"],
"duplicate-selected": ["ctrl+d"],
undo: ["ctrl+z"],
redo: ["ctrl+shift+z", "ctrl+y"],
} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
Record<TAction, readonly ShortcutKey[]>
> = ACTION_DEFAULT_SHORTCUTS;
export function getActionDefinition({
action,
}: {
action: TAction;
}): TActionDefinition {
return {
...ACTIONS[action],
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
};
}
export function getDefaultShortcuts(): KeybindingConfig {
const shortcuts: KeybindingConfig = {};
for (const [action, defaultShortcuts] of Object.entries(
ACTION_DEFAULT_SHORTCUTS,
) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
for (const shortcut of defaultShortcuts) {
shortcuts[shortcut] = action;
}
}
return shortcuts;
}
import type { ShortcutKey } from "@/actions/keybinding";
import type { TActionWithOptionalArgs } from "./types";
export type TActionCategory =
| "playback"
| "navigation"
| "editing"
| "selection"
| "history"
| "timeline"
| "controls"
| "assets";
export interface TActionBaseDefinition {
description: string;
category: TActionCategory;
args?: Record<string, unknown>;
}
export interface TActionDefinition extends TActionBaseDefinition {
defaultShortcuts?: readonly ShortcutKey[];
}
export const ACTIONS = {
"toggle-play": {
description: "Play/Pause",
category: "playback",
},
"stop-playback": {
description: "Stop playback",
category: "playback",
},
"seek-forward": {
description: "Seek forward 1 second",
category: "playback",
args: { seconds: "number" },
},
"seek-backward": {
description: "Seek backward 1 second",
category: "playback",
args: { seconds: "number" },
},
"frame-step-forward": {
description: "Frame step forward",
category: "navigation",
},
"frame-step-backward": {
description: "Frame step backward",
category: "navigation",
},
"jump-forward": {
description: "Jump forward 5 seconds",
category: "navigation",
args: { seconds: "number" },
},
"jump-backward": {
description: "Jump backward 5 seconds",
category: "navigation",
args: { seconds: "number" },
},
"goto-start": {
description: "Go to timeline start",
category: "navigation",
},
"goto-end": {
description: "Go to timeline end",
category: "navigation",
},
split: {
description: "Split elements at playhead",
category: "editing",
},
"split-left": {
description: "Split and remove left",
category: "editing",
},
"split-right": {
description: "Split and remove right",
category: "editing",
},
"delete-selected": {
description: "Delete current selection",
category: "editing",
},
"copy-selected": {
description: "Copy selected elements",
category: "editing",
},
"paste-copied": {
description: "Paste elements at playhead",
category: "editing",
},
"toggle-snapping": {
description: "Toggle snapping",
category: "editing",
},
"toggle-ripple-editing": {
description: "Toggle ripple editing",
category: "editing",
},
"toggle-source-audio": {
description: "Extract or recover source audio",
category: "editing",
},
"select-all": {
description: "Select all elements",
category: "selection",
},
"cancel-interaction": {
description: "Cancel current interaction",
category: "controls",
},
"deselect-all": {
description: "Deselect all elements",
category: "selection",
},
"duplicate-selected": {
description: "Duplicate selected element",
category: "selection",
},
"toggle-elements-muted-selected": {
description: "Mute/unmute selected elements",
category: "selection",
},
"toggle-elements-visibility-selected": {
description: "Show/hide selected elements",
category: "selection",
},
"toggle-bookmark": {
description: "Toggle bookmark at playhead",
category: "timeline",
},
undo: {
description: "Undo",
category: "history",
},
redo: {
description: "Redo",
category: "history",
},
"remove-media-asset": {
description: "Remove media asset",
category: "assets",
args: { projectId: "string", assetId: "string" },
},
"remove-media-assets": {
description: "Remove media assets",
category: "assets",
args: { projectId: "string", assetIds: "string[]" },
},
} as const satisfies Record<string, TActionBaseDefinition>;
export type TAction = keyof typeof ACTIONS;
const ACTION_DEFAULT_SHORTCUTS = [
["toggle-play", ["space", "k"]],
["seek-forward", ["l"]],
["seek-backward", ["j"]],
["frame-step-forward", ["right"]],
["frame-step-backward", ["left"]],
["jump-forward", ["shift+right"]],
["jump-backward", ["shift+left"]],
["goto-start", ["home", "enter"]],
["goto-end", ["end"]],
["split", ["s"]],
["split-left", ["q"]],
["split-right", ["w"]],
["delete-selected", ["backspace", "delete"]],
["copy-selected", ["ctrl+c"]],
["paste-copied", ["ctrl+v"]],
["toggle-snapping", ["n"]],
["select-all", ["ctrl+a"]],
["cancel-interaction", ["escape"]],
["duplicate-selected", ["ctrl+d"]],
["undo", ["ctrl+z"]],
["redo", ["ctrl+shift+z", "ctrl+y"]],
] as const satisfies ReadonlyArray<
readonly [TActionWithOptionalArgs, readonly ShortcutKey[]]
>;
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION = new Map<
TAction,
readonly ShortcutKey[]
>(ACTION_DEFAULT_SHORTCUTS);
export function getActionDefinition({
action,
}: {
action: TAction;
}): TActionDefinition {
return {
...ACTIONS[action],
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION.get(action),
};
}
export function getDefaultShortcuts(): Map<
ShortcutKey,
TActionWithOptionalArgs
> {
const shortcuts = new Map<ShortcutKey, TActionWithOptionalArgs>();
for (const [action, defaultShortcuts] of ACTION_DEFAULT_SHORTCUTS) {
for (const shortcut of defaultShortcuts) {
shortcuts.set(shortcut, action);
}
}
return shortcuts;
}
@@ -1,79 +1,43 @@
import type { TActionWithOptionalArgs } from "./types";
/**
* Alt is also regarded as macOS OPTION (⌥) key
* Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
*/
export type ModifierKeys =
| "ctrl"
| "alt"
| "shift"
| "ctrl+shift"
| "alt+shift"
| "ctrl+alt"
| "ctrl+alt+shift";
export type Key =
| "a"
| "b"
| "c"
| "d"
| "e"
| "f"
| "g"
| "h"
| "i"
| "j"
| "k"
| "l"
| "m"
| "n"
| "o"
| "p"
| "q"
| "r"
| "s"
| "t"
| "u"
| "v"
| "w"
| "x"
| "y"
| "z"
| "0"
| "1"
| "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "up"
| "down"
| "left"
| "right"
| "/"
| "?"
| "."
| "enter"
| "tab"
| "space"
| "escape"
| "esc"
| "backspace"
| "delete"
| "home"
| "end";
/* eslint-enable */
export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
// Singular keybindings (these will be disabled when an input-ish area has been focused)
export type SingleCharacterShortcutKey = `${Key}`;
export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
export type KeybindingConfig = {
[key in ShortcutKey]?: TActionWithOptionalArgs;
};
import type { TActionWithOptionalArgs } from "./types";
/**
* Alt is also regarded as macOS OPTION (⌥) key
* Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
*/
export type ModifierKeys =
| "ctrl"
| "alt"
| "shift"
| "ctrl+shift"
| "alt+shift"
| "ctrl+alt"
| "ctrl+alt+shift";
const KEYS = [
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
"k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
"u", "v", "w", "x", "y", "z",
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
"up", "down", "left", "right",
"/", "?", ".",
"enter", "tab", "space", "escape", "esc",
"backspace", "delete", "home", "end",
] as const;
export type Key = (typeof KEYS)[number];
const KEY_SET: ReadonlySet<string> = new Set(KEYS);
export function isKey(value: string): value is Key {
return KEY_SET.has(value);
}
export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
// Singular keybindings (these will be disabled when an input-ish area has been focused)
export type SingleCharacterShortcutKey = `${Key}`;
export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
export type KeybindingConfig = {
[key in ShortcutKey]?: TActionWithOptionalArgs;
};
+274
View File
@@ -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/);
});
});
@@ -2,6 +2,7 @@ import { v2ToV3 } from "./v2-to-v3";
import { v3ToV4 } from "./v3-to-v4";
import { v4ToV5 } from "./v4-to-v5";
import { v5ToV6 } from "./v5-to-v6";
import { v6ToV7 } from "./v6-to-v7";
type MigrationFn = ({ state }: { state: unknown }) => unknown;
@@ -10,9 +11,10 @@ const migrations: Record<number, MigrationFn> = {
3: v3ToV4,
4: v4ToV5,
5: v5ToV6,
6: v6ToV7,
};
export const CURRENT_VERSION = 6;
export const CURRENT_VERSION = 7;
export function runMigrations({
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 };
}
@@ -1,12 +1,8 @@
import type { KeybindingConfig } from "@/lib/actions/keybinding";
interface V4State {
keybindings: KeybindingConfig;
isCustomized: boolean;
}
import { getPersistedKeybindingsState } from "../persisted-state";
export function v4ToV5({ state }: { state: unknown }): unknown {
const v4 = state as V4State;
const v4 = getPersistedKeybindingsState({ state });
if (!v4) return state;
const keybindings = { ...v4.keybindings };
if (!keybindings.escape) {
@@ -1,12 +1,8 @@
import type { KeybindingConfig } from "@/lib/actions/keybinding";
interface V5State {
keybindings: KeybindingConfig;
isCustomized: boolean;
}
import { getPersistedKeybindingsState } from "../persisted-state";
export function v5ToV6({ state }: { state: unknown }): unknown {
const v5 = state as V5State;
const v5 = getPersistedKeybindingsState({ state });
if (!v5) return state;
const keybindings = { ...v5.keybindings };
if (keybindings.escape === "deselect-all") {
@@ -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;
const boundActions: Partial<Record<TAction, ActionHandler[]>> = {};
// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler).
export function bindAction<A extends TAction>(
action: A,
handler: TActionFunc<A>,
@@ -24,6 +25,7 @@ export function bindAction<A extends TAction>(
}
}
// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler).
export function unbindAction<A extends TAction>(
action: A,
handler: TActionFunc<A>,
@@ -52,34 +54,12 @@ type InvokeActionFunc = {
): void;
};
// eslint-disable-next-line opencut/prefer-object-params -- dispatchers conventionally separate action, payload, and trigger.
export const invokeAction: InvokeActionFunc = <A extends TAction>(
action: A,
args?: TArgOfAction<A>,
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) => {
handler(args, trigger);
});
@@ -1,44 +1,44 @@
import type { MutableRefObject } from "react";
import type { TAction } from "./definitions";
export type { TAction };
export type TActionArgsMap = {
"seek-forward": { seconds: number } | undefined;
"seek-backward": { seconds: number } | undefined;
"jump-forward": { seconds: number } | undefined;
"jump-backward": { seconds: number } | undefined;
"remove-media-asset": { projectId: string; assetId: string };
"remove-media-assets": { projectId: string; assetIds: string[] };
};
type TKeysWithValueUndefined<T> = {
[K in keyof T]: undefined extends T[K] ? K : never;
}[keyof T];
export type TActionWithArgs = keyof TActionArgsMap;
export type TActionWithOptionalArgs =
| TActionWithNoArgs
| TKeysWithValueUndefined<TActionArgsMap>;
export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
? TActionArgsMap[A]
: undefined;
export type TActionFunc<A extends TAction> = A extends TActionWithArgs
? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
: (_?: undefined, trigger?: TInvocationTrigger) => void;
export type TInvocationTrigger = "keypress" | "mouseclick";
export type TBoundActionList = {
[A in TAction]?: Array<TActionFunc<A>>;
};
export type TActionHandlerOptions =
| MutableRefObject<boolean>
| boolean
| undefined;
import type { MutableRefObject } from "react";
import type { TAction } from "./definitions";
export type { TAction };
export type TActionArgsMap = {
"seek-forward": { seconds: number } | undefined;
"seek-backward": { seconds: number } | undefined;
"jump-forward": { seconds: number } | undefined;
"jump-backward": { seconds: number } | undefined;
"remove-media-asset": { projectId: string; assetId: string };
"remove-media-assets": { projectId: string; assetIds: string[] };
};
type TKeysWithValueUndefined<T> = {
[K in keyof T]: undefined extends T[K] ? K : never;
}[keyof T];
export type TActionWithArgs = keyof TActionArgsMap;
export type TActionWithOptionalArgs =
| TActionWithNoArgs
| TKeysWithValueUndefined<TActionArgsMap>;
export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
? TActionArgsMap[A]
: undefined;
export type TActionFunc<A extends TAction> = A extends TActionWithArgs
? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
: (_?: undefined, trigger?: TInvocationTrigger) => void;
export type TInvocationTrigger = "keypress" | "mouseclick";
export type TBoundActionList = {
[A in TAction]?: Array<TActionFunc<A>>;
};
export type TActionHandlerOptions =
| MutableRefObject<boolean>
| boolean
| undefined;
@@ -5,9 +5,10 @@ import type {
TActionHandlerOptions,
TArgOfAction,
TInvocationTrigger,
} from "@/lib/actions";
import { bindAction, unbindAction } from "@/lib/actions";
} from "@/actions";
import { bindAction, unbindAction } from "@/actions";
// eslint-disable-next-line opencut/prefer-object-params -- action subscriptions read best as (action, handler, isActive).
export function useActionHandler<A extends TAction>(
action: A,
handler: TActionFunc<A>,
@@ -1,66 +1,70 @@
"use client";
import { useEffect, useRef } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
import { useActionHandler } from "@/hooks/actions/use-action-handler";
import { useEditor } from "../use-editor";
import { useElementSelection } from "../timeline/element/use-element-selection";
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
import { useEffect, useState } from "react";
import { useTimelineStore } from "@/timeline/timeline-store";
import { useActionHandler } from "@/actions/use-action-handler";
import { useEditor } from "@/editor/use-editor";
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
import {
getElementsAtTime,
hasMediaId,
} from "@/lib/timeline";
import { cancelInteraction } from "@/lib/cancel-interaction";
import { invokeAction } from "@/lib/actions";
import { canToggleSourceAudio } from "@/lib/timeline/audio-separation";
addMediaTime,
maxMediaTime,
mediaTime,
mediaTimeFromSeconds,
minMediaTime,
subMediaTime,
TICKS_PER_SECOND,
ZERO_MEDIA_TIME,
} from "@/wasm";
import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
import { getElementsAtTime, hasMediaId } from "@/timeline";
import { cancelInteraction } from "@/editor/cancel-interaction";
import { invokeAction } from "@/actions";
import { canToggleSourceAudio } from "@/timeline/audio-separation";
import {
activateScope,
clearActiveScope,
type ScopeEntry,
} from "@/lib/selection/scope";
} from "@/selection/scope";
import { useCommittedRef } from "@/hooks/use-committed-ref";
export function useEditorActions() {
const editor = useEditor();
const { selectedElements, setElementSelection } = useElementSelection();
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
const clipboard = useTimelineStore((s) => s.clipboard);
const setClipboard = useTimelineStore((s) => s.setClipboard);
const selectedMaskPointSelection = useEditor((e) =>
e.selection.getSelectedMaskPointSelection(),
);
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
const hasTimelineSelectionRef = useRef(false);
const clearTimelineSelectionRef = useRef(() => {});
const timelineScopeRef = useRef<ScopeEntry | null>(null);
const hasTimelineSelection =
selectedElements.length > 0 || selectedKeyframes.length > 0;
hasTimelineSelectionRef.current = hasTimelineSelection;
clearTimelineSelectionRef.current = () => {
setElementSelection({ elements: [] });
clearKeyframeSelection();
};
if (!timelineScopeRef.current) {
timelineScopeRef.current = {
hasSelection: () => hasTimelineSelectionRef.current,
clear: () => {
clearTimelineSelectionRef.current();
},
};
}
selectedElements.length > 0 ||
selectedKeyframes.length > 0 ||
selectedMaskPointSelection !== null;
const hasTimelineSelectionRef = useCommittedRef(hasTimelineSelection);
const clearTimelineSelectionRef = useCommittedRef(() => {
editor.selection.clearSelection();
});
const clearTimelineActiveSelectionRef = useCommittedRef(() => {
editor.selection.clearMostSpecificSelection();
});
const [timelineScope] = useState<ScopeEntry>(() => ({
hasSelection: () => hasTimelineSelectionRef.current,
clear: () => {
clearTimelineSelectionRef.current();
},
clearActive: () => {
clearTimelineActiveSelectionRef.current();
},
}));
useEffect(() => {
if (!hasTimelineSelection) {
return;
}
const timelineScope = timelineScopeRef.current;
if (!timelineScope) {
return;
}
return activateScope({ entry: timelineScope });
}, [hasTimelineSelection]);
}, [hasTimelineSelection, timelineScope]);
useActionHandler(
"toggle-play",
@@ -76,7 +80,7 @@ export function useEditorActions() {
if (editor.playback.getIsPlaying()) {
editor.playback.toggle();
}
editor.playback.seek({ time: 0 });
editor.playback.seek({ time: ZERO_MEDIA_TIME });
},
undefined,
);
@@ -85,11 +89,15 @@ export function useEditorActions() {
"seek-forward",
(args) => {
const seconds = args?.seconds ?? 1;
const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({
time: Math.min(
editor.timeline.getTotalDuration(),
editor.playback.getCurrentTime() + seconds,
),
time: minMediaTime({
a: editor.timeline.getTotalDuration(),
b: addMediaTime({
a: editor.playback.getCurrentTime(),
b: delta,
}),
}),
});
},
undefined,
@@ -99,8 +107,15 @@ export function useEditorActions() {
"seek-backward",
(args) => {
const seconds = args?.seconds ?? 1;
const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
time: maxMediaTime({
a: ZERO_MEDIA_TIME,
b: subMediaTime({
a: editor.playback.getCurrentTime(),
b: delta,
}),
}),
});
},
undefined,
@@ -110,12 +125,20 @@ export function useEditorActions() {
"frame-step-forward",
() => {
const fps = editor.project.getActive().settings.fps;
editor.playback.seek({
time: Math.min(
editor.timeline.getTotalDuration(),
editor.playback.getCurrentTime() + 1 / fps,
const ticksPerFrame = mediaTime({
ticks: Math.round(
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
),
});
editor.playback.seek({
time: minMediaTime({
a: editor.timeline.getTotalDuration(),
b: addMediaTime({
a: editor.playback.getCurrentTime(),
b: ticksPerFrame,
}),
}),
});
},
undefined,
);
@@ -124,8 +147,19 @@ export function useEditorActions() {
"frame-step-backward",
() => {
const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = mediaTime({
ticks: Math.round(
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
),
});
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,
@@ -135,11 +169,15 @@ export function useEditorActions() {
"jump-forward",
(args) => {
const seconds = args?.seconds ?? 5;
const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({
time: Math.min(
editor.timeline.getTotalDuration(),
editor.playback.getCurrentTime() + seconds,
),
time: minMediaTime({
a: editor.timeline.getTotalDuration(),
b: addMediaTime({
a: editor.playback.getCurrentTime(),
b: delta,
}),
}),
});
},
undefined,
@@ -149,8 +187,15 @@ export function useEditorActions() {
"jump-backward",
(args) => {
const seconds = args?.seconds ?? 5;
const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
time: maxMediaTime({
a: ZERO_MEDIA_TIME,
b: subMediaTime({
a: editor.playback.getCurrentTime(),
b: delta,
}),
}),
});
},
undefined,
@@ -159,7 +204,7 @@ export function useEditorActions() {
useActionHandler(
"goto-start",
() => {
editor.playback.seek({ time: 0 });
editor.playback.seek({ time: ZERO_MEDIA_TIME });
},
undefined,
);
@@ -176,11 +221,12 @@ export function useEditorActions() {
"split",
() => {
const currentTime = editor.playback.getCurrentTime();
const tracks = editor.scenes.getActiveScene().tracks;
const elementsToSplit =
selectedElements.length > 0
? selectedElements
: getElementsAtTime({
tracks: editor.timeline.getTracks(),
tracks,
time: currentTime,
});
@@ -198,11 +244,12 @@ export function useEditorActions() {
"split-left",
() => {
const currentTime = editor.playback.getCurrentTime();
const tracks = editor.scenes.getActiveScene().tracks;
const elementsToSplit =
selectedElements.length > 0
? selectedElements
: getElementsAtTime({
tracks: editor.timeline.getTracks(),
tracks,
time: currentTime,
});
@@ -212,7 +259,6 @@ export function useEditorActions() {
elements: elementsToSplit,
splitTime: currentTime,
retainSide: "right",
rippleEnabled: rippleEditingEnabled,
});
if (rippleEditingEnabled && rightSideElements.length > 0) {
@@ -231,11 +277,12 @@ export function useEditorActions() {
"split-right",
() => {
const currentTime = editor.playback.getCurrentTime();
const tracks = editor.scenes.getActiveScene().tracks;
const elementsToSplit =
selectedElements.length > 0
? selectedElements
: getElementsAtTime({
tracks: editor.timeline.getTracks(),
tracks,
time: currentTime,
});
@@ -253,18 +300,36 @@ export function useEditorActions() {
useActionHandler(
"delete-selected",
() => {
if (selectedKeyframes.length > 0) {
editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
clearKeyframeSelection();
return;
switch (editor.selection.getActiveSelectionKind()) {
case "mask-points":
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;
}
if (selectedElements.length === 0) {
return;
}
editor.timeline.deleteElements({
elements: selectedElements,
rippleEnabled: rippleEditingEnabled,
});
},
undefined,
);
@@ -290,16 +355,12 @@ export function useEditorActions() {
}
return (
editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
null
editor.media
.getAssets()
.find((asset) => asset.id === element.mediaId) ?? null
);
})();
if (
!canToggleSourceAudio({
element: selectedElement.element,
mediaAsset,
})
) {
if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
return;
}
@@ -314,7 +375,12 @@ export function useEditorActions() {
useActionHandler(
"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) => ({
trackId: track.id,
elementId: element.id,
@@ -339,8 +405,7 @@ export function useEditorActions() {
"deselect-all",
() => {
if (!clearActiveScope()) {
setElementSelection({ elements: [] });
clearKeyframeSelection();
editor.selection.clearMostSpecificSelection();
}
},
undefined,
@@ -383,21 +448,7 @@ export function useEditorActions() {
useActionHandler(
"copy-selected",
() => {
if (selectedElements.length === 0) return;
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 });
editor.clipboard.copy();
},
undefined,
);
@@ -405,12 +456,7 @@ export function useEditorActions() {
useActionHandler(
"paste-copied",
() => {
if (!clipboard?.items.length) return;
editor.timeline.pasteAtTime({
time: editor.playback.getCurrentTime(),
clipboardItems: clipboard.items,
});
editor.clipboard.paste();
},
undefined,
);
+87
View File
@@ -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,
]);
}
@@ -1,8 +1,8 @@
"use client";
import { useMemo } from "react";
import { useKeybindingsStore } from "@/stores/keybindings-store";
import { ACTIONS, type TActionWithOptionalArgs } from "@/lib/actions";
import { useKeybindingsStore } from "@/actions/keybindings-store";
import { ACTIONS, type TActionWithOptionalArgs } from "@/actions";
import {
getPlatformAlternateKey,
getPlatformSpecialKey,
@@ -39,24 +39,21 @@ export function useKeyboardShortcutsHelp() {
const { keybindings } = useKeybindingsStore();
const shortcuts = useMemo(() => {
const result: KeyboardShortcut[] = [];
const actionToKeys: Partial<Record<TActionWithOptionalArgs, string[]>> = {};
const actionToKeys = new Map<TActionWithOptionalArgs, string[]>();
for (const [key, action] of Object.entries(keybindings) as Array<
[string, TActionWithOptionalArgs | undefined]
>) {
if (action) {
if (!actionToKeys[action]) {
actionToKeys[action] = [];
}
actionToKeys[action].push(formatKey({ key }));
for (const [key, action] of keybindings) {
const existing = actionToKeys.get(action);
if (existing) {
existing.push(formatKey({ key }));
} else {
actionToKeys.set(action, [formatKey({ key })]);
}
}
for (const [action, keys] of Object.entries(actionToKeys) as Array<
[TActionWithOptionalArgs, string[]]
>) {
const result: KeyboardShortcut[] = [];
for (const [action, keys] of actionToKeys) {
const actionDef = ACTIONS[action];
if (!actionDef) continue;
result.push({
id: action,
keys,
@@ -0,0 +1,241 @@
import { describe, expect, test } from "bun:test";
import {
coerceParamValue,
getParamDefaultInterpolation,
getParamNumericRange,
getParamValueKind,
} from "@/params";
describe("animated params", () => {
test("snaps and clamps number params", () => {
expect(
coerceParamValue({
param: {
key: "intensity",
label: "Intensity",
type: "number",
default: 0,
min: 0,
max: 1,
step: 0.25,
},
value: 0.62,
}),
).toBe(0.5);
expect(
coerceParamValue({
param: {
key: "intensity",
label: "Intensity",
type: "number",
default: 0,
min: 0,
max: 1,
step: 0.25,
},
value: 1.2,
}),
).toBe(1);
});
test("rejects NaN and non-number values for number params", () => {
const param = {
key: "intensity",
label: "Intensity",
type: "number" as const,
default: 0,
min: 0,
max: 1,
step: 0.25,
};
expect(coerceParamValue({ param, value: Number.NaN })).toBeNull();
expect(coerceParamValue({ param, value: "0.5" })).toBeNull();
expect(coerceParamValue({ param, value: true })).toBeNull();
});
test("passthrough with step <= 0 guard", () => {
expect(
coerceParamValue({
param: {
key: "x",
label: "X",
type: "number",
default: 0,
min: 0,
step: 0,
},
value: 0.123,
}),
).toBe(0.123);
});
test("accepts valid select values", () => {
const param = {
key: "blend",
label: "Blend",
type: "select" as const,
default: "normal",
options: [
{ value: "normal", label: "Normal" },
{ value: "multiply", label: "Multiply" },
],
};
expect(coerceParamValue({ param, value: "normal" })).toBe("normal");
expect(coerceParamValue({ param, value: "multiply" })).toBe("multiply");
});
test("rejects select values outside the allowed options", () => {
expect(
coerceParamValue({
param: {
key: "blend",
label: "Blend",
type: "select",
default: "normal",
options: [
{ value: "normal", label: "Normal" },
{ value: "multiply", label: "Multiply" },
],
},
value: "screen",
}),
).toBeNull();
});
test("rejects non-string select values", () => {
const param = {
key: "blend",
label: "Blend",
type: "select" as const,
default: "normal",
options: [{ value: "normal", label: "Normal" }],
};
expect(coerceParamValue({ param, value: 42 })).toBeNull();
expect(coerceParamValue({ param, value: null })).toBeNull();
expect(coerceParamValue({ param, value: undefined })).toBeNull();
});
test("boolean params accept booleans and reject other types", () => {
const param = {
key: "visible",
label: "Visible",
type: "boolean" as const,
default: true,
};
expect(coerceParamValue({ param, value: true })).toBe(true);
expect(coerceParamValue({ param, value: false })).toBe(false);
expect(coerceParamValue({ param, value: 1 })).toBeNull();
expect(coerceParamValue({ param, value: "true" })).toBeNull();
});
test("color params accept strings and reject other types", () => {
const param = {
key: "fill",
label: "Fill",
type: "color" as const,
default: "#ffffff",
};
expect(coerceParamValue({ param, value: "#ff0000" })).toBe("#ff0000");
expect(coerceParamValue({ param, value: 0xff0000 })).toBeNull();
expect(coerceParamValue({ param, value: null })).toBeNull();
});
test("getAnimationParamValueKind maps param type to binding kind", () => {
expect(
getParamValueKind({
param: {
key: "n",
label: "N",
type: "number",
default: 0,
min: 0,
step: 1,
},
}),
).toBe("number");
expect(
getParamValueKind({
param: { key: "c", label: "C", type: "color", default: "#fff" },
}),
).toBe("color");
expect(
getParamValueKind({
param: { key: "b", label: "B", type: "boolean", default: false },
}),
).toBe("discrete");
expect(
getParamValueKind({
param: {
key: "s",
label: "S",
type: "select",
default: "a",
options: [{ value: "a", label: "A" }],
},
}),
).toBe("discrete");
});
test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => {
expect(
getParamDefaultInterpolation({
param: {
key: "n",
label: "N",
type: "number",
default: 0,
min: 0,
step: 1,
},
}),
).toBe("linear");
expect(
getParamDefaultInterpolation({
param: { key: "c", label: "C", type: "color", default: "#fff" },
}),
).toBe("linear");
expect(
getParamDefaultInterpolation({
param: { key: "b", label: "B", type: "boolean", default: false },
}),
).toBe("hold");
expect(
getParamDefaultInterpolation({
param: {
key: "s",
label: "S",
type: "select",
default: "a",
options: [{ value: "a", label: "A" }],
},
}),
).toBe("hold");
});
test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => {
expect(
getParamNumericRange({
param: {
key: "intensity",
label: "Intensity",
type: "number",
default: 0.5,
min: 0,
max: 1,
step: 0.1,
},
}),
).toEqual({ min: 0, max: 1, step: 0.1 });
expect(
getParamNumericRange({
param: { key: "c", label: "C", type: "color", default: "#fff" },
}),
).toBeUndefined();
expect(
getParamNumericRange({
param: { key: "b", label: "B", type: "boolean", default: false },
}),
).toBeUndefined();
});
});
+90
View File
@@ -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;
}
+57
View File
@@ -0,0 +1,57 @@
import type {
AnimationChannel,
ChannelData,
CompositeChannelData,
} from "@/animation/types";
const LEGACY_ANIMATION_STORAGE_KEYS = new Set(["bindings", "channels"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function isLeafChannelData(
data: ChannelData | undefined,
): data is AnimationChannel {
return isRecord(data) && Array.isArray(data.keys);
}
export function isCompositeChannelData(
data: ChannelData | undefined,
): data is CompositeChannelData {
return isRecord(data) && !Array.isArray(data.keys);
}
export function getChannelsFromData({
data,
}: {
data: ChannelData | undefined;
}): AnimationChannel[] {
if (isLeafChannelData(data)) {
return [data];
}
if (!isCompositeChannelData(data)) {
return [];
}
return Object.values(data).filter(isLeafChannelData);
}
export function getChannelEntriesFromData({
data,
}: {
data: ChannelData | undefined;
}): Array<[string, AnimationChannel]> {
if (isLeafChannelData(data)) {
return [["value", data]];
}
if (!isCompositeChannelData(data)) {
return [];
}
return Object.entries(data).flatMap(([componentKey, channel]) =>
isLeafChannelData(channel) ? [[componentKey, channel]] : [],
);
}
export function isAnimationStorageKey({ key }: { key: string }): boolean {
return !LEGACY_ANIMATION_STORAGE_KEYS.has(key);
}
+96
View File
@@ -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,
});
}
+114
View File
@@ -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 {
getChannelValueAtTime,
getNumberChannelValueAtTime,
getVectorChannelValueAtTime,
normalizeChannel,
} from "./interpolation";
export {
clampAnimationsToDuration,
cloneAnimations,
getChannel,
removeElementKeyframe,
retimeElementKeyframe,
setChannel,
splitAnimationsAtTime,
upsertElementKeyframe,
upsertPathKeyframe,
} from "./keyframes";
export {
getElementLocalTime,
resolveColorAtTime,
resolveNumberAtTime,
resolveOpacityAtTime,
resolveTransformAtTime,
} from "./resolve";
export {
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
getDefaultInterpolationForProperty,
getElementBaseValueForProperty,
isAnimationPropertyPath,
supportsAnimationProperty,
type AnimationPropertyDefinition,
type NumericSpec,
type NumericRange,
withElementBaseValueForProperty,
} from "./property-registry";
export {
getElementKeyframes,
getKeyframeAtTime,
hasKeyframesForPath,
} from "./keyframe-query";
export {
buildGraphicParamPath,
isGraphicParamPath,
parseGraphicParamPath,
resolveGraphicParamsAtTime,
} from "./graphic-param-channel";
export {
isAnimationPath,
resolveAnimationTarget,
getParamValueKind,
getParamDefaultInterpolation,
type AnimationPathDescriptor,
} from "./target-resolver";
export {
getGroupKeyframesAtTime,
hasGroupKeyframeAtTime,
type GroupKeyframeRef,
} from "./property-groups";
export {
getVectorChannelForPath,
isVectorValue,
} from "./vector-channel";
export {
getChannelValueAtTime,
getDiscreteChannelValueAtTime,
getScalarChannelValueAtTime,
getScalarSegmentInterpolation,
normalizeChannel,
} from "./interpolation";
export {
clampAnimationsToDuration,
cloneAnimations,
getChannel,
removeElementKeyframe,
retimeElementKeyframe,
setBindingComponentChannel,
setChannel,
splitAnimationsAtTime,
updateScalarKeyframeCurve,
upsertPathKeyframe,
} from "./keyframes";
export {
getElementLocalTime,
resolveAnimationPathValueAtTime,
} from "./resolve";
export {
getElementKeyframes,
getKeyframeById,
getKeyframeAtTime,
hasKeyframesForPath,
} from "./keyframe-query";
export {
type EditableScalarChannels,
getEditableScalarChannel,
getEditableScalarChannels,
getScalarKeyframeContext,
} from "./graph-channels";
export {
getCurveHandlesForNormalizedCubicBezier,
getNormalizedCubicBezierForScalarSegment,
} from "./curve-bridge";
export {
buildGraphicParamPath,
isGraphicParamPath,
parseGraphicParamPath,
resolveGraphicParamsAtTime,
} from "./graphic-param-channel";
export {
buildEffectParamPath,
isEffectParamPath,
parseEffectParamPath,
removeEffectParamKeyframe,
resolveEffectParamsAtTime,
} from "./effect-param-channel";
export {
getGroupKeyframesAtTime,
hasGroupKeyframeAtTime,
type GroupKeyframeRef,
} from "./property-groups";
export {
isAnimationPath,
isAnimationPropertyPath,
} from "./path";
export type { NumericSpec } from "./types";
+425
View File
@@ -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,
});
}
+314
View File
@@ -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
+22
View File
@@ -0,0 +1,22 @@
import type { AnimationPath, AnimationPropertyPath } from "@/animation/types";
import { ANIMATION_PROPERTY_PATHS } from "./types";
import { isEffectParamPath } from "./effect-param-channel";
import { isGraphicParamPath } from "./graphic-param-channel";
const ANIMATION_PROPERTY_PATH_SET = new Set<string>(ANIMATION_PROPERTY_PATHS);
export function isAnimationPropertyPath(
propertyPath: string,
): propertyPath is AnimationPropertyPath {
return ANIMATION_PROPERTY_PATH_SET.has(propertyPath);
}
export function isAnimationPath(
propertyPath: string,
): propertyPath is AnimationPath {
return (
isAnimationPropertyPath(propertyPath) ||
isGraphicParamPath(propertyPath) ||
isEffectParamPath(propertyPath)
);
}
@@ -2,8 +2,8 @@ import type {
AnimationPropertyGroup,
AnimationPropertyPath,
ElementAnimations,
} from "@/lib/animation/types";
import { ANIMATION_PROPERTY_GROUPS } from "@/lib/animation/types";
} from "@/animation/types";
import { ANIMATION_PROPERTY_GROUPS } from "@/animation/types";
import { getKeyframeAtTime } from "./keyframe-query";
export interface GroupKeyframeRef {
+153
View File
@@ -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,
}),
},
});
}
+1
View File
@@ -0,0 +1 @@
export const MIN_TRANSFORM_SCALE = 0.01;
+157
View File
@@ -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;
}
+61
View File
@@ -0,0 +1,61 @@
import type {
AnimationColorPropertyPath,
AnimationNumericPropertyPath,
ElementAnimations,
} from "./types";
import { resolveAnimationPathValueAtTime } from "./resolve";
export function resolveOpacityAtTime({
baseOpacity,
animations,
localTime,
}: {
baseOpacity: number;
animations: ElementAnimations | undefined;
localTime: number;
}): number {
return resolveAnimationPathValueAtTime({
animations,
propertyPath: "opacity",
localTime: Math.max(0, localTime),
fallbackValue: baseOpacity,
});
}
export function resolveNumberAtTime({
baseValue,
animations,
propertyPath,
localTime,
}: {
baseValue: number;
animations: ElementAnimations | undefined;
propertyPath: AnimationNumericPropertyPath;
localTime: number;
}): number {
return resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime: Math.max(0, localTime),
fallbackValue: baseValue,
});
}
export function resolveColorAtTime({
baseColor,
animations,
propertyPath,
localTime,
}: {
baseColor: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationColorPropertyPath;
localTime: number;
}): string {
return resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime: Math.max(0, localTime),
fallbackValue: baseColor,
});
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { auth } from "@/lib/auth/server";
import { auth } from "@/auth/server";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);
+31
View File
@@ -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 });
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { webEnv } from "@/lib/env/web";
import { webEnv } from "@/env/web";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit";
import { checkRateLimit } from "@/auth/rate-limit";
const searchParamsSchema = z.object({
q: z.string().max(500, "Query too long").optional(),
+2 -2
View File
@@ -4,8 +4,8 @@ import { notFound } from "next/navigation";
import { BasePage } from "@/app/base-page";
import Prose from "@/components/ui/prose";
import { Separator } from "@/components/ui/separator";
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
import type { Author, Post } from "@/lib/blog/types";
import { getPosts, getSinglePost, processHtmlContent } from "@/blog/query";
import type { Author, Post } from "@/blog/types";
type PageProps = {
params: Promise<{ slug: string }>;
+2 -2
View File
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
import Link from "next/link";
import { BasePage } from "@/app/base-page";
import { Separator } from "@/components/ui/separator";
import { getPosts } from "@/lib/blog/query";
import type { Post } from "@/lib/blog/types";
import { getPosts } from "@/blog/query";
import type { Post } from "@/blog/types";
export const metadata: Metadata = {
title: "Blog - OpenCut",
+268 -268
View File
@@ -1,268 +1,268 @@
"use client";
import type { CSSProperties } from "react";
import Image from "next/image";
import Link from "next/link";
import { Check, Copy, Download } from "lucide-react";
import { useState } from "react";
import { BasePage } from "@/app/base-page";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/utils/ui";
function downloadAsset(src: string) {
const filename = src.split("/").pop() ?? "asset.svg";
const a = document.createElement("a");
a.href = src;
a.download = filename;
a.click();
}
async function copyAsset(src: string) {
const res = await fetch(src);
const text = await res.text();
await navigator.clipboard.writeText(text);
}
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
type AssetTheme = "dark" | "light" | "icon";
interface AssetVariant {
src: string;
theme: AssetTheme;
label: string;
width: number;
height: number;
}
interface AssetSection {
title: string;
description: string;
cols: "1" | "2";
assets: AssetVariant[];
}
const ASSET_SECTIONS: AssetSection[] = [
{
title: "Symbol",
description:
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
cols: "2",
assets: [
{
src: "/logos/opencut/symbol.svg",
theme: "dark",
label: "Symbol",
width: 400,
height: 400,
},
{
src: "/logos/opencut/symbol-light.svg",
theme: "light",
label: "Symbol",
width: 400,
height: 400,
},
],
},
{
title: "Lockup",
description:
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
cols: "2",
assets: [
{
src: "/logos/opencut/logo.svg",
theme: "dark",
label: "Logo",
width: 1809,
height: 400,
},
{
src: "/logos/opencut/logo-light.svg",
theme: "light",
label: "Logo",
width: 1809,
height: 400,
},
{
src: "/logos/opencut/text.svg",
theme: "dark",
label: "Text",
width: 1760,
height: 400,
},
{
src: "/logos/opencut/text-light.svg",
theme: "light",
label: "Text",
width: 1760,
height: 400,
},
],
},
];
export default function BrandPage() {
return (
<BasePage
maxWidth="6xl"
title="Brand"
description={
<>
Download OpenCut brand assets for use in your projects.{" "}
<Link
href="#guidelines"
className="underline underline-offset-4"
onClick={() =>
document
.getElementById("guidelines")
?.scrollIntoView({ behavior: "smooth" })
}
>
Read the brand guidelines.
</Link>
</>
}
action={
<Button
variant="outline"
size="lg"
className="mx-auto gap-2"
onClick={() => {
ALL_ASSETS().forEach((asset, i) => {
setTimeout(() => downloadAsset(asset.src), i * 200);
});
}}
>
<Download />
Download all
</Button>
}
>
<div className="flex flex-col gap-10">
{ASSET_SECTIONS.map((section) => (
<div key={section.title} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h2 className="font-semibold text-lg">{section.title}</h2>
<p className="text-muted-foreground text-sm">
{section.description}
</p>
</div>
<div
className={cn(
"grid gap-3",
section.cols === "2"
? "grid-cols-1 sm:grid-cols-2"
: "grid-cols-1",
)}
>
{section.assets.map((variant) => (
<AssetCard key={variant.src} variant={variant} />
))}
</div>
</div>
))}
</div>
<Separator />
<div id="guidelines" className="flex flex-col gap-8 text-sm">
<div className="flex flex-col gap-3">
<h2 className="font-semibold text-lg">Usage</h2>
<p className="text-muted-foreground text-base leading-relaxed">
OpenCut is open source — the code is free to use under its license.
That license does not cover the name or logo. You can say you use
OpenCut, that your project integrates with OpenCut, or that it was
built on top of OpenCut. You cannot name your product OpenCut, imply
we made or endorse your product, or use the marks commercially
without asking first. For anything unclear, reach out at{" "}
<Link
href="mailto:brand@opencut.app"
className="underline underline-offset-4"
>
brand@opencut.app
</Link>
.
</p>
</div>
<div className="flex flex-col gap-3">
<h2 className="font-semibold text-lg">What's not allowed</h2>
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
{[
"Using OpenCut in the name of your product, service, or domain.",
"Implying that OpenCut made, sponsors, or endorses your work.",
"Using the logo or name on merchandise or commercial marketing.",
"Modifying the marks.",
].map((item) => (
<li key={item} className="flex gap-2">
<span className="mt-0.5 shrink-0">-</span>
<span>{item}</span>
</li>
))}
</ul>
</div>
</div>
</BasePage>
);
}
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
light: {
backgroundImage:
"linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
backgroundSize: "18px 18px",
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
backgroundColor: "#000",
},
dark: {
backgroundImage:
"linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
backgroundSize: "18px 18px",
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
backgroundColor: "#f5f5f5",
},
};
function AssetCard({ variant }: { variant: AssetVariant }) {
const [copied, setCopied] = useState(false);
async function handleCopy() {
await copyAsset(variant.src);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Card
className="group relative overflow-hidden"
style={
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
}
>
<div className="flex h-56 items-center justify-center px-12 py-8">
<Image
src={variant.src}
alt={variant.label}
width={variant.width}
height={variant.height}
className="max-h-16 w-auto select-none object-contain"
draggable={false}
unoptimized
/>
</div>
<Button
variant="outline"
size="icon"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
onClick={handleCopy}
>
{copied ? <Check /> : <Copy />}
</Button>
</Card>
);
}
"use client";
import type { CSSProperties } from "react";
import Image from "next/image";
import Link from "next/link";
import { Check, Copy, Download } from "lucide-react";
import { useState } from "react";
import { BasePage } from "@/app/base-page";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/utils/ui";
function downloadAsset(src: string) {
const filename = src.split("/").pop() ?? "asset.svg";
const a = document.createElement("a");
a.href = src;
a.download = filename;
a.click();
}
async function copyAsset(src: string) {
const res = await fetch(src);
const text = await res.text();
await navigator.clipboard.writeText(text);
}
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
type AssetTheme = "dark" | "light" | "icon";
interface AssetVariant {
src: string;
theme: AssetTheme;
label: string;
width: number;
height: number;
}
interface AssetSection {
title: string;
description: string;
cols: "1" | "2";
assets: AssetVariant[];
}
const ASSET_SECTIONS: AssetSection[] = [
{
title: "Symbol",
description:
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
cols: "2",
assets: [
{
src: "/logos/opencut/symbol.svg",
theme: "dark",
label: "Symbol",
width: 400,
height: 400,
},
{
src: "/logos/opencut/symbol-light.svg",
theme: "light",
label: "Symbol",
width: 400,
height: 400,
},
],
},
{
title: "Lockup",
description:
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
cols: "2",
assets: [
{
src: "/logos/opencut/logo.svg",
theme: "dark",
label: "Logo",
width: 1809,
height: 400,
},
{
src: "/logos/opencut/logo-light.svg",
theme: "light",
label: "Logo",
width: 1809,
height: 400,
},
{
src: "/logos/opencut/text.svg",
theme: "dark",
label: "Text",
width: 1760,
height: 400,
},
{
src: "/logos/opencut/text-light.svg",
theme: "light",
label: "Text",
width: 1760,
height: 400,
},
],
},
];
export default function BrandPage() {
return (
<BasePage
maxWidth="6xl"
title="Brand"
description={
<>
Download OpenCut brand assets for use in your projects.{" "}
<Link
href="#guidelines"
className="underline underline-offset-4"
onClick={() =>
document
.getElementById("guidelines")
?.scrollIntoView({ behavior: "smooth" })
}
>
Read the brand guidelines.
</Link>
</>
}
action={
<Button
variant="outline"
size="lg"
className="mx-auto gap-2"
onClick={() => {
ALL_ASSETS().forEach((asset, i) => {
setTimeout(() => downloadAsset(asset.src), i * 200);
});
}}
>
<Download />
Download all
</Button>
}
>
<div className="flex flex-col gap-10">
{ASSET_SECTIONS.map((section) => (
<div key={section.title} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h2 className="font-semibold text-lg">{section.title}</h2>
<p className="text-muted-foreground text-sm">
{section.description}
</p>
</div>
<div
className={cn(
"grid gap-3",
section.cols === "2"
? "grid-cols-1 sm:grid-cols-2"
: "grid-cols-1",
)}
>
{section.assets.map((variant) => (
<AssetCard key={variant.src} variant={variant} />
))}
</div>
</div>
))}
</div>
<Separator />
<div id="guidelines" className="flex flex-col gap-8 text-sm">
<div className="flex flex-col gap-3">
<h2 className="font-semibold text-lg">Usage</h2>
<p className="text-muted-foreground text-base leading-relaxed">
OpenCut is open source — the code is free to use under its license.
That license does not cover the name or logo. You can say you use
OpenCut, that your project integrates with OpenCut, or that it was
built on top of OpenCut. You cannot name your product OpenCut, imply
we made or endorse your product, or use the marks commercially
without asking first. For anything unclear, reach out at{" "}
<Link
href="mailto:brand@opencut.app"
className="underline underline-offset-4"
>
brand@opencut.app
</Link>
.
</p>
</div>
<div className="flex flex-col gap-3">
<h2 className="font-semibold text-lg">What&apos;s not allowed</h2>
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
{[
"Using OpenCut in the name of your product, service, or domain.",
"Implying that OpenCut made, sponsors, or endorses your work.",
"Using the logo or name on merchandise or commercial marketing.",
"Modifying the marks.",
].map((item) => (
<li key={item} className="flex gap-2">
<span className="mt-0.5 shrink-0">-</span>
<span>{item}</span>
</li>
))}
</ul>
</div>
</div>
</BasePage>
);
}
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
light: {
backgroundImage:
"linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
backgroundSize: "18px 18px",
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
backgroundColor: "#000",
},
dark: {
backgroundImage:
"linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
backgroundSize: "18px 18px",
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
backgroundColor: "#f5f5f5",
},
};
function AssetCard({ variant }: { variant: AssetVariant }) {
const [copied, setCopied] = useState(false);
async function handleCopy() {
await copyAsset(variant.src);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Card
className="group relative overflow-hidden"
style={
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
}
>
<div className="flex h-56 items-center justify-center px-12 py-8">
<Image
src={variant.src}
alt={variant.label}
width={variant.width}
height={variant.height}
className="max-h-16 w-auto select-none object-contain"
draggable={false}
unoptimized
/>
</div>
<Button
variant="outline"
size="icon"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
onClick={handleCopy}
>
{copied ? <Check /> : <Copy />}
</Button>
</Card>
);
}
@@ -3,15 +3,15 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { BasePage } from "@/app/base-page";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { getReleaseByVersion, getSortedReleases } from "../utils";
import { getReleaseByVersion, getSortedReleases } from "@/changelog/utils";
import {
ReleaseArticle,
ReleaseMeta,
ReleaseTitle,
ReleaseDescription,
ReleaseChanges,
} from "../components/release";
import { CopyMarkdownButton } from "../components/copy-markdown-button";
} from "@/changelog/components/release";
import { CopyMarkdownButton } from "@/changelog/components/copy-markdown-button";
type Props = { params: Promise<{ version: string }> };
+5 -2
View File
@@ -1,14 +1,17 @@
import type { Metadata } from "next";
import { BasePage } from "@/app/base-page";
import { Separator } from "@/components/ui/separator";
import { type Release as ReleaseType, getSortedReleases } from "./utils";
import {
type Release as ReleaseType,
getSortedReleases,
} from "@/changelog/utils";
import {
ReleaseArticle,
ReleaseMeta,
ReleaseTitle,
ReleaseDescription,
ReleaseChanges,
} from "./components/release";
} from "@/changelog/components/release";
export const metadata: Metadata = {
title: "Changelog - OpenCut",
-53
View File
@@ -1,53 +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 }: { 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 };
}
function isPublishedRelease({ published }: Release) {
return published !== false;
}
export function getSortedReleases() {
return allChangelogs
.filter(isPublishedRelease)
.sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }),
);
}
export function getReleaseByVersion({ version }: { version: string }) {
return getSortedReleases().find((release) => release.version === version);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
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";
export const metadata: Metadata = {
+209 -113
View File
@@ -1,113 +1,209 @@
"use client";
import { useParams } from "next/navigation";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { AssetsPanel } from "@/components/editor/panels/assets";
import { PropertiesPanel } from "@/components/editor/panels/properties";
import { Timeline } from "@/components/editor/panels/timeline";
import { PreviewPanel } from "@/components/editor/panels/preview";
import { EditorHeader } from "@/components/editor/editor-header";
import { EditorProvider } from "@/components/providers/editor-provider";
import { Onboarding } from "@/components/editor/onboarding";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
import { usePanelStore } from "@/stores/panel-store";
import { usePasteMedia } from "@/hooks/use-paste-media";
import { MobileGate } from "@/components/editor/mobile-gate";
export default function Editor() {
const params = useParams();
const projectId = params.project_id as string;
return (
<MobileGate>
<EditorProvider projectId={projectId}>
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
<EditorHeader />
<div className="min-h-0 min-w-0 flex-1">
<EditorLayout />
</div>
<Onboarding />
<MigrationDialog />
</div>
</EditorProvider>
</MobileGate>
);
}
function EditorLayout() {
usePasteMedia();
const { panels, setPanel } = usePanelStore();
return (
<ResizablePanelGroup
direction="vertical"
className="size-full gap-[0.18rem]"
onLayout={(sizes) => {
setPanel("mainContent", sizes[0] ?? panels.mainContent);
setPanel("timeline", 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("tools", sizes[0] ?? panels.tools);
setPanel("preview", sizes[1] ?? panels.preview);
setPanel("properties", 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 />
</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>
);
}
"use client";
import { useParams } from "next/navigation";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { AssetsPanel } from "@/components/editor/panels/assets";
import { PropertiesPanel } from "@/components/editor/panels/properties";
import { Timeline } from "@/timeline/components";
import { PreviewPanel } from "@/preview/components";
import { EditorHeader } from "@/components/editor/editor-header";
import { EditorProvider } from "@/components/providers/editor-provider";
import { Onboarding } from "@/components/editor/onboarding";
import { MigrationDialog } from "@/project/components/migration-dialog";
import { usePanelStore } from "@/editor/panel-store";
import { usePasteMedia } from "@/media/use-paste-media";
import { MobileGate } from "@/components/editor/mobile-gate";
import { useMemo, useState } from "react";
import { useEditor } from "@/editor/use-editor";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@/components/ui/button";
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
import {
createPreviewOverlayControl,
isPreviewOverlayVisible,
mergePreviewOverlaySources,
} from "@/preview/overlays";
import { usePreviewStore } from "@/preview/preview-store";
import { getGuidePreviewOverlaySource } from "@/guides";
import {
bookmarkNotesPreviewOverlay,
getBookmarkPreviewOverlaySource,
} from "@/timeline/bookmarks/index";
export default function Editor() {
const params = useParams();
const projectId = params.project_id as string;
return (
<MobileGate>
<EditorProvider projectId={projectId}>
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
<DegradedRendererBanner />
<EditorHeader />
<div className="min-h-0 min-w-0 flex-1">
<EditorLayout />
</div>
<Onboarding />
<MigrationDialog />
<ChangelogNotification />
</div>
</EditorProvider>
</MobileGate>
);
}
function DegradedRendererBanner() {
const isDegraded = useEditor((e) => e.renderer.isDegraded);
const [dismissed, setDismissed] = useState(false);
if (!isDegraded || dismissed) return null;
return (
<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
variant="text"
size="icon"
className="p-0 w-auto [&_svg]:size-3.5"
onClick={() => setDismissed(true)}
aria-label="Dismiss"
>
<HugeiconsIcon icon={Cancel01Icon} />
</Button>
</div>
);
}
function EditorLayout() {
usePasteMedia();
const { panels, setPanel } = usePanelStore();
const activeScene = useEditor((editor) =>
editor.scenes.getActiveSceneOrNull(),
);
const currentTime = useEditor((editor) => editor.playback.getCurrentTime());
const activeGuide = usePreviewStore((state) => state.activeGuide);
const overlays = usePreviewStore((state) => state.overlays);
const setOverlayVisibility = usePreviewStore(
(state) => state.setOverlayVisibility,
);
const showBookmarkNotes = isPreviewOverlayVisible({
overlay: bookmarkNotesPreviewOverlay,
overlays,
});
const overlaySource = useMemo(
() =>
mergePreviewOverlaySources({
sources: [
getGuidePreviewOverlaySource({
guideId: activeGuide,
}),
activeScene
? getBookmarkPreviewOverlaySource({
bookmarks: activeScene.bookmarks,
time: currentTime,
isVisible: showBookmarkNotes,
})
: {
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
View File
@@ -1,284 +1,291 @@
@import "tailwindcss";
/* Custom variant for dark mode */
@custom-variant dark (&:where(.dark, .dark *));
/* Plugins */
@plugin "@tailwindcss/typography";
@plugin "tailwindcss-animate";
:root {
--background: hsl(0, 0%, 100%);
--foreground: hsl(0 0% 11%);
--card: hsl(0, 0%, 100%);
--card-foreground: hsl(0 0% 11%);
--popover: hsl(0, 0%, 100%);
--popover-hover: hsl(0, 0%, 96%);
--popover-foreground: hsl(0 0% 2%);
--primary: hsl(200, 90%, 52%);
--primary-foreground: hsl(0, 0%, 100%);
--secondary: hsl(204, 100%, 97%);
--secondary-border: hsl(204, 100%, 94%);
--secondary-foreground: hsl(200, 98%, 39%);
--muted: hsl(0 0% 85.1%);
--muted-foreground: hsl(0 0% 50%);
--accent: hsl(0, 0%, 96%);
--accent-foreground: hsl(0 0% 2%);
--destructive: hsl(0, 83%, 50%);
--destructive-foreground: hsl(0, 0%, 100%);
--constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0, 0%, 100%);
--border: hsl(0 0% 91%);
--input: hsl(0, 0%, 100%);
--ring: hsl(0, 0%, 55%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(0 0% 96.1%);
--sidebar-foreground: hsl(0 0% 2%);
--sidebar-primary: hsl(0 0% 2%);
--sidebar-primary-foreground: hsl(0 0% 91%);
--sidebar-accent: hsl(0 0% 85.1%);
--sidebar-accent-foreground: hsl(0 0% 2%);
--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%);
--card: hsl(0, 0%, 98%);
--card-foreground: hsl(0 0% 13%);
--primary-foreground: hsl(0, 0%, 98%);
--secondary: hsl(204, 100%, 95%);
--secondary-border: hsl(204, 100%, 92%);
--secondary-foreground: hsl(200, 98%, 37%);
--muted: hsl(0 0% 83.1%);
--muted-foreground: hsl(0 0% 48%);
--accent: hsl(0, 0%, 93%);
--accent-foreground: hsl(0 0% 5%);
--destructive-foreground: hsl(0, 0%, 98%);
--constructive-foreground: hsl(0, 0%, 98%);
--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%);
--card: hsl(0, 0%, 5%);
--card-foreground: hsl(0 0% 87%);
--popover: hsl(0, 0%, 5%);
--popover-hover: hsl(0, 0%, 13%);
--popover-foreground: hsl(0 0% 95%);
--secondary: hsl(204, 100%, 12%);
--secondary-border: hsl(204, 100%, 15%);
--secondary-foreground: hsl(200, 98%, 61%);
--muted: hsl(0 0% 20%);
--accent: hsl(0, 0%, 14%);
--accent-foreground: hsl(0 0% 95%);
--border: hsl(0 0% 16%);
--input: hsl(0 0% 5%);
--ring: hsl(0, 0%, 50%);
--sidebar-background: hsl(0 0% 8%);
--sidebar-foreground: hsl(0 0% 95%);
--sidebar-primary: hsl(0 0% 95%);
--sidebar-primary-foreground: hsl(0 0% 15%);
--sidebar-accent: hsl(0 0% 20%);
--sidebar-accent-foreground: hsl(0 0% 95%);
--sidebar-border: hsl(0 0% 20%);
--sidebar-ring: hsl(0 0% 83.1%);
--sidebar: hsl(0 0% 6%);
}
.dark .panel {
--background: hsl(0 0% 10%);
--foreground: hsl(0 0% 85%);
--card: hsl(0, 0%, 10%);
--card-foreground: hsl(0 0% 85%);
--secondary: hsl(204, 67%, 9%);
--secondary-border: hsl(204, 100%, 14%);
--secondary-foreground: hsl(200, 98%, 63%);
--muted: hsl(0 0% 22%);
--accent: hsl(0, 0%, 15%);
--accent-foreground: hsl(0 0% 93%);
--border: hsl(0 0% 18%);
--input: hsl(0 0% 22%);
--ring: hsl(0, 0%, 52%);
}
@layer base {
/*
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.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
/* Other default base styles */
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
/* Prevent back/forward swipe */
overscroll-behavior-x: contain;
}
::selection {
@apply bg-primary/35 selection:text-primary-foreground;
}
}
@theme inline {
/* Responsive breakpoints */
--breakpoint-xs: 30rem;
/* Typography */
--font-sans: var(--font-inter), sans-serif;
/* Font sizes */
--text-xs: 0.72rem;
--text-sm: 0.79rem;
--text-base: 0.92rem;
--text-base--line-height: calc(1.5 / 0.95);
--text-xs--line-height: calc(1 / 0.8);
/* Border radius */
--radius-lg: 0.82rem;
--radius-md: 0.65rem;
--radius-sm: 0.35rem;
/* Palette mapped to root design tokens */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-hover: var(--popover-hover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-border: var(--secondary-border);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-constructive: var(--constructive);
--color-constructive-foreground: var(--constructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* Chart colors */
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
/* Sidebar */
--color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
/* Animations */
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
@utility scrollbar-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@utility scrollbar-x-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar:horizontal {
display: none;
}
}
@utility scrollbar-y-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar:vertical {
display: none;
}
}
@utility scrollbar-thin {
&::-webkit-scrollbar {
width: 7px;
height: 7px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@import "tailwindcss";
/* Custom variant for dark mode */
@custom-variant dark (&:where(.dark, .dark *));
/* Plugins */
@plugin "@tailwindcss/typography";
@plugin "tailwindcss-animate";
:root {
--background: hsl(0, 0%, 100%);
--foreground: hsl(0 0% 11%);
--card: hsl(0, 0%, 100%);
--card-foreground: hsl(0 0% 11%);
--popover: hsl(0, 0%, 100%);
--popover-hover: hsl(0, 0%, 96%);
--popover-foreground: hsl(0 0% 2%);
--primary: hsl(200, 90%, 52%);
--primary-foreground: hsl(0, 0%, 100%);
--secondary: hsl(204, 100%, 97%);
--secondary-border: hsl(204, 100%, 94%);
--secondary-foreground: hsl(200, 98%, 39%);
--muted: hsl(0 0% 85.1%);
--muted-foreground: hsl(0 0% 50%);
--accent: hsl(0, 0%, 96%);
--accent-foreground: hsl(0 0% 2%);
--destructive: hsl(0, 83%, 50%);
--destructive-foreground: hsl(0, 0%, 100%);
--constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0, 0%, 100%);
--caution: hsl(38, 92%, 50%);
--caution-foreground: hsl(0, 0%, 10%);
--border: hsl(0 0% 91%);
--input: hsl(0, 0%, 100%);
--ring: hsl(0, 0%, 55%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(0 0% 96.1%);
--sidebar-foreground: hsl(0 0% 2%);
--sidebar-primary: hsl(0 0% 2%);
--sidebar-primary-foreground: hsl(0 0% 91%);
--sidebar-accent: hsl(0 0% 85.1%);
--sidebar-accent-foreground: hsl(0 0% 2%);
--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%);
--card: hsl(0, 0%, 98%);
--card-foreground: hsl(0 0% 13%);
--primary-foreground: hsl(0, 0%, 98%);
--secondary: hsl(204, 100%, 95%);
--secondary-border: hsl(204, 100%, 92%);
--secondary-foreground: hsl(200, 98%, 37%);
--muted: hsl(0 0% 83.1%);
--muted-foreground: hsl(0 0% 48%);
--accent: hsl(0, 0%, 93%);
--accent-foreground: hsl(0 0% 5%);
--destructive-foreground: hsl(0, 0%, 98%);
--constructive-foreground: hsl(0, 0%, 98%);
--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%);
--card: hsl(0, 0%, 5%);
--card-foreground: hsl(0 0% 87%);
--popover: hsl(0, 0%, 5%);
--popover-hover: hsl(0, 0%, 13%);
--popover-foreground: hsl(0 0% 95%);
--secondary: hsl(204, 100%, 12%);
--secondary-border: hsl(204, 100%, 15%);
--secondary-foreground: hsl(200, 98%, 61%);
--muted: hsl(0 0% 20%);
--accent: hsl(0, 0%, 14%);
--accent-foreground: hsl(0 0% 95%);
--border: hsl(0 0% 16%);
--input: hsl(0 0% 5%);
--ring: hsl(0, 0%, 50%);
--caution: hsl(38, 92%, 60%);
--caution-foreground: hsl(0, 0%, 10%);
--sidebar-background: hsl(0 0% 8%);
--sidebar-foreground: hsl(0 0% 95%);
--sidebar-primary: hsl(0 0% 95%);
--sidebar-primary-foreground: hsl(0 0% 15%);
--sidebar-accent: hsl(0 0% 20%);
--sidebar-accent-foreground: hsl(0 0% 95%);
--sidebar-border: hsl(0 0% 20%);
--sidebar-ring: hsl(0 0% 83.1%);
--sidebar: hsl(0 0% 6%);
}
.dark .panel {
--background: hsl(0 0% 10%);
--foreground: hsl(0 0% 85%);
--card: hsl(0, 0%, 10%);
--card-foreground: hsl(0 0% 85%);
--secondary: hsl(204, 67%, 9%);
--secondary-border: hsl(204, 100%, 14%);
--secondary-foreground: hsl(200, 98%, 63%);
--muted: hsl(0 0% 22%);
--accent: hsl(0, 0%, 15%);
--accent-foreground: hsl(0 0% 93%);
--border: hsl(0 0% 18%);
--input: hsl(0 0% 22%);
--ring: hsl(0, 0%, 52%);
}
@layer base {
/*
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.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
/* Other default base styles */
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
/* Prevent back/forward swipe */
overscroll-behavior-x: contain;
}
::selection {
@apply bg-primary/35 selection:text-primary-foreground;
}
}
@theme inline {
/* Responsive breakpoints */
--breakpoint-xs: 30rem;
/* Typography */
--font-sans: var(--font-inter), sans-serif;
/* Font sizes */
--text-xs: 0.72rem;
--text-sm: 0.79rem;
--text-base: 0.92rem;
--text-base--line-height: calc(1.5 / 0.95);
--text-xs--line-height: calc(1 / 0.8);
/* Border radius */
--radius-lg: 0.82rem;
--radius-md: 0.65rem;
--radius-sm: 0.35rem;
/* Palette mapped to root design tokens */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-hover: var(--popover-hover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-border: var(--secondary-border);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-constructive: var(--constructive);
--color-constructive-foreground: var(--constructive-foreground);
--color-caution: var(--caution);
--color-caution-foreground: var(--caution-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* Chart colors */
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
/* Sidebar */
--color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
/* Animations */
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
@utility scrollbar-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@utility scrollbar-x-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar:horizontal {
display: none;
}
}
@utility scrollbar-y-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar:vertical {
display: none;
}
}
@utility scrollbar-thin {
&::-webkit-scrollbar {
width: 7px;
height: 7px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+2 -8
View File
@@ -2,10 +2,11 @@ import { ThemeProvider } from "next-themes";
import Script from "next/script";
import "./globals.css";
import { Toaster } from "../components/ui/sonner";
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
import { TooltipProvider } from "../components/ui/tooltip";
import { baseMetaData } from "./metadata";
import { BotIdClient } from "botid/client";
import { webEnv } from "@/lib/env/web";
import { webEnv } from "@/env/web";
import { Inter } from "next/font/google";
const siteFont = Inter({ subsets: ["latin"] });
@@ -35,13 +36,6 @@ export default function RootLayout({
crossOrigin="anonymous"
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>
+1 -1
View File
@@ -1,5 +1,5 @@
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 = {
metadataBase: new URL(SITE_URL),
+1 -1
View File
@@ -2,7 +2,7 @@ import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import type { Metadata } from "next";
import { SITE_URL } from "@/constants/site-constants";
import { SITE_URL } from "@/site/brand";
export const metadata: Metadata = {
alternates: {
+8 -5
View File
@@ -7,7 +7,7 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import { SOCIAL_LINKS } from "@/site/social";
export const metadata: Metadata = {
title: "Privacy Policy - OpenCut",
@@ -57,7 +57,10 @@ export default function PrivacyPage() {
content is tracked
</li>
<li>You can clear local data from your browser at any time</li>
<li>We don't sell or share your data with anyone (we don't even have it)</li>
<li>
We don&apos;t sell or share your data with anyone (we don&apos;t
even have it)
</li>
</ol>
<p className="mt-4">
Questions? Email us at{" "}
@@ -172,7 +175,7 @@ export default function PrivacyPage() {
<a
href={SOCIAL_LINKS.github}
target="_blank"
rel="noopener"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
GitHub
@@ -189,7 +192,7 @@ export default function PrivacyPage() {
<a
href={`${SOCIAL_LINKS.github}/issues`}
target="_blank"
rel="noopener"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
GitHub repository
@@ -205,7 +208,7 @@ export default function PrivacyPage() {
<a
href={SOCIAL_LINKS.x}
target="_blank"
rel="noopener"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
X (Twitter)
+13 -11
View File
@@ -7,21 +7,21 @@ import type { KeyboardEvent, MouseEvent } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import type { EditorCore } from "@/core";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
import { StoragePersistenceDialog } from "@/components/editor/dialogs/storage-persistence-dialog";
import { MigrationDialog } from "@/project/components/migration-dialog";
import { StoragePersistenceDialog } from "@/services/storage/components/storage-persistence-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { useEditor } from "@/hooks/use-editor";
import { useEditor } from "@/editor/use-editor";
import { useProjectsStore } from "./store";
import type {
TProjectMetadata,
TProjectSortKey,
TProjectSortOption,
} from "@/lib/project/types";
import { formatTimeCode } from "opencut-wasm";
} from "@/project/types";
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
import { formatDate } from "@/utils/date";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@@ -62,11 +62,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog";
import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog";
import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog";
import { DeleteProjectDialog } from "@/project/components/delete-project-dialog";
import { ProjectInfoDialog } from "@/project/components/project-info-dialog";
import { RenameProjectDialog } from "@/project/components/rename-project-dialog";
import { cn } from "@/utils/ui";
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
const formatProjectDuration = ({
duration,
}: {
@@ -76,8 +76,9 @@ const formatProjectDuration = ({
return null;
}
const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
return formatTimeCode({ timeInSeconds: duration, format }) ?? "";
const durationSeconds = mediaTimeToSeconds({ time: duration });
const format = durationSeconds >= 3600 ? "HH:MM:SS" : "MM:SS";
return formatTimecode({ time: duration, format }) ?? "";
};
const VIEW_MODE_OPTIONS = [
@@ -106,6 +107,7 @@ export default function ProjectsPage() {
<div className="bg-background min-h-screen">
<MigrationDialog />
<StoragePersistenceDialog />
<ChangelogNotification />
<ProjectsHeader />
<ProjectsToolbar projectIds={projectsToDisplay.map((p) => p.id)} />
<main className="mx-auto px-4 pt-2 pb-6 flex flex-col gap-4">
+1 -1
View File
@@ -1,6 +1,6 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { TProjectSortKey } from "@/lib/project/types";
import type { TProjectSortKey } from "@/project/types";
export type ProjectsViewMode = "grid" | "list";
+1 -1
View File
@@ -1,5 +1,5 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/constants/site-constants";
import { SITE_URL } from "@/site/brand";
export default function robots(): MetadataRoute.Robots {
return {
+2 -2
View File
@@ -1,6 +1,6 @@
import { Feed } from "feed";
import { getPosts } from "@/lib/blog/query";
import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
import { getPosts } from "@/blog/query";
import { SITE_INFO, SITE_URL } from "@/site/brand";
export async function GET() {
try {
+2 -2
View File
@@ -1,5 +1,5 @@
import { SITE_URL } from "@/constants/site-constants";
import { getPosts } from "@/lib/blog/query";
import { SITE_URL } from "@/site/brand";
import { getPosts } from "@/blog/query";
import type { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
+1 -1
View File
@@ -3,7 +3,7 @@ import Image from "next/image";
import Link from "next/link";
import { BasePage } from "@/app/base-page";
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 { LinkSquare02Icon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
+17 -13
View File
@@ -7,7 +7,7 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import { SOCIAL_LINKS } from "@/site/social";
export const metadata: Metadata = {
title: "Terms of Service - OpenCut",
@@ -51,9 +51,13 @@ export default function TermsPage() {
Free for personal and commercial use with no watermarks or
restrictions
</li>
<li>You're responsible for how you use it - don't break the law</li>
<li>
Service provided "as is" - we can't guarantee perfect uptime
You&apos;re responsible for how you use it - don&apos;t break
the law
</li>
<li>
Service provided &quot;as is&quot; - we can&apos;t guarantee
perfect uptime
</li>
<li>
Open source means you can review our code and self-host if
@@ -109,8 +113,8 @@ export default function TermsPage() {
</li>
</ul>
<p>
You're responsible for how you use OpenCut and the content you create.
Don't use it for anything illegal in your jurisdiction.
You&apos;re responsible for how you use OpenCut and the content you
create. Don&apos;t use it for anything illegal in your jurisdiction.
</p>
</section>
@@ -127,8 +131,8 @@ export default function TermsPage() {
<h2 className="text-2xl font-semibold">Service</h2>
<p>
OpenCut does not currently require an account. The service is provided
"as is" without warranties. While we strive for reliability, we can't
guarantee uninterrupted service.
&quot;as is&quot; without warranties. While we strive for
reliability, we can&apos;t guarantee uninterrupted service.
</p>
</section>
@@ -146,7 +150,7 @@ export default function TermsPage() {
<a
href={SOCIAL_LINKS.github}
target="_blank"
rel="noopener"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
GitHub
@@ -161,12 +165,12 @@ export default function TermsPage() {
OpenCut is provided free of charge. To the extent permitted by law:
</p>
<ul className="list-disc space-y-2 pl-6">
<li>We're not liable for any loss of data or content</li>
<li>We&apos;re not liable for any loss of data or content</li>
<li>
Projects are stored in your browser and may be lost if you clear
browser data
</li>
<li>We're not responsible for how you use the service</li>
<li>We&apos;re not responsible for how you use the service</li>
<li>Our liability is limited to the maximum extent allowed by law</li>
</ul>
<p>
@@ -180,7 +184,7 @@ export default function TermsPage() {
<h2 className="text-2xl font-semibold">Service Changes</h2>
<p>We may update OpenCut and these terms:</p>
<ul className="list-disc space-y-2 pl-6">
<li>We'll notify you of significant changes to these terms</li>
<li>We&apos;ll notify you of significant changes to these terms</li>
<li>Continued use means you accept any updates</li>
<li>You can always self-host an older version if you prefer</li>
<li>Major changes will be discussed with the community on GitHub</li>
@@ -203,7 +207,7 @@ export default function TermsPage() {
<a
href={`${SOCIAL_LINKS.github}/issues`}
target="_blank"
rel="noopener"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
GitHub repository
@@ -219,7 +223,7 @@ export default function TermsPage() {
<a
href={SOCIAL_LINKS.x}
target="_blank"
rel="noopener"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
X (Twitter)
@@ -1,5 +1,5 @@
import { createAuthClient } from "better-auth/react";
import { webEnv } from "@/lib/env/web";
import { webEnv } from "@/env/web";
export const { signIn, signUp, useSession } = createAuthClient({
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
@@ -1,6 +1,6 @@
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { webEnv } from "@/lib/env/web";
import { webEnv } from "@/env/web";
const redis = new Redis({
url: webEnv.UPSTASH_REDIS_REST_URL,
@@ -1,8 +1,8 @@
import { betterAuth, type RateLimit } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { Redis } from "@upstash/redis";
import { db } from "@/lib/db";
import { webEnv } from "@/lib/env/web";
import { db } from "@/db";
import { webEnv } from "@/env/web";
const redis = new Redis({
url: webEnv.UPSTASH_REDIS_REST_URL,
+10
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
export const DEFAULT_BACKGROUND_COLOR = "#000000";
@@ -5,7 +5,7 @@ import type {
MarblePost,
MarblePostList,
MarbleTagList,
} from "@/lib/blog/types";
} from "@/blog/types";
import { unified } from "unified";
import rehypeParse from "rehype-parse";
import rehypeStringify from "rehype-stringify";
@@ -47,7 +47,12 @@ const EMPTY_AUTHORS: MarbleAuthorList = {
};
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>({
+10
View File
@@ -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>
);
}
@@ -1,68 +1,98 @@
"use client";
import { useState } from "react";
import { CheckIcon, ClipboardIcon } from "lucide-react";
import { getSectionTitle, groupAndOrderChanges } from "../utils";
import type { Change } from "../utils";
import { cn } from "@/utils/ui";
import { Button } from "@/components/ui/button";
function buildMarkdown({
description,
changes,
}: {
description?: string;
changes: Change[];
}): string {
const lines: string[] = [];
if (description) {
lines.push(description, "");
}
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
for (const type of orderedTypes) {
lines.push(`## ${getSectionTitle({ type })}`);
for (const change of grouped[type]) {
lines.push(`- ${change.text}`);
}
lines.push("");
}
return lines.join("\n").trimEnd();
}
export function CopyMarkdownButton({
description,
changes,
}: {
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>
);
}
"use client";
import { useState } from "react";
import { CheckIcon, ClipboardIcon } from "lucide-react";
import {
getSectionTitle,
groupAndOrderChanges,
isSectionCollapsible,
} from "../utils";
import type { Change } from "../utils";
import { cn } from "@/utils/ui";
import { Button } from "@/components/ui/button";
function buildMarkdown({
description,
changes,
}: {
description?: string;
changes: Change[];
}): string {
const lines: string[] = [];
if (description) {
lines.push(description, "");
}
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
for (const type of orderedTypes) {
if (isSectionCollapsible({ type })) {
lines.push(
buildCollapsibleMarkdownSection({
title: getSectionTitle({ type }),
changes: grouped[type],
}),
"",
);
continue;
}
lines.push(`## ${getSectionTitle({ type })}`);
for (const change of grouped[type]) {
lines.push(`- ${change.text}`);
}
lines.push("");
}
return lines.join("\n").trimEnd();
}
function buildCollapsibleMarkdownSection({
title,
changes,
}: {
title: string;
changes: Change[];
}): string {
const bulletLines = changes.map((change) => `- ${change.text}`).join("\n");
return `<details>\n<summary>${title}</summary>\n\n${bulletLines}\n\n</details>`;
}
export function CopyMarkdownButton({
description,
changes,
}: {
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>
);
}
@@ -1,106 +1,153 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { cn } from "@/utils/ui";
import { getSectionTitle, groupAndOrderChanges } from "../utils";
import type { Release } from "../utils";
export function ReleaseArticle({
variant,
isLatest,
children,
}: {
variant: "list" | "detail";
isLatest?: boolean;
children: ReactNode;
}) {
if (variant === "list") {
return (
<article className="relative sm:pl-10">
<div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
<div
className={cn(
"size-[11px] rounded-full border-[1.5px]",
isLatest
? "border-foreground bg-foreground"
: "border-muted-foreground/30 bg-background",
)}
/>
</div>
<div className="flex flex-col gap-5">{children}</div>
</article>
);
}
return <article className="flex flex-col gap-8">{children}</article>;
}
export function ReleaseMeta({ release }: { release: Release }) {
return (
<span className="text-sm font-medium tracking-widest text-muted-foreground">
{release.version} — {release.date}
</span>
);
}
const titleSizes: Record<"h1" | "h2", string> = {
h1: "text-4xl",
h2: "text-2xl",
};
export function ReleaseTitle({
as: As,
href,
children,
}: {
as: "h1" | "h2";
href?: string;
children: ReactNode;
}) {
return (
<As className={cn("font-bold tracking-tight", titleSizes[As])}>
{href ? (
<Link href={href} className="hover:underline underline-offset-4">
{children}
</Link>
) : (
children
)}
</As>
);
}
export function ReleaseDescription({ children }: { children: ReactNode }) {
return (
<p className="text-base text-foreground leading-relaxed max-w-xl">
{children}
</p>
);
}
export function ReleaseChanges({ release }: { release: Release }) {
const { grouped, orderedTypes } = groupAndOrderChanges({
changes: release.changes,
});
return (
<div className="flex flex-col gap-4">
{orderedTypes.map((type) => (
<div key={type} className="flex flex-col gap-1.5">
<h3 className="text-base font-semibold text-foreground">
{getSectionTitle({ type })}:
</h3>
<ul className="list-disc pl-5 space-y-1.5">
{grouped[type].map((change) => (
<li
key={change.text}
className="text-base text-foreground leading-relaxed"
>
{change.text}
</li>
))}
</ul>
</div>
))}
</div>
);
}
import type { ReactNode } from "react";
import Link from "next/link";
import { cn } from "@/utils/ui";
import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
import {
getSectionTitle,
groupAndOrderChanges,
isSectionCollapsible,
type Change,
type Release,
} from "../utils";
import { ArrowRightIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
export function ReleaseArticle({
variant,
isLatest,
children,
}: {
variant: "list" | "detail";
isLatest?: boolean;
children: ReactNode;
}) {
if (variant === "list") {
return (
<article className="relative sm:pl-10">
<div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
<div
className={cn(
"size-[11px] rounded-full border-[1.5px]",
isLatest
? "border-foreground bg-foreground"
: "border-muted-foreground/30 bg-background",
)}
/>
</div>
<div className="flex flex-col gap-5">{children}</div>
</article>
);
}
return <article className="flex flex-col gap-8">{children}</article>;
}
export function ReleaseMeta({ release }: { release: Release }) {
return (
<span className="text-sm font-medium tracking-widest text-muted-foreground">
{release.version} — {release.date}
</span>
);
}
const titleSizes: Record<"h1" | "h2", string> = {
h1: "text-4xl",
h2: "text-2xl",
};
export function ReleaseTitle({
as: As,
href,
children,
}: {
as: "h1" | "h2";
href?: string;
children: ReactNode;
}) {
return (
<As className={cn("font-bold tracking-tight", titleSizes[As])}>
{href ? (
<Link href={href} className="hover:underline underline-offset-4">
{children}
</Link>
) : (
children
)}
</As>
);
}
export function ReleaseDescription({ children }: { children: ReactNode }) {
return (
<p className="text-base text-foreground leading-relaxed max-w-xl">
{children}
</p>
);
}
export function ReleaseChanges({ release }: { release: Release }) {
const { grouped, orderedTypes } = groupAndOrderChanges({
changes: release.changes,
});
return (
<div className="flex flex-col gap-4">
{orderedTypes.map((type) => (
<ReleaseChangeSection key={type} type={type} changes={grouped[type]} />
))}
</div>
);
}
function ReleaseChangeSection({
type,
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>
);
}
@@ -4,6 +4,7 @@ date: "2026-02-23"
published: true
title: "Editor foundation"
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:
- 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."
@@ -4,6 +4,7 @@ date: "2026-03-01"
published: true
title: "Motion & effects"
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:
- type: new
text: "Keyframe animation system for animating element properties over time."
@@ -1,83 +1,114 @@
---
version: "0.3.0"
date: "2026-03-04"
published: false
title: "Brand & polish"
description: "This release adds a brand page with downloadable assets, alongside a few fixes and improvements."
changes:
- type: new
text: "Added a brand page with downloadable brand assets. Assets are also accessible directly from the header logo context menu."
- type: fixed
text: "Sponsor logos now correctly invert in dark mode."
- type: improved
text: "Added a changelog link to the footer."
- type: fixed
text: "Media asset cards in the grid view were larger than their thumbnails, leaving empty space around each item. Cards now size to their content."
- type: improved
text: "Playback performance is dramatically better. The editor used to slow to a crawl during playback, and audio would stutter when interacting with elements mid-play. Both are fixed. Playback now runs at a consistent 60fps."
- type: fixed
text: "Project thumbnails now generate correctly even when the timeline is empty. If you set a background color or gradient with no clips, the thumbnail reflects that instead of staying blank."
- type: fixed
text: "Clicking a selected element no longer keeps the rest of the selection."
- type: improved
text: "More vertical space between timeline tracks. Selection outlines on adjacent tracks no longer overlap each other."
- type: fixed
text: "Track labels on the left side of the timeline were misaligned with the tracks on the right."
- type: improved
text: "Holding Shift or Ctrl while drag-selecting in the timeline now adds to your existing selection instead of replacing it. Matches how Shift/Ctrl+click already worked."
- type: improved
text: "Timeline track rows now highlight when they contain a selected element, making it easier to see which tracks are active at a glance."
- type: fixed
text: "Clicking in the empty space below timeline tracks now seeks the playhead."
- type: fixed
text: "Timeline vertical scroll now works anywhere in the panel, not just over the track labels. The header no longer scrolls with the tracks."
- type: new
text: "Scale now has separate width and height controls. You can stretch or squash elements independently on each axis."
- type: improved
text: "System fonts (Arial, Helvetica, Times New Roman, and others) now appear in the font picker alongside Google Fonts."
- type: fixed
text: "MP4 exports with audio failed on Firefox. The export now completes successfully regardless of browser."
- type: improved
text: "Your projects can disappear if your disk runs low on space. OpenCut now asks the browser to protect them."
- type: fixed
text: "Opening the mask or effects panel for one element, then selecting a different element, would keep the wrong panel open and hide the transform handles for the new selection. Each element now remembers its own panel state independently."
- type: new
text: "Volume control for audio and video elements, both in the properties panel and inline on timeline clips."
- type: new
text: "Speed control for video and audio clips. Includes a maintain pitch option so audio doesn't chipmunk or deepen when you change the rate."
- type: improved
text: "Audio waveforms have gotten an upgrade. The old ones were inaccurate, prone to crashing, and not very useful. The new ones are RMS-based, properly scaled, and only render what's visible so they don't slow things down."
- type: improved
text: "The timeline now opens scrolled to the bottom, so your main video track is visible right away instead of being hidden above the fold."
- type: fixed
text: "Snap guides didn't show when moving a full-canvas element to the center of the frame."
- type: fixed
text: "The rotation handle could float above the editor UI when an element was near the top of the canvas. It now clips to the preview area."
- type: fixed
text: "Images with a blur effect at 0% intensity would disappear completely when a blurred background was also active. Now 0% blur correctly renders the image unchanged."
- type: fixed
text: "Section titles in the properties panel showed the wrong text color when the section wasn't collapsible. Non-collapsible titles now use the correct muted style."
- type: new
text: "Masks. Hide or reveal parts of any video or image layer. Choose from split, rectangle, ellipse, star, heart, diamond, text, and custom masks. Adjust position, size, rotation, feather, and stroke in the properties panel, or drag the handles in the preview."
- type: improved
text: "The settings panel has been redesigned with a cleaner layout and better organization."
- type: new
text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight."
- type: new
text: "Canvas backgrounds are back. Set a blur, solid color, or gradient as the canvas background from the new Background tab in the settings panel."
- type: fixed
text: "Blur looked pixelated at higher intensities instead of producing a real blur. Both the canvas background blur and the blur effect are now fixed."
- type: new
text: "Custom canvas size. Instead of being locked to a handful of presets, you can now enter any width and height you want."
- type: new
text: "Preview zoom and panning. Zoom into the canvas to work on fine details, and pan around to navigate when zoomed in."
- type: new
text: "Stickers panel now has content. Browse and add logos, country flags, and shapes."
- type: improved
text: "You can now select multiple assets in the assets panel. Useful for deleting several at once."
- type: fixed
text: "Auto-generated captions were sometimes inaccurate. The underlying issue has been fixed."
- type: new
text: "You can now import transcript files to generate captions instead of running auto-transcription."
---
---
version: "0.3.0"
date: "2026-04-15"
published: true
title: "Masks, animation & more"
description: "This release adds masks, a graph editor for keyframe curves, volume and speed controls, preview zoom, canvas backgrounds, stickers, and a lot more."
summary: "Masks, graph editor for curves, volume & speed controls, preview zoom, canvas backgrounds, and stickers."
changes:
- type: new
text: "Added a brand page with downloadable brand assets. Assets are also accessible directly from the header logo context menu."
- type: fixed
text: "Sponsor logos now correctly invert in dark mode."
- type: improved
text: "Added a changelog link to the footer."
- type: fixed
text: "Media asset cards in the grid view were larger than their thumbnails, leaving empty space around each item. Cards now size to their content."
- type: improved
text: "Playback performance is dramatically better. The editor used to slow to a crawl during playback, and audio would stutter when interacting with elements mid-play. Both are fixed."
- type: fixed
text: "Project thumbnails now generate correctly even when the timeline is empty. If you set a background color or gradient with no clips, the thumbnail reflects that instead of staying blank."
- type: fixed
text: "Clicking a selected element no longer keeps the rest of the selection."
- type: improved
text: "More vertical space between timeline tracks. Selection outlines on adjacent tracks no longer overlap each other."
- type: fixed
text: "Track labels on the left side of the timeline were misaligned with the tracks on the right."
- type: improved
text: "Holding Shift or Ctrl while drag-selecting in the timeline now adds to your existing selection instead of replacing it. Matches how Shift/Ctrl+click already worked."
- type: improved
text: "Timeline track rows now highlight when they contain a selected element, making it easier to see which tracks are active at a glance."
- type: fixed
text: "Clicking in the empty space below timeline tracks now seeks the playhead."
- type: fixed
text: "Timeline vertical scroll now works anywhere in the panel, not just over the track labels. The header no longer scrolls with the tracks."
- type: new
text: "Scale now has separate width and height controls. You can stretch or squash elements independently on each axis."
- type: improved
text: "System fonts (Arial, Helvetica, Times New Roman, and others) now appear in the font picker alongside Google Fonts."
- type: fixed
text: "MP4 exports with audio failed on Firefox. The export now completes successfully regardless of browser."
- type: improved
text: "Your projects can disappear if your disk runs low on space. OpenCut now asks the browser to protect them."
- type: improved
text: "The properties panel has been redesigned with a tabbed layout."
- type: new
text: "Volume control for audio and video elements."
- type: new
text: "Speed control for video and audio clips. Includes a maintain pitch option so audio doesn't chipmunk or deepen when you change the rate."
- type: improved
text: "Audio waveforms have gotten an upgrade. The old ones were inaccurate, prone to crashing, and not very useful. The new ones are RMS-based, properly scaled, and only render what's visible so they don't slow things down."
- type: improved
text: "The timeline now opens scrolled to the bottom, so your main video track is visible right away instead of being hidden above the fold."
- type: fixed
text: "Snap guides in the preview panel didn't show when moving a full-canvas element to the center of the frame."
- type: fixed
text: "The rotation handle could float above the editor UI when an element was near the top of the canvas. It now clips to the preview area."
- type: fixed
text: "Images with a blur effect at 0% intensity would disappear completely when a blurred background was also active. Now 0% blur correctly renders the image unchanged."
- type: fixed
text: "Section titles in the properties panel showed the wrong text color when the section wasn't collapsible. Non-collapsible titles now use the correct muted style."
- type: new
text: "Masks. Hide or reveal parts of any video or image layer. Choose from split, rectangle, ellipse, star, heart, diamond, and cinematic bars. Adjust position, size, rotation, feather, and stroke in the properties panel, or drag the handles in the preview."
- type: improved
text: "The settings panel has been redesigned with a cleaner layout and better organization."
- type: new
text: "Canvas backgrounds are back. Set a blur, solid color, or gradient as the canvas background from the new Background tab in the settings panel."
- type: fixed
text: "Blur looked pixelated at higher intensities instead of producing a real blur. Both the canvas background blur and the blur effect are now fixed."
- type: new
text: "Custom canvas size. Instead of being locked to a handful of presets, you can now enter any width and height you want."
- type: new
text: "Preview zoom and panning. Zoom into the canvas to work on fine details, and pan around to navigate when zoomed in."
- type: new
text: "Stickers panel now has content. Browse and add country flags and shapes."
- type: improved
text: "You can now select multiple assets in the assets panel. Useful for deleting several at once."
- type: fixed
text: "Auto-generated captions were sometimes inaccurate. The underlying issue has been fixed."
- type: new
text: "You can now import transcript files to generate captions instead of running auto-transcription."
- type: new
text: "Graph editor for keyframe curves. Shape the easing between keyframes by dragging bezier handles."
- type: fixed
text: "Text element handles no longer shift position as you type."
- type: new
text: "Multiple selected timeline clips move and resize together."
- type: fixed
text: "The rotation handle drifted further and further from the element as the canvas size got smaller. On a 100x100 project it was almost off-screen. The gap is now a fixed size regardless of canvas dimensions or zoom."
- type: new
text: "Position X and Y can now be animated independently."
- type: new
text: "Keyframes can be copied and pasted between elements."
- type: new
text: 'Timeline clips can expand to show a lane for each animated property. Right-click a clip -> "Expand keyframes" to see all properties in their own rows.'
- type: improved
text: "If your browser doesn't support GPU-accelerated rendering, the editor now shows a notice explaining why and suggests switching to another browser."
- type: technical
text: "Ripple editing now has a proper algorithmic foundation. The old inline approach was replaced with a diff-based system in `src/lib/ripple/`: track snapshots before and after an operation are compared as interval sets, vacated intervals are computed (accounting for elements moved to other tracks), and the result is a list of explicit `RippleAdjustment` values applied in a separate pass. Composable, testable, and independent of the command that triggered it."
- type: technical
text: "Time is now represented as `MediaTime`, an integer tick count at 120,000 ticks per second, defined in `rust/crates/time`. Previously, floating-point seconds were used throughout, which accumulated rounding errors and made frame alignment imprecise. 120,000 was chosen because it divides evenly by every standard frame rate denominator, including drop-frame rates: 23.976 maps to 5,005 ticks/frame, 29.97 to 4,004, 30 to 4,000. `FrameRate` is a rational `{numerator, denominator}` type rather than a float. All time arithmetic, frame snapping, and timecode formatting are implemented in Rust and exposed via WASM."
- type: technical
text: "Track ordering is now enforced at the type level. `SceneTracks` replaces a flat track array with three explicit fields: `overlay`, `main` (a singleton `VideoTrack`), and `audio`. The type makes it structurally impossible to mix track kinds or misplace a track."
- type: technical
text: "The WebGL renderer has been replaced with a Rust/wgpu compositor compiled to WASM. The `rust/` crate tree covers compositing (`rust/crates/compositor`), effects (`rust/crates/effects`), masks with JFA-based feathering (`rust/crates/masks`), GPU context (`rust/crates/gpu`), and time (`rust/crates/time`). WASM bindings live in `rust/wasm/` and are consumed from TypeScript through `src/lib/wasm/`. The old GLSL shaders and WebGL utilities are gone."
- type: technical
text: "`src/types/` and `src/constants/` have been dissolved. All types and constants now live alongside the code that owns them inside `src/lib/`. The `packages/env` and `packages/ui` internal packages were also folded into the web app, with icons at `src/components/icons/` and env at `src/lib/env/`."
- type: technical
text: "The web app can now deploy to Cloudflare Workers via OpenNext. `wrangler.jsonc` and `apps/web/open-next.config.ts` are included for anyone self-hosting."
- type: technical
text: "`apps/desktop/` is a new GPUI + Rust app. It is early: there is a `main.rs` and a working build setup, but no features yet."
- type: technical
text: "16 new storage migration steps were added (v9 through v25), covering the schema changes accumulated during this release cycle."
---
+19
View File
@@ -0,0 +1,19 @@
---
version: "0.3.0"
date: "2026-03-04"
published: false
title: "Title"
description: "Description"
changes:
- type: new
text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight."
- type: new
text: "Two new mask types: text and custom (draw any shape with the pen tool using bezier curves). Both support feather, invert, and stroke."
- type: fixed
text: "Preview panning now keeps working while the Masks tab is open."
- type: new
text: "Audio clips have a horizontal volume line you can drag up or down to change volume. The line position reflects the current volume setting and is consistent across clips: same dB always means the same position."
- type: improved
text: "Audio waveforms now show the actual shape of the audio. Quiet parts are visible, loud parts are tall, and any bar that peaks above 0 dBFS gets a colored tip at the top."
- type: fixed
text: "Trimming or cutting an audio clip no longer causes the waveform to compress or show the wrong section of audio."
+82
View File
@@ -0,0 +1,82 @@
import { allChangelogs } from "content-collections";
export type Change = { type: string; text: string };
export type Release = (typeof allChangelogs)[number];
type ChangeSectionConfig = {
title: string;
order: number;
collapsible?: boolean;
};
const knownSectionConfigs: Record<string, ChangeSectionConfig> = {
new: { title: "Features", order: 0 },
improved: { title: "Improvements", order: 1 },
fixed: { title: "Fixes", order: 2 },
breaking: { title: "Breaking Changes", order: 3 },
technical: {
title: "Technical details",
order: 4,
collapsible: true,
},
};
function getSectionConfig({ type }: { type: string }): ChangeSectionConfig {
return (
knownSectionConfigs[type] ?? {
title: type.charAt(0).toUpperCase() + type.slice(1),
order: Number.MAX_SAFE_INTEGER,
}
);
}
export function getSectionTitle({ type }: { type: string }): string {
return getSectionConfig({ type }).title;
}
export function isSectionCollapsible({ type }: { type: string }): boolean {
return getSectionConfig({ type }).collapsible ?? false;
}
export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
const typeEncounterOrder = new Map<string, number>();
const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
if (!typeEncounterOrder.has(change.type)) {
typeEncounterOrder.set(change.type, typeEncounterOrder.size);
}
if (!acc[change.type]) acc[change.type] = [];
acc[change.type].push(change);
return acc;
}, {});
const orderedTypes = Object.keys(grouped).sort((left, right) => {
const orderDifference =
getSectionConfig({ type: left }).order -
getSectionConfig({ type: right }).order;
if (orderDifference !== 0) {
return orderDifference;
}
return (
(typeEncounterOrder.get(left) ?? Number.MAX_SAFE_INTEGER) -
(typeEncounterOrder.get(right) ?? Number.MAX_SAFE_INTEGER)
);
});
return { grouped, orderedTypes };
}
function isPublishedRelease({ published }: Release) {
return published !== false;
}
export function getSortedReleases() {
return allChangelogs
.filter(isPublishedRelease)
.sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }),
);
}
export function getReleaseByVersion({ version }: { version: string }) {
return getSortedReleases().find((release) => release.version === version);
}
@@ -0,0 +1,48 @@
import { PasteCommand } from "@/commands/timeline";
import type { ClipboardHandler } from "../types";
export const ElementsClipboardHandler = {
type: "elements",
canCopy({ selectedElements }) {
return selectedElements.length > 0;
},
copy({ editor, selectedElements }) {
if (selectedElements.length === 0) {
return null;
}
const results = editor.timeline.getElementsWithTracks({
elements: selectedElements,
});
const items = results.map(({ track, element }) => {
const { id: _elementId, ...elementWithoutId } = element;
return {
trackId: track.id,
trackType: track.type,
element: elementWithoutId,
};
});
if (items.length === 0) {
return null;
}
return {
type: "elements",
items,
};
},
paste({ entry, context }) {
if (entry.items.length === 0) {
return null;
}
return new PasteCommand({
time: context.time,
clipboardItems: entry.items,
});
},
} satisfies ClipboardHandler<"elements">;

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