mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02d957582b | ||
|
|
ab982b888e | ||
|
|
6ac2116daa | ||
|
|
79cc7d2159 | ||
|
|
8ab3aeba7c | ||
|
|
0b7597b31f | ||
|
|
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 |
@@ -21,3 +21,17 @@ Each app is a frontend that calls into Rust. Logic is never duplicated between a
|
||||
|
||||
- Read components before using them. They may already apply classes, which affects what you need to pass and how to override them.
|
||||
|
||||
### TypeScript
|
||||
|
||||
Function signatures should make the call site readable and let the function evolve without breaking callers. Positional parameters fail both: `formatTime(30, 24)` hides which number is which, and adding, removing, or reordering an argument silently breaks every caller whose types happen to still line up. A single destructured object fixes both at once - each argument names itself at the call site, and the shape can grow without churn. So signatures default to one object parameter:
|
||||
|
||||
```tsx
|
||||
// ❌ meaning depends on order; the shape can't evolve without touching every caller
|
||||
function formatTime(seconds: number, fps: number) { ... }
|
||||
|
||||
// ✅ each argument names itself; fields can be added, reordered, or made optional freely
|
||||
function formatTime({ seconds, fps }: { seconds: number; fps: number }) { ... }
|
||||
```
|
||||
|
||||
The one real exception is type predicates (`element is VideoElement`) — the language requires a positional subject, so the reasoning above doesn't get to apply.
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -3820,7 +3820,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opencut-wasm"
|
||||
version = "0.2.5"
|
||||
version = "0.2.9"
|
||||
dependencies = [
|
||||
"bridge",
|
||||
"compositor",
|
||||
|
||||
@@ -85,6 +85,19 @@ If you want to get ready for `apps/desktop`, see [`apps/desktop/README.md`](apps
|
||||
|
||||
Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package.
|
||||
|
||||
**Prerequisites** — install these once before anything else:
|
||||
|
||||
```bash
|
||||
# Rust toolchain
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
# build the WASM package
|
||||
cargo install wasm-pack
|
||||
|
||||
# reruns the build on file changes, used by bun dev:wasm
|
||||
cargo install cargo-watch
|
||||
```
|
||||
|
||||
1. Build the package once from the repo root:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use gpui::{
|
||||
App, Application, Bounds, Context, SharedString, Window, WindowBounds, WindowOptions, div,
|
||||
prelude::*, px, rgb, size,
|
||||
div, prelude::*, px, rgb, size, App, Application, Bounds, Context, SharedString, Window,
|
||||
WindowBounds, WindowOptions,
|
||||
};
|
||||
|
||||
struct AppWindow {
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import type { Config } from "drizzle-kit";
|
||||
import * as dotenv from "dotenv";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
|
||||
if (webEnv.NODE_ENV === "production") {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
dotenv.config({ path: ".env.production" });
|
||||
} else {
|
||||
dotenv.config({ path: ".env.local" });
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
}
|
||||
|
||||
export default {
|
||||
schema: "./src/schema.ts",
|
||||
schema: "./src/lib/db/schema.ts",
|
||||
dialect: "postgresql",
|
||||
migrations: {
|
||||
table: "drizzle_migrations",
|
||||
},
|
||||
dbCredentials: {
|
||||
url: webEnv.DATABASE_URL,
|
||||
url: databaseUrl,
|
||||
},
|
||||
out: "./migrations",
|
||||
strict: webEnv.NODE_ENV === "production",
|
||||
strict: process.env.NODE_ENV === "production",
|
||||
} satisfies Config;
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"opencut-wasm": "^0.2.5",
|
||||
"opencut-wasm": "^0.2.9",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
+2
-2
@@ -5,8 +5,8 @@ import { toast } from "sonner";
|
||||
import {
|
||||
type KeyboardShortcut,
|
||||
useKeyboardShortcutsHelp,
|
||||
} from "@/hooks/use-keyboard-shortcuts-help";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
} from "@/actions/use-keyboard-shortcuts-help";
|
||||
import { useKeybindingsStore } from "@/actions/keybindings-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -1,209 +1,209 @@
|
||||
import type {
|
||||
KeybindingConfig,
|
||||
ShortcutKey,
|
||||
} from "@/lib/actions/keybinding";
|
||||
import type { TActionWithOptionalArgs } from "./types";
|
||||
|
||||
export type TActionCategory =
|
||||
| "playback"
|
||||
| "navigation"
|
||||
| "editing"
|
||||
| "selection"
|
||||
| "history"
|
||||
| "timeline"
|
||||
| "controls"
|
||||
| "assets";
|
||||
|
||||
export interface TActionBaseDefinition {
|
||||
description: string;
|
||||
category: TActionCategory;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TActionDefinition extends TActionBaseDefinition {
|
||||
defaultShortcuts?: readonly ShortcutKey[];
|
||||
}
|
||||
|
||||
export const ACTIONS = {
|
||||
"toggle-play": {
|
||||
description: "Play/Pause",
|
||||
category: "playback",
|
||||
},
|
||||
"stop-playback": {
|
||||
description: "Stop playback",
|
||||
category: "playback",
|
||||
},
|
||||
"seek-forward": {
|
||||
description: "Seek forward 1 second",
|
||||
category: "playback",
|
||||
args: { seconds: "number" },
|
||||
},
|
||||
"seek-backward": {
|
||||
description: "Seek backward 1 second",
|
||||
category: "playback",
|
||||
args: { seconds: "number" },
|
||||
},
|
||||
"frame-step-forward": {
|
||||
description: "Frame step forward",
|
||||
category: "navigation",
|
||||
},
|
||||
"frame-step-backward": {
|
||||
description: "Frame step backward",
|
||||
category: "navigation",
|
||||
},
|
||||
"jump-forward": {
|
||||
description: "Jump forward 5 seconds",
|
||||
category: "navigation",
|
||||
args: { seconds: "number" },
|
||||
},
|
||||
"jump-backward": {
|
||||
description: "Jump backward 5 seconds",
|
||||
category: "navigation",
|
||||
args: { seconds: "number" },
|
||||
},
|
||||
"goto-start": {
|
||||
description: "Go to timeline start",
|
||||
category: "navigation",
|
||||
},
|
||||
"goto-end": {
|
||||
description: "Go to timeline end",
|
||||
category: "navigation",
|
||||
},
|
||||
split: {
|
||||
description: "Split elements at playhead",
|
||||
category: "editing",
|
||||
},
|
||||
"split-left": {
|
||||
description: "Split and remove left",
|
||||
category: "editing",
|
||||
},
|
||||
"split-right": {
|
||||
description: "Split and remove right",
|
||||
category: "editing",
|
||||
},
|
||||
"delete-selected": {
|
||||
description: "Delete selected elements",
|
||||
category: "editing",
|
||||
},
|
||||
"copy-selected": {
|
||||
description: "Copy selected elements",
|
||||
category: "editing",
|
||||
},
|
||||
"paste-copied": {
|
||||
description: "Paste elements at playhead",
|
||||
category: "editing",
|
||||
},
|
||||
"toggle-snapping": {
|
||||
description: "Toggle snapping",
|
||||
category: "editing",
|
||||
},
|
||||
"toggle-ripple-editing": {
|
||||
description: "Toggle ripple editing",
|
||||
category: "editing",
|
||||
},
|
||||
"toggle-source-audio": {
|
||||
description: "Extract or recover source audio",
|
||||
category: "editing",
|
||||
},
|
||||
"select-all": {
|
||||
description: "Select all elements",
|
||||
category: "selection",
|
||||
},
|
||||
"cancel-interaction": {
|
||||
description: "Cancel current interaction",
|
||||
category: "controls",
|
||||
},
|
||||
"deselect-all": {
|
||||
description: "Deselect all elements",
|
||||
category: "selection",
|
||||
},
|
||||
"duplicate-selected": {
|
||||
description: "Duplicate selected element",
|
||||
category: "selection",
|
||||
},
|
||||
"toggle-elements-muted-selected": {
|
||||
description: "Mute/unmute selected elements",
|
||||
category: "selection",
|
||||
},
|
||||
"toggle-elements-visibility-selected": {
|
||||
description: "Show/hide selected elements",
|
||||
category: "selection",
|
||||
},
|
||||
"toggle-bookmark": {
|
||||
description: "Toggle bookmark at playhead",
|
||||
category: "timeline",
|
||||
},
|
||||
undo: {
|
||||
description: "Undo",
|
||||
category: "history",
|
||||
},
|
||||
redo: {
|
||||
description: "Redo",
|
||||
category: "history",
|
||||
},
|
||||
"remove-media-asset": {
|
||||
description: "Remove media asset",
|
||||
category: "assets",
|
||||
args: { projectId: "string", assetId: "string" },
|
||||
},
|
||||
"remove-media-assets": {
|
||||
description: "Remove media assets",
|
||||
category: "assets",
|
||||
args: { projectId: "string", assetIds: "string[]" },
|
||||
},
|
||||
} as const satisfies Record<string, TActionBaseDefinition>;
|
||||
|
||||
export type TAction = keyof typeof ACTIONS;
|
||||
|
||||
const ACTION_DEFAULT_SHORTCUTS = {
|
||||
"toggle-play": ["space", "k"],
|
||||
"seek-forward": ["l"],
|
||||
"seek-backward": ["j"],
|
||||
"frame-step-forward": ["right"],
|
||||
"frame-step-backward": ["left"],
|
||||
"jump-forward": ["shift+right"],
|
||||
"jump-backward": ["shift+left"],
|
||||
"goto-start": ["home", "enter"],
|
||||
"goto-end": ["end"],
|
||||
split: ["s"],
|
||||
"split-left": ["q"],
|
||||
"split-right": ["w"],
|
||||
"delete-selected": ["backspace", "delete"],
|
||||
"copy-selected": ["ctrl+c"],
|
||||
"paste-copied": ["ctrl+v"],
|
||||
"toggle-snapping": ["n"],
|
||||
"select-all": ["ctrl+a"],
|
||||
"cancel-interaction": ["escape"],
|
||||
"duplicate-selected": ["ctrl+d"],
|
||||
undo: ["ctrl+z"],
|
||||
redo: ["ctrl+shift+z", "ctrl+y"],
|
||||
} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
|
||||
|
||||
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
|
||||
Record<TAction, readonly ShortcutKey[]>
|
||||
> = ACTION_DEFAULT_SHORTCUTS;
|
||||
|
||||
export function getActionDefinition({
|
||||
action,
|
||||
}: {
|
||||
action: TAction;
|
||||
}): TActionDefinition {
|
||||
return {
|
||||
...ACTIONS[action],
|
||||
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultShortcuts(): KeybindingConfig {
|
||||
const shortcuts: KeybindingConfig = {};
|
||||
|
||||
for (const [action, defaultShortcuts] of Object.entries(
|
||||
ACTION_DEFAULT_SHORTCUTS,
|
||||
) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
|
||||
for (const shortcut of defaultShortcuts) {
|
||||
shortcuts[shortcut] = action;
|
||||
}
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
}
|
||||
import type {
|
||||
KeybindingConfig,
|
||||
ShortcutKey,
|
||||
} from "@/actions/keybinding";
|
||||
import type { TActionWithOptionalArgs } from "./types";
|
||||
|
||||
export type TActionCategory =
|
||||
| "playback"
|
||||
| "navigation"
|
||||
| "editing"
|
||||
| "selection"
|
||||
| "history"
|
||||
| "timeline"
|
||||
| "controls"
|
||||
| "assets";
|
||||
|
||||
export interface TActionBaseDefinition {
|
||||
description: string;
|
||||
category: TActionCategory;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TActionDefinition extends TActionBaseDefinition {
|
||||
defaultShortcuts?: readonly ShortcutKey[];
|
||||
}
|
||||
|
||||
export const ACTIONS = {
|
||||
"toggle-play": {
|
||||
description: "Play/Pause",
|
||||
category: "playback",
|
||||
},
|
||||
"stop-playback": {
|
||||
description: "Stop playback",
|
||||
category: "playback",
|
||||
},
|
||||
"seek-forward": {
|
||||
description: "Seek forward 1 second",
|
||||
category: "playback",
|
||||
args: { seconds: "number" },
|
||||
},
|
||||
"seek-backward": {
|
||||
description: "Seek backward 1 second",
|
||||
category: "playback",
|
||||
args: { seconds: "number" },
|
||||
},
|
||||
"frame-step-forward": {
|
||||
description: "Frame step forward",
|
||||
category: "navigation",
|
||||
},
|
||||
"frame-step-backward": {
|
||||
description: "Frame step backward",
|
||||
category: "navigation",
|
||||
},
|
||||
"jump-forward": {
|
||||
description: "Jump forward 5 seconds",
|
||||
category: "navigation",
|
||||
args: { seconds: "number" },
|
||||
},
|
||||
"jump-backward": {
|
||||
description: "Jump backward 5 seconds",
|
||||
category: "navigation",
|
||||
args: { seconds: "number" },
|
||||
},
|
||||
"goto-start": {
|
||||
description: "Go to timeline start",
|
||||
category: "navigation",
|
||||
},
|
||||
"goto-end": {
|
||||
description: "Go to timeline end",
|
||||
category: "navigation",
|
||||
},
|
||||
split: {
|
||||
description: "Split elements at playhead",
|
||||
category: "editing",
|
||||
},
|
||||
"split-left": {
|
||||
description: "Split and remove left",
|
||||
category: "editing",
|
||||
},
|
||||
"split-right": {
|
||||
description: "Split and remove right",
|
||||
category: "editing",
|
||||
},
|
||||
"delete-selected": {
|
||||
description: "Delete current selection",
|
||||
category: "editing",
|
||||
},
|
||||
"copy-selected": {
|
||||
description: "Copy selected elements",
|
||||
category: "editing",
|
||||
},
|
||||
"paste-copied": {
|
||||
description: "Paste elements at playhead",
|
||||
category: "editing",
|
||||
},
|
||||
"toggle-snapping": {
|
||||
description: "Toggle snapping",
|
||||
category: "editing",
|
||||
},
|
||||
"toggle-ripple-editing": {
|
||||
description: "Toggle ripple editing",
|
||||
category: "editing",
|
||||
},
|
||||
"toggle-source-audio": {
|
||||
description: "Extract or recover source audio",
|
||||
category: "editing",
|
||||
},
|
||||
"select-all": {
|
||||
description: "Select all elements",
|
||||
category: "selection",
|
||||
},
|
||||
"cancel-interaction": {
|
||||
description: "Cancel current interaction",
|
||||
category: "controls",
|
||||
},
|
||||
"deselect-all": {
|
||||
description: "Deselect all elements",
|
||||
category: "selection",
|
||||
},
|
||||
"duplicate-selected": {
|
||||
description: "Duplicate selected element",
|
||||
category: "selection",
|
||||
},
|
||||
"toggle-elements-muted-selected": {
|
||||
description: "Mute/unmute selected elements",
|
||||
category: "selection",
|
||||
},
|
||||
"toggle-elements-visibility-selected": {
|
||||
description: "Show/hide selected elements",
|
||||
category: "selection",
|
||||
},
|
||||
"toggle-bookmark": {
|
||||
description: "Toggle bookmark at playhead",
|
||||
category: "timeline",
|
||||
},
|
||||
undo: {
|
||||
description: "Undo",
|
||||
category: "history",
|
||||
},
|
||||
redo: {
|
||||
description: "Redo",
|
||||
category: "history",
|
||||
},
|
||||
"remove-media-asset": {
|
||||
description: "Remove media asset",
|
||||
category: "assets",
|
||||
args: { projectId: "string", assetId: "string" },
|
||||
},
|
||||
"remove-media-assets": {
|
||||
description: "Remove media assets",
|
||||
category: "assets",
|
||||
args: { projectId: "string", assetIds: "string[]" },
|
||||
},
|
||||
} as const satisfies Record<string, TActionBaseDefinition>;
|
||||
|
||||
export type TAction = keyof typeof ACTIONS;
|
||||
|
||||
const ACTION_DEFAULT_SHORTCUTS = {
|
||||
"toggle-play": ["space", "k"],
|
||||
"seek-forward": ["l"],
|
||||
"seek-backward": ["j"],
|
||||
"frame-step-forward": ["right"],
|
||||
"frame-step-backward": ["left"],
|
||||
"jump-forward": ["shift+right"],
|
||||
"jump-backward": ["shift+left"],
|
||||
"goto-start": ["home", "enter"],
|
||||
"goto-end": ["end"],
|
||||
split: ["s"],
|
||||
"split-left": ["q"],
|
||||
"split-right": ["w"],
|
||||
"delete-selected": ["backspace", "delete"],
|
||||
"copy-selected": ["ctrl+c"],
|
||||
"paste-copied": ["ctrl+v"],
|
||||
"toggle-snapping": ["n"],
|
||||
"select-all": ["ctrl+a"],
|
||||
"cancel-interaction": ["escape"],
|
||||
"duplicate-selected": ["ctrl+d"],
|
||||
undo: ["ctrl+z"],
|
||||
redo: ["ctrl+shift+z", "ctrl+y"],
|
||||
} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
|
||||
|
||||
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
|
||||
Record<TAction, readonly ShortcutKey[]>
|
||||
> = ACTION_DEFAULT_SHORTCUTS;
|
||||
|
||||
export function getActionDefinition({
|
||||
action,
|
||||
}: {
|
||||
action: TAction;
|
||||
}): TActionDefinition {
|
||||
return {
|
||||
...ACTIONS[action],
|
||||
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultShortcuts(): KeybindingConfig {
|
||||
const shortcuts: KeybindingConfig = {};
|
||||
|
||||
for (const [action, defaultShortcuts] of Object.entries(
|
||||
ACTION_DEFAULT_SHORTCUTS,
|
||||
) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
|
||||
for (const shortcut of defaultShortcuts) {
|
||||
shortcuts[shortcut] = action;
|
||||
}
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
}
|
||||
+3
-3
@@ -2,11 +2,11 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { TActionWithOptionalArgs } from "@/lib/actions";
|
||||
import { getDefaultShortcuts } from "@/lib/actions";
|
||||
import type { TActionWithOptionalArgs } from "@/actions";
|
||||
import { getDefaultShortcuts } from "@/actions";
|
||||
import { isTypableDOMElement } from "@/utils/browser";
|
||||
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";
|
||||
|
||||
const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
|
||||
+3
-1
@@ -2,6 +2,7 @@ import { v2ToV3 } from "./v2-to-v3";
|
||||
import { v3ToV4 } from "./v3-to-v4";
|
||||
import { v4ToV5 } from "./v4-to-v5";
|
||||
import { v5ToV6 } from "./v5-to-v6";
|
||||
import { v6ToV7 } from "./v6-to-v7";
|
||||
|
||||
type MigrationFn = ({ state }: { state: unknown }) => unknown;
|
||||
|
||||
@@ -10,9 +11,10 @@ const migrations: Record<number, MigrationFn> = {
|
||||
3: v3ToV4,
|
||||
4: v4ToV5,
|
||||
5: v5ToV6,
|
||||
6: v6ToV7,
|
||||
};
|
||||
|
||||
export const CURRENT_VERSION = 6;
|
||||
export const CURRENT_VERSION = 7;
|
||||
|
||||
export function runMigrations({
|
||||
state,
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import type { KeybindingConfig, ShortcutKey } from "@/lib/actions/keybinding";
|
||||
import type { TActionWithOptionalArgs } from "@/lib/actions";
|
||||
import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding";
|
||||
import type { TActionWithOptionalArgs } from "@/actions";
|
||||
|
||||
interface V2State {
|
||||
keybindings: KeybindingConfig;
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import type { TActionWithOptionalArgs } from "@/lib/actions";
|
||||
import type { ShortcutKey } from "@/lib/actions/keybinding";
|
||||
import type { KeybindingConfig } from "@/lib/actions/keybinding";
|
||||
import type { TActionWithOptionalArgs } from "@/actions";
|
||||
import type { ShortcutKey } from "@/actions/keybinding";
|
||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||
|
||||
interface V3State {
|
||||
keybindings: KeybindingConfig;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { KeybindingConfig } from "@/lib/actions/keybinding";
|
||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||
|
||||
interface V4State {
|
||||
keybindings: KeybindingConfig;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { KeybindingConfig } from "@/lib/actions/keybinding";
|
||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||
|
||||
interface V5State {
|
||||
keybindings: KeybindingConfig;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||
|
||||
interface V6State {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
}
|
||||
|
||||
export function v6ToV7({ state }: { state: unknown }): unknown {
|
||||
const v6 = state as V6State;
|
||||
const keybindings = { ...v6.keybindings };
|
||||
|
||||
for (const key of Object.keys(keybindings) as Array<keyof KeybindingConfig>) {
|
||||
if (keybindings[key] === ("split-element" as never)) {
|
||||
keybindings[key] = "split";
|
||||
}
|
||||
}
|
||||
|
||||
return { ...v6, keybindings };
|
||||
}
|
||||
+2
-2
@@ -5,8 +5,8 @@ import type {
|
||||
TActionHandlerOptions,
|
||||
TArgOfAction,
|
||||
TInvocationTrigger,
|
||||
} from "@/lib/actions";
|
||||
import { bindAction, unbindAction } from "@/lib/actions";
|
||||
} from "@/actions";
|
||||
import { bindAction, unbindAction } from "@/actions";
|
||||
|
||||
export function useActionHandler<A extends TAction>(
|
||||
action: A,
|
||||
+55
-26
@@ -1,39 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useActionHandler } from "@/hooks/actions/use-action-handler";
|
||||
import { useEditor } from "../use-editor";
|
||||
import { useElementSelection } from "../timeline/element/use-element-selection";
|
||||
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
|
||||
import { getElementsAtTime, hasMediaId } from "@/lib/timeline";
|
||||
import { cancelInteraction } from "@/lib/cancel-interaction";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { canToggleSourceAudio } from "@/lib/timeline/audio-separation";
|
||||
import { useTimelineStore } from "@/timeline/timeline-store";
|
||||
import { useActionHandler } from "@/actions/use-action-handler";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
|
||||
import { getElementsAtTime, hasMediaId } from "@/timeline";
|
||||
import { cancelInteraction } from "@/editor/cancel-interaction";
|
||||
import { invokeAction } from "@/actions";
|
||||
import { canToggleSourceAudio } from "@/timeline/audio-separation";
|
||||
import {
|
||||
activateScope,
|
||||
clearActiveScope,
|
||||
type ScopeEntry,
|
||||
} from "@/lib/selection/scope";
|
||||
} from "@/selection/scope";
|
||||
|
||||
export function useEditorActions() {
|
||||
const editor = useEditor();
|
||||
const { selectedElements, setElementSelection } = useElementSelection();
|
||||
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
|
||||
const selectedMaskPointSelection = useEditor((e) =>
|
||||
e.selection.getSelectedMaskPointSelection(),
|
||||
);
|
||||
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
||||
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
||||
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
||||
const hasTimelineSelectionRef = useRef(false);
|
||||
const clearTimelineSelectionRef = useRef(() => {});
|
||||
const clearTimelineActiveSelectionRef = useRef(() => {});
|
||||
const timelineScopeRef = useRef<ScopeEntry | null>(null);
|
||||
const hasTimelineSelection =
|
||||
selectedElements.length > 0 || selectedKeyframes.length > 0;
|
||||
selectedElements.length > 0 ||
|
||||
selectedKeyframes.length > 0 ||
|
||||
selectedMaskPointSelection !== null;
|
||||
|
||||
hasTimelineSelectionRef.current = hasTimelineSelection;
|
||||
clearTimelineSelectionRef.current = () => {
|
||||
setElementSelection({ elements: [] });
|
||||
clearKeyframeSelection();
|
||||
editor.selection.clearSelection();
|
||||
};
|
||||
clearTimelineActiveSelectionRef.current = () => {
|
||||
editor.selection.clearMostSpecificSelection();
|
||||
};
|
||||
|
||||
if (!timelineScopeRef.current) {
|
||||
@@ -42,6 +50,9 @@ export function useEditorActions() {
|
||||
clear: () => {
|
||||
clearTimelineSelectionRef.current();
|
||||
},
|
||||
clearActive: () => {
|
||||
clearTimelineActiveSelectionRef.current();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -257,17 +268,36 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"delete-selected",
|
||||
() => {
|
||||
if (selectedKeyframes.length > 0) {
|
||||
editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
|
||||
clearKeyframeSelection();
|
||||
return;
|
||||
switch (editor.selection.getActiveSelectionKind()) {
|
||||
case "mask-points":
|
||||
if (!selectedMaskPointSelection) {
|
||||
return;
|
||||
}
|
||||
editor.timeline.deleteCustomMaskPoints({
|
||||
trackId: selectedMaskPointSelection.trackId,
|
||||
elementId: selectedMaskPointSelection.elementId,
|
||||
maskId: selectedMaskPointSelection.maskId,
|
||||
pointIds: selectedMaskPointSelection.pointIds,
|
||||
});
|
||||
return;
|
||||
case "keyframes":
|
||||
if (selectedKeyframes.length === 0) {
|
||||
return;
|
||||
}
|
||||
editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
|
||||
clearKeyframeSelection();
|
||||
return;
|
||||
case "elements":
|
||||
if (selectedElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
editor.timeline.deleteElements({
|
||||
elements: selectedElements,
|
||||
});
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
if (selectedElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
editor.timeline.deleteElements({
|
||||
elements: selectedElements,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -343,8 +373,7 @@ export function useEditorActions() {
|
||||
"deselect-all",
|
||||
() => {
|
||||
if (!clearActiveScope()) {
|
||||
setElementSelection({ elements: [] });
|
||||
clearKeyframeSelection();
|
||||
editor.selection.clearMostSpecificSelection();
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { invokeAction } from "@/actions";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useKeybindingsStore } from "@/actions/keybindings-store";
|
||||
import { isTypableDOMElement } from "@/utils/browser";
|
||||
|
||||
/**
|
||||
+5
-2
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { ACTIONS, type TActionWithOptionalArgs } from "@/lib/actions";
|
||||
import { useKeybindingsStore } from "@/actions/keybindings-store";
|
||||
import { ACTIONS, type TActionWithOptionalArgs } from "@/actions";
|
||||
import {
|
||||
getPlatformAlternateKey,
|
||||
getPlatformSpecialKey,
|
||||
@@ -57,6 +57,9 @@ export function useKeyboardShortcutsHelp() {
|
||||
[TActionWithOptionalArgs, string[]]
|
||||
>) {
|
||||
const actionDef = ACTIONS[action];
|
||||
if (!actionDef) {
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
id: action,
|
||||
keys,
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
coerceAnimationParamValue,
|
||||
getAnimationParamDefaultInterpolation,
|
||||
getAnimationParamNumericRange,
|
||||
getAnimationParamValueKind,
|
||||
} from "@/animation/animated-params";
|
||||
|
||||
describe("animated params", () => {
|
||||
test("snaps and clamps number params", () => {
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
param: {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.25,
|
||||
},
|
||||
value: 0.62,
|
||||
}),
|
||||
).toBe(0.5);
|
||||
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
param: {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.25,
|
||||
},
|
||||
value: 1.2,
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test("rejects NaN and non-number values for number params", () => {
|
||||
const param = {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
type: "number" as const,
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.25,
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: Number.NaN })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: "0.5" })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: true })).toBeNull();
|
||||
});
|
||||
|
||||
test("passthrough with step <= 0 guard", () => {
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
param: {
|
||||
key: "x",
|
||||
label: "X",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
step: 0,
|
||||
},
|
||||
value: 0.123,
|
||||
}),
|
||||
).toBe(0.123);
|
||||
});
|
||||
|
||||
test("accepts valid select values", () => {
|
||||
const param = {
|
||||
key: "blend",
|
||||
label: "Blend",
|
||||
type: "select" as const,
|
||||
default: "normal",
|
||||
options: [
|
||||
{ value: "normal", label: "Normal" },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
],
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal");
|
||||
expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply");
|
||||
});
|
||||
|
||||
test("rejects select values outside the allowed options", () => {
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
param: {
|
||||
key: "blend",
|
||||
label: "Blend",
|
||||
type: "select",
|
||||
default: "normal",
|
||||
options: [
|
||||
{ value: "normal", label: "Normal" },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
],
|
||||
},
|
||||
value: "screen",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects non-string select values", () => {
|
||||
const param = {
|
||||
key: "blend",
|
||||
label: "Blend",
|
||||
type: "select" as const,
|
||||
default: "normal",
|
||||
options: [{ value: "normal", label: "Normal" }],
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: 42 })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: undefined })).toBeNull();
|
||||
});
|
||||
|
||||
test("boolean params accept booleans and reject other types", () => {
|
||||
const param = {
|
||||
key: "visible",
|
||||
label: "Visible",
|
||||
type: "boolean" as const,
|
||||
default: true,
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: true })).toBe(true);
|
||||
expect(coerceAnimationParamValue({ param, value: false })).toBe(false);
|
||||
expect(coerceAnimationParamValue({ param, value: 1 })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: "true" })).toBeNull();
|
||||
});
|
||||
|
||||
test("color params accept strings and reject other types", () => {
|
||||
const param = {
|
||||
key: "fill",
|
||||
label: "Fill",
|
||||
type: "color" as const,
|
||||
default: "#ffffff",
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: "#ff0000" })).toBe("#ff0000");
|
||||
expect(coerceAnimationParamValue({ param, value: 0xff0000 })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
|
||||
});
|
||||
|
||||
test("getAnimationParamValueKind maps param type to binding kind", () => {
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
param: {
|
||||
key: "n",
|
||||
label: "N",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
step: 1,
|
||||
},
|
||||
}),
|
||||
).toBe("number");
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||
}),
|
||||
).toBe("color");
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||
}),
|
||||
).toBe("discrete");
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
param: {
|
||||
key: "s",
|
||||
label: "S",
|
||||
type: "select",
|
||||
default: "a",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
}),
|
||||
).toBe("discrete");
|
||||
});
|
||||
|
||||
test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => {
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
param: {
|
||||
key: "n",
|
||||
label: "N",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
step: 1,
|
||||
},
|
||||
}),
|
||||
).toBe("linear");
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||
}),
|
||||
).toBe("linear");
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||
}),
|
||||
).toBe("hold");
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
param: {
|
||||
key: "s",
|
||||
label: "S",
|
||||
type: "select",
|
||||
default: "a",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
}),
|
||||
).toBe("hold");
|
||||
});
|
||||
|
||||
test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => {
|
||||
expect(
|
||||
getAnimationParamNumericRange({
|
||||
param: {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
type: "number",
|
||||
default: 0.5,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
},
|
||||
}),
|
||||
).toEqual({ min: 0, max: 1, step: 0.1 });
|
||||
expect(
|
||||
getAnimationParamNumericRange({
|
||||
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getAnimationParamNumericRange({
|
||||
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
composeAnimationValue,
|
||||
createAnimationBinding,
|
||||
} from "@/lib/animation/binding-values";
|
||||
} from "@/animation/binding-values";
|
||||
|
||||
describe("binding values", () => {
|
||||
test("formats composed animated colors as hex", () => {
|
||||
@@ -0,0 +1,83 @@
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import type { ParamDefinition } from "@/model/decorations/param-values";
|
||||
import type { DynamicAnimationPathValue, NumericSpec } from "./types";
|
||||
|
||||
export function getAnimationParamValueKind({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): "number" | "color" | "discrete" {
|
||||
if (param.type === "number") {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return "color";
|
||||
}
|
||||
|
||||
return "discrete";
|
||||
}
|
||||
|
||||
export function getAnimationParamDefaultInterpolation({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): "linear" | "hold" {
|
||||
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
||||
}
|
||||
|
||||
export function getAnimationParamNumericRange({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): NumericSpec | undefined {
|
||||
if (param.type !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
step: param.step,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `coerceAnimationParamValue` accepts `unknown` rather than a narrow
|
||||
* `DynamicAnimationPathValue` because it doubles as a runtime gate for
|
||||
* untrusted inputs (persisted state, paste payloads, programmatic updates).
|
||||
* The caller does not need to pre-validate; null means "this value cannot live
|
||||
* on this param".
|
||||
*/
|
||||
export function coerceAnimationParamValue({
|
||||
param,
|
||||
value,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
value: unknown;
|
||||
}): DynamicAnimationPathValue | null {
|
||||
if (param.type === "number") {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steppedValue = snapToStep({ value, step: param.step });
|
||||
const minValue = param.min;
|
||||
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
if (param.type === "boolean") {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return param.options.some((option) => option.value === value) ? value : null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ScalarAnimationKey } from "@/lib/animation/types";
|
||||
import type { ScalarAnimationKey } from "@/model/decorations/animations";
|
||||
|
||||
const BEZIER_SOLVE_ITERATIONS = 20;
|
||||
|
||||
+1
-14
@@ -1,18 +1,5 @@
|
||||
import { converter, formatHex, formatHex8, parse } from "culori";
|
||||
import type {
|
||||
AnimationBindingComponent,
|
||||
AnimationBindingOfKind,
|
||||
AnimationBindingInstance,
|
||||
AnimationBindingKind,
|
||||
ColorAnimationBinding,
|
||||
DiscreteAnimationBinding,
|
||||
NumberAnimationBinding,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
DiscreteValue,
|
||||
Vector2AnimationBinding,
|
||||
VectorValue,
|
||||
} from "@/lib/animation/types";
|
||||
import type { AnimationBindingComponent, AnimationBindingInstance, AnimationBindingKind, AnimationBindingOfKind, AnimationPath, AnimationValue, ColorAnimationBinding, DiscreteAnimationBinding, DiscreteValue, NumberAnimationBinding, Vector2AnimationBinding, VectorValue } from "@/model/decorations/animations";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
interface LinearRgba {
|
||||
@@ -1,12 +1,8 @@
|
||||
import {
|
||||
getDefaultLeftHandle,
|
||||
getDefaultRightHandle,
|
||||
} from "@/lib/animation/bezier";
|
||||
import type {
|
||||
CurveHandle,
|
||||
NormalizedCubicBezier,
|
||||
ScalarAnimationKey,
|
||||
} from "@/lib/animation/types";
|
||||
} from "@/animation/bezier";
|
||||
import type { CurveHandle, NormalizedCubicBezier, ScalarAnimationKey } from "@/model/decorations/animations";
|
||||
|
||||
const VALUE_EPSILON = 1e-6;
|
||||
|
||||
+10
-11
@@ -1,9 +1,5 @@
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type { Effect } from "@/lib/effects/types";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
EffectParamPath,
|
||||
} from "@/lib/animation/types";
|
||||
import type { EffectParamPath, ElementAnimations } from "@/model/decorations/animations";
|
||||
import type { ParamValues } from "@/model/decorations/param-values";
|
||||
import { removeElementKeyframe } from "./keyframes";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
@@ -56,23 +52,26 @@ export function parseEffectParamPath({
|
||||
}
|
||||
|
||||
export function resolveEffectParamsAtTime({
|
||||
effect,
|
||||
effectId,
|
||||
params,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
effect: Effect;
|
||||
effectId: string;
|
||||
params: ParamValues;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
const resolved: ParamValues = {};
|
||||
|
||||
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
|
||||
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
|
||||
for (const [paramKey, staticValue] of Object.entries(params)) {
|
||||
const path = buildEffectParamPath({ effectId, paramKey });
|
||||
resolved[paramKey] = animations?.bindings[path]
|
||||
? resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: path,
|
||||
localTime,
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: staticValue,
|
||||
})
|
||||
: staticValue;
|
||||
+2
-8
@@ -1,11 +1,5 @@
|
||||
import type {
|
||||
AnimationBindingInstance,
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
ScalarAnimationChannel,
|
||||
ScalarGraphChannel,
|
||||
ScalarGraphKeyframeContext,
|
||||
} from "@/lib/animation/types";
|
||||
import type { AnimationBindingInstance, AnimationPath, ElementAnimations, ScalarAnimationChannel } from "@/model/decorations/animations";
|
||||
import type { ScalarGraphChannel, ScalarGraphKeyframeContext } from "@/animation/types";
|
||||
|
||||
export interface EditableScalarChannels {
|
||||
binding: AnimationBindingInstance;
|
||||
+13
-24
@@ -1,12 +1,5 @@
|
||||
import type {
|
||||
ElementAnimations,
|
||||
GraphicParamPath,
|
||||
} from "@/lib/animation/types";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import {
|
||||
getGraphicDefinition,
|
||||
resolveGraphicParams,
|
||||
} from "@/lib/graphics";
|
||||
import type { ElementAnimations, GraphicParamPath } from "@/model/decorations/animations";
|
||||
import type { ParamDefinition, ParamValues } from "@/model/decorations/param-values";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
||||
@@ -39,33 +32,29 @@ export function parseGraphicParamPath({
|
||||
}
|
||||
|
||||
export function resolveGraphicParamsAtTime({
|
||||
element,
|
||||
params,
|
||||
definitions,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
element: {
|
||||
definitionId: string;
|
||||
params: ParamValues;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
params: ParamValues;
|
||||
definitions: ParamDefinition[];
|
||||
animations?: ElementAnimations;
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const definition = getGraphicDefinition({
|
||||
definitionId: element.definitionId,
|
||||
});
|
||||
const baseParams = resolveGraphicParams(definition, element.params);
|
||||
const resolved: ParamValues = { ...baseParams };
|
||||
const resolved: ParamValues = { ...params };
|
||||
|
||||
for (const param of definition.params) {
|
||||
for (const param of definitions) {
|
||||
const path = buildGraphicParamPath({ paramKey: param.key });
|
||||
if (!element.animations?.bindings[path]) {
|
||||
if (!animations?.bindings[path]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved[param.key] = resolveAnimationPathValueAtTime({
|
||||
animations: element.animations,
|
||||
animations,
|
||||
propertyPath: path,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseParams[param.key] ?? param.default,
|
||||
fallbackValue: params[param.key] ?? param.default,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,31 +16,14 @@ export {
|
||||
setChannel,
|
||||
splitAnimationsAtTime,
|
||||
updateScalarKeyframeCurve,
|
||||
upsertElementKeyframe,
|
||||
upsertPathKeyframe,
|
||||
} from "./keyframes";
|
||||
|
||||
export {
|
||||
getElementLocalTime,
|
||||
resolveAnimationPathValueAtTime,
|
||||
resolveColorAtTime,
|
||||
resolveNumberAtTime,
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "./resolve";
|
||||
|
||||
export {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getDefaultInterpolationForProperty,
|
||||
getElementBaseValueForProperty,
|
||||
isAnimationPropertyPath,
|
||||
supportsAnimationProperty,
|
||||
type AnimationPropertyDefinition,
|
||||
type NumericSpec,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
|
||||
export {
|
||||
getElementKeyframes,
|
||||
getKeyframeById,
|
||||
@@ -75,15 +58,6 @@ export {
|
||||
resolveEffectParamsAtTime,
|
||||
} from "./effect-param-channel";
|
||||
|
||||
export {
|
||||
isAnimationPath,
|
||||
coerceAnimationValueForParam,
|
||||
resolveAnimationTarget,
|
||||
getParamValueKind,
|
||||
getParamDefaultInterpolation,
|
||||
type AnimationPathDescriptor,
|
||||
} from "./target-resolver";
|
||||
|
||||
export {
|
||||
getGroupKeyframesAtTime,
|
||||
hasGroupKeyframeAtTime,
|
||||
@@ -94,3 +68,7 @@ export {
|
||||
type EasingMode,
|
||||
getEasingModeForKind,
|
||||
} from "./binding-values";
|
||||
|
||||
export {
|
||||
isAnimationPath,
|
||||
} from "./path";
|
||||
+2
-11
@@ -1,20 +1,11 @@
|
||||
import type {
|
||||
AnimationChannel,
|
||||
AnimationInterpolation,
|
||||
AnimationValue,
|
||||
DiscreteAnimationChannel,
|
||||
DiscreteValue,
|
||||
ScalarAnimationChannel,
|
||||
ScalarAnimationKey,
|
||||
ScalarSegmentType,
|
||||
} from "@/lib/animation/types";
|
||||
import { clamp } from "@/utils/math";
|
||||
import type { AnimationChannel, AnimationInterpolation, AnimationValue, DiscreteAnimationChannel, DiscreteValue, ScalarAnimationChannel, ScalarAnimationKey, ScalarSegmentType } from "@/model/decorations/animations";
|
||||
import {
|
||||
getBezierPoint,
|
||||
getDefaultLeftHandle,
|
||||
getDefaultRightHandle,
|
||||
solveBezierProgressForTime,
|
||||
} from "./bezier";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
function byTimeAscending({
|
||||
leftTime,
|
||||
+3
-8
@@ -1,10 +1,5 @@
|
||||
import type {
|
||||
AnimationBindingInstance,
|
||||
AnimationChannel,
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
ElementKeyframe,
|
||||
} from "@/lib/animation/types";
|
||||
import type { AnimationBindingInstance, AnimationChannel, AnimationPath, ElementAnimations } from "@/model/decorations/animations";
|
||||
import type { ElementKeyframe } from "@/animation/types";
|
||||
import {
|
||||
type AnimationComponentValue,
|
||||
composeAnimationValue,
|
||||
@@ -13,7 +8,7 @@ import {
|
||||
getChannelValueAtTime,
|
||||
getScalarSegmentInterpolation,
|
||||
} from "./interpolation";
|
||||
import { isAnimationPath } from "./target-resolver";
|
||||
import { isAnimationPath } from "./path";
|
||||
|
||||
function getBindingFallbackValue({
|
||||
channel,
|
||||
@@ -1,20 +1,5 @@
|
||||
import type {
|
||||
AnimationBindingInstance,
|
||||
AnimationBindingKind,
|
||||
AnimationChannel,
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
DiscreteAnimationChannel,
|
||||
DiscreteAnimationKey,
|
||||
ElementAnimations,
|
||||
ScalarAnimationChannel,
|
||||
ScalarAnimationKey,
|
||||
ScalarCurveKeyframePatch,
|
||||
ScalarSegmentType,
|
||||
} from "@/lib/animation/types";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import type { AnimationBindingInstance, AnimationBindingKind, AnimationChannel, AnimationInterpolation, AnimationPath, AnimationValue, DiscreteAnimationChannel, DiscreteAnimationKey, ElementAnimations, ScalarAnimationChannel, ScalarAnimationKey, ScalarSegmentType } from "@/model/decorations/animations";
|
||||
import type { ScalarCurveKeyframePatch } from "@/animation/types";
|
||||
import {
|
||||
cloneAnimationBinding,
|
||||
createAnimationBinding,
|
||||
@@ -31,10 +16,7 @@ import {
|
||||
getScalarSegmentInterpolation,
|
||||
normalizeChannel,
|
||||
} from "./interpolation";
|
||||
import {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
} from "./property-registry";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
|
||||
function isNearlySameTime({
|
||||
leftTime,
|
||||
@@ -527,47 +509,6 @@ export function upsertPathKeyframe({
|
||||
});
|
||||
}
|
||||
|
||||
export function upsertElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
}): ElementAnimations | undefined {
|
||||
const coercedValue = coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value,
|
||||
});
|
||||
if (coercedValue === null) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return upsertPathKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
value: coercedValue,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
kind: propertyDefinition.kind,
|
||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||
coerceValue: ({ value: nextValue }) =>
|
||||
coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value: nextValue,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function upsertKeyframe({
|
||||
channel,
|
||||
time,
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AnimationPath, AnimationPropertyPath } from "@/model/decorations/animations";
|
||||
import { ANIMATION_PROPERTY_PATHS } from "./types";
|
||||
import { isEffectParamPath } from "./effect-param-channel";
|
||||
import { isGraphicParamPath } from "./graphic-param-channel";
|
||||
|
||||
const ANIMATION_PROPERTY_PATH_SET = new Set<string>(ANIMATION_PROPERTY_PATHS);
|
||||
|
||||
export function isAnimationPropertyPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPropertyPath {
|
||||
return ANIMATION_PROPERTY_PATH_SET.has(propertyPath);
|
||||
}
|
||||
|
||||
export function isAnimationPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPath {
|
||||
return (
|
||||
isAnimationPropertyPath(propertyPath) ||
|
||||
isGraphicParamPath(propertyPath) ||
|
||||
isEffectParamPath(propertyPath)
|
||||
);
|
||||
}
|
||||
+2
-6
@@ -1,9 +1,5 @@
|
||||
import type {
|
||||
AnimationPropertyGroup,
|
||||
AnimationPropertyPath,
|
||||
ElementAnimations,
|
||||
} from "@/lib/animation/types";
|
||||
import { ANIMATION_PROPERTY_GROUPS } from "@/lib/animation/types";
|
||||
import type { AnimationPropertyGroup, AnimationPropertyPath, ElementAnimations } from "@/model/decorations/animations";
|
||||
import { ANIMATION_PROPERTY_GROUPS } from "@/model/decorations/animations";
|
||||
import { getKeyframeAtTime } from "./keyframe-query";
|
||||
|
||||
export interface GroupKeyframeRef {
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { AnimationPath, AnimationValueForPath, ElementAnimations } from "@/model/decorations/animations";
|
||||
import {
|
||||
type AnimationComponentValue,
|
||||
composeAnimationValue,
|
||||
decomposeAnimationValue,
|
||||
} from "./binding-values";
|
||||
import {
|
||||
getChannelValueAtTime,
|
||||
} from "./interpolation";
|
||||
|
||||
export function getElementLocalTime({
|
||||
timelineTime,
|
||||
elementStartTime,
|
||||
elementDuration,
|
||||
}: {
|
||||
timelineTime: number;
|
||||
elementStartTime: number;
|
||||
elementDuration: number;
|
||||
}): number {
|
||||
const localTime = timelineTime - elementStartTime;
|
||||
if (localTime <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (localTime >= elementDuration) {
|
||||
return elementDuration;
|
||||
}
|
||||
|
||||
return localTime;
|
||||
}
|
||||
|
||||
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
fallbackValue,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: TPath;
|
||||
localTime: number;
|
||||
fallbackValue: AnimationValueForPath<TPath>;
|
||||
}): AnimationValueForPath<TPath> {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const fallbackComponents = decomposeAnimationValue({
|
||||
kind: binding.kind,
|
||||
value: fallbackValue,
|
||||
});
|
||||
if (!fallbackComponents) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const componentValues = Object.fromEntries(
|
||||
binding.components.map((component) => {
|
||||
const channel = animations?.channels[component.channelId];
|
||||
return [
|
||||
component.key,
|
||||
getChannelValueAtTime({
|
||||
channel,
|
||||
time: localTime,
|
||||
fallbackValue:
|
||||
fallbackComponents[component.key] ??
|
||||
(channel?.kind === "discrete" ? false : 0),
|
||||
}),
|
||||
];
|
||||
}),
|
||||
) as Record<string, AnimationComponentValue | undefined>;
|
||||
return (composeAnimationValue({
|
||||
binding,
|
||||
componentValues,
|
||||
}) ?? fallbackValue) as AnimationValueForPath<TPath>;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
CurveHandle,
|
||||
ScalarAnimationChannel,
|
||||
ScalarAnimationKey,
|
||||
ScalarSegmentType,
|
||||
TangentMode,
|
||||
} from "@/model/decorations/animations";
|
||||
|
||||
export type {
|
||||
AnimationBindingByKind,
|
||||
AnimationBindingComponent,
|
||||
AnimationBindingInstance,
|
||||
AnimationBindingKind,
|
||||
AnimationBindingOfKind,
|
||||
AnimationChannel,
|
||||
AnimationColorPropertyPath,
|
||||
AnimationInterpolation,
|
||||
AnimationKeyframe,
|
||||
AnimationNumericPropertyPath,
|
||||
AnimationPath,
|
||||
AnimationPropertyGroup,
|
||||
AnimationPropertyPath,
|
||||
AnimationPropertyValueMap,
|
||||
AnimationValue,
|
||||
AnimationValueForPath,
|
||||
ChannelExtrapolationMode,
|
||||
ColorAnimationBinding,
|
||||
ContinuousKeyframeInterpolation,
|
||||
CurveHandle,
|
||||
DiscreteAnimationBinding,
|
||||
DiscreteAnimationChannel,
|
||||
DiscreteAnimationKey,
|
||||
DiscreteKeyframeInterpolation,
|
||||
DiscreteValue,
|
||||
DynamicAnimationPathValue,
|
||||
EffectParamPath,
|
||||
ElementAnimationBindingMap,
|
||||
ElementAnimationChannelMap,
|
||||
ElementAnimations,
|
||||
GraphicParamPath,
|
||||
NormalizedCubicBezier,
|
||||
NumberAnimationBinding,
|
||||
NumericSpec,
|
||||
PrimitiveAnimationChannelKind,
|
||||
ScalarAnimationChannel,
|
||||
ScalarAnimationKey,
|
||||
ScalarSegmentType,
|
||||
TangentMode,
|
||||
Vector2AnimationBinding,
|
||||
VectorValue,
|
||||
} from "@/model/decorations/animations";
|
||||
|
||||
export {
|
||||
ANIMATION_PROPERTY_GROUPS,
|
||||
ANIMATION_PROPERTY_PATHS,
|
||||
} from "@/model/decorations/animations";
|
||||
|
||||
export interface ScalarGraphChannelTarget {
|
||||
propertyPath: AnimationPath;
|
||||
componentKey: string;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export interface ScalarGraphChannel extends ScalarGraphChannelTarget {
|
||||
channel: ScalarAnimationChannel;
|
||||
}
|
||||
|
||||
export interface ScalarGraphKeyframeRef extends ScalarGraphChannelTarget {
|
||||
keyframeId: string;
|
||||
}
|
||||
|
||||
export interface ScalarGraphKeyframeContext extends ScalarGraphChannel {
|
||||
keyframe: ScalarAnimationKey;
|
||||
keyframeIndex: number;
|
||||
previousKey: ScalarAnimationKey | null;
|
||||
nextKey: ScalarAnimationKey | null;
|
||||
}
|
||||
|
||||
export interface ScalarCurveKeyframePatch {
|
||||
leftHandle?: CurveHandle | null;
|
||||
rightHandle?: CurveHandle | null;
|
||||
segmentToNext?: ScalarSegmentType;
|
||||
tangentMode?: TangentMode;
|
||||
}
|
||||
|
||||
export interface ElementKeyframe {
|
||||
propertyPath: AnimationPath;
|
||||
id: string;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation: AnimationInterpolation;
|
||||
}
|
||||
|
||||
export interface SelectedKeyframeRef {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
AnimationColorPropertyPath,
|
||||
AnimationNumericPropertyPath,
|
||||
ElementAnimations,
|
||||
} from "./types";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
export function resolveOpacityAtTime({
|
||||
baseOpacity,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseOpacity: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "opacity",
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseOpacity,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveNumberAtTime({
|
||||
baseValue,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseValue: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationNumericPropertyPath;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveColorAtTime({
|
||||
baseColor,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseColor: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationColorPropertyPath;
|
||||
localTime: number;
|
||||
}): string {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseColor,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { auth } from "@/auth/server";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/lib/feedback";
|
||||
import { checkRateLimit } from "@/auth/rate-limit";
|
||||
import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/feedback";
|
||||
|
||||
const submitSchema = z.object({
|
||||
message: z
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
import { webEnv } from "@/env/web";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { checkRateLimit } from "@/auth/rate-limit";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
q: z.string().max(500, "Query too long").optional(),
|
||||
|
||||
@@ -4,8 +4,8 @@ import { notFound } from "next/navigation";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import Prose from "@/components/ui/prose";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
|
||||
import type { Author, Post } from "@/lib/blog/types";
|
||||
import { getPosts, getSinglePost, processHtmlContent } from "@/blog/query";
|
||||
import type { Author, Post } from "@/blog/types";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import type { Post } from "@/lib/blog/types";
|
||||
import { getPosts } from "@/blog/query";
|
||||
import type { Post } from "@/blog/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog - OpenCut",
|
||||
|
||||
+268
-268
@@ -1,268 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Check, Copy, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
function downloadAsset(src: string) {
|
||||
const filename = src.split("/").pop() ?? "asset.svg";
|
||||
const a = document.createElement("a");
|
||||
a.href = src;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function copyAsset(src: string) {
|
||||
const res = await fetch(src);
|
||||
const text = await res.text();
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
|
||||
|
||||
type AssetTheme = "dark" | "light" | "icon";
|
||||
|
||||
interface AssetVariant {
|
||||
src: string;
|
||||
theme: AssetTheme;
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface AssetSection {
|
||||
title: string;
|
||||
description: string;
|
||||
cols: "1" | "2";
|
||||
assets: AssetVariant[];
|
||||
}
|
||||
|
||||
const ASSET_SECTIONS: AssetSection[] = [
|
||||
{
|
||||
title: "Symbol",
|
||||
description:
|
||||
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/symbol.svg",
|
||||
theme: "dark",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/symbol-light.svg",
|
||||
theme: "light",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Lockup",
|
||||
description:
|
||||
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/logo.svg",
|
||||
theme: "dark",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/logo-light.svg",
|
||||
theme: "light",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text.svg",
|
||||
theme: "dark",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text-light.svg",
|
||||
theme: "light",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrandPage() {
|
||||
return (
|
||||
<BasePage
|
||||
maxWidth="6xl"
|
||||
title="Brand"
|
||||
description={
|
||||
<>
|
||||
Download OpenCut brand assets for use in your projects.{" "}
|
||||
<Link
|
||||
href="#guidelines"
|
||||
className="underline underline-offset-4"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("guidelines")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
Read the brand guidelines.
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="mx-auto gap-2"
|
||||
onClick={() => {
|
||||
ALL_ASSETS().forEach((asset, i) => {
|
||||
setTimeout(() => downloadAsset(asset.src), i * 200);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Download />
|
||||
Download all
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-10">
|
||||
{ASSET_SECTIONS.map((section) => (
|
||||
<div key={section.title} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="font-semibold text-lg">{section.title}</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3",
|
||||
section.cols === "2"
|
||||
? "grid-cols-1 sm:grid-cols-2"
|
||||
: "grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{section.assets.map((variant) => (
|
||||
<AssetCard key={variant.src} variant={variant} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div id="guidelines" className="flex flex-col gap-8 text-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">Usage</h2>
|
||||
<p className="text-muted-foreground text-base leading-relaxed">
|
||||
OpenCut is open source — the code is free to use under its license.
|
||||
That license does not cover the name or logo. You can say you use
|
||||
OpenCut, that your project integrates with OpenCut, or that it was
|
||||
built on top of OpenCut. You cannot name your product OpenCut, imply
|
||||
we made or endorse your product, or use the marks commercially
|
||||
without asking first. For anything unclear, reach out at{" "}
|
||||
<Link
|
||||
href="mailto:brand@opencut.app"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
brand@opencut.app
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
||||
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
|
||||
{[
|
||||
"Using OpenCut in the name of your product, service, or domain.",
|
||||
"Implying that OpenCut made, sponsors, or endorses your work.",
|
||||
"Using the logo or name on merchandise or commercial marketing.",
|
||||
"Modifying the marks.",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="mt-0.5 shrink-0">-</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
|
||||
light: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#000",
|
||||
},
|
||||
dark: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
};
|
||||
|
||||
function AssetCard({ variant }: { variant: AssetVariant }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
await copyAsset(variant.src);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="group relative overflow-hidden"
|
||||
style={
|
||||
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
|
||||
}
|
||||
>
|
||||
<div className="flex h-56 items-center justify-center px-12 py-8">
|
||||
<Image
|
||||
src={variant.src}
|
||||
alt={variant.label}
|
||||
width={variant.width}
|
||||
height={variant.height}
|
||||
className="max-h-16 w-auto select-none object-contain"
|
||||
draggable={false}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Check, Copy, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
function downloadAsset(src: string) {
|
||||
const filename = src.split("/").pop() ?? "asset.svg";
|
||||
const a = document.createElement("a");
|
||||
a.href = src;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function copyAsset(src: string) {
|
||||
const res = await fetch(src);
|
||||
const text = await res.text();
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
|
||||
|
||||
type AssetTheme = "dark" | "light" | "icon";
|
||||
|
||||
interface AssetVariant {
|
||||
src: string;
|
||||
theme: AssetTheme;
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface AssetSection {
|
||||
title: string;
|
||||
description: string;
|
||||
cols: "1" | "2";
|
||||
assets: AssetVariant[];
|
||||
}
|
||||
|
||||
const ASSET_SECTIONS: AssetSection[] = [
|
||||
{
|
||||
title: "Symbol",
|
||||
description:
|
||||
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/symbol.svg",
|
||||
theme: "dark",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/symbol-light.svg",
|
||||
theme: "light",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Lockup",
|
||||
description:
|
||||
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/logo.svg",
|
||||
theme: "dark",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/logo-light.svg",
|
||||
theme: "light",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text.svg",
|
||||
theme: "dark",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text-light.svg",
|
||||
theme: "light",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrandPage() {
|
||||
return (
|
||||
<BasePage
|
||||
maxWidth="6xl"
|
||||
title="Brand"
|
||||
description={
|
||||
<>
|
||||
Download OpenCut brand assets for use in your projects.{" "}
|
||||
<Link
|
||||
href="#guidelines"
|
||||
className="underline underline-offset-4"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("guidelines")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
Read the brand guidelines.
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="mx-auto gap-2"
|
||||
onClick={() => {
|
||||
ALL_ASSETS().forEach((asset, i) => {
|
||||
setTimeout(() => downloadAsset(asset.src), i * 200);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Download />
|
||||
Download all
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-10">
|
||||
{ASSET_SECTIONS.map((section) => (
|
||||
<div key={section.title} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="font-semibold text-lg">{section.title}</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3",
|
||||
section.cols === "2"
|
||||
? "grid-cols-1 sm:grid-cols-2"
|
||||
: "grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{section.assets.map((variant) => (
|
||||
<AssetCard key={variant.src} variant={variant} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div id="guidelines" className="flex flex-col gap-8 text-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">Usage</h2>
|
||||
<p className="text-muted-foreground text-base leading-relaxed">
|
||||
OpenCut is open source — the code is free to use under its license.
|
||||
That license does not cover the name or logo. You can say you use
|
||||
OpenCut, that your project integrates with OpenCut, or that it was
|
||||
built on top of OpenCut. You cannot name your product OpenCut, imply
|
||||
we made or endorse your product, or use the marks commercially
|
||||
without asking first. For anything unclear, reach out at{" "}
|
||||
<Link
|
||||
href="mailto:brand@opencut.app"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
brand@opencut.app
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
||||
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
|
||||
{[
|
||||
"Using OpenCut in the name of your product, service, or domain.",
|
||||
"Implying that OpenCut made, sponsors, or endorses your work.",
|
||||
"Using the logo or name on merchandise or commercial marketing.",
|
||||
"Modifying the marks.",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="mt-0.5 shrink-0">-</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
|
||||
light: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#000",
|
||||
},
|
||||
dark: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
};
|
||||
|
||||
function AssetCard({ variant }: { variant: AssetVariant }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
await copyAsset(variant.src);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="group relative overflow-hidden"
|
||||
style={
|
||||
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
|
||||
}
|
||||
>
|
||||
<div className="flex h-56 items-center justify-center px-12 py-8">
|
||||
<Image
|
||||
src={variant.src}
|
||||
alt={variant.label}
|
||||
width={variant.width}
|
||||
height={variant.height}
|
||||
className="max-h-16 w-auto select-none object-contain"
|
||||
draggable={false}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { getReleaseByVersion, getSortedReleases } from "@/lib/changelog/utils";
|
||||
import { getReleaseByVersion, getSortedReleases } from "@/changelog/utils";
|
||||
import {
|
||||
ReleaseArticle,
|
||||
ReleaseMeta,
|
||||
ReleaseTitle,
|
||||
ReleaseDescription,
|
||||
ReleaseChanges,
|
||||
} from "@/lib/changelog/components/release";
|
||||
import { CopyMarkdownButton } from "@/lib/changelog/components/copy-markdown-button";
|
||||
} from "@/changelog/components/release";
|
||||
import { CopyMarkdownButton } from "@/changelog/components/copy-markdown-button";
|
||||
|
||||
type Props = { params: Promise<{ version: string }> };
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@ import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
type Release as ReleaseType,
|
||||
getSortedReleases,
|
||||
} from "@/lib/changelog/utils";
|
||||
} from "@/changelog/utils";
|
||||
import {
|
||||
ReleaseArticle,
|
||||
ReleaseMeta,
|
||||
ReleaseTitle,
|
||||
ReleaseDescription,
|
||||
ReleaseChanges,
|
||||
} from "@/lib/changelog/components/release";
|
||||
} from "@/changelog/components/release";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Changelog - OpenCut",
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EXTERNAL_TOOLS } from "@/lib/site/external-tools";
|
||||
import { EXTERNAL_TOOLS } from "@/site/external-tools";
|
||||
import { BasePage } from "../base-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
||||
@@ -8,21 +8,32 @@ import {
|
||||
} from "@/components/ui/resizable";
|
||||
import { AssetsPanel } from "@/components/editor/panels/assets";
|
||||
import { PropertiesPanel } from "@/components/editor/panels/properties";
|
||||
import { Timeline } from "@/components/editor/panels/timeline";
|
||||
import { PreviewPanel } from "@/components/editor/panels/preview";
|
||||
import { Timeline } from "@/timeline/components";
|
||||
import { PreviewPanel } from "@/preview/components";
|
||||
import { EditorHeader } from "@/components/editor/editor-header";
|
||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||
import { Onboarding } from "@/components/editor/onboarding";
|
||||
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { usePasteMedia } from "@/hooks/use-paste-media";
|
||||
import { MigrationDialog } from "@/project/components/migration-dialog";
|
||||
import { usePanelStore } from "@/editor/panel-store";
|
||||
import { usePasteMedia } from "@/media/use-paste-media";
|
||||
import { MobileGate } from "@/components/editor/mobile-gate";
|
||||
import { useState } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { Cancel01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChangelogNotification } from "@/lib/changelog/components/changelog-notification";
|
||||
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
|
||||
import {
|
||||
createPreviewOverlayControl,
|
||||
isPreviewOverlayVisible,
|
||||
mergePreviewOverlaySources,
|
||||
} from "@/preview/overlays";
|
||||
import { usePreviewStore } from "@/preview/preview-store";
|
||||
import { getGuidePreviewOverlaySource } from "@/guides";
|
||||
import {
|
||||
bookmarkNotesPreviewOverlay,
|
||||
getBookmarkPreviewOverlaySource,
|
||||
} from "@/timeline/bookmarks/index";
|
||||
|
||||
export default function Editor() {
|
||||
const params = useParams();
|
||||
@@ -70,6 +81,49 @@ function DegradedRendererBanner() {
|
||||
function EditorLayout() {
|
||||
usePasteMedia();
|
||||
const { panels, setPanel } = usePanelStore();
|
||||
const activeScene = useEditor((editor) =>
|
||||
editor.scenes.getActiveSceneOrNull(),
|
||||
);
|
||||
const currentTime = useEditor((editor) => editor.playback.getCurrentTime());
|
||||
const activeGuide = usePreviewStore((state) => state.activeGuide);
|
||||
const overlays = usePreviewStore((state) => state.overlays);
|
||||
const setOverlayVisibility = usePreviewStore(
|
||||
(state) => state.setOverlayVisibility,
|
||||
);
|
||||
const showBookmarkNotes = isPreviewOverlayVisible({
|
||||
overlay: bookmarkNotesPreviewOverlay,
|
||||
overlays,
|
||||
});
|
||||
|
||||
const overlaySource = useMemo(
|
||||
() =>
|
||||
mergePreviewOverlaySources({
|
||||
sources: [
|
||||
getGuidePreviewOverlaySource({
|
||||
guideId: activeGuide,
|
||||
}),
|
||||
activeScene
|
||||
? getBookmarkPreviewOverlaySource({
|
||||
bookmarks: activeScene.bookmarks,
|
||||
time: currentTime,
|
||||
isVisible: showBookmarkNotes,
|
||||
})
|
||||
: {
|
||||
definitions: [bookmarkNotesPreviewOverlay],
|
||||
instances: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
[activeGuide, activeScene, currentTime, showBookmarkNotes],
|
||||
);
|
||||
|
||||
const overlayControls = useMemo(
|
||||
() =>
|
||||
overlaySource.definitions.map((overlay) =>
|
||||
createPreviewOverlayControl({ overlay, overlays }),
|
||||
),
|
||||
[overlaySource.definitions, overlays],
|
||||
);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
@@ -111,7 +165,11 @@ function EditorLayout() {
|
||||
minSize={30}
|
||||
className="min-h-0 min-w-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
<PreviewPanel
|
||||
overlayControls={overlayControls}
|
||||
overlayInstances={overlaySource.instances}
|
||||
onOverlayVisibilityChange={setOverlayVisibility}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
@@ -2,11 +2,11 @@ import { ThemeProvider } from "next-themes";
|
||||
import Script from "next/script";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { ChangelogNotification } from "@/lib/changelog/components/changelog-notification";
|
||||
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
|
||||
import { TooltipProvider } from "../components/ui/tooltip";
|
||||
import { baseMetaData } from "./metadata";
|
||||
import { BotIdClient } from "botid/client";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
import { webEnv } from "@/env/web";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
const siteFont = Inter({ subsets: ["latin"] });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { SITE_INFO, SITE_URL } from "@/lib/site/brand";
|
||||
import { SITE_INFO, SITE_URL } from "@/site/brand";
|
||||
|
||||
export const baseMetaData: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hero } from "@/components/landing/hero";
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import type { Metadata } from "next";
|
||||
import { SITE_URL } from "@/lib/site/brand";
|
||||
import { SITE_URL } from "@/site/brand";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
alternates: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SOCIAL_LINKS } from "@/lib/site/social";
|
||||
import { SOCIAL_LINKS } from "@/site/social";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy - OpenCut",
|
||||
|
||||
@@ -7,20 +7,20 @@ import type { KeyboardEvent, MouseEvent } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { EditorCore } from "@/core";
|
||||
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
|
||||
import { StoragePersistenceDialog } from "@/components/editor/dialogs/storage-persistence-dialog";
|
||||
import { MigrationDialog } from "@/project/components/migration-dialog";
|
||||
import { StoragePersistenceDialog } from "@/services/storage/components/storage-persistence-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useProjectsStore } from "./store";
|
||||
import type {
|
||||
TProjectMetadata,
|
||||
TProjectSortKey,
|
||||
TProjectSortOption,
|
||||
} from "@/lib/project/types";
|
||||
} from "@/project/types";
|
||||
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
|
||||
import { formatDate } from "@/utils/date";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
@@ -62,11 +62,11 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog";
|
||||
import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog";
|
||||
import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog";
|
||||
import { DeleteProjectDialog } from "@/project/components/delete-project-dialog";
|
||||
import { ProjectInfoDialog } from "@/project/components/project-info-dialog";
|
||||
import { RenameProjectDialog } from "@/project/components/rename-project-dialog";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { ChangelogNotification } from "@/lib/changelog/components/changelog-notification";
|
||||
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
|
||||
const formatProjectDuration = ({
|
||||
duration,
|
||||
}: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { TProjectSortKey } from "@/lib/project/types";
|
||||
import type { TProjectSortKey } from "@/project/types";
|
||||
|
||||
export type ProjectsViewMode = "grid" | "list";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/lib/site/brand";
|
||||
import { SITE_URL } from "@/site/brand";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Feed } from "feed";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import { SITE_INFO, SITE_URL } from "@/lib/site/brand";
|
||||
import { getPosts } from "@/blog/query";
|
||||
import { SITE_INFO, SITE_URL } from "@/site/brand";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SITE_URL } from "@/lib/site/brand";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import { SITE_URL } from "@/site/brand";
|
||||
import { getPosts } from "@/blog/query";
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
|
||||
@@ -3,7 +3,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { SPONSORS, type Sponsor } from "@/lib/site/sponsors";
|
||||
import { SPONSORS, type Sponsor } from "@/site/sponsors";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SOCIAL_LINKS } from "@/lib/site/social";
|
||||
import { SOCIAL_LINKS } from "@/site/social";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Service - OpenCut",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
import { webEnv } from "@/env/web";
|
||||
|
||||
export const { signIn, signUp, useSession } = createAuthClient({
|
||||
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Redis } from "@upstash/redis";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
import { webEnv } from "@/env/web";
|
||||
|
||||
const redis = new Redis({
|
||||
url: webEnv.UPSTASH_REDIS_REST_URL,
|
||||
@@ -1,8 +1,8 @@
|
||||
import { betterAuth, type RateLimit } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { Redis } from "@upstash/redis";
|
||||
import { db } from "@/lib/db";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
import { db } from "@/db";
|
||||
import { webEnv } from "@/env/web";
|
||||
|
||||
const redis = new Redis({
|
||||
url: webEnv.UPSTASH_REDIS_REST_URL,
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
MarblePost,
|
||||
MarblePostList,
|
||||
MarbleTagList,
|
||||
} from "@/lib/blog/types";
|
||||
} from "@/blog/types";
|
||||
import { unified } from "unified";
|
||||
import rehypeParse from "rehype-parse";
|
||||
import rehypeStringify from "rehype-stringify";
|
||||
@@ -47,7 +47,12 @@ const EMPTY_AUTHORS: MarbleAuthorList = {
|
||||
};
|
||||
|
||||
function isMarbleConfigured() {
|
||||
return !["", "placeholder", "build-placeholder", "your_workspace_key_here"].includes(key);
|
||||
return ![
|
||||
"",
|
||||
"placeholder",
|
||||
"build-placeholder",
|
||||
"your_workspace_key_here",
|
||||
].includes(key);
|
||||
}
|
||||
|
||||
async function fetchFromMarble<T>({
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TCanvasSize } from "@/lib/project/types";
|
||||
import type { TCanvasSize } from "@/project/types";
|
||||
|
||||
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
|
||||
{ width: 1920, height: 1080 },
|
||||
+2
-2
@@ -43,7 +43,7 @@ changes:
|
||||
- type: improved
|
||||
text: "The properties panel has been redesigned with a tabbed layout."
|
||||
- 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
|
||||
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
|
||||
@@ -71,7 +71,7 @@ changes:
|
||||
- type: new
|
||||
text: "Preview zoom and panning. Zoom into the canvas to work on fine details, and pan around to navigate when zoomed in."
|
||||
- type: new
|
||||
text: "Stickers panel now has content. Browse and add logos, country flags, and shapes."
|
||||
text: "Stickers panel now has content. Browse and add country flags and shapes."
|
||||
- type: improved
|
||||
text: "You can now select multiple assets in the assets panel. Useful for deleting several at once."
|
||||
- type: fixed
|
||||
@@ -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."
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { PasteCommand } from "@/lib/commands/timeline";
|
||||
import { PasteCommand } from "@/commands/timeline";
|
||||
import type { ClipboardHandler } from "../types";
|
||||
|
||||
export const ElementsClipboardHandler = {
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
import { getKeyframeById } from "@/lib/animation";
|
||||
import type { SelectedKeyframeRef } from "@/lib/animation/types";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
import { PasteKeyframesCommand } from "@/lib/commands/timeline";
|
||||
import { getKeyframeById } from "@/animation";
|
||||
import type { SelectedKeyframeRef } from "@/animation/types";
|
||||
import { elementId, type ElementRef, type TimelineElement } from "@/model";
|
||||
import { PasteKeyframesCommand } from "@/commands/timeline";
|
||||
import type {
|
||||
ClipboardHandler,
|
||||
KeyframeClipboardCurvePatch,
|
||||
@@ -12,7 +12,7 @@ function resolveSingleSourceElement({
|
||||
selectedKeyframes,
|
||||
}: {
|
||||
selectedKeyframes: SelectedKeyframeRef[];
|
||||
}) {
|
||||
}): ElementRef | null {
|
||||
const firstKeyframe = selectedKeyframes[0];
|
||||
if (!firstKeyframe) {
|
||||
return null;
|
||||
@@ -20,7 +20,7 @@ function resolveSingleSourceElement({
|
||||
|
||||
const sourceElement = {
|
||||
trackId: firstKeyframe.trackId,
|
||||
elementId: firstKeyframe.elementId,
|
||||
elementId: elementId(firstKeyframe.elementId),
|
||||
};
|
||||
const isSingleSource = selectedKeyframes.every(
|
||||
(keyframe) =>
|
||||
@@ -1,17 +1,8 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
ScalarCurveKeyframePatch,
|
||||
SelectedKeyframeRef,
|
||||
} from "@/lib/animation/types";
|
||||
import type { Command } from "@/lib/commands/base-command";
|
||||
import type {
|
||||
CreateTimelineElement,
|
||||
ElementRef,
|
||||
TrackType,
|
||||
} from "@/lib/timeline";
|
||||
import type { AnimationInterpolation, AnimationPath, AnimationValue } from "@/model/decorations/animations";
|
||||
import type { ScalarCurveKeyframePatch, SelectedKeyframeRef } from "@/animation/types";
|
||||
import type { Command } from "@/commands/base-command";
|
||||
import type { CreateTimelineElement, ElementRef, TrackType } from "@/model";
|
||||
|
||||
export interface ElementClipboardItem {
|
||||
trackId: string;
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { EditorSelectionPatch } from "@/selection/editor-selection";
|
||||
import type { ElementRef } from "@/model";
|
||||
|
||||
export interface CommandResult {
|
||||
selection?: EditorSelectionPatch;
|
||||
}
|
||||
|
||||
export function createElementSelectionResult(
|
||||
selectedElements: ElementRef[],
|
||||
): CommandResult {
|
||||
return {
|
||||
selection: {
|
||||
selectedElements,
|
||||
selectedKeyframes: [],
|
||||
keyframeSelectionAnchor: null,
|
||||
selectedMaskPoints: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export abstract class Command {
|
||||
abstract execute(): CommandResult | undefined;
|
||||
|
||||
undo(): void {
|
||||
throw new Error("Undo not implemented for this command");
|
||||
}
|
||||
|
||||
redo(): CommandResult | undefined {
|
||||
return this.execute();
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export class BatchCommand extends Command {
|
||||
|
||||
for (const command of this.commands) {
|
||||
const result = command.execute();
|
||||
if (result?.select !== undefined) {
|
||||
if (result?.selection !== undefined) {
|
||||
latestSelectionResult = result;
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export class BatchCommand extends Command {
|
||||
|
||||
for (const command of this.commands) {
|
||||
const result = command.redo();
|
||||
if (result?.select !== undefined) {
|
||||
if (result?.selection !== undefined) {
|
||||
latestSelectionResult = result;
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,13 +1,13 @@
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { toast } from "sonner";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import type { FrameRate } from "opencut-wasm";
|
||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
||||
import { frameRatesEqual, getHighestImportedVideoFps } from "@/lib/fps/utils";
|
||||
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
|
||||
import { hasMediaId } from "@/timeline/element-utils";
|
||||
import { frameRatesEqual, getHighestImportedVideoFps } from "@/fps/utils";
|
||||
import { UpdateProjectSettingsCommand } from "@/commands/project";
|
||||
|
||||
export class AddMediaAssetCommand extends Command {
|
||||
private assetId: string;
|
||||
+12
-4
@@ -1,10 +1,12 @@
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
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 { videoCache } from "@/services/video-cache/service";
|
||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
||||
import type { SceneTracks } from "@/lib/timeline";
|
||||
import { waveformCache } from "@/services/waveform-cache/service";
|
||||
import { hasMediaId } from "@/timeline/element-utils";
|
||||
import type { SceneTracks } from "@/model";
|
||||
|
||||
export class RemoveMediaAssetCommand extends Command {
|
||||
private savedAssets: MediaAsset[] | null = null;
|
||||
@@ -41,6 +43,12 @@ export class RemoveMediaAssetCommand extends Command {
|
||||
}
|
||||
|
||||
videoCache.clearVideo({ mediaId: this.assetId });
|
||||
waveformCache.clearSource({
|
||||
sourceKey: buildWaveformSourceKey({
|
||||
kind: "media",
|
||||
id: this.assetId,
|
||||
}),
|
||||
});
|
||||
|
||||
editor.media.setAssets({
|
||||
assets: assets.filter((media) => media.id !== this.assetId),
|
||||
+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 type { TProject, TProjectSettings } from "@/lib/project/types";
|
||||
import type { TProject, TProjectSettings } from "@/project/types";
|
||||
|
||||
export class UpdateProjectSettingsCommand extends Command {
|
||||
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 type { TScene } from "@/lib/timeline";
|
||||
import { buildDefaultScene } from "@/lib/scenes";
|
||||
import type { TScene } from "@/model";
|
||||
import { buildDefaultScene } from "@/timeline/scenes";
|
||||
|
||||
export class CreateSceneCommand extends Command {
|
||||
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 type { TScene } from "@/lib/timeline";
|
||||
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
|
||||
import type { TScene } from "@/model";
|
||||
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/timeline/scenes";
|
||||
|
||||
export class DeleteSceneCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { updateSceneInArray } from "@/lib/scenes";
|
||||
import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks";
|
||||
import type { TScene } from "@/model";
|
||||
import { updateSceneInArray } from "@/timeline/scenes";
|
||||
import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index";
|
||||
|
||||
export class MoveBookmarkCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { updateSceneInArray } from "@/lib/scenes";
|
||||
import type { TScene } from "@/model";
|
||||
import { updateSceneInArray } from "@/timeline/scenes";
|
||||
import {
|
||||
getFrameTime,
|
||||
removeBookmarkFromArray,
|
||||
} from "@/lib/timeline/bookmarks";
|
||||
} from "@/timeline/bookmarks/index";
|
||||
|
||||
export class RemoveBookmarkCommand extends Command {
|
||||
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