mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
109
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b305917ac1 | ||
|
|
6a22a3ecbd | ||
|
|
0dffefdd59 | ||
|
|
cceb835a84 | ||
|
|
f1b45b4328 | ||
|
|
7428dc75cf | ||
|
|
1301d027e9 | ||
|
|
f5f31b43df | ||
|
|
a709af0059 | ||
|
|
75eebf52bc | ||
|
|
1582423dbf | ||
|
|
148d38d7c9 | ||
|
|
3e89d29985 | ||
|
|
729d10592f | ||
|
|
9da365bc01 | ||
|
|
8bdc894690 | ||
|
|
0387bd4c80 | ||
|
|
b1d0b10354 | ||
|
|
94eed96cad | ||
|
|
794d4ae149 | ||
|
|
011f51051c | ||
|
|
c46d28891f | ||
|
|
66e4367bc4 | ||
|
|
8161de00e6 | ||
|
|
eb28942dab | ||
|
|
68f175d3be | ||
|
|
d5c1b7bf0e | ||
|
|
0868d9cf13 | ||
|
|
8a3577d2e7 | ||
|
|
ac43a5a73f | ||
|
|
6312fc5b07 | ||
|
|
2da93aa98f | ||
|
|
f4bd689f51 | ||
|
|
4f7dc5ee3a | ||
|
|
4e8b7d3745 | ||
|
|
ee372266ca | ||
|
|
e10b0a3355 | ||
|
|
3914ea9b71 | ||
|
|
370944f443 | ||
|
|
c839db9234 | ||
|
|
769a7e708a | ||
|
|
1fa5442f21 | ||
|
|
dc0a8e7c96 | ||
|
|
3db30c72e7 | ||
|
|
eca13e3e71 | ||
|
|
1943a4d18f | ||
|
|
3eccc2f9e3 | ||
|
|
368a20ddc2 | ||
|
|
64588ceeea | ||
|
|
8fe66090e7 | ||
|
|
91e1232112 | ||
|
|
f726d95295 | ||
|
|
78a0a27a72 | ||
|
|
b68bf00454 | ||
|
|
cd19a4409f | ||
|
|
f1ed84006a | ||
|
|
8d82ea9c0b | ||
|
|
6aa4ffe462 | ||
|
|
8a54e5d884 | ||
|
|
5e6c26f3c1 | ||
|
|
520c0b3264 | ||
|
|
607eaeacc7 | ||
|
|
6de6c7fd95 | ||
|
|
34c67ef8e8 | ||
|
|
5f2f00dde0 | ||
|
|
6db7ad0696 | ||
|
|
24a0d10a92 | ||
|
|
e3d4f3fa92 | ||
|
|
d85566ab8d | ||
|
|
3191e038d8 | ||
|
|
ba994d8bf9 | ||
|
|
270e2951b1 | ||
|
|
496f7d690d | ||
|
|
11ee0dbf27 | ||
|
|
2c344fa01d | ||
|
|
3d9d669cb9 | ||
|
|
7feb3b238c | ||
|
|
7d4e166678 | ||
|
|
cad88ee4d1 | ||
|
|
36efdf66e7 | ||
|
|
2272598d42 | ||
|
|
5a2aaf3601 | ||
|
|
108d3f4eca | ||
|
|
8230faf082 | ||
|
|
dbd68ff139 | ||
|
|
0d0d737563 | ||
|
|
f06a930fc1 | ||
|
|
532f050a64 | ||
|
|
d422b602c9 | ||
|
|
cbd1b82123 | ||
|
|
4f7d401a97 | ||
|
|
30709d7134 | ||
|
|
93a0afdf64 | ||
|
|
b8979c65d4 | ||
|
|
f47e85c3bb | ||
|
|
e43b859439 | ||
|
|
e4b67094e7 | ||
|
|
79df736431 | ||
|
|
c411edb7c1 | ||
|
|
2776adaae1 | ||
|
|
964ac82d5e | ||
|
|
05243616ea | ||
|
|
ccd552a1c0 | ||
|
|
fcc09e80e2 | ||
|
|
dedb13546a | ||
|
|
c56d6f8188 | ||
|
|
e62227914a | ||
|
|
44d8c4c876 | ||
|
|
20235d0724 |
@@ -0,0 +1,7 @@
|
|||||||
|
*.json
|
||||||
|
*.svg
|
||||||
|
*.xml
|
||||||
|
*.lock
|
||||||
|
**/dist/**
|
||||||
|
**/build/**
|
||||||
|
**/node_modules/**
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
# Agents.md
|
# Agents.md
|
||||||
|
|
||||||
## Apps
|
## Architecture
|
||||||
|
|
||||||
- Web
|
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.
|
||||||
- Desktop
|
|
||||||
|
|
||||||
## 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
|
## 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.
|
- 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",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compositor"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"effects",
|
||||||
|
"gpu",
|
||||||
|
"masks",
|
||||||
|
"serde",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.37"
|
version = "0.4.37"
|
||||||
@@ -982,6 +994,16 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"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]]
|
[[package]]
|
||||||
name = "const-random"
|
name = "const-random"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
@@ -1427,6 +1449,16 @@ version = "1.0.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "effects"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"gpu",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"wgpu",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -2104,6 +2136,8 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2148,7 +2182,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"presser",
|
"presser",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows 0.61.3",
|
"windows 0.62.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3217,6 +3251,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "masks"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"gpu",
|
||||||
|
"wgpu",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maybe-rayon"
|
name = "maybe-rayon"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -3777,10 +3820,16 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opencut-wasm"
|
name = "opencut-wasm"
|
||||||
version = "0.1.3"
|
version = "0.2.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bridge",
|
||||||
|
"compositor",
|
||||||
|
"console_error_panic_hook",
|
||||||
|
"effects",
|
||||||
"gpu",
|
"gpu",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
"masks",
|
||||||
|
"num-traits",
|
||||||
"serde",
|
"serde",
|
||||||
"serde-wasm-bindgen",
|
"serde-wasm-bindgen",
|
||||||
"time",
|
"time",
|
||||||
@@ -5581,6 +5630,7 @@ name = "time"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bridge",
|
"bridge",
|
||||||
|
"num-traits",
|
||||||
"serde",
|
"serde",
|
||||||
"tsify-next",
|
"tsify-next",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -6489,6 +6539,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"wgpu-core-deps-apple",
|
"wgpu-core-deps-apple",
|
||||||
"wgpu-core-deps-emscripten",
|
"wgpu-core-deps-emscripten",
|
||||||
|
"wgpu-core-deps-wasm",
|
||||||
"wgpu-core-deps-windows-linux-android",
|
"wgpu-core-deps-windows-linux-android",
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
"wgpu-naga-bridge",
|
"wgpu-naga-bridge",
|
||||||
@@ -6513,6 +6564,15 @@ dependencies = [
|
|||||||
"wgpu-hal",
|
"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]]
|
[[package]]
|
||||||
name = "wgpu-core-deps-windows-linux-android"
|
name = "wgpu-core-deps-windows-linux-android"
|
||||||
version = "29.0.0"
|
version = "29.0.0"
|
||||||
|
|||||||
+4
-1
@@ -4,5 +4,8 @@ members = [
|
|||||||
"apps/desktop",
|
"apps/desktop",
|
||||||
"rust/crates/time",
|
"rust/crates/time",
|
||||||
"rust/crates/bridge",
|
"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
|
- **Free features**: Most basic CapCut features are now paywalled
|
||||||
- **Simple**: People want editors that are easy to use - CapCut proved that
|
- **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
|
## Project Structure
|
||||||
|
|
||||||
- `apps/web/` – Main Next.js web application
|
- `apps/web/`: Next.js web application
|
||||||
- `apps/desktop/` – Native desktop app (in progress)
|
- `apps/desktop/`: Native desktop app built with GPUI (in progress)
|
||||||
- `src/components/` – UI and editor components
|
- `rust/`: Platform-agnostic core: GPU compositor, effects, masks, and WASM bindings. We're actively migrating business logic here from TypeScript.
|
||||||
- `src/hooks/` – Custom React hooks
|
- `docs/`: Architecture and subsystem documentation
|
||||||
- `src/lib/` – Utility and API logic
|
|
||||||
- `src/stores/` – State management (Zustand, etc.)
|
|
||||||
- `src/types/` – TypeScript types
|
|
||||||
|
|
||||||
## Getting Started
|
## 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.
|
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:
|
1. Build the package once from the repo root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
App, Application, Bounds, Context, SharedString, Window, WindowBounds, WindowOptions, div,
|
div, prelude::*, px, rgb, size, App, Application, Bounds, Context, SharedString, Window,
|
||||||
prelude::*, px, rgb, size,
|
WindowBounds, WindowOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct AppWindow {
|
struct AppWindow {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const changelog = defineCollection({
|
const changelog = defineCollection({
|
||||||
name: "changelog",
|
name: "changelog",
|
||||||
directory: "content/changelog",
|
directory: "src/lib/changelog/entries",
|
||||||
include: "*.md",
|
include: "*.md",
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
content: z.string(),
|
content: z.string(),
|
||||||
@@ -12,6 +12,7 @@ const changelog = defineCollection({
|
|||||||
published: z.boolean().default(true),
|
published: z.boolean().default(true),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
|
summary: z.string().optional(),
|
||||||
changes: z.array(
|
changes: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
@@ -25,7 +26,8 @@ const changelog = defineCollection({
|
|||||||
const sorted = [...publishedDocs].sort((a, b) =>
|
const sorted = [...publishedDocs].sort((a, b) =>
|
||||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
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 };
|
return { ...doc, isLatest };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import type { Config } from "drizzle-kit";
|
import type { Config } from "drizzle-kit";
|
||||||
import * as dotenv from "dotenv";
|
import * as dotenv from "dotenv";
|
||||||
import { webEnv } from "@/lib/env/web";
|
|
||||||
|
|
||||||
// Load the right env file based on environment
|
if (process.env.NODE_ENV === "production") {
|
||||||
if (webEnv.NODE_ENV === "production") {
|
|
||||||
dotenv.config({ path: ".env.production" });
|
dotenv.config({ path: ".env.production" });
|
||||||
} else {
|
} else {
|
||||||
dotenv.config({ path: ".env.local" });
|
dotenv.config({ path: ".env.local" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error("DATABASE_URL is not set");
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
schema: "./src/schema.ts",
|
schema: "./src/lib/db/schema.ts",
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
migrations: {
|
migrations: {
|
||||||
table: "drizzle_migrations",
|
table: "drizzle_migrations",
|
||||||
},
|
},
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: webEnv.DATABASE_URL,
|
url: databaseUrl,
|
||||||
},
|
},
|
||||||
out: "./migrations",
|
out: "./migrations",
|
||||||
strict: webEnv.NODE_ENV === "production",
|
strict: process.env.NODE_ENV === "production",
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@hugeicons/core-free-icons": "^3.1.1",
|
"@hugeicons/core-free-icons": "^3.3.0",
|
||||||
"@hugeicons/react": "^1.1.6",
|
"@hugeicons/react": "^1.1.6",
|
||||||
"@huggingface/transformers": "^3.8.1",
|
"@huggingface/transformers": "^3.8.1",
|
||||||
"@opennextjs/cloudflare": "^1.18.0",
|
"@opennextjs/cloudflare": "^1.18.0",
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"next": "16.1.3",
|
"next": "16.1.3",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"opencut-wasm": "^0.1.3",
|
"opencut-wasm": "^0.2.10",
|
||||||
"pg": "^8.16.2",
|
"pg": "^8.16.2",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|||||||
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 {
|
import {
|
||||||
type KeyboardShortcut,
|
type KeyboardShortcut,
|
||||||
useKeyboardShortcutsHelp,
|
useKeyboardShortcutsHelp,
|
||||||
} from "@/hooks/use-keyboard-shortcuts-help";
|
} from "@/actions/use-keyboard-shortcuts-help";
|
||||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
import { useKeybindingsStore } from "@/actions/keybindings-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
KeybindingConfig,
|
KeybindingConfig,
|
||||||
ShortcutKey,
|
ShortcutKey,
|
||||||
} from "@/lib/actions/keybinding";
|
} from "@/actions/keybinding";
|
||||||
import type { TActionWithOptionalArgs } from "./types";
|
import type { TActionWithOptionalArgs } from "./types";
|
||||||
|
|
||||||
export type TActionCategory =
|
export type TActionCategory =
|
||||||
@@ -82,7 +82,7 @@ export const ACTIONS = {
|
|||||||
category: "editing",
|
category: "editing",
|
||||||
},
|
},
|
||||||
"delete-selected": {
|
"delete-selected": {
|
||||||
description: "Delete selected elements",
|
description: "Delete current selection",
|
||||||
category: "editing",
|
category: "editing",
|
||||||
},
|
},
|
||||||
"copy-selected": {
|
"copy-selected": {
|
||||||
+7
-103
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import type { TActionWithOptionalArgs } from "@/lib/actions";
|
import type { TActionWithOptionalArgs } from "@/actions";
|
||||||
import { getDefaultShortcuts } from "@/lib/actions";
|
import { getDefaultShortcuts } from "@/actions";
|
||||||
import { isTypableDOMElement } from "@/utils/browser";
|
import { isTypableDOMElement } from "@/utils/browser";
|
||||||
import { isAppleDevice } from "@/utils/platform";
|
import { isAppleDevice } from "@/utils/platform";
|
||||||
import type { KeybindingConfig, ShortcutKey } from "@/lib/actions/keybinding";
|
import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding";
|
||||||
import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations";
|
import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations";
|
||||||
|
|
||||||
const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
|
const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
|
||||||
@@ -30,8 +30,8 @@ interface KeybindingsState {
|
|||||||
resetToDefaults: () => void;
|
resetToDefaults: () => void;
|
||||||
importKeybindings: (config: KeybindingConfig) => void;
|
importKeybindings: (config: KeybindingConfig) => void;
|
||||||
exportKeybindings: () => KeybindingConfig;
|
exportKeybindings: () => KeybindingConfig;
|
||||||
openOverlay: (overlayId: string, source: string) => void;
|
openOverlay: (overlayId: string) => void;
|
||||||
closeOverlay: (overlayId: string, source: string) => void;
|
closeOverlay: (overlayId: string) => void;
|
||||||
setLoadingProject: (loading: boolean) => void;
|
setLoadingProject: (loading: boolean) => void;
|
||||||
setIsRecording: (isRecording: boolean) => void;
|
setIsRecording: (isRecording: boolean) => void;
|
||||||
validateKeybinding: (
|
validateKeybinding: (
|
||||||
@@ -56,104 +56,29 @@ export const useKeybindingsStore = create<KeybindingsState>()(
|
|||||||
isLoadingProject: false,
|
isLoadingProject: false,
|
||||||
isRecording: false,
|
isRecording: false,
|
||||||
|
|
||||||
openOverlay: (overlayId, source) =>
|
openOverlay: (overlayId) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const openOverlayIds = s.openOverlayIds.includes(overlayId)
|
const openOverlayIds = s.openOverlayIds.includes(overlayId)
|
||||||
? s.openOverlayIds
|
? s.openOverlayIds
|
||||||
: [...s.openOverlayIds, overlayId];
|
: [...s.openOverlayIds, overlayId];
|
||||||
const nextOverlayDepth = openOverlayIds.length;
|
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 {
|
return {
|
||||||
openOverlayIds,
|
openOverlayIds,
|
||||||
overlayDepth: nextOverlayDepth,
|
overlayDepth: nextOverlayDepth,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
closeOverlay: (overlayId, source) =>
|
closeOverlay: (overlayId) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const openOverlayIds = s.openOverlayIds.filter(
|
const openOverlayIds = s.openOverlayIds.filter(
|
||||||
(id) => id !== overlayId,
|
(id) => id !== overlayId,
|
||||||
);
|
);
|
||||||
const nextOverlayDepth = openOverlayIds.length;
|
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 {
|
return {
|
||||||
openOverlayIds,
|
openOverlayIds,
|
||||||
overlayDepth: nextOverlayDepth,
|
overlayDepth: nextOverlayDepth,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
setLoadingProject: (loading) => {
|
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 });
|
set({ isLoadingProject: loading });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -222,27 +147,6 @@ export const useKeybindingsStore = create<KeybindingsState>()(
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
setIsRecording: (isRecording: boolean) => {
|
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 });
|
set({ isRecording });
|
||||||
},
|
},
|
||||||
|
|
||||||
+3
-1
@@ -2,6 +2,7 @@ import { v2ToV3 } from "./v2-to-v3";
|
|||||||
import { v3ToV4 } from "./v3-to-v4";
|
import { v3ToV4 } from "./v3-to-v4";
|
||||||
import { v4ToV5 } from "./v4-to-v5";
|
import { v4ToV5 } from "./v4-to-v5";
|
||||||
import { v5ToV6 } from "./v5-to-v6";
|
import { v5ToV6 } from "./v5-to-v6";
|
||||||
|
import { v6ToV7 } from "./v6-to-v7";
|
||||||
|
|
||||||
type MigrationFn = ({ state }: { state: unknown }) => unknown;
|
type MigrationFn = ({ state }: { state: unknown }) => unknown;
|
||||||
|
|
||||||
@@ -10,9 +11,10 @@ const migrations: Record<number, MigrationFn> = {
|
|||||||
3: v3ToV4,
|
3: v3ToV4,
|
||||||
4: v4ToV5,
|
4: v4ToV5,
|
||||||
5: v5ToV6,
|
5: v5ToV6,
|
||||||
|
6: v6ToV7,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CURRENT_VERSION = 6;
|
export const CURRENT_VERSION = 7;
|
||||||
|
|
||||||
export function runMigrations({
|
export function runMigrations({
|
||||||
state,
|
state,
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import type { KeybindingConfig, ShortcutKey } from "@/lib/actions/keybinding";
|
import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding";
|
||||||
import type { TActionWithOptionalArgs } from "@/lib/actions";
|
import type { TActionWithOptionalArgs } from "@/actions";
|
||||||
|
|
||||||
interface V2State {
|
interface V2State {
|
||||||
keybindings: KeybindingConfig;
|
keybindings: KeybindingConfig;
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
import type { TActionWithOptionalArgs } from "@/lib/actions";
|
import type { TActionWithOptionalArgs } from "@/actions";
|
||||||
import type { ShortcutKey } from "@/lib/actions/keybinding";
|
import type { ShortcutKey } from "@/actions/keybinding";
|
||||||
import type { KeybindingConfig } from "@/lib/actions/keybinding";
|
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||||
|
|
||||||
interface V3State {
|
interface V3State {
|
||||||
keybindings: KeybindingConfig;
|
keybindings: KeybindingConfig;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { KeybindingConfig } from "@/lib/actions/keybinding";
|
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||||
|
|
||||||
interface V4State {
|
interface V4State {
|
||||||
keybindings: KeybindingConfig;
|
keybindings: KeybindingConfig;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { KeybindingConfig } from "@/lib/actions/keybinding";
|
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||||
|
|
||||||
interface V5State {
|
interface V5State {
|
||||||
keybindings: KeybindingConfig;
|
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>,
|
args?: TArgOfAction<A>,
|
||||||
trigger?: TInvocationTrigger,
|
trigger?: TInvocationTrigger,
|
||||||
) => {
|
) => {
|
||||||
if (trigger === "keypress") {
|
|
||||||
// #region agent log
|
|
||||||
fetch("http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Debug-Session-Id": "3997d9",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
sessionId: "3997d9",
|
|
||||||
runId: "initial",
|
|
||||||
hypothesisId: "H4",
|
|
||||||
location: "actions/registry.ts:invokeAction",
|
|
||||||
message: "Action invoked from keypress",
|
|
||||||
data: {
|
|
||||||
action,
|
|
||||||
handlerCount: boundActions[action]?.length ?? 0,
|
|
||||||
},
|
|
||||||
timestamp: Date.now(),
|
|
||||||
}),
|
|
||||||
}).catch(() => {});
|
|
||||||
// #endregion
|
|
||||||
}
|
|
||||||
boundActions[action]?.forEach((handler) => {
|
boundActions[action]?.forEach((handler) => {
|
||||||
handler(args, trigger);
|
handler(args, trigger);
|
||||||
});
|
});
|
||||||
+2
-2
@@ -5,8 +5,8 @@ import type {
|
|||||||
TActionHandlerOptions,
|
TActionHandlerOptions,
|
||||||
TArgOfAction,
|
TArgOfAction,
|
||||||
TInvocationTrigger,
|
TInvocationTrigger,
|
||||||
} from "@/lib/actions";
|
} from "@/actions";
|
||||||
import { bindAction, unbindAction } from "@/lib/actions";
|
import { bindAction, unbindAction } from "@/actions";
|
||||||
|
|
||||||
export function useActionHandler<A extends TAction>(
|
export function useActionHandler<A extends TAction>(
|
||||||
action: A,
|
action: A,
|
||||||
+80
-61
@@ -1,43 +1,47 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/timeline/timeline-store";
|
||||||
import { useActionHandler } from "@/hooks/actions/use-action-handler";
|
import { useActionHandler } from "@/actions/use-action-handler";
|
||||||
import { useEditor } from "../use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { useElementSelection } from "../timeline/element/use-element-selection";
|
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||||
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
|
import { TICKS_PER_SECOND } from "@/wasm";
|
||||||
import {
|
import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
|
||||||
getElementsAtTime,
|
import { getElementsAtTime, hasMediaId } from "@/timeline";
|
||||||
hasMediaId,
|
import { cancelInteraction } from "@/editor/cancel-interaction";
|
||||||
} from "@/lib/timeline";
|
import { invokeAction } from "@/actions";
|
||||||
import { cancelInteraction } from "@/lib/cancel-interaction";
|
import { canToggleSourceAudio } from "@/timeline/audio-separation";
|
||||||
import { invokeAction } from "@/lib/actions";
|
|
||||||
import { canToggleSourceAudio } from "@/lib/timeline/audio-separation";
|
|
||||||
import {
|
import {
|
||||||
activateScope,
|
activateScope,
|
||||||
clearActiveScope,
|
clearActiveScope,
|
||||||
type ScopeEntry,
|
type ScopeEntry,
|
||||||
} from "@/lib/selection/scope";
|
} from "@/selection/scope";
|
||||||
|
|
||||||
export function useEditorActions() {
|
export function useEditorActions() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const { selectedElements, setElementSelection } = useElementSelection();
|
const { selectedElements, setElementSelection } = useElementSelection();
|
||||||
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
|
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
|
||||||
const clipboard = useTimelineStore((s) => s.clipboard);
|
const selectedMaskPointSelection = useEditor((e) =>
|
||||||
const setClipboard = useTimelineStore((s) => s.setClipboard);
|
e.selection.getSelectedMaskPointSelection(),
|
||||||
|
);
|
||||||
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
||||||
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
||||||
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
||||||
const hasTimelineSelectionRef = useRef(false);
|
const hasTimelineSelectionRef = useRef(false);
|
||||||
const clearTimelineSelectionRef = useRef(() => {});
|
const clearTimelineSelectionRef = useRef(() => {});
|
||||||
|
const clearTimelineActiveSelectionRef = useRef(() => {});
|
||||||
const timelineScopeRef = useRef<ScopeEntry | null>(null);
|
const timelineScopeRef = useRef<ScopeEntry | null>(null);
|
||||||
const hasTimelineSelection =
|
const hasTimelineSelection =
|
||||||
selectedElements.length > 0 || selectedKeyframes.length > 0;
|
selectedElements.length > 0 ||
|
||||||
|
selectedKeyframes.length > 0 ||
|
||||||
|
selectedMaskPointSelection !== null;
|
||||||
|
|
||||||
hasTimelineSelectionRef.current = hasTimelineSelection;
|
hasTimelineSelectionRef.current = hasTimelineSelection;
|
||||||
clearTimelineSelectionRef.current = () => {
|
clearTimelineSelectionRef.current = () => {
|
||||||
setElementSelection({ elements: [] });
|
editor.selection.clearSelection();
|
||||||
clearKeyframeSelection();
|
};
|
||||||
|
clearTimelineActiveSelectionRef.current = () => {
|
||||||
|
editor.selection.clearMostSpecificSelection();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!timelineScopeRef.current) {
|
if (!timelineScopeRef.current) {
|
||||||
@@ -46,6 +50,9 @@ export function useEditorActions() {
|
|||||||
clear: () => {
|
clear: () => {
|
||||||
clearTimelineSelectionRef.current();
|
clearTimelineSelectionRef.current();
|
||||||
},
|
},
|
||||||
|
clearActive: () => {
|
||||||
|
clearTimelineActiveSelectionRef.current();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,10 +117,13 @@ export function useEditorActions() {
|
|||||||
"frame-step-forward",
|
"frame-step-forward",
|
||||||
() => {
|
() => {
|
||||||
const fps = editor.project.getActive().settings.fps;
|
const fps = editor.project.getActive().settings.fps;
|
||||||
|
const ticksPerFrame = Math.round(
|
||||||
|
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||||
|
);
|
||||||
editor.playback.seek({
|
editor.playback.seek({
|
||||||
time: Math.min(
|
time: Math.min(
|
||||||
editor.timeline.getTotalDuration(),
|
editor.timeline.getTotalDuration(),
|
||||||
editor.playback.getCurrentTime() + 1 / fps,
|
editor.playback.getCurrentTime() + ticksPerFrame,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -124,8 +134,11 @@ export function useEditorActions() {
|
|||||||
"frame-step-backward",
|
"frame-step-backward",
|
||||||
() => {
|
() => {
|
||||||
const fps = editor.project.getActive().settings.fps;
|
const fps = editor.project.getActive().settings.fps;
|
||||||
|
const ticksPerFrame = Math.round(
|
||||||
|
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||||
|
);
|
||||||
editor.playback.seek({
|
editor.playback.seek({
|
||||||
time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps),
|
time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
@@ -176,11 +189,12 @@ export function useEditorActions() {
|
|||||||
"split",
|
"split",
|
||||||
() => {
|
() => {
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const elementsToSplit =
|
const elementsToSplit =
|
||||||
selectedElements.length > 0
|
selectedElements.length > 0
|
||||||
? selectedElements
|
? selectedElements
|
||||||
: getElementsAtTime({
|
: getElementsAtTime({
|
||||||
tracks: editor.timeline.getTracks(),
|
tracks,
|
||||||
time: currentTime,
|
time: currentTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,11 +212,12 @@ export function useEditorActions() {
|
|||||||
"split-left",
|
"split-left",
|
||||||
() => {
|
() => {
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const elementsToSplit =
|
const elementsToSplit =
|
||||||
selectedElements.length > 0
|
selectedElements.length > 0
|
||||||
? selectedElements
|
? selectedElements
|
||||||
: getElementsAtTime({
|
: getElementsAtTime({
|
||||||
tracks: editor.timeline.getTracks(),
|
tracks,
|
||||||
time: currentTime,
|
time: currentTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,7 +227,6 @@ export function useEditorActions() {
|
|||||||
elements: elementsToSplit,
|
elements: elementsToSplit,
|
||||||
splitTime: currentTime,
|
splitTime: currentTime,
|
||||||
retainSide: "right",
|
retainSide: "right",
|
||||||
rippleEnabled: rippleEditingEnabled,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (rippleEditingEnabled && rightSideElements.length > 0) {
|
if (rippleEditingEnabled && rightSideElements.length > 0) {
|
||||||
@@ -231,11 +245,12 @@ export function useEditorActions() {
|
|||||||
"split-right",
|
"split-right",
|
||||||
() => {
|
() => {
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const elementsToSplit =
|
const elementsToSplit =
|
||||||
selectedElements.length > 0
|
selectedElements.length > 0
|
||||||
? selectedElements
|
? selectedElements
|
||||||
: getElementsAtTime({
|
: getElementsAtTime({
|
||||||
tracks: editor.timeline.getTracks(),
|
tracks,
|
||||||
time: currentTime,
|
time: currentTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -253,18 +268,36 @@ export function useEditorActions() {
|
|||||||
useActionHandler(
|
useActionHandler(
|
||||||
"delete-selected",
|
"delete-selected",
|
||||||
() => {
|
() => {
|
||||||
if (selectedKeyframes.length > 0) {
|
switch (editor.selection.getActiveSelectionKind()) {
|
||||||
editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
|
case "mask-points":
|
||||||
clearKeyframeSelection();
|
if (!selectedMaskPointSelection) {
|
||||||
return;
|
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,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -290,8 +323,9 @@ export function useEditorActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
|
editor.media
|
||||||
null
|
.getAssets()
|
||||||
|
.find((asset) => asset.id === element.mediaId) ?? null
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
|
if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
|
||||||
@@ -309,7 +343,12 @@ export function useEditorActions() {
|
|||||||
useActionHandler(
|
useActionHandler(
|
||||||
"select-all",
|
"select-all",
|
||||||
() => {
|
() => {
|
||||||
const allElements = editor.timeline.getTracks().flatMap((track) =>
|
const scene = editor.scenes.getActiveScene();
|
||||||
|
const allElements = [
|
||||||
|
...scene.tracks.overlay,
|
||||||
|
scene.tracks.main,
|
||||||
|
...scene.tracks.audio,
|
||||||
|
].flatMap((track) =>
|
||||||
track.elements.map((element) => ({
|
track.elements.map((element) => ({
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
@@ -334,8 +373,7 @@ export function useEditorActions() {
|
|||||||
"deselect-all",
|
"deselect-all",
|
||||||
() => {
|
() => {
|
||||||
if (!clearActiveScope()) {
|
if (!clearActiveScope()) {
|
||||||
setElementSelection({ elements: [] });
|
editor.selection.clearMostSpecificSelection();
|
||||||
clearKeyframeSelection();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
@@ -378,21 +416,7 @@ export function useEditorActions() {
|
|||||||
useActionHandler(
|
useActionHandler(
|
||||||
"copy-selected",
|
"copy-selected",
|
||||||
() => {
|
() => {
|
||||||
if (selectedElements.length === 0) return;
|
editor.clipboard.copy();
|
||||||
|
|
||||||
const results = editor.timeline.getElementsWithTracks({
|
|
||||||
elements: selectedElements,
|
|
||||||
});
|
|
||||||
const items = results.map(({ track, element }) => {
|
|
||||||
const { id: _, ...elementWithoutId } = element;
|
|
||||||
return {
|
|
||||||
trackId: track.id,
|
|
||||||
trackType: track.type,
|
|
||||||
element: elementWithoutId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setClipboard({ items });
|
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -400,12 +424,7 @@ export function useEditorActions() {
|
|||||||
useActionHandler(
|
useActionHandler(
|
||||||
"paste-copied",
|
"paste-copied",
|
||||||
() => {
|
() => {
|
||||||
if (!clipboard?.items.length) return;
|
editor.clipboard.paste();
|
||||||
|
|
||||||
editor.timeline.pasteAtTime({
|
|
||||||
time: editor.playback.getCurrentTime(),
|
|
||||||
clipboardItems: clipboard.items,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { invokeAction } from "@/actions";
|
||||||
|
import { useEditor } from "@/editor/use-editor";
|
||||||
|
import { useKeybindingsStore } from "@/actions/keybindings-store";
|
||||||
|
import { isTypableDOMElement } from "@/utils/browser";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a composable that hooks to the caller component's
|
||||||
|
* lifecycle and hooks to the keyboard events to fire
|
||||||
|
* the appropriate actions based on keybindings
|
||||||
|
*/
|
||||||
|
export function useKeybindingsListener() {
|
||||||
|
const editor = useEditor();
|
||||||
|
const {
|
||||||
|
keybindings,
|
||||||
|
getKeybindingString,
|
||||||
|
overlayDepth,
|
||||||
|
isLoadingProject,
|
||||||
|
isRecording,
|
||||||
|
} = useKeybindingsStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const eventOptions: AddEventListenerOptions = { capture: true };
|
||||||
|
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||||
|
const normalizedKey = (ev.key ?? "").toLowerCase();
|
||||||
|
|
||||||
|
if (overlayDepth > 0 || isLoadingProject || isRecording) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const binding = getKeybindingString(ev);
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
const isTextInput =
|
||||||
|
activeElement instanceof HTMLElement &&
|
||||||
|
isTypableDOMElement({ element: activeElement });
|
||||||
|
const boundAction = binding ? keybindings[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";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
import { useKeybindingsStore } from "@/actions/keybindings-store";
|
||||||
import { ACTIONS, type TActionWithOptionalArgs } from "@/lib/actions";
|
import { ACTIONS, type TActionWithOptionalArgs } from "@/actions";
|
||||||
import {
|
import {
|
||||||
getPlatformAlternateKey,
|
getPlatformAlternateKey,
|
||||||
getPlatformSpecialKey,
|
getPlatformSpecialKey,
|
||||||
@@ -57,6 +57,9 @@ export function useKeyboardShortcutsHelp() {
|
|||||||
[TActionWithOptionalArgs, string[]]
|
[TActionWithOptionalArgs, string[]]
|
||||||
>) {
|
>) {
|
||||||
const actionDef = ACTIONS[action];
|
const actionDef = ACTIONS[action];
|
||||||
|
if (!actionDef) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
result.push({
|
result.push({
|
||||||
id: action,
|
id: action,
|
||||||
keys,
|
keys,
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|||||||
import {
|
import {
|
||||||
composeAnimationValue,
|
composeAnimationValue,
|
||||||
createAnimationBinding,
|
createAnimationBinding,
|
||||||
} from "@/lib/animation/binding-values";
|
} from "@/animation/binding-values";
|
||||||
|
|
||||||
describe("binding values", () => {
|
describe("binding values", () => {
|
||||||
test("formats composed animated colors as hex", () => {
|
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({
|
export function getBezierPoint({
|
||||||
progress,
|
progress,
|
||||||
@@ -68,7 +70,7 @@ export function solveBezierProgressForTime({
|
|||||||
const leftHandle =
|
const leftHandle =
|
||||||
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
|
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 mid = (lower + upper) / 2;
|
||||||
const estimate = getBezierPoint({
|
const estimate = getBezierPoint({
|
||||||
progress: mid,
|
progress: mid,
|
||||||
+17
-8
@@ -12,7 +12,8 @@ import type {
|
|||||||
DiscreteValue,
|
DiscreteValue,
|
||||||
Vector2AnimationBinding,
|
Vector2AnimationBinding,
|
||||||
VectorValue,
|
VectorValue,
|
||||||
} from "@/lib/animation/types";
|
} from "@/animation/types";
|
||||||
|
import { clamp } from "@/utils/math";
|
||||||
|
|
||||||
interface LinearRgba {
|
interface LinearRgba {
|
||||||
r: number;
|
r: number;
|
||||||
@@ -25,10 +26,6 @@ export type AnimationComponentValue = number | DiscreteValue;
|
|||||||
|
|
||||||
const toRgb = converter("rgb");
|
const toRgb = converter("rgb");
|
||||||
|
|
||||||
function clamp01({ value }: { value: number }): number {
|
|
||||||
return Math.max(0, Math.min(1, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function srgbToLinear({ value }: { value: number }): number {
|
function srgbToLinear({ value }: { value: number }): number {
|
||||||
return value <= 0.04045
|
return value <= 0.04045
|
||||||
? value / 12.92
|
? value / 12.92
|
||||||
@@ -36,7 +33,7 @@ function srgbToLinear({ value }: { value: number }): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function linearToSrgb({ 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
|
return clamped <= 0.0031308
|
||||||
? clamped * 12.92
|
? clamped * 12.92
|
||||||
: 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
|
: 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";
|
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({
|
export function getBindingComponentKeys({
|
||||||
kind,
|
kind,
|
||||||
}: {
|
}: {
|
||||||
@@ -234,7 +243,7 @@ export function parseColorToLinearRgba({
|
|||||||
r: srgbToLinear({ value: rgb.r ?? 0 }),
|
r: srgbToLinear({ value: rgb.r ?? 0 }),
|
||||||
g: srgbToLinear({ value: rgb.g ?? 0 }),
|
g: srgbToLinear({ value: rgb.g ?? 0 }),
|
||||||
b: srgbToLinear({ value: rgb.b ?? 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 }),
|
r: linearToSrgb({ value: color.r }),
|
||||||
g: linearToSrgb({ value: color.g }),
|
g: linearToSrgb({ value: color.g }),
|
||||||
b: linearToSrgb({ value: color.b }),
|
b: linearToSrgb({ value: color.b }),
|
||||||
alpha: clamp01({ value: color.a }),
|
alpha: clamp({ value: color.a, min: 0, max: 1 }),
|
||||||
} as const;
|
} as const;
|
||||||
return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
|
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 { ParamValues } from "@/params";
|
||||||
import type { Effect } from "@/lib/effects/types";
|
import type { Effect } from "@/effects/types";
|
||||||
import type {
|
import type {
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
EffectParamPath,
|
EffectParamPath,
|
||||||
} from "@/lib/animation/types";
|
} from "@/animation/types";
|
||||||
import { removeElementKeyframe } from "./keyframes";
|
import { removeElementKeyframe } from "./keyframes";
|
||||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
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 {
|
import type {
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
GraphicParamPath,
|
GraphicParamPath,
|
||||||
} from "@/lib/animation/types";
|
} from "@/animation/types";
|
||||||
import type { ParamValues } from "@/lib/params";
|
import type { ParamValues } from "@/params";
|
||||||
import {
|
import {
|
||||||
getGraphicDefinition,
|
getGraphicDefinition,
|
||||||
resolveGraphicParams,
|
resolveGraphicParams,
|
||||||
} from "@/lib/graphics";
|
} from "@/graphics";
|
||||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||||
|
|
||||||
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
||||||
@@ -12,8 +12,10 @@ export {
|
|||||||
getChannel,
|
getChannel,
|
||||||
removeElementKeyframe,
|
removeElementKeyframe,
|
||||||
retimeElementKeyframe,
|
retimeElementKeyframe,
|
||||||
|
setBindingComponentChannel,
|
||||||
setChannel,
|
setChannel,
|
||||||
splitAnimationsAtTime,
|
splitAnimationsAtTime,
|
||||||
|
updateScalarKeyframeCurve,
|
||||||
upsertElementKeyframe,
|
upsertElementKeyframe,
|
||||||
upsertPathKeyframe,
|
upsertPathKeyframe,
|
||||||
} from "./keyframes";
|
} from "./keyframes";
|
||||||
@@ -46,6 +48,18 @@ export {
|
|||||||
hasKeyframesForPath,
|
hasKeyframesForPath,
|
||||||
} from "./keyframe-query";
|
} from "./keyframe-query";
|
||||||
|
|
||||||
|
export {
|
||||||
|
type EditableScalarChannels,
|
||||||
|
getEditableScalarChannel,
|
||||||
|
getEditableScalarChannels,
|
||||||
|
getScalarKeyframeContext,
|
||||||
|
} from "./graph-channels";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getCurveHandlesForNormalizedCubicBezier,
|
||||||
|
getNormalizedCubicBezierForScalarSegment,
|
||||||
|
} from "./curve-bridge";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
buildGraphicParamPath,
|
buildGraphicParamPath,
|
||||||
isGraphicParamPath,
|
isGraphicParamPath,
|
||||||
@@ -77,5 +91,6 @@ export {
|
|||||||
} from "./property-groups";
|
} from "./property-groups";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
isVectorValue,
|
type EasingMode,
|
||||||
|
getEasingModeForKind,
|
||||||
} from "./binding-values";
|
} from "./binding-values";
|
||||||
+16
-21
@@ -7,8 +7,8 @@ import type {
|
|||||||
ScalarAnimationChannel,
|
ScalarAnimationChannel,
|
||||||
ScalarAnimationKey,
|
ScalarAnimationKey,
|
||||||
ScalarSegmentType,
|
ScalarSegmentType,
|
||||||
} from "@/lib/animation/types";
|
} from "@/animation/types";
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
import { clamp } from "@/utils/math";
|
||||||
import {
|
import {
|
||||||
getBezierPoint,
|
getBezierPoint,
|
||||||
getDefaultLeftHandle,
|
getDefaultLeftHandle,
|
||||||
@@ -35,14 +35,7 @@ function isWithinTimePair({
|
|||||||
leftTime: number;
|
leftTime: number;
|
||||||
rightTime: number;
|
rightTime: number;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return (
|
return time >= leftTime && time <= rightTime;
|
||||||
time >= leftTime - TIME_EPSILON_SECONDS &&
|
|
||||||
time <= rightTime + TIME_EPSILON_SECONDS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp01({ value }: { value: number }): number {
|
|
||||||
return Math.max(0, Math.min(1, value));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function lerpNumber({
|
function lerpNumber({
|
||||||
@@ -70,7 +63,7 @@ function normalizeRightHandle({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
|
const span = Math.max(1, rightKey.time - leftKey.time);
|
||||||
return {
|
return {
|
||||||
dt: Math.min(span, Math.max(0, handle.dt)),
|
dt: Math.min(span, Math.max(0, handle.dt)),
|
||||||
dv: handle.dv,
|
dv: handle.dv,
|
||||||
@@ -90,7 +83,7 @@ function normalizeLeftHandle({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
|
const span = Math.max(1, rightKey.time - leftKey.time);
|
||||||
return {
|
return {
|
||||||
dt: Math.max(-span, Math.min(0, handle.dt)),
|
dt: Math.max(-span, Math.min(0, handle.dt)),
|
||||||
dv: handle.dv,
|
dv: handle.dv,
|
||||||
@@ -190,7 +183,7 @@ function extrapolateScalarEdge({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const span = neighborKey.time - edgeKey.time;
|
const span = neighborKey.time - edgeKey.time;
|
||||||
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
if (span === 0) {
|
||||||
return edgeKey.value;
|
return edgeKey.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,8 +224,8 @@ export function getScalarChannelValueAtTime({
|
|||||||
return fallbackValue;
|
return fallbackValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (time <= firstKey.time + TIME_EPSILON_SECONDS) {
|
if (time <= firstKey.time) {
|
||||||
if (time < firstKey.time - TIME_EPSILON_SECONDS) {
|
if (time < firstKey.time) {
|
||||||
return extrapolateScalarEdge({
|
return extrapolateScalarEdge({
|
||||||
mode: normalizedChannel.extrapolation?.before ?? "hold",
|
mode: normalizedChannel.extrapolation?.before ?? "hold",
|
||||||
edgeKey: firstKey,
|
edgeKey: firstKey,
|
||||||
@@ -244,8 +237,8 @@ export function getScalarChannelValueAtTime({
|
|||||||
return firstKey.value;
|
return firstKey.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (time >= lastKey.time - TIME_EPSILON_SECONDS) {
|
if (time >= lastKey.time) {
|
||||||
if (time > lastKey.time + TIME_EPSILON_SECONDS) {
|
if (time > lastKey.time) {
|
||||||
return extrapolateScalarEdge({
|
return extrapolateScalarEdge({
|
||||||
mode: normalizedChannel.extrapolation?.after ?? "hold",
|
mode: normalizedChannel.extrapolation?.after ?? "hold",
|
||||||
edgeKey: lastKey,
|
edgeKey: lastKey,
|
||||||
@@ -264,7 +257,7 @@ export function getScalarChannelValueAtTime({
|
|||||||
) {
|
) {
|
||||||
const leftKey = normalizedChannel.keys[keyIndex];
|
const leftKey = normalizedChannel.keys[keyIndex];
|
||||||
const rightKey = normalizedChannel.keys[keyIndex + 1];
|
const rightKey = normalizedChannel.keys[keyIndex + 1];
|
||||||
if (Math.abs(time - rightKey.time) <= TIME_EPSILON_SECONDS) {
|
if (time === rightKey.time) {
|
||||||
return rightKey.value;
|
return rightKey.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,12 +276,14 @@ export function getScalarChannelValueAtTime({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const span = rightKey.time - leftKey.time;
|
const span = rightKey.time - leftKey.time;
|
||||||
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
if (span === 0) {
|
||||||
return rightKey.value;
|
return rightKey.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress = clamp01({
|
const progress = clamp({
|
||||||
value: (time - leftKey.time) / span,
|
value: (time - leftKey.time) / span,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
});
|
});
|
||||||
if (leftKey.segmentToNext === "linear") {
|
if (leftKey.segmentToNext === "linear") {
|
||||||
return lerpNumber({
|
return lerpNumber({
|
||||||
@@ -337,7 +332,7 @@ export function getDiscreteChannelValueAtTime({
|
|||||||
});
|
});
|
||||||
let currentValue = fallbackValue;
|
let currentValue = fallbackValue;
|
||||||
for (const key of normalizedChannel.keys) {
|
for (const key of normalizedChannel.keys) {
|
||||||
if (time + TIME_EPSILON_SECONDS < key.time) {
|
if (time < key.time) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
currentValue = key.value;
|
currentValue = key.value;
|
||||||
+3
-7
@@ -1,11 +1,10 @@
|
|||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
|
||||||
import type {
|
import type {
|
||||||
AnimationBindingInstance,
|
AnimationBindingInstance,
|
||||||
AnimationChannel,
|
AnimationChannel,
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
ElementKeyframe,
|
ElementKeyframe,
|
||||||
} from "@/lib/animation/types";
|
} from "@/animation/types";
|
||||||
import {
|
import {
|
||||||
type AnimationComponentValue,
|
type AnimationComponentValue,
|
||||||
composeAnimationValue,
|
composeAnimationValue,
|
||||||
@@ -76,8 +75,7 @@ function getUniqueBindingKeyframeMatches({
|
|||||||
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
|
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
|
||||||
if (
|
if (
|
||||||
!previousMatch ||
|
!previousMatch ||
|
||||||
Math.abs(previousMatch.keyframe.time - match.keyframe.time) >
|
previousMatch.keyframe.time !== match.keyframe.time
|
||||||
TIME_EPSILON_SECONDS
|
|
||||||
) {
|
) {
|
||||||
uniqueMatches.push(match);
|
uniqueMatches.push(match);
|
||||||
continue;
|
continue;
|
||||||
@@ -249,9 +247,7 @@ export function getKeyframeAtTime({
|
|||||||
matches: getBindingKeyframeMatches({
|
matches: getBindingKeyframeMatches({
|
||||||
animations,
|
animations,
|
||||||
binding,
|
binding,
|
||||||
}).filter(({ keyframe }) =>
|
}).filter(({ keyframe }) => keyframe.time === time),
|
||||||
Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
if (!keyframeMatch) {
|
if (!keyframeMatch) {
|
||||||
return null;
|
return null;
|
||||||
@@ -11,9 +11,9 @@ import type {
|
|||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
ScalarAnimationChannel,
|
ScalarAnimationChannel,
|
||||||
ScalarAnimationKey,
|
ScalarAnimationKey,
|
||||||
|
ScalarCurveKeyframePatch,
|
||||||
ScalarSegmentType,
|
ScalarSegmentType,
|
||||||
} from "@/lib/animation/types";
|
} from "@/animation/types";
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import {
|
import {
|
||||||
cloneAnimationBinding,
|
cloneAnimationBinding,
|
||||||
@@ -43,7 +43,7 @@ function isNearlySameTime({
|
|||||||
leftTime: number;
|
leftTime: number;
|
||||||
rightTime: number;
|
rightTime: number;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS;
|
return leftTime === rightTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasChannelKeys({
|
function hasChannelKeys({
|
||||||
@@ -173,7 +173,7 @@ function createScalarKey({
|
|||||||
id: string;
|
id: string;
|
||||||
time: number;
|
time: number;
|
||||||
value: number;
|
value: number;
|
||||||
interpolation: AnimationInterpolation;
|
interpolation?: AnimationInterpolation;
|
||||||
previousKey?: ScalarAnimationKey;
|
previousKey?: ScalarAnimationKey;
|
||||||
}): ScalarAnimationKey {
|
}): ScalarAnimationKey {
|
||||||
return {
|
return {
|
||||||
@@ -183,7 +183,8 @@ function createScalarKey({
|
|||||||
leftHandle: previousKey?.leftHandle,
|
leftHandle: previousKey?.leftHandle,
|
||||||
rightHandle: previousKey?.rightHandle,
|
rightHandle: previousKey?.rightHandle,
|
||||||
segmentToNext:
|
segmentToNext:
|
||||||
previousKey?.segmentToNext ?? getScalarSegmentType({ interpolation }),
|
previousKey?.segmentToNext ??
|
||||||
|
getScalarSegmentType({ interpolation: interpolation ?? "linear" }),
|
||||||
tangentMode: previousKey?.tangentMode ?? "flat",
|
tangentMode: previousKey?.tangentMode ?? "flat",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -209,7 +210,7 @@ function getBinding({
|
|||||||
propertyPath,
|
propertyPath,
|
||||||
}: {
|
}: {
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: string;
|
propertyPath: AnimationPath;
|
||||||
}): AnimationBindingInstance | undefined {
|
}): AnimationBindingInstance | undefined {
|
||||||
return animations?.bindings[propertyPath];
|
return animations?.bindings[propertyPath];
|
||||||
}
|
}
|
||||||
@@ -224,6 +225,16 @@ function getChannelById({
|
|||||||
return animations?.channels[channelId];
|
return animations?.channels[channelId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBindingComponent({
|
||||||
|
binding,
|
||||||
|
componentKey,
|
||||||
|
}: {
|
||||||
|
binding: AnimationBindingInstance;
|
||||||
|
componentKey: string;
|
||||||
|
}) {
|
||||||
|
return binding.components.find((component) => component.key === componentKey) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function getTargetKeyMetadata({
|
function getTargetKeyMetadata({
|
||||||
channel,
|
channel,
|
||||||
time,
|
time,
|
||||||
@@ -322,12 +333,14 @@ function upsertScalarChannelKey({
|
|||||||
time,
|
time,
|
||||||
value,
|
value,
|
||||||
interpolation,
|
interpolation,
|
||||||
|
defaultInterpolation,
|
||||||
keyframeId,
|
keyframeId,
|
||||||
}: {
|
}: {
|
||||||
channel: ScalarAnimationChannel | undefined;
|
channel: ScalarAnimationChannel | undefined;
|
||||||
time: number;
|
time: number;
|
||||||
value: number;
|
value: number;
|
||||||
interpolation: AnimationInterpolation;
|
interpolation?: AnimationInterpolation;
|
||||||
|
defaultInterpolation?: AnimationInterpolation;
|
||||||
keyframeId?: string;
|
keyframeId?: string;
|
||||||
}): ScalarAnimationChannel {
|
}): ScalarAnimationChannel {
|
||||||
const normalizedChannel = normalizeChannel({
|
const normalizedChannel = normalizeChannel({
|
||||||
@@ -342,10 +355,13 @@ function upsertScalarChannelKey({
|
|||||||
time,
|
time,
|
||||||
value,
|
value,
|
||||||
interpolation,
|
interpolation,
|
||||||
previousKey: {
|
previousKey:
|
||||||
...keys[existingIndex],
|
interpolation != null
|
||||||
segmentToNext: getScalarSegmentType({ interpolation }),
|
? {
|
||||||
},
|
...keys[existingIndex],
|
||||||
|
segmentToNext: getScalarSegmentType({ interpolation }),
|
||||||
|
}
|
||||||
|
: keys[existingIndex],
|
||||||
});
|
});
|
||||||
return normalizeChannel({
|
return normalizeChannel({
|
||||||
channel: {
|
channel: {
|
||||||
@@ -366,10 +382,13 @@ function upsertScalarChannelKey({
|
|||||||
time: keys[existingAtTimeIndex].time,
|
time: keys[existingAtTimeIndex].time,
|
||||||
value,
|
value,
|
||||||
interpolation,
|
interpolation,
|
||||||
previousKey: {
|
previousKey:
|
||||||
...keys[existingAtTimeIndex],
|
interpolation != null
|
||||||
segmentToNext: getScalarSegmentType({ interpolation }),
|
? {
|
||||||
},
|
...keys[existingAtTimeIndex],
|
||||||
|
segmentToNext: getScalarSegmentType({ interpolation }),
|
||||||
|
}
|
||||||
|
: keys[existingAtTimeIndex],
|
||||||
});
|
});
|
||||||
return normalizeChannel({
|
return normalizeChannel({
|
||||||
channel: {
|
channel: {
|
||||||
@@ -385,7 +404,7 @@ function upsertScalarChannelKey({
|
|||||||
id: keyframeId ?? generateUUID(),
|
id: keyframeId ?? generateUUID(),
|
||||||
time,
|
time,
|
||||||
value,
|
value,
|
||||||
interpolation,
|
interpolation: interpolation ?? defaultInterpolation,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return normalizeChannel({
|
return normalizeChannel({
|
||||||
@@ -402,7 +421,7 @@ export function getChannel({
|
|||||||
propertyPath,
|
propertyPath,
|
||||||
}: {
|
}: {
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: string;
|
propertyPath: AnimationPath;
|
||||||
}): AnimationChannel | undefined {
|
}): AnimationChannel | undefined {
|
||||||
const binding = getBinding({ animations, propertyPath });
|
const binding = getBinding({ animations, propertyPath });
|
||||||
const primaryChannelId =
|
const primaryChannelId =
|
||||||
@@ -429,9 +448,9 @@ export function upsertPathKeyframe({
|
|||||||
keyframeId?: string;
|
keyframeId?: string;
|
||||||
kind: AnimationBindingKind;
|
kind: AnimationBindingKind;
|
||||||
defaultInterpolation: AnimationInterpolation;
|
defaultInterpolation: AnimationInterpolation;
|
||||||
coerceValue: (value: AnimationValue) => AnimationValue | null;
|
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
|
||||||
}): ElementAnimations | undefined {
|
}): ElementAnimations | undefined {
|
||||||
const coercedValue = coerceValue(value);
|
const coercedValue = coerceValue({ value });
|
||||||
if (coercedValue === null) {
|
if (coercedValue === null) {
|
||||||
return animations;
|
return animations;
|
||||||
}
|
}
|
||||||
@@ -462,9 +481,13 @@ export function upsertPathKeyframe({
|
|||||||
return animations;
|
return animations;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextInterpolation = getInterpolationForBinding({
|
const explicitInterpolation =
|
||||||
|
interpolation != null
|
||||||
|
? getInterpolationForBinding({ kind, interpolation })
|
||||||
|
: undefined;
|
||||||
|
const validatedDefaultInterpolation = getInterpolationForBinding({
|
||||||
kind,
|
kind,
|
||||||
interpolation: interpolation ?? defaultInterpolation,
|
interpolation: defaultInterpolation,
|
||||||
});
|
});
|
||||||
nextAnimations.bindings[propertyPath] = binding;
|
nextAnimations.bindings[propertyPath] = binding;
|
||||||
for (const component of binding.components) {
|
for (const component of binding.components) {
|
||||||
@@ -493,7 +516,8 @@ export function upsertPathKeyframe({
|
|||||||
channel: targetChannel,
|
channel: targetChannel,
|
||||||
time: targetKey.time,
|
time: targetKey.time,
|
||||||
value: nextValue as number,
|
value: nextValue as number,
|
||||||
interpolation: nextInterpolation,
|
interpolation: explicitInterpolation,
|
||||||
|
defaultInterpolation: validatedDefaultInterpolation,
|
||||||
keyframeId: targetKey.id,
|
keyframeId: targetKey.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -536,7 +560,7 @@ export function upsertElementKeyframe({
|
|||||||
keyframeId,
|
keyframeId,
|
||||||
kind: propertyDefinition.kind,
|
kind: propertyDefinition.kind,
|
||||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||||
coerceValue: (nextValue) =>
|
coerceValue: ({ value: nextValue }) =>
|
||||||
coerceAnimationValueForProperty({
|
coerceAnimationValueForProperty({
|
||||||
propertyPath,
|
propertyPath,
|
||||||
value: nextValue,
|
value: nextValue,
|
||||||
@@ -582,7 +606,7 @@ export function upsertKeyframe({
|
|||||||
channel,
|
channel,
|
||||||
time,
|
time,
|
||||||
value,
|
value,
|
||||||
interpolation: interpolation ?? "linear",
|
interpolation,
|
||||||
keyframeId,
|
keyframeId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -651,7 +675,7 @@ export function setChannel({
|
|||||||
channel,
|
channel,
|
||||||
}: {
|
}: {
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: string;
|
propertyPath: AnimationPath;
|
||||||
channel: AnimationChannel | undefined;
|
channel: AnimationChannel | undefined;
|
||||||
}): ElementAnimations | undefined {
|
}): ElementAnimations | undefined {
|
||||||
const binding = getBinding({ animations, propertyPath });
|
const binding = getBinding({ animations, propertyPath });
|
||||||
@@ -659,29 +683,66 @@ export function setChannel({
|
|||||||
return animations;
|
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) {
|
if (binding.components.length !== 1) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`,
|
`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,
|
channel,
|
||||||
});
|
});
|
||||||
return toAnimation({
|
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({
|
export function cloneAnimations({
|
||||||
animations,
|
animations,
|
||||||
shouldRegenerateKeyframeIds = false,
|
shouldRegenerateKeyframeIds = false,
|
||||||
@@ -917,8 +1045,8 @@ function splitScalarChannelAtTime({
|
|||||||
const rightKey = normalizedChannel.keys[keyIndex + 1];
|
const rightKey = normalizedChannel.keys[keyIndex + 1];
|
||||||
if (
|
if (
|
||||||
!(
|
!(
|
||||||
splitTime > leftKey.time + TIME_EPSILON_SECONDS &&
|
splitTime > leftKey.time &&
|
||||||
splitTime < rightKey.time - TIME_EPSILON_SECONDS
|
splitTime < rightKey.time
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
+2
-2
@@ -2,8 +2,8 @@ import type {
|
|||||||
AnimationPropertyGroup,
|
AnimationPropertyGroup,
|
||||||
AnimationPropertyPath,
|
AnimationPropertyPath,
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
} from "@/lib/animation/types";
|
} from "@/animation/types";
|
||||||
import { ANIMATION_PROPERTY_GROUPS } from "@/lib/animation/types";
|
import { ANIMATION_PROPERTY_GROUPS } from "@/animation/types";
|
||||||
import { getKeyframeAtTime } from "./keyframe-query";
|
import { getKeyframeAtTime } from "./keyframe-query";
|
||||||
|
|
||||||
export interface GroupKeyframeRef {
|
export interface GroupKeyframeRef {
|
||||||
+29
-16
@@ -3,21 +3,20 @@ import type {
|
|||||||
AnimationInterpolation,
|
AnimationInterpolation,
|
||||||
AnimationPropertyPath,
|
AnimationPropertyPath,
|
||||||
AnimationValue,
|
AnimationValue,
|
||||||
VectorValue,
|
} from "@/animation/types";
|
||||||
} from "@/lib/animation/types";
|
import { parseColorToLinearRgba } from "./binding-values";
|
||||||
import { isVectorValue, parseColorToLinearRgba } from "./binding-values";
|
import type { TimelineElement } from "@/timeline";
|
||||||
import type { TimelineElement } from "@/lib/timeline";
|
import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
|
||||||
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
|
|
||||||
import {
|
import {
|
||||||
CORNER_RADIUS_MAX,
|
CORNER_RADIUS_MAX,
|
||||||
CORNER_RADIUS_MIN,
|
CORNER_RADIUS_MIN,
|
||||||
} from "@/constants/text-constants";
|
} from "@/text/background";
|
||||||
import {
|
import {
|
||||||
canElementHaveAudio,
|
canElementHaveAudio,
|
||||||
isVisualElement,
|
isVisualElement,
|
||||||
} from "@/lib/timeline/element-utils";
|
} from "@/timeline/element-utils";
|
||||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
||||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
import { DEFAULTS } from "@/timeline/defaults";
|
||||||
import { snapToStep } from "@/utils/math";
|
import { snapToStep } from "@/utils/math";
|
||||||
|
|
||||||
export interface NumericSpec {
|
export interface NumericSpec {
|
||||||
@@ -116,24 +115,38 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
|||||||
AnimationPropertyPath,
|
AnimationPropertyPath,
|
||||||
AnimationPropertyDefinition
|
AnimationPropertyDefinition
|
||||||
> = {
|
> = {
|
||||||
"transform.position": {
|
"transform.positionX": createNumberPropertyDefinition({
|
||||||
kind: "vector2",
|
numericRange: { step: 1 },
|
||||||
defaultInterpolation: "linear",
|
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
isVisualElement(element) ? element.transform.position : null,
|
isVisualElement(element) ? element.transform.position.x : null,
|
||||||
coerceValue: ({ value }) => (isVectorValue(value) ? value : null),
|
|
||||||
setValue: ({ element, value }) =>
|
setValue: ({ element, value }) =>
|
||||||
isVisualElement(element)
|
isVisualElement(element)
|
||||||
? {
|
? {
|
||||||
...element,
|
...element,
|
||||||
transform: {
|
transform: {
|
||||||
...element.transform,
|
...element.transform,
|
||||||
position: value as VectorValue,
|
position: { ...element.transform.position, x: value as number },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: element,
|
: 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({
|
"transform.scaleX": createNumberPropertyDefinition({
|
||||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AnimationColorPropertyPath,
|
||||||
|
AnimationNumericPropertyPath,
|
||||||
|
AnimationPath,
|
||||||
AnimationPropertyPath,
|
AnimationPropertyPath,
|
||||||
|
AnimationValueForPath,
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
} from "@/lib/animation/types";
|
} from "@/animation/types";
|
||||||
import type { Transform } from "@/lib/rendering";
|
import type { Transform } from "@/rendering";
|
||||||
import {
|
import {
|
||||||
type AnimationComponentValue,
|
type AnimationComponentValue,
|
||||||
composeAnimationValue,
|
composeAnimationValue,
|
||||||
@@ -44,12 +48,20 @@ export function resolveTransformAtTime({
|
|||||||
}): Transform {
|
}): Transform {
|
||||||
const safeLocalTime = Math.max(0, localTime);
|
const safeLocalTime = Math.max(0, localTime);
|
||||||
return {
|
return {
|
||||||
position: resolveAnimationPathValueAtTime({
|
position: {
|
||||||
animations,
|
x: resolveAnimationPathValueAtTime({
|
||||||
propertyPath: "transform.position",
|
animations,
|
||||||
localTime: safeLocalTime,
|
propertyPath: "transform.positionX",
|
||||||
fallbackValue: baseTransform.position,
|
localTime: safeLocalTime,
|
||||||
}),
|
fallbackValue: baseTransform.position.x,
|
||||||
|
}),
|
||||||
|
y: resolveAnimationPathValueAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath: "transform.positionY",
|
||||||
|
localTime: safeLocalTime,
|
||||||
|
fallbackValue: baseTransform.position.y,
|
||||||
|
}),
|
||||||
|
},
|
||||||
scaleX: resolveAnimationPathValueAtTime({
|
scaleX: resolveAnimationPathValueAtTime({
|
||||||
animations,
|
animations,
|
||||||
propertyPath: "transform.scaleX",
|
propertyPath: "transform.scaleX",
|
||||||
@@ -96,7 +108,7 @@ export function resolveNumberAtTime({
|
|||||||
}: {
|
}: {
|
||||||
baseValue: number;
|
baseValue: number;
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: AnimationPropertyPath;
|
propertyPath: AnimationNumericPropertyPath;
|
||||||
localTime: number;
|
localTime: number;
|
||||||
}): number {
|
}): number {
|
||||||
return resolveAnimationPathValueAtTime({
|
return resolveAnimationPathValueAtTime({
|
||||||
@@ -115,7 +127,7 @@ export function resolveColorAtTime({
|
|||||||
}: {
|
}: {
|
||||||
baseColor: string;
|
baseColor: string;
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: AnimationPropertyPath;
|
propertyPath: AnimationColorPropertyPath;
|
||||||
localTime: number;
|
localTime: number;
|
||||||
}): string {
|
}): string {
|
||||||
return resolveAnimationPathValueAtTime({
|
return resolveAnimationPathValueAtTime({
|
||||||
@@ -126,19 +138,17 @@ export function resolveColorAtTime({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAnimationPathValueAtTime<
|
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
|
||||||
T extends number | string | boolean | Transform["position"],
|
|
||||||
>({
|
|
||||||
animations,
|
animations,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
localTime,
|
localTime,
|
||||||
fallbackValue,
|
fallbackValue,
|
||||||
}: {
|
}: {
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: string;
|
propertyPath: TPath;
|
||||||
localTime: number;
|
localTime: number;
|
||||||
fallbackValue: T;
|
fallbackValue: AnimationValueForPath<TPath>;
|
||||||
}): T {
|
}): AnimationValueForPath<TPath> {
|
||||||
const binding = animations?.bindings[propertyPath];
|
const binding = animations?.bindings[propertyPath];
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
return fallbackValue;
|
return fallbackValue;
|
||||||
@@ -170,5 +180,5 @@ export function resolveAnimationPathValueAtTime<
|
|||||||
return (composeAnimationValue({
|
return (composeAnimationValue({
|
||||||
binding,
|
binding,
|
||||||
componentValues,
|
componentValues,
|
||||||
}) ?? fallbackValue) as T;
|
}) ?? fallbackValue) as AnimationValueForPath<TPath>;
|
||||||
}
|
}
|
||||||
+17
-17
@@ -3,20 +3,20 @@ import type {
|
|||||||
AnimationInterpolation,
|
AnimationInterpolation,
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
AnimationValue,
|
AnimationValue,
|
||||||
} from "@/lib/animation/types";
|
} from "@/animation/types";
|
||||||
import {
|
import {
|
||||||
parseEffectParamPath,
|
parseEffectParamPath,
|
||||||
isEffectParamPath,
|
isEffectParamPath,
|
||||||
} from "@/lib/animation/effect-param-channel";
|
} from "@/animation/effect-param-channel";
|
||||||
import {
|
import {
|
||||||
isGraphicParamPath,
|
isGraphicParamPath,
|
||||||
parseGraphicParamPath,
|
parseGraphicParamPath,
|
||||||
} from "@/lib/animation/graphic-param-channel";
|
} from "@/animation/graphic-param-channel";
|
||||||
import type { ParamDefinition } from "@/lib/params";
|
import type { ParamDefinition } from "@/params";
|
||||||
import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
|
import { effectsRegistry, registerDefaultEffects } from "@/effects";
|
||||||
import { getGraphicDefinition } from "@/lib/graphics";
|
import { getGraphicDefinition } from "@/graphics";
|
||||||
import type { TimelineElement } from "@/lib/timeline";
|
import type { TimelineElement } from "@/timeline";
|
||||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
import { isVisualElement } from "@/timeline/element-utils";
|
||||||
import { snapToStep } from "@/utils/math";
|
import { snapToStep } from "@/utils/math";
|
||||||
import {
|
import {
|
||||||
coerceAnimationValueForProperty,
|
coerceAnimationValueForProperty,
|
||||||
@@ -32,9 +32,9 @@ export interface AnimationPathDescriptor {
|
|||||||
kind: AnimationBindingKind;
|
kind: AnimationBindingKind;
|
||||||
defaultInterpolation: AnimationInterpolation;
|
defaultInterpolation: AnimationInterpolation;
|
||||||
numericRanges?: Partial<Record<string, NumericSpec>>;
|
numericRanges?: Partial<Record<string, NumericSpec>>;
|
||||||
coerceValue(value: AnimationValue): AnimationValue | null;
|
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
|
||||||
getBaseValue(): AnimationValue | null;
|
getBaseValue: () => AnimationValue | null;
|
||||||
setBaseValue(value: AnimationValue): TimelineElement;
|
setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getParamValueKind({
|
export function getParamValueKind({
|
||||||
@@ -135,9 +135,9 @@ function buildGraphicParamDescriptor({
|
|||||||
kind: getParamValueKind({ param }),
|
kind: getParamValueKind({ param }),
|
||||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||||
numericRanges: getParamNumericRange({ param }),
|
numericRanges: getParamNumericRange({ param }),
|
||||||
coerceValue: (value) => coerceAnimationValueForParam({ param, value }),
|
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
|
||||||
getBaseValue: () => element.params[param.key] ?? param.default,
|
getBaseValue: () => element.params[param.key] ?? param.default,
|
||||||
setBaseValue: (value) => {
|
setBaseValue: ({ value }) => {
|
||||||
const coercedValue = coerceAnimationValueForParam({ param, value });
|
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||||
if (coercedValue === null) {
|
if (coercedValue === null) {
|
||||||
return element;
|
return element;
|
||||||
@@ -183,9 +183,9 @@ function buildEffectParamDescriptor({
|
|||||||
kind: getParamValueKind({ param }),
|
kind: getParamValueKind({ param }),
|
||||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||||
numericRanges: getParamNumericRange({ param }),
|
numericRanges: getParamNumericRange({ param }),
|
||||||
coerceValue: (value) => coerceAnimationValueForParam({ param, value }),
|
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
|
||||||
getBaseValue: () => effect.params[param.key] ?? param.default,
|
getBaseValue: () => effect.params[param.key] ?? param.default,
|
||||||
setBaseValue: (value) => {
|
setBaseValue: ({ value }) => {
|
||||||
const coercedValue = coerceAnimationValueForParam({ param, value });
|
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||||
if (coercedValue === null) {
|
if (coercedValue === null) {
|
||||||
return element;
|
return element;
|
||||||
@@ -239,7 +239,7 @@ export function resolveAnimationTarget({
|
|||||||
kind: propertyDefinition.kind,
|
kind: propertyDefinition.kind,
|
||||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||||
numericRanges: propertyDefinition.numericRanges,
|
numericRanges: propertyDefinition.numericRanges,
|
||||||
coerceValue: (value) =>
|
coerceValue: ({ value }) =>
|
||||||
coerceAnimationValueForProperty({
|
coerceAnimationValueForProperty({
|
||||||
propertyPath: path,
|
propertyPath: path,
|
||||||
value,
|
value,
|
||||||
@@ -249,7 +249,7 @@ export function resolveAnimationTarget({
|
|||||||
element,
|
element,
|
||||||
propertyPath: path,
|
propertyPath: path,
|
||||||
}),
|
}),
|
||||||
setBaseValue: (value) => {
|
setBaseValue: ({ value }) => {
|
||||||
const coercedValue = propertyDefinition.coerceValue({ value });
|
const coercedValue = propertyDefinition.coerceValue({ value });
|
||||||
if (coercedValue === null) {
|
if (coercedValue === null) {
|
||||||
return element;
|
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 = [
|
export const ANIMATION_PROPERTY_PATHS = [
|
||||||
"transform.position",
|
"transform.positionX",
|
||||||
|
"transform.positionY",
|
||||||
"transform.scaleX",
|
"transform.scaleX",
|
||||||
"transform.scaleY",
|
"transform.scaleY",
|
||||||
"transform.rotate",
|
"transform.rotate",
|
||||||
@@ -31,6 +34,35 @@ export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
|
|||||||
export type VectorValue = { x: number; y: number };
|
export type VectorValue = { x: number; y: number };
|
||||||
export type DiscreteValue = boolean | string;
|
export type DiscreteValue = boolean | string;
|
||||||
export type AnimationValue = number | string | boolean | VectorValue;
|
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 ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
|
||||||
export type DiscreteKeyframeInterpolation = "hold";
|
export type DiscreteKeyframeInterpolation = "hold";
|
||||||
@@ -144,6 +176,36 @@ export interface ElementAnimations {
|
|||||||
channels: ElementAnimationChannelMap;
|
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 {
|
export interface ElementKeyframe {
|
||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/auth/server";
|
||||||
import { toNextJsHandler } from "better-auth/next-js";
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
|
||||||
export const { POST, GET } = toNextJsHandler(auth);
|
export const { POST, GET } = toNextJsHandler(auth);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { checkRateLimit } from "@/auth/rate-limit";
|
||||||
|
import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/feedback";
|
||||||
|
|
||||||
|
const submitSchema = z.object({
|
||||||
|
message: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Message is required")
|
||||||
|
.max(MAX_MESSAGE_LENGTH, "Message too long"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const { limited } = await checkRateLimit({ request });
|
||||||
|
if (limited) {
|
||||||
|
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const result = submitSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid input", details: result.error.flatten().fieldErrors },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await submitFeedback(result.data);
|
||||||
|
return NextResponse.json({ entry }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { webEnv } from "@/lib/env/web";
|
import { webEnv } from "@/env/web";
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { checkRateLimit } from "@/lib/rate-limit";
|
import { checkRateLimit } from "@/auth/rate-limit";
|
||||||
|
|
||||||
const searchParamsSchema = z.object({
|
const searchParamsSchema = z.object({
|
||||||
q: z.string().max(500, "Query too long").optional(),
|
q: z.string().max(500, "Query too long").optional(),
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { notFound } from "next/navigation";
|
|||||||
import { BasePage } from "@/app/base-page";
|
import { BasePage } from "@/app/base-page";
|
||||||
import Prose from "@/components/ui/prose";
|
import Prose from "@/components/ui/prose";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
|
import { getPosts, getSinglePost, processHtmlContent } from "@/blog/query";
|
||||||
import type { Author, Post } from "@/lib/blog/types";
|
import type { Author, Post } from "@/blog/types";
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BasePage } from "@/app/base-page";
|
import { BasePage } from "@/app/base-page";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { getPosts } from "@/lib/blog/query";
|
import { getPosts } from "@/blog/query";
|
||||||
import type { Post } from "@/lib/blog/types";
|
import type { Post } from "@/blog/types";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Blog - OpenCut",
|
title: "Blog - OpenCut",
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ import Link from "next/link";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { BasePage } from "@/app/base-page";
|
import { BasePage } from "@/app/base-page";
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||||
import { getReleaseByVersion, getSortedReleases } from "../utils";
|
import { getReleaseByVersion, getSortedReleases } from "@/changelog/utils";
|
||||||
import {
|
import {
|
||||||
ReleaseArticle,
|
ReleaseArticle,
|
||||||
ReleaseMeta,
|
ReleaseMeta,
|
||||||
ReleaseTitle,
|
ReleaseTitle,
|
||||||
ReleaseDescription,
|
ReleaseDescription,
|
||||||
ReleaseChanges,
|
ReleaseChanges,
|
||||||
} from "../components/release";
|
} from "@/changelog/components/release";
|
||||||
import { CopyMarkdownButton } from "../components/copy-markdown-button";
|
import { CopyMarkdownButton } from "@/changelog/components/copy-markdown-button";
|
||||||
|
|
||||||
type Props = { params: Promise<{ version: string }> };
|
type Props = { params: Promise<{ version: string }> };
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { BasePage } from "@/app/base-page";
|
import { BasePage } from "@/app/base-page";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { type Release as ReleaseType, getSortedReleases } from "./utils";
|
import {
|
||||||
|
type Release as ReleaseType,
|
||||||
|
getSortedReleases,
|
||||||
|
} from "@/changelog/utils";
|
||||||
import {
|
import {
|
||||||
ReleaseArticle,
|
ReleaseArticle,
|
||||||
ReleaseMeta,
|
ReleaseMeta,
|
||||||
ReleaseTitle,
|
ReleaseTitle,
|
||||||
ReleaseDescription,
|
ReleaseDescription,
|
||||||
ReleaseChanges,
|
ReleaseChanges,
|
||||||
} from "./components/release";
|
} from "@/changelog/components/release";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Changelog - OpenCut",
|
title: "Changelog - OpenCut",
|
||||||
|
|||||||
@@ -1,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 { GitHubContributeSection } from "@/components/gitHub-contribute-section";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { EXTERNAL_TOOLS } from "@/constants/site-constants";
|
import { EXTERNAL_TOOLS } from "@/site/external-tools";
|
||||||
import { BasePage } from "../base-page";
|
import { BasePage } from "../base-page";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
|||||||
@@ -8,15 +8,30 @@ import {
|
|||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
import { AssetsPanel } from "@/components/editor/panels/assets";
|
import { AssetsPanel } from "@/components/editor/panels/assets";
|
||||||
import { PropertiesPanel } from "@/components/editor/panels/properties";
|
import { PropertiesPanel } from "@/components/editor/panels/properties";
|
||||||
import { Timeline } from "@/components/editor/panels/timeline";
|
import { Timeline } from "@/timeline/components";
|
||||||
import { PreviewPanel } from "@/components/editor/panels/preview";
|
import { PreviewPanel } from "@/preview/components";
|
||||||
import { EditorHeader } from "@/components/editor/editor-header";
|
import { EditorHeader } from "@/components/editor/editor-header";
|
||||||
|
import { EditorNoticeBar } from "@/components/editor/editor-notice-bar";
|
||||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||||
import { Onboarding } from "@/components/editor/onboarding";
|
import { Onboarding } from "@/components/editor/onboarding";
|
||||||
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
|
import { MigrationDialog } from "@/project/components/migration-dialog";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/editor/panel-store";
|
||||||
import { usePasteMedia } from "@/hooks/use-paste-media";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
|
import { usePasteMedia } from "@/media/use-paste-media";
|
||||||
import { MobileGate } from "@/components/editor/mobile-gate";
|
import { MobileGate } from "@/components/editor/mobile-gate";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
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() {
|
export default function Editor() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -26,12 +41,14 @@ export default function Editor() {
|
|||||||
<MobileGate>
|
<MobileGate>
|
||||||
<EditorProvider projectId={projectId}>
|
<EditorProvider projectId={projectId}>
|
||||||
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
||||||
|
<EditorNoticeBar />
|
||||||
<EditorHeader />
|
<EditorHeader />
|
||||||
<div className="min-h-0 min-w-0 flex-1">
|
<div className="min-h-0 min-w-0 flex-1">
|
||||||
<EditorLayout />
|
<EditorLayout />
|
||||||
</div>
|
</div>
|
||||||
<Onboarding />
|
<Onboarding />
|
||||||
<MigrationDialog />
|
<MigrationDialog />
|
||||||
|
<ChangelogNotification />
|
||||||
</div>
|
</div>
|
||||||
</EditorProvider>
|
</EditorProvider>
|
||||||
</MobileGate>
|
</MobileGate>
|
||||||
@@ -41,6 +58,49 @@ export default function Editor() {
|
|||||||
function EditorLayout() {
|
function EditorLayout() {
|
||||||
usePasteMedia();
|
usePasteMedia();
|
||||||
const { panels, setPanel } = usePanelStore();
|
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 (
|
return (
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
@@ -82,7 +142,11 @@ function EditorLayout() {
|
|||||||
minSize={30}
|
minSize={30}
|
||||||
className="min-h-0 min-w-0 flex-1"
|
className="min-h-0 min-w-0 flex-1"
|
||||||
>
|
>
|
||||||
<PreviewPanel />
|
<PreviewPanel
|
||||||
|
overlayControls={overlayControls}
|
||||||
|
overlayInstances={overlaySource.instances}
|
||||||
|
onOverlayVisibilityChange={setOverlayVisibility}
|
||||||
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
--destructive-foreground: hsl(0, 0%, 100%);
|
--destructive-foreground: hsl(0, 0%, 100%);
|
||||||
--constructive: hsl(141, 71%, 48%);
|
--constructive: hsl(141, 71%, 48%);
|
||||||
--constructive-foreground: hsl(0, 0%, 100%);
|
--constructive-foreground: hsl(0, 0%, 100%);
|
||||||
|
--caution: hsl(38, 92%, 50%);
|
||||||
|
--caution-foreground: hsl(0, 0%, 10%);
|
||||||
--border: hsl(0 0% 91%);
|
--border: hsl(0 0% 91%);
|
||||||
--input: hsl(0, 0%, 100%);
|
--input: hsl(0, 0%, 100%);
|
||||||
--ring: hsl(0, 0%, 55%);
|
--ring: hsl(0, 0%, 55%);
|
||||||
@@ -84,6 +86,8 @@
|
|||||||
--border: hsl(0 0% 16%);
|
--border: hsl(0 0% 16%);
|
||||||
--input: hsl(0 0% 5%);
|
--input: hsl(0 0% 5%);
|
||||||
--ring: hsl(0, 0%, 50%);
|
--ring: hsl(0, 0%, 50%);
|
||||||
|
--caution: hsl(38, 92%, 60%);
|
||||||
|
--caution-foreground: hsl(0, 0%, 10%);
|
||||||
--sidebar-background: hsl(0 0% 8%);
|
--sidebar-background: hsl(0 0% 8%);
|
||||||
--sidebar-foreground: hsl(0 0% 95%);
|
--sidebar-foreground: hsl(0 0% 95%);
|
||||||
--sidebar-primary: hsl(0 0% 95%);
|
--sidebar-primary: hsl(0 0% 95%);
|
||||||
@@ -189,6 +193,9 @@
|
|||||||
--color-constructive: var(--constructive);
|
--color-constructive: var(--constructive);
|
||||||
--color-constructive-foreground: var(--constructive-foreground);
|
--color-constructive-foreground: var(--constructive-foreground);
|
||||||
|
|
||||||
|
--color-caution: var(--caution);
|
||||||
|
--color-caution-foreground: var(--caution-foreground);
|
||||||
|
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { ThemeProvider } from "next-themes";
|
|||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
|
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
|
||||||
import { TooltipProvider } from "../components/ui/tooltip";
|
import { TooltipProvider } from "../components/ui/tooltip";
|
||||||
import { baseMetaData } from "./metadata";
|
import { baseMetaData } from "./metadata";
|
||||||
import { BotIdClient } from "botid/client";
|
import { BotIdClient } from "botid/client";
|
||||||
import { webEnv } from "@/lib/env/web";
|
import { webEnv } from "@/env/web";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
const siteFont = Inter({ subsets: ["latin"] });
|
const siteFont = Inter({ subsets: ["latin"] });
|
||||||
@@ -35,13 +36,6 @@ export default function RootLayout({
|
|||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
strategy="beforeInteractive"
|
strategy="beforeInteractive"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* code to figma */}
|
|
||||||
{/* <script
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `(function(){var s=document.createElement('script');s.src='https://mcp.figma.com/mcp/html-to-design/capture.js';document.head.appendChild(s);})();`,
|
|
||||||
}}
|
|
||||||
/> */}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
|
import { SITE_INFO, SITE_URL } from "@/site/brand";
|
||||||
|
|
||||||
export const baseMetaData: Metadata = {
|
export const baseMetaData: Metadata = {
|
||||||
metadataBase: new URL(SITE_URL),
|
metadataBase: new URL(SITE_URL),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Hero } from "@/components/landing/hero";
|
|||||||
import { Header } from "@/components/header";
|
import { Header } from "@/components/header";
|
||||||
import { Footer } from "@/components/footer";
|
import { Footer } from "@/components/footer";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { SITE_URL } from "@/constants/site-constants";
|
import { SITE_URL } from "@/site/brand";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
alternates: {
|
alternates: {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
import { SOCIAL_LINKS } from "@/site/social";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Privacy Policy - OpenCut",
|
title: "Privacy Policy - OpenCut",
|
||||||
|
|||||||
@@ -7,21 +7,21 @@ import type { KeyboardEvent, MouseEvent } from "react";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { EditorCore } from "@/core";
|
import type { EditorCore } from "@/core";
|
||||||
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
|
import { MigrationDialog } from "@/project/components/migration-dialog";
|
||||||
import { StoragePersistenceDialog } from "@/components/editor/dialogs/storage-persistence-dialog";
|
import { StoragePersistenceDialog } from "@/services/storage/components/storage-persistence-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { useProjectsStore } from "./store";
|
import { useProjectsStore } from "./store";
|
||||||
import type {
|
import type {
|
||||||
TProjectMetadata,
|
TProjectMetadata,
|
||||||
TProjectSortKey,
|
TProjectSortKey,
|
||||||
TProjectSortOption,
|
TProjectSortOption,
|
||||||
} from "@/lib/project/types";
|
} from "@/project/types";
|
||||||
import { formatTimeCode } from "opencut-wasm";
|
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
|
||||||
import { formatDate } from "@/utils/date";
|
import { formatDate } from "@/utils/date";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import {
|
import {
|
||||||
@@ -62,11 +62,11 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog";
|
import { DeleteProjectDialog } from "@/project/components/delete-project-dialog";
|
||||||
import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog";
|
import { ProjectInfoDialog } from "@/project/components/project-info-dialog";
|
||||||
import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog";
|
import { RenameProjectDialog } from "@/project/components/rename-project-dialog";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
|
||||||
const formatProjectDuration = ({
|
const formatProjectDuration = ({
|
||||||
duration,
|
duration,
|
||||||
}: {
|
}: {
|
||||||
@@ -76,8 +76,9 @@ const formatProjectDuration = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
|
const durationSeconds = mediaTimeToSeconds({ time: duration });
|
||||||
return formatTimeCode({ timeInSeconds: duration, format }) ?? "";
|
const format = durationSeconds >= 3600 ? "HH:MM:SS" : "MM:SS";
|
||||||
|
return formatTimecode({ time: duration, format }) ?? "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const VIEW_MODE_OPTIONS = [
|
const VIEW_MODE_OPTIONS = [
|
||||||
@@ -106,6 +107,7 @@ export default function ProjectsPage() {
|
|||||||
<div className="bg-background min-h-screen">
|
<div className="bg-background min-h-screen">
|
||||||
<MigrationDialog />
|
<MigrationDialog />
|
||||||
<StoragePersistenceDialog />
|
<StoragePersistenceDialog />
|
||||||
|
<ChangelogNotification />
|
||||||
<ProjectsHeader />
|
<ProjectsHeader />
|
||||||
<ProjectsToolbar projectIds={projectsToDisplay.map((p) => p.id)} />
|
<ProjectsToolbar projectIds={projectsToDisplay.map((p) => p.id)} />
|
||||||
<main className="mx-auto px-4 pt-2 pb-6 flex flex-col gap-4">
|
<main className="mx-auto px-4 pt-2 pb-6 flex flex-col gap-4">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import type { TProjectSortKey } from "@/lib/project/types";
|
import type { TProjectSortKey } from "@/project/types";
|
||||||
|
|
||||||
export type ProjectsViewMode = "grid" | "list";
|
export type ProjectsViewMode = "grid" | "list";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
import { SITE_URL } from "@/constants/site-constants";
|
import { SITE_URL } from "@/site/brand";
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Feed } from "feed";
|
import { Feed } from "feed";
|
||||||
import { getPosts } from "@/lib/blog/query";
|
import { getPosts } from "@/blog/query";
|
||||||
import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
|
import { SITE_INFO, SITE_URL } from "@/site/brand";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SITE_URL } from "@/constants/site-constants";
|
import { SITE_URL } from "@/site/brand";
|
||||||
import { getPosts } from "@/lib/blog/query";
|
import { getPosts } from "@/blog/query";
|
||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BasePage } from "@/app/base-page";
|
import { BasePage } from "@/app/base-page";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { SPONSORS, type Sponsor } from "@/constants/site-constants";
|
import { SPONSORS, type Sponsor } from "@/site/sponsors";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
|
import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
import { SOCIAL_LINKS } from "@/site/social";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Terms of Service - OpenCut",
|
title: "Terms of Service - OpenCut",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
import { webEnv } from "@/lib/env/web";
|
import { webEnv } from "@/env/web";
|
||||||
|
|
||||||
export const { signIn, signUp, useSession } = createAuthClient({
|
export const { signIn, signUp, useSession } = createAuthClient({
|
||||||
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
|
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Ratelimit } from "@upstash/ratelimit";
|
import { Ratelimit } from "@upstash/ratelimit";
|
||||||
import { Redis } from "@upstash/redis";
|
import { Redis } from "@upstash/redis";
|
||||||
import { webEnv } from "@/lib/env/web";
|
import { webEnv } from "@/env/web";
|
||||||
|
|
||||||
const redis = new Redis({
|
const redis = new Redis({
|
||||||
url: webEnv.UPSTASH_REDIS_REST_URL,
|
url: webEnv.UPSTASH_REDIS_REST_URL,
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { betterAuth, type RateLimit } from "better-auth";
|
import { betterAuth, type RateLimit } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { Redis } from "@upstash/redis";
|
import { Redis } from "@upstash/redis";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/db";
|
||||||
import { webEnv } from "@/lib/env/web";
|
import { webEnv } from "@/env/web";
|
||||||
|
|
||||||
const redis = new Redis({
|
const redis = new Redis({
|
||||||
url: webEnv.UPSTASH_REDIS_REST_URL,
|
url: webEnv.UPSTASH_REDIS_REST_URL,
|
||||||
@@ -2,10 +2,9 @@ export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{
|
|||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
}> = [
|
}> = [
|
||||||
{ label: "Light", value: 10 },
|
{ label: "Light", value: 100 },
|
||||||
{ label: "Medium", value: 50 },
|
{ label: "Medium", value: 200 },
|
||||||
{ label: "Heavy", value: 100 },
|
{ label: "Heavy", value: 500 },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10;
|
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,
|
MarblePost,
|
||||||
MarblePostList,
|
MarblePostList,
|
||||||
MarbleTagList,
|
MarbleTagList,
|
||||||
} from "@/lib/blog/types";
|
} from "@/blog/types";
|
||||||
import { unified } from "unified";
|
import { unified } from "unified";
|
||||||
import rehypeParse from "rehype-parse";
|
import rehypeParse from "rehype-parse";
|
||||||
import rehypeStringify from "rehype-stringify";
|
import rehypeStringify from "rehype-stringify";
|
||||||
@@ -47,7 +47,12 @@ const EMPTY_AUTHORS: MarbleAuthorList = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function isMarbleConfigured() {
|
function isMarbleConfigured() {
|
||||||
return !["", "placeholder", "build-placeholder", "your_workspace_key_here"].includes(key);
|
return ![
|
||||||
|
"",
|
||||||
|
"placeholder",
|
||||||
|
"build-placeholder",
|
||||||
|
"your_workspace_key_here",
|
||||||
|
].includes(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchFromMarble<T>({
|
async function fetchFromMarble<T>({
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TCanvasSize } from "@/lib/project/types";
|
import type { TCanvasSize } from "@/project/types";
|
||||||
|
|
||||||
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
|
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
|
||||||
{ width: 1920, height: 1080 },
|
{ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+32
-2
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CheckIcon, ClipboardIcon } from "lucide-react";
|
import { CheckIcon, ClipboardIcon } from "lucide-react";
|
||||||
import { getSectionTitle, groupAndOrderChanges } from "../utils";
|
import {
|
||||||
|
getSectionTitle,
|
||||||
|
groupAndOrderChanges,
|
||||||
|
isSectionCollapsible,
|
||||||
|
} from "../utils";
|
||||||
import type { Change } from "../utils";
|
import type { Change } from "../utils";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -23,6 +27,17 @@ function buildMarkdown({
|
|||||||
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
|
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
|
||||||
|
|
||||||
for (const type of orderedTypes) {
|
for (const type of orderedTypes) {
|
||||||
|
if (isSectionCollapsible({ type })) {
|
||||||
|
lines.push(
|
||||||
|
buildCollapsibleMarkdownSection({
|
||||||
|
title: getSectionTitle({ type }),
|
||||||
|
changes: grouped[type],
|
||||||
|
}),
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
lines.push(`## ${getSectionTitle({ type })}`);
|
lines.push(`## ${getSectionTitle({ type })}`);
|
||||||
for (const change of grouped[type]) {
|
for (const change of grouped[type]) {
|
||||||
lines.push(`- ${change.text}`);
|
lines.push(`- ${change.text}`);
|
||||||
@@ -33,6 +48,18 @@ function buildMarkdown({
|
|||||||
return lines.join("\n").trimEnd();
|
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({
|
export function CopyMarkdownButton({
|
||||||
description,
|
description,
|
||||||
changes,
|
changes,
|
||||||
@@ -54,7 +81,10 @@ export function CopyMarkdownButton({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="text"
|
variant="text"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className={cn("flex items-center gap-1.5", copied && "pointer-events-none")}
|
className={cn(
|
||||||
|
"flex items-center gap-1.5",
|
||||||
|
copied && "pointer-events-none",
|
||||||
|
)}
|
||||||
title="Copy as markdown"
|
title="Copy as markdown"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
+64
-17
@@ -1,8 +1,16 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import { getSectionTitle, groupAndOrderChanges } from "../utils";
|
import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
|
||||||
import type { Release } from "../utils";
|
import {
|
||||||
|
getSectionTitle,
|
||||||
|
groupAndOrderChanges,
|
||||||
|
isSectionCollapsible,
|
||||||
|
type Change,
|
||||||
|
type Release,
|
||||||
|
} from "../utils";
|
||||||
|
import { ArrowRightIcon } from "@hugeicons/core-free-icons";
|
||||||
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
|
|
||||||
export function ReleaseArticle({
|
export function ReleaseArticle({
|
||||||
variant,
|
variant,
|
||||||
@@ -85,22 +93,61 @@ export function ReleaseChanges({ release }: { release: Release }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{orderedTypes.map((type) => (
|
{orderedTypes.map((type) => (
|
||||||
<div key={type} className="flex flex-col gap-1.5">
|
<ReleaseChangeSection key={type} type={type} changes={grouped[type]} />
|
||||||
<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>
|
</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
|
published: true
|
||||||
title: "Editor foundation"
|
title: "Editor foundation"
|
||||||
description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use."
|
description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use."
|
||||||
|
summary: "Properties panel overhaul, 1,000+ fonts, blend modes, a new color picker, and direct manipulation in the preview."
|
||||||
changes:
|
changes:
|
||||||
- type: new
|
- type: new
|
||||||
text: "Rebuilt the properties panel from scratch. Number inputs now support math expressions and click-drag scrubbing instead of just typing values in."
|
text: "Rebuilt the properties panel from scratch. Number inputs now support math expressions and click-drag scrubbing instead of just typing values in."
|
||||||
@@ -4,6 +4,7 @@ date: "2026-03-01"
|
|||||||
published: true
|
published: true
|
||||||
title: "Motion & effects"
|
title: "Motion & effects"
|
||||||
description: "This release adds the foundation for motion and effects, alongside many improvements & fixes."
|
description: "This release adds the foundation for motion and effects, alongside many improvements & fixes."
|
||||||
|
summary: "Keyframe animation, a new effects system with per-clip blur, and ripple editing mode."
|
||||||
changes:
|
changes:
|
||||||
- type: new
|
- type: new
|
||||||
text: "Keyframe animation system for animating element properties over time."
|
text: "Keyframe animation system for animating element properties over time."
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
version: "0.3.0"
|
version: "0.3.0"
|
||||||
date: "2026-03-04"
|
date: "2026-04-15"
|
||||||
published: false
|
published: true
|
||||||
title: "Brand & polish"
|
title: "Masks, animation & more"
|
||||||
description: "This release adds a brand page with downloadable assets, alongside a few fixes and improvements."
|
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:
|
changes:
|
||||||
- type: new
|
- type: new
|
||||||
text: "Added a brand page with downloadable brand assets. Assets are also accessible directly from the header logo context menu."
|
text: "Added a brand page with downloadable brand assets. Assets are also accessible directly from the header logo context menu."
|
||||||
@@ -14,7 +15,7 @@ changes:
|
|||||||
- type: fixed
|
- 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."
|
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
|
- 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."
|
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
|
- 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."
|
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
|
- type: fixed
|
||||||
@@ -39,10 +40,10 @@ changes:
|
|||||||
text: "MP4 exports with audio failed on Firefox. The export now completes successfully regardless of browser."
|
text: "MP4 exports with audio failed on Firefox. The export now completes successfully regardless of browser."
|
||||||
- type: improved
|
- type: improved
|
||||||
text: "Your projects can disappear if your disk runs low on space. OpenCut now asks the browser to protect them."
|
text: "Your projects can disappear if your disk runs low on space. OpenCut now asks the browser to protect them."
|
||||||
- type: fixed
|
- type: improved
|
||||||
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."
|
text: "The properties panel has been redesigned with a tabbed layout."
|
||||||
- type: new
|
- type: new
|
||||||
text: "Volume control for audio and video elements, both in the properties panel and inline on timeline clips."
|
text: "Volume control for audio and video elements."
|
||||||
- type: new
|
- 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."
|
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
|
- type: improved
|
||||||
@@ -50,7 +51,7 @@ changes:
|
|||||||
- type: improved
|
- 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."
|
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
|
- type: fixed
|
||||||
text: "Snap guides didn't show when moving a full-canvas element to the center of the frame."
|
text: "Snap guides in the preview panel didn't show when moving a full-canvas element to the center of the frame."
|
||||||
- type: fixed
|
- 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."
|
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
|
- type: fixed
|
||||||
@@ -58,11 +59,9 @@ changes:
|
|||||||
- type: fixed
|
- 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."
|
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
|
- 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."
|
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
|
- type: improved
|
||||||
text: "The settings panel has been redesigned with a cleaner layout and better organization."
|
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
|
- 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."
|
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
|
- type: fixed
|
||||||
@@ -72,12 +71,44 @@ changes:
|
|||||||
- type: new
|
- type: new
|
||||||
text: "Preview zoom and panning. Zoom into the canvas to work on fine details, and pan around to navigate when zoomed in."
|
text: "Preview zoom and panning. Zoom into the canvas to work on fine details, and pan around to navigate when zoomed in."
|
||||||
- type: new
|
- type: new
|
||||||
text: "Stickers panel now has content. Browse and add logos, country flags, and shapes."
|
text: "Stickers panel now has content. Browse and add country flags and shapes."
|
||||||
- type: improved
|
- type: improved
|
||||||
text: "You can now select multiple assets in the assets panel. Useful for deleting several at once."
|
text: "You can now select multiple assets in the assets panel. Useful for deleting several at once."
|
||||||
- type: fixed
|
- type: fixed
|
||||||
text: "Auto-generated captions were sometimes inaccurate. The underlying issue has been fixed."
|
text: "Auto-generated captions were sometimes inaccurate. The underlying issue has been fixed."
|
||||||
- type: new
|
- type: new
|
||||||
text: "You can now import transcript files to generate captions instead of running auto-transcription."
|
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) {
|
for (const command of this.commands) {
|
||||||
const result = command.execute();
|
const result = command.execute();
|
||||||
if (result?.select !== undefined) {
|
if (result?.selection !== undefined) {
|
||||||
latestSelectionResult = result;
|
latestSelectionResult = result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ export class BatchCommand extends Command {
|
|||||||
|
|
||||||
for (const command of this.commands) {
|
for (const command of this.commands) {
|
||||||
const result = command.redo();
|
const result = command.redo();
|
||||||
if (result?.select !== undefined) {
|
if (result?.selection !== undefined) {
|
||||||
latestSelectionResult = result;
|
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 { EditorCore } from "@/core";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { MediaAsset } from "@/lib/media/types";
|
import type { MediaAsset } from "@/media/types";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import { storageService } from "@/services/storage/service";
|
import { storageService } from "@/services/storage/service";
|
||||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
import type { FrameRate } from "opencut-wasm";
|
||||||
import { getHighestImportedVideoFps } from "@/lib/fps/utils";
|
import { hasMediaId } from "@/timeline/element-utils";
|
||||||
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
|
import { frameRatesEqual, getHighestImportedVideoFps } from "@/fps/utils";
|
||||||
|
import { UpdateProjectSettingsCommand } from "@/commands/project";
|
||||||
|
|
||||||
export class AddMediaAssetCommand extends Command {
|
export class AddMediaAssetCommand extends Command {
|
||||||
private assetId: string;
|
private assetId: string;
|
||||||
private savedAssets: MediaAsset[] | null = null;
|
private savedAssets: MediaAsset[] | null = null;
|
||||||
private createdAsset: MediaAsset | null = null;
|
private createdAsset: MediaAsset | null = null;
|
||||||
private previousProjectFps: number | null = null;
|
private previousProjectFps: FrameRate | null = null;
|
||||||
private appliedProjectFps: number | null = null;
|
private appliedProjectFps: FrameRate | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private projectId: string,
|
private projectId: string,
|
||||||
@@ -53,11 +54,15 @@ export class AddMediaAssetCommand extends Command {
|
|||||||
assets: currentAssets.filter((asset) => asset.id !== this.assetId),
|
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 }> =
|
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) {
|
for (const element of track.elements) {
|
||||||
if (hasMediaId(element) && element.mediaId === this.assetId) {
|
if (hasMediaId(element) && element.mediaId === this.assetId) {
|
||||||
orphanedElements.push({
|
orphanedElements.push({
|
||||||
@@ -112,14 +117,15 @@ export class AddMediaAssetCommand extends Command {
|
|||||||
|
|
||||||
const activeProject = editor.project.getActiveOrNull();
|
const activeProject = editor.project.getActiveOrNull();
|
||||||
if (!activeProject) return;
|
if (!activeProject) return;
|
||||||
if (activeProject.settings.fps !== this.appliedProjectFps) return;
|
if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return;
|
||||||
|
|
||||||
const highestRemainingVideoFps = getHighestImportedVideoFps({
|
const highestRemainingVideoFps = getHighestImportedVideoFps({
|
||||||
mediaAssets: editor.media.getAssets(),
|
mediaAssets: editor.media.getAssets(),
|
||||||
});
|
});
|
||||||
|
const appliedFpsFloat = this.appliedProjectFps.numerator / this.appliedProjectFps.denominator;
|
||||||
if (
|
if (
|
||||||
highestRemainingVideoFps !== null &&
|
highestRemainingVideoFps !== null &&
|
||||||
highestRemainingVideoFps >= this.appliedProjectFps
|
highestRemainingVideoFps >= appliedFpsFloat
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
+19
-7
@@ -1,14 +1,16 @@
|
|||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { MediaAsset } from "@/lib/media/types";
|
import type { MediaAsset } from "@/media/types";
|
||||||
|
import { buildWaveformSourceKey } from "@/media/waveform-summary";
|
||||||
import { storageService } from "@/services/storage/service";
|
import { storageService } from "@/services/storage/service";
|
||||||
import { videoCache } from "@/services/video-cache/service";
|
import { videoCache } from "@/services/video-cache/service";
|
||||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
import { waveformCache } from "@/services/waveform-cache/service";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import { hasMediaId } from "@/timeline/element-utils";
|
||||||
|
import type { SceneTracks } from "@/timeline";
|
||||||
|
|
||||||
export class RemoveMediaAssetCommand extends Command {
|
export class RemoveMediaAssetCommand extends Command {
|
||||||
private savedAssets: MediaAsset[] | null = null;
|
private savedAssets: MediaAsset[] | null = null;
|
||||||
private savedTracks: TimelineTrack[] | null = null;
|
private savedTracks: SceneTracks | null = null;
|
||||||
private removedAsset: MediaAsset | null = null;
|
private removedAsset: MediaAsset | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -23,7 +25,7 @@ export class RemoveMediaAssetCommand extends Command {
|
|||||||
const assets = editor.media.getAssets();
|
const assets = editor.media.getAssets();
|
||||||
|
|
||||||
this.savedAssets = [...assets];
|
this.savedAssets = [...assets];
|
||||||
this.savedTracks = editor.timeline.getTracks();
|
this.savedTracks = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
this.removedAsset =
|
this.removedAsset =
|
||||||
assets.find((media) => media.id === this.assetId) ?? null;
|
assets.find((media) => media.id === this.assetId) ?? null;
|
||||||
@@ -41,6 +43,12 @@ export class RemoveMediaAssetCommand extends Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
videoCache.clearVideo({ mediaId: this.assetId });
|
videoCache.clearVideo({ mediaId: this.assetId });
|
||||||
|
waveformCache.clearSource({
|
||||||
|
sourceKey: buildWaveformSourceKey({
|
||||||
|
kind: "media",
|
||||||
|
id: this.assetId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
editor.media.setAssets({
|
editor.media.setAssets({
|
||||||
assets: assets.filter((media) => media.id !== this.assetId),
|
assets: assets.filter((media) => media.id !== this.assetId),
|
||||||
@@ -48,7 +56,11 @@ export class RemoveMediaAssetCommand extends Command {
|
|||||||
|
|
||||||
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
|
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
|
||||||
|
|
||||||
for (const track of this.savedTracks) {
|
for (const track of [
|
||||||
|
...this.savedTracks.overlay,
|
||||||
|
this.savedTracks.main,
|
||||||
|
...this.savedTracks.audio,
|
||||||
|
]) {
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
if (hasMediaId(element) && element.mediaId === this.assetId) {
|
if (hasMediaId(element) && element.mediaId === this.assetId) {
|
||||||
elementsToRemove.push({ trackId: track.id, elementId: element.id });
|
elementsToRemove.push({ trackId: track.id, elementId: element.id });
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { TProject, TProjectSettings } from "@/lib/project/types";
|
import type { TProject, TProjectSettings } from "@/project/types";
|
||||||
|
|
||||||
export class UpdateProjectSettingsCommand extends Command {
|
export class UpdateProjectSettingsCommand extends Command {
|
||||||
private savedSettings: TProjectSettings | null = null;
|
private savedSettings: TProjectSettings | null = null;
|
||||||
+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 { EditorCore } from "@/core";
|
||||||
import type { TScene } from "@/lib/timeline";
|
import type { TScene } from "@/timeline";
|
||||||
import { buildDefaultScene } from "@/lib/scenes";
|
import { buildDefaultScene } from "@/timeline/scenes";
|
||||||
|
|
||||||
export class CreateSceneCommand extends Command {
|
export class CreateSceneCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
+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 { EditorCore } from "@/core";
|
||||||
import type { TScene } from "@/lib/timeline";
|
import type { TScene } from "@/timeline";
|
||||||
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
|
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/timeline/scenes";
|
||||||
|
|
||||||
export class DeleteSceneCommand extends Command {
|
export class DeleteSceneCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user