Compare commits

..
Author SHA1 Message Date
Maze Winther 94e75278f8 chore: force deployment 2026-04-15 02:20:40 +02:00
577 changed files with 12634 additions and 17643 deletions
-14
View File
@@ -21,17 +21,3 @@ 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.9"
version = "0.2.5"
dependencies = [
"bridge",
"compositor",
-13
View File
@@ -85,19 +85,6 @@ 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::{
div, prelude::*, px, rgb, size, App, Application, Bounds, Context, SharedString, Window,
WindowBounds, WindowOptions,
App, Application, Bounds, Context, SharedString, Window, WindowBounds, WindowOptions, div,
prelude::*, px, rgb, size,
};
struct AppWindow {
+5 -9
View File
@@ -1,26 +1,22 @@
import type { Config } from "drizzle-kit";
import * as dotenv from "dotenv";
import { webEnv } from "@/lib/env/web";
if (process.env.NODE_ENV === "production") {
if (webEnv.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/lib/db/schema.ts",
schema: "./src/schema.ts",
dialect: "postgresql",
migrations: {
table: "drizzle_migrations",
},
dbCredentials: {
url: databaseUrl,
url: webEnv.DATABASE_URL,
},
out: "./migrations",
strict: process.env.NODE_ENV === "production",
strict: webEnv.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.9",
"opencut-wasm": "^0.2.5",
"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.
@@ -1,19 +0,0 @@
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 };
}
@@ -1,35 +0,0 @@
import { getElementKeyframes } from "@/animation";
import type { SceneTracks } from "@/timeline";
import type { SnapPoint } from "@/timeline/snapping";
export function getAnimationKeyframeSnapPointsForTimeline({
tracks,
excludeElementIds,
}: {
tracks: SceneTracks;
excludeElementIds?: Set<string>;
}): SnapPoint[] {
const snapPoints: SnapPoint[] = [];
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
for (const track of orderedTracks) {
for (const element of track.elements) {
if (excludeElementIds?.has(element.id)) {
continue;
}
for (const keyframe of getElementKeyframes({
animations: element.animations,
})) {
snapPoints.push({
time: element.startTime + keyframe.time,
type: "keyframe",
elementId: element.id,
trackId: track.id,
});
}
}
}
return snapPoints;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { auth } from "@/auth/server";
import { auth } from "@/lib/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 "@/auth/rate-limit";
import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/feedback";
import { checkRateLimit } from "@/lib/rate-limit";
import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/lib/feedback";
const submitSchema = z.object({
message: z
+2 -2
View File
@@ -1,7 +1,7 @@
import { webEnv } from "@/env/web";
import { webEnv } from "@/lib/env/web";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { checkRateLimit } from "@/auth/rate-limit";
import { checkRateLimit } from "@/lib/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 "@/blog/query";
import type { Author, Post } from "@/blog/types";
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
import type { Author, Post } from "@/lib/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 "@/blog/query";
import type { Post } from "@/blog/types";
import { getPosts } from "@/lib/blog/query";
import type { Post } from "@/lib/blog/types";
export const metadata: Metadata = {
title: "Blog - OpenCut",
@@ -3,15 +3,15 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { BasePage } from "@/app/base-page";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { getReleaseByVersion, getSortedReleases } from "@/changelog/utils";
import { getReleaseByVersion, getSortedReleases } from "@/lib/changelog/utils";
import {
ReleaseArticle,
ReleaseMeta,
ReleaseTitle,
ReleaseDescription,
ReleaseChanges,
} from "@/changelog/components/release";
import { CopyMarkdownButton } from "@/changelog/components/copy-markdown-button";
} from "@/lib/changelog/components/release";
import { CopyMarkdownButton } from "@/lib/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 "@/changelog/utils";
} from "@/lib/changelog/utils";
import {
ReleaseArticle,
ReleaseMeta,
ReleaseTitle,
ReleaseDescription,
ReleaseChanges,
} from "@/changelog/components/release";
} from "@/lib/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 "@/site/external-tools";
import { EXTERNAL_TOOLS } from "@/lib/site/external-tools";
import { BasePage } from "../base-page";
export const metadata: Metadata = {
+9 -67
View File
@@ -8,32 +8,21 @@ import {
} from "@/components/ui/resizable";
import { AssetsPanel } from "@/components/editor/panels/assets";
import { PropertiesPanel } from "@/components/editor/panels/properties";
import { Timeline } from "@/timeline/components";
import { PreviewPanel } from "@/preview/components";
import { Timeline } from "@/components/editor/panels/timeline";
import { PreviewPanel } from "@/components/editor/panels/preview";
import { EditorHeader } from "@/components/editor/editor-header";
import { EditorProvider } from "@/components/providers/editor-provider";
import { Onboarding } from "@/components/editor/onboarding";
import { MigrationDialog } from "@/project/components/migration-dialog";
import { usePanelStore } from "@/editor/panel-store";
import { usePasteMedia } from "@/media/use-paste-media";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
import { usePanelStore } from "@/stores/panel-store";
import { usePasteMedia } from "@/hooks/use-paste-media";
import { MobileGate } from "@/components/editor/mobile-gate";
import { useMemo, useState } from "react";
import { useEditor } from "@/editor/use-editor";
import { useState } from "react";
import { useEditor } from "@/hooks/use-editor";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@/components/ui/button";
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
import {
createPreviewOverlayControl,
isPreviewOverlayVisible,
mergePreviewOverlaySources,
} from "@/preview/overlays";
import { usePreviewStore } from "@/preview/preview-store";
import { getGuidePreviewOverlaySource } from "@/guides";
import {
bookmarkNotesPreviewOverlay,
getBookmarkPreviewOverlaySource,
} from "@/timeline/bookmarks/index";
import { ChangelogNotification } from "@/lib/changelog/components/changelog-notification";
export default function Editor() {
const params = useParams();
@@ -81,49 +70,6 @@ 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
@@ -165,11 +111,7 @@ function EditorLayout() {
minSize={30}
className="min-h-0 min-w-0 flex-1"
>
<PreviewPanel
overlayControls={overlayControls}
overlayInstances={overlaySource.instances}
onOverlayVisibilityChange={setOverlayVisibility}
/>
<PreviewPanel />
</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 "@/changelog/components/changelog-notification";
import { ChangelogNotification } from "@/lib/changelog/components/changelog-notification";
import { TooltipProvider } from "../components/ui/tooltip";
import { baseMetaData } from "./metadata";
import { BotIdClient } from "botid/client";
import { webEnv } from "@/env/web";
import { webEnv } from "@/lib/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 "@/site/brand";
import { SITE_INFO, SITE_URL } from "@/lib/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 "@/site/brand";
import { SITE_URL } from "@/lib/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 "@/site/social";
import { SOCIAL_LINKS } from "@/lib/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 "@/project/components/migration-dialog";
import { StoragePersistenceDialog } from "@/services/storage/components/storage-persistence-dialog";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
import { StoragePersistenceDialog } from "@/components/editor/dialogs/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 "@/editor/use-editor";
import { useEditor } from "@/hooks/use-editor";
import { useProjectsStore } from "./store";
import type {
TProjectMetadata,
TProjectSortKey,
TProjectSortOption,
} from "@/project/types";
} from "@/lib/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 "@/project/components/delete-project-dialog";
import { ProjectInfoDialog } from "@/project/components/project-info-dialog";
import { RenameProjectDialog } from "@/project/components/rename-project-dialog";
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 { cn } from "@/utils/ui";
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
import { ChangelogNotification } from "@/lib/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 "@/project/types";
import type { TProjectSortKey } from "@/lib/project/types";
export type ProjectsViewMode = "grid" | "list";
+1 -1
View File
@@ -1,5 +1,5 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/site/brand";
import { SITE_URL } from "@/lib/site/brand";
export default function robots(): MetadataRoute.Robots {
return {
+2 -2
View File
@@ -1,6 +1,6 @@
import { Feed } from "feed";
import { getPosts } from "@/blog/query";
import { SITE_INFO, SITE_URL } from "@/site/brand";
import { getPosts } from "@/lib/blog/query";
import { SITE_INFO, SITE_URL } from "@/lib/site/brand";
export async function GET() {
try {
+2 -2
View File
@@ -1,5 +1,5 @@
import { SITE_URL } from "@/site/brand";
import { getPosts } from "@/blog/query";
import { SITE_URL } from "@/lib/site/brand";
import { getPosts } from "@/lib/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 "@/site/sponsors";
import { SPONSORS, type Sponsor } from "@/lib/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 "@/site/social";
import { SOCIAL_LINKS } from "@/lib/site/social";
export const metadata: Metadata = {
title: "Terms of Service - OpenCut",
-19
View File
@@ -1,19 +0,0 @@
---
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."
-31
View File
@@ -1,31 +0,0 @@
import type { EditorSelectionPatch } from "@/selection/editor-selection";
import type { ElementRef } from "@/timeline/types";
export interface CommandResult {
selection?: EditorSelectionPatch;
}
export function createElementSelectionResult(
selectedElements: ElementRef[],
): CommandResult {
return {
selection: {
selectedElements,
selectedKeyframes: [],
keyframeSelectionAnchor: null,
selectedMaskPoints: null,
},
};
}
export abstract class Command {
abstract execute(): CommandResult | undefined;
undo(): void {
throw new Error("Undo not implemented for this command");
}
redo(): CommandResult | undefined {
return this.execute();
}
}
@@ -1,131 +0,0 @@
import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command";
import {
getCustomMaskClosedStateAfterPointRemoval,
removeCustomMaskPoints,
} from "@/masks/custom-path";
import type { CustomMask } from "@/masks/types";
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/timeline";
function deletePointsFromCustomMask({
mask,
pointIds,
}: {
mask: CustomMask;
pointIds: string[];
}): CustomMask {
const points = mask.params.path;
const nextPoints = removeCustomMaskPoints({ points, pointIds });
if (nextPoints.length === points.length) {
return mask;
}
return {
...mask,
params: {
...mask.params,
path: nextPoints,
closed: getCustomMaskClosedStateAfterPointRemoval({
wasClosed: mask.params.closed,
remainingPointCount: nextPoints.length,
}),
},
};
}
function deletePointsFromElementMask({
element,
maskId,
pointIds,
}: {
element: MaskableElement;
maskId: string;
pointIds: string[];
}): { element: MaskableElement; didDeletePoints: boolean } {
const currentMasks = element.masks ?? [];
let didDeletePoints = false;
const nextMasks = currentMasks.map((mask) => {
if (mask.id !== maskId || mask.type !== "custom") {
return mask;
}
const nextMask = deletePointsFromCustomMask({
mask,
pointIds,
});
didDeletePoints ||= nextMask !== mask;
return nextMask;
});
return {
element: didDeletePoints ? { ...element, masks: nextMasks } : element,
didDeletePoints,
};
}
export class DeleteCustomMaskPointsCommand extends Command {
private savedState: SceneTracks | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly maskId: string;
private readonly pointIds: string[];
constructor({
trackId,
elementId,
maskId,
pointIds,
}: {
trackId: string;
elementId: string;
maskId: string;
pointIds: string[];
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.maskId = maskId;
this.pointIds = pointIds;
}
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedState = editor.scenes.getActiveScene().tracks;
let didDeletePoints = false;
const updatedTracks = updateElementInSceneTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isMaskableElement,
update: (element) => {
const result = deletePointsFromElementMask({
element: element as MaskableElement,
maskId: this.maskId,
pointIds: this.pointIds,
});
didDeletePoints ||= result.didDeletePoints;
return result.element;
},
});
if (didDeletePoints) {
editor.timeline.updateTracks(updatedTracks);
return {
selection: {
selectedMaskPoints: null,
},
};
}
return undefined;
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -1,4 +0,0 @@
export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points";
export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point";
export { RemoveMaskCommand } from "./remove-mask";
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
@@ -1,169 +0,0 @@
import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command";
import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom";
import type { ElementBounds } from "@/preview/element-bounds";
import type { CustomMask } from "@/masks/types";
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/timeline";
function insertPointIntoCustomMask({
mask,
segmentIndex,
canvasPoint,
bounds,
}: {
mask: CustomMask;
segmentIndex: number;
canvasPoint: { x: number; y: number };
bounds: ElementBounds;
}): { mask: CustomMask; insertedPointId: string | null } {
const result = insertPointOnCustomMaskSegment({
params: mask.params,
segmentIndex,
canvasPoint,
bounds,
});
if (!result) {
return {
mask,
insertedPointId: null,
};
}
return {
mask: {
...mask,
params: result.params,
},
insertedPointId: result.pointId,
};
}
function insertPointIntoElementMask({
element,
maskId,
segmentIndex,
canvasPoint,
bounds,
}: {
element: MaskableElement;
maskId: string;
segmentIndex: number;
canvasPoint: { x: number; y: number };
bounds: ElementBounds;
}): {
element: MaskableElement;
didInsertPoint: boolean;
insertedPointId: string | null;
} {
const currentMasks = element.masks ?? [];
let insertedPointId: string | null = null;
let didInsertPoint = false;
const nextMasks = currentMasks.map((mask) => {
if (mask.id !== maskId || mask.type !== "custom") {
return mask;
}
const result = insertPointIntoCustomMask({
mask,
segmentIndex,
canvasPoint,
bounds,
});
if (result.insertedPointId) {
insertedPointId = result.insertedPointId;
didInsertPoint = true;
}
return result.mask;
});
return {
element: didInsertPoint ? { ...element, masks: nextMasks } : element,
didInsertPoint,
insertedPointId,
};
}
export class InsertCustomMaskPointCommand extends Command {
private savedState: SceneTracks | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly maskId: string;
private readonly segmentIndex: number;
private readonly canvasPoint: { x: number; y: number };
private readonly bounds: ElementBounds;
constructor({
trackId,
elementId,
maskId,
segmentIndex,
canvasPoint,
bounds,
}: {
trackId: string;
elementId: string;
maskId: string;
segmentIndex: number;
canvasPoint: { x: number; y: number };
bounds: ElementBounds;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.maskId = maskId;
this.segmentIndex = segmentIndex;
this.canvasPoint = canvasPoint;
this.bounds = bounds;
}
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedState = editor.scenes.getActiveScene().tracks;
let didInsertPoint = false;
let insertedPointId: string | null = null;
const updatedTracks = updateElementInSceneTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isMaskableElement,
update: (element) => {
const result = insertPointIntoElementMask({
element: element as MaskableElement,
maskId: this.maskId,
segmentIndex: this.segmentIndex,
canvasPoint: this.canvasPoint,
bounds: this.bounds,
});
didInsertPoint ||= result.didInsertPoint;
insertedPointId ??= result.insertedPointId;
return result.element;
},
});
if (!didInsertPoint || !insertedPointId) {
return undefined;
}
editor.timeline.updateTracks(updatedTracks);
return {
selection: {
selectedMaskPoints: {
trackId: this.trackId,
elementId: this.elementId,
maskId: this.maskId,
pointIds: [insertedPointId],
},
},
};
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -1,188 +0,0 @@
import {
Command,
createElementSelectionResult,
type CommandResult,
} from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline";
import {
buildEmptyTrack,
validateElementTrackCompatibility,
} from "@/timeline/placement";
import type {
PlannedElementMove,
PlannedTrackCreation,
} from "@/timeline/group-move";
import { normalizeTimelineElement } from "@/timeline/normalize";
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
export class MoveElementCommand extends Command {
private savedState: SceneTracks | null = null;
constructor({
moves,
createTracks = [],
}: {
moves: PlannedElementMove[];
createTracks?: PlannedTrackCreation[];
}) {
super();
this.moves = moves;
this.createTracks = createTracks;
}
private readonly moves: PlannedElementMove[];
private readonly createTracks: PlannedTrackCreation[];
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedState = editor.scenes.getActiveScene().tracks;
let tracksToUpdate = this.savedState;
for (const createTrack of [...this.createTracks].sort(
(firstTrack, secondTrack) => firstTrack.index - secondTrack.index,
)) {
tracksToUpdate = insertTrackAtDisplayIndex({
tracks: tracksToUpdate,
track: buildEmptyTrack({
id: createTrack.id,
type: createTrack.type,
}),
insertIndex: createTrack.index,
});
}
const movedElementsById = new Map<string, TimelineElement>();
for (const move of this.moves) {
const sourceTrack = findTrackInSceneTracks({
tracks: this.savedState,
trackId: move.sourceTrackId,
});
const sourceElement = sourceTrack?.elements.find(
(trackElement) => trackElement.id === move.elementId,
);
if (!sourceTrack || !sourceElement) {
throw new Error("Source track or element not found");
}
const targetTrack = findTrackInSceneTracks({
tracks: tracksToUpdate,
trackId: move.targetTrackId,
});
if (!targetTrack) {
throw new Error("Target track not found");
}
const validation = validateElementTrackCompatibility({
element: sourceElement,
track: targetTrack,
});
if (!validation.isValid) {
throw new Error(validation.errorMessage);
}
movedElementsById.set(move.elementId, {
...normalizeTimelineElement({
element: {
...sourceElement,
startTime: move.newStartTime,
},
}),
});
}
const movedElementIds = new Set(this.moves.map((move) => move.elementId));
const movedElementsByTargetTrackId = new Map<string, TimelineElement[]>();
for (const move of this.moves) {
const movedElement = movedElementsById.get(move.elementId);
if (!movedElement) {
continue;
}
const nextTargetElements =
movedElementsByTargetTrackId.get(move.targetTrackId) ?? [];
nextTargetElements.push(movedElement);
movedElementsByTargetTrackId.set(move.targetTrackId, nextTargetElements);
}
const updatedTracks = mapSceneTracks({
tracks: tracksToUpdate,
update: (track) => ({
...track,
elements: [
...track.elements.filter(
(element) => !movedElementIds.has(element.id),
),
...(movedElementsByTargetTrackId.get(track.id) ?? []),
],
}),
});
editor.timeline.updateTracks(updatedTracks);
return createElementSelectionResult(
this.moves.map(({ elementId, targetTrackId }) => ({
trackId: targetTrackId,
elementId,
})),
);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
function mapSceneTracks({
tracks,
update,
}: {
tracks: SceneTracks;
update: <TTrack extends TimelineTrack>(track: TTrack) => TTrack;
}): SceneTracks {
return {
overlay: tracks.overlay.map((track) => update(track)),
main: update(tracks.main),
audio: tracks.audio.map((track) => update(track)),
};
}
function insertTrackAtDisplayIndex({
tracks,
track,
insertIndex,
}: {
tracks: SceneTracks;
track: TimelineTrack;
insertIndex: number;
}): SceneTracks {
if (track.type === "audio") {
const audioInsertIndex = Math.max(
0,
Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length),
);
return {
...tracks,
audio: [
...tracks.audio.slice(0, audioInsertIndex),
track,
...tracks.audio.slice(audioInsertIndex),
],
};
}
const overlayInsertIndex = Math.max(
0,
Math.min(insertIndex, tracks.overlay.length),
);
return {
...tracks,
overlay: [
...tracks.overlay.slice(0, overlayInsertIndex),
track,
...tracks.overlay.slice(overlayInsertIndex),
],
};
}
@@ -7,7 +7,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useEditor } from "@/editor/use-editor";
import { useEditor } from "@/hooks/use-editor";
import { Loader2 } from "lucide-react";
export function MigrationDialog() {
@@ -6,7 +6,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { TProjectMetadata } from "@/project/types";
import type { TProjectMetadata } from "@/lib/project/types";
import { formatDate } from "@/utils/date";
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
import { Button } from "@/components/ui/button";
@@ -5,8 +5,8 @@ import { toast } from "sonner";
import {
type KeyboardShortcut,
useKeyboardShortcutsHelp,
} from "@/actions/use-keyboard-shortcuts-help";
import { useKeybindingsStore } from "@/actions/keybindings-store";
} from "@/hooks/use-keyboard-shortcuts-help";
import { useKeybindingsStore } from "@/stores/keybindings-store";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -9,7 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useStoragePersistence } from "@/services/storage/use-storage-persistence";
import { useStoragePersistence } from "@/hooks/use-storage-persistence";
export function StoragePersistenceDialog() {
const { showDialog, onConfirm, onDismiss } = useStoragePersistence();
@@ -10,20 +10,20 @@ import {
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import Link from "next/link";
import { RenameProjectDialog } from "@/project/components/rename-project-dialog";
import { DeleteProjectDialog } from "@/project/components/delete-project-dialog";
import { RenameProjectDialog } from "./dialogs/rename-project-dialog";
import { DeleteProjectDialog } from "./dialogs/delete-project-dialog";
import { useRouter } from "next/navigation";
import { FaDiscord } from "react-icons/fa6";
import { ExportButton } from "./export-button";
import { FeedbackPopover } from "@/feedback/components/feedback-popover";
import { FeedbackPopover } from "@/lib/feedback/components/feedback-popover";
import { ThemeToggle } from "../theme-toggle";
import { DEFAULT_LOGO_URL } from "@/site/brand";
import { SOCIAL_LINKS } from "@/site/social";
import { DEFAULT_LOGO_URL } from "@/lib/site/brand";
import { SOCIAL_LINKS } from "@/lib/site/social";
import { toast } from "sonner";
import { useEditor } from "@/editor/use-editor";
import { useEditor } from "@/hooks/use-editor";
import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { ShortcutsDialog } from "@/actions/components/shortcuts-dialog";
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
import Image from "next/image";
import { cn } from "@/utils/ui";
@@ -18,22 +18,22 @@ import {
getExportMimeType,
getExportFileExtension,
downloadBuffer,
} from "@/export";
} from "@/lib/export";
import { Check, Copy, Download, RotateCcw } from "lucide-react";
import {
EXPORT_FORMAT_VALUES,
EXPORT_QUALITY_VALUES,
type ExportFormat,
type ExportQuality,
} from "@/export";
} from "@/lib/export";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
} from "@/components/section";
import { useEditor } from "@/editor/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/export/defaults";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/lib/export/defaults";
function isExportFormat(value: string): value is ExportFormat {
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
@@ -3,8 +3,8 @@
import { ArrowRightIcon } from "lucide-react";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import { SOCIAL_LINKS } from "@/site/social";
import { useLocalStorage } from "@/services/storage/use-local-storage";
import { SOCIAL_LINKS } from "@/lib/site/social";
import { useLocalStorage } from "@/hooks/storage/use-local-storage";
import { Button } from "../ui/button";
import { Dialog, DialogBody, DialogContent, DialogTitle } from "../ui/dialog";
@@ -10,9 +10,9 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useEditor } from "@/editor/use-editor";
import { clearDragData, setDragData } from "@/timeline/drag-data";
import type { TimelineDragData } from "@/timeline/drag";
import { useEditor } from "@/hooks/use-editor";
import { clearDragData, setDragData } from "@/lib/drag-data";
import type { TimelineDragData } from "@/lib/timeline/drag";
import { cn } from "@/utils/ui";
export interface DraggableItemProps {
@@ -1,15 +1,15 @@
"use client";
import { Separator } from "@/components/ui/separator";
import { type Tab, useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store";
import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store";
import { TabBar } from "./tabbar";
import { Captions } from "@/subtitles/components/assets-view";
import { Captions } from "./views/captions";
import { MediaView } from "./views/assets";
import { SettingsView } from "./views/settings";
import { SoundsView } from "@/sounds/components/assets-view";
import { StickersView } from "@/stickers/components/assets-view";
import { TextView } from "@/text/components/assets-view";
import { EffectsView } from "@/effects/components/assets-view";
import { SoundsView } from "./views/sounds";
import { StickersView } from "./views/stickers";
import { TextView } from "./views/text";
import { EffectsView } from "./views/effects";
export function AssetsPanel() {
const { activeTab } = useAssetsPanelStore();
@@ -12,7 +12,7 @@ import {
TAB_KEYS,
tabs,
useAssetsPanelStore,
} from "@/components/editor/panels/assets/assets-panel-store";
} from "@/stores/assets-panel-store";
export function TabBar() {
const { activeTab, setActiveTab } = useAssetsPanelStore();
@@ -25,28 +25,28 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
import { TICKS_PER_SECOND } from "@/wasm";
import { useEditor } from "@/editor/use-editor";
import { useFileUpload } from "@/media/use-file-upload";
import { invokeAction } from "@/actions";
import { processMediaAssets } from "@/media/processing";
import { showMediaUploadToast } from "@/media/upload-toast";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
import { invokeAction } from "@/lib/actions";
import { processMediaAssets } from "@/lib/media/processing";
import { showMediaUploadToast } from "@/lib/media/upload-toast";
import {
SelectableItem,
SelectableSurface,
useSelection,
useSelectionScope,
} from "@/selection";
import { buildElementFromMedia } from "@/timeline/element-utils";
} from "@/lib/selection";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import {
type MediaSortKey,
type MediaSortOrder,
type MediaViewMode,
useAssetsPanelStore,
} from "@/components/editor/panels/assets/assets-panel-store";
import { MASKABLE_ELEMENT_TYPES } from "@/timeline";
import type { MediaAsset } from "@/media/types";
} from "@/stores/assets-panel-store";
import { MASKABLE_ELEMENT_TYPES } from "@/lib/timeline";
import type { MediaAsset } from "@/lib/media/types";
import { cn } from "@/utils/ui";
import {
CloudUploadIcon,
@@ -8,21 +8,21 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useReducer, useRef, useState } from "react";
import { extractTimelineAudio } from "@/media/mediabunny";
import { useEditor } from "@/editor/use-editor";
import { TRANSCRIPTION_DIAGNOSTICS_SCOPE } from "@/transcription/diagnostics";
import { DEFAULT_TRANSCRIPTION_SAMPLE_RATE } from "@/transcription/audio";
import { TRANSCRIPTION_LANGUAGES } from "@/transcription/supported-languages";
import { extractTimelineAudio } from "@/lib/media/mediabunny";
import { useEditor } from "@/hooks/use-editor";
import { TRANSCRIPTION_DIAGNOSTICS_SCOPE } from "@/lib/transcription/diagnostics";
import { DEFAULT_TRANSCRIPTION_SAMPLE_RATE } from "@/lib/transcription/audio";
import { TRANSCRIPTION_LANGUAGES } from "@/lib/transcription/supported-languages";
import type {
CaptionChunk,
TranscriptionLanguage,
TranscriptionProgress,
} from "@/transcription/types";
} from "@/lib/transcription/types";
import { transcriptionService } from "@/services/transcription/service";
import { decodeAudioToFloat32 } from "@/media/audio";
import { buildCaptionChunks } from "@/transcription/caption";
import { insertCaptionChunksAsTextTrack } from "@/subtitles/insert";
import { parseSubtitleFile } from "@/subtitles/parse";
import { decodeAudioToFloat32 } from "@/lib/media/audio";
import { buildCaptionChunks } from "@/lib/transcription/caption";
import { insertCaptionChunksAsTextTrack } from "@/lib/subtitles/insert";
import { parseSubtitleFile } from "@/lib/subtitles/parse";
import { Spinner } from "@/components/ui/spinner";
import {
Section,
@@ -38,7 +38,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { DiagnosticSeverity } from "@/diagnostics/types";
import type { DiagnosticSeverity } from "@/lib/diagnostics/types";
const DIAGNOSTIC_BUTTON_VARIANT: Record<
DiagnosticSeverity,
@@ -254,7 +254,9 @@ export function Captions() {
<HugeiconsIcon icon={AlertCircleIcon} size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{diagnostic.message}</TooltipContent>
<TooltipContent>
{diagnostic.message}
</TooltipContent>
</Tooltip>
))}
<Button
@@ -3,11 +3,11 @@
import { useEffect, useRef, useCallback } from "react";
import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { effectsRegistry, EFFECT_TARGET_ELEMENT_TYPES } from "@/effects";
import { effectsRegistry, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects";
import { effectPreviewService } from "@/services/renderer/effect-preview";
import { useEditor } from "@/editor/use-editor";
import { buildEffectElement } from "@/timeline/element-utils";
import type { EffectDefinition } from "@/effects/types";
import { useEditor } from "@/hooks/use-editor";
import { buildEffectElement } from "@/lib/timeline/element-utils";
import type { EffectDefinition } from "@/lib/effects/types";
export function EffectsView() {
const effects = effectsRegistry.getAll();
@@ -1,309 +1,226 @@
"use client";
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
} from "@/components/section";
import { ColorPickerContent } from "@/components/ui/color-picker";
import { Popover, PopoverTrigger } from "@/components/ui/popover";
import {
BACKGROUND_BLUR_INTENSITY_PRESETS,
DEFAULT_BACKGROUND_BLUR_INTENSITY,
} from "@/background/blur";
import { DEFAULT_BACKGROUND_COLOR } from "@/background/color";
import { patternCraftGradients } from "@/data/colors/pattern-craft";
import { colors } from "@/data/colors/solid";
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
import { useEditor } from "@/editor/use-editor";
import { effectPreviewService } from "@/services/renderer/effect-preview";
import { cn } from "@/utils/ui";
const BLUR_PREVIEW_UNIFORM_DIMENSIONS = {
width: 1920,
height: 1080,
} as const;
const CUSTOM_COLOR_SWATCH_BACKGROUND =
"conic-gradient(from 180deg at 50% 50%, #ff5e5e 0deg, #ffb35e 55deg, #fff26b 110deg, #6bff8f 165deg, #5ee7ff 220deg, #6f7cff 275deg, #d76bff 330deg, #ff5e9b 360deg)";
const BlurPreview = memo(
({
blur,
isSelected,
onSelect,
}: {
blur: { label: string; value: number };
isSelected: boolean;
onSelect: () => void;
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const renderPreview = () => {
if (!canvasRef.current) return;
effectPreviewService.renderPreview({
effectType: "blur",
params: { intensity: blur.value },
targetCanvas: canvasRef.current,
uniformDimensions: BLUR_PREVIEW_UNIFORM_DIMENSIONS,
});
};
renderPreview();
return effectPreviewService.onPreviewImageReady({
callback: renderPreview,
});
}, [blur.value]);
return (
<button
className={cn(
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
isSelected && "border-primary border-2",
)}
onClick={onSelect}
type="button"
aria-label={`Select ${blur.label} blur`}
>
<canvas
ref={canvasRef}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute right-1 bottom-1 left-1 text-center">
<span className="rounded bg-black/50 px-1 text-xs text-white">
{blur.label}
</span>
</div>
</button>
);
},
);
BlurPreview.displayName = "BlurPreview";
const BackgroundPreviews = memo(
({
backgrounds,
currentBackgroundColor,
isColorBackground,
onSelect,
useBackgroundColor = false,
}: {
backgrounds: readonly string[];
currentBackgroundColor: string;
isColorBackground: boolean;
onSelect: (bg: string) => void;
useBackgroundColor?: boolean;
}) => {
return useMemo(
() =>
backgrounds.map((bg) => (
<button
key={bg}
className={cn(
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
isColorBackground &&
bg.toLowerCase() === currentBackgroundColor.toLowerCase() &&
"border-primary border-2",
)}
style={
useBackgroundColor
? { backgroundColor: bg }
: {
background: bg,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}
}
onClick={() => onSelect(bg)}
type="button"
aria-label={`Select background ${bg}`}
/>
)),
[
backgrounds,
isColorBackground,
currentBackgroundColor,
onSelect,
useBackgroundColor,
],
);
},
);
BackgroundPreviews.displayName = "BackgroundPreviews";
function CustomColorPreview({
currentBackgroundColor,
isSelected,
onPreview,
onCommit,
}: {
currentBackgroundColor: string;
isSelected: boolean;
onPreview: (color: string) => void;
onCommit: (color: string) => void;
}) {
return (
<Popover>
<PopoverTrigger asChild>
<button
className={cn(
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
isSelected && "border-primary border-2",
)}
type="button"
aria-label="Pick a custom background color"
>
<span
className="absolute inset-0"
style={{ background: CUSTOM_COLOR_SWATCH_BACKGROUND }}
/>
<span
className="absolute right-1 bottom-1 size-5 rounded-sm border border-white/70 shadow-sm"
style={{ backgroundColor: currentBackgroundColor }}
/>
</button>
</PopoverTrigger>
<ColorPickerContent
value={currentBackgroundColor.replace(/^#/, "").toUpperCase()}
onChange={(color) => onPreview(`#${color}`)}
onChangeEnd={(color) => onCommit(`#${color}`)}
/>
</Popover>
);
}
const COLOR_SECTIONS = [
{ id: "colors", title: "Colors", backgrounds: colors, useBackgroundColor: true, showCustomPicker: true },
{ id: "pattern-craft", title: "Pattern craft", backgrounds: patternCraftGradients, showCustomPicker: false },
{ id: "syntax-ui", title: "Syntax UI", backgrounds: syntaxUIGradients, showCustomPicker: false },
] as const;
export function BackgroundContent() {
const editor = useEditor();
const activeProject = useEditor((e) => e.project.getActive());
const handleBlurSelect = useCallback(
async (blurIntensity: number) => {
await editor.project.updateSettings({
settings: { background: { type: "blur", blurIntensity } },
});
},
[editor.project],
);
const previewBackgroundColor = useCallback(
async (color: string) => {
await editor.project.updateSettings({
settings: { background: { type: "color", color } },
pushHistory: false,
});
},
[editor.project],
);
const commitBackgroundColor = useCallback(
async (color: string) => {
await editor.project.updateSettings({
settings: { background: { type: "color", color } },
pushHistory: true,
});
},
[editor.project],
);
const isBlurBackground = activeProject.settings.background.type === "blur";
const isColorBackground = activeProject.settings.background.type === "color";
const currentBlurIntensity = isBlurBackground
? (activeProject.settings.background as { blurIntensity: number })
.blurIntensity
: DEFAULT_BACKGROUND_BLUR_INTENSITY;
const currentBackgroundColor = isColorBackground
? (activeProject.settings.background as { color: string }).color
: DEFAULT_BACKGROUND_COLOR;
const hasPresetColorMatch = colors.some(
(color) => color.toLowerCase() === currentBackgroundColor.toLowerCase(),
);
const handlePresetColorSelect = useCallback(
(color: string) => {
void commitBackgroundColor(color);
},
[commitBackgroundColor],
);
const blurPreviews = useMemo(
() =>
BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
<BlurPreview
key={blur.value}
blur={blur}
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
onSelect={() => handleBlurSelect(blur.value)}
/>
)),
[isBlurBackground, currentBlurIntensity, handleBlurSelect],
);
return (
<div className="flex flex-col">
<Section
collapsible
defaultOpen={true}
sectionKey="background-blur"
showTopBorder={false}
>
<SectionHeader>
<SectionTitle>Blur</SectionTitle>
</SectionHeader>
<SectionContent>
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
</SectionContent>
</Section>
{COLOR_SECTIONS.map((section) => (
<Section
key={section.id}
collapsible
defaultOpen={false}
sectionKey={`settings:background-${section.id}`}
>
<SectionHeader>
<SectionTitle>{section.title}</SectionTitle>
</SectionHeader>
<SectionContent>
<div className="flex flex-wrap gap-2">
{section.showCustomPicker ? (
<CustomColorPreview
currentBackgroundColor={currentBackgroundColor}
isSelected={isColorBackground && !hasPresetColorMatch}
onPreview={previewBackgroundColor}
onCommit={commitBackgroundColor}
/>
) : null}
<BackgroundPreviews
backgrounds={section.backgrounds}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
onSelect={handlePresetColorSelect}
useBackgroundColor={
"useBackgroundColor" in section
? section.useBackgroundColor
: false
}
/>
</div>
</SectionContent>
</Section>
))}
</div>
);
}
"use client";
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
} from "@/components/section";
import {
BACKGROUND_BLUR_INTENSITY_PRESETS,
DEFAULT_BACKGROUND_BLUR_INTENSITY,
} from "@/lib/background/blur";
import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color";
import { patternCraftGradients } from "@/data/colors/pattern-craft";
import { colors } from "@/data/colors/solid";
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
import { useEditor } from "@/hooks/use-editor";
import { effectPreviewService } from "@/services/renderer/effect-preview";
import { cn } from "@/utils/ui";
const BLUR_PREVIEW_UNIFORM_DIMENSIONS = {
width: 1920,
height: 1080,
} as const;
const BlurPreview = memo(
({
blur,
isSelected,
onSelect,
}: {
blur: { label: string; value: number };
isSelected: boolean;
onSelect: () => void;
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const renderPreview = () => {
if (!canvasRef.current) return;
effectPreviewService.renderPreview({
effectType: "blur",
params: { intensity: blur.value },
targetCanvas: canvasRef.current,
uniformDimensions: BLUR_PREVIEW_UNIFORM_DIMENSIONS,
});
};
renderPreview();
return effectPreviewService.onPreviewImageReady({ callback: renderPreview });
}, [blur.value]);
return (
<button
className={cn(
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
isSelected && "border-primary border-2",
)}
onClick={onSelect}
type="button"
aria-label={`Select ${blur.label} blur`}
>
<canvas
ref={canvasRef}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute right-1 bottom-1 left-1 text-center">
<span className="rounded bg-black/50 px-1 text-xs text-white">
{blur.label}
</span>
</div>
</button>
);
},
);
BlurPreview.displayName = "BlurPreview";
const BackgroundPreviews = memo(
({
backgrounds,
currentBackgroundColor,
isColorBackground,
onSelect,
useBackgroundColor = false,
}: {
backgrounds: string[];
currentBackgroundColor: string;
isColorBackground: boolean;
onSelect: (bg: string) => void;
useBackgroundColor?: boolean;
}) => {
return useMemo(
() =>
backgrounds.map((bg) => (
<button
key={bg}
className={cn(
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
isColorBackground &&
bg === currentBackgroundColor &&
"border-primary border-2",
)}
style={
useBackgroundColor
? { backgroundColor: bg }
: {
background: bg,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}
}
onClick={() => onSelect(bg)}
type="button"
aria-label={`Select background ${bg}`}
/>
)),
[
backgrounds,
isColorBackground,
currentBackgroundColor,
onSelect,
useBackgroundColor,
],
);
},
);
BackgroundPreviews.displayName = "BackgroundPreviews";
const COLOR_SECTIONS = [
{ title: "Colors", backgrounds: colors, useBackgroundColor: true },
{ title: "Pattern craft", backgrounds: patternCraftGradients },
{ title: "Syntax UI", backgrounds: syntaxUIGradients },
] as const;
export function BackgroundContent() {
const editor = useEditor();
const activeProject = useEditor((e) => e.project.getActive());
const handleBlurSelect = useCallback(
async (blurIntensity: number) => {
await editor.project.updateSettings({
settings: { background: { type: "blur", blurIntensity } },
});
},
[editor.project],
);
const handleColorSelect = useCallback(
async (color: string) => {
await editor.project.updateSettings({
settings: { background: { type: "color", color } },
});
},
[editor.project],
);
const isBlurBackground = activeProject.settings.background.type === "blur";
const isColorBackground = activeProject.settings.background.type === "color";
const currentBlurIntensity = isBlurBackground
? (activeProject.settings.background as { blurIntensity: number })
.blurIntensity
: DEFAULT_BACKGROUND_BLUR_INTENSITY;
const currentBackgroundColor = isColorBackground
? (activeProject.settings.background as { color: string }).color
: DEFAULT_BACKGROUND_COLOR;
const blurPreviews = useMemo(
() =>
BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
<BlurPreview
key={blur.value}
blur={blur}
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
onSelect={() => handleBlurSelect(blur.value)}
/>
)),
[isBlurBackground, currentBlurIntensity, handleBlurSelect],
);
return (
<div className="flex flex-col">
<Section collapsible defaultOpen={true} sectionKey="background-blur" showTopBorder={false}>
<SectionHeader>
<SectionTitle>Blur</SectionTitle>
</SectionHeader>
<SectionContent>
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
</SectionContent>
</Section>
{COLOR_SECTIONS.map((section) => (
<Section
key={section.title}
collapsible
defaultOpen={false}
sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`}
>
<SectionHeader>
<SectionTitle>{section.title}</SectionTitle>
</SectionHeader>
<SectionContent>
<div className="flex flex-wrap gap-2">
<BackgroundPreviews
backgrounds={section.backgrounds as string[]}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
onSelect={handleColorSelect}
useBackgroundColor={
"useBackgroundColor" in section
? section.useBackgroundColor
: false
}
/>
</div>
</SectionContent>
</Section>
))}
</div>
);
}
@@ -9,9 +9,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FPS_PRESETS } from "@/fps/presets";
import { floatToFrameRate, frameRateToFloat } from "@/fps/utils";
import { useEditor } from "@/editor/use-editor";
import { FPS_PRESETS } from "@/lib/fps/presets";
import { floatToFrameRate, frameRateToFloat } from "@/lib/fps/utils";
import { useEditor } from "@/hooks/use-editor";
import {
Section,
SectionContent,
@@ -22,7 +22,7 @@ import { BackgroundContent } from "./background";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { NumberField } from "@/components/ui/number-field";
import { useEditorStore } from "@/editor/editor-store";
import { useEditorStore } from "@/stores/editor-store";
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
import { HugeiconsIcon } from "@hugeicons/react";
import { Tick02Icon } from "@hugeicons/core-free-icons";
@@ -30,7 +30,7 @@ import { cn } from "@/utils/ui";
import { dimensionToAspectRatio } from "@/utils/geometry";
import { formatNumberForDisplay } from "@/utils/math";
import { OcSquarePlusIcon } from "@/components/icons";
import type { TCanvasSize } from "@/project/types";
import type { TCanvasSize } from "@/lib/project/types";
type SettingsView = "project-info" | "background";
@@ -22,9 +22,9 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { useSoundSearch } from "@/sounds/use-sound-search";
import { useSoundsStore } from "@/sounds/sounds-store";
import type { SavedSound, SoundEffect } from "@/sounds/types";
import { useSoundSearch } from "@/hooks/use-sound-search";
import { useSoundsStore } from "@/stores/sounds-store";
import type { SavedSound, SoundEffect } from "@/lib/sounds/types";
import { cn } from "@/utils/ui";
import {
FavouriteIcon,
@@ -9,22 +9,22 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useEditor } from "@/editor/use-editor";
import { resolveStickerIntrinsicSize } from "@/stickers";
import { useEditor } from "@/hooks/use-editor";
import { resolveStickerIntrinsicSize } from "@/lib/stickers";
import {
buildGraphicElement,
buildStickerElement,
} from "@/timeline/element-utils";
import { STICKER_CATEGORIES } from "@/stickers/categories";
import { getRegionLabel, resolveQueryToRegions } from "@/stickers";
import { parseShapeStickerId } from "@/stickers/providers/shapes";
import type { TimelineDragData } from "@/timeline/drag";
} from "@/lib/timeline/element-utils";
import { STICKER_CATEGORIES } from "@/lib/stickers/categories";
import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers";
import { parseShapeStickerId } from "@/lib/stickers/providers/shapes";
import type { TimelineDragData } from "@/lib/timeline/drag";
import type {
StickerBrowseSection,
StickerCategory,
StickerItem as StickerData,
} from "@/stickers";
import { useStickersStore } from "@/stickers/stickers-store";
} from "@/lib/stickers";
import { useStickersStore } from "@/stores/stickers-store";
import { cn } from "@/utils/ui";
import {
HappyIcon,
@@ -1,8 +1,8 @@
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { useEditor } from "@/editor/use-editor";
import { DEFAULTS } from "@/timeline/defaults";
import { buildTextElement } from "@/timeline/element-utils";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { buildTextElement } from "@/lib/timeline/element-utils";
export function TextView() {
const editor = useEditor();
@@ -0,0 +1,59 @@
"use client";
import { useEditor } from "@/hooks/use-editor";
import { findCurrentScene } from "@/lib/scenes";
import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks";
export function BookmarkNoteOverlay() {
const editor = useEditor();
const currentTime = editor.playback.getCurrentTime();
const activeProject = editor.project.getActive();
if (!activeProject) {
return null;
}
const activeScene = findCurrentScene({
scenes: activeProject.scenes,
currentSceneId: activeProject.currentSceneId,
});
if (!activeScene) {
return null;
}
const bookmarks = activeScene.bookmarks;
const activeBookmarks = getBookmarksActiveAtTime({
bookmarks,
time: currentTime,
});
const bookmarksWithNotes = activeBookmarks.filter(
(bookmark) => bookmark.note != null && bookmark.note.trim() !== "",
);
if (bookmarksWithNotes.length === 0) {
return null;
}
return (
<div
className="pointer-events-none absolute top-2 left-2 flex flex-col gap-1.5"
aria-live="polite"
>
{bookmarksWithNotes.map((bookmark) => (
<div
key={bookmark.time}
className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm"
style={{
backgroundColor: "rgb(0 0 0 / 0.5)",
borderLeft: bookmark.color
? `3px solid ${bookmark.color}`
: "3px solid var(--primary)",
}}
>
{bookmark.note}
</div>
))}
</div>
);
}
@@ -1,85 +1,73 @@
"use client";
import {
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
} from "@/components/ui/context-menu";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import { useEditor } from "@/editor/use-editor";
import type { PreviewOverlayControl } from "@/preview/overlays";
import { toast } from "sonner";
export function PreviewContextMenu({
onToggleFullscreen,
containerRef,
overlayControls,
onOverlayVisibilityChange,
}: {
onToggleFullscreen: () => void;
containerRef: React.RefObject<HTMLElement | null>;
overlayControls: PreviewOverlayControl[];
onOverlayVisibilityChange: (params: {
overlayId: string;
isVisible: boolean;
}) => void;
}) {
const editor = useEditor();
const viewport = usePreviewViewport();
const handleCopySnapshot = async () => {
const result = await editor.renderer.copySnapshot();
if (!result.success) {
toast.error("Failed to copy snapshot", {
description: result.error ?? "Please try again",
});
return;
}
};
const handleSaveSnapshot = async () => {
const result = await editor.renderer.saveSnapshot();
if (!result.success) {
toast.error("Failed to save snapshot", {
description: result.error ?? "Please try again",
});
return;
}
};
return (
<ContextMenuContent className="w-56" container={containerRef.current}>
<ContextMenuItem onClick={viewport.fitToScreen} inset>
Fit to screen
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onToggleFullscreen} inset>
Full screen
</ContextMenuItem>
<ContextMenuItem onClick={handleSaveSnapshot} inset>
Save snapshot
</ContextMenuItem>
<ContextMenuItem onClick={handleCopySnapshot} inset>
Copy snapshot
</ContextMenuItem>
{overlayControls.length > 0 ? <ContextMenuSeparator /> : null}
{overlayControls.map((overlayControl) => (
<ContextMenuCheckboxItem
key={overlayControl.id}
checked={overlayControl.isVisible}
onCheckedChange={(checked) =>
onOverlayVisibilityChange({
overlayId: overlayControl.id,
isVisible: !!checked,
})
}
>
{overlayControl.label}
</ContextMenuCheckboxItem>
))}
</ContextMenuContent>
);
}
"use client";
import {
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
} from "@/components/ui/context-menu";
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useEditor } from "@/hooks/use-editor";
import { usePreviewStore } from "@/stores/preview-store";
import { toast } from "sonner";
export function PreviewContextMenu({
onToggleFullscreen,
containerRef,
}: {
onToggleFullscreen: () => void;
containerRef: React.RefObject<HTMLElement | null>;
}) {
const editor = useEditor();
const viewport = usePreviewViewport();
const { overlays, setOverlayVisibility } = usePreviewStore();
const handleCopySnapshot = async () => {
const result = await editor.renderer.copySnapshot();
if (!result.success) {
toast.error("Failed to copy snapshot", {
description: result.error ?? "Please try again",
});
return;
}
};
const handleSaveSnapshot = async () => {
const result = await editor.renderer.saveSnapshot();
if (!result.success) {
toast.error("Failed to save snapshot", {
description: result.error ?? "Please try again",
});
return;
}
};
return (
<ContextMenuContent className="w-56" container={containerRef.current}>
<ContextMenuItem onClick={viewport.fitToScreen} inset>
Fit to screen
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onToggleFullscreen} inset>
Full screen
</ContextMenuItem>
<ContextMenuItem onClick={handleSaveSnapshot} inset>
Save snapshot
</ContextMenuItem>
<ContextMenuItem onClick={handleCopySnapshot} inset>
Copy snapshot
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuCheckboxItem
checked={overlays.bookmarks}
onCheckedChange={(checked) =>
setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked })
}
>
Show bookmark notes
</ContextMenuCheckboxItem>
</ContextMenuContent>
);
}
@@ -0,0 +1,32 @@
"use client";
import { getGuideById } from "@/lib/guides";
import { usePreviewStore } from "@/stores/preview-store";
import { usePreviewViewport } from "./preview-viewport";
export function GuideOverlay() {
const activeGuide = usePreviewStore((state) => state.activeGuide);
const viewport = usePreviewViewport();
const guide = getGuideById(activeGuide);
if (!guide) {
return null;
}
return (
<div
className="pointer-events-none absolute"
style={{
left: viewport.sceneLeft,
top: viewport.sceneTop,
width: viewport.sceneWidth,
height: viewport.sceneHeight,
}}
>
{guide.renderOverlay({
width: viewport.sceneWidth,
height: viewport.sceneHeight,
})}
</div>
);
}
@@ -1,8 +1,8 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { GUIDE_REGISTRY, getGuideById } from "@/guides";
import { usePreviewStore } from "@/preview/preview-store";
import { GUIDE_REGISTRY, getGuideById } from "@/lib/guides";
import { usePreviewStore } from "@/stores/preview-store";
import {
Popover,
PopoverContent,
@@ -1,427 +1,332 @@
"use client";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
export const HANDLE_SIZE = 10;
export const HANDLE_HIT_AREA_SIZE = 18;
export const ICON_HANDLE_RADIUS = 10;
export const EDGE_HANDLE_THIN_SIZE = 6;
export const EDGE_HANDLE_THICK_SIZE = 14;
export const LINE_HIT_AREA_SIZE = 48;
export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
const normalized = ((angleDeg % 180) + 180) % 180;
if (normalized < 22.5 || normalized >= 157.5) return "ew-resize";
if (normalized < 67.5) return "nwse-resize";
if (normalized < 112.5) return "ns-resize";
return "nesw-resize";
}
export function HandleButton({
screen,
cursor,
hitAreaSize,
className,
onPointerDown,
onPointerMove,
onPointerUp,
children,
}: {
screen: { x: number; y: number };
cursor?: string;
hitAreaSize: number;
className?: string;
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
className={cn(
"absolute flex items-center justify-center outline-none",
className,
)}
style={{
left: screen.x - hitAreaSize / 2,
top: screen.y - hitAreaSize / 2,
width: hitAreaSize,
height: hitAreaSize,
pointerEvents: "auto",
cursor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
>
{children}
</button>
);
}
export function CornerHandle({
cursor,
screen,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
cursor?: string;
screen: { x: number; y: number };
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
return (
<HandleButton
screen={screen}
cursor={cursor}
hitAreaSize={HANDLE_HIT_AREA_SIZE}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div
className="rounded-sm bg-white"
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
/>
</HandleButton>
);
}
export function CircleHandle({
cursor,
screen,
size = HANDLE_SIZE,
isSelected = false,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
cursor?: string;
screen: { x: number; y: number };
size?: number;
isSelected?: boolean;
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
return (
<HandleButton
screen={screen}
cursor={cursor}
hitAreaSize={HANDLE_HIT_AREA_SIZE}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div
className={cn("rounded-full", isSelected ? "bg-primary" : "bg-white")}
style={{ width: size, height: size }}
/>
</HandleButton>
);
}
export function EdgeHandle({
edge,
screen,
rotation,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
edge: "right" | "left" | "bottom";
screen: { x: number; y: number };
rotation: number;
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
const isHorizontalEdge = edge === "right" || edge === "left";
const width = isHorizontalEdge
? EDGE_HANDLE_THIN_SIZE
: EDGE_HANDLE_THICK_SIZE;
const height = isHorizontalEdge
? EDGE_HANDLE_THICK_SIZE
: EDGE_HANDLE_THIN_SIZE;
const cursor = getResizeCursor({
angleDeg: isHorizontalEdge ? rotation : rotation + 90,
});
return (
<HandleButton
screen={screen}
cursor={cursor}
hitAreaSize={HANDLE_HIT_AREA_SIZE}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div
className="rounded-sm bg-white"
style={{ width, height, transform: `rotate(${rotation}deg)` }}
/>
</HandleButton>
);
}
export function IconHandle({
icon,
screen,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
icon: IconSvgElement;
screen: { x: number; y: number };
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
return (
<HandleButton
screen={screen}
hitAreaSize={ICON_HANDLE_RADIUS * 2}
className="rounded-full bg-white text-black shadow-sm"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
</HandleButton>
);
}
export function BoundingBoxOutline({
center,
outlineWidth,
outlineHeight,
rotation,
cursor,
dashed = false,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
center: { x: number; y: number };
outlineWidth: number;
outlineHeight: number;
rotation: number;
cursor?: string;
dashed?: boolean;
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
return (
<svg
className="absolute overflow-visible"
aria-hidden="true"
focusable="false"
style={{
left: center.x - outlineWidth / 2,
top: center.y - outlineHeight / 2,
width: outlineWidth,
height: outlineHeight,
transform: `rotate(${rotation}deg)`,
transformOrigin: "center center",
pointerEvents: onPointerDown ? "auto" : "none",
cursor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
<rect
x={0.5}
y={0.5}
width={Math.max(outlineWidth - 1, 0)}
height={Math.max(outlineHeight - 1, 0)}
fill="transparent"
stroke="white"
strokeDasharray={dashed ? "4 4" : undefined}
strokeOpacity={0.75}
vectorEffect="non-scaling-stroke"
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
/>
</svg>
);
}
export function ShapeOutline({
center,
outlineWidth,
outlineHeight,
rotation,
pathData,
cursor,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
center: { x: number; y: number };
outlineWidth: number;
outlineHeight: number;
rotation: number;
pathData: string;
cursor?: string;
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
return (
<svg
className="absolute overflow-visible"
aria-hidden="true"
focusable="false"
style={{
left: center.x - outlineWidth / 2,
top: center.y - outlineHeight / 2,
width: outlineWidth,
height: outlineHeight,
transform: `rotate(${rotation}deg)`,
transformOrigin: "center center",
pointerEvents: onPointerDown ? "auto" : "none",
cursor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
<path
d={pathData}
fill="transparent"
stroke="white"
strokeOpacity={0.75}
vectorEffect="non-scaling-stroke"
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
/>
</svg>
);
}
export function CanvasPathOutline({
pathData,
translateX = 0,
translateY = 0,
scaleX = 1,
scaleY = 1,
cursor,
strokeWidth = 1,
strokeOpacity = 0.75,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
pathData: string;
translateX?: number;
translateY?: number;
scaleX?: number;
scaleY?: number;
cursor?: string;
strokeWidth?: number;
strokeOpacity?: number;
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
return (
<svg
className="absolute inset-0 overflow-visible"
aria-hidden="true"
focusable="false"
style={{
pointerEvents: onPointerDown ? "auto" : "none",
cursor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
<g
transform={`translate(${translateX} ${translateY}) scale(${scaleX} ${scaleY})`}
>
<path
d={pathData}
fill="transparent"
stroke="white"
strokeWidth={strokeWidth}
strokeOpacity={strokeOpacity}
vectorEffect="non-scaling-stroke"
style={{ pointerEvents: onPointerDown ? "stroke" : "none" }}
/>
</g>
</svg>
);
}
export function LineOverlay({
start,
end,
cursor,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
start: { x: number; y: number };
end: { x: number; y: number };
cursor?: string;
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.sqrt(dx * dx + dy * dy);
const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
const cx = (start.x + end.x) / 2;
const cy = (start.y + end.y) / 2;
const sharedStyle = {
left: cx - length / 2,
width: length,
transform: `rotate(${angleDeg}deg)`,
transformOrigin: "center center",
};
return (
<>
{onPointerDown && (
<div
className="absolute"
style={{
...sharedStyle,
top: cy - LINE_HIT_AREA_SIZE / 2,
height: LINE_HIT_AREA_SIZE,
pointerEvents: "auto",
cursor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
/>
)}
<div
className="pointer-events-none absolute"
style={{
...sharedStyle,
top: cy - 0.5,
height: 1,
backgroundColor: "white",
opacity: 0.75,
}}
/>
</>
);
}
"use client";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
export const HANDLE_SIZE = 10;
export const HANDLE_HIT_AREA_SIZE = 18;
export const ICON_HANDLE_RADIUS = 10;
export const EDGE_HANDLE_THIN_SIZE = 6;
export const EDGE_HANDLE_THICK_SIZE = 14;
export const LINE_HIT_AREA_SIZE = 48;
export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
const normalized = ((angleDeg % 180) + 180) % 180;
if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize";
if (normalized < 67.5) return "cursor-nwse-resize";
if (normalized < 112.5) return "cursor-ns-resize";
return "cursor-nesw-resize";
}
export function HandleButton({
screen,
cursor,
hitAreaSize,
className,
onPointerDown,
onPointerMove,
onPointerUp,
children,
}: {
screen: { x: number; y: number };
cursor?: string;
hitAreaSize: number;
className?: string;
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
className={cn(
"absolute flex items-center justify-center outline-none",
cursor,
className,
)}
style={{
left: screen.x - hitAreaSize / 2,
top: screen.y - hitAreaSize / 2,
width: hitAreaSize,
height: hitAreaSize,
pointerEvents: "auto",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
>
{children}
</button>
);
}
export function CornerHandle({
cursor,
screen,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
cursor?: string;
screen: { x: number; y: number };
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
return (
<HandleButton
screen={screen}
cursor={cursor}
hitAreaSize={HANDLE_HIT_AREA_SIZE}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div
className="rounded-sm bg-white"
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
/>
</HandleButton>
);
}
export function EdgeHandle({
edge,
screen,
rotation,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
edge: "right" | "left" | "bottom";
screen: { x: number; y: number };
rotation: number;
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
const isHorizontalEdge = edge === "right" || edge === "left";
const width = isHorizontalEdge
? EDGE_HANDLE_THIN_SIZE
: EDGE_HANDLE_THICK_SIZE;
const height = isHorizontalEdge
? EDGE_HANDLE_THICK_SIZE
: EDGE_HANDLE_THIN_SIZE;
const cursor = getResizeCursor({
angleDeg: isHorizontalEdge ? rotation : rotation + 90,
});
return (
<HandleButton
screen={screen}
cursor={cursor}
hitAreaSize={HANDLE_HIT_AREA_SIZE}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div
className="rounded-sm bg-white"
style={{ width, height, transform: `rotate(${rotation}deg)` }}
/>
</HandleButton>
);
}
export function IconHandle({
icon,
screen,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
icon: IconSvgElement;
screen: { x: number; y: number };
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
return (
<HandleButton
screen={screen}
hitAreaSize={ICON_HANDLE_RADIUS * 2}
className="rounded-full bg-white text-black shadow-sm"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
</HandleButton>
);
}
export function BoundingBoxOutline({
center,
outlineWidth,
outlineHeight,
rotation,
cursor,
dashed = false,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
center: { x: number; y: number };
outlineWidth: number;
outlineHeight: number;
rotation: number;
cursor?: string;
dashed?: boolean;
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
return (
<svg
className={cn("absolute overflow-visible", cursor)}
aria-hidden="true"
focusable="false"
style={{
left: center.x - outlineWidth / 2,
top: center.y - outlineHeight / 2,
width: outlineWidth,
height: outlineHeight,
transform: `rotate(${rotation}deg)`,
transformOrigin: "center center",
pointerEvents: onPointerDown ? "auto" : "none",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
<rect
x={0.5}
y={0.5}
width={Math.max(outlineWidth - 1, 0)}
height={Math.max(outlineHeight - 1, 0)}
fill="transparent"
stroke="white"
strokeDasharray={dashed ? "4 4" : undefined}
strokeOpacity={0.75}
vectorEffect="non-scaling-stroke"
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
/>
</svg>
);
}
export function ShapeOutline({
center,
outlineWidth,
outlineHeight,
rotation,
pathData,
cursor,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
center: { x: number; y: number };
outlineWidth: number;
outlineHeight: number;
rotation: number;
pathData: string;
cursor?: string;
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
return (
<svg
className={cn("absolute overflow-visible", cursor)}
aria-hidden="true"
focusable="false"
style={{
left: center.x - outlineWidth / 2,
top: center.y - outlineHeight / 2,
width: outlineWidth,
height: outlineHeight,
transform: `rotate(${rotation}deg)`,
transformOrigin: "center center",
pointerEvents: onPointerDown ? "auto" : "none",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
<path
d={pathData}
fill="transparent"
stroke="white"
strokeOpacity={0.75}
vectorEffect="non-scaling-stroke"
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
/>
</svg>
);
}
export function LineOverlay({
start,
end,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
start: { x: number; y: number };
end: { x: number; y: number };
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.sqrt(dx * dx + dy * dy);
const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
const cx = (start.x + end.x) / 2;
const cy = (start.y + end.y) / 2;
const sharedStyle = {
left: cx - length / 2,
width: length,
transform: `rotate(${angleDeg}deg)`,
transformOrigin: "center center",
};
return (
<>
{onPointerDown && (
<div
className="absolute"
style={{
...sharedStyle,
top: cy - LINE_HIT_AREA_SIZE / 2,
height: LINE_HIT_AREA_SIZE,
pointerEvents: "auto",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
/>
)}
<div
className="pointer-events-none absolute"
style={{
...sharedStyle,
top: cy - 0.5,
height: 1,
backgroundColor: "white",
opacity: 0.75,
}}
/>
</>
);
}
@@ -2,21 +2,19 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import useDeepCompareEffect from "use-deep-compare-effect";
import { useEditor } from "@/editor/use-editor";
import { useEditor } from "@/hooks/use-editor";
import { useRafLoop } from "@/hooks/use-raf-loop";
import { useContainerSize } from "@/hooks/use-container-size";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { TICKS_PER_SECOND } from "@/wasm";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import { buildScene } from "@/services/renderer/scene-builder";
import { PreviewOverlayLayer } from "./overlay-layer";
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
import { BookmarkNoteOverlay } from "./bookmark-note-overlay";
import { GuideOverlay } from "./guide-overlay";
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import type {
PreviewOverlayControl,
PreviewOverlayInstance,
} from "@/preview/overlays";
import { usePreviewStore } from "@/stores/preview-store";
import { PreviewContextMenu } from "./context-menu";
import { PreviewToolbar } from "./toolbar";
import {
@@ -55,18 +53,7 @@ function normalizeWheelDelta({
return delta;
}
export function PreviewPanel({
overlayControls,
overlayInstances,
onOverlayVisibilityChange,
}: {
overlayControls: PreviewOverlayControl[];
overlayInstances: PreviewOverlayInstance[];
onOverlayVisibilityChange: (params: {
overlayId: string;
isVisible: boolean;
}) => void;
}) {
export function PreviewPanel() {
const containerRef = useRef<HTMLDivElement>(null);
const { toggleFullscreen } = useFullscreen({ containerRef });
@@ -78,9 +65,6 @@ export function PreviewPanel({
<PreviewCanvas
containerRef={containerRef}
onToggleFullscreen={toggleFullscreen}
overlayControls={overlayControls}
overlayInstances={overlayInstances}
onOverlayVisibilityChange={onOverlayVisibilityChange}
/>
<RenderTreeController />
</div>
@@ -119,18 +103,9 @@ function RenderTreeController() {
function PreviewCanvas({
containerRef,
onToggleFullscreen,
overlayControls,
overlayInstances,
onOverlayVisibilityChange,
}: {
containerRef: React.RefObject<HTMLElement | null>;
onToggleFullscreen: () => void;
overlayControls: PreviewOverlayControl[];
overlayInstances: PreviewOverlayInstance[];
onOverlayVisibilityChange: (params: {
overlayId: string;
isVisible: boolean;
}) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
@@ -142,6 +117,7 @@ function PreviewCanvas({
const editor = useEditor();
const activeProject = useEditor((e) => e.project.getActive());
const renderTree = useEditor((e) => e.renderer.getRenderTree());
const { overlays } = usePreviewStore();
const viewport = usePreviewViewportState({
canvasHeight: nativeHeight,
canvasWidth: nativeWidth,
@@ -156,18 +132,16 @@ function PreviewCanvas({
height: nativeHeight,
fps: activeProject.settings.fps,
});
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
}, [nativeWidth, nativeHeight, activeProject.settings.fps.numerator, activeProject.settings.fps.denominator]);
const render = useCallback(() => {
if (canvasRef.current && renderTree && !renderingRef.current) {
const renderTime = Math.min(
editor.playback.getCurrentTime(),
editor.timeline.getLastFrameTime(),
);
const ticksPerFrame = Math.round(
(TICKS_PER_SECOND * renderer.fps.denominator) / renderer.fps.numerator,
);
const frame = Math.floor(renderTime / ticksPerFrame);
const renderTime = Math.min(
editor.playback.getCurrentTime(),
editor.timeline.getLastFrameTime(),
);
const ticksPerFrame = Math.round(TICKS_PER_SECOND * renderer.fps.denominator / renderer.fps.numerator);
const frame = Math.floor(renderTime / ticksPerFrame);
if (
frame !== lastFrameRef.current ||
@@ -187,7 +161,7 @@ function PreviewCanvas({
});
}
}
}, [renderer, renderTree, editor.playback, editor.timeline]);
}, [renderer, renderTree, editor.playback]);
useRafLoop(render);
@@ -302,22 +276,14 @@ function PreviewCanvas({
: activeProject?.settings.background.color,
}}
/>
<PreviewOverlayLayer
instances={overlayInstances}
plane="under-interaction"
/>
<GuideOverlay />
<PreviewInteractionOverlay />
<PreviewOverlayLayer
instances={overlayInstances}
plane="over-interaction"
/>
{overlays.bookmarks && <BookmarkNoteOverlay />}
</div>
</ContextMenuTrigger>
<PreviewContextMenu
onToggleFullscreen={onToggleFullscreen}
containerRef={containerRef}
overlayControls={overlayControls}
onOverlayVisibilityChange={onOverlayVisibilityChange}
/>
</ContextMenu>
</div>
@@ -0,0 +1,238 @@
"use client";
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useMaskHandles } from "@/hooks/use-mask-handles";
import { masksRegistry } from "@/lib/masks";
import type { SnapLine } from "@/lib/preview/preview-snap";
import type { ParamValues } from "@/lib/params";
import type { RectangleMaskParams } from "@/lib/masks/types";
import {
CornerHandle,
EdgeHandle,
IconHandle,
LineOverlay,
BoundingBoxOutline,
ShapeOutline,
} from "./handle-primitives";
import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
function hasRectangleOutlineParams(
params: ParamValues,
): params is RectangleMaskParams {
return (
typeof params.centerX === "number" &&
typeof params.centerY === "number" &&
typeof params.width === "number" &&
typeof params.height === "number"
);
}
export function MaskHandles({
onSnapLinesChange,
}: {
onSnapLinesChange?: (lines: SnapLine[]) => void;
}) {
const viewport = usePreviewViewport();
const {
selectedWithMask,
handlePositions,
linePoints,
handlePointerDown,
handlePointerMove,
handlePointerUp,
} = useMaskHandles({ onSnapLinesChange });
if (!selectedWithMask) return null;
const toOverlay = ({
canvasX,
canvasY,
}: {
canvasX: number;
canvasY: number;
}) =>
viewport.canvasToOverlay({
canvasX,
canvasY,
});
const def = masksRegistry.get(selectedWithMask.mask.type);
const { bounds } = selectedWithMask;
const maskRotation = selectedWithMask.mask.params.rotation;
const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
const rectangleOutlineProps = hasRectangleOutlineParams(
selectedWithMask.mask.params,
)
? {
center: toOverlay({
canvasX:
bounds.cx + selectedWithMask.mask.params.centerX * bounds.width,
canvasY:
bounds.cy + selectedWithMask.mask.params.centerY * bounds.height,
}),
outlineWidth:
selectedWithMask.mask.params.width * bounds.width * scaleX,
outlineHeight:
selectedWithMask.mask.params.height * bounds.height * scaleY,
rotation: maskRotation,
}
: null;
const onPointerMove = (event: React.PointerEvent) =>
handlePointerMove({ event });
const onPointerUp = () => handlePointerUp();
return (
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
aria-hidden
>
{def.overlayShape === "line" && linePoints && (
<LineOverlay
start={toOverlay({
canvasX: linePoints.start.x,
canvasY: linePoints.start.y,
})}
end={toOverlay({
canvasX: linePoints.end.x,
canvasY: linePoints.end.y,
})}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: "position" })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
)}
{def.overlayShape === "box" && rectangleOutlineProps && (
def.buildOverlayPath ? (
<>
<BoundingBoxOutline {...rectangleOutlineProps} dashed />
<ShapeOutline
{...rectangleOutlineProps}
pathData={def.buildOverlayPath({
width: rectangleOutlineProps.outlineWidth,
height: rectangleOutlineProps.outlineHeight,
})}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: "position" })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
</>
) : (
<BoundingBoxOutline
{...rectangleOutlineProps}
cursor="cursor-move"
onPointerDown={(event) =>
handlePointerDown({ event, handleId: "position" })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
)
)}
{handlePositions.map((handle) => {
const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y });
if (handle.id === "rotation") {
return (
<IconHandle
key={handle.id}
icon={Rotate01Icon}
screen={screen}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
}
if (handle.id === "feather") {
return (
<IconHandle
key={handle.id}
icon={FeatherIcon}
screen={screen}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
}
if (handle.id === "right" || handle.id === "left") {
return (
<EdgeHandle
key={handle.id}
edge="right"
screen={screen}
rotation={maskRotation}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
}
if (handle.id === "bottom" || handle.id === "top") {
return (
<EdgeHandle
key={handle.id}
edge="bottom"
screen={screen}
rotation={maskRotation}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
}
if (
handle.id === "top-left" ||
handle.id === "top-right" ||
handle.id === "bottom-left" ||
handle.id === "bottom-right" ||
handle.id === "scale"
) {
return (
<CornerHandle
key={handle.id}
screen={screen}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
}
return (
<CornerHandle
key={handle.id}
cursor={handle.cursor}
screen={screen}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
})}
</div>
);
}
@@ -1,13 +1,13 @@
import { useState } from "react";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import { usePreviewInteraction } from "@/preview/hooks/use-preview-interaction";
import type { SnapLine } from "@/preview/preview-snap";
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
import type { SnapLine } from "@/lib/preview/preview-snap";
import { TransformHandles } from "./transform-handles";
import { MaskHandles } from "./mask-handles";
import { SnapGuides } from "./snap-guides";
import { TextEditOverlay } from "./text-edit-overlay";
import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
import { useEditor } from "@/editor/use-editor";
import { usePropertiesStore } from "../properties/stores/properties-store";
import { useEditor } from "@/hooks/use-editor";
export function PreviewInteractionOverlay() {
const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
@@ -15,9 +15,9 @@ import {
positionToOverlay,
screenPixelsToLogicalThreshold,
screenToCanvas,
} from "@/preview/preview-coords";
} from "@/lib/preview/preview-coords";
import { clamp, isNearlyEqual } from "@/utils/math";
import { PREVIEW_ZOOM } from "@/preview/zoom";
import { PREVIEW_ZOOM } from "@/lib/preview/zoom";
const MIDDLE_MOUSE_BUTTON = 1;
const IS_AT_FIT_EPSILON = 0.001;
@@ -1,7 +1,7 @@
"use client";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import type { SnapLine } from "@/preview/preview-snap";
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import type { SnapLine } from "@/lib/preview/preview-snap";
export function SnapGuides({ lines }: { lines: SnapLine[] }) {
const viewport = usePreviewViewport();
@@ -1,15 +1,17 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import { useEditor } from "@/editor/use-editor";
import type { TextElement } from "@/timeline";
import { DEFAULTS } from "@/timeline/defaults";
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useEditor } from "@/hooks/use-editor";
import type { TextElement } from "@/lib/timeline";
import {
FONT_SIZE_SCALE_REFERENCE,
} from "@/lib/text/typography";
import { DEFAULTS } from "@/lib/timeline/defaults";
import {
getElementLocalTime,
resolveTransformAtTime,
} from "@/animation";
import { resolveTextLayout } from "@/text/primitives";
} from "@/lib/animation";
export function TextEditOverlay({
trackId,
@@ -80,29 +82,20 @@ export function TextEditOverlay({
});
const { x: displayScaleX } = viewport.getDisplayScale();
const resolvedTextLayout = resolveTextLayout({
text: {
content: element.content,
fontSize: element.fontSize,
fontFamily: element.fontFamily,
fontWeight: element.fontWeight,
fontStyle: element.fontStyle,
textAlign: element.textAlign,
textDecoration: element.textDecoration,
letterSpacing: element.letterSpacing,
lineHeight: element.lineHeight,
},
canvasHeight: canvasSize.height,
});
const scaledFontSize =
element.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE);
const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
const canvasLetterSpacing = element.letterSpacing ?? 0;
const lineHeightPx = resolvedTextLayout.lineHeightPx;
const lineHeightPx = scaledFontSize * lineHeight;
const bg = element.background;
const shouldShowBackground =
bg.enabled && bg.color && bg.color !== "transparent";
const fontSizeRatio = resolvedTextLayout.fontSizeRatio;
const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
const canvasPaddingX = shouldShowBackground
? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio
: 0;
@@ -130,10 +123,10 @@ export function TextEditOverlay({
aria-label="Edit text"
className="cursor-text select-text outline-none whitespace-pre"
style={{
fontSize: resolvedTextLayout.scaledFontSize,
fontSize: scaledFontSize,
fontFamily: element.fontFamily,
fontWeight: element.fontWeight === "bold" ? "bold" : "normal",
fontStyle: element.fontStyle === "italic" ? "italic" : "normal",
fontWeight,
fontStyle,
textAlign: element.textAlign,
letterSpacing: `${canvasLetterSpacing}px`,
lineHeight,
@@ -1,9 +1,9 @@
"use client";
import { useState, useEffect } from "react";
import { useEditor } from "@/editor/use-editor";
import { useEditor } from "@/hooks/use-editor";
import { formatTimecode } from "opencut-wasm";
import { invokeAction } from "@/actions";
import { invokeAction } from "@/lib/actions";
import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
import {
@@ -13,7 +13,7 @@ import {
PlayIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { getGuideById } from "@/guides";
import { getGuideById } from "@/lib/guides";
import { Separator } from "@/components/ui/separator";
import {
Select,
@@ -22,10 +22,10 @@ import {
SelectItem,
SelectSeparator,
} from "@/components/ui/select";
import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom";
import { PREVIEW_ZOOM_PRESETS } from "@/lib/preview/zoom";
import { usePreviewViewport } from "./preview-viewport";
import { GridPopover } from "./guide-popover";
import { usePreviewStore } from "@/preview/preview-store";
import { usePreviewStore } from "@/stores/preview-store";
export function PreviewToolbar({
onToggleFullscreen,
@@ -1,16 +1,16 @@
"use client";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import { useTransformHandles } from "@/preview/hooks/use-transform-handles";
import { isVisualElement } from "@/timeline/element-utils";
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useTransformHandles } from "@/hooks/use-transform-handles";
import { isVisualElement } from "@/lib/timeline/element-utils";
import {
getCornerPosition,
getEdgeHandlePosition,
ROTATION_HANDLE_OFFSET,
type Corner,
type Edge,
} from "@/preview/element-bounds";
import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction";
} from "@/lib/preview/element-bounds";
import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
import {
BoundingBoxOutline,
CornerHandle,
@@ -1,6 +1,6 @@
"use client";
import type { ParamDefinition, NumberParamDefinition } from "@/params";
import type { ParamDefinition, NumberParamDefinition } from "@/lib/params";
import {
formatNumberForDisplay,
getFractionDigitsForStep,
@@ -1,5 +1,5 @@
import { useEditor } from "@/editor/use-editor";
import { getElementLocalTime } from "@/animation";
import { useEditor } from "@/hooks/use-editor";
import { getElementLocalTime } from "@/lib/animation";
export function useElementPlayhead({
startTime,
@@ -1,11 +1,11 @@
import { useEditor } from "@/editor/use-editor";
import { useEditor } from "@/hooks/use-editor";
import {
getKeyframeAtTime,
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
import type { TimelineElement } from "@/timeline";
} from "@/lib/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
import type { TimelineElement } from "@/lib/timeline";
export function useKeyframedColorProperty({
trackId,
@@ -1,11 +1,11 @@
import { useEditor } from "@/editor/use-editor";
import { useEditor } from "@/hooks/use-editor";
import {
getKeyframeAtTime,
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
import type { TimelineElement } from "@/timeline";
} from "@/lib/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
import type { TimelineElement } from "@/lib/timeline";
import { snapToStep } from "@/utils/math";
import { usePropertyDraft } from "./use-property-draft";
@@ -1,6 +1,6 @@
"use client";
import { useEditor } from "@/editor/use-editor";
import { useEditor } from "@/hooks/use-editor";
import {
buildGraphicParamPath,
coerceAnimationValueForParam,
@@ -9,12 +9,12 @@ import {
getParamValueKind,
hasKeyframesForPath,
upsertPathKeyframe,
} from "@/animation";
} from "@/lib/animation";
import type {
ElementAnimations,
} from "@/animation/types";
import type { ParamDefinition } from "@/params";
import type { TimelineElement } from "@/timeline";
} from "@/lib/animation/types";
import type { ParamDefinition } from "@/lib/params";
import type { TimelineElement } from "@/lib/timeline";
export interface KeyframedParamPropertyResult {
hasAnimatedKeyframes: boolean;
@@ -8,8 +8,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useEditor } from "@/editor/use-editor";
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { usePropertiesStore } from "./stores/properties-store";
import { getPropertiesConfig } from "./registry";
import { cn } from "@/utils/ui";
@@ -11,8 +11,8 @@ import type {
VideoElement,
AudioElement,
TimelineElement,
} from "@/timeline";
import type { MediaAsset } from "@/media/types";
} from "@/lib/timeline";
import type { MediaAsset } from "@/lib/media/types";
import { HugeiconsIcon } from "@hugeicons/react";
import {
TextFontIcon,
@@ -22,14 +22,14 @@ import {
MagicWand05Icon,
DashboardSpeed02Icon,
} from "@hugeicons/core-free-icons";
import { TransformTab } from "@/rendering/components/transform-tab";
import { BlendingTab } from "@/rendering/components/blending-tab";
import { AudioTab } from "@/timeline/components/audio-tab";
import { TextTab } from "@/text/components/text-tab";
import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab";
import { MasksTab } from "@/masks/components/masks-tab";
import { SpeedTab } from "@/speed/components/speed-tab";
import { GraphicTab } from "@/graphics/components/graphic-tab";
import { TransformTab } from "./tabs/transform-tab";
import { BlendingTab } from "./tabs/blending-tab";
import { AudioTab } from "./tabs/audio-tab";
import { TextTab } from "./tabs/text-tab";
import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
import { MasksTab } from "./tabs/masks-tab";
import { SpeedTab } from "./tabs/speed-tab";
import { GraphicTab } from "./tabs/graphic-tab";
import { OcShapesIcon } from "@/components/icons";
export type TabContentProps = {
@@ -1,8 +1,8 @@
import { Button } from "@/components/ui/button";
import { NumberField } from "@/components/ui/number-field";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
import { isSourceAudioSeparated } from "@/timeline/audio-separation";
import { DEFAULTS } from "@/timeline/defaults";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
import { isSourceAudioSeparated } from "@/lib/timeline/audio-separation";
import { DEFAULTS } from "@/lib/timeline/defaults";
import {
clamp,
formatNumberForDisplay,
@@ -10,12 +10,12 @@ import {
isNearlyEqual,
snapToStep,
} from "@/utils/math";
import type { AudioElement, VideoElement } from "@/timeline";
import { resolveNumberAtTime } from "@/animation";
import { useEditor } from "@/editor/use-editor";
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
import type { AudioElement, VideoElement } from "@/lib/timeline";
import { resolveNumberAtTime } from "@/lib/animation";
import { useEditor } from "@/hooks/use-editor";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { KeyframeToggle } from "../components/keyframe-toggle";
import { HugeiconsIcon } from "@hugeicons/react";
import { VolumeHighIcon } from "@hugeicons/core-free-icons";
import {
@@ -1,9 +1,9 @@
import { useEditor } from "@/editor/use-editor";
import { useEditor } from "@/hooks/use-editor";
import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field";
import { OcCheckerboardIcon } from "@/components/icons";
import { Fragment, useRef } from "react";
import { useMenuPreview } from "@/editor/use-menu-preview";
import { useMenuPreview } from "@/hooks/use-menu-preview";
import {
Section,
SectionContent,
@@ -19,16 +19,16 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BlendMode } from "@/rendering";
import type { ElementType } from "@/timeline";
import type { ElementAnimations } from "@/animation/types";
import type { BlendMode } from "@/lib/rendering";
import type { ElementType } from "@/lib/timeline";
import type { ElementAnimations } from "@/lib/animation/types";
import { HugeiconsIcon } from "@hugeicons/react";
import { RainDropIcon } from "@hugeicons/core-free-icons";
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
import { resolveOpacityAtTime } from "@/animation";
import { DEFAULTS } from "@/timeline/defaults";
import { KeyframeToggle } from "../components/keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import { resolveOpacityAtTime } from "@/lib/animation";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { isPropertyAtDefault } from "./transform-tab";
type BlendingElement = {
@@ -1,309 +1,309 @@
"use client";
import { useState } from "react";
import type { ParamValues } from "@/params";
import type { Effect } from "@/effects/types";
import type { EffectElement, VisualElement } from "@/timeline";
import { effectsRegistry } from "@/effects";
import { useEditor } from "@/editor/use-editor";
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
SectionFields,
} from "@/components/section";
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Delete02Icon,
ViewIcon,
ViewOffSlashIcon,
MagicWand05Icon,
} from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
import { Separator } from "@/components/ui/separator";
import { useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store";
export function StandaloneEffectTab({
element,
trackId,
}: {
element: EffectElement;
trackId: string;
}) {
const { renderElement, previewUpdates, commit } = useElementPreview({
trackId,
elementId: element.id,
fallback: element,
});
const effect: Effect = {
id: element.id,
type: element.effectType,
params: element.params,
enabled: true,
};
const previewParam = (key: string) => (value: number | string | boolean) => {
previewUpdates({
params: { ...(renderElement as EffectElement).params, [key]: value },
});
};
return (
<div className="flex flex-col h-full">
<div className="border-b px-3.5 h-11 shrink-0 flex items-center">
<SectionTitle>Effect</SectionTitle>
</div>
<EffectSection
effect={effect}
renderParams={(renderElement as EffectElement).params}
previewParam={previewParam}
onCommit={commit}
/>
</div>
);
}
export function ClipEffectsTab({
element,
trackId,
}: {
element: VisualElement;
trackId: string;
}) {
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const editor = useEditor();
const { renderElement, previewUpdates, commit } = useElementPreview({
trackId,
elementId: element.id,
fallback: element,
});
const effects: Effect[] = element.effects ?? [];
const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => {
return (
(renderElement as VisualElement).effects?.find((ef) => ef.id === effectId)
?.params ??
effects.find((ef) => ef.id === effectId)?.params ??
{}
);
};
const buildPreviewParam =
(effectId: string) =>
(key: string) =>
(value: number | string | boolean) => {
const updatedEffects = (
(renderElement as VisualElement).effects ?? []
).map((existing) =>
existing.id !== effectId
? existing
: { ...existing, params: { ...existing.params, [key]: value } },
);
previewUpdates({ effects: updatedEffects });
};
const handleDragStart = ({ index }: { index: number }) => setDragIndex(index);
const handleDragOver = ({
event,
index,
}: {
event: React.DragEvent;
index: number;
}) => {
event.preventDefault();
if (index !== dropIndex) setDropIndex(index);
};
const handleDrop = ({ toIndex }: { toIndex: number }) => {
if (dragIndex !== null && dragIndex !== toIndex) {
editor.timeline.reorderClipEffects({
trackId,
elementId: element.id,
fromIndex: dragIndex,
toIndex,
});
}
setDragIndex(null);
setDropIndex(null);
};
const handleDragEnd = () => {
setDragIndex(null);
setDropIndex(null);
};
return (
<div className="flex flex-col h-full">
<div className="border-b px-3.5 h-11 shrink-0 flex items-center">
<SectionTitle>Effects</SectionTitle>
</div>
{effects.length === 0 ? (
<EmptyView />
) : (
<ul className="flex flex-col">
{effects.map((effect, index) => {
const resolvedDragIndex = dragIndex ?? -1;
const isDragging = dragIndex === index;
const isDropTarget =
dropIndex === index && dragIndex !== null && dragIndex !== index;
const showTopDropIndicator =
isDropTarget && index < resolvedDragIndex;
const showBottomDropIndicator =
isDropTarget && index > resolvedDragIndex;
return (
<li
key={effect.id}
draggable
onDragStart={() => handleDragStart({ index })}
onDragOver={(event) => handleDragOver({ event, index })}
onDrop={() => handleDrop({ toIndex: index })}
onDragEnd={handleDragEnd}
className={cn(
"group list-none",
isDragging && "opacity-40",
showTopDropIndicator && "border-t-2 border-primary",
showBottomDropIndicator && "border-b-2 border-primary",
)}
>
<EffectSection
effect={effect}
renderParams={getRenderParams({ effectId: effect.id })}
previewParam={buildPreviewParam(effect.id)}
onCommit={commit}
onToggle={() =>
editor.timeline.toggleClipEffect({
trackId,
elementId: element.id,
effectId: effect.id,
})
}
onRemove={() =>
editor.timeline.removeClipEffect({
trackId,
elementId: element.id,
effectId: effect.id,
})
}
/>
</li>
);
})}
</ul>
)}
</div>
);
}
function EmptyView() {
const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab);
return (
<div className="flex flex-col h-full items-center justify-center gap-4 text-center">
<HugeiconsIcon
icon={MagicWand05Icon}
className="size-10 text-muted-foreground"
strokeWidth={1}
/>
<div className="flex flex-col gap-2">
<h3 className="font-medium text-foreground">No effects</h3>
<p className="text-muted-foreground text-sm text-balance max-w-44">
Add effects to this layer from the Assets panel.
</p>
</div>
<Button
variant="default"
size="sm"
onClick={() => setActiveTab("effects")}
>
Open effects
</Button>
</div>
);
}
function EffectSection({
effect,
renderParams,
previewParam,
onCommit,
onToggle,
onRemove,
}: {
effect: Effect;
renderParams: ParamValues;
previewParam: (key: string) => (value: number | string | boolean) => void;
onCommit: () => void;
onToggle?: () => void;
onRemove?: () => void;
}) {
const definition = effectsRegistry.get(effect.type);
return (
<Section
sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined}
showTopBorder={false}
>
<SectionHeader
className={cn(onToggle && "cursor-move")}
trailing={
onToggle && (
<div className="flex items-center gap-1">
<Button
variant={effect.enabled ? "secondary" : "ghost"}
size="icon"
aria-label={`Toggle ${definition.name}`}
onClick={onToggle}
>
<HugeiconsIcon
icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
/>
</Button>
<Button
variant="ghost"
size="icon"
aria-label={`Remove ${definition.name}`}
onClick={onRemove}
>
<HugeiconsIcon icon={Delete02Icon} />
</Button>
</div>
)
}
>
<SectionTitle
className={cn(onToggle && !effect.enabled && "text-muted-foreground")}
>
{definition.name}
</SectionTitle>
</SectionHeader>
<SectionContent
className={cn("p-0", onToggle && !effect.enabled && "opacity-50")}
>
<SectionFields>
{definition.params.map((param) => (
<div key={param.key} className="flex flex-col gap-3.5">
<div className="px-4">
<PropertyParamField
param={param}
value={renderParams[param.key] ?? param.default}
onPreview={previewParam(param.key)}
onCommit={onCommit}
/>
</div>
<Separator />
</div>
))}
</SectionFields>
</SectionContent>
</Section>
);
}
"use client";
import { useState } from "react";
import type { ParamValues } from "@/lib/params";
import type { Effect } from "@/lib/effects/types";
import type { EffectElement, VisualElement } from "@/lib/timeline";
import { effectsRegistry } from "@/lib/effects";
import { useEditor } from "@/hooks/use-editor";
import { useElementPreview } from "@/hooks/use-element-preview";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
SectionFields,
} from "@/components/section";
import { PropertyParamField } from "../components/property-param-field";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Delete02Icon,
ViewIcon,
ViewOffSlashIcon,
MagicWand05Icon,
} from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
import { Separator } from "@/components/ui/separator";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
export function StandaloneEffectTab({
element,
trackId,
}: {
element: EffectElement;
trackId: string;
}) {
const { renderElement, previewUpdates, commit } = useElementPreview({
trackId,
elementId: element.id,
fallback: element,
});
const effect: Effect = {
id: element.id,
type: element.effectType,
params: element.params,
enabled: true,
};
const previewParam = (key: string) => (value: number | string | boolean) => {
previewUpdates({
params: { ...(renderElement as EffectElement).params, [key]: value },
});
};
return (
<div className="flex flex-col h-full">
<div className="border-b px-3.5 h-11 shrink-0 flex items-center">
<SectionTitle>Effect</SectionTitle>
</div>
<EffectSection
effect={effect}
renderParams={(renderElement as EffectElement).params}
previewParam={previewParam}
onCommit={commit}
/>
</div>
);
}
export function ClipEffectsTab({
element,
trackId,
}: {
element: VisualElement;
trackId: string;
}) {
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const editor = useEditor();
const { renderElement, previewUpdates, commit } = useElementPreview({
trackId,
elementId: element.id,
fallback: element,
});
const effects: Effect[] = element.effects ?? [];
const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => {
return (
(renderElement as VisualElement).effects?.find((ef) => ef.id === effectId)
?.params ??
effects.find((ef) => ef.id === effectId)?.params ??
{}
);
};
const buildPreviewParam =
(effectId: string) =>
(key: string) =>
(value: number | string | boolean) => {
const updatedEffects = (
(renderElement as VisualElement).effects ?? []
).map((existing) =>
existing.id !== effectId
? existing
: { ...existing, params: { ...existing.params, [key]: value } },
);
previewUpdates({ effects: updatedEffects });
};
const handleDragStart = ({ index }: { index: number }) => setDragIndex(index);
const handleDragOver = ({
event,
index,
}: {
event: React.DragEvent;
index: number;
}) => {
event.preventDefault();
if (index !== dropIndex) setDropIndex(index);
};
const handleDrop = ({ toIndex }: { toIndex: number }) => {
if (dragIndex !== null && dragIndex !== toIndex) {
editor.timeline.reorderClipEffects({
trackId,
elementId: element.id,
fromIndex: dragIndex,
toIndex,
});
}
setDragIndex(null);
setDropIndex(null);
};
const handleDragEnd = () => {
setDragIndex(null);
setDropIndex(null);
};
return (
<div className="flex flex-col h-full">
<div className="border-b px-3.5 h-11 shrink-0 flex items-center">
<SectionTitle>Effects</SectionTitle>
</div>
{effects.length === 0 ? (
<EmptyView />
) : (
<ul className="flex flex-col">
{effects.map((effect, index) => {
const resolvedDragIndex = dragIndex ?? -1;
const isDragging = dragIndex === index;
const isDropTarget =
dropIndex === index && dragIndex !== null && dragIndex !== index;
const showTopDropIndicator =
isDropTarget && index < resolvedDragIndex;
const showBottomDropIndicator =
isDropTarget && index > resolvedDragIndex;
return (
<li
key={effect.id}
draggable
onDragStart={() => handleDragStart({ index })}
onDragOver={(event) => handleDragOver({ event, index })}
onDrop={() => handleDrop({ toIndex: index })}
onDragEnd={handleDragEnd}
className={cn(
"group list-none",
isDragging && "opacity-40",
showTopDropIndicator && "border-t-2 border-primary",
showBottomDropIndicator && "border-b-2 border-primary",
)}
>
<EffectSection
effect={effect}
renderParams={getRenderParams({ effectId: effect.id })}
previewParam={buildPreviewParam(effect.id)}
onCommit={commit}
onToggle={() =>
editor.timeline.toggleClipEffect({
trackId,
elementId: element.id,
effectId: effect.id,
})
}
onRemove={() =>
editor.timeline.removeClipEffect({
trackId,
elementId: element.id,
effectId: effect.id,
})
}
/>
</li>
);
})}
</ul>
)}
</div>
);
}
function EmptyView() {
const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab);
return (
<div className="flex flex-col h-full items-center justify-center gap-4 text-center">
<HugeiconsIcon
icon={MagicWand05Icon}
className="size-10 text-muted-foreground"
strokeWidth={1}
/>
<div className="flex flex-col gap-2">
<h3 className="font-medium text-foreground">No effects</h3>
<p className="text-muted-foreground text-sm text-balance max-w-44">
Add effects to this layer from the Assets panel.
</p>
</div>
<Button
variant="default"
size="sm"
onClick={() => setActiveTab("effects")}
>
Open effects
</Button>
</div>
);
}
function EffectSection({
effect,
renderParams,
previewParam,
onCommit,
onToggle,
onRemove,
}: {
effect: Effect;
renderParams: ParamValues;
previewParam: (key: string) => (value: number | string | boolean) => void;
onCommit: () => void;
onToggle?: () => void;
onRemove?: () => void;
}) {
const definition = effectsRegistry.get(effect.type);
return (
<Section
sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined}
showTopBorder={false}
>
<SectionHeader
className={cn(onToggle && "cursor-move")}
trailing={
onToggle && (
<div className="flex items-center gap-1">
<Button
variant={effect.enabled ? "secondary" : "ghost"}
size="icon"
aria-label={`Toggle ${definition.name}`}
onClick={onToggle}
>
<HugeiconsIcon
icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
/>
</Button>
<Button
variant="ghost"
size="icon"
aria-label={`Remove ${definition.name}`}
onClick={onRemove}
>
<HugeiconsIcon icon={Delete02Icon} />
</Button>
</div>
)
}
>
<SectionTitle
className={cn(onToggle && !effect.enabled && "text-muted-foreground")}
>
{definition.name}
</SectionTitle>
</SectionHeader>
<SectionContent
className={cn("p-0", onToggle && !effect.enabled && "opacity-50")}
>
<SectionFields>
{definition.params.map((param) => (
<div key={param.key} className="flex flex-col gap-3.5">
<div className="px-4">
<PropertyParamField
param={param}
value={renderParams[param.key] ?? param.default}
onPreview={previewParam(param.key)}
onCommit={onCommit}
/>
</div>
<Separator />
</div>
))}
</SectionFields>
</SectionContent>
</Section>
);
}
@@ -1,17 +1,17 @@
"use client";
import { useRef } from "react";
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import {
useKeyframedParamProperty,
type KeyframedParamPropertyResult,
} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
import { resolveGraphicParamsAtTime } from "@/animation";
import type { ParamDefinition, ParamValues } from "@/params";
import type { GraphicElement } from "@/timeline";
import { graphicsRegistry, registerDefaultGraphics } from "@/graphics";
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
import { useEditor } from "@/editor/use-editor";
} from "../hooks/use-keyframed-param-property";
import { resolveGraphicParamsAtTime } from "@/lib/animation";
import type { ParamDefinition, ParamValues } from "@/lib/params";
import type { GraphicElement } from "@/lib/timeline";
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
import { useElementPreview } from "@/hooks/use-element-preview";
import { useEditor } from "@/hooks/use-editor";
import {
Section,
SectionContent,
@@ -19,7 +19,7 @@ import {
SectionHeader,
SectionTitle,
} from "@/components/section";
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
import { PropertyParamField } from "../components/property-param-field";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
@@ -78,7 +78,9 @@ export function GraphicTab({
</SectionFields>
</SectionContent>
</Section>
{hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
{hasStrokeParams && (
<StrokeSection element={element} trackId={trackId} />
)}
</div>
);
}
@@ -204,23 +206,21 @@ function AnimatedGraphicParamField({
isPlayheadWithinElementRange: boolean;
resolvedParams: ParamValues;
}) {
const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
{
param,
trackId,
elementId: element.id,
animations: element.animations,
localTime,
isPlayheadWithinElementRange,
resolvedValue: resolvedParams[param.key] ?? param.default,
buildBaseUpdates: ({ value }) => ({
params: {
...element.params,
[param.key]: value,
},
}),
},
);
const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({
param,
trackId,
elementId: element.id,
animations: element.animations,
localTime,
isPlayheadWithinElementRange,
resolvedValue: resolvedParams[param.key] ?? param.default,
buildBaseUpdates: ({ value }) => ({
params: {
...element.params,
[param.key]: value,
},
}),
});
return (
<PropertyParamField
@@ -1,26 +1,23 @@
"use client";
import type { MaskableElement } from "@/timeline";
import type { Mask, MaskType, TextMask } from "@/masks/types";
import type { NumberParamDefinition, SelectParamDefinition } from "@/params";
import { masksRegistry, buildDefaultMaskInstance } from "@/masks";
import { useEditor } from "@/editor/use-editor";
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
import { useMenuPreview } from "@/editor/use-menu-preview";
import { getVisibleElementsWithBounds } from "@/preview/element-bounds";
import type { MaskableElement } from "@/lib/timeline";
import type { Mask, MaskType } from "@/lib/masks/types";
import type { NumberParamDefinition, SelectParamDefinition } from "@/lib/params";
import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks";
import { useEditor } from "@/hooks/use-editor";
import { useElementPreview } from "@/hooks/use-element-preview";
import { useMenuPreview } from "@/hooks/use-menu-preview";
import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ArrowExpandIcon,
Delete02Icon,
FeatherIcon,
PlusSignIcon,
RotateClockwiseIcon,
TextFontIcon,
} from "@hugeicons/core-free-icons";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { ColorPicker } from "@/components/ui/color-picker";
import { FontPicker } from "@/components/ui/font-picker";
import {
DropdownMenu,
DropdownMenuContent,
@@ -35,7 +32,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
@@ -55,13 +51,8 @@ import {
SectionHeader,
SectionTitle,
} from "@/components/section";
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
import {
OcMirrorIcon,
OcShapesIcon,
OcTextHeightIcon,
OcTextWidthIcon,
} from "@/components/icons";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { OcMirrorIcon, OcShapesIcon } from "@/components/icons";
import { cn } from "@/utils/ui";
type MasksTabProps = {
@@ -87,10 +78,6 @@ type PreviewParamHandler = (
type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>;
function isTextMask(mask: Mask): mask is TextMask {
return mask.type === "text";
}
export function MasksTab({ element, trackId }: MasksTabProps) {
const editor = useEditor();
const { renderElement, previewUpdates, commit } =
@@ -383,23 +370,12 @@ function MaskParamsFields({
previewParam(key)(value);
const previewStrokeColor = previewParam("strokeColor");
const strokeAlignParam = definition.params.find(
(param): param is SelectParamDefinition<string> =>
(param): param is SelectParamDefinition =>
param.key === "strokeAlign" && param.type === "select",
);
return (
<SectionFields>
{isTextMask(mask) ? (
<TextMaskFields
mask={mask}
previewParam={previewParam}
onCommit={onCommit}
fontSizeParam={getNumberParamDefinition({
definition,
key: "fontSize",
})}
/>
) : null}
{definition.features.hasPosition &&
"centerX" in mask.params &&
"centerY" in mask.params && (
@@ -515,9 +491,7 @@ function MaskParamsFields({
{definition.features.sizeMode === "uniform" && "scale" in mask.params && (
<SectionField label="Scale">
<MaskNumberField
icon={
isTextMask(mask) ? <HugeiconsIcon icon={ArrowExpandIcon} /> : "S"
}
icon="S"
param={getNumberParamDefinition({
definition,
key: "scale",
@@ -613,100 +587,6 @@ function MaskParamsFields({
);
}
const LETTER_SPACING_PARAM: NumberParamDefinition = {
key: "letterSpacing",
label: "Letter spacing",
type: "number",
default: 0,
min: -100,
max: 500,
step: 1,
};
const LINE_HEIGHT_PARAM: NumberParamDefinition = {
key: "lineHeight",
label: "Line height",
type: "number",
default: 1.2,
min: 0.1,
max: 10,
step: 0.1,
};
function TextMaskFields({
mask,
previewParam,
onCommit,
fontSizeParam,
}: {
mask: TextMask;
previewParam: PreviewParamHandler;
onCommit: () => void;
fontSizeParam: NumberParamDefinition;
}) {
const content = usePropertyDraft({
displayValue: mask.params.content,
parse: (input) => input,
onPreview: (value) => previewParam("content")(value),
onCommit,
});
const previewNumberParam = (key: string) => (value: number) =>
previewParam(key)(value);
return (
<>
<SectionField label="Content">
<Textarea
value={content.displayValue}
className="min-h-20"
onFocus={content.onFocus}
onChange={content.onChange}
onBlur={content.onBlur}
/>
</SectionField>
<SectionField label="Font">
<FontPicker
defaultValue={mask.params.fontFamily}
onValueChange={(value) => {
previewParam("fontFamily")(value);
onCommit();
}}
/>
</SectionField>
<SectionField label="Size">
<MaskNumberField
icon={<HugeiconsIcon icon={TextFontIcon} />}
param={fontSizeParam}
value={mask.params.fontSize}
onPreview={previewNumberParam("fontSize")}
onCommit={onCommit}
/>
</SectionField>
<SectionField label="Spacing">
<div className="flex items-start gap-2">
<MaskNumberField
className="w-1/2"
icon={<OcTextWidthIcon size={14} />}
param={LETTER_SPACING_PARAM}
value={mask.params.letterSpacing ?? 0}
onPreview={previewNumberParam("letterSpacing")}
onCommit={onCommit}
/>
<MaskNumberField
className="w-1/2"
icon={<OcTextHeightIcon size={14} />}
param={LINE_HEIGHT_PARAM}
value={mask.params.lineHeight ?? 1.2}
onPreview={previewNumberParam("lineHeight")}
onCommit={onCommit}
/>
</div>
</SectionField>
</>
);
}
function getNumberParamDefinition({
definition,
key,
@@ -723,10 +603,13 @@ function getNumberParamDefinition({
return param;
}
function getMaskNumber<
TParams extends Mask["params"],
TKey extends keyof TParams & string,
>({ params, key }: { params: TParams; key: TKey }): number {
function getMaskNumber({
params,
key,
}: {
params: Mask["params"];
key: string;
}): number {
const value = params[key];
if (typeof value !== "number") {
@@ -782,9 +665,7 @@ function MaskNumberField({
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return (
clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier
);
return clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier;
},
onPreview,
onCommit,
@@ -1,18 +1,18 @@
import { useRef } from "react";
import { useEditor } from "@/editor/use-editor";
import { useEditor } from "@/hooks/use-editor";
import { NumberField } from "@/components/ui/number-field";
import { Switch } from "@/components/ui/switch";
import { HugeiconsIcon } from "@hugeicons/react";
import { DashboardSpeed02Icon } from "@hugeicons/core-free-icons";
import { buildConstantRetime } from "@/retime";
import { buildConstantRetime } from "@/lib/retime";
import {
DEFAULT_RETIME_RATE,
MIN_RETIME_RATE,
MAX_RETIME_RATE,
clampRetimeRate,
canMaintainPitch,
} from "@/retime/rate";
import type { AudioElement, VideoElement } from "@/timeline";
} from "@/lib/retime/rate";
import type { AudioElement, VideoElement } from "@/lib/timeline";
import {
Section,
SectionContent,
@@ -21,7 +21,7 @@ import {
SectionHeader,
SectionTitle,
} from "@/components/section";
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
import { usePropertyDraft } from "../hooks/use-property-draft";
import {
formatNumberForDisplay,
getFractionDigitsForStep,
@@ -2,7 +2,7 @@
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
import type { TextElement } from "@/timeline";
import type { TextElement } from "@/lib/timeline";
import { NumberField } from "@/components/ui/number-field";
import { useRef } from "react";
import {
@@ -17,20 +17,23 @@ import { ColorPicker } from "@/components/ui/color-picker";
import { Button } from "@/components/ui/button";
import { uppercase } from "@/utils/string";
import { clamp, formatNumberForDisplay } from "@/utils/math";
import { useEditor } from "@/editor/use-editor";
import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN } from "@/text/background";
import { useEditor } from "@/hooks/use-editor";
import {
CORNER_RADIUS_MAX,
CORNER_RADIUS_MIN,
} from "@/lib/text/background";
import {
DEFAULT_TEXT_COLOR,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from "@/text/typography";
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
import { useKeyframedColorProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-color-property";
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
import { isPropertyAtDefault } from "@/rendering/components/transform-tab";
import { resolveColorAtTime, resolveNumberAtTime } from "@/animation";
} from "@/lib/text/typography";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import { KeyframeToggle } from "../components/keyframe-toggle";
import { isPropertyAtDefault } from "./transform-tab";
import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
import { HugeiconsIcon } from "@hugeicons/react";
import {
MinusSignIcon,
@@ -38,7 +41,7 @@ import {
TextFontIcon,
} from "@hugeicons/core-free-icons";
import { OcTextHeightIcon, OcTextWidthIcon } from "@/components/icons";
import { DEFAULTS } from "@/timeline/defaults";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { cn } from "@/utils/ui";
export function TextTab({
@@ -1,7 +1,7 @@
import { NumberField } from "@/components/ui/number-field";
import { useEditor } from "@/editor/use-editor";
import { useEditor } from "@/hooks/use-editor";
import { clamp, isNearlyEqual } from "@/utils/math";
import type { VisualElement } from "@/timeline";
import type { VisualElement } from "@/lib/timeline";
import {
Section,
SectionContent,
@@ -21,12 +21,12 @@ import {
getGroupKeyframesAtTime,
hasGroupKeyframeAtTime,
resolveTransformAtTime,
} from "@/animation";
import { DEFAULTS } from "@/timeline/defaults";
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
} from "@/lib/animation";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import { KeyframeToggle } from "../components/keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { usePropertiesStore } from "../stores/properties-store";
export function parseNumericInput({ input }: { input: string }): number | null {
const parsed = parseFloat(input);
@@ -0,0 +1,167 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useResizeObserver } from "@/hooks/use-resize-observer";
import { computeGlobalMaxRms, extractRmsRange } from "@/lib/media/audio";
import { findScrollParent } from "@/utils/browser";
import { cn } from "@/utils/ui";
const BAR_WIDTH = 2;
const BAR_GAP = 1;
const BAR_STEP = BAR_WIDTH + BAR_GAP;
interface AudioWaveformProps {
audioUrl?: string;
audioBuffer?: AudioBuffer;
color?: string;
className?: string;
}
export function AudioWaveform({
audioUrl,
audioBuffer,
color = "rgba(255, 255, 255, 0.7)",
className = "",
}: AudioWaveformProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const bufferRef = useRef<AudioBuffer | null>(null);
const globalMaxRef = useRef<number>(1);
const scrollParentRef = useRef<HTMLElement | null>(null);
const heightRef = useRef<number>(0);
const drawVisible = useCallback(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
const buffer = bufferRef.current;
const height = heightRef.current;
if (!container || !canvas || !buffer || height <= 0) return;
const elementWidth = container.offsetWidth;
if (elementWidth <= 0) return;
const containerRect = container.getBoundingClientRect();
const scrollParent = scrollParentRef.current;
let clipLeft: number;
let clipRight: number;
if (scrollParent) {
const parentRect = scrollParent.getBoundingClientRect();
clipLeft = Math.max(0, parentRect.left - containerRect.left);
clipRight = Math.min(elementWidth, parentRect.right - containerRect.left);
} else {
clipLeft = Math.max(0, -containerRect.left);
clipRight = Math.min(
elementWidth,
window.innerWidth - containerRect.left,
);
}
const visibleWidth = clipRight - clipLeft;
if (visibleWidth <= 0) return;
const dpr = window.devicePixelRatio || 1;
const canvasW = Math.round(visibleWidth * dpr);
const canvasH = Math.round(height * dpr);
if (canvasW <= 0 || canvasH <= 0) return;
canvas.width = canvasW;
canvas.height = canvasH;
canvas.style.width = `${visibleWidth}px`;
canvas.style.height = `${height}px`;
canvas.style.left = `${clipLeft}px`;
const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP));
const startFraction = clipLeft / elementWidth;
const endFraction = clipRight / elementWidth;
const startSample = Math.floor(startFraction * buffer.length);
const endSample = Math.min(
buffer.length,
Math.ceil(endFraction * buffer.length),
);
const peaks = extractRmsRange({
buffer,
count: barCount,
startSample,
endSample,
globalMax: globalMaxRef.current,
});
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvasW, canvasH);
ctx.scale(dpr, dpr);
ctx.fillStyle = color;
const maxBarHeight = height * 0.7;
for (let i = 0; i < barCount; i++) {
const scaled = Math.log1p(peaks[i]) / Math.log1p(1);
const barH = Math.max(1, scaled * maxBarHeight);
ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
}
}, [color]);
useEffect(() => {
let isCancelled = false;
async function load() {
let buffer = audioBuffer ?? null;
if (!buffer && audioUrl) {
try {
const resp = await fetch(audioUrl);
const arrayBuffer = await resp.arrayBuffer();
const actx = new AudioContext();
buffer = await actx.decodeAudioData(arrayBuffer);
actx.close();
} catch {
return;
}
}
if (!buffer || isCancelled) return;
bufferRef.current = buffer;
globalMaxRef.current = computeGlobalMaxRms({ buffer });
drawVisible();
}
load();
return () => {
isCancelled = true;
};
}, [audioUrl, audioBuffer, drawVisible]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
scrollParentRef.current = findScrollParent({ element: container });
const scrollParent = scrollParentRef.current;
if (!scrollParent) return;
scrollParent.addEventListener("scroll", drawVisible, { passive: true });
return () => scrollParent.removeEventListener("scroll", drawVisible);
}, [drawVisible]);
const onResize = useCallback(
(entry: ResizeObserverEntry) => {
heightRef.current = entry.contentRect.height;
drawVisible();
},
[drawVisible],
);
useResizeObserver({ ref: containerRef, onResize });
return (
<div ref={containerRef} className={cn("relative size-full", className)}>
<canvas ref={canvasRef} className="absolute bottom-0" />
</div>
);
}
@@ -2,18 +2,20 @@
import { useEffect, useState } from "react";
import type { EditorCore } from "@/core";
import { useEditor } from "@/editor/use-editor";
import type { BookmarkDragState } from "../hooks/use-bookmark-drag";
import { DEFAULT_TIMELINE_BOOKMARK_COLOR } from "@/timeline/components/theme";
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "@/timeline/components/layout";
import { DEFAULT_FPS } from "@/fps/defaults";
import { useEditor } from "@/hooks/use-editor";
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
import {
DEFAULT_TIMELINE_BOOKMARK_COLOR,
} from "./theme";
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout";
import { DEFAULT_FPS } from "@/lib/fps/defaults";
import { snappedSeekTime } from "opencut-wasm";
import {
ArrowTurnBackwardIcon,
Delete02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { Bookmark } from "@/timeline";
import type { Bookmark } from "@/lib/timeline";
import {
Popover,
PopoverAnchor,
@@ -25,7 +27,10 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { uppercase } from "@/utils/string";
import { clamp, formatNumberForDisplay } from "@/utils/math";
import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline";
import {
timelineTimeToPixels,
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
const MIN_BOOKMARK_WIDTH_PX = 2;
const BOOKMARK_MARKER_WIDTH_PX = 12;
@@ -355,10 +360,7 @@ function BookmarkPopoverContent({
/>
{bookmark.color &&
bookmark.color.replace(/^#/, "").toUpperCase() !==
DEFAULT_TIMELINE_BOOKMARK_COLOR.replace(
/^#/,
"",
).toUpperCase() && (
DEFAULT_TIMELINE_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
<Button
type="button"
variant="text"
@@ -1,5 +1,5 @@
import { getDropLineY } from "./drop-target";
import type { TimelineTrack, DropTarget } from "@/timeline";
import type { TimelineTrack, DropTarget } from "@/lib/timeline";
import { TIMELINE_LAYERS } from "./layers";
interface DragLineProps {
@@ -1,9 +1,9 @@
import type { TimelineTrack, TimelineElement } from "@/timeline";
import type { ComputeDropTargetParams, DropTarget } from "@/timeline";
import { resolveTrackPlacement } from "@/timeline/placement";
import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
import { resolveTrackPlacement } from "@/lib/timeline/placement";
import { TIMELINE_TRACK_GAP_PX } from "./layout";
import { getTrackHeight } from "./track-layout";
import { TICKS_PER_SECOND } from "@/wasm";
import { TICKS_PER_SECOND } from "@/lib/wasm";
function findElementAtPosition({
mouseX,
@@ -1,9 +1,9 @@
import type {
AnimationPath,
ElementAnimations,
} from "@/animation/types";
import type { TimelineTrack } from "@/timeline";
import { getElementKeyframes } from "@/animation";
} from "@/lib/animation/types";
import type { TimelineTrack } from "@/lib/timeline";
import { getElementKeyframes } from "@/lib/animation";
import { KEYFRAME_LANE_HEIGHT_PX } from "./layout";
export interface ExpandedRow {
@@ -2,8 +2,8 @@
import { useRef, useState, type PointerEvent } from "react";
import { useShiftKey } from "@/hooks/use-shift-key";
import { getBezierPoint } from "@/animation/bezier";
import type { NormalizedCubicBezier } from "@/animation/types";
import { getBezierPoint } from "@/lib/animation/bezier";
import type { NormalizedCubicBezier } from "@/lib/animation/types";
import { cn } from "@/utils/ui";
const GRAPH_WIDTH = 140;
@@ -2,7 +2,7 @@
import { useSyncExternalStore } from "react";
import { generateUUID } from "@/utils/id";
import type { NormalizedCubicBezier } from "@/animation/types";
import type { NormalizedCubicBezier } from "@/lib/animation/types";
import type { EasingPreset } from "./easing-presets";
const STORAGE_KEY = "graph-editor-presets";
@@ -1,4 +1,4 @@
import type { NormalizedCubicBezier } from "@/animation/types";
import type { NormalizedCubicBezier } from "@/lib/animation/types";
export const PRESET_MATCH_TOLERANCE = 0.02;
@@ -11,8 +11,8 @@ import {
Delete02Icon,
PlusSignIcon,
} from "@hugeicons/core-free-icons";
import { getBezierPoint } from "@/animation/bezier";
import type { NormalizedCubicBezier } from "@/animation/types";
import { getBezierPoint } from "@/lib/animation/bezier";
import type { NormalizedCubicBezier } from "@/lib/animation/types";
import type { GraphEditorComponentOption } from "./session";
import {
BUILTIN_PRESETS,
@@ -5,7 +5,7 @@ import {
getNormalizedCubicBezierForScalarSegment,
getScalarKeyframeContext,
updateScalarKeyframeCurve,
} from "@/animation";
} from "@/lib/animation";
import type {
AnimationPath,
ElementAnimations,
@@ -13,8 +13,8 @@ import type {
ScalarCurveKeyframePatch,
ScalarGraphKeyframeContext,
SelectedKeyframeRef,
} from "@/animation/types";
import type { SceneTracks, TimelineElement } from "@/timeline";
} from "@/lib/animation/types";
import type { SceneTracks, TimelineElement } from "@/lib/timeline";
const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1];
const FLAT_VALUE_EPSILON = 1e-6;
@@ -1,10 +1,10 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEditor } from "@/editor/use-editor";
import { registerCanceller } from "@/editor/cancel-interaction";
import type { NormalizedCubicBezier } from "@/animation/types";
import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
import { useEditor } from "@/hooks/use-editor";
import { registerCanceller } from "@/lib/cancel-interaction";
import type { NormalizedCubicBezier } from "@/lib/animation/types";
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
import {
applyGraphEditorCurvePreview,
buildGraphEditorCurvePatches,
@@ -20,7 +20,7 @@ import {
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { useTimelineZoom } from "@/timeline/hooks/use-timeline-zoom";
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
import {
useCallback,
useEffect,
@@ -29,14 +29,14 @@ import {
useState,
type ReactNode,
} from "react";
import type { ElementDragState, DropTarget } from "@/timeline";
import type { ElementDragState, DropTarget } from "@/lib/timeline";
import { TimelineTrackContent } from "./timeline-track";
import { TimelinePlayhead } from "./timeline-playhead";
import { SelectionBox } from "@/selection/selection-box";
import { useBoxSelect } from "@/selection/hooks/use-box-select";
import { SelectionBox } from "@/lib/selection/selection-box";
import { useBoxSelect } from "@/lib/selection/hooks/use-box-select";
import { SnapIndicator } from "./snap-indicator";
import type { SnapPoint } from "@/timeline/snapping";
import type { TimelineTrack } from "@/timeline";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import type { TimelineTrack } from "@/lib/timeline";
import {
TIMELINE_SCROLLBAR_SIZE_PX,
TIMELINE_CONTENT_TOP_PADDING_PX,
@@ -44,14 +44,14 @@ import {
TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX,
KEYFRAME_LANE_HEIGHT_PX,
} from "./layout";
import { useElementInteraction } from "@/timeline/hooks/element/use-element-interaction";
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
import {
canTrackHaveAudio,
canTrackBeHidden,
getTimelineZoomMin,
getTimelinePaddingPx,
} from "@/timeline";
import { timelineTimeToPixels } from "@/timeline/pixel-utils";
} from "@/lib/timeline";
import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
import {
getTrackHeight,
getCumulativeHeightBefore,
@@ -61,27 +61,25 @@ import { SELECTED_TRACK_ROW_CLASS } from "./theme";
import {
computeTrackExpansionHeight,
getTrackExpandedRows,
getExpansionHeight,
getPropertyLabel,
type ExpandedRow,
} from "./expanded-layout";
import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
import { TimelineToolbar } from "./timeline-toolbar";
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
import { useTimelineSeek } from "@/timeline/hooks/use-timeline-seek";
import { useTimelineDragDrop } from "@/timeline/hooks/use-timeline-drag-drop";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
import { TimelineRuler } from "./timeline-ruler";
import {
TimelineBookmarksRow,
useBookmarkDrag,
} from "@/timeline/bookmarks/index";
import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
import { useInitialScrollBottom } from "@/timeline/hooks/use-initial-scroll-bottom";
import { useTimelineResize } from "@/timeline/hooks/use-timeline-resize";
import { useTimelineStore } from "@/timeline/timeline-store";
import { useEditor } from "@/editor/use-editor";
import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead";
import { TimelineBookmarksRow } from "./bookmarks";
import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom";
import { useTimelineStore } from "@/stores/timeline-store";
import { useEditor } from "@/hooks/use-editor";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { DragLine } from "./drag-line";
import { invokeAction } from "@/actions";
import { invokeAction } from "@/lib/actions";
import { resolveTimelineElementIntersections } from "./selection-hit-testing";
import { cn } from "@/utils/ui";
@@ -121,9 +119,7 @@ export function Timeline() {
} = useElementSelection();
const editor = useEditor();
const timeline = editor.timeline;
const scene = useEditor((currentEditor) =>
currentEditor.scenes.getActiveSceneOrNull(),
);
const scene = useEditor((currentEditor) => currentEditor.scenes.getActiveSceneOrNull());
const tracks = useMemo<TimelineTrack[]>(
() =>
scene
@@ -144,6 +140,7 @@ export function Timeline() {
const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
const [isResizing, setIsResizing] = useState(false);
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
null,
);
@@ -151,6 +148,15 @@ export function Timeline() {
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
setCurrentSnapPoint(snapPoint);
}, []);
const handleResizeStateChange = useCallback(
({ isResizing: nextIsResizing }: { isResizing: boolean }) => {
setIsResizing(nextIsResizing);
if (!nextIsResizing) {
setCurrentSnapPoint(null);
}
},
[],
);
const timelineDuration = timeline.getTotalDuration() || 0;
const minZoomLevel = getTimelineZoomMin({
@@ -170,10 +176,6 @@ export function Timeline() {
tracksScrollRef,
rulerScrollRef,
});
const { isResizing, handleResizeStart } = useTimelineResize({
zoomLevel,
onSnapPointChange: handleSnapPointChange,
});
const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
@@ -364,10 +366,7 @@ export function Timeline() {
const containerWidth =
tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
const contentWidth = timelineTimeToPixels({
time: timelineDuration,
zoomLevel,
});
const contentWidth = timelineTimeToPixels({ time: timelineDuration, zoomLevel });
const paddingPx = getTimelinePaddingPx({
containerWidth,
zoomLevel,
@@ -514,10 +513,7 @@ export function Timeline() {
TRACKS_CONTAINER_HEIGHT.min,
Math.min(
TRACKS_CONTAINER_HEIGHT.max,
getTotalTracksHeight({
tracks,
getExtraHeight: getTrackExpansionHeight,
}),
getTotalTracksHeight({ tracks, getExtraHeight: getTrackExpansionHeight }),
),
) + TIMELINE_CONTENT_TOP_PADDING_PX
}px`,
@@ -537,13 +533,14 @@ export function Timeline() {
}}
>
{tracks.length > 0 && (
<TimelineTrackRows
mainTrackId={mainTrackId}
<TimelineTrackRows
mainTrackId={mainTrackId}
zoomLevel={zoomLevel}
dragState={dragState}
tracksScrollRef={tracksScrollRef}
lastMouseXRef={lastMouseXRef}
onResizeStart={handleResizeStart}
onSnapPointChange={handleSnapPointChange}
onResizeStateChange={handleResizeStateChange}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
onTrackMouseDown={(event) => {
@@ -721,7 +718,8 @@ function TimelineTrackRows({
dragState,
tracksScrollRef,
lastMouseXRef,
onResizeStart,
onSnapPointChange,
onResizeStateChange,
onElementMouseDown,
onElementClick,
onTrackMouseDown,
@@ -735,9 +733,8 @@ function TimelineTrackRows({
dragState: ElementDragState;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
lastMouseXRef: React.RefObject<number>;
onResizeStart: React.ComponentProps<
typeof TimelineTrackContent
>["onResizeStart"];
onSnapPointChange: (snapPoint: SnapPoint | null) => void;
onResizeStateChange: (params: { isResizing: boolean }) => void;
onElementMouseDown: React.ComponentProps<
typeof TimelineTrackContent
>["onElementMouseDown"];
@@ -801,7 +798,8 @@ function TimelineTrackRows({
<div
className={cn(
"absolute right-0 left-0 transition-colors",
tracksWithSelection.has(track.id) && SELECTED_TRACK_ROW_CLASS,
tracksWithSelection.has(track.id) &&
SELECTED_TRACK_ROW_CLASS,
)}
style={{
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`,
@@ -815,7 +813,8 @@ function TimelineTrackRows({
rulerScrollRef={tracksScrollRef}
tracksScrollRef={tracksScrollRef}
lastMouseXRef={lastMouseXRef}
onResizeStart={onResizeStart}
onSnapPointChange={onSnapPointChange}
onResizeStateChange={onResizeStateChange}
onElementMouseDown={onElementMouseDown}
onElementClick={onElementClick}
onTrackMouseDown={onTrackMouseDown}
@@ -930,10 +929,12 @@ function TrackToggleIcon({
function PropertyTree({ rows }: { rows: ExpandedRow[] }) {
return (
<div className="flex flex-col overflow-hidden">
{rows.map((row) => (
{rows.map((row, index) => (
<div
key={row.propertyPath}
className={cn("flex shrink-0 items-center px-3 bg-muted/50")}
className={cn(
"flex shrink-0 items-center px-3 bg-muted/50",
)}
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
>
<span className="text-muted-foreground truncate text-xs leading-none">
@@ -1,4 +1,4 @@
import type { TrackType } from "@/timeline";
import type { TrackType } from "@/lib/timeline";
export const TIMELINE_TRACK_HEIGHTS_PX: Record<TrackType, number> = {
video: 65,
@@ -1,5 +1,5 @@
import type { TimelineTrack } from "@/timeline";
import { timelineTimeToPixels } from "@/timeline/pixel-utils";
import type { TimelineTrack } from "@/lib/timeline";
import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
import {
TIMELINE_CONTENT_TOP_PADDING_PX,
} from "./layout";

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