Compare commits

...
Author SHA1 Message Date
Maze WintherandCursor 02d957582b refactor: move rendering primitives into model
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 04:20:00 +02:00
Maze WintherandCursor ab982b888e refactor: extract decoration model types
Move animation, effect, mask, and param value shapes into model decorations so element data no longer depends on decoration domains.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 03:58:21 +02:00
Maze WintherandCursor 6ac2116daa refactor: extract timeline model types
Move project entity shapes into the model layer so sibling domains no longer depend on timeline for core data types.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 03:49:52 +02:00
Maze Winther 79cc7d2159 chore: bun.lock 2026-05-03 01:10:27 +02:00
Maze Winther 8ab3aeba7c docs: move NOTES.md file 2026-05-02 16:38:06 +02:00
Maze Winther 0b7597b31f refactor: split animation helpers by domain 2026-04-26 04:04:30 +02:00
Maze Winther 6a22a3ecbd refactor: centralize editor interaction state
Move timeline and preview gestures into dedicated controllers so hooks stay thin around editor state and DOM events.

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

Made-with: Cursor
2026-04-25 03:56:22 +02:00
Maze Winther 0dffefdd59 fix: surface unsupported video codec on import, fix Input leaks 2026-04-25 02:46:31 +02:00
Maze Winther cceb835a84 perf: improve rendering performance by ~20x 2026-04-22 16:48:37 +02:00
Maze Winther f1b45b4328 chore: remove stale files 2026-04-22 14:36:51 +02:00
Maze Winther 7428dc75cf chore: bump opencut-wasm version 2026-04-22 14:24:28 +02:00
Maze Winther 1301d027e9 fix: wasm WebGL only until WebGPU is fixed 2026-04-22 14:20:40 +02:00
Maze Winther f5f31b43df refactor: migration run() and pure v1-to-v2 transform 2026-04-22 02:41:18 +02:00
Maze Winther a709af0059 chore: formatting 2026-04-22 02:05:41 +02:00
Maze Winther 75eebf52bc fix: project loading thing 2026-04-21 20:45:33 +02:00
Maze Winther 1582423dbf chore: delete ffmpeg public assets 2026-04-21 13:00:26 +02:00
Maze Winther 148d38d7c9 docs: ts conventions to agents.md 2026-04-21 12:47:21 +02:00
Maze Winther 3e89d29985 refactor: restructure files to their domains, new preview overlay system, and dep graph 2026-04-20 11:31:17 +02:00
Maze Winther 729d10592f fix: WebGL frame rendering and opencut-wasm 0.2.8 2026-04-19 07:42:52 +02:00
Maze Winther 9da365bc01 docs: add prerequisites for rust 2026-04-19 07:23:15 +02:00
Maze Winther 8bdc894690 fix: migrate commands to CommandResult selection contract 2026-04-18 16:01:35 +02:00
Maze Winther 0387bd4c80 delete legacy folder 2026-04-18 15:35:04 +02:00
Maze Winther b1d0b10354 fix: default audio waveform color from timeline theme 2026-04-18 15:11:52 +02:00
Maze Winther 94eed96cad chore: update opencut-wasm version to 0.2.6 in Cargo.lock 2026-04-18 15:03:00 +02:00
Maze Winther 794d4ae149 fix: align LayerUniformBuffer to 16 bytes for WebGL compat 2026-04-18 15:01:59 +02:00
Maze Winther 011f51051c feat: clip volume line and timeline waveform improvements 2026-04-18 01:40:18 +02:00
Maze Winther c46d28891f feat: text / custom masks, fix preview pan, refactor selection 2026-04-15 15:45:50 +02:00
Maze Winther 66e4367bc4 feat: history view in feedback popover 2026-04-15 06:39:52 +02:00
Maze Winther 8161de00e6 chore: formatting 2026-04-15 06:26:55 +02:00
Maze Winther eb28942dab fix: audio waveform height in timeline 2026-04-15 06:05:13 +02:00
Maze Winther 68f175d3be fix: correct schema path in drizzle config 2026-04-15 06:04:29 +02:00
Maze Winther d5c1b7bf0e fix: load dotenv before env validation in drizzle config 2026-04-15 06:03:23 +02:00
Maze Winther 0868d9cf13 tests: delete 2026-04-15 05:53:06 +02:00
Maze Winther 8a3577d2e7 review: fixes 2026-04-15 05:52:33 +02:00
Maze Winther ac43a5a73f feat: move/resize multiple elements 2026-04-15 05:43:29 +02:00
Maze Winther 6312fc5b07 fix: repair scene tracks broken by v23-to-v24 migration 2026-04-15 04:45:15 +02:00
Maze Winther 2da93aa98f changelog: rephrasing 2026-04-15 04:34:36 +02:00
Maze Winther f4bd689f51 chore: force deployment 2026-04-15 03:59:52 +02:00
Maze Winther 4f7dc5ee3a fix: remap split-element keybinding to split 2026-04-15 03:57:51 +02:00
Maze Winther 4e8b7d3745 fix: skip unknown actions in keyboard shortcuts help 2026-04-15 03:47:57 +02:00
Maze Winther ee372266ca chore: force deployment 2026-04-15 03:35:07 +02:00
Maze Winther e10b0a3355 tests: remove 2026-04-15 02:44:52 +02:00
628 changed files with 25213 additions and 18987 deletions
+14
View File
@@ -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
View File
@@ -3820,7 +3820,7 @@ dependencies = [
[[package]]
name = "opencut-wasm"
version = "0.2.5"
version = "0.2.9"
dependencies = [
"bridge",
"compositor",
+13
View File
@@ -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
+2 -2
View File
@@ -1,6 +1,6 @@
use gpui::{
App, Application, Bounds, Context, SharedString, Window, WindowBounds, WindowOptions, div,
prelude::*, px, rgb, size,
div, prelude::*, px, rgb, size, App, Application, Bounds, Context, SharedString, Window,
WindowBounds, WindowOptions,
};
struct AppWindow {
+9 -5
View File
@@ -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;
+1 -1
View File
@@ -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.
@@ -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;
}
@@ -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();
@@ -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,
@@ -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;
@@ -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,4 +1,4 @@
import type { KeybindingConfig } from "@/lib/actions/keybinding";
import type { KeybindingConfig } from "@/actions/keybinding";
interface V4State {
keybindings: KeybindingConfig;
@@ -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 };
}
@@ -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,
@@ -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";
/**
@@ -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();
});
});
@@ -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", () => {
+83
View File
@@ -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,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;
@@ -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;
@@ -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;
@@ -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";
@@ -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,
@@ -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,
+22
View File
@@ -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)
);
}
@@ -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 {
+75
View File
@@ -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>;
}
+102
View File
@@ -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;
}
+61
View File
@@ -0,0 +1,61 @@
import type {
AnimationColorPropertyPath,
AnimationNumericPropertyPath,
ElementAnimations,
} from "./types";
import { resolveAnimationPathValueAtTime } from "./resolve";
export function resolveOpacityAtTime({
baseOpacity,
animations,
localTime,
}: {
baseOpacity: number;
animations: ElementAnimations | undefined;
localTime: number;
}): number {
return resolveAnimationPathValueAtTime({
animations,
propertyPath: "opacity",
localTime: Math.max(0, localTime),
fallbackValue: baseOpacity,
});
}
export function resolveNumberAtTime({
baseValue,
animations,
propertyPath,
localTime,
}: {
baseValue: number;
animations: ElementAnimations | undefined;
propertyPath: AnimationNumericPropertyPath;
localTime: number;
}): number {
return resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime: Math.max(0, localTime),
fallbackValue: baseValue,
});
}
export function resolveColorAtTime({
baseColor,
animations,
propertyPath,
localTime,
}: {
baseColor: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationColorPropertyPath;
localTime: number;
}): string {
return resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime: Math.max(0, localTime),
fallbackValue: baseColor,
});
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { auth } from "@/lib/auth/server";
import { auth } from "@/auth/server";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);
+2 -2
View File
@@ -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
+2 -2
View File
@@ -1,7 +1,7 @@
import { webEnv } from "@/lib/env/web";
import { webEnv } from "@/env/web";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit";
import { checkRateLimit } from "@/auth/rate-limit";
const searchParamsSchema = z.object({
q: z.string().max(500, "Query too long").optional(),
+2 -2
View File
@@ -4,8 +4,8 @@ import { notFound } from "next/navigation";
import { BasePage } from "@/app/base-page";
import Prose from "@/components/ui/prose";
import { Separator } from "@/components/ui/separator";
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
import type { Author, Post } from "@/lib/blog/types";
import { getPosts, getSinglePost, processHtmlContent } from "@/blog/query";
import type { Author, Post } from "@/blog/types";
type PageProps = {
params: Promise<{ slug: string }>;
+2 -2
View File
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
import Link from "next/link";
import { BasePage } from "@/app/base-page";
import { Separator } from "@/components/ui/separator";
import { getPosts } from "@/lib/blog/query";
import type { Post } from "@/lib/blog/types";
import { getPosts } from "@/blog/query";
import type { Post } from "@/blog/types";
export const metadata: Metadata = {
title: "Blog - OpenCut",
+268 -268
View File
@@ -1,268 +1,268 @@
"use client";
import type { CSSProperties } from "react";
import Image from "next/image";
import Link from "next/link";
import { Check, Copy, Download } from "lucide-react";
import { useState } from "react";
import { BasePage } from "@/app/base-page";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/utils/ui";
function downloadAsset(src: string) {
const filename = src.split("/").pop() ?? "asset.svg";
const a = document.createElement("a");
a.href = src;
a.download = filename;
a.click();
}
async function copyAsset(src: string) {
const res = await fetch(src);
const text = await res.text();
await navigator.clipboard.writeText(text);
}
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
type AssetTheme = "dark" | "light" | "icon";
interface AssetVariant {
src: string;
theme: AssetTheme;
label: string;
width: number;
height: number;
}
interface AssetSection {
title: string;
description: string;
cols: "1" | "2";
assets: AssetVariant[];
}
const ASSET_SECTIONS: AssetSection[] = [
{
title: "Symbol",
description:
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
cols: "2",
assets: [
{
src: "/logos/opencut/symbol.svg",
theme: "dark",
label: "Symbol",
width: 400,
height: 400,
},
{
src: "/logos/opencut/symbol-light.svg",
theme: "light",
label: "Symbol",
width: 400,
height: 400,
},
],
},
{
title: "Lockup",
description:
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
cols: "2",
assets: [
{
src: "/logos/opencut/logo.svg",
theme: "dark",
label: "Logo",
width: 1809,
height: 400,
},
{
src: "/logos/opencut/logo-light.svg",
theme: "light",
label: "Logo",
width: 1809,
height: 400,
},
{
src: "/logos/opencut/text.svg",
theme: "dark",
label: "Text",
width: 1760,
height: 400,
},
{
src: "/logos/opencut/text-light.svg",
theme: "light",
label: "Text",
width: 1760,
height: 400,
},
],
},
];
export default function BrandPage() {
return (
<BasePage
maxWidth="6xl"
title="Brand"
description={
<>
Download OpenCut brand assets for use in your projects.{" "}
<Link
href="#guidelines"
className="underline underline-offset-4"
onClick={() =>
document
.getElementById("guidelines")
?.scrollIntoView({ behavior: "smooth" })
}
>
Read the brand guidelines.
</Link>
</>
}
action={
<Button
variant="outline"
size="lg"
className="mx-auto gap-2"
onClick={() => {
ALL_ASSETS().forEach((asset, i) => {
setTimeout(() => downloadAsset(asset.src), i * 200);
});
}}
>
<Download />
Download all
</Button>
}
>
<div className="flex flex-col gap-10">
{ASSET_SECTIONS.map((section) => (
<div key={section.title} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h2 className="font-semibold text-lg">{section.title}</h2>
<p className="text-muted-foreground text-sm">
{section.description}
</p>
</div>
<div
className={cn(
"grid gap-3",
section.cols === "2"
? "grid-cols-1 sm:grid-cols-2"
: "grid-cols-1",
)}
>
{section.assets.map((variant) => (
<AssetCard key={variant.src} variant={variant} />
))}
</div>
</div>
))}
</div>
<Separator />
<div id="guidelines" className="flex flex-col gap-8 text-sm">
<div className="flex flex-col gap-3">
<h2 className="font-semibold text-lg">Usage</h2>
<p className="text-muted-foreground text-base leading-relaxed">
OpenCut is open source — the code is free to use under its license.
That license does not cover the name or logo. You can say you use
OpenCut, that your project integrates with OpenCut, or that it was
built on top of OpenCut. You cannot name your product OpenCut, imply
we made or endorse your product, or use the marks commercially
without asking first. For anything unclear, reach out at{" "}
<Link
href="mailto:brand@opencut.app"
className="underline underline-offset-4"
>
brand@opencut.app
</Link>
.
</p>
</div>
<div className="flex flex-col gap-3">
<h2 className="font-semibold text-lg">What's not allowed</h2>
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
{[
"Using OpenCut in the name of your product, service, or domain.",
"Implying that OpenCut made, sponsors, or endorses your work.",
"Using the logo or name on merchandise or commercial marketing.",
"Modifying the marks.",
].map((item) => (
<li key={item} className="flex gap-2">
<span className="mt-0.5 shrink-0">-</span>
<span>{item}</span>
</li>
))}
</ul>
</div>
</div>
</BasePage>
);
}
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
light: {
backgroundImage:
"linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
backgroundSize: "18px 18px",
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
backgroundColor: "#000",
},
dark: {
backgroundImage:
"linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
backgroundSize: "18px 18px",
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
backgroundColor: "#f5f5f5",
},
};
function AssetCard({ variant }: { variant: AssetVariant }) {
const [copied, setCopied] = useState(false);
async function handleCopy() {
await copyAsset(variant.src);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Card
className="group relative overflow-hidden"
style={
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
}
>
<div className="flex h-56 items-center justify-center px-12 py-8">
<Image
src={variant.src}
alt={variant.label}
width={variant.width}
height={variant.height}
className="max-h-16 w-auto select-none object-contain"
draggable={false}
unoptimized
/>
</div>
<Button
variant="outline"
size="icon"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
onClick={handleCopy}
>
{copied ? <Check /> : <Copy />}
</Button>
</Card>
);
}
"use client";
import type { CSSProperties } from "react";
import Image from "next/image";
import Link from "next/link";
import { Check, Copy, Download } from "lucide-react";
import { useState } from "react";
import { BasePage } from "@/app/base-page";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/utils/ui";
function downloadAsset(src: string) {
const filename = src.split("/").pop() ?? "asset.svg";
const a = document.createElement("a");
a.href = src;
a.download = filename;
a.click();
}
async function copyAsset(src: string) {
const res = await fetch(src);
const text = await res.text();
await navigator.clipboard.writeText(text);
}
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
type AssetTheme = "dark" | "light" | "icon";
interface AssetVariant {
src: string;
theme: AssetTheme;
label: string;
width: number;
height: number;
}
interface AssetSection {
title: string;
description: string;
cols: "1" | "2";
assets: AssetVariant[];
}
const ASSET_SECTIONS: AssetSection[] = [
{
title: "Symbol",
description:
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
cols: "2",
assets: [
{
src: "/logos/opencut/symbol.svg",
theme: "dark",
label: "Symbol",
width: 400,
height: 400,
},
{
src: "/logos/opencut/symbol-light.svg",
theme: "light",
label: "Symbol",
width: 400,
height: 400,
},
],
},
{
title: "Lockup",
description:
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
cols: "2",
assets: [
{
src: "/logos/opencut/logo.svg",
theme: "dark",
label: "Logo",
width: 1809,
height: 400,
},
{
src: "/logos/opencut/logo-light.svg",
theme: "light",
label: "Logo",
width: 1809,
height: 400,
},
{
src: "/logos/opencut/text.svg",
theme: "dark",
label: "Text",
width: 1760,
height: 400,
},
{
src: "/logos/opencut/text-light.svg",
theme: "light",
label: "Text",
width: 1760,
height: 400,
},
],
},
];
export default function BrandPage() {
return (
<BasePage
maxWidth="6xl"
title="Brand"
description={
<>
Download OpenCut brand assets for use in your projects.{" "}
<Link
href="#guidelines"
className="underline underline-offset-4"
onClick={() =>
document
.getElementById("guidelines")
?.scrollIntoView({ behavior: "smooth" })
}
>
Read the brand guidelines.
</Link>
</>
}
action={
<Button
variant="outline"
size="lg"
className="mx-auto gap-2"
onClick={() => {
ALL_ASSETS().forEach((asset, i) => {
setTimeout(() => downloadAsset(asset.src), i * 200);
});
}}
>
<Download />
Download all
</Button>
}
>
<div className="flex flex-col gap-10">
{ASSET_SECTIONS.map((section) => (
<div key={section.title} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h2 className="font-semibold text-lg">{section.title}</h2>
<p className="text-muted-foreground text-sm">
{section.description}
</p>
</div>
<div
className={cn(
"grid gap-3",
section.cols === "2"
? "grid-cols-1 sm:grid-cols-2"
: "grid-cols-1",
)}
>
{section.assets.map((variant) => (
<AssetCard key={variant.src} variant={variant} />
))}
</div>
</div>
))}
</div>
<Separator />
<div id="guidelines" className="flex flex-col gap-8 text-sm">
<div className="flex flex-col gap-3">
<h2 className="font-semibold text-lg">Usage</h2>
<p className="text-muted-foreground text-base leading-relaxed">
OpenCut is open source — the code is free to use under its license.
That license does not cover the name or logo. You can say you use
OpenCut, that your project integrates with OpenCut, or that it was
built on top of OpenCut. You cannot name your product OpenCut, imply
we made or endorse your product, or use the marks commercially
without asking first. For anything unclear, reach out at{" "}
<Link
href="mailto:brand@opencut.app"
className="underline underline-offset-4"
>
brand@opencut.app
</Link>
.
</p>
</div>
<div className="flex flex-col gap-3">
<h2 className="font-semibold text-lg">What'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 }> };
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Card, CardContent } from "@/components/ui/card";
import { EXTERNAL_TOOLS } from "@/lib/site/external-tools";
import { EXTERNAL_TOOLS } from "@/site/external-tools";
import { BasePage } from "../base-page";
export const metadata: Metadata = {
+67 -9
View File
@@ -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 -2
View File
@@ -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 -1
View File
@@ -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),
+1 -1
View File
@@ -2,7 +2,7 @@ import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import type { Metadata } from "next";
import { SITE_URL } from "@/lib/site/brand";
import { SITE_URL } from "@/site/brand";
export const metadata: Metadata = {
alternates: {
+1 -1
View File
@@ -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",
+8 -8
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { TProjectSortKey } from "@/lib/project/types";
import type { TProjectSortKey } from "@/project/types";
export type ProjectsViewMode = "grid" | "list";
+1 -1
View File
@@ -1,5 +1,5 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/site/brand";
import { SITE_URL } from "@/site/brand";
export default function robots(): MetadataRoute.Robots {
return {
+2 -2
View File
@@ -1,6 +1,6 @@
import { Feed } from "feed";
import { getPosts } from "@/lib/blog/query";
import { SITE_INFO, SITE_URL } from "@/lib/site/brand";
import { getPosts } from "@/blog/query";
import { SITE_INFO, SITE_URL } from "@/site/brand";
export async function GET() {
try {
+2 -2
View File
@@ -1,5 +1,5 @@
import { SITE_URL } from "@/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> {
+1 -1
View File
@@ -3,7 +3,7 @@ import Image from "next/image";
import Link from "next/link";
import { BasePage } from "@/app/base-page";
import { Card, CardContent } from "@/components/ui/card";
import { SPONSORS, type Sponsor } from "@/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";
+1 -1
View File
@@ -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 },
@@ -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
+19
View File
@@ -0,0 +1,19 @@
---
version: "0.3.0"
date: "2026-03-04"
published: false
title: "Title"
description: "Description"
changes:
- type: new
text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight."
- type: new
text: "Two new mask types: text and custom (draw any shape with the pen tool using bezier curves). Both support feather, invert, and stroke."
- type: fixed
text: "Preview panning now keeps working while the Masks tab is open."
- type: new
text: "Audio clips have a horizontal volume line you can drag up or down to change volume. The line position reflects the current volume setting and is consistent across clips: same dB always means the same position."
- type: improved
text: "Audio waveforms now show the actual shape of the audio. Quiet parts are visible, loud parts are tall, and any bar that peaks above 0 dBFS gets a colored tip at the top."
- type: fixed
text: "Trimming or cutting an audio clip no longer causes the waveform to compress or show the wrong section of audio."
@@ -1,4 +1,4 @@
import { PasteCommand } from "@/lib/commands/timeline";
import { PasteCommand } from "@/commands/timeline";
import type { ClipboardHandler } from "../types";
export const ElementsClipboardHandler = {
@@ -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;
+31
View File
@@ -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;
}
}
@@ -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;
@@ -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),
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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