mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
106
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cf858c0c2 | ||
|
|
f1b45b4328 | ||
|
|
7428dc75cf | ||
|
|
1301d027e9 | ||
|
|
f5f31b43df | ||
|
|
a709af0059 | ||
|
|
75eebf52bc | ||
|
|
1582423dbf | ||
|
|
148d38d7c9 | ||
|
|
3e89d29985 | ||
|
|
729d10592f | ||
|
|
9da365bc01 | ||
|
|
8bdc894690 | ||
|
|
0387bd4c80 | ||
|
|
b1d0b10354 | ||
|
|
94eed96cad | ||
|
|
794d4ae149 | ||
|
|
011f51051c | ||
|
|
c46d28891f | ||
|
|
66e4367bc4 | ||
|
|
8161de00e6 | ||
|
|
eb28942dab | ||
|
|
68f175d3be | ||
|
|
d5c1b7bf0e | ||
|
|
0868d9cf13 | ||
|
|
8a3577d2e7 | ||
|
|
ac43a5a73f | ||
|
|
6312fc5b07 | ||
|
|
2da93aa98f | ||
|
|
f4bd689f51 | ||
|
|
4f7dc5ee3a | ||
|
|
4e8b7d3745 | ||
|
|
ee372266ca | ||
|
|
e10b0a3355 | ||
|
|
3914ea9b71 | ||
|
|
370944f443 | ||
|
|
c839db9234 | ||
|
|
769a7e708a | ||
|
|
1fa5442f21 | ||
|
|
dc0a8e7c96 | ||
|
|
3db30c72e7 | ||
|
|
eca13e3e71 | ||
|
|
1943a4d18f | ||
|
|
3eccc2f9e3 | ||
|
|
368a20ddc2 | ||
|
|
64588ceeea | ||
|
|
8fe66090e7 | ||
|
|
91e1232112 | ||
|
|
f726d95295 | ||
|
|
78a0a27a72 | ||
|
|
b68bf00454 | ||
|
|
cd19a4409f | ||
|
|
f1ed84006a | ||
|
|
8d82ea9c0b | ||
|
|
6aa4ffe462 | ||
|
|
8a54e5d884 | ||
|
|
5e6c26f3c1 | ||
|
|
520c0b3264 | ||
|
|
607eaeacc7 | ||
|
|
6de6c7fd95 | ||
|
|
34c67ef8e8 | ||
|
|
5f2f00dde0 | ||
|
|
6db7ad0696 | ||
|
|
24a0d10a92 | ||
|
|
e3d4f3fa92 | ||
|
|
d85566ab8d | ||
|
|
3191e038d8 | ||
|
|
ba994d8bf9 | ||
|
|
270e2951b1 | ||
|
|
496f7d690d | ||
|
|
11ee0dbf27 | ||
|
|
2c344fa01d | ||
|
|
3d9d669cb9 | ||
|
|
7feb3b238c | ||
|
|
7d4e166678 | ||
|
|
cad88ee4d1 | ||
|
|
36efdf66e7 | ||
|
|
2272598d42 | ||
|
|
5a2aaf3601 | ||
|
|
108d3f4eca | ||
|
|
8230faf082 | ||
|
|
dbd68ff139 | ||
|
|
0d0d737563 | ||
|
|
f06a930fc1 | ||
|
|
532f050a64 | ||
|
|
d422b602c9 | ||
|
|
cbd1b82123 | ||
|
|
4f7d401a97 | ||
|
|
30709d7134 | ||
|
|
93a0afdf64 | ||
|
|
b8979c65d4 | ||
|
|
f47e85c3bb | ||
|
|
e43b859439 | ||
|
|
e4b67094e7 | ||
|
|
79df736431 | ||
|
|
c411edb7c1 | ||
|
|
2776adaae1 | ||
|
|
964ac82d5e | ||
|
|
05243616ea | ||
|
|
ccd552a1c0 | ||
|
|
fcc09e80e2 | ||
|
|
dedb13546a | ||
|
|
c56d6f8188 | ||
|
|
e62227914a | ||
|
|
44d8c4c876 | ||
|
|
20235d0724 |
@@ -0,0 +1,7 @@
|
||||
*.json
|
||||
*.svg
|
||||
*.xml
|
||||
*.lock
|
||||
**/dist/**
|
||||
**/build/**
|
||||
**/node_modules/**
|
||||
@@ -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
|
||||
|
||||
@@ -15,3 +21,17 @@ Shared code between apps live in `rust/`, not in `packages/`
|
||||
|
||||
- Read components before using them. They may already apply classes, which affects what you need to pass and how to override them.
|
||||
|
||||
### TypeScript
|
||||
|
||||
Function signatures should make the call site readable and let the function evolve without breaking callers. Positional parameters fail both: `formatTime(30, 24)` hides which number is which, and adding, removing, or reordering an argument silently breaks every caller whose types happen to still line up. A single destructured object fixes both at once - each argument names itself at the call site, and the shape can grow without churn. So signatures default to one object parameter:
|
||||
|
||||
```tsx
|
||||
// ❌ meaning depends on order; the shape can't evolve without touching every caller
|
||||
function formatTime(seconds: number, fps: number) { ... }
|
||||
|
||||
// ✅ each argument names itself; fields can be added, reordered, or made optional freely
|
||||
function formatTime({ seconds, fps }: { seconds: number; fps: number }) { ... }
|
||||
```
|
||||
|
||||
The one real exception is type predicates (`element is VideoElement`) — the language requires a positional subject, so the reasoning above doesn't get to apply.
|
||||
|
||||
|
||||
Generated
+62
-2
@@ -955,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"
|
||||
@@ -982,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"
|
||||
@@ -1427,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"
|
||||
@@ -2104,6 +2136,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"thiserror 2.0.18",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
@@ -2148,7 +2182,7 @@ dependencies = [
|
||||
"log",
|
||||
"presser",
|
||||
"thiserror 2.0.18",
|
||||
"windows 0.61.3",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3217,6 +3251,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "masks"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"gpu",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
@@ -3777,10 +3820,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opencut-wasm"
|
||||
version = "0.1.3"
|
||||
version = "0.2.9"
|
||||
dependencies = [
|
||||
"bridge",
|
||||
"compositor",
|
||||
"console_error_panic_hook",
|
||||
"effects",
|
||||
"gpu",
|
||||
"js-sys",
|
||||
"masks",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"time",
|
||||
@@ -5581,6 +5630,7 @@ name = "time"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bridge",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"tsify-next",
|
||||
"wasm-bindgen",
|
||||
@@ -6489,6 +6539,7 @@ dependencies = [
|
||||
"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",
|
||||
@@ -6513,6 +6564,15 @@ 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"
|
||||
|
||||
+4
-1
@@ -4,5 +4,8 @@ members = [
|
||||
"apps/desktop",
|
||||
"rust/crates/time",
|
||||
"rust/crates/bridge",
|
||||
"rust/wasm", "rust/crates/gpu",
|
||||
"rust/crates/effects",
|
||||
"rust/crates/gpu",
|
||||
"rust/crates/masks",
|
||||
"rust/wasm", "rust/crates/compositor",
|
||||
]
|
||||
|
||||
@@ -28,24 +28,12 @@ Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss
|
||||
- **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
|
||||
- `apps/web/`: Next.js web application
|
||||
- `apps/desktop/`: Native desktop app built with GPUI (in progress)
|
||||
- `rust/`: Platform-agnostic core: GPU compositor, effects, masks, and WASM bindings. We're actively migrating business logic here from TypeScript.
|
||||
- `docs/`: Architecture and subsystem documentation
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -97,6 +85,19 @@ If you want to get ready for `apps/desktop`, see [`apps/desktop/README.md`](apps
|
||||
|
||||
Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package.
|
||||
|
||||
**Prerequisites** — install these once before anything else:
|
||||
|
||||
```bash
|
||||
# Rust toolchain
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
# build the WASM package
|
||||
cargo install wasm-pack
|
||||
|
||||
# reruns the build on file changes, used by bun dev:wasm
|
||||
cargo install cargo-watch
|
||||
```
|
||||
|
||||
1. Build the package once from the repo root:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"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",
|
||||
"@opennextjs/cloudflare": "^1.18.0",
|
||||
@@ -52,7 +52,7 @@
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"opencut-wasm": "^0.1.3",
|
||||
"opencut-wasm": "^0.2.9",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
+2
-2
@@ -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,
|
||||
@@ -1,209 +1,209 @@
|
||||
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 {
|
||||
KeybindingConfig,
|
||||
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 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;
|
||||
}
|
||||
+251
-347
@@ -1,347 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { TActionWithOptionalArgs } from "@/lib/actions";
|
||||
import { getDefaultShortcuts } from "@/lib/actions";
|
||||
import { isTypableDOMElement } from "@/utils/browser";
|
||||
import { isAppleDevice } from "@/utils/platform";
|
||||
import type { KeybindingConfig, ShortcutKey } from "@/lib/actions/keybinding";
|
||||
import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations";
|
||||
|
||||
const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
|
||||
|
||||
export interface KeybindingConflict {
|
||||
key: ShortcutKey;
|
||||
existingAction: TActionWithOptionalArgs;
|
||||
newAction: TActionWithOptionalArgs;
|
||||
}
|
||||
|
||||
interface KeybindingsState {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
overlayDepth: number;
|
||||
openOverlayIds: string[];
|
||||
isLoadingProject: boolean;
|
||||
isRecording: boolean;
|
||||
|
||||
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void;
|
||||
removeKeybinding: (key: ShortcutKey) => void;
|
||||
resetToDefaults: () => void;
|
||||
importKeybindings: (config: KeybindingConfig) => void;
|
||||
exportKeybindings: () => KeybindingConfig;
|
||||
openOverlay: (overlayId: string, source: string) => void;
|
||||
closeOverlay: (overlayId: string, source: string) => void;
|
||||
setLoadingProject: (loading: boolean) => void;
|
||||
setIsRecording: (isRecording: boolean) => void;
|
||||
validateKeybinding: (
|
||||
key: ShortcutKey,
|
||||
action: TActionWithOptionalArgs,
|
||||
) => KeybindingConflict | null;
|
||||
getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[];
|
||||
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
|
||||
}
|
||||
|
||||
function isDOMElement(element: EventTarget | null): element is HTMLElement {
|
||||
return element instanceof HTMLElement;
|
||||
}
|
||||
|
||||
export const useKeybindingsStore = create<KeybindingsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
keybindings: { ...defaultKeybindings },
|
||||
isCustomized: false,
|
||||
overlayDepth: 0,
|
||||
openOverlayIds: [],
|
||||
isLoadingProject: false,
|
||||
isRecording: false,
|
||||
|
||||
openOverlay: (overlayId, source) =>
|
||||
set((s) => {
|
||||
const openOverlayIds = s.openOverlayIds.includes(overlayId)
|
||||
? s.openOverlayIds
|
||||
: [...s.openOverlayIds, overlayId];
|
||||
const nextOverlayDepth = openOverlayIds.length;
|
||||
// #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: "H2",
|
||||
location: "keybindings-store.ts:openOverlay",
|
||||
message: "Overlay depth incremented",
|
||||
data: {
|
||||
source,
|
||||
overlayId,
|
||||
overlayDepth: s.overlayDepth,
|
||||
nextOverlayDepth,
|
||||
openOverlayIds,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
},
|
||||
).catch(() => {});
|
||||
// #endregion
|
||||
return {
|
||||
openOverlayIds,
|
||||
overlayDepth: nextOverlayDepth,
|
||||
};
|
||||
}),
|
||||
closeOverlay: (overlayId, source) =>
|
||||
set((s) => {
|
||||
const openOverlayIds = s.openOverlayIds.filter(
|
||||
(id) => id !== overlayId,
|
||||
);
|
||||
const nextOverlayDepth = openOverlayIds.length;
|
||||
// #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: "H2",
|
||||
location: "keybindings-store.ts:closeOverlay",
|
||||
message: "Overlay depth decremented",
|
||||
data: {
|
||||
source,
|
||||
overlayId,
|
||||
overlayDepth: s.overlayDepth,
|
||||
nextOverlayDepth,
|
||||
openOverlayIds,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
},
|
||||
).catch(() => {});
|
||||
// #endregion
|
||||
return {
|
||||
openOverlayIds,
|
||||
overlayDepth: nextOverlayDepth,
|
||||
};
|
||||
}),
|
||||
setLoadingProject: (loading) => {
|
||||
// #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: "H2",
|
||||
location: "keybindings-store.ts:setLoadingProject",
|
||||
message: "Loading gate updated",
|
||||
data: { loading },
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
},
|
||||
).catch(() => {});
|
||||
// #endregion
|
||||
set({ isLoadingProject: loading });
|
||||
},
|
||||
|
||||
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => {
|
||||
set((state) => {
|
||||
const newKeybindings = { ...state.keybindings };
|
||||
newKeybindings[key] = action;
|
||||
|
||||
return {
|
||||
keybindings: newKeybindings,
|
||||
isCustomized: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeKeybinding: (key: ShortcutKey) => {
|
||||
set((state) => {
|
||||
const newKeybindings = { ...state.keybindings };
|
||||
delete newKeybindings[key];
|
||||
|
||||
return {
|
||||
keybindings: newKeybindings,
|
||||
isCustomized: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
resetToDefaults: () => {
|
||||
set({
|
||||
keybindings: { ...defaultKeybindings },
|
||||
isCustomized: false,
|
||||
});
|
||||
},
|
||||
|
||||
importKeybindings: (config: KeybindingConfig) => {
|
||||
for (const [key] of Object.entries(config)) {
|
||||
if (typeof key !== "string" || key.length === 0) {
|
||||
throw new Error(`Invalid key format: ${key}`);
|
||||
}
|
||||
}
|
||||
set({
|
||||
keybindings: { ...config },
|
||||
isCustomized: true,
|
||||
});
|
||||
},
|
||||
|
||||
exportKeybindings: () => {
|
||||
return get().keybindings;
|
||||
},
|
||||
|
||||
validateKeybinding: (
|
||||
key: ShortcutKey,
|
||||
action: TActionWithOptionalArgs,
|
||||
) => {
|
||||
const { keybindings } = get();
|
||||
const existingAction = keybindings[key];
|
||||
|
||||
if (existingAction && existingAction !== action) {
|
||||
return {
|
||||
key,
|
||||
existingAction,
|
||||
newAction: action,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
setIsRecording: (isRecording: boolean) => {
|
||||
// #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: "H2",
|
||||
location: "keybindings-store.ts:setIsRecording",
|
||||
message: "Recording gate updated",
|
||||
data: { isRecording },
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
},
|
||||
).catch(() => {});
|
||||
// #endregion
|
||||
set({ isRecording });
|
||||
},
|
||||
|
||||
getKeybindingsForAction: (action: TActionWithOptionalArgs) => {
|
||||
const { keybindings } = get();
|
||||
return Object.keys(keybindings).filter(
|
||||
(key) => keybindings[key as ShortcutKey] === action,
|
||||
) as ShortcutKey[];
|
||||
},
|
||||
|
||||
getKeybindingString: (ev: KeyboardEvent) => {
|
||||
return generateKeybindingString(ev) as ShortcutKey | null;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "opencut-keybindings",
|
||||
version: CURRENT_VERSION,
|
||||
partialize: (state) => ({
|
||||
keybindings: state.keybindings,
|
||||
isCustomized: state.isCustomized,
|
||||
}),
|
||||
migrate: (persisted, version) =>
|
||||
runMigrations({ state: persisted, fromVersion: version }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
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 as HTMLElement })
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${modifierKey}+${key}` as ShortcutKey;
|
||||
}
|
||||
|
||||
if (
|
||||
isDOMElement(target) &&
|
||||
isTypableDOMElement({ element: target as HTMLElement })
|
||||
)
|
||||
return null;
|
||||
|
||||
return `${key}` as ShortcutKey;
|
||||
}
|
||||
|
||||
function getPressedKey(ev: KeyboardEvent): string | null {
|
||||
const key = (ev.key ?? "").toLowerCase();
|
||||
const code = ev.code ?? "";
|
||||
|
||||
if (code === "Space" || key === " " || key === "spacebar" || key === "space")
|
||||
return "space";
|
||||
|
||||
if (key.startsWith("arrow")) return key.slice(5);
|
||||
|
||||
if (key === "escape") return "escape";
|
||||
if (key === "tab") return "tab";
|
||||
if (key === "home") return "home";
|
||||
if (key === "end") return "end";
|
||||
if (key === "delete") return "delete";
|
||||
if (key === "backspace") return "backspace";
|
||||
|
||||
if (code.startsWith("Key")) {
|
||||
const letter = code.slice(3).toLowerCase();
|
||||
if (letter.length === 1 && letter >= "a" && letter <= "z") return letter;
|
||||
}
|
||||
|
||||
// Use physical key position for AZERTY and other non-QWERTY layouts
|
||||
if (code.startsWith("Digit")) {
|
||||
const digit = code.slice(5);
|
||||
if (digit.length === 1 && digit >= "0" && digit <= "9") return digit;
|
||||
}
|
||||
|
||||
const isDigit = key.length === 1 && key >= "0" && key <= "9";
|
||||
if (isDigit) return key;
|
||||
|
||||
if (key === "/" || key === "." || key === "enter") return key;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getActiveModifier(ev: KeyboardEvent): string | null {
|
||||
const modifierKeys = {
|
||||
ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey,
|
||||
alt: ev.altKey,
|
||||
shift: ev.shiftKey,
|
||||
};
|
||||
|
||||
const activeModifier = Object.keys(modifierKeys)
|
||||
.filter((key) => modifierKeys[key as keyof typeof modifierKeys])
|
||||
.join("+");
|
||||
|
||||
return activeModifier === "" ? null : activeModifier;
|
||||
}
|
||||
"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 { KeybindingConfig, ShortcutKey } from "@/actions/keybinding";
|
||||
import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations";
|
||||
|
||||
const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
|
||||
|
||||
export interface KeybindingConflict {
|
||||
key: ShortcutKey;
|
||||
existingAction: TActionWithOptionalArgs;
|
||||
newAction: TActionWithOptionalArgs;
|
||||
}
|
||||
|
||||
interface KeybindingsState {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
overlayDepth: number;
|
||||
openOverlayIds: string[];
|
||||
isLoadingProject: boolean;
|
||||
isRecording: boolean;
|
||||
|
||||
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void;
|
||||
removeKeybinding: (key: ShortcutKey) => void;
|
||||
resetToDefaults: () => void;
|
||||
importKeybindings: (config: KeybindingConfig) => void;
|
||||
exportKeybindings: () => KeybindingConfig;
|
||||
openOverlay: (overlayId: string) => void;
|
||||
closeOverlay: (overlayId: string) => void;
|
||||
setLoadingProject: (loading: boolean) => void;
|
||||
setIsRecording: (isRecording: boolean) => void;
|
||||
validateKeybinding: (
|
||||
key: ShortcutKey,
|
||||
action: TActionWithOptionalArgs,
|
||||
) => KeybindingConflict | null;
|
||||
getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[];
|
||||
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
|
||||
}
|
||||
|
||||
function isDOMElement(element: EventTarget | null): element is HTMLElement {
|
||||
return element instanceof HTMLElement;
|
||||
}
|
||||
|
||||
export const useKeybindingsStore = create<KeybindingsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
keybindings: { ...defaultKeybindings },
|
||||
isCustomized: false,
|
||||
overlayDepth: 0,
|
||||
openOverlayIds: [],
|
||||
isLoadingProject: false,
|
||||
isRecording: false,
|
||||
|
||||
openOverlay: (overlayId) =>
|
||||
set((s) => {
|
||||
const openOverlayIds = s.openOverlayIds.includes(overlayId)
|
||||
? s.openOverlayIds
|
||||
: [...s.openOverlayIds, overlayId];
|
||||
const nextOverlayDepth = openOverlayIds.length;
|
||||
return {
|
||||
openOverlayIds,
|
||||
overlayDepth: nextOverlayDepth,
|
||||
};
|
||||
}),
|
||||
closeOverlay: (overlayId) =>
|
||||
set((s) => {
|
||||
const openOverlayIds = s.openOverlayIds.filter(
|
||||
(id) => id !== overlayId,
|
||||
);
|
||||
const nextOverlayDepth = openOverlayIds.length;
|
||||
return {
|
||||
openOverlayIds,
|
||||
overlayDepth: nextOverlayDepth,
|
||||
};
|
||||
}),
|
||||
setLoadingProject: (loading) => {
|
||||
set({ isLoadingProject: loading });
|
||||
},
|
||||
|
||||
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => {
|
||||
set((state) => {
|
||||
const newKeybindings = { ...state.keybindings };
|
||||
newKeybindings[key] = action;
|
||||
|
||||
return {
|
||||
keybindings: newKeybindings,
|
||||
isCustomized: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeKeybinding: (key: ShortcutKey) => {
|
||||
set((state) => {
|
||||
const newKeybindings = { ...state.keybindings };
|
||||
delete newKeybindings[key];
|
||||
|
||||
return {
|
||||
keybindings: newKeybindings,
|
||||
isCustomized: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
resetToDefaults: () => {
|
||||
set({
|
||||
keybindings: { ...defaultKeybindings },
|
||||
isCustomized: false,
|
||||
});
|
||||
},
|
||||
|
||||
importKeybindings: (config: KeybindingConfig) => {
|
||||
for (const [key] of Object.entries(config)) {
|
||||
if (typeof key !== "string" || key.length === 0) {
|
||||
throw new Error(`Invalid key format: ${key}`);
|
||||
}
|
||||
}
|
||||
set({
|
||||
keybindings: { ...config },
|
||||
isCustomized: true,
|
||||
});
|
||||
},
|
||||
|
||||
exportKeybindings: () => {
|
||||
return get().keybindings;
|
||||
},
|
||||
|
||||
validateKeybinding: (
|
||||
key: ShortcutKey,
|
||||
action: TActionWithOptionalArgs,
|
||||
) => {
|
||||
const { keybindings } = get();
|
||||
const existingAction = keybindings[key];
|
||||
|
||||
if (existingAction && existingAction !== action) {
|
||||
return {
|
||||
key,
|
||||
existingAction,
|
||||
newAction: action,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
setIsRecording: (isRecording: boolean) => {
|
||||
set({ isRecording });
|
||||
},
|
||||
|
||||
getKeybindingsForAction: (action: TActionWithOptionalArgs) => {
|
||||
const { keybindings } = get();
|
||||
return Object.keys(keybindings).filter(
|
||||
(key) => keybindings[key as ShortcutKey] === action,
|
||||
) as ShortcutKey[];
|
||||
},
|
||||
|
||||
getKeybindingString: (ev: KeyboardEvent) => {
|
||||
return generateKeybindingString(ev) as ShortcutKey | null;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "opencut-keybindings",
|
||||
version: CURRENT_VERSION,
|
||||
partialize: (state) => ({
|
||||
keybindings: state.keybindings,
|
||||
isCustomized: state.isCustomized,
|
||||
}),
|
||||
migrate: (persisted, version) =>
|
||||
runMigrations({ state: persisted, fromVersion: version }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
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 as HTMLElement })
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${modifierKey}+${key}` as ShortcutKey;
|
||||
}
|
||||
|
||||
if (
|
||||
isDOMElement(target) &&
|
||||
isTypableDOMElement({ element: target as HTMLElement })
|
||||
)
|
||||
return null;
|
||||
|
||||
return `${key}` as ShortcutKey;
|
||||
}
|
||||
|
||||
function getPressedKey(ev: KeyboardEvent): string | null {
|
||||
const key = (ev.key ?? "").toLowerCase();
|
||||
const code = ev.code ?? "";
|
||||
|
||||
if (code === "Space" || key === " " || key === "spacebar" || key === "space")
|
||||
return "space";
|
||||
|
||||
if (key.startsWith("arrow")) return key.slice(5);
|
||||
|
||||
if (key === "escape") return "escape";
|
||||
if (key === "tab") return "tab";
|
||||
if (key === "home") return "home";
|
||||
if (key === "end") return "end";
|
||||
if (key === "delete") return "delete";
|
||||
if (key === "backspace") return "backspace";
|
||||
|
||||
if (code.startsWith("Key")) {
|
||||
const letter = code.slice(3).toLowerCase();
|
||||
if (letter.length === 1 && letter >= "a" && letter <= "z") return letter;
|
||||
}
|
||||
|
||||
// Use physical key position for AZERTY and other non-QWERTY layouts
|
||||
if (code.startsWith("Digit")) {
|
||||
const digit = code.slice(5);
|
||||
if (digit.length === 1 && digit >= "0" && digit <= "9") return digit;
|
||||
}
|
||||
|
||||
const isDigit = key.length === 1 && key >= "0" && key <= "9";
|
||||
if (isDigit) return key;
|
||||
|
||||
if (key === "/" || key === "." || key === "enter") return key;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getActiveModifier(ev: KeyboardEvent): string | null {
|
||||
const modifierKeys = {
|
||||
ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey,
|
||||
alt: ev.altKey,
|
||||
shift: ev.shiftKey,
|
||||
};
|
||||
|
||||
const activeModifier = Object.keys(modifierKeys)
|
||||
.filter((key) => modifierKeys[key as keyof typeof modifierKeys])
|
||||
.join("+");
|
||||
|
||||
return activeModifier === "" ? null : activeModifier;
|
||||
}
|
||||
+3
-1
@@ -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,
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import type { KeybindingConfig, ShortcutKey } from "@/lib/actions/keybinding";
|
||||
import type { TActionWithOptionalArgs } from "@/lib/actions";
|
||||
import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding";
|
||||
import type { TActionWithOptionalArgs } from "@/actions";
|
||||
|
||||
interface V2State {
|
||||
keybindings: KeybindingConfig;
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import type { TActionWithOptionalArgs } from "@/lib/actions";
|
||||
import type { ShortcutKey } from "@/lib/actions/keybinding";
|
||||
import type { KeybindingConfig } from "@/lib/actions/keybinding";
|
||||
import type { TActionWithOptionalArgs } from "@/actions";
|
||||
import type { ShortcutKey } from "@/actions/keybinding";
|
||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||
|
||||
interface V3State {
|
||||
keybindings: KeybindingConfig;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { KeybindingConfig } from "@/lib/actions/keybinding";
|
||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||
|
||||
interface V4State {
|
||||
keybindings: KeybindingConfig;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { KeybindingConfig } from "@/lib/actions/keybinding";
|
||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||
|
||||
interface V5State {
|
||||
keybindings: KeybindingConfig;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||
|
||||
interface V6State {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
}
|
||||
|
||||
export function v6ToV7({ state }: { state: unknown }): unknown {
|
||||
const v6 = state as V6State;
|
||||
const keybindings = { ...v6.keybindings };
|
||||
|
||||
for (const key of Object.keys(keybindings) as Array<keyof KeybindingConfig>) {
|
||||
if (keybindings[key] === ("split-element" as never)) {
|
||||
keybindings[key] = "split";
|
||||
}
|
||||
}
|
||||
|
||||
return { ...v6, keybindings };
|
||||
}
|
||||
@@ -57,29 +57,6 @@ export const invokeAction: InvokeActionFunc = <A extends TAction>(
|
||||
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);
|
||||
});
|
||||
+2
-2
@@ -5,8 +5,8 @@ import type {
|
||||
TActionHandlerOptions,
|
||||
TArgOfAction,
|
||||
TInvocationTrigger,
|
||||
} from "@/lib/actions";
|
||||
import { bindAction, unbindAction } from "@/lib/actions";
|
||||
} from "@/actions";
|
||||
import { bindAction, unbindAction } from "@/actions";
|
||||
|
||||
export function useActionHandler<A extends TAction>(
|
||||
action: A,
|
||||
+80
-61
@@ -1,43 +1,47 @@
|
||||
"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 {
|
||||
getElementsAtTime,
|
||||
hasMediaId,
|
||||
} from "@/lib/timeline";
|
||||
import { cancelInteraction } from "@/lib/cancel-interaction";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { canToggleSourceAudio } from "@/lib/timeline/audio-separation";
|
||||
import { useTimelineStore } from "@/timeline/timeline-store";
|
||||
import { useActionHandler } from "@/actions/use-action-handler";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { 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";
|
||||
|
||||
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 clearTimelineActiveSelectionRef = useRef(() => {});
|
||||
const timelineScopeRef = useRef<ScopeEntry | null>(null);
|
||||
const hasTimelineSelection =
|
||||
selectedElements.length > 0 || selectedKeyframes.length > 0;
|
||||
selectedElements.length > 0 ||
|
||||
selectedKeyframes.length > 0 ||
|
||||
selectedMaskPointSelection !== null;
|
||||
|
||||
hasTimelineSelectionRef.current = hasTimelineSelection;
|
||||
clearTimelineSelectionRef.current = () => {
|
||||
setElementSelection({ elements: [] });
|
||||
clearKeyframeSelection();
|
||||
editor.selection.clearSelection();
|
||||
};
|
||||
clearTimelineActiveSelectionRef.current = () => {
|
||||
editor.selection.clearMostSpecificSelection();
|
||||
};
|
||||
|
||||
if (!timelineScopeRef.current) {
|
||||
@@ -46,6 +50,9 @@ export function useEditorActions() {
|
||||
clear: () => {
|
||||
clearTimelineSelectionRef.current();
|
||||
},
|
||||
clearActive: () => {
|
||||
clearTimelineActiveSelectionRef.current();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,10 +117,13 @@ export function useEditorActions() {
|
||||
"frame-step-forward",
|
||||
() => {
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
const ticksPerFrame = Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
);
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + 1 / fps,
|
||||
editor.playback.getCurrentTime() + ticksPerFrame,
|
||||
),
|
||||
});
|
||||
},
|
||||
@@ -124,8 +134,11 @@ export function useEditorActions() {
|
||||
"frame-step-backward",
|
||||
() => {
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
const ticksPerFrame = Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
);
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps),
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
@@ -176,11 +189,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 +212,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 +227,6 @@ export function useEditorActions() {
|
||||
elements: elementsToSplit,
|
||||
splitTime: currentTime,
|
||||
retainSide: "right",
|
||||
rippleEnabled: rippleEditingEnabled,
|
||||
});
|
||||
|
||||
if (rippleEditingEnabled && rightSideElements.length > 0) {
|
||||
@@ -231,11 +245,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 +268,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.deleteCustomMaskPoints({
|
||||
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,8 +323,9 @@ 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(selectedElement.element, mediaAsset)) {
|
||||
@@ -309,7 +343,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,
|
||||
@@ -334,8 +373,7 @@ export function useEditorActions() {
|
||||
"deselect-all",
|
||||
() => {
|
||||
if (!clearActiveScope()) {
|
||||
setElementSelection({ elements: [] });
|
||||
clearKeyframeSelection();
|
||||
editor.selection.clearMostSpecificSelection();
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
@@ -378,21 +416,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,
|
||||
);
|
||||
@@ -400,12 +424,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,
|
||||
);
|
||||
@@ -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[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,
|
||||
]);
|
||||
}
|
||||
+5
-2
@@ -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,
|
||||
@@ -57,6 +57,9 @@ export function useKeyboardShortcutsHelp() {
|
||||
[TActionWithOptionalArgs, string[]]
|
||||
>) {
|
||||
const actionDef = ACTIONS[action];
|
||||
if (!actionDef) {
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
id: action,
|
||||
keys,
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
composeAnimationValue,
|
||||
createAnimationBinding,
|
||||
} from "@/lib/animation/binding-values";
|
||||
} from "@/animation/binding-values";
|
||||
|
||||
describe("binding values", () => {
|
||||
test("formats composed animated colors as hex", () => {
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ScalarAnimationKey } from "@/lib/animation/types";
|
||||
import type { ScalarAnimationKey } from "@/animation/types";
|
||||
|
||||
const BEZIER_SOLVE_ITERATIONS = 20;
|
||||
|
||||
export function getBezierPoint({
|
||||
progress,
|
||||
@@ -68,7 +70,7 @@ export function solveBezierProgressForTime({
|
||||
const leftHandle =
|
||||
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
|
||||
|
||||
for (let iteration = 0; iteration < 20; iteration++) {
|
||||
for (let iteration = 0; iteration < BEZIER_SOLVE_ITERATIONS; iteration++) {
|
||||
const mid = (lower + upper) / 2;
|
||||
const estimate = getBezierPoint({
|
||||
progress: mid,
|
||||
+17
-8
@@ -12,7 +12,8 @@ import type {
|
||||
DiscreteValue,
|
||||
Vector2AnimationBinding,
|
||||
VectorValue,
|
||||
} from "@/lib/animation/types";
|
||||
} from "@/animation/types";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
interface LinearRgba {
|
||||
r: number;
|
||||
@@ -25,10 +26,6 @@ export type AnimationComponentValue = number | DiscreteValue;
|
||||
|
||||
const toRgb = converter("rgb");
|
||||
|
||||
function clamp01({ value }: { value: number }): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function srgbToLinear({ value }: { value: number }): number {
|
||||
return value <= 0.04045
|
||||
? value / 12.92
|
||||
@@ -36,7 +33,7 @@ function srgbToLinear({ value }: { value: number }): number {
|
||||
}
|
||||
|
||||
function linearToSrgb({ value }: { value: number }): number {
|
||||
const clamped = clamp01({ value });
|
||||
const clamped = clamp({ value, min: 0, max: 1 });
|
||||
return clamped <= 0.0031308
|
||||
? clamped * 12.92
|
||||
: 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
|
||||
@@ -50,6 +47,18 @@ export function isVectorValue(value: unknown): value is VectorValue {
|
||||
return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
|
||||
}
|
||||
|
||||
export type EasingMode = "independent" | "shared";
|
||||
|
||||
/**
|
||||
* Declares how easing curves apply to a binding's components.
|
||||
* "shared" means all components always use the same curve (e.g. color — you never
|
||||
* want to ease R independently from G/B/A). "independent" means each component
|
||||
* can have its own curve.
|
||||
*/
|
||||
export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode {
|
||||
return kind === "color" ? "shared" : "independent";
|
||||
}
|
||||
|
||||
export function getBindingComponentKeys({
|
||||
kind,
|
||||
}: {
|
||||
@@ -234,7 +243,7 @@ export function parseColorToLinearRgba({
|
||||
r: srgbToLinear({ value: rgb.r ?? 0 }),
|
||||
g: srgbToLinear({ value: rgb.g ?? 0 }),
|
||||
b: srgbToLinear({ value: rgb.b ?? 0 }),
|
||||
a: clamp01({ value: rgb.alpha ?? 1 }),
|
||||
a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -248,7 +257,7 @@ export function formatLinearRgba({
|
||||
r: linearToSrgb({ value: color.r }),
|
||||
g: linearToSrgb({ value: color.g }),
|
||||
b: linearToSrgb({ value: color.b }),
|
||||
alpha: clamp01({ value: color.a }),
|
||||
alpha: clamp({ value: color.a, min: 0, max: 1 }),
|
||||
} as const;
|
||||
return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
getDefaultLeftHandle,
|
||||
getDefaultRightHandle,
|
||||
} from "@/animation/bezier";
|
||||
import type {
|
||||
CurveHandle,
|
||||
NormalizedCubicBezier,
|
||||
ScalarAnimationKey,
|
||||
} from "@/animation/types";
|
||||
|
||||
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: spanTime * x1,
|
||||
dv: effectiveSpanValue * y1,
|
||||
},
|
||||
leftHandle: {
|
||||
dt: spanTime * (x2 - 1),
|
||||
dv: effectiveSpanValue * (y2 - 1),
|
||||
},
|
||||
};
|
||||
}
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type { Effect } from "@/lib/effects/types";
|
||||
import type { ParamValues } from "@/params";
|
||||
import type { Effect } from "@/effects/types";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
EffectParamPath,
|
||||
} from "@/lib/animation/types";
|
||||
} from "@/animation/types";
|
||||
import { removeElementKeyframe } from "./keyframes";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type {
|
||||
AnimationBindingInstance,
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
ScalarAnimationChannel,
|
||||
ScalarGraphChannel,
|
||||
ScalarGraphKeyframeContext,
|
||||
} from "@/animation/types";
|
||||
|
||||
export interface EditableScalarChannels {
|
||||
binding: AnimationBindingInstance;
|
||||
channels: ScalarGraphChannel[];
|
||||
}
|
||||
|
||||
function isScalarAnimationChannel(
|
||||
channel: ElementAnimations["channels"][string],
|
||||
): channel is ScalarAnimationChannel {
|
||||
return channel?.kind === "scalar";
|
||||
}
|
||||
|
||||
export function getEditableScalarChannels({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
}): EditableScalarChannels | null {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channels = binding.components.flatMap((component) => {
|
||||
const channel = animations?.channels[component.channelId];
|
||||
if (!isScalarAnimationChannel(channel)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
propertyPath,
|
||||
componentKey: component.key,
|
||||
channelId: component.channelId,
|
||||
channel,
|
||||
} satisfies ScalarGraphChannel,
|
||||
];
|
||||
});
|
||||
|
||||
return { binding, 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,
|
||||
};
|
||||
}
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
import type {
|
||||
ElementAnimations,
|
||||
GraphicParamPath,
|
||||
} from "@/lib/animation/types";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
} from "@/animation/types";
|
||||
import type { ParamValues } from "@/params";
|
||||
import {
|
||||
getGraphicDefinition,
|
||||
resolveGraphicParams,
|
||||
} from "@/lib/graphics";
|
||||
} from "@/graphics";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
||||
@@ -12,8 +12,10 @@ export {
|
||||
getChannel,
|
||||
removeElementKeyframe,
|
||||
retimeElementKeyframe,
|
||||
setBindingComponentChannel,
|
||||
setChannel,
|
||||
splitAnimationsAtTime,
|
||||
updateScalarKeyframeCurve,
|
||||
upsertElementKeyframe,
|
||||
upsertPathKeyframe,
|
||||
} from "./keyframes";
|
||||
@@ -46,6 +48,18 @@ export {
|
||||
hasKeyframesForPath,
|
||||
} from "./keyframe-query";
|
||||
|
||||
export {
|
||||
type EditableScalarChannels,
|
||||
getEditableScalarChannel,
|
||||
getEditableScalarChannels,
|
||||
getScalarKeyframeContext,
|
||||
} from "./graph-channels";
|
||||
|
||||
export {
|
||||
getCurveHandlesForNormalizedCubicBezier,
|
||||
getNormalizedCubicBezierForScalarSegment,
|
||||
} from "./curve-bridge";
|
||||
|
||||
export {
|
||||
buildGraphicParamPath,
|
||||
isGraphicParamPath,
|
||||
@@ -77,5 +91,6 @@ export {
|
||||
} from "./property-groups";
|
||||
|
||||
export {
|
||||
isVectorValue,
|
||||
type EasingMode,
|
||||
getEasingModeForKind,
|
||||
} from "./binding-values";
|
||||
+16
-21
@@ -7,8 +7,8 @@ import type {
|
||||
ScalarAnimationChannel,
|
||||
ScalarAnimationKey,
|
||||
ScalarSegmentType,
|
||||
} from "@/lib/animation/types";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
} from "@/animation/types";
|
||||
import { clamp } from "@/utils/math";
|
||||
import {
|
||||
getBezierPoint,
|
||||
getDefaultLeftHandle,
|
||||
@@ -35,14 +35,7 @@ function isWithinTimePair({
|
||||
leftTime: number;
|
||||
rightTime: number;
|
||||
}): boolean {
|
||||
return (
|
||||
time >= leftTime - TIME_EPSILON_SECONDS &&
|
||||
time <= rightTime + TIME_EPSILON_SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
function clamp01({ value }: { value: number }): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
return time >= leftTime && time <= rightTime;
|
||||
}
|
||||
|
||||
function lerpNumber({
|
||||
@@ -70,7 +63,7 @@ function normalizeRightHandle({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
|
||||
const span = Math.max(1, rightKey.time - leftKey.time);
|
||||
return {
|
||||
dt: Math.min(span, Math.max(0, handle.dt)),
|
||||
dv: handle.dv,
|
||||
@@ -90,7 +83,7 @@ function normalizeLeftHandle({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
|
||||
const span = Math.max(1, rightKey.time - leftKey.time);
|
||||
return {
|
||||
dt: Math.max(-span, Math.min(0, handle.dt)),
|
||||
dv: handle.dv,
|
||||
@@ -190,7 +183,7 @@ function extrapolateScalarEdge({
|
||||
}
|
||||
|
||||
const span = neighborKey.time - edgeKey.time;
|
||||
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
||||
if (span === 0) {
|
||||
return edgeKey.value;
|
||||
}
|
||||
|
||||
@@ -231,8 +224,8 @@ export function getScalarChannelValueAtTime({
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
if (time <= firstKey.time + TIME_EPSILON_SECONDS) {
|
||||
if (time < firstKey.time - TIME_EPSILON_SECONDS) {
|
||||
if (time <= firstKey.time) {
|
||||
if (time < firstKey.time) {
|
||||
return extrapolateScalarEdge({
|
||||
mode: normalizedChannel.extrapolation?.before ?? "hold",
|
||||
edgeKey: firstKey,
|
||||
@@ -244,8 +237,8 @@ export function getScalarChannelValueAtTime({
|
||||
return firstKey.value;
|
||||
}
|
||||
|
||||
if (time >= lastKey.time - TIME_EPSILON_SECONDS) {
|
||||
if (time > lastKey.time + TIME_EPSILON_SECONDS) {
|
||||
if (time >= lastKey.time) {
|
||||
if (time > lastKey.time) {
|
||||
return extrapolateScalarEdge({
|
||||
mode: normalizedChannel.extrapolation?.after ?? "hold",
|
||||
edgeKey: lastKey,
|
||||
@@ -264,7 +257,7 @@ export function getScalarChannelValueAtTime({
|
||||
) {
|
||||
const leftKey = normalizedChannel.keys[keyIndex];
|
||||
const rightKey = normalizedChannel.keys[keyIndex + 1];
|
||||
if (Math.abs(time - rightKey.time) <= TIME_EPSILON_SECONDS) {
|
||||
if (time === rightKey.time) {
|
||||
return rightKey.value;
|
||||
}
|
||||
|
||||
@@ -283,12 +276,14 @@ export function getScalarChannelValueAtTime({
|
||||
}
|
||||
|
||||
const span = rightKey.time - leftKey.time;
|
||||
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
||||
if (span === 0) {
|
||||
return rightKey.value;
|
||||
}
|
||||
|
||||
const progress = clamp01({
|
||||
const progress = clamp({
|
||||
value: (time - leftKey.time) / span,
|
||||
min: 0,
|
||||
max: 1,
|
||||
});
|
||||
if (leftKey.segmentToNext === "linear") {
|
||||
return lerpNumber({
|
||||
@@ -337,7 +332,7 @@ export function getDiscreteChannelValueAtTime({
|
||||
});
|
||||
let currentValue = fallbackValue;
|
||||
for (const key of normalizedChannel.keys) {
|
||||
if (time + TIME_EPSILON_SECONDS < key.time) {
|
||||
if (time < key.time) {
|
||||
break;
|
||||
}
|
||||
currentValue = key.value;
|
||||
+3
-7
@@ -1,11 +1,10 @@
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import type {
|
||||
AnimationBindingInstance,
|
||||
AnimationChannel,
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
ElementKeyframe,
|
||||
} from "@/lib/animation/types";
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
type AnimationComponentValue,
|
||||
composeAnimationValue,
|
||||
@@ -76,8 +75,7 @@ function getUniqueBindingKeyframeMatches({
|
||||
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
|
||||
if (
|
||||
!previousMatch ||
|
||||
Math.abs(previousMatch.keyframe.time - match.keyframe.time) >
|
||||
TIME_EPSILON_SECONDS
|
||||
previousMatch.keyframe.time !== match.keyframe.time
|
||||
) {
|
||||
uniqueMatches.push(match);
|
||||
continue;
|
||||
@@ -249,9 +247,7 @@ export function getKeyframeAtTime({
|
||||
matches: getBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
}).filter(({ keyframe }) =>
|
||||
Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
|
||||
),
|
||||
}).filter(({ keyframe }) => keyframe.time === time),
|
||||
});
|
||||
if (!keyframeMatch) {
|
||||
return null;
|
||||
@@ -11,9 +11,9 @@ import type {
|
||||
ElementAnimations,
|
||||
ScalarAnimationChannel,
|
||||
ScalarAnimationKey,
|
||||
ScalarCurveKeyframePatch,
|
||||
ScalarSegmentType,
|
||||
} from "@/lib/animation/types";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
} from "@/animation/types";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import {
|
||||
cloneAnimationBinding,
|
||||
@@ -43,7 +43,7 @@ function isNearlySameTime({
|
||||
leftTime: number;
|
||||
rightTime: number;
|
||||
}): boolean {
|
||||
return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS;
|
||||
return leftTime === rightTime;
|
||||
}
|
||||
|
||||
function hasChannelKeys({
|
||||
@@ -173,7 +173,7 @@ function createScalarKey({
|
||||
id: string;
|
||||
time: number;
|
||||
value: number;
|
||||
interpolation: AnimationInterpolation;
|
||||
interpolation?: AnimationInterpolation;
|
||||
previousKey?: ScalarAnimationKey;
|
||||
}): ScalarAnimationKey {
|
||||
return {
|
||||
@@ -183,7 +183,8 @@ function createScalarKey({
|
||||
leftHandle: previousKey?.leftHandle,
|
||||
rightHandle: previousKey?.rightHandle,
|
||||
segmentToNext:
|
||||
previousKey?.segmentToNext ?? getScalarSegmentType({ interpolation }),
|
||||
previousKey?.segmentToNext ??
|
||||
getScalarSegmentType({ interpolation: interpolation ?? "linear" }),
|
||||
tangentMode: previousKey?.tangentMode ?? "flat",
|
||||
};
|
||||
}
|
||||
@@ -209,7 +210,7 @@ function getBinding({
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: string;
|
||||
propertyPath: AnimationPath;
|
||||
}): AnimationBindingInstance | undefined {
|
||||
return animations?.bindings[propertyPath];
|
||||
}
|
||||
@@ -224,6 +225,16 @@ function getChannelById({
|
||||
return animations?.channels[channelId];
|
||||
}
|
||||
|
||||
function getBindingComponent({
|
||||
binding,
|
||||
componentKey,
|
||||
}: {
|
||||
binding: AnimationBindingInstance;
|
||||
componentKey: string;
|
||||
}) {
|
||||
return binding.components.find((component) => component.key === componentKey) ?? null;
|
||||
}
|
||||
|
||||
function getTargetKeyMetadata({
|
||||
channel,
|
||||
time,
|
||||
@@ -322,12 +333,14 @@ function upsertScalarChannelKey({
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
defaultInterpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
channel: ScalarAnimationChannel | undefined;
|
||||
time: number;
|
||||
value: number;
|
||||
interpolation: AnimationInterpolation;
|
||||
interpolation?: AnimationInterpolation;
|
||||
defaultInterpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
}): ScalarAnimationChannel {
|
||||
const normalizedChannel = normalizeChannel({
|
||||
@@ -342,10 +355,13 @@ function upsertScalarChannelKey({
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
previousKey: {
|
||||
...keys[existingIndex],
|
||||
segmentToNext: getScalarSegmentType({ interpolation }),
|
||||
},
|
||||
previousKey:
|
||||
interpolation != null
|
||||
? {
|
||||
...keys[existingIndex],
|
||||
segmentToNext: getScalarSegmentType({ interpolation }),
|
||||
}
|
||||
: keys[existingIndex],
|
||||
});
|
||||
return normalizeChannel({
|
||||
channel: {
|
||||
@@ -366,10 +382,13 @@ function upsertScalarChannelKey({
|
||||
time: keys[existingAtTimeIndex].time,
|
||||
value,
|
||||
interpolation,
|
||||
previousKey: {
|
||||
...keys[existingAtTimeIndex],
|
||||
segmentToNext: getScalarSegmentType({ interpolation }),
|
||||
},
|
||||
previousKey:
|
||||
interpolation != null
|
||||
? {
|
||||
...keys[existingAtTimeIndex],
|
||||
segmentToNext: getScalarSegmentType({ interpolation }),
|
||||
}
|
||||
: keys[existingAtTimeIndex],
|
||||
});
|
||||
return normalizeChannel({
|
||||
channel: {
|
||||
@@ -385,7 +404,7 @@ function upsertScalarChannelKey({
|
||||
id: keyframeId ?? generateUUID(),
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
interpolation: interpolation ?? defaultInterpolation,
|
||||
}),
|
||||
);
|
||||
return normalizeChannel({
|
||||
@@ -402,7 +421,7 @@ export function getChannel({
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: string;
|
||||
propertyPath: AnimationPath;
|
||||
}): AnimationChannel | undefined {
|
||||
const binding = getBinding({ animations, propertyPath });
|
||||
const primaryChannelId =
|
||||
@@ -429,9 +448,9 @@ export function upsertPathKeyframe({
|
||||
keyframeId?: string;
|
||||
kind: AnimationBindingKind;
|
||||
defaultInterpolation: AnimationInterpolation;
|
||||
coerceValue: (value: AnimationValue) => AnimationValue | null;
|
||||
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
|
||||
}): ElementAnimations | undefined {
|
||||
const coercedValue = coerceValue(value);
|
||||
const coercedValue = coerceValue({ value });
|
||||
if (coercedValue === null) {
|
||||
return animations;
|
||||
}
|
||||
@@ -462,9 +481,13 @@ export function upsertPathKeyframe({
|
||||
return animations;
|
||||
}
|
||||
|
||||
const nextInterpolation = getInterpolationForBinding({
|
||||
const explicitInterpolation =
|
||||
interpolation != null
|
||||
? getInterpolationForBinding({ kind, interpolation })
|
||||
: undefined;
|
||||
const validatedDefaultInterpolation = getInterpolationForBinding({
|
||||
kind,
|
||||
interpolation: interpolation ?? defaultInterpolation,
|
||||
interpolation: defaultInterpolation,
|
||||
});
|
||||
nextAnimations.bindings[propertyPath] = binding;
|
||||
for (const component of binding.components) {
|
||||
@@ -493,7 +516,8 @@ export function upsertPathKeyframe({
|
||||
channel: targetChannel,
|
||||
time: targetKey.time,
|
||||
value: nextValue as number,
|
||||
interpolation: nextInterpolation,
|
||||
interpolation: explicitInterpolation,
|
||||
defaultInterpolation: validatedDefaultInterpolation,
|
||||
keyframeId: targetKey.id,
|
||||
});
|
||||
}
|
||||
@@ -536,7 +560,7 @@ export function upsertElementKeyframe({
|
||||
keyframeId,
|
||||
kind: propertyDefinition.kind,
|
||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||
coerceValue: (nextValue) =>
|
||||
coerceValue: ({ value: nextValue }) =>
|
||||
coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value: nextValue,
|
||||
@@ -582,7 +606,7 @@ export function upsertKeyframe({
|
||||
channel,
|
||||
time,
|
||||
value,
|
||||
interpolation: interpolation ?? "linear",
|
||||
interpolation,
|
||||
keyframeId,
|
||||
});
|
||||
}
|
||||
@@ -651,7 +675,7 @@ export function setChannel({
|
||||
channel,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: string;
|
||||
propertyPath: AnimationPath;
|
||||
channel: AnimationChannel | undefined;
|
||||
}): ElementAnimations | undefined {
|
||||
const binding = getBinding({ animations, propertyPath });
|
||||
@@ -659,29 +683,66 @@ export function setChannel({
|
||||
return animations;
|
||||
}
|
||||
|
||||
const primaryComponent = getPrimaryComponent({ binding });
|
||||
if (!primaryComponent) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
const nextAnimations = cloneAnimationsState({ animations });
|
||||
if (!channel || !hasChannelKeys({ channel })) {
|
||||
for (const component of binding.components) {
|
||||
delete nextAnimations.channels[component.channelId];
|
||||
}
|
||||
delete nextAnimations.bindings[propertyPath];
|
||||
return toAnimation({
|
||||
animations: nextAnimations,
|
||||
});
|
||||
}
|
||||
|
||||
if (binding.components.length !== 1) {
|
||||
throw new Error(
|
||||
`setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`,
|
||||
);
|
||||
}
|
||||
|
||||
nextAnimations.channels[primaryComponent.channelId] = normalizeChannel({
|
||||
const primaryComponent = getPrimaryComponent({ binding });
|
||||
if (!primaryComponent) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
return setBindingComponentChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
componentKey: primaryComponent.key,
|
||||
channel,
|
||||
});
|
||||
}
|
||||
|
||||
export function setBindingComponentChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
componentKey,
|
||||
channel,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
componentKey: string;
|
||||
channel: AnimationChannel | undefined;
|
||||
}): ElementAnimations | undefined {
|
||||
const binding = getBinding({ animations, propertyPath });
|
||||
if (!binding) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
const component = getBindingComponent({
|
||||
binding,
|
||||
componentKey,
|
||||
});
|
||||
if (!component) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
const nextAnimations = cloneAnimationsState({ animations });
|
||||
if (!channel || !hasChannelKeys({ channel })) {
|
||||
delete nextAnimations.channels[component.channelId];
|
||||
const hasRemainingKeys = binding.components.some((candidate) =>
|
||||
hasChannelKeys({
|
||||
channel: nextAnimations.channels[candidate.channelId],
|
||||
}),
|
||||
);
|
||||
if (!hasRemainingKeys) {
|
||||
delete nextAnimations.bindings[propertyPath];
|
||||
}
|
||||
return toAnimation({
|
||||
animations: nextAnimations,
|
||||
});
|
||||
}
|
||||
|
||||
nextAnimations.channels[component.channelId] = normalizeChannel({
|
||||
channel,
|
||||
});
|
||||
return toAnimation({
|
||||
@@ -689,6 +750,73 @@ export function setChannel({
|
||||
});
|
||||
}
|
||||
|
||||
export function updateScalarKeyframeCurve({
|
||||
animations,
|
||||
propertyPath,
|
||||
componentKey,
|
||||
keyframeId,
|
||||
patch,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
componentKey: string;
|
||||
keyframeId: string;
|
||||
patch: ScalarCurveKeyframePatch;
|
||||
}): ElementAnimations | undefined {
|
||||
const binding = getBinding({ animations, propertyPath });
|
||||
if (!binding) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
const component = getBindingComponent({
|
||||
binding,
|
||||
componentKey,
|
||||
});
|
||||
if (!component) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
const channel = getChannelById({
|
||||
animations,
|
||||
channelId: component.channelId,
|
||||
});
|
||||
if (channel?.kind !== "scalar") {
|
||||
return animations;
|
||||
}
|
||||
|
||||
const keyframeIndex = channel.keys.findIndex((keyframe) => keyframe.id === keyframeId);
|
||||
if (keyframeIndex < 0) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
const nextKeys = [...channel.keys];
|
||||
const currentKey = nextKeys[keyframeIndex];
|
||||
nextKeys[keyframeIndex] = {
|
||||
...currentKey,
|
||||
leftHandle:
|
||||
patch.leftHandle === undefined
|
||||
? currentKey.leftHandle
|
||||
: patch.leftHandle ?? undefined,
|
||||
rightHandle:
|
||||
patch.rightHandle === undefined
|
||||
? currentKey.rightHandle
|
||||
: patch.rightHandle ?? undefined,
|
||||
segmentToNext: patch.segmentToNext ?? currentKey.segmentToNext,
|
||||
tangentMode: patch.tangentMode ?? currentKey.tangentMode,
|
||||
};
|
||||
|
||||
return setBindingComponentChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
componentKey,
|
||||
channel: {
|
||||
kind: "scalar",
|
||||
keys: nextKeys,
|
||||
extrapolation: channel.extrapolation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function cloneAnimations({
|
||||
animations,
|
||||
shouldRegenerateKeyframeIds = false,
|
||||
@@ -917,8 +1045,8 @@ function splitScalarChannelAtTime({
|
||||
const rightKey = normalizedChannel.keys[keyIndex + 1];
|
||||
if (
|
||||
!(
|
||||
splitTime > leftKey.time + TIME_EPSILON_SECONDS &&
|
||||
splitTime < rightKey.time - TIME_EPSILON_SECONDS
|
||||
splitTime > leftKey.time &&
|
||||
splitTime < rightKey.time
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
+2
-2
@@ -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 {
|
||||
+29
-16
@@ -3,21 +3,20 @@ import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
VectorValue,
|
||||
} from "@/lib/animation/types";
|
||||
import { isVectorValue, parseColorToLinearRgba } from "./binding-values";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
|
||||
} from "@/animation/types";
|
||||
import { parseColorToLinearRgba } from "./binding-values";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
|
||||
import {
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
} from "@/constants/text-constants";
|
||||
} from "@/text/background";
|
||||
import {
|
||||
canElementHaveAudio,
|
||||
isVisualElement,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
} from "@/timeline/element-utils";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
|
||||
export interface NumericSpec {
|
||||
@@ -116,24 +115,38 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||
AnimationPropertyPath,
|
||||
AnimationPropertyDefinition
|
||||
> = {
|
||||
"transform.position": {
|
||||
kind: "vector2",
|
||||
defaultInterpolation: "linear",
|
||||
"transform.positionX": createNumberPropertyDefinition({
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.position : null,
|
||||
coerceValue: ({ value }) => (isVectorValue(value) ? value : null),
|
||||
isVisualElement(element) ? element.transform.position.x : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: value as VectorValue,
|
||||
position: { ...element.transform.position, x: value as number },
|
||||
},
|
||||
}
|
||||
: element,
|
||||
},
|
||||
}),
|
||||
"transform.positionY": createNumberPropertyDefinition({
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.position.y : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: { ...element.transform.position, y: value as number },
|
||||
},
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"transform.scaleX": createNumberPropertyDefinition({
|
||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
@@ -1,8 +1,12 @@
|
||||
import type {
|
||||
AnimationColorPropertyPath,
|
||||
AnimationNumericPropertyPath,
|
||||
AnimationPath,
|
||||
AnimationPropertyPath,
|
||||
AnimationValueForPath,
|
||||
ElementAnimations,
|
||||
} from "@/lib/animation/types";
|
||||
import type { Transform } from "@/lib/rendering";
|
||||
} from "@/animation/types";
|
||||
import type { Transform } from "@/rendering";
|
||||
import {
|
||||
type AnimationComponentValue,
|
||||
composeAnimationValue,
|
||||
@@ -44,12 +48,20 @@ export function resolveTransformAtTime({
|
||||
}): Transform {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
return {
|
||||
position: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.position",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.position,
|
||||
}),
|
||||
position: {
|
||||
x: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.positionX",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.position.x,
|
||||
}),
|
||||
y: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.positionY",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.position.y,
|
||||
}),
|
||||
},
|
||||
scaleX: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
@@ -96,7 +108,7 @@ export function resolveNumberAtTime({
|
||||
}: {
|
||||
baseValue: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
propertyPath: AnimationNumericPropertyPath;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
@@ -115,7 +127,7 @@ export function resolveColorAtTime({
|
||||
}: {
|
||||
baseColor: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
propertyPath: AnimationColorPropertyPath;
|
||||
localTime: number;
|
||||
}): string {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
@@ -126,19 +138,17 @@ export function resolveColorAtTime({
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAnimationPathValueAtTime<
|
||||
T extends number | string | boolean | Transform["position"],
|
||||
>({
|
||||
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
fallbackValue,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: string;
|
||||
propertyPath: TPath;
|
||||
localTime: number;
|
||||
fallbackValue: T;
|
||||
}): T {
|
||||
fallbackValue: AnimationValueForPath<TPath>;
|
||||
}): AnimationValueForPath<TPath> {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
return fallbackValue;
|
||||
@@ -170,5 +180,5 @@ export function resolveAnimationPathValueAtTime<
|
||||
return (composeAnimationValue({
|
||||
binding,
|
||||
componentValues,
|
||||
}) ?? fallbackValue) as T;
|
||||
}) ?? fallbackValue) as AnimationValueForPath<TPath>;
|
||||
}
|
||||
+17
-17
@@ -3,20 +3,20 @@ import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
} from "@/lib/animation/types";
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
parseEffectParamPath,
|
||||
isEffectParamPath,
|
||||
} from "@/lib/animation/effect-param-channel";
|
||||
} from "@/animation/effect-param-channel";
|
||||
import {
|
||||
isGraphicParamPath,
|
||||
parseGraphicParamPath,
|
||||
} from "@/lib/animation/graphic-param-channel";
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
|
||||
import { getGraphicDefinition } from "@/lib/graphics";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
} from "@/animation/graphic-param-channel";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import { effectsRegistry, registerDefaultEffects } from "@/effects";
|
||||
import { getGraphicDefinition } from "@/graphics";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import {
|
||||
coerceAnimationValueForProperty,
|
||||
@@ -32,9 +32,9 @@ export interface AnimationPathDescriptor {
|
||||
kind: AnimationBindingKind;
|
||||
defaultInterpolation: AnimationInterpolation;
|
||||
numericRanges?: Partial<Record<string, NumericSpec>>;
|
||||
coerceValue(value: AnimationValue): AnimationValue | null;
|
||||
getBaseValue(): AnimationValue | null;
|
||||
setBaseValue(value: AnimationValue): TimelineElement;
|
||||
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
|
||||
getBaseValue: () => AnimationValue | null;
|
||||
setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
|
||||
}
|
||||
|
||||
export function getParamValueKind({
|
||||
@@ -135,9 +135,9 @@ function buildGraphicParamDescriptor({
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRanges: getParamNumericRange({ param }),
|
||||
coerceValue: (value) => coerceAnimationValueForParam({ param, value }),
|
||||
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
|
||||
getBaseValue: () => element.params[param.key] ?? param.default,
|
||||
setBaseValue: (value) => {
|
||||
setBaseValue: ({ value }) => {
|
||||
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
@@ -183,9 +183,9 @@ function buildEffectParamDescriptor({
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRanges: getParamNumericRange({ param }),
|
||||
coerceValue: (value) => coerceAnimationValueForParam({ param, value }),
|
||||
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
|
||||
getBaseValue: () => effect.params[param.key] ?? param.default,
|
||||
setBaseValue: (value) => {
|
||||
setBaseValue: ({ value }) => {
|
||||
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
@@ -239,7 +239,7 @@ export function resolveAnimationTarget({
|
||||
kind: propertyDefinition.kind,
|
||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||
numericRanges: propertyDefinition.numericRanges,
|
||||
coerceValue: (value) =>
|
||||
coerceValue: ({ value }) =>
|
||||
coerceAnimationValueForProperty({
|
||||
propertyPath: path,
|
||||
value,
|
||||
@@ -249,7 +249,7 @@ export function resolveAnimationTarget({
|
||||
element,
|
||||
propertyPath: path,
|
||||
}),
|
||||
setBaseValue: (value) => {
|
||||
setBaseValue: ({ value }) => {
|
||||
const coercedValue = propertyDefinition.coerceValue({ value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getElementKeyframes } from "@/animation";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import type { SnapPoint } from "@/timeline/snapping";
|
||||
|
||||
export function getAnimationKeyframeSnapPointsForTimeline({
|
||||
tracks,
|
||||
excludeElementIds,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
excludeElementIds?: Set<string>;
|
||||
}): SnapPoint[] {
|
||||
const snapPoints: SnapPoint[] = [];
|
||||
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||
|
||||
for (const track of orderedTracks) {
|
||||
for (const element of track.elements) {
|
||||
if (excludeElementIds?.has(element.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const keyframe of getElementKeyframes({
|
||||
animations: element.animations,
|
||||
})) {
|
||||
snapPoints.push({
|
||||
time: element.startTime + keyframe.time,
|
||||
type: "keyframe",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return snapPoints;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const MIN_TRANSFORM_SCALE = 0.01;
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ParamValues } from "@/params";
|
||||
|
||||
export const ANIMATION_PROPERTY_PATHS = [
|
||||
"transform.position",
|
||||
"transform.positionX",
|
||||
"transform.positionY",
|
||||
"transform.scaleX",
|
||||
"transform.scaleY",
|
||||
"transform.rotate",
|
||||
@@ -31,6 +34,35 @@ export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
|
||||
export type VectorValue = { x: number; y: number };
|
||||
export type DiscreteValue = boolean | string;
|
||||
export type AnimationValue = number | string | boolean | VectorValue;
|
||||
export interface AnimationPropertyValueMap {
|
||||
"transform.positionX": number;
|
||||
"transform.positionY": number;
|
||||
"transform.scaleX": number;
|
||||
"transform.scaleY": number;
|
||||
"transform.rotate": number;
|
||||
opacity: number;
|
||||
volume: number;
|
||||
color: string;
|
||||
"background.color": string;
|
||||
"background.paddingX": number;
|
||||
"background.paddingY": number;
|
||||
"background.offsetX": number;
|
||||
"background.offsetY": number;
|
||||
"background.cornerRadius": number;
|
||||
}
|
||||
export type DynamicAnimationPathValue = ParamValues[string];
|
||||
export type AnimationValueForPath<TPath extends AnimationPath> =
|
||||
TPath extends AnimationPropertyPath
|
||||
? AnimationPropertyValueMap[TPath]
|
||||
: TPath extends GraphicParamPath | EffectParamPath
|
||||
? DynamicAnimationPathValue
|
||||
: never;
|
||||
export type AnimationNumericPropertyPath = {
|
||||
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
|
||||
}[AnimationPropertyPath];
|
||||
export type AnimationColorPropertyPath = {
|
||||
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
|
||||
}[AnimationPropertyPath];
|
||||
|
||||
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
|
||||
export type DiscreteKeyframeInterpolation = "hold";
|
||||
@@ -144,6 +176,36 @@ export interface ElementAnimations {
|
||||
channels: ElementAnimationChannelMap;
|
||||
}
|
||||
|
||||
export type NormalizedCubicBezier = [number, number, number, number];
|
||||
|
||||
export interface ScalarGraphChannelTarget {
|
||||
propertyPath: AnimationPath;
|
||||
componentKey: string;
|
||||
channelId: 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { checkRateLimit } from "@/auth/rate-limit";
|
||||
import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/feedback";
|
||||
|
||||
const submitSchema = z.object({
|
||||
message: z
|
||||
.string()
|
||||
.min(1, "Message is required")
|
||||
.max(MAX_MESSAGE_LENGTH, "Message too long"),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { limited } = await checkRateLimit({ request });
|
||||
if (limited) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const result = submitSchema.safeParse(body);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: result.error.flatten().fieldErrors },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const entry = await submitFeedback(result.data);
|
||||
return NextResponse.json({ entry }, { status: 201 });
|
||||
}
|
||||
@@ -1,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(),
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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 }> };
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,113 +1,200 @@
|
||||
"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("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
|
||||
overlayControls={overlayControls}
|
||||
overlayInstances={overlaySource.instances}
|
||||
onOverlayVisibilityChange={setOverlayVisibility}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.properties}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
className="min-h-0 px-3 pb-3"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
+291
-284
@@ -1,284 +1,291 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 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,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,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),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
@@ -2,10 +2,9 @@ export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{
|
||||
label: string;
|
||||
value: number;
|
||||
}> = [
|
||||
{ label: "Light", value: 10 },
|
||||
{ label: "Medium", value: 50 },
|
||||
{ label: "Heavy", value: 100 },
|
||||
{ label: "Light", value: 100 },
|
||||
{ label: "Medium", value: 200 },
|
||||
{ label: "Heavy", value: 500 },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10;
|
||||
export const DEFAULT_BACKGROUND_COLOR = "#000000";
|
||||
@@ -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>({
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TCanvasSize } from "@/lib/project/types";
|
||||
import type { TCanvasSize } from "@/project/types";
|
||||
|
||||
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
|
||||
{ width: 1920, height: 1080 },
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Cancel01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getSortedReleases } from "../utils";
|
||||
import type { Release } from "../utils";
|
||||
|
||||
const STORAGE_KEY = "last-seen-version";
|
||||
|
||||
export function ChangelogNotification() {
|
||||
const [release, setRelease] = useState<Release | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const releases = getSortedReleases();
|
||||
const latest = releases[0];
|
||||
if (!latest) return;
|
||||
|
||||
let storedVersion: string | null = null;
|
||||
try {
|
||||
storedVersion = localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
|
||||
const isOutdated =
|
||||
storedVersion === null ||
|
||||
storedVersion.localeCompare(latest.version, undefined, {
|
||||
numeric: true,
|
||||
}) < 0;
|
||||
|
||||
// TODO(v0.4): revert to the standard "null = first-time visitor, record silently"
|
||||
// path. The null case intentionally shows the card for this release so existing
|
||||
// users who never had the key get the 0.3.0 announcement.
|
||||
if (!isOutdated) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, latest.version);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
setRelease(latest);
|
||||
}, []);
|
||||
|
||||
if (!release) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-5 left-5 z-50 flex w-72 flex-col gap-3 rounded-xl border bg-card p-4 shadow-lg">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold leading-snug">
|
||||
{release.title}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
v{release.version}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="-mr-1 -mt-1 shrink-0"
|
||||
onClick={() => setRelease(null)}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{release.summary && (
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{release.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button asChild size="sm">
|
||||
<Link href="/changelog" onClick={() => setRelease(null)}>
|
||||
See full changelog
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+98
-68
@@ -1,68 +1,98 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
+153
-106
@@ -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."
|
||||
|
||||
---
|
||||
@@ -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."
|
||||
@@ -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">;
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
ClipboardEntry,
|
||||
ClipboardEntryType,
|
||||
ClipboardHandler,
|
||||
ClipboardHandlerMap,
|
||||
CopyContext,
|
||||
PasteContext,
|
||||
} from "../types";
|
||||
import { ElementsClipboardHandler } from "./elements";
|
||||
import { KeyframesClipboardHandler } from "./keyframes";
|
||||
|
||||
export const clipboardHandlers = {
|
||||
elements: ElementsClipboardHandler,
|
||||
keyframes: KeyframesClipboardHandler,
|
||||
} satisfies ClipboardHandlerMap;
|
||||
|
||||
export const clipboardCopyHandlers = [
|
||||
KeyframesClipboardHandler,
|
||||
ElementsClipboardHandler,
|
||||
] as const satisfies readonly ClipboardHandler<ClipboardEntryType>[];
|
||||
|
||||
export function copyClipboardEntry({
|
||||
context,
|
||||
}: {
|
||||
context: CopyContext;
|
||||
}): ClipboardEntry | null {
|
||||
for (const handler of clipboardCopyHandlers) {
|
||||
if (!handler.canCopy(context)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return handler.copy(context);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildPasteClipboardCommand({
|
||||
entry,
|
||||
context,
|
||||
}: {
|
||||
entry: ClipboardEntry;
|
||||
context: PasteContext;
|
||||
}) {
|
||||
const handler = clipboardHandlers[entry.type] as ClipboardHandler<
|
||||
typeof entry.type
|
||||
>;
|
||||
return handler.paste(entry as never, context);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { getKeyframeById } from "@/animation";
|
||||
import type { SelectedKeyframeRef } from "@/animation/types";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import { PasteKeyframesCommand } from "@/commands/timeline";
|
||||
import type {
|
||||
ClipboardHandler,
|
||||
KeyframeClipboardCurvePatch,
|
||||
KeyframeClipboardItem,
|
||||
} from "../types";
|
||||
|
||||
function resolveSingleSourceElement({
|
||||
selectedKeyframes,
|
||||
}: {
|
||||
selectedKeyframes: SelectedKeyframeRef[];
|
||||
}) {
|
||||
const firstKeyframe = selectedKeyframes[0];
|
||||
if (!firstKeyframe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceElement = {
|
||||
trackId: firstKeyframe.trackId,
|
||||
elementId: firstKeyframe.elementId,
|
||||
};
|
||||
const isSingleSource = selectedKeyframes.every(
|
||||
(keyframe) =>
|
||||
keyframe.trackId === sourceElement.trackId &&
|
||||
keyframe.elementId === sourceElement.elementId,
|
||||
);
|
||||
|
||||
return isSingleSource ? sourceElement : null;
|
||||
}
|
||||
|
||||
function getCurvePatches({
|
||||
element,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: KeyframeClipboardItem["propertyPath"];
|
||||
keyframeId: string;
|
||||
}): KeyframeClipboardCurvePatch[] {
|
||||
const binding = element.animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return binding.components.flatMap((component) => {
|
||||
const channel = element.animations?.channels[component.channelId];
|
||||
if (channel?.kind !== "scalar") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const keyframe = channel.keys.find(
|
||||
(candidate) => candidate.id === keyframeId,
|
||||
);
|
||||
if (!keyframe) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
componentKey: component.key,
|
||||
patch: {
|
||||
leftHandle: keyframe.leftHandle ?? null,
|
||||
rightHandle: keyframe.rightHandle ?? null,
|
||||
segmentToNext: keyframe.segmentToNext,
|
||||
tangentMode: keyframe.tangentMode,
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function buildClipboardItem({
|
||||
element,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: KeyframeClipboardItem["propertyPath"];
|
||||
keyframeId: string;
|
||||
}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: number }) | null {
|
||||
const keyframe = getKeyframeById({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
});
|
||||
if (!keyframe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
propertyPath,
|
||||
time: keyframe.time,
|
||||
value: keyframe.value,
|
||||
interpolation: keyframe.interpolation,
|
||||
curvePatches: getCurvePatches({
|
||||
element,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const KeyframesClipboardHandler = {
|
||||
type: "keyframes",
|
||||
|
||||
canCopy({ selectedKeyframes }) {
|
||||
return selectedKeyframes.length > 0;
|
||||
},
|
||||
|
||||
copy({ editor, selectedKeyframes }) {
|
||||
const sourceElement = resolveSingleSourceElement({ selectedKeyframes });
|
||||
if (!sourceElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [result] = editor.timeline.getElementsWithTracks({
|
||||
elements: [sourceElement],
|
||||
});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawItems = selectedKeyframes.flatMap((keyframeRef) => {
|
||||
const item = buildClipboardItem({
|
||||
element: result.element,
|
||||
propertyPath: keyframeRef.propertyPath,
|
||||
keyframeId: keyframeRef.keyframeId,
|
||||
});
|
||||
return item ? [item] : [];
|
||||
});
|
||||
if (rawItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minTime = Math.min(...rawItems.map((item) => item.time));
|
||||
const items = rawItems
|
||||
.map(({ time, ...item }) => ({
|
||||
...item,
|
||||
timeOffset: time - minTime,
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.timeOffset - right.timeOffset ||
|
||||
left.propertyPath.localeCompare(right.propertyPath),
|
||||
);
|
||||
|
||||
return {
|
||||
type: "keyframes",
|
||||
sourceElement,
|
||||
items,
|
||||
};
|
||||
},
|
||||
|
||||
paste(entry, { selectedElements, time }) {
|
||||
const targetElement = selectedElements[0];
|
||||
if (!targetElement || entry.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PasteKeyframesCommand({
|
||||
trackId: targetElement.trackId,
|
||||
elementId: targetElement.elementId,
|
||||
time,
|
||||
clipboardItems: entry.items,
|
||||
});
|
||||
},
|
||||
} satisfies ClipboardHandler<"keyframes">;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./types";
|
||||
export * from "./handlers";
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
ScalarCurveKeyframePatch,
|
||||
SelectedKeyframeRef,
|
||||
} from "@/animation/types";
|
||||
import type { Command } from "@/commands/base-command";
|
||||
import type {
|
||||
CreateTimelineElement,
|
||||
ElementRef,
|
||||
TrackType,
|
||||
} from "@/timeline";
|
||||
|
||||
export interface ElementClipboardItem {
|
||||
trackId: string;
|
||||
trackType: TrackType;
|
||||
element: CreateTimelineElement;
|
||||
}
|
||||
|
||||
export interface KeyframeClipboardCurvePatch {
|
||||
componentKey: string;
|
||||
patch: ScalarCurveKeyframePatch;
|
||||
}
|
||||
|
||||
export interface KeyframeClipboardItem {
|
||||
propertyPath: AnimationPath;
|
||||
timeOffset: number;
|
||||
value: AnimationValue;
|
||||
interpolation: AnimationInterpolation;
|
||||
curvePatches: KeyframeClipboardCurvePatch[];
|
||||
}
|
||||
|
||||
export interface ElementsClipboardEntry {
|
||||
type: "elements";
|
||||
items: ElementClipboardItem[];
|
||||
}
|
||||
|
||||
export interface KeyframesClipboardEntry {
|
||||
type: "keyframes";
|
||||
sourceElement: ElementRef;
|
||||
items: KeyframeClipboardItem[];
|
||||
}
|
||||
|
||||
export interface ClipboardEntryByType {
|
||||
elements: ElementsClipboardEntry;
|
||||
keyframes: KeyframesClipboardEntry;
|
||||
}
|
||||
|
||||
export type ClipboardEntry = ClipboardEntryByType[keyof ClipboardEntryByType];
|
||||
export type ClipboardEntryType = keyof ClipboardEntryByType;
|
||||
|
||||
export interface CopyContext {
|
||||
editor: EditorCore;
|
||||
selectedElements: ElementRef[];
|
||||
selectedKeyframes: SelectedKeyframeRef[];
|
||||
}
|
||||
|
||||
export interface PasteContext {
|
||||
editor: EditorCore;
|
||||
selectedElements: ElementRef[];
|
||||
selectedKeyframes: SelectedKeyframeRef[];
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface ClipboardHandler<TType extends ClipboardEntryType> {
|
||||
type: TType;
|
||||
canCopy(context: CopyContext): boolean;
|
||||
copy(context: CopyContext): ClipboardEntryByType[TType] | null;
|
||||
paste(
|
||||
entry: ClipboardEntryByType[TType],
|
||||
context: PasteContext,
|
||||
): Command | null;
|
||||
}
|
||||
|
||||
export type ClipboardHandlerMap = {
|
||||
[TType in ClipboardEntryType]: ClipboardHandler<TType>;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { EditorSelectionPatch } from "@/selection/editor-selection";
|
||||
import type { ElementRef } from "@/timeline/types";
|
||||
|
||||
export interface CommandResult {
|
||||
selection?: EditorSelectionPatch;
|
||||
}
|
||||
|
||||
export function createElementSelectionResult(
|
||||
selectedElements: ElementRef[],
|
||||
): CommandResult {
|
||||
return {
|
||||
selection: {
|
||||
selectedElements,
|
||||
selectedKeyframes: [],
|
||||
keyframeSelectionAnchor: null,
|
||||
selectedMaskPoints: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export abstract class Command {
|
||||
abstract execute(): CommandResult | undefined;
|
||||
|
||||
undo(): void {
|
||||
throw new Error("Undo not implemented for this command");
|
||||
}
|
||||
|
||||
redo(): CommandResult | undefined {
|
||||
return this.execute();
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export class BatchCommand extends Command {
|
||||
|
||||
for (const command of this.commands) {
|
||||
const result = command.execute();
|
||||
if (result?.select !== undefined) {
|
||||
if (result?.selection !== undefined) {
|
||||
latestSelectionResult = result;
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export class BatchCommand extends Command {
|
||||
|
||||
for (const command of this.commands) {
|
||||
const result = command.redo();
|
||||
if (result?.select !== undefined) {
|
||||
if (result?.selection !== undefined) {
|
||||
latestSelectionResult = result;
|
||||
}
|
||||
}
|
||||
+17
-11
@@ -1,19 +1,20 @@
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { toast } from "sonner";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
||||
import { getHighestImportedVideoFps } from "@/lib/fps/utils";
|
||||
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
|
||||
import type { FrameRate } from "opencut-wasm";
|
||||
import { hasMediaId } from "@/timeline/element-utils";
|
||||
import { frameRatesEqual, getHighestImportedVideoFps } from "@/fps/utils";
|
||||
import { UpdateProjectSettingsCommand } from "@/commands/project";
|
||||
|
||||
export class AddMediaAssetCommand extends Command {
|
||||
private assetId: string;
|
||||
private savedAssets: MediaAsset[] | null = null;
|
||||
private createdAsset: MediaAsset | null = null;
|
||||
private previousProjectFps: number | null = null;
|
||||
private appliedProjectFps: number | null = null;
|
||||
private previousProjectFps: FrameRate | null = null;
|
||||
private appliedProjectFps: FrameRate | null = null;
|
||||
|
||||
constructor(
|
||||
private projectId: string,
|
||||
@@ -53,11 +54,15 @@ export class AddMediaAssetCommand extends Command {
|
||||
assets: currentAssets.filter((asset) => asset.id !== this.assetId),
|
||||
});
|
||||
|
||||
const currentTracks = editor.timeline.getTracks();
|
||||
const currentTracks = editor.scenes.getActiveScene().tracks;
|
||||
const orphanedElements: Array<{ trackId: string; elementId: string }> =
|
||||
[];
|
||||
|
||||
for (const track of currentTracks) {
|
||||
for (const track of [
|
||||
...currentTracks.overlay,
|
||||
currentTracks.main,
|
||||
...currentTracks.audio,
|
||||
]) {
|
||||
for (const element of track.elements) {
|
||||
if (hasMediaId(element) && element.mediaId === this.assetId) {
|
||||
orphanedElements.push({
|
||||
@@ -112,14 +117,15 @@ export class AddMediaAssetCommand extends Command {
|
||||
|
||||
const activeProject = editor.project.getActiveOrNull();
|
||||
if (!activeProject) return;
|
||||
if (activeProject.settings.fps !== this.appliedProjectFps) return;
|
||||
if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return;
|
||||
|
||||
const highestRemainingVideoFps = getHighestImportedVideoFps({
|
||||
mediaAssets: editor.media.getAssets(),
|
||||
});
|
||||
const appliedFpsFloat = this.appliedProjectFps.numerator / this.appliedProjectFps.denominator;
|
||||
if (
|
||||
highestRemainingVideoFps !== null &&
|
||||
highestRemainingVideoFps >= this.appliedProjectFps
|
||||
highestRemainingVideoFps >= appliedFpsFloat
|
||||
) {
|
||||
return;
|
||||
}
|
||||
+111
-99
@@ -1,99 +1,111 @@
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import { videoCache } from "@/services/video-cache/service";
|
||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
|
||||
export class RemoveMediaAssetCommand extends Command {
|
||||
private savedAssets: MediaAsset[] | null = null;
|
||||
private savedTracks: TimelineTrack[] | null = null;
|
||||
private removedAsset: MediaAsset | null = null;
|
||||
|
||||
constructor(
|
||||
private projectId: string,
|
||||
private assetId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const assets = editor.media.getAssets();
|
||||
|
||||
this.savedAssets = [...assets];
|
||||
this.savedTracks = editor.timeline.getTracks();
|
||||
|
||||
this.removedAsset =
|
||||
assets.find((media) => media.id === this.assetId) ?? null;
|
||||
|
||||
if (!this.removedAsset) {
|
||||
console.error("Media asset not found:", this.assetId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.removedAsset.url) {
|
||||
URL.revokeObjectURL(this.removedAsset.url);
|
||||
}
|
||||
if (this.removedAsset.thumbnailUrl) {
|
||||
URL.revokeObjectURL(this.removedAsset.thumbnailUrl);
|
||||
}
|
||||
|
||||
videoCache.clearVideo({ mediaId: this.assetId });
|
||||
|
||||
editor.media.setAssets({
|
||||
assets: assets.filter((media) => media.id !== this.assetId),
|
||||
});
|
||||
|
||||
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
|
||||
|
||||
for (const track of this.savedTracks) {
|
||||
for (const element of track.elements) {
|
||||
if (hasMediaId(element) && element.mediaId === this.assetId) {
|
||||
elementsToRemove.push({ trackId: track.id, elementId: element.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (elementsToRemove.length > 0) {
|
||||
editor.timeline.deleteElements({ elements: elementsToRemove });
|
||||
}
|
||||
|
||||
storageService
|
||||
.deleteMediaAsset({ projectId: this.projectId, id: this.assetId })
|
||||
.catch((error) => {
|
||||
console.error("Failed to delete media item:", error);
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
|
||||
if (this.savedAssets && this.removedAsset) {
|
||||
const restoredAsset: MediaAsset = {
|
||||
...this.removedAsset,
|
||||
url: URL.createObjectURL(this.removedAsset.file),
|
||||
};
|
||||
|
||||
editor.media.setAssets({
|
||||
assets: this.savedAssets.map((a) =>
|
||||
a.id === this.assetId ? restoredAsset : a,
|
||||
),
|
||||
});
|
||||
|
||||
storageService
|
||||
.saveMediaAsset({
|
||||
projectId: this.projectId,
|
||||
mediaAsset: restoredAsset,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to restore media item on undo:", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.savedTracks) {
|
||||
editor.timeline.updateTracks(this.savedTracks);
|
||||
}
|
||||
}
|
||||
}
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import { buildWaveformSourceKey } from "@/media/waveform-summary";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import { videoCache } from "@/services/video-cache/service";
|
||||
import { waveformCache } from "@/services/waveform-cache/service";
|
||||
import { hasMediaId } from "@/timeline/element-utils";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
|
||||
export class RemoveMediaAssetCommand extends Command {
|
||||
private savedAssets: MediaAsset[] | null = null;
|
||||
private savedTracks: SceneTracks | null = null;
|
||||
private removedAsset: MediaAsset | null = null;
|
||||
|
||||
constructor(
|
||||
private projectId: string,
|
||||
private assetId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const assets = editor.media.getAssets();
|
||||
|
||||
this.savedAssets = [...assets];
|
||||
this.savedTracks = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
this.removedAsset =
|
||||
assets.find((media) => media.id === this.assetId) ?? null;
|
||||
|
||||
if (!this.removedAsset) {
|
||||
console.error("Media asset not found:", this.assetId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.removedAsset.url) {
|
||||
URL.revokeObjectURL(this.removedAsset.url);
|
||||
}
|
||||
if (this.removedAsset.thumbnailUrl) {
|
||||
URL.revokeObjectURL(this.removedAsset.thumbnailUrl);
|
||||
}
|
||||
|
||||
videoCache.clearVideo({ mediaId: this.assetId });
|
||||
waveformCache.clearSource({
|
||||
sourceKey: buildWaveformSourceKey({
|
||||
kind: "media",
|
||||
id: this.assetId,
|
||||
}),
|
||||
});
|
||||
|
||||
editor.media.setAssets({
|
||||
assets: assets.filter((media) => media.id !== this.assetId),
|
||||
});
|
||||
|
||||
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
|
||||
|
||||
for (const track of [
|
||||
...this.savedTracks.overlay,
|
||||
this.savedTracks.main,
|
||||
...this.savedTracks.audio,
|
||||
]) {
|
||||
for (const element of track.elements) {
|
||||
if (hasMediaId(element) && element.mediaId === this.assetId) {
|
||||
elementsToRemove.push({ trackId: track.id, elementId: element.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (elementsToRemove.length > 0) {
|
||||
editor.timeline.deleteElements({ elements: elementsToRemove });
|
||||
}
|
||||
|
||||
storageService
|
||||
.deleteMediaAsset({ projectId: this.projectId, id: this.assetId })
|
||||
.catch((error) => {
|
||||
console.error("Failed to delete media item:", error);
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
|
||||
if (this.savedAssets && this.removedAsset) {
|
||||
const restoredAsset: MediaAsset = {
|
||||
...this.removedAsset,
|
||||
url: URL.createObjectURL(this.removedAsset.file),
|
||||
};
|
||||
|
||||
editor.media.setAssets({
|
||||
assets: this.savedAssets.map((a) =>
|
||||
a.id === this.assetId ? restoredAsset : a,
|
||||
),
|
||||
});
|
||||
|
||||
storageService
|
||||
.saveMediaAsset({
|
||||
projectId: this.projectId,
|
||||
mediaAsset: restoredAsset,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to restore media item on undo:", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.savedTracks) {
|
||||
editor.timeline.updateTracks(this.savedTracks);
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-23
@@ -1,23 +1,23 @@
|
||||
export class PreviewTracker<T> {
|
||||
private snapshot: T | null = null;
|
||||
|
||||
begin({ state }: { state: T }): void {
|
||||
if (this.snapshot === null) {
|
||||
this.snapshot = structuredClone(state);
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.snapshot !== null;
|
||||
}
|
||||
|
||||
getSnapshot(): T | null {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
end(): T | null {
|
||||
const snapshot = this.snapshot;
|
||||
this.snapshot = null;
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
export class PreviewTracker<T> {
|
||||
private snapshot: T | null = null;
|
||||
|
||||
begin({ state }: { state: T }): void {
|
||||
if (this.snapshot === null) {
|
||||
this.snapshot = structuredClone(state);
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.snapshot !== null;
|
||||
}
|
||||
|
||||
getSnapshot(): T | null {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
end(): T | null {
|
||||
const snapshot = this.snapshot;
|
||||
this.snapshot = null;
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
+46
-46
@@ -1,46 +1,46 @@
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TProject, TProjectSettings } from "@/lib/project/types";
|
||||
|
||||
export class UpdateProjectSettingsCommand extends Command {
|
||||
private savedSettings: TProjectSettings | null = null;
|
||||
private savedUpdatedAt: Date | null = null;
|
||||
|
||||
constructor(private updates: Partial<TProjectSettings>) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
this.savedSettings = activeProject.settings;
|
||||
this.savedUpdatedAt = activeProject.metadata.updatedAt;
|
||||
|
||||
const updatedProject: TProject = {
|
||||
...activeProject,
|
||||
settings: { ...activeProject.settings, ...this.updates },
|
||||
metadata: { ...activeProject.metadata, updatedAt: new Date() },
|
||||
};
|
||||
|
||||
editor.project.setActiveProject({ project: updatedProject });
|
||||
editor.save.markDirty();
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedSettings || !this.savedUpdatedAt) return;
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject: TProject = {
|
||||
...activeProject,
|
||||
settings: this.savedSettings,
|
||||
metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt },
|
||||
};
|
||||
|
||||
editor.project.setActiveProject({ project: updatedProject });
|
||||
editor.save.markDirty();
|
||||
}
|
||||
}
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TProject, TProjectSettings } from "@/project/types";
|
||||
|
||||
export class UpdateProjectSettingsCommand extends Command {
|
||||
private savedSettings: TProjectSettings | null = null;
|
||||
private savedUpdatedAt: Date | null = null;
|
||||
|
||||
constructor(private updates: Partial<TProjectSettings>) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
this.savedSettings = activeProject.settings;
|
||||
this.savedUpdatedAt = activeProject.metadata.updatedAt;
|
||||
|
||||
const updatedProject: TProject = {
|
||||
...activeProject,
|
||||
settings: { ...activeProject.settings, ...this.updates },
|
||||
metadata: { ...activeProject.metadata, updatedAt: new Date() },
|
||||
};
|
||||
|
||||
editor.project.setActiveProject({ project: updatedProject });
|
||||
editor.save.markDirty();
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedSettings || !this.savedUpdatedAt) return;
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject: TProject = {
|
||||
...activeProject,
|
||||
settings: this.savedSettings,
|
||||
metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt },
|
||||
};
|
||||
|
||||
editor.project.setActiveProject({ project: updatedProject });
|
||||
editor.save.markDirty();
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { buildDefaultScene } from "@/lib/scenes";
|
||||
import type { TScene } from "@/timeline";
|
||||
import { buildDefaultScene } from "@/timeline/scenes";
|
||||
|
||||
export class CreateSceneCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
+59
-59
@@ -1,59 +1,59 @@
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
|
||||
|
||||
export class DeleteSceneCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
private savedActiveSceneId: string | null = null;
|
||||
private deletedScene: TScene | null = null;
|
||||
|
||||
constructor(private sceneId: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const scenes = editor.scenes.getScenes();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
|
||||
this.savedScenes = [...scenes];
|
||||
this.savedActiveSceneId = activeScene?.id ?? null;
|
||||
|
||||
this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null;
|
||||
|
||||
if (!this.deletedScene) {
|
||||
console.error("Scene not found:", this.sceneId);
|
||||
return;
|
||||
}
|
||||
|
||||
const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene });
|
||||
if (!canDelete) {
|
||||
console.error("Cannot delete scene:", reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedScenes = scenes.filter((s) => s.id !== this.sceneId);
|
||||
|
||||
const newActiveScene = getFallbackSceneAfterDelete({
|
||||
scenes: updatedScenes,
|
||||
deletedSceneId: this.sceneId,
|
||||
currentSceneId: activeScene?.id ?? null,
|
||||
});
|
||||
|
||||
editor.scenes.setScenes({
|
||||
scenes: updatedScenes,
|
||||
activeSceneId: newActiveScene?.id,
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedScenes && this.deletedScene) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.scenes.setScenes({
|
||||
scenes: this.savedScenes,
|
||||
activeSceneId: this.savedActiveSceneId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/timeline";
|
||||
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/timeline/scenes";
|
||||
|
||||
export class DeleteSceneCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
private savedActiveSceneId: string | null = null;
|
||||
private deletedScene: TScene | null = null;
|
||||
|
||||
constructor(private sceneId: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const scenes = editor.scenes.getScenes();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
|
||||
this.savedScenes = [...scenes];
|
||||
this.savedActiveSceneId = activeScene?.id ?? null;
|
||||
|
||||
this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null;
|
||||
|
||||
if (!this.deletedScene) {
|
||||
console.error("Scene not found:", this.sceneId);
|
||||
return;
|
||||
}
|
||||
|
||||
const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene });
|
||||
if (!canDelete) {
|
||||
console.error("Cannot delete scene:", reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedScenes = scenes.filter((s) => s.id !== this.sceneId);
|
||||
|
||||
const newActiveScene = getFallbackSceneAfterDelete({
|
||||
scenes: updatedScenes,
|
||||
deletedSceneId: this.sceneId,
|
||||
currentSceneId: activeScene?.id ?? null,
|
||||
});
|
||||
|
||||
editor.scenes.setScenes({
|
||||
scenes: updatedScenes,
|
||||
activeSceneId: newActiveScene?.id,
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedScenes && this.deletedScene) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.scenes.setScenes({
|
||||
scenes: this.savedScenes,
|
||||
activeSceneId: this.savedActiveSceneId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user