summaryrefslogtreecommitdiff
path: root/apps/web/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib')
-rw-r--r--apps/web/src/lib/actions/definitions.ts162
-rw-r--r--apps/web/src/lib/actions/index.ts3
-rw-r--r--apps/web/src/lib/actions/registry.ts61
-rw-r--r--apps/web/src/lib/actions/types.ts42
-rw-r--r--apps/web/src/lib/auth/client.ts6
-rw-r--r--apps/web/src/lib/auth/server.ts43
-rw-r--r--apps/web/src/lib/blog-query.ts64
-rw-r--r--apps/web/src/lib/blog/query.ts72
-rw-r--r--apps/web/src/lib/canvas-gradients.ts219
-rw-r--r--apps/web/src/lib/commands/base-command.ts11
-rw-r--r--apps/web/src/lib/commands/index.ts6
-rw-r--r--apps/web/src/lib/commands/media/add-media-asset.ts61
-rw-r--r--apps/web/src/lib/commands/media/index.ts2
-rw-r--r--apps/web/src/lib/commands/media/remove-media-asset.ts85
-rw-r--r--apps/web/src/lib/commands/project/index.ts1
-rw-r--r--apps/web/src/lib/commands/project/update-project-settings.ts46
-rw-r--r--apps/web/src/lib/commands/scene/create-scene.ts40
-rw-r--r--apps/web/src/lib/commands/scene/delete-scene.ts59
-rw-r--r--apps/web/src/lib/commands/scene/index.ts5
-rw-r--r--apps/web/src/lib/commands/scene/remove-bookmark.ts59
-rw-r--r--apps/web/src/lib/commands/scene/rename-scene.ts46
-rw-r--r--apps/web/src/lib/commands/scene/toggle-bookmark.ts52
-rw-r--r--apps/web/src/lib/commands/timeline/clipboard/index.ts1
-rw-r--r--apps/web/src/lib/commands/timeline/clipboard/paste.ts263
-rw-r--r--apps/web/src/lib/commands/timeline/element/delete-elements.ts48
-rw-r--r--apps/web/src/lib/commands/timeline/element/duplicate-elements.ts114
-rw-r--r--apps/web/src/lib/commands/timeline/element/index.ts11
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts311
-rw-r--r--apps/web/src/lib/commands/timeline/element/move-elements.ts127
-rw-r--r--apps/web/src/lib/commands/timeline/element/split-elements.ts133
-rw-r--r--apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts57
-rw-r--r--apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts47
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-duration.ts37
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-start-time.ts48
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-trim.ts48
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-text-element.ts52
-rw-r--r--apps/web/src/lib/commands/timeline/index.ts3
-rw-r--r--apps/web/src/lib/commands/timeline/track/add-track.ts53
-rw-r--r--apps/web/src/lib/commands/timeline/track/index.ts4
-rw-r--r--apps/web/src/lib/commands/timeline/track/remove-track.ts35
-rw-r--r--apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts39
-rw-r--r--apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts40
-rw-r--r--apps/web/src/lib/db/index.ts19
-rw-r--r--apps/web/src/lib/db/schema.ts59
-rw-r--r--apps/web/src/lib/drag-data.ts48
-rw-r--r--apps/web/src/lib/editor-utils.ts46
-rw-r--r--apps/web/src/lib/export.ts311
-rw-r--r--apps/web/src/lib/font-config.ts39
-rw-r--r--apps/web/src/lib/gradients/canvas.ts923
-rw-r--r--apps/web/src/lib/gradients/index.ts2
-rw-r--r--apps/web/src/lib/gradients/parser.ts652
-rw-r--r--apps/web/src/lib/iconify-api.ts357
-rw-r--r--apps/web/src/lib/media-processing.ts89
-rw-r--r--apps/web/src/lib/media/audio.ts459
-rw-r--r--apps/web/src/lib/media/media-utils.ts32
-rw-r--r--apps/web/src/lib/media/mediabunny.ts226
-rw-r--r--apps/web/src/lib/media/processing.ts282
-rw-r--r--apps/web/src/lib/mediabunny-utils.ts311
-rw-r--r--apps/web/src/lib/rate-limit.ts21
-rw-r--r--apps/web/src/lib/scenes.ts103
-rw-r--r--apps/web/src/lib/schemas/waitlist.ts15
-rw-r--r--apps/web/src/lib/storage/indexeddb-adapter.ts89
-rw-r--r--apps/web/src/lib/storage/opfs-adapter.ts73
-rw-r--r--apps/web/src/lib/storage/storage-service.ts480
-rw-r--r--apps/web/src/lib/storage/types.ts69
-rw-r--r--apps/web/src/lib/time.ts325
-rw-r--r--apps/web/src/lib/timeline-renderer.ts279
-rw-r--r--apps/web/src/lib/timeline.ts54
-rw-r--r--apps/web/src/lib/timeline/bookmarks.ts61
-rw-r--r--apps/web/src/lib/timeline/drop-utils.ts234
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts304
-rw-r--r--apps/web/src/lib/timeline/index.ts23
-rw-r--r--apps/web/src/lib/timeline/ruler-utils.ts228
-rw-r--r--apps/web/src/lib/timeline/track-utils.ts244
-rw-r--r--apps/web/src/lib/timeline/zoom-utils.ts53
-rw-r--r--apps/web/src/lib/transcription-utils.ts13
-rw-r--r--apps/web/src/lib/transcription/caption.ts49
-rw-r--r--apps/web/src/lib/utils.ts80
-rw-r--r--apps/web/src/lib/video-cache.ts186
-rw-r--r--apps/web/src/lib/zk-encryption.ts71
80 files changed, 6743 insertions, 2782 deletions
diff --git a/apps/web/src/lib/actions/definitions.ts b/apps/web/src/lib/actions/definitions.ts
new file mode 100644
index 00000000..83144a25
--- /dev/null
+++ b/apps/web/src/lib/actions/definitions.ts
@@ -0,0 +1,162 @@
+import type { ShortcutKey } from "@/types/keybinding";
+
+export type TActionCategory =
+ | "playback"
+ | "navigation"
+ | "editing"
+ | "selection"
+ | "history"
+ | "timeline"
+ | "controls";
+
+export interface TActionDefinition {
+ description: string;
+ category: TActionCategory;
+ defaultShortcuts?: ShortcutKey[];
+ args?: Record<string, unknown>;
+}
+
+export const ACTIONS = {
+ "toggle-play": {
+ description: "Play/Pause",
+ category: "playback",
+ defaultShortcuts: ["space", "k"],
+ },
+ "stop-playback": {
+ description: "Stop playback",
+ category: "playback",
+ },
+ "seek-forward": {
+ description: "Seek forward 1 second",
+ category: "playback",
+ defaultShortcuts: ["l"],
+ args: { seconds: "number" },
+ },
+ "seek-backward": {
+ description: "Seek backward 1 second",
+ category: "playback",
+ defaultShortcuts: ["j"],
+ args: { seconds: "number" },
+ },
+ "frame-step-forward": {
+ description: "Frame step forward",
+ category: "navigation",
+ defaultShortcuts: ["right"],
+ },
+ "frame-step-backward": {
+ description: "Frame step backward",
+ category: "navigation",
+ defaultShortcuts: ["left"],
+ },
+ "jump-forward": {
+ description: "Jump forward 5 seconds",
+ category: "navigation",
+ defaultShortcuts: ["shift+right"],
+ args: { seconds: "number" },
+ },
+ "jump-backward": {
+ description: "Jump backward 5 seconds",
+ category: "navigation",
+ defaultShortcuts: ["shift+left"],
+ args: { seconds: "number" },
+ },
+ "goto-start": {
+ description: "Go to timeline start",
+ category: "navigation",
+ defaultShortcuts: ["home", "enter"],
+ },
+ "goto-end": {
+ description: "Go to timeline end",
+ category: "navigation",
+ defaultShortcuts: ["end"],
+ },
+ split: {
+ description: "Split elements at playhead",
+ category: "editing",
+ defaultShortcuts: ["s"],
+ },
+ "split-left": {
+ description: "Split and remove left",
+ category: "editing",
+ defaultShortcuts: ["q"],
+ },
+ "split-right": {
+ description: "Split and remove right",
+ category: "editing",
+ defaultShortcuts: ["w"],
+ },
+ "delete-selected": {
+ description: "Delete selected elements",
+ category: "editing",
+ defaultShortcuts: ["backspace", "delete"],
+ },
+ "copy-selected": {
+ description: "Copy selected elements",
+ category: "editing",
+ defaultShortcuts: ["ctrl+c"],
+ },
+ "paste-copied": {
+ description: "Paste elements at playhead",
+ category: "editing",
+ defaultShortcuts: ["ctrl+v"],
+ },
+ "toggle-snapping": {
+ description: "Toggle snapping",
+ category: "editing",
+ defaultShortcuts: ["n"],
+ },
+ "select-all": {
+ description: "Select all elements",
+ category: "selection",
+ defaultShortcuts: ["ctrl+a"],
+ },
+ "duplicate-selected": {
+ description: "Duplicate selected element",
+ category: "selection",
+ defaultShortcuts: ["ctrl+d"],
+ },
+ "toggle-elements-muted-selected": {
+ description: "Mute/unmute selected elements",
+ category: "selection",
+ },
+ "toggle-elements-visibility-selected": {
+ description: "Show/hide selected elements",
+ category: "selection",
+ },
+ "toggle-bookmark": {
+ description: "Toggle bookmark at playhead",
+ category: "timeline",
+ },
+ undo: {
+ description: "Undo",
+ category: "history",
+ defaultShortcuts: ["ctrl+z"],
+ },
+ redo: {
+ description: "Redo",
+ category: "history",
+ defaultShortcuts: ["ctrl+shift+z", "ctrl+y"],
+ },
+} as const satisfies Record<string, TActionDefinition>;
+
+export type TAction = keyof typeof ACTIONS;
+
+export function getActionDefinition(action: TAction): TActionDefinition {
+ return ACTIONS[action];
+}
+
+export function getDefaultShortcuts(): Record<ShortcutKey, TAction> {
+ const shortcuts: Record<string, TAction> = {};
+
+ for (const [action, def] of Object.entries(ACTIONS) as Array<
+ [TAction, TActionDefinition]
+ >) {
+ if (def.defaultShortcuts) {
+ for (const shortcut of def.defaultShortcuts) {
+ shortcuts[shortcut] = action;
+ }
+ }
+ }
+
+ return shortcuts as Record<ShortcutKey, TAction>;
+}
diff --git a/apps/web/src/lib/actions/index.ts b/apps/web/src/lib/actions/index.ts
new file mode 100644
index 00000000..801a846a
--- /dev/null
+++ b/apps/web/src/lib/actions/index.ts
@@ -0,0 +1,3 @@
+export * from "./definitions";
+export * from "./types";
+export * from "./registry";
diff --git a/apps/web/src/lib/actions/registry.ts b/apps/web/src/lib/actions/registry.ts
new file mode 100644
index 00000000..b9c84529
--- /dev/null
+++ b/apps/web/src/lib/actions/registry.ts
@@ -0,0 +1,61 @@
+import type {
+ TAction,
+ TActionFunc,
+ TActionWithArgs,
+ TActionWithOptionalArgs,
+ TActionArgsMap,
+ TArgOfAction,
+ TInvocationTrigger,
+} from "./types";
+
+type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void;
+const boundActions: Partial<Record<TAction, ActionHandler[]>> = {};
+
+export function bindAction<A extends TAction>(
+ action: A,
+ handler: TActionFunc<A>,
+) {
+ const handlers = boundActions[action];
+ const typedHandler = handler as ActionHandler;
+ if (handlers) {
+ handlers.push(typedHandler);
+ } else {
+ boundActions[action] = [typedHandler];
+ }
+}
+
+export function unbindAction<A extends TAction>(
+ action: A,
+ handler: TActionFunc<A>,
+) {
+ const handlers = boundActions[action];
+ if (!handlers) return;
+
+ const typedHandler = handler as ActionHandler;
+ boundActions[action] = handlers.filter((h) => h !== typedHandler);
+
+ if (boundActions[action]?.length === 0) {
+ delete boundActions[action];
+ }
+}
+
+type InvokeActionFunc = {
+ (
+ action: TActionWithOptionalArgs,
+ args?: undefined,
+ trigger?: TInvocationTrigger,
+ ): void;
+ <A extends TActionWithArgs>(
+ action: A,
+ args: TActionArgsMap[A],
+ trigger?: TInvocationTrigger,
+ ): void;
+};
+
+export const invokeAction: InvokeActionFunc = <A extends TAction>(
+ action: A,
+ args?: TArgOfAction<A>,
+ trigger?: TInvocationTrigger,
+) => {
+ boundActions[action]?.forEach((handler) => handler(args, trigger));
+};
diff --git a/apps/web/src/lib/actions/types.ts b/apps/web/src/lib/actions/types.ts
new file mode 100644
index 00000000..508bf10e
--- /dev/null
+++ b/apps/web/src/lib/actions/types.ts
@@ -0,0 +1,42 @@
+import type { MutableRefObject } from "react";
+import type { TAction } from "./definitions";
+
+export type { TAction };
+
+export type TActionArgsMap = {
+ "seek-forward": { seconds: number } | undefined;
+ "seek-backward": { seconds: number } | undefined;
+ "jump-forward": { seconds: number } | undefined;
+ "jump-backward": { seconds: number } | undefined;
+};
+
+type TKeysWithValueUndefined<T> = {
+ [K in keyof T]: undefined extends T[K] ? K : never;
+}[keyof T];
+
+export type TActionWithArgs = keyof TActionArgsMap;
+
+export type TActionWithOptionalArgs =
+ | TActionWithNoArgs
+ | TKeysWithValueUndefined<TActionArgsMap>;
+
+export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
+
+export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
+ ? TActionArgsMap[A]
+ : undefined;
+
+export type TActionFunc<A extends TAction> = A extends TActionWithArgs
+ ? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
+ : (_?: undefined, trigger?: TInvocationTrigger) => void;
+
+export type TInvocationTrigger = "keypress" | "mouseclick";
+
+export type TBoundActionList = {
+ [A in TAction]?: Array<TActionFunc<A>>;
+};
+
+export type TActionHandlerOptions =
+ | MutableRefObject<boolean>
+ | boolean
+ | undefined;
diff --git a/apps/web/src/lib/auth/client.ts b/apps/web/src/lib/auth/client.ts
new file mode 100644
index 00000000..63b441f7
--- /dev/null
+++ b/apps/web/src/lib/auth/client.ts
@@ -0,0 +1,6 @@
+import { createAuthClient } from "better-auth/react";
+import { webEnv } from "@opencut/env/web";
+
+export const { signIn, signUp, useSession } = createAuthClient({
+ baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
+});
diff --git a/apps/web/src/lib/auth/server.ts b/apps/web/src/lib/auth/server.ts
new file mode 100644
index 00000000..978d7383
--- /dev/null
+++ b/apps/web/src/lib/auth/server.ts
@@ -0,0 +1,43 @@
+import { betterAuth, type RateLimit } from "better-auth";
+import { drizzleAdapter } from "better-auth/adapters/drizzle";
+import { Redis } from "@upstash/redis";
+import { db } from "@/lib/db";
+import { webEnv } from "@opencut/env/web";
+
+const redis = new Redis({
+ url: webEnv.UPSTASH_REDIS_REST_URL,
+ token: webEnv.UPSTASH_REDIS_REST_TOKEN,
+});
+
+export const auth = betterAuth({
+ database: drizzleAdapter(db, {
+ provider: "pg",
+ usePlural: true,
+ }),
+ secret: webEnv.BETTER_AUTH_SECRET,
+ user: {
+ deleteUser: {
+ enabled: true,
+ },
+ },
+ emailAndPassword: {
+ enabled: true,
+ },
+ rateLimit: {
+ storage: "secondary-storage",
+ customStorage: {
+ get: async (key) => {
+ const value = await redis.get(key);
+ return value as RateLimit | undefined;
+ },
+ set: async (key, value) => {
+ await redis.set(key, value);
+ },
+ },
+ },
+ baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
+ appName: "OpenCut",
+ trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL],
+});
+
+export type Auth = typeof auth;
diff --git a/apps/web/src/lib/blog-query.ts b/apps/web/src/lib/blog-query.ts
deleted file mode 100644
index 5cba0cf3..00000000
--- a/apps/web/src/lib/blog-query.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import type {
- MarbleAuthorList,
- MarbleCategoryList,
- MarblePost,
- MarblePostList,
- MarbleTagList,
-} from "@/types/post";
-import { unified } from "unified";
-import rehypeParse from "rehype-parse";
-import rehypeStringify from "rehype-stringify";
-import rehypeSlug from "rehype-slug";
-import rehypeAutolinkHeadings from "rehype-autolink-headings";
-import rehypeSanitize from "rehype-sanitize";
-
-const url =
- process.env.NEXT_PUBLIC_MARBLE_API_URL ?? "https://api.marblecms.com";
-const key = process.env.MARBLE_WORKSPACE_KEY ?? "cmd4iw9mm0006l804kwqv0k46";
-
-async function fetchFromMarble<T>(endpoint: string): Promise<T> {
- try {
- const response = await fetch(`${url}/${key}/${endpoint}`);
- if (!response.ok) {
- throw new Error(
- `Failed to fetch ${endpoint}: ${response.status} ${response.statusText}`
- );
- }
- return (await response.json()) as T;
- } catch (error) {
- console.error(`Error fetching ${endpoint}:`, error);
- throw error;
- }
-}
-
-export async function getPosts() {
- return fetchFromMarble<MarblePostList>("posts");
-}
-
-export async function getTags() {
- return fetchFromMarble<MarbleTagList>("tags");
-}
-
-export async function getSinglePost(slug: string) {
- return fetchFromMarble<MarblePost>(`posts/${slug}`);
-}
-
-export async function getCategories() {
- return fetchFromMarble<MarbleCategoryList>("categories");
-}
-
-export async function getAuthors() {
- return fetchFromMarble<MarbleAuthorList>("authors");
-}
-
-export async function processHtmlContent(html: string): Promise<string> {
- const processor = unified()
- .use(rehypeSanitize)
- .use(rehypeParse, { fragment: true })
- .use(rehypeSlug)
- .use(rehypeAutolinkHeadings, { behavior: "append" })
- .use(rehypeStringify);
-
- const file = await processor.process(html);
- return String(file);
-}
diff --git a/apps/web/src/lib/blog/query.ts b/apps/web/src/lib/blog/query.ts
new file mode 100644
index 00000000..e6d68bb6
--- /dev/null
+++ b/apps/web/src/lib/blog/query.ts
@@ -0,0 +1,72 @@
+import type {
+ MarbleAuthorList,
+ MarbleCategoryList,
+ MarblePost,
+ MarblePostList,
+ MarbleTagList,
+} from "@/types/blog";
+import { unified } from "unified";
+import rehypeParse from "rehype-parse";
+import rehypeStringify from "rehype-stringify";
+import rehypeSlug from "rehype-slug";
+import rehypeAutolinkHeadings from "rehype-autolink-headings";
+import rehypeSanitize from "rehype-sanitize";
+
+const url =
+ process.env.NEXT_PUBLIC_MARBLE_API_URL ?? "https://api.marblecms.com";
+const key = process.env.MARBLE_WORKSPACE_KEY ?? "cmd4iw9mm0006l804kwqv0k46";
+
+async function fetchFromMarble<T>({
+ endpoint,
+}: {
+ endpoint: string;
+}): Promise<T> {
+ try {
+ const response = await fetch(`${url}/${key}/${endpoint}`);
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch ${endpoint}: ${response.status} ${response.statusText}`,
+ );
+ }
+ return (await response.json()) as T;
+ } catch (error) {
+ console.error(`Error fetching ${endpoint}:`, error);
+ throw error;
+ }
+}
+
+export async function getPosts() {
+ return fetchFromMarble<MarblePostList>({ endpoint: "posts" });
+}
+
+export async function getTags() {
+ return fetchFromMarble<MarbleTagList>({ endpoint: "tags" });
+}
+
+export async function getSinglePost({ slug }: { slug: string }) {
+ return fetchFromMarble<MarblePost>({ endpoint: `posts/${slug}` });
+}
+
+export async function getCategories() {
+ return fetchFromMarble<MarbleCategoryList>({ endpoint: "categories" });
+}
+
+export async function getAuthors() {
+ return fetchFromMarble<MarbleAuthorList>({ endpoint: "authors" });
+}
+
+export async function processHtmlContent({
+ html,
+}: {
+ html: string;
+}): Promise<string> {
+ const processor = unified()
+ .use(rehypeSanitize)
+ .use(rehypeParse, { fragment: true })
+ .use(rehypeSlug)
+ .use(rehypeAutolinkHeadings, { behavior: "append" })
+ .use(rehypeStringify);
+
+ const file = await processor.process({ value: html, type: "html" });
+ return String(file);
+}
diff --git a/apps/web/src/lib/canvas-gradients.ts b/apps/web/src/lib/canvas-gradients.ts
deleted file mode 100644
index 7d1cdc05..00000000
--- a/apps/web/src/lib/canvas-gradients.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-function splitBackgroundLayers(input: string): string[] {
- const layers: string[] = [];
- let depth = 0;
- let start = 0;
- for (let i = 0; i < input.length; i += 1) {
- const ch = input[i] as string;
- if (ch === "(") depth += 1;
- else if (ch === ")") depth -= 1;
- else if (ch === "," && depth === 0) {
- layers.push(input.slice(start, i).trim());
- start = i + 1;
- }
- }
- layers.push(input.slice(start).trim());
- return layers;
-}
-
-function parseColorStop(stop: string): { color: string; position?: number } {
- const s = stop.trim();
-
- // Handle functional colors like rgba(), rgb(), hsla(), hsl()
- const colorFunctions = ["rgba(", "rgb(", "hsla(", "hsl("];
- let color = "";
- let remaining = "";
-
- for (const fn of colorFunctions) {
- if (s.startsWith(fn)) {
- let depth = 0;
- for (let i = 0; i < s.length; i += 1) {
- const ch = s[i] as string;
- if (ch === "(") depth += 1;
- else if (ch === ")") {
- depth -= 1;
- if (depth === 0) {
- color = s.slice(0, i + 1);
- remaining = s.slice(i + 1).trim();
- break;
- }
- }
- }
- break;
- }
- }
-
- if (!color) {
- const parts = s.split(/\s+/);
- color = parts[0] as string;
- remaining = parts.slice(1).join(" ");
- }
-
- // Convert transparent to transparent white for better blending
- if (color === "transparent") {
- color = "rgba(255, 255, 255, 0)";
- }
-
- // Parse position if present
- let position: number | undefined;
- if (remaining) {
- const posMatch = remaining.match(/(\d+(?:\.\d+)?)%/);
- if (posMatch) {
- position = parseFloat(posMatch[1] as string) / 100;
- }
- }
-
- return { color, position };
-}
-
-function parseLinearGradient(layer: string, width: number, height: number) {
- const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")"));
- const parts = splitBackgroundLayers(inside);
- const dir = (parts.shift() || "").trim();
-
- let x0 = 0,
- y0 = 0,
- x1 = width,
- y1 = 0; // default: to right
-
- if (dir.endsWith("deg")) {
- const deg = parseFloat(dir);
- const rad = (deg * Math.PI) / 180;
- const cx = width / 2;
- const cy = height / 2;
- const r = Math.hypot(width, height) / 2;
- x0 = cx - Math.cos(rad) * r;
- y0 = cy - Math.sin(rad) * r;
- x1 = cx + Math.cos(rad) * r;
- y1 = cy + Math.sin(rad) * r;
- } else if (dir.startsWith("to ")) {
- const d = dir.slice(3).trim();
- if (d === "right") {
- x0 = 0;
- y0 = 0;
- x1 = width;
- y1 = 0;
- } else if (d === "left") {
- x0 = width;
- y0 = 0;
- x1 = 0;
- y1 = 0;
- } else if (d === "bottom") {
- x0 = 0;
- y0 = 0;
- x1 = 0;
- y1 = height;
- } else if (d === "top") {
- x0 = 0;
- y0 = height;
- x1 = 0;
- y1 = 0;
- }
- } else {
- // No direction specified, treat as first color
- parts.unshift(dir);
- }
-
- return { x0, y0, x1, y1, colors: parts };
-}
-
-function parseRadialGradient(layer: string, width: number, height: number) {
- const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")"));
- const parts = splitBackgroundLayers(inside);
- const first = (parts.shift() || "").trim();
-
- let cx = width / 2;
- let cy = height / 2;
-
- if (first.startsWith("circle at")) {
- const pos = first.replace("circle at", "").trim();
- const coords = pos.split(/\s+/);
-
- for (let i = 0; i < coords.length; i += 1) {
- const coord = coords[i] as string;
- if (coord.endsWith("%")) {
- const val = parseFloat(coord) / 100;
- if (i === 0) cx = val * width;
- else if (i === 1) cy = val * height;
- } else if (coord === "left") cx = 0;
- else if (coord === "right") cx = width;
- else if (coord === "top") cy = 0;
- else if (coord === "bottom") cy = height;
- else if (coord === "center") {
- if (i === 0) cx = width / 2;
- else if (i === 1) cy = height / 2;
- }
- }
- } else {
- parts.unshift(first);
- }
-
- // Use farthest-corner for radius
- const r = Math.max(
- Math.hypot(cx, cy),
- Math.hypot(width - cx, cy),
- Math.hypot(cx, height - cy),
- Math.hypot(width - cx, height - cy)
- );
-
- return { cx, cy, r, colors: parts };
-}
-
-export function drawCssBackground(
- ctx: CanvasRenderingContext2D,
- width: number,
- height: number,
- css: string
-): void {
- const layers = splitBackgroundLayers(css).filter(Boolean);
-
- // Draw layers from last to first (CSS background order)
- for (let i = layers.length - 1; i >= 0; i -= 1) {
- const layer = layers[i] as string;
-
- if (layer.startsWith("linear-gradient(")) {
- const { x0, y0, x1, y1, colors } = parseLinearGradient(
- layer,
- width,
- height
- );
- const grad = ctx.createLinearGradient(x0, y0, x1, y1);
- const colorStops = colors.map((c) => parseColorStop(c as string));
-
- // Handle positions properly
- for (let j = 0; j < colorStops.length; j += 1) {
- const stop = colorStops[j] as { color: string; position?: number };
- const pos = stop.position ?? j / Math.max(1, colorStops.length - 1);
- grad.addColorStop(Math.max(0, Math.min(1, pos)), stop.color);
- }
-
- ctx.fillStyle = grad;
- ctx.fillRect(0, 0, width, height);
- } else if (layer.startsWith("radial-gradient(")) {
- const { cx, cy, r, colors } = parseRadialGradient(layer, width, height);
- const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
- const colorStops = colors.map((c) => parseColorStop(c as string));
-
- // Handle positions properly
- for (let j = 0; j < colorStops.length; j += 1) {
- const stop = colorStops[j] as { color: string; position?: number };
- const pos = stop.position ?? j / Math.max(1, colorStops.length - 1);
- grad.addColorStop(Math.max(0, Math.min(1, pos)), stop.color);
- }
-
- ctx.fillStyle = grad;
- ctx.fillRect(0, 0, width, height);
- } else if (
- layer.startsWith("#") ||
- layer.startsWith("rgb") ||
- layer.startsWith("hsl") ||
- layer === "transparent" ||
- layer === "white" ||
- layer === "black"
- ) {
- if (layer !== "transparent") {
- ctx.fillStyle = layer;
- ctx.fillRect(0, 0, width, height);
- }
- }
- }
-}
diff --git a/apps/web/src/lib/commands/base-command.ts b/apps/web/src/lib/commands/base-command.ts
new file mode 100644
index 00000000..66062c52
--- /dev/null
+++ b/apps/web/src/lib/commands/base-command.ts
@@ -0,0 +1,11 @@
+export abstract class Command {
+ abstract execute(): void;
+
+ undo(): void {
+ throw new Error("Undo not implemented for this command");
+ }
+
+ redo(): void {
+ this.execute();
+ }
+}
diff --git a/apps/web/src/lib/commands/index.ts b/apps/web/src/lib/commands/index.ts
new file mode 100644
index 00000000..6700087b
--- /dev/null
+++ b/apps/web/src/lib/commands/index.ts
@@ -0,0 +1,6 @@
+export { Command } from "./base-command";
+
+export * from "./timeline";
+export * from "./media";
+export * from "./scene";
+export * from "./project";
diff --git a/apps/web/src/lib/commands/media/add-media-asset.ts b/apps/web/src/lib/commands/media/add-media-asset.ts
new file mode 100644
index 00000000..d947cb97
--- /dev/null
+++ b/apps/web/src/lib/commands/media/add-media-asset.ts
@@ -0,0 +1,61 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { MediaAsset } from "@/types/assets";
+import { generateUUID } from "@/utils/id";
+import { storageService } from "@/services/storage/storage-service";
+
+export class AddMediaAssetCommand extends Command {
+ private assetId: string;
+ private savedAssets: MediaAsset[] | null = null;
+ private createdAsset: MediaAsset | null = null;
+
+ constructor(
+ private projectId: string,
+ private asset: Omit<MediaAsset, "id">,
+ ) {
+ super();
+ this.assetId = generateUUID();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedAssets = [...editor.media.getAssets()];
+
+ this.createdAsset = {
+ ...this.asset,
+ id: this.assetId,
+ };
+
+ editor.media.setAssets({
+ assets: [...this.savedAssets, this.createdAsset],
+ });
+
+ storageService
+ .saveMediaAsset({
+ projectId: this.projectId,
+ mediaAsset: this.createdAsset,
+ })
+ .catch((error) => {
+ console.error("Failed to save media item:", error);
+ });
+ }
+
+ undo(): void {
+ if (this.savedAssets) {
+ const editor = EditorCore.getInstance();
+ editor.media.setAssets({ assets: this.savedAssets });
+
+ if (this.createdAsset) {
+ storageService
+ .deleteMediaAsset({ projectId: this.projectId, id: this.assetId })
+ .catch((error) => {
+ console.error("Failed to delete media item on undo:", error);
+ });
+ }
+ }
+ }
+
+ getAssetId(): string {
+ return this.assetId;
+ }
+}
diff --git a/apps/web/src/lib/commands/media/index.ts b/apps/web/src/lib/commands/media/index.ts
new file mode 100644
index 00000000..466222f2
--- /dev/null
+++ b/apps/web/src/lib/commands/media/index.ts
@@ -0,0 +1,2 @@
+export { AddMediaAssetCommand } from "./add-media-asset";
+export { RemoveMediaAssetCommand } from "./remove-media-asset";
diff --git a/apps/web/src/lib/commands/media/remove-media-asset.ts b/apps/web/src/lib/commands/media/remove-media-asset.ts
new file mode 100644
index 00000000..aa1a5775
--- /dev/null
+++ b/apps/web/src/lib/commands/media/remove-media-asset.ts
@@ -0,0 +1,85 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { MediaAsset } from "@/types/assets";
+import { storageService } from "@/services/storage/storage-service";
+import { videoCache } from "@/services/media/video-cache";
+import { hasMediaId } from "@/lib/timeline/element-utils";
+import type { TimelineTrack } from "@/types/timeline";
+
+export class RemoveMediaAssetCommand extends Command {
+ private savedAssets: MediaAsset[] | null = null;
+ private savedTracks: TimelineTrack[] | null = null;
+ private removedAsset: MediaAsset | null = null;
+
+ constructor(
+ private projectId: string,
+ private assetId: string,
+ ) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ const assets = editor.media.getAssets();
+
+ this.savedAssets = [...assets];
+ this.savedTracks = editor.timeline.getTracks();
+
+ this.removedAsset =
+ assets.find((media) => media.id === this.assetId) ?? null;
+
+ if (!this.removedAsset) {
+ console.error("Media asset not found:", this.assetId);
+ return;
+ }
+
+ videoCache.clearVideo({ mediaId: this.assetId });
+
+ editor.media.setAssets({
+ assets: assets.filter((media) => media.id !== this.assetId),
+ });
+
+ const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
+
+ for (const track of this.savedTracks) {
+ for (const element of track.elements) {
+ if (hasMediaId(element) && element.mediaId === this.assetId) {
+ elementsToRemove.push({ trackId: track.id, elementId: element.id });
+ }
+ }
+ }
+
+ if (elementsToRemove.length > 0) {
+ editor.timeline.deleteElements({ elements: elementsToRemove });
+ }
+
+ storageService
+ .deleteMediaAsset({ projectId: this.projectId, id: this.assetId })
+ .catch((error) => {
+ console.error("Failed to delete media item:", error);
+ });
+ }
+
+ undo(): void {
+ const editor = EditorCore.getInstance();
+
+ if (this.savedAssets) {
+ editor.media.setAssets({ assets: this.savedAssets });
+ }
+
+ if (this.savedTracks) {
+ editor.timeline.updateTracks(this.savedTracks);
+ }
+
+ if (this.removedAsset) {
+ storageService
+ .saveMediaAsset({
+ projectId: this.projectId,
+ mediaAsset: this.removedAsset,
+ })
+ .catch((error) => {
+ console.error("Failed to restore media item on undo:", error);
+ });
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/project/index.ts b/apps/web/src/lib/commands/project/index.ts
new file mode 100644
index 00000000..a9f26edc
--- /dev/null
+++ b/apps/web/src/lib/commands/project/index.ts
@@ -0,0 +1 @@
+export * from "./update-project-settings";
diff --git a/apps/web/src/lib/commands/project/update-project-settings.ts b/apps/web/src/lib/commands/project/update-project-settings.ts
new file mode 100644
index 00000000..b7ab75ee
--- /dev/null
+++ b/apps/web/src/lib/commands/project/update-project-settings.ts
@@ -0,0 +1,46 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { TProject, TProjectSettings } from "@/types/project";
+
+export class UpdateProjectSettingsCommand extends Command {
+ private savedSettings: TProjectSettings | null = null;
+ private savedUpdatedAt: Date | null = null;
+
+ constructor(private updates: Partial<TProjectSettings>) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ this.savedSettings = activeProject.settings;
+ this.savedUpdatedAt = activeProject.metadata.updatedAt;
+
+ const updatedProject: TProject = {
+ ...activeProject,
+ settings: { ...activeProject.settings, ...this.updates },
+ metadata: { ...activeProject.metadata, updatedAt: new Date() },
+ };
+
+ editor.project.setActiveProject({ project: updatedProject });
+ editor.save.markDirty();
+ }
+
+ undo(): void {
+ if (!this.savedSettings || !this.savedUpdatedAt) return;
+ const editor = EditorCore.getInstance();
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ const updatedProject: TProject = {
+ ...activeProject,
+ settings: this.savedSettings,
+ metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt },
+ };
+
+ editor.project.setActiveProject({ project: updatedProject });
+ editor.save.markDirty();
+ }
+}
diff --git a/apps/web/src/lib/commands/scene/create-scene.ts b/apps/web/src/lib/commands/scene/create-scene.ts
new file mode 100644
index 00000000..5fea5d53
--- /dev/null
+++ b/apps/web/src/lib/commands/scene/create-scene.ts
@@ -0,0 +1,40 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { TScene } from "@/types/timeline";
+import { buildDefaultScene } from "@/lib/scenes";
+
+export class CreateSceneCommand extends Command {
+ private savedScenes: TScene[] | null = null;
+ private createdScene: TScene | null = null;
+
+ constructor(
+ private name: string,
+ private isMain: boolean = false,
+ ) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedScenes = [...editor.scenes.getScenes()];
+
+ this.createdScene = buildDefaultScene({
+ name: this.name,
+ isMain: this.isMain,
+ });
+
+ const updatedScenes = [...this.savedScenes, this.createdScene];
+ editor.scenes.setScenes({ scenes: updatedScenes });
+ }
+
+ undo(): void {
+ if (this.savedScenes) {
+ const editor = EditorCore.getInstance();
+ editor.scenes.setScenes({ scenes: this.savedScenes });
+ }
+ }
+
+ getSceneId(): string {
+ return this.createdScene?.id ?? "";
+ }
+}
diff --git a/apps/web/src/lib/commands/scene/delete-scene.ts b/apps/web/src/lib/commands/scene/delete-scene.ts
new file mode 100644
index 00000000..83066e24
--- /dev/null
+++ b/apps/web/src/lib/commands/scene/delete-scene.ts
@@ -0,0 +1,59 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { TScene } from "@/types/timeline";
+import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
+
+export class DeleteSceneCommand extends Command {
+ private savedScenes: TScene[] | null = null;
+ private savedActiveSceneId: string | null = null;
+ private deletedScene: TScene | null = null;
+
+ constructor(private sceneId: string) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ const scenes = editor.scenes.getScenes();
+ const activeScene = editor.scenes.getActiveScene();
+
+ this.savedScenes = [...scenes];
+ this.savedActiveSceneId = activeScene?.id ?? null;
+
+ this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null;
+
+ if (!this.deletedScene) {
+ console.error("Scene not found:", this.sceneId);
+ return;
+ }
+
+ const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene });
+ if (!canDelete) {
+ console.error("Cannot delete scene:", reason);
+ return;
+ }
+
+ const updatedScenes = scenes.filter((s) => s.id !== this.sceneId);
+
+ const newActiveScene = getFallbackSceneAfterDelete({
+ scenes: updatedScenes,
+ deletedSceneId: this.sceneId,
+ currentSceneId: activeScene?.id ?? null,
+ });
+
+ editor.scenes.setScenes({
+ scenes: updatedScenes,
+ activeSceneId: newActiveScene?.id,
+ });
+ }
+
+ undo(): void {
+ if (this.savedScenes && this.deletedScene) {
+ const editor = EditorCore.getInstance();
+ editor.scenes.setScenes({
+ scenes: this.savedScenes,
+ activeSceneId: this.savedActiveSceneId ?? undefined,
+ });
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/scene/index.ts b/apps/web/src/lib/commands/scene/index.ts
new file mode 100644
index 00000000..5bf9406a
--- /dev/null
+++ b/apps/web/src/lib/commands/scene/index.ts
@@ -0,0 +1,5 @@
+export { CreateSceneCommand } from "./create-scene";
+export { DeleteSceneCommand } from "./delete-scene";
+export { RenameSceneCommand } from "./rename-scene";
+export { ToggleBookmarkCommand } from "./toggle-bookmark";
+export { RemoveBookmarkCommand } from "./remove-bookmark";
diff --git a/apps/web/src/lib/commands/scene/remove-bookmark.ts b/apps/web/src/lib/commands/scene/remove-bookmark.ts
new file mode 100644
index 00000000..a3029366
--- /dev/null
+++ b/apps/web/src/lib/commands/scene/remove-bookmark.ts
@@ -0,0 +1,59 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { TScene } from "@/types/timeline";
+import { updateSceneInArray } from "@/lib/scenes";
+import {
+ getFrameTime,
+ removeBookmarkFromArray,
+} from "@/lib/timeline/bookmarks";
+
+export class RemoveBookmarkCommand extends Command {
+ private savedScenes: TScene[] | null = null;
+ private frameTime: number = 0;
+
+ constructor(private time: number) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ const activeScene = editor.scenes.getActiveScene();
+ const activeProject = editor.project.getActive();
+
+ if (!activeScene || !activeProject) {
+ return;
+ }
+
+ const scenes = editor.scenes.getScenes();
+ this.savedScenes = [...scenes];
+
+ this.frameTime = getFrameTime({
+ time: this.time,
+ fps: activeProject.settings.fps,
+ });
+
+ const updatedBookmarks = removeBookmarkFromArray({
+ bookmarks: activeScene.bookmarks,
+ frameTime: this.frameTime,
+ });
+
+ if (updatedBookmarks.length === activeScene.bookmarks.length) {
+ return;
+ }
+
+ const updatedScenes = updateSceneInArray({
+ scenes,
+ sceneId: activeScene.id,
+ updates: { bookmarks: updatedBookmarks },
+ });
+
+ editor.scenes.setScenes({ scenes: updatedScenes });
+ }
+
+ undo(): void {
+ if (this.savedScenes) {
+ const editor = EditorCore.getInstance();
+ editor.scenes.setScenes({ scenes: this.savedScenes });
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/scene/rename-scene.ts b/apps/web/src/lib/commands/scene/rename-scene.ts
new file mode 100644
index 00000000..dc62369d
--- /dev/null
+++ b/apps/web/src/lib/commands/scene/rename-scene.ts
@@ -0,0 +1,46 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { TScene } from "@/types/timeline";
+import { updateSceneInArray } from "@/lib/scenes";
+
+export class RenameSceneCommand extends Command {
+ private savedScenes: TScene[] | null = null;
+ private previousName: string | null = null;
+
+ constructor(
+ private sceneId: string,
+ private newName: string,
+ ) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ const scenes = editor.scenes.getScenes();
+
+ this.savedScenes = [...scenes];
+
+ const scene = scenes.find((s) => s.id === this.sceneId);
+ if (!scene) {
+ console.error("Scene not found:", this.sceneId);
+ return;
+ }
+
+ this.previousName = scene.name;
+
+ const updatedScenes = updateSceneInArray({
+ scenes,
+ sceneId: this.sceneId,
+ updates: { name: this.newName, updatedAt: new Date() },
+ });
+
+ editor.scenes.setScenes({ scenes: updatedScenes });
+ }
+
+ undo(): void {
+ if (this.savedScenes && this.previousName !== null) {
+ const editor = EditorCore.getInstance();
+ editor.scenes.setScenes({ scenes: this.savedScenes });
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/scene/toggle-bookmark.ts b/apps/web/src/lib/commands/scene/toggle-bookmark.ts
new file mode 100644
index 00000000..ac76d8fc
--- /dev/null
+++ b/apps/web/src/lib/commands/scene/toggle-bookmark.ts
@@ -0,0 +1,52 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { TScene } from "@/types/timeline";
+import { updateSceneInArray } from "@/lib/scenes";
+import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks";
+
+export class ToggleBookmarkCommand extends Command {
+ private savedScenes: TScene[] | null = null;
+ private frameTime: number = 0;
+
+ constructor(private time: number) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ const activeScene = editor.scenes.getActiveScene();
+ const activeProject = editor.project.getActive();
+
+ if (!activeScene || !activeProject) {
+ return;
+ }
+
+ const scenes = editor.scenes.getScenes();
+ this.savedScenes = [...scenes];
+
+ this.frameTime = getFrameTime({
+ time: this.time,
+ fps: activeProject.settings.fps,
+ });
+
+ const updatedBookmarks = toggleBookmarkInArray({
+ bookmarks: activeScene.bookmarks,
+ frameTime: this.frameTime,
+ });
+
+ const updatedScenes = updateSceneInArray({
+ scenes,
+ sceneId: activeScene.id,
+ updates: { bookmarks: updatedBookmarks },
+ });
+
+ editor.scenes.setScenes({ scenes: updatedScenes });
+ }
+
+ undo(): void {
+ if (this.savedScenes) {
+ const editor = EditorCore.getInstance();
+ editor.scenes.setScenes({ scenes: this.savedScenes });
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/clipboard/index.ts b/apps/web/src/lib/commands/timeline/clipboard/index.ts
new file mode 100644
index 00000000..11a8bed4
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/clipboard/index.ts
@@ -0,0 +1 @@
+export { PasteCommand } from "./paste";
diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
new file mode 100644
index 00000000..9c979790
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
@@ -0,0 +1,263 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type {
+ TimelineTrack,
+ TimelineElement,
+ ClipboardItem,
+} from "@/types/timeline";
+import { generateUUID } from "@/utils/id";
+import { wouldElementOverlap } from "@/lib/timeline/element-utils";
+import {
+ buildEmptyTrack,
+ getHighestInsertIndexForTrack,
+} from "@/lib/timeline/track-utils";
+
+export class PasteCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private pastedElements: { trackId: string; elementId: string }[] = [];
+ private previousSelection: { trackId: string; elementId: string }[] = [];
+
+ constructor(
+ private time: number,
+ private clipboardItems: ClipboardItem[],
+ ) {
+ super();
+ }
+
+ execute(): void {
+ if (this.clipboardItems.length === 0) return;
+
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+ this.previousSelection = editor.selection.getSelectedElements();
+ this.pastedElements = [];
+
+ const minStart = Math.min(
+ ...this.clipboardItems.map((item) => item.element.startTime),
+ );
+
+ const updatedTracks = [...this.savedState];
+ const itemsByTrackId = groupClipboardItemsByTrackId({
+ clipboardItems: this.clipboardItems,
+ });
+
+ for (const [trackId, items] of itemsByTrackId) {
+ const elementsToAdd = buildPastedElements({
+ items,
+ minStart,
+ time: this.time,
+ });
+
+ if (elementsToAdd.length === 0) {
+ continue;
+ }
+
+ const trackType = items[0].trackType;
+ const sourceTrackIndex = updatedTracks.findIndex(
+ (track) => track.id === trackId,
+ );
+ const resolvedTargetIndex = resolveTargetTrackIndex({
+ tracks: updatedTracks,
+ sourceTrackIndex,
+ trackType,
+ elements: elementsToAdd,
+ });
+
+ if (resolvedTargetIndex >= 0) {
+ const targetTrack = updatedTracks[resolvedTargetIndex];
+ updatedTracks[resolvedTargetIndex] = {
+ ...targetTrack,
+ elements: [...targetTrack.elements, ...elementsToAdd],
+ } as TimelineTrack;
+ for (const element of elementsToAdd) {
+ this.pastedElements.push({
+ trackId: targetTrack.id,
+ elementId: element.id,
+ });
+ }
+ continue;
+ }
+
+ const insertIndex = resolveInsertIndexForNewTrack({
+ tracks: updatedTracks,
+ sourceTrackIndex,
+ trackType,
+ });
+ const newTrack = buildTrackWithElements({
+ trackType,
+ elements: elementsToAdd,
+ });
+ updatedTracks.splice(insertIndex, 0, newTrack);
+ for (const element of elementsToAdd) {
+ this.pastedElements.push({
+ trackId: newTrack.id,
+ elementId: element.id,
+ });
+ }
+ }
+
+ editor.timeline.updateTracks(updatedTracks);
+
+ if (this.pastedElements.length > 0) {
+ editor.selection.setSelectedElements({ elements: this.pastedElements });
+ }
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ editor.selection.setSelectedElements({ elements: this.previousSelection });
+ }
+ }
+
+ getPastedElements(): { trackId: string; elementId: string }[] {
+ return this.pastedElements;
+ }
+}
+
+function groupClipboardItemsByTrackId({
+ clipboardItems,
+}: {
+ clipboardItems: ClipboardItem[];
+}): Map<string, ClipboardItem[]> {
+ const groupedItems = new Map<string, ClipboardItem[]>();
+
+ for (const item of clipboardItems) {
+ const existingItems = groupedItems.get(item.trackId) ?? [];
+ groupedItems.set(item.trackId, [...existingItems, item]);
+ }
+
+ return groupedItems;
+}
+
+function buildPastedElements({
+ items,
+ minStart,
+ time,
+}: {
+ items: ClipboardItem[];
+ minStart: number;
+ time: number;
+}): TimelineElement[] {
+ const elementsToAdd: TimelineElement[] = [];
+
+ for (const item of items) {
+ const relativeOffset = item.element.startTime - minStart;
+ const startTime = Math.max(0, time + relativeOffset);
+ const newElementId = generateUUID();
+
+ elementsToAdd.push({
+ ...item.element,
+ id: newElementId,
+ startTime,
+ } as TimelineElement);
+ }
+
+ return elementsToAdd;
+}
+
+function resolveTargetTrackIndex({
+ tracks,
+ sourceTrackIndex,
+ trackType,
+ elements,
+}: {
+ tracks: TimelineTrack[];
+ sourceTrackIndex: number;
+ trackType: ClipboardItem["trackType"];
+ elements: TimelineElement[];
+}): number {
+ if (sourceTrackIndex >= 0) {
+ const aboveIndex = sourceTrackIndex - 1;
+ if (aboveIndex < 0) {
+ return -1;
+ }
+
+ const aboveTrack = tracks[aboveIndex];
+ if (aboveTrack.type !== trackType) {
+ return -1;
+ }
+
+ const canPlaceOnAbove = canPlaceElementsOnTrack({
+ track: aboveTrack,
+ elements,
+ });
+ return canPlaceOnAbove ? aboveIndex : -1;
+ }
+
+ const highestCompatibleIndex = tracks.findIndex(
+ (track) => track.type === trackType,
+ );
+ if (highestCompatibleIndex < 0) {
+ return -1;
+ }
+
+ const highestCompatibleTrack = tracks[highestCompatibleIndex];
+ const canPlaceOnHighest = canPlaceElementsOnTrack({
+ track: highestCompatibleTrack,
+ elements,
+ });
+
+ return canPlaceOnHighest ? highestCompatibleIndex : -1;
+}
+
+function resolveInsertIndexForNewTrack({
+ tracks,
+ sourceTrackIndex,
+ trackType,
+}: {
+ tracks: TimelineTrack[];
+ sourceTrackIndex: number;
+ trackType: ClipboardItem["trackType"];
+}): number {
+ if (sourceTrackIndex >= 0) {
+ return sourceTrackIndex;
+ }
+
+ const highestCompatibleIndex = tracks.findIndex(
+ (track) => track.type === trackType,
+ );
+ if (highestCompatibleIndex >= 0) {
+ return highestCompatibleIndex;
+ }
+
+ return getHighestInsertIndexForTrack({ tracks, trackType });
+}
+
+function canPlaceElementsOnTrack({
+ track,
+ elements,
+}: {
+ track: TimelineTrack;
+ elements: TimelineElement[];
+}): boolean {
+ for (const element of elements) {
+ const endTime = element.startTime + element.duration;
+ const hasOverlap = wouldElementOverlap({
+ elements: track.elements,
+ startTime: element.startTime,
+ endTime,
+ });
+ if (hasOverlap) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function buildTrackWithElements({
+ trackType,
+ elements,
+}: {
+ trackType: ClipboardItem["trackType"];
+ elements: TimelineElement[];
+}): TimelineTrack {
+ const newTrackId = generateUUID();
+ const newTrackBase = buildEmptyTrack({ id: newTrackId, type: trackType });
+ return {
+ ...newTrackBase,
+ elements,
+ } as TimelineTrack;
+}
diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
new file mode 100644
index 00000000..91eb6397
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
@@ -0,0 +1,48 @@
+import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/types/timeline";
+import { EditorCore } from "@/core";
+import { isMainTrack } from "@/lib/timeline";
+
+export class DeleteElementsCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(private elements: { trackId: string; elementId: string }[]) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = this.savedState
+ .map((track) => {
+ const hasElementsToDelete = this.elements.some(
+ (el) => el.trackId === track.id,
+ );
+
+ if (!hasElementsToDelete) {
+ return track;
+ }
+
+ return {
+ ...track,
+ elements: track.elements.filter(
+ (element) =>
+ !this.elements.some(
+ (el) => el.trackId === track.id && el.elementId === element.id,
+ ),
+ ),
+ } as typeof track;
+ })
+ .filter((track) => track.elements.length > 0 || isMainTrack(track));
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
new file mode 100644
index 00000000..4bec6444
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
@@ -0,0 +1,114 @@
+import { Command } from "@/lib/commands/base-command";
+import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import { generateUUID } from "@/utils/id";
+import { EditorCore } from "@/core";
+import {
+ buildEmptyTrack,
+ getHighestInsertIndexForTrack,
+} from "@/lib/timeline/track-utils";
+
+interface DuplicateElementsParams {
+ elements: { trackId: string; elementId: string }[];
+}
+
+export class DuplicateElementsCommand extends Command {
+ private duplicatedElements: { trackId: string; elementId: string }[] = [];
+ private savedState: TimelineTrack[] | null = null;
+ private previousSelection: { trackId: string; elementId: string }[] = [];
+ private elements: DuplicateElementsParams["elements"];
+
+ constructor({ elements }: DuplicateElementsParams) {
+ super();
+ this.elements = elements;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+ this.previousSelection = editor.selection.getSelectedElements();
+ this.duplicatedElements = [];
+
+ const updatedTracks = [...this.savedState];
+
+ for (const track of this.savedState) {
+ const elementsToDuplicate = this.elements.filter(
+ (el) => el.trackId === track.id,
+ );
+
+ if (elementsToDuplicate.length === 0) {
+ continue;
+ }
+
+ const elementIdsToDuplicate = new Set(
+ elementsToDuplicate.map((element) => element.elementId),
+ );
+ const newTrackElements: TimelineElement[] = [];
+
+ const newTrackId = generateUUID();
+ const newTrackBase = buildEmptyTrack({
+ id: newTrackId,
+ type: track.type,
+ });
+
+ for (const element of track.elements) {
+ if (!elementIdsToDuplicate.has(element.id)) {
+ continue;
+ }
+
+ const newId = generateUUID();
+ this.duplicatedElements.push({
+ trackId: newTrackId,
+ elementId: newId,
+ });
+ newTrackElements.push(
+ buildDuplicateElement({
+ element,
+ id: newId,
+ startTime: element.startTime,
+ }),
+ );
+ }
+
+ const newTrack = {
+ ...newTrackBase,
+ elements: newTrackElements,
+ } as TimelineTrack;
+
+ const insertIndex = getHighestInsertIndexForTrack({
+ tracks: updatedTracks,
+ trackType: track.type,
+ });
+ updatedTracks.splice(insertIndex, 0, newTrack);
+ }
+
+ editor.timeline.updateTracks(updatedTracks);
+
+ if (this.duplicatedElements.length > 0) {
+ editor.selection.setSelectedElements({ elements: this.duplicatedElements });
+ }
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ editor.selection.setSelectedElements({ elements: this.previousSelection });
+ }
+ }
+
+ getDuplicatedElements(): { trackId: string; elementId: string }[] {
+ return this.duplicatedElements;
+ }
+}
+
+function buildDuplicateElement({
+ element,
+ id,
+ startTime,
+}: {
+ element: TimelineElement;
+ id: string;
+ startTime: number;
+}): TimelineElement {
+ return { ...element, id, name: `${element.name} (copy)`, startTime };
+}
diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts
new file mode 100644
index 00000000..8b719afa
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/index.ts
@@ -0,0 +1,11 @@
+export { InsertElementCommand } from "./insert-element";
+export { DeleteElementsCommand } from "./delete-elements";
+export { DuplicateElementsCommand } from "./duplicate-elements";
+export { UpdateElementTrimCommand } from "./update-element-trim";
+export { UpdateElementDurationCommand } from "./update-element-duration";
+export { UpdateElementStartTimeCommand } from "./update-element-start-time";
+export { SplitElementsCommand } from "./split-elements";
+export { UpdateTextElementCommand } from "./update-text-element";
+export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
+export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
+export { MoveElementCommand } from "./move-elements";
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts
new file mode 100644
index 00000000..f95935a5
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts
@@ -0,0 +1,311 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type {
+ CreateTimelineElement,
+ TimelineTrack,
+ TimelineElement,
+ TrackType,
+ ElementType,
+} from "@/types/timeline";
+import { generateUUID } from "@/utils/id";
+import {
+ requiresMediaId,
+ wouldElementOverlap,
+} from "@/lib/timeline/element-utils";
+import {
+ buildEmptyTrack,
+ canElementGoOnTrack,
+ getDefaultInsertIndexForTrack,
+ validateElementTrackCompatibility,
+} from "@/lib/timeline/track-utils";
+import type { MediaAsset } from "@/types/assets";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+type InsertElementPlacement =
+ | { mode: "explicit"; trackId: string }
+ | { mode: "auto"; trackType?: TrackType; insertIndex?: number };
+
+export interface InsertElementParams {
+ element: CreateTimelineElement;
+ placement: InsertElementPlacement;
+}
+
+export class InsertElementCommand extends Command {
+ private elementId: string;
+ private savedState: TimelineTrack[] | null = null;
+ private targetTrackId: string | null = null;
+
+ constructor({ element, placement }: InsertElementParams) {
+ super();
+ this.elementId = generateUUID();
+ this.element = element;
+ this.placement = placement;
+ }
+
+ private element: CreateTimelineElement;
+ private placement: InsertElementPlacement;
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ if (!this.savedState) {
+ console.error("Tracks not available");
+ return;
+ }
+
+ if (!this.validateElementBasics({ element: this.element })) {
+ return;
+ }
+
+ const totalElementsInTimeline = this.savedState.reduce(
+ (total, t) => total + t.elements.length,
+ 0,
+ );
+ const isFirstElement = totalElementsInTimeline === 0;
+
+ const newElement = this.buildElement({ element: this.element });
+ const updateResult = this.resolveTracksWithElement({
+ tracks: this.savedState,
+ element: newElement,
+ });
+
+ if (!updateResult) {
+ return;
+ }
+
+ const { updatedTracks, targetTrackId } = updateResult;
+ this.targetTrackId = targetTrackId;
+
+ const isVisualMedia =
+ newElement.type === "video" || newElement.type === "image";
+
+ if (isFirstElement && isVisualMedia) {
+ const mediaAssets = editor.media.getAssets();
+ const activeProject = editor.project.getActive();
+ const asset = mediaAssets.find(
+ (item: MediaAsset) => item.id === newElement.mediaId,
+ );
+
+ if (asset?.width && asset?.height) {
+ const nextCanvasSize = { width: asset.width, height: asset.height };
+ const shouldSetOriginalCanvasSize =
+ !activeProject?.settings.originalCanvasSize;
+ editor.project.updateSettings({
+ settings: {
+ canvasSize: nextCanvasSize,
+ ...(shouldSetOriginalCanvasSize
+ ? { originalCanvasSize: nextCanvasSize }
+ : {}),
+ },
+ pushHistory: false,
+ });
+ }
+
+ if (asset?.type === "video" && asset?.fps) {
+ editor.project.updateSettings({
+ settings: { fps: asset.fps },
+ pushHistory: false,
+ });
+ }
+ }
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+
+ getElementId(): string {
+ return this.elementId;
+ }
+
+ getTrackId(): string | null {
+ return this.targetTrackId;
+ }
+
+ private buildElement({
+ element,
+ }: {
+ element: CreateTimelineElement;
+ }): TimelineElement {
+ return {
+ ...element,
+ id: this.elementId,
+ startTime: element.startTime,
+ trimStart: element.trimStart ?? 0,
+ trimEnd: element.trimEnd ?? 0,
+ duration: element.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ } as TimelineElement;
+ }
+
+ private validateElementBasics({
+ element,
+ }: {
+ element: CreateTimelineElement;
+ }): boolean {
+ if (requiresMediaId({ element }) && !("mediaId" in element)) {
+ console.error("Element requires mediaId");
+ return false;
+ }
+
+ if (
+ element.type === "audio" &&
+ element.sourceType === "library" &&
+ !element.sourceUrl
+ ) {
+ console.error("Library audio element must have sourceUrl");
+ return false;
+ }
+
+ if (element.type === "sticker" && !element.iconName) {
+ console.error("Sticker element must have iconName");
+ return false;
+ }
+
+ if (element.type === "text" && !element.content) {
+ console.error("Text element must have content");
+ return false;
+ }
+
+ return true;
+ }
+
+ private resolveTracksWithElement({
+ tracks,
+ element,
+ }: {
+ tracks: TimelineTrack[];
+ element: TimelineElement;
+ }): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
+ const placement = this.placement;
+
+ if (placement.mode === "explicit") {
+ const targetTrack = tracks.find(
+ (track) => track.id === placement.trackId,
+ );
+
+ if (!targetTrack) {
+ console.error("Track not found:", placement.trackId);
+ return null;
+ }
+
+ const validation = validateElementTrackCompatibility({
+ element,
+ track: targetTrack,
+ });
+
+ if (!validation.isValid) {
+ console.error(validation.errorMessage);
+ return null;
+ }
+
+ const updatedTracks = tracks.map((track) =>
+ track.id === targetTrack.id
+ ? { ...track, elements: [...track.elements, element] }
+ : track,
+ ) as TimelineTrack[];
+
+ return { updatedTracks, targetTrackId: targetTrack.id };
+ }
+
+ const trackType =
+ placement.trackType ?? this.getTrackTypeForElement({ element });
+
+ if (
+ placement.trackType &&
+ !canElementGoOnTrack({
+ elementType: element.type,
+ trackType,
+ })
+ ) {
+ console.error(
+ `${element.type} elements cannot be placed on ${trackType} tracks`,
+ );
+ return null;
+ }
+
+ const elementEndTime = element.startTime + element.duration;
+ const existingTrack = tracks.find((track) => {
+ if (
+ !canElementGoOnTrack({
+ elementType: element.type,
+ trackType: track.type,
+ })
+ ) {
+ return false;
+ }
+
+ return !wouldElementOverlap({
+ elements: track.elements,
+ startTime: element.startTime,
+ endTime: elementEndTime,
+ });
+ });
+
+ if (existingTrack) {
+ const updatedTracks = tracks.map((track) =>
+ track.id === existingTrack.id
+ ? { ...track, elements: [...track.elements, element] }
+ : track,
+ ) as TimelineTrack[];
+
+ return { updatedTracks, targetTrackId: existingTrack.id };
+ }
+
+ const newTrackId = generateUUID();
+ const newTrack = buildEmptyTrack({
+ id: newTrackId,
+ type: trackType,
+ });
+ const newTrackWithElement = {
+ ...newTrack,
+ elements: [...newTrack.elements, element],
+ } as TimelineTrack;
+
+ const updatedTracks = [...tracks];
+ const insertIndex =
+ placement.insertIndex ??
+ this.getAutoInsertIndex({ tracks: updatedTracks, trackType });
+ updatedTracks.splice(insertIndex, 0, newTrackWithElement);
+
+ return { updatedTracks, targetTrackId: newTrackId };
+ }
+
+ private getAutoInsertIndex({
+ tracks,
+ trackType,
+ }: {
+ tracks: TimelineTrack[];
+ trackType: TrackType;
+ }): number {
+ if (trackType === "text") {
+ const firstVideoTrackIndex = tracks.findIndex(
+ (track) => track.type === "video",
+ );
+ if (firstVideoTrackIndex >= 0) {
+ return firstVideoTrackIndex;
+ }
+ }
+
+ return getDefaultInsertIndexForTrack({
+ tracks,
+ trackType,
+ });
+ }
+
+ private getTrackTypeForElement({
+ element,
+ }: {
+ element: { type: ElementType };
+ }): TrackType {
+ if (element.type === "video" || element.type === "image") {
+ return "video";
+ }
+ return element.type;
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts
new file mode 100644
index 00000000..2d4f9236
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts
@@ -0,0 +1,127 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type {
+ TimelineTrack,
+ TimelineElement,
+ TrackType,
+} from "@/types/timeline";
+import {
+ buildEmptyTrack,
+ isMainTrack,
+ validateElementTrackCompatibility,
+} from "@/lib/timeline/track-utils";
+
+export class MoveElementCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(
+ private sourceTrackId: string,
+ private targetTrackId: string,
+ private elementId: string,
+ private newStartTime: number,
+ private createTrack?: { type: TrackType; index: number },
+ ) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const sourceTrack = this.savedState.find(
+ (t) => t.id === this.sourceTrackId,
+ );
+ const element = sourceTrack?.elements.find(
+ (el) => el.id === this.elementId,
+ );
+
+ if (!sourceTrack || !element) {
+ console.error("Source track or element not found");
+ return;
+ }
+
+ let targetTrack = this.savedState.find((t) => t.id === this.targetTrackId);
+ let tracksToUpdate = this.savedState;
+ if (!targetTrack && this.createTrack) {
+ const newTrack = buildEmptyTrack({
+ id: this.targetTrackId,
+ type: this.createTrack.type,
+ });
+ tracksToUpdate = [...this.savedState];
+ tracksToUpdate.splice(this.createTrack.index, 0, newTrack);
+ targetTrack = newTrack;
+ }
+ if (!targetTrack) {
+ console.error("Target track not found");
+ return;
+ }
+
+ const validation = validateElementTrackCompatibility({
+ element,
+ track: targetTrack,
+ });
+
+ if (!validation.isValid) {
+ console.error(validation.errorMessage);
+ return;
+ }
+
+ const movedElement: TimelineElement = {
+ ...element,
+ startTime: this.newStartTime,
+ };
+
+ const isSameTrack = this.sourceTrackId === this.targetTrackId;
+
+ let updatedTracks = tracksToUpdate.map((track) => {
+ if (isSameTrack && track.id === this.sourceTrackId) {
+ return {
+ ...track,
+ elements: track.elements.map((el) =>
+ el.id === this.elementId ? movedElement : el,
+ ),
+ };
+ }
+
+ if (track.id === this.sourceTrackId) {
+ return {
+ ...track,
+ elements: track.elements.filter((el) => el.id !== this.elementId),
+ };
+ }
+
+ if (track.id === this.targetTrackId) {
+ return {
+ ...track,
+ elements: [...track.elements, movedElement],
+ };
+ }
+
+ return track;
+ }) as TimelineTrack[];
+
+ if (!isSameTrack) {
+ const sourceTrackAfterMove = updatedTracks.find(
+ (track) => track.id === this.sourceTrackId,
+ );
+ if (
+ sourceTrackAfterMove &&
+ sourceTrackAfterMove.elements.length === 0 &&
+ !isMainTrack(sourceTrackAfterMove)
+ ) {
+ updatedTracks = updatedTracks.filter(
+ (track) => track.id !== this.sourceTrackId,
+ );
+ }
+ }
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts
new file mode 100644
index 00000000..13d4d730
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts
@@ -0,0 +1,133 @@
+import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/types/timeline";
+import { generateUUID } from "@/utils/id";
+import { EditorCore } from "@/core";
+
+export class SplitElementsCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private rightSideElements: { trackId: string; elementId: string }[] = [];
+ private previousSelection: { trackId: string; elementId: string }[] = [];
+
+ constructor(
+ private elements: { trackId: string; elementId: string }[],
+ private splitTime: number,
+ private retainSide: "both" | "left" | "right" = "both",
+ ) {
+ super();
+ }
+
+ getRightSideElements(): { trackId: string; elementId: string }[] {
+ return this.rightSideElements;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+ this.previousSelection = editor.selection.getSelectedElements();
+ this.rightSideElements = [];
+
+ const updatedTracks = this.savedState.map((track) => {
+ const elementsToSplit = this.elements.filter(
+ (el) => el.trackId === track.id,
+ );
+
+ if (elementsToSplit.length === 0) {
+ return track;
+ }
+
+ return {
+ ...track,
+ elements: track.elements.flatMap((element) => {
+ const shouldSplit = elementsToSplit.some(
+ (el) => el.elementId === element.id,
+ );
+
+ if (!shouldSplit) {
+ return [element];
+ }
+
+ const effectiveStart = element.startTime;
+ const effectiveEnd = element.startTime + element.duration;
+
+ if (
+ this.splitTime <= effectiveStart ||
+ this.splitTime >= effectiveEnd
+ ) {
+ return [element];
+ }
+
+ const relativeTime = this.splitTime - element.startTime;
+ const leftVisibleDuration = relativeTime;
+ const rightVisibleDuration = element.duration - relativeTime;
+
+ if (this.retainSide === "left") {
+ return [
+ {
+ ...element,
+ duration: leftVisibleDuration,
+ trimEnd: element.trimEnd + rightVisibleDuration,
+ name: `${element.name} (left)`,
+ },
+ ];
+ }
+
+ if (this.retainSide === "right") {
+ const newId = generateUUID();
+ this.rightSideElements.push({
+ trackId: track.id,
+ elementId: newId,
+ });
+ return [
+ {
+ ...element,
+ id: newId,
+ startTime: this.splitTime,
+ duration: rightVisibleDuration,
+ trimStart: element.trimStart + leftVisibleDuration,
+ name: `${element.name} (right)`,
+ },
+ ];
+ }
+
+ // "both" - split into two pieces
+ const secondElementId = generateUUID();
+ this.rightSideElements.push({
+ trackId: track.id,
+ elementId: secondElementId,
+ });
+
+ return [
+ {
+ ...element,
+ duration: leftVisibleDuration,
+ trimEnd: element.trimEnd + rightVisibleDuration,
+ name: `${element.name} (left)`,
+ },
+ {
+ ...element,
+ id: secondElementId,
+ startTime: this.splitTime,
+ duration: rightVisibleDuration,
+ trimStart: element.trimStart + leftVisibleDuration,
+ name: `${element.name} (right)`,
+ },
+ ];
+ }),
+ } as typeof track;
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+
+ if (this.rightSideElements.length > 0) {
+ editor.selection.setSelectedElements({ elements: this.rightSideElements });
+ }
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ editor.selection.setSelectedElements({ elements: this.previousSelection });
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts
new file mode 100644
index 00000000..1fa7421e
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts
@@ -0,0 +1,57 @@
+import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/types/timeline";
+import { canElementHaveAudio } from "@/lib/timeline/element-utils";
+import { EditorCore } from "@/core";
+
+export class ToggleElementsMutedCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(private elements: { trackId: string; elementId: string }[]) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const mutableElements = this.elements.filter(({ trackId, elementId }) => {
+ const track = this.savedState?.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
+ return element && canElementHaveAudio(element);
+ });
+
+ if (mutableElements.length === 0) {
+ return;
+ }
+
+ const shouldMute = mutableElements.some(({ trackId, elementId }) => {
+ const track = this.savedState?.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
+ return element && canElementHaveAudio(element) && !element.muted;
+ });
+
+ const updatedTracks = this.savedState.map((track) => {
+ const newElements = track.elements.map((element) => {
+ const shouldUpdate = mutableElements.some(
+ ({ trackId, elementId }) =>
+ track.id === trackId && element.id === elementId,
+ );
+ return shouldUpdate &&
+ canElementHaveAudio(element) &&
+ element.muted !== shouldMute
+ ? { ...element, muted: shouldMute }
+ : element;
+ });
+ return { ...track, elements: newElements } as typeof track;
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts
new file mode 100644
index 00000000..daeb759a
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts
@@ -0,0 +1,47 @@
+import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/types/timeline";
+import { canElementBeHidden } from "@/lib/timeline/element-utils";
+import { EditorCore } from "@/core";
+
+export class ToggleElementsVisibilityCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(private elements: { trackId: string; elementId: string }[]) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const shouldHide = this.elements.some(({ trackId, elementId }) => {
+ const track = this.savedState?.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
+ return element && canElementBeHidden(element) && !element.hidden;
+ });
+
+ const updatedTracks = this.savedState.map((track) => {
+ const newElements = track.elements.map((element) => {
+ const shouldUpdate = this.elements.some(
+ ({ trackId, elementId }) =>
+ track.id === trackId && element.id === elementId,
+ );
+ return shouldUpdate &&
+ canElementBeHidden(element) &&
+ element.hidden !== shouldHide
+ ? { ...element, hidden: shouldHide }
+ : element;
+ });
+ return { ...track, elements: newElements } as typeof track;
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
new file mode 100644
index 00000000..6b3a860b
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
@@ -0,0 +1,37 @@
+import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/types/timeline";
+import { EditorCore } from "@/core";
+
+export class UpdateElementDurationCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(
+ private trackId: string,
+ private elementId: string,
+ private duration: number,
+ ) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = this.savedState.map((t) => {
+ if (t.id !== this.trackId) return t;
+ const newElements = t.elements.map((el) =>
+ el.id === this.elementId ? { ...el, duration: this.duration } : el,
+ );
+ return { ...t, elements: newElements } as typeof t;
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
new file mode 100644
index 00000000..5312000f
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
@@ -0,0 +1,48 @@
+import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/types/timeline";
+import { EditorCore } from "@/core";
+
+export class UpdateElementStartTimeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(
+ private elements: { trackId: string; elementId: string }[],
+ private startTime: number,
+ ) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = this.savedState.map((track) => {
+ const hasElementsToUpdate = this.elements.some(
+ (el) => el.trackId === track.id,
+ );
+
+ if (!hasElementsToUpdate) {
+ return track;
+ }
+
+ const newElements = track.elements.map((element) => {
+ const shouldUpdate = this.elements.some(
+ (el) => el.elementId === element.id && el.trackId === track.id,
+ );
+ return shouldUpdate
+ ? { ...element, startTime: Math.max(0, this.startTime) }
+ : element;
+ });
+ return { ...track, elements: newElements } as typeof track;
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
new file mode 100644
index 00000000..99c63287
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
@@ -0,0 +1,48 @@
+import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/types/timeline";
+import { EditorCore } from "@/core";
+
+export class UpdateElementTrimCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(
+ private elementId: string,
+ private trimStart: number,
+ private trimEnd: number,
+ private startTime?: number,
+ private duration?: number,
+ ) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = this.savedState.map((track) => {
+ const newElements = track.elements.map((element) => {
+ if (element.id !== this.elementId) {
+ return element;
+ }
+
+ return {
+ ...element,
+ trimStart: this.trimStart,
+ trimEnd: this.trimEnd,
+ startTime: this.startTime ?? element.startTime,
+ duration: this.duration ?? element.duration,
+ };
+ });
+ return { ...track, elements: newElements } as typeof track;
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/update-text-element.ts b/apps/web/src/lib/commands/timeline/element/update-text-element.ts
new file mode 100644
index 00000000..e3cc4a32
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/update-text-element.ts
@@ -0,0 +1,52 @@
+import { Command } from "@/lib/commands/base-command";
+import type { TextElement, TimelineTrack } from "@/types/timeline";
+import { EditorCore } from "@/core";
+
+export class UpdateTextElementCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(
+ private trackId: string,
+ private elementId: string,
+ private updates: Partial<
+ Pick<
+ TextElement,
+ | "content"
+ | "fontSize"
+ | "fontFamily"
+ | "color"
+ | "backgroundColor"
+ | "textAlign"
+ | "fontWeight"
+ | "fontStyle"
+ | "textDecoration"
+ >
+ >,
+ ) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = this.savedState.map((t) => {
+ if (t.id !== this.trackId) return t;
+ const newElements = t.elements.map((el) =>
+ el.id === this.elementId && el.type === "text"
+ ? { ...el, ...this.updates }
+ : el,
+ );
+ return { ...t, elements: newElements } as typeof t;
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/index.ts b/apps/web/src/lib/commands/timeline/index.ts
new file mode 100644
index 00000000..30bc81ef
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/index.ts
@@ -0,0 +1,3 @@
+export * from "./track";
+export * from "./element";
+export * from "./clipboard";
diff --git a/apps/web/src/lib/commands/timeline/track/add-track.ts b/apps/web/src/lib/commands/timeline/track/add-track.ts
new file mode 100644
index 00000000..95338888
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/track/add-track.ts
@@ -0,0 +1,53 @@
+import { Command } from "@/lib/commands/base-command";
+import type { TrackType, TimelineTrack } from "@/types/timeline";
+import { generateUUID } from "@/utils/id";
+import { EditorCore } from "@/core";
+import {
+ buildEmptyTrack,
+ getDefaultInsertIndexForTrack,
+} from "@/lib/timeline/track-utils";
+
+export class AddTrackCommand extends Command {
+ private trackId: string;
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(
+ private type: TrackType,
+ private index?: number,
+ ) {
+ super();
+ this.trackId = generateUUID();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const newTrack: TimelineTrack = buildEmptyTrack({
+ id: this.trackId,
+ type: this.type,
+ });
+
+ const updatedTracks = [...(this.savedState || [])];
+ const insertIndex =
+ this.index ??
+ getDefaultInsertIndexForTrack({
+ tracks: updatedTracks,
+ trackType: this.type,
+ });
+ updatedTracks.splice(insertIndex, 0, newTrack);
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+
+ getTrackId(): string {
+ return this.trackId;
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/track/index.ts b/apps/web/src/lib/commands/timeline/track/index.ts
new file mode 100644
index 00000000..9d4bd9c3
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/track/index.ts
@@ -0,0 +1,4 @@
+export { AddTrackCommand } from "./add-track";
+export { RemoveTrackCommand } from "./remove-track";
+export { ToggleTrackMuteCommand } from "./toggle-track-mute";
+export { ToggleTrackVisibilityCommand } from "./toggle-track-visibility";
diff --git a/apps/web/src/lib/commands/timeline/track/remove-track.ts b/apps/web/src/lib/commands/timeline/track/remove-track.ts
new file mode 100644
index 00000000..a32aacd7
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/track/remove-track.ts
@@ -0,0 +1,35 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { TimelineTrack } from "@/types/timeline";
+import { getMainTrack } from "@/lib/timeline";
+
+export class RemoveTrackCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(private trackId: string) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+ const targetTrack = this.savedState.find(
+ (track) => track.id === this.trackId,
+ );
+ const mainTrack = getMainTrack({ tracks: this.savedState });
+ if (mainTrack?.id === targetTrack?.id) {
+ return;
+ }
+ const updatedTracks = this.savedState.filter(
+ (track) => track.id !== this.trackId,
+ );
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
new file mode 100644
index 00000000..3fe526c7
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
@@ -0,0 +1,39 @@
+import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/types/timeline";
+import { EditorCore } from "@/core";
+import { canTracktHaveAudio } from "@/lib/timeline";
+
+export class ToggleTrackMuteCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(private trackId: string) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const targetTrack = this.savedState.find(
+ (track) => track.id === this.trackId,
+ );
+ if (!targetTrack) {
+ return;
+ }
+
+ const updatedTracks = this.savedState.map((track) =>
+ track.id === this.trackId && canTracktHaveAudio(track)
+ ? { ...track, muted: !track.muted }
+ : track,
+ );
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts
new file mode 100644
index 00000000..e81714ac
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts
@@ -0,0 +1,40 @@
+import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/types/timeline";
+import { EditorCore } from "@/core";
+import { canTrackBeHidden } from "@/lib/timeline";
+
+export class ToggleTrackVisibilityCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(private trackId: string) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const targetTrack = this.savedState.find(
+ (track) => track.id === this.trackId,
+ );
+ if (!targetTrack) {
+ return;
+ }
+
+ const updatedTracks = this.savedState.map((track) => {
+ if (track.id === this.trackId && canTrackBeHidden(track)) {
+ return { ...track, hidden: !track.hidden };
+ }
+ return track;
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/db/index.ts b/apps/web/src/lib/db/index.ts
new file mode 100644
index 00000000..290e7c2a
--- /dev/null
+++ b/apps/web/src/lib/db/index.ts
@@ -0,0 +1,19 @@
+import { drizzle } from "drizzle-orm/postgres-js";
+import postgres from "postgres";
+import * as schema from "./schema";
+import { webEnv } from "@opencut/env/web";
+
+let _db: ReturnType<typeof drizzle> | null = null;
+
+function getDb() {
+ if (!_db) {
+ const client = postgres(webEnv.DATABASE_URL);
+ _db = drizzle(client, { schema });
+ }
+
+ return _db;
+}
+
+export const db = getDb();
+
+export * from "./schema";
diff --git a/apps/web/src/lib/db/schema.ts b/apps/web/src/lib/db/schema.ts
new file mode 100644
index 00000000..dcd7db67
--- /dev/null
+++ b/apps/web/src/lib/db/schema.ts
@@ -0,0 +1,59 @@
+import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
+
+export const users = pgTable("users", {
+ id: text("id").primaryKey(),
+ name: text("name").notNull(),
+ email: text("email").notNull().unique(),
+ emailVerified: boolean("email_verified").default(false).notNull(),
+ image: text("image"),
+ createdAt: timestamp("created_at")
+ .$defaultFn(() => /* @__PURE__ */ new Date())
+ .notNull(),
+ updatedAt: timestamp("updated_at")
+ .$defaultFn(() => /* @__PURE__ */ new Date())
+ .notNull(),
+}).enableRLS();
+
+export const sessions = pgTable("sessions", {
+ id: text("id").primaryKey(),
+ expiresAt: timestamp("expires_at").notNull(),
+ token: text("token").notNull().unique(),
+ createdAt: timestamp("created_at").notNull(),
+ updatedAt: timestamp("updated_at").notNull(),
+ ipAddress: text("ip_address"),
+ userAgent: text("user_agent"),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+}).enableRLS();
+
+export const accounts = pgTable("accounts", {
+ id: text("id").primaryKey(),
+ accountId: text("account_id").notNull(),
+ providerId: text("provider_id").notNull(),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ accessToken: text("access_token"),
+ refreshToken: text("refresh_token"),
+ idToken: text("id_token"),
+ accessTokenExpiresAt: timestamp("access_token_expires_at"),
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
+ scope: text("scope"),
+ password: text("password"),
+ createdAt: timestamp("created_at").notNull(),
+ updatedAt: timestamp("updated_at").notNull(),
+}).enableRLS();
+
+export const verifications = pgTable("verifications", {
+ id: text("id").primaryKey(),
+ identifier: text("identifier").notNull(),
+ value: text("value").notNull(),
+ expiresAt: timestamp("expires_at").notNull(),
+ createdAt: timestamp("created_at").$defaultFn(
+ () => /* @__PURE__ */ new Date(),
+ ),
+ updatedAt: timestamp("updated_at").$defaultFn(
+ () => /* @__PURE__ */ new Date(),
+ ),
+}).enableRLS();
diff --git a/apps/web/src/lib/drag-data.ts b/apps/web/src/lib/drag-data.ts
new file mode 100644
index 00000000..067b0a35
--- /dev/null
+++ b/apps/web/src/lib/drag-data.ts
@@ -0,0 +1,48 @@
+import type { TimelineDragData } from "@/types/drag";
+
+const MIME_TYPE = "application/x-timeline-drag";
+let lastDragData: TimelineDragData | null = null;
+
+export function setDragData({
+ dataTransfer,
+ dragData,
+}: {
+ dataTransfer: DataTransfer;
+ dragData: TimelineDragData;
+}): void {
+ dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData));
+ dataTransfer.setData("text/plain", JSON.stringify(dragData));
+ lastDragData = dragData;
+}
+
+export function getDragData({
+ dataTransfer,
+}: {
+ dataTransfer: DataTransfer;
+}): TimelineDragData | null {
+ const data = dataTransfer.getData(MIME_TYPE);
+ if (data) return JSON.parse(data) as TimelineDragData;
+
+ const textData = dataTransfer.getData("text/plain");
+ if (textData) {
+ try {
+ return JSON.parse(textData) as TimelineDragData;
+ } catch {
+ return lastDragData;
+ }
+ }
+
+ return lastDragData;
+}
+
+export function hasDragData({
+ dataTransfer,
+}: {
+ dataTransfer: DataTransfer;
+}): boolean {
+ return dataTransfer.types.includes(MIME_TYPE) || lastDragData !== null;
+}
+
+export function clearDragData(): void {
+ lastDragData = null;
+}
diff --git a/apps/web/src/lib/editor-utils.ts b/apps/web/src/lib/editor-utils.ts
deleted file mode 100644
index f041aa5e..00000000
--- a/apps/web/src/lib/editor-utils.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { CanvasSize } from "@/types/editor";
-
-const DEFAULT_CANVAS_PRESETS = [
- { name: "16:9", width: 1920, height: 1080 },
- { name: "9:16", width: 1080, height: 1920 },
- { name: "1:1", width: 1080, height: 1080 },
- { name: "4:3", width: 1440, height: 1080 },
-];
-
-/**
- * Helper function to find the best matching canvas preset for an aspect ratio
- * @param aspectRatio The target aspect ratio to match
- * @returns The best matching canvas size
- */
-export function findBestCanvasPreset(aspectRatio: number): CanvasSize {
- // Calculate aspect ratio for each preset and find the closest match
- let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
- let smallestDifference = Math.abs(
- aspectRatio - bestMatch.width / bestMatch.height
- );
-
- for (const preset of DEFAULT_CANVAS_PRESETS) {
- const presetAspectRatio = preset.width / preset.height;
- const difference = Math.abs(aspectRatio - presetAspectRatio);
-
- if (difference < smallestDifference) {
- smallestDifference = difference;
- bestMatch = preset;
- }
- }
-
- // If the difference is still significant (> 0.1), create a custom size
- // based on the media aspect ratio with a reasonable resolution
- const bestAspectRatio = bestMatch.width / bestMatch.height;
- if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
- // Create custom dimensions based on the aspect ratio
- if (aspectRatio > 1) {
- // Landscape - use 1920 width
- return { width: 1920, height: Math.round(1920 / aspectRatio) };
- }
- // Portrait or square - use 1080 height
- return { width: Math.round(1080 * aspectRatio), height: 1080 };
- }
-
- return { width: bestMatch.width, height: bestMatch.height };
-}
diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts
index 52c58736..67621e16 100644
--- a/apps/web/src/lib/export.ts
+++ b/apps/web/src/lib/export.ts
@@ -1,301 +1,18 @@
-import {
- Output,
- Mp4OutputFormat,
- WebMOutputFormat,
- BufferTarget,
- CanvasSource,
- AudioBufferSource,
- QUALITY_LOW,
- QUALITY_MEDIUM,
- QUALITY_HIGH,
- QUALITY_VERY_HIGH,
-} from "mediabunny";
-import { renderTimelineFrame } from "./timeline-renderer";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { useProjectStore } from "@/stores/project-store";
-import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE } from "@/stores/project-store";
-import { ExportOptions, ExportResult } from "@/types/export";
-import { TimelineTrack } from "@/types/timeline";
-import { MediaFile } from "@/types/media";
+import { EXPORT_MIME_TYPES } from "@/constants/export-constants";
+import type { ExportFormat } from "@/types/export";
-export const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
- format: "mp4",
- quality: "high",
- includeAudio: true,
-};
-
-const qualityMap = {
- low: QUALITY_LOW,
- medium: QUALITY_MEDIUM,
- high: QUALITY_HIGH,
- very_high: QUALITY_VERY_HIGH,
-};
-
-interface AudioElement {
- buffer: AudioBuffer;
- startTime: number;
- duration: number;
- trimStart: number;
- trimEnd: number;
- muted: boolean;
-}
-
-async function createTimelineAudioBuffer(
- tracks: TimelineTrack[],
- mediaFiles: MediaFile[],
- duration: number,
- sampleRate: number = 44100
-): Promise<AudioBuffer | null> {
- // Get Web Audio context
- const audioContext = new (window.AudioContext ||
- (window as any).webkitAudioContext)();
-
- // Collect all audio elements from timeline
- const audioElements: AudioElement[] = [];
- const mediaMap = new Map<string, MediaFile>(mediaFiles.map((m) => [m.id, m]));
-
- for (const track of tracks) {
- if (track.muted) continue;
-
- for (const element of track.elements) {
- if (element.type !== "media") continue;
-
- const mediaElement = element;
- const mediaItem = mediaMap.get(mediaElement.mediaId);
- if (!mediaItem || mediaItem.type !== "audio") continue;
-
- const visibleDuration =
- mediaElement.duration - mediaElement.trimStart - mediaElement.trimEnd;
- if (visibleDuration <= 0) continue;
-
- try {
- // Decode audio file
- const arrayBuffer = await mediaItem.file.arrayBuffer();
- const audioBuffer = await audioContext.decodeAudioData(
- arrayBuffer.slice(0)
- );
-
- audioElements.push({
- buffer: audioBuffer,
- startTime: mediaElement.startTime,
- duration: mediaElement.duration,
- trimStart: mediaElement.trimStart,
- trimEnd: mediaElement.trimEnd,
- muted: mediaElement.muted || track.muted || false,
- });
- } catch (error) {
- console.warn(`Failed to decode audio file ${mediaItem.name}:`, error);
- }
- }
- }
-
- if (audioElements.length === 0) {
- return null; // No audio to mix
- }
-
- // Create output buffer
- const outputChannels = 2; // Stereo
- const outputLength = Math.ceil(duration * sampleRate);
- const outputBuffer = audioContext.createBuffer(
- outputChannels,
- outputLength,
- sampleRate
- );
-
- // Mix all audio elements
- for (const element of audioElements) {
- if (element.muted) continue;
-
- const {
- buffer,
- startTime,
- trimStart,
- trimEnd,
- duration: elementDuration,
- } = element;
-
- // Calculate timing
- const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
- const sourceDuration = elementDuration - trimStart - trimEnd;
- const sourceLengthSamples = Math.floor(sourceDuration * buffer.sampleRate);
- const outputStartSample = Math.floor(startTime * sampleRate);
-
- // Resample if needed (simple approach)
- const resampleRatio = sampleRate / buffer.sampleRate;
- const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
-
- // Mix each channel
- for (let channel = 0; channel < outputChannels; channel++) {
- const outputData = outputBuffer.getChannelData(channel);
- const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
- const sourceData = buffer.getChannelData(sourceChannel);
-
- for (let i = 0; i < resampledLength; i++) {
- const outputIndex = outputStartSample + i;
- if (outputIndex >= outputLength) break;
-
- // Simple resampling (could be improved with proper interpolation)
- const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
- if (sourceIndex >= sourceData.length) break;
-
- outputData[outputIndex] += sourceData[sourceIndex];
- }
- }
- }
-
- return outputBuffer;
-}
-
-export async function exportProject(
- options: ExportOptions
-): Promise<ExportResult> {
- const { format, quality, fps, includeAudio, onProgress, onCancel } = options;
-
- try {
- const timelineStore = useTimelineStore.getState();
- const mediaStore = useMediaStore.getState();
- const projectStore = useProjectStore.getState();
-
- const { tracks, getTotalDuration } = timelineStore;
- const { mediaFiles } = mediaStore;
- const { activeProject } = projectStore;
-
- if (!activeProject) {
- return { success: false, error: "No active project" };
- }
-
- const duration = getTotalDuration();
- if (duration === 0) {
- return { success: false, error: "Project is empty" };
- }
-
- const exportFps = fps || activeProject.fps || DEFAULT_FPS;
- const canvasSize = activeProject.canvasSize || DEFAULT_CANVAS_SIZE;
-
- const outputFormat =
- format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
-
- // BufferTarget for smaller files, StreamTarget for larger ones
- // TODO: Implement StreamTarget
- const output = new Output({
- format: outputFormat,
- target: new BufferTarget(),
- });
-
- // Canvas for rendering
- const canvas = document.createElement("canvas");
- canvas.width = canvasSize.width;
- canvas.height = canvasSize.height;
- const ctx = canvas.getContext("2d");
-
- if (!ctx) {
- return { success: false, error: "Failed to create canvas context" };
- }
-
- const videoSource = new CanvasSource(canvas, {
- codec: format === "webm" ? "vp9" : "avc", // VP9 for WebM, H.264 for MP4
- bitrate: qualityMap[quality],
- });
-
- output.addVideoTrack(videoSource, { frameRate: exportFps });
-
- // Add audio track if requested (but don't add data yet)
- let audioSource: AudioBufferSource | null = null;
- let audioBuffer: AudioBuffer | null = null;
-
- if (includeAudio) {
- onProgress?.(0.05); // 5% for audio processing
-
- audioBuffer = await createTimelineAudioBuffer(
- tracks,
- mediaFiles,
- duration
- );
-
- if (audioBuffer) {
- audioSource = new AudioBufferSource({
- codec: format === "webm" ? "opus" : "aac", // Opus for WebM, AAC for MP4
- bitrate: qualityMap[quality], // Use same quality for audio
- });
-
- output.addAudioTrack(audioSource);
- }
- }
-
- // Start the output (after all tracks are added)
- await output.start();
-
- // Now add audio data after starting
- if (audioSource && audioBuffer) {
- await audioSource.add(audioBuffer);
- audioSource.close();
- }
-
- const totalFrames = Math.ceil(duration * exportFps);
- let cancelled = false;
-
- // Render each frame
- for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
- // Check for cancellation
- if (onCancel?.()) {
- cancelled = true;
- break;
- }
-
- const time = frameIndex / exportFps;
-
- await renderTimelineFrame({
- ctx,
- time,
- canvasWidth: canvas.width,
- canvasHeight: canvas.height,
- tracks,
- mediaFiles,
- backgroundType: activeProject.backgroundType,
- blurIntensity: activeProject.blurIntensity,
- backgroundColor:
- activeProject.backgroundType === "blur"
- ? undefined
- : activeProject.backgroundColor || "#000000",
- projectCanvasSize: canvasSize,
- });
-
- const frameDuration = 1 / exportFps;
- await videoSource.add(time, frameDuration);
-
- // Adjust progress to account for audio processing (5% at start)
- const videoProgress = includeAudio
- ? 0.05 + (frameIndex / totalFrames) * 0.95
- : frameIndex / totalFrames;
- onProgress?.(videoProgress);
- }
-
- if (cancelled) {
- await output.cancel();
- return { success: false, cancelled: true };
- }
- videoSource.close();
- await output.finalize();
- onProgress?.(1);
-
- return {
- success: true,
- buffer: output.target.buffer || undefined,
- };
- } catch (error) {
- console.error("Export failed:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "Unknown export error",
- };
- }
-}
-
-export function getExportMimeType(format: "mp4" | "webm"): string {
- return format === "webm" ? "video/webm" : "video/mp4";
+export function getExportMimeType({
+ format,
+}: {
+ format: ExportFormat;
+}): string {
+ return EXPORT_MIME_TYPES[format];
}
-export function getExportFileExtension(format: "mp4" | "webm"): string {
- return `.${format}`;
+export function getExportFileExtension({
+ format,
+}: {
+ format: ExportFormat;
+}): string {
+ return `.${format}`;
}
diff --git a/apps/web/src/lib/font-config.ts b/apps/web/src/lib/font-config.ts
deleted file mode 100644
index b47b3c3e..00000000
--- a/apps/web/src/lib/font-config.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import {
- Inter,
- Roboto,
- Open_Sans,
- Playfair_Display,
- Comic_Neue,
-} from "next/font/google";
-
-// Configure all fonts
-const inter = Inter({ subsets: ["latin"] });
-const roboto = Roboto({ subsets: ["latin"], weight: ["400", "700"] });
-const openSans = Open_Sans({ subsets: ["latin"] });
-const playfairDisplay = Playfair_Display({ subsets: ["latin"] });
-const comicNeue = Comic_Neue({ subsets: ["latin"], weight: ["400", "700"] });
-
-// Export font class mapping for use in components
-export const FONT_CLASS_MAP = {
- Inter: inter.className,
- Roboto: roboto.className,
- "Open Sans": openSans.className,
- "Playfair Display": playfairDisplay.className,
- "Comic Neue": comicNeue.className,
- Arial: "",
- Helvetica: "",
- "Times New Roman": "",
- Georgia: "",
-} as const;
-
-// Export individual fonts for use in layout
-export const fonts = {
- inter,
- roboto,
- openSans,
- playfairDisplay,
- comicNeue,
-};
-
-// Default font for the body
-export const defaultFont = inter;
diff --git a/apps/web/src/lib/gradients/canvas.ts b/apps/web/src/lib/gradients/canvas.ts
new file mode 100644
index 00000000..e2287333
--- /dev/null
+++ b/apps/web/src/lib/gradients/canvas.ts
@@ -0,0 +1,923 @@
+import type {
+ GradientAst,
+ Color,
+ ColorStop,
+ GradientOrientation,
+} from "./parser";
+import { parseGradient } from "./parser";
+
+type BackgroundLayer =
+ | { type: "color"; value: string }
+ | { type: "gradient"; value: GradientAst };
+
+type LinearPoints = {
+ x0: number;
+ y0: number;
+ x1: number;
+ y1: number;
+ length: number;
+};
+
+type RadialDimensions = {
+ cx: number;
+ cy: number;
+ rx: number;
+ ry: number;
+};
+
+type PositionKeyword = "left" | "center" | "right" | "top" | "bottom";
+
+type Distance =
+ | { type: "%"; value: string }
+ | { type: "position-keyword"; value: string }
+ | { type: "calc"; value: string }
+ | { type: "px"; value: string }
+ | { type: "em"; value: string };
+
+type Position = { type: "position"; value: { x?: Distance; y?: Distance } };
+
+type Shape = {
+ type: "shape";
+ value: "circle" | "ellipse";
+ style?: Distance | { type: "extent-keyword"; value: string } | Position;
+ at?: Position;
+};
+
+type DefaultRadial = { type: "default-radial"; at: Position };
+
+type ExtentKeyword = { type: "extent-keyword"; value: string; at?: Position };
+
+type RadialOrientation = Shape | ExtentKeyword | DefaultRadial;
+
+const gradientLayerPattern =
+ /^(?:-(webkit|o|ms|moz)-)?(linear-gradient|repeating-linear-gradient|radial-gradient|repeating-radial-gradient)/i;
+
+export function drawCssBackground({
+ ctx,
+ width,
+ height,
+ css,
+}: {
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ width: number;
+ height: number;
+ css: string;
+}): void {
+ const layers = parseBackgroundLayers({ css });
+ const layersInPaintOrder = layers.slice().reverse();
+
+ for (const layer of layersInPaintOrder) {
+ if (layer.type === "color") {
+ ctx.fillStyle = layer.value;
+ ctx.fillRect(0, 0, width, height);
+ continue;
+ }
+
+ drawGradientLayer({ ctx, width, height, gradient: layer.value });
+ }
+}
+
+const parseBackgroundLayers = ({
+ css,
+}: {
+ css: string;
+}): Array<BackgroundLayer> => {
+ const segments = splitCssLayers({ css });
+ const layers: Array<BackgroundLayer> = [];
+
+ for (const segment of segments) {
+ if (!segment) {
+ continue;
+ }
+
+ if (gradientLayerPattern.test(segment)) {
+ try {
+ const parsed = parseGradient({ code: segment.trim() });
+ for (const gradient of parsed) {
+ layers.push({ type: "gradient", value: gradient });
+ }
+ continue;
+ } catch {
+ layers.push({ type: "color", value: segment });
+ continue;
+ }
+ }
+
+ layers.push({ type: "color", value: segment });
+ }
+
+ return layers;
+};
+
+const splitCssLayers = ({ css }: { css: string }): Array<string> => {
+ const layers: Array<string> = [];
+ let current = "";
+ let depth = 0;
+
+ for (const char of css) {
+ if (char === "(") {
+ depth += 1;
+ }
+ if (char === ")") {
+ depth = Math.max(0, depth - 1);
+ }
+
+ if (char === "," && depth === 0) {
+ layers.push(current.trim());
+ current = "";
+ continue;
+ }
+
+ current += char;
+ }
+
+ if (current.trim()) {
+ layers.push(current.trim());
+ }
+
+ return layers;
+};
+
+const drawGradientLayer = ({
+ ctx,
+ width,
+ height,
+ gradient,
+}: {
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ width: number;
+ height: number;
+ gradient: GradientAst;
+}): void => {
+ if (gradient.type.includes("linear")) {
+ drawLinearGradient({ ctx, width, height, gradient });
+ return;
+ }
+
+ drawRadialGradient({ ctx, width, height, gradient });
+};
+
+const drawLinearGradient = ({
+ ctx,
+ width,
+ height,
+ gradient,
+}: {
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ width: number;
+ height: number;
+ gradient: GradientAst;
+}): void => {
+ const { x0, y0, x1, y1, length } = resolveLinearPoints({
+ width,
+ height,
+ orientation: gradient.orientation,
+ });
+ const canvasGradient = ctx.createLinearGradient(x0, y0, x1, y1);
+ const colorStops = normalizeColorStops({
+ colorStops: gradient.colorStops,
+ gradientLength: length,
+ });
+
+ for (const stop of colorStops) {
+ canvasGradient.addColorStop(stop.offset, stop.color);
+ }
+
+ ctx.fillStyle = canvasGradient;
+ ctx.fillRect(0, 0, width, height);
+};
+
+const drawRadialGradient = ({
+ ctx,
+ width,
+ height,
+ gradient,
+}: {
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ width: number;
+ height: number;
+ gradient: GradientAst;
+}): void => {
+ const { cx, cy, rx, ry } = resolveRadialDimensions({
+ width,
+ height,
+ orientation: gradient.orientation,
+ });
+ const gradientLength = Math.max(rx, ry);
+ const colorStops = normalizeColorStops({
+ colorStops: gradient.colorStops,
+ gradientLength,
+ });
+
+ if (rx === ry || ry === 0) {
+ const canvasGradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, rx);
+ for (const stop of colorStops) {
+ canvasGradient.addColorStop(stop.offset, stop.color);
+ }
+ ctx.fillStyle = canvasGradient;
+ ctx.fillRect(0, 0, width, height);
+ return;
+ }
+
+ const scaleY = ry / rx;
+ ctx.save();
+ ctx.translate(cx, cy);
+ ctx.scale(1, scaleY);
+ const canvasGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, rx);
+ for (const stop of colorStops) {
+ canvasGradient.addColorStop(stop.offset, stop.color);
+ }
+ ctx.fillStyle = canvasGradient;
+ ctx.fillRect(-cx, -cy / scaleY, width, height / scaleY);
+ ctx.restore();
+};
+
+const resolveLinearPoints = ({
+ width,
+ height,
+ orientation,
+}: {
+ width: number;
+ height: number;
+ orientation: GradientOrientation | undefined;
+}): LinearPoints => {
+ const angle = resolveLinearAngle({ orientation });
+ const radians = (angle * Math.PI) / 180;
+ const dx = Math.sin(radians);
+ const dy = -Math.cos(radians);
+ const centerX = width / 2;
+ const centerY = height / 2;
+ const halfLength = (Math.abs(width * dx) + Math.abs(height * dy)) / 2;
+ const x0 = centerX - dx * halfLength;
+ const y0 = centerY - dy * halfLength;
+ const x1 = centerX + dx * halfLength;
+ const y1 = centerY + dy * halfLength;
+ const length = Math.hypot(x1 - x0, y1 - y0);
+
+ return { x0, y0, x1, y1, length };
+};
+
+const resolveLinearAngle = ({
+ orientation,
+}: {
+ orientation: GradientOrientation | undefined;
+}): number => {
+ if (!orientation) {
+ return 180;
+ }
+
+ if (!Array.isArray(orientation)) {
+ if (orientation.type === "angular") {
+ return Number.parseFloat(orientation.value);
+ }
+
+ if (orientation.type === "directional") {
+ return angleFromDirectional({ value: orientation.value });
+ }
+ }
+
+ return 180;
+};
+
+const angleFromDirectional = ({ value }: { value: string }): number => {
+ const normalized = value.toLowerCase().replace("to", "").trim();
+ const parts = normalized.split(/\s+/).filter(Boolean);
+ let dx = 0;
+ let dy = 0;
+
+ for (const part of parts) {
+ if (part === "left") {
+ dx = -1;
+ }
+ if (part === "right") {
+ dx = 1;
+ }
+ if (part === "top") {
+ dy = -1;
+ }
+ if (part === "bottom") {
+ dy = 1;
+ }
+ }
+
+ if (dx === 0 && dy === 0) {
+ return 180;
+ }
+
+ const angle = (Math.atan2(dx, -dy) * 180) / Math.PI;
+ return (angle + 360) % 360;
+};
+
+const resolveRadialDimensions = ({
+ width,
+ height,
+ orientation,
+}: {
+ width: number;
+ height: number;
+ orientation: GradientOrientation | undefined;
+}): RadialDimensions => {
+ const centerFallback = { cx: width / 2, cy: height / 2 };
+ const radial = Array.isArray(orientation)
+ ? (orientation[0] as RadialOrientation | undefined)
+ : undefined;
+
+ if (!radial) {
+ const { rx, ry } = resolveRadialExtents({
+ width,
+ height,
+ cx: centerFallback.cx,
+ cy: centerFallback.cy,
+ shape: "ellipse",
+ extent: "farthest-corner",
+ });
+ return { ...centerFallback, rx, ry };
+ }
+
+ if (radial.type === "shape") {
+ const { cx, cy } = resolveRadialCenter({
+ width,
+ height,
+ position: radial.at,
+ });
+ const shape = radial.value;
+
+ if (radial.style && radial.style.type === "position") {
+ const xDist = radial.style.value.x;
+ const yDist = radial.style.value.y;
+ const rx = xDist
+ ? resolveEllipseDimension({ distance: xDist, axisSize: width })
+ : width / 2;
+ const ry = yDist
+ ? resolveEllipseDimension({ distance: yDist, axisSize: height })
+ : height / 2;
+ return { cx, cy, rx, ry };
+ }
+
+ if (radial.style && radial.style.type !== "extent-keyword") {
+ const resolvedRadius = resolveDistanceInPixels({
+ distance: radial.style,
+ axisSize: Math.max(width, height),
+ });
+ if (shape === "circle") {
+ return {
+ cx,
+ cy,
+ rx: resolvedRadius ?? 0,
+ ry: resolvedRadius ?? 0,
+ };
+ }
+
+ const radius = resolvedRadius ?? 0;
+ return { cx, cy, rx: radius, ry: radius };
+ }
+
+ const extent =
+ radial.style && radial.style.type === "extent-keyword"
+ ? radial.style.value
+ : "farthest-corner";
+ const { rx, ry } = resolveRadialExtents({
+ width,
+ height,
+ cx,
+ cy,
+ shape,
+ extent,
+ });
+ return { cx, cy, rx, ry };
+ }
+
+ if (radial.type === "extent-keyword") {
+ const { cx, cy } = resolveRadialCenter({
+ width,
+ height,
+ position: radial.at,
+ });
+ const { rx, ry } = resolveRadialExtents({
+ width,
+ height,
+ cx,
+ cy,
+ shape: "ellipse",
+ extent: radial.value,
+ });
+ return { cx, cy, rx, ry };
+ }
+
+ const { cx, cy } = resolveRadialCenter({
+ width,
+ height,
+ position: radial.at,
+ });
+ const { rx, ry } = resolveRadialExtents({
+ width,
+ height,
+ cx,
+ cy,
+ shape: "ellipse",
+ extent: "farthest-corner",
+ });
+ return { cx, cy, rx, ry };
+};
+
+const resolveRadialCenter = ({
+ width,
+ height,
+ position,
+}: {
+ width: number;
+ height: number;
+ position?: Position;
+}): { cx: number; cy: number } => {
+ if (!position) {
+ return { cx: width / 2, cy: height / 2 };
+ }
+
+ const normalized = normalizePositionKeywords({ position });
+ const cx = resolvePositionValue({
+ distance: normalized.value.x,
+ axisSize: width,
+ axis: "x",
+ });
+ const cy = resolvePositionValue({
+ distance: normalized.value.y,
+ axisSize: height,
+ axis: "y",
+ });
+
+ return { cx, cy };
+};
+
+const normalizePositionKeywords = ({
+ position,
+}: {
+ position: Position;
+}): Position => {
+ const xValue = position.value.x;
+ const yValue = position.value.y;
+
+ if (
+ xValue?.type === "position-keyword" &&
+ yValue?.type === "position-keyword"
+ ) {
+ const xKeyword = xValue.value.toLowerCase() as PositionKeyword;
+ const yKeyword = yValue.value.toLowerCase() as PositionKeyword;
+ const xIsVertical = xKeyword === "top" || xKeyword === "bottom";
+ const yIsHorizontal = yKeyword === "left" || yKeyword === "right";
+
+ if (xIsVertical && yIsHorizontal) {
+ return {
+ type: "position",
+ value: {
+ x: { type: "position-keyword", value: yKeyword },
+ y: { type: "position-keyword", value: xKeyword },
+ },
+ };
+ }
+ }
+
+ return position;
+};
+
+const resolveRadialExtents = ({
+ width,
+ height,
+ cx,
+ cy,
+ shape,
+ extent,
+}: {
+ width: number;
+ height: number;
+ cx: number;
+ cy: number;
+ shape: "circle" | "ellipse";
+ extent: string;
+}): { rx: number; ry: number } => {
+ const left = cx;
+ const right = width - cx;
+ const top = cy;
+ const bottom = height - cy;
+
+ if (shape === "circle") {
+ const distances = [
+ Math.hypot(left, top),
+ Math.hypot(right, top),
+ Math.hypot(left, bottom),
+ Math.hypot(right, bottom),
+ ];
+ if (extent === "closest-side") {
+ return { rx: Math.min(left, right, top, bottom), ry: 0 };
+ }
+ if (extent === "farthest-side") {
+ return { rx: Math.max(left, right, top, bottom), ry: 0 };
+ }
+ if (extent === "closest-corner") {
+ return { rx: Math.min(...distances), ry: 0 };
+ }
+ return { rx: Math.max(...distances), ry: 0 };
+ }
+
+ if (extent === "closest-side") {
+ return { rx: Math.min(left, right), ry: Math.min(top, bottom) };
+ }
+ if (extent === "farthest-side") {
+ return { rx: Math.max(left, right), ry: Math.max(top, bottom) };
+ }
+
+ const corners = [
+ { dx: left, dy: top },
+ { dx: right, dy: top },
+ { dx: left, dy: bottom },
+ { dx: right, dy: bottom },
+ ];
+ const sorted = corners
+ .slice()
+ .sort((a, b) => Math.hypot(a.dx, a.dy) - Math.hypot(b.dx, b.dy));
+ const chosen =
+ extent === "closest-corner" ? sorted[0] : sorted[sorted.length - 1];
+ return { rx: Math.abs(chosen.dx), ry: Math.abs(chosen.dy) };
+};
+
+const resolvePositionValue = ({
+ distance,
+ axisSize,
+ axis,
+}: {
+ distance?: Distance;
+ axisSize: number;
+ axis: "x" | "y";
+}): number => {
+ if (!distance) {
+ return axisSize / 2;
+ }
+
+ if (distance.type === "%") {
+ return (Number.parseFloat(distance.value) / 100) * axisSize;
+ }
+
+ if (distance.type === "position-keyword") {
+ return keywordToPosition({
+ value: distance.value,
+ axisSize,
+ axis,
+ });
+ }
+
+ if (distance.type === "px") {
+ return Number.parseFloat(distance.value);
+ }
+
+ if (distance.type === "em") {
+ return Number.parseFloat(distance.value) * 16;
+ }
+
+ return axisSize / 2;
+};
+
+const resolveDistanceInPixels = ({
+ distance,
+ axisSize,
+}: {
+ distance: Distance;
+ axisSize: number;
+}): number | null => {
+ if (distance.type === "%") {
+ return (Number.parseFloat(distance.value) / 100) * axisSize;
+ }
+
+ if (distance.type === "px") {
+ return Number.parseFloat(distance.value);
+ }
+
+ if (distance.type === "em") {
+ return Number.parseFloat(distance.value) * 16;
+ }
+
+ return null;
+};
+
+const resolveEllipseDimension = ({
+ distance,
+ axisSize,
+}: {
+ distance: Distance;
+ axisSize: number;
+}): number => {
+ if (distance.type === "%") {
+ return (Number.parseFloat(distance.value) / 100) * axisSize;
+ }
+
+ if (distance.type === "px") {
+ return Number.parseFloat(distance.value);
+ }
+
+ if (distance.type === "em") {
+ return Number.parseFloat(distance.value) * 16;
+ }
+
+ return axisSize / 2;
+};
+
+const keywordToPosition = ({
+ value,
+ axisSize,
+ axis,
+}: {
+ value: string;
+ axisSize: number;
+ axis: "x" | "y";
+}): number => {
+ const keyword = value.toLowerCase() as PositionKeyword;
+
+ if (keyword === "center") {
+ return axisSize / 2;
+ }
+
+ if (axis === "x") {
+ if (keyword === "left") {
+ return 0;
+ }
+ if (keyword === "right") {
+ return axisSize;
+ }
+ }
+
+ if (axis === "y") {
+ if (keyword === "top") {
+ return 0;
+ }
+ if (keyword === "bottom") {
+ return axisSize;
+ }
+ }
+
+ return axisSize / 2;
+};
+
+const isTransparent = ({ color }: { color: string }): boolean => {
+ const lower = color.toLowerCase().trim();
+ if (lower === "transparent") {
+ return true;
+ }
+ const rgbaMatch = lower.match(
+ /^rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)$/,
+ );
+ if (rgbaMatch && Number.parseFloat(rgbaMatch[1]) === 0) {
+ return true;
+ }
+ return false;
+};
+
+const parseColorToRgb = ({
+ color,
+}: {
+ color: string;
+}): { r: number; g: number; b: number; a: number } | null => {
+ const lower = color.toLowerCase().trim();
+
+ const hexMatch = lower.match(/^#([0-9a-f]{3,8})$/);
+ if (hexMatch) {
+ const hex = hexMatch[1];
+ if (hex.length === 3) {
+ return {
+ r: Number.parseInt(hex[0] + hex[0], 16),
+ g: Number.parseInt(hex[1] + hex[1], 16),
+ b: Number.parseInt(hex[2] + hex[2], 16),
+ a: 1,
+ };
+ }
+ if (hex.length === 6) {
+ return {
+ r: Number.parseInt(hex.slice(0, 2), 16),
+ g: Number.parseInt(hex.slice(2, 4), 16),
+ b: Number.parseInt(hex.slice(4, 6), 16),
+ a: 1,
+ };
+ }
+ if (hex.length === 8) {
+ return {
+ r: Number.parseInt(hex.slice(0, 2), 16),
+ g: Number.parseInt(hex.slice(2, 4), 16),
+ b: Number.parseInt(hex.slice(4, 6), 16),
+ a: Number.parseInt(hex.slice(6, 8), 16) / 255,
+ };
+ }
+ }
+
+ const rgbMatch = lower.match(
+ /^rgb\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/,
+ );
+ if (rgbMatch) {
+ return {
+ r: Number.parseFloat(rgbMatch[1]),
+ g: Number.parseFloat(rgbMatch[2]),
+ b: Number.parseFloat(rgbMatch[3]),
+ a: 1,
+ };
+ }
+
+ const rgbaMatch = lower.match(
+ /^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/,
+ );
+ if (rgbaMatch) {
+ return {
+ r: Number.parseFloat(rgbaMatch[1]),
+ g: Number.parseFloat(rgbaMatch[2]),
+ b: Number.parseFloat(rgbaMatch[3]),
+ a: Number.parseFloat(rgbaMatch[4]),
+ };
+ }
+
+ return null;
+};
+
+const fixTransparentStops = ({
+ stops,
+}: {
+ stops: Array<{ color: string; offset: number | null }>;
+}): Array<{ color: string; offset: number | null }> => {
+ if (stops.length === 0) {
+ return stops;
+ }
+
+ const result = stops.map((stop) => ({ ...stop }));
+
+ for (let i = 0; i < result.length; i++) {
+ if (!isTransparent({ color: result[i].color })) {
+ continue;
+ }
+
+ let donorColor: { r: number; g: number; b: number } | null = null;
+
+ for (let j = i - 1; j >= 0; j--) {
+ if (!isTransparent({ color: result[j].color })) {
+ const parsed = parseColorToRgb({ color: result[j].color });
+ if (parsed) {
+ donorColor = parsed;
+ break;
+ }
+ }
+ }
+
+ if (!donorColor) {
+ for (let j = i + 1; j < result.length; j++) {
+ if (!isTransparent({ color: result[j].color })) {
+ const parsed = parseColorToRgb({ color: result[j].color });
+ if (parsed) {
+ donorColor = parsed;
+ break;
+ }
+ }
+ }
+ }
+
+ if (donorColor) {
+ result[i].color =
+ `rgba(${donorColor.r},${donorColor.g},${donorColor.b},0)`;
+ }
+ }
+
+ return result;
+};
+
+const normalizeColorStops = ({
+ colorStops,
+ gradientLength,
+}: {
+ colorStops: Array<ColorStop>;
+ gradientLength: number;
+}): Array<{ color: string; offset: number }> => {
+ const mappedStops = colorStops.map((stop) => ({
+ color: colorToString({ color: stop }),
+ offset: resolveStopOffset({ stop, gradientLength }),
+ }));
+
+ const fixedStops = fixTransparentStops({ stops: mappedStops });
+ const resolvedStops = fixedStops.map((stop) => ({ ...stop }));
+ const knownIndices = resolvedStops
+ .map((stop, index) => (stop.offset === null ? null : index))
+ .filter((index): index is number => index !== null);
+
+ if (knownIndices.length === 0) {
+ const step = resolvedStops.length > 1 ? 1 / (resolvedStops.length - 1) : 1;
+ for (let index = 0; index < resolvedStops.length; index += 1) {
+ resolvedStops[index].offset = step * index;
+ }
+ return clampStops({ stops: resolvedStops });
+ }
+
+ const firstKnown = knownIndices[0];
+ if (resolvedStops[firstKnown].offset === null) {
+ resolvedStops[firstKnown].offset = 0;
+ }
+
+ for (let index = 0; index < firstKnown; index += 1) {
+ const nextOffset = resolvedStops[firstKnown].offset ?? 0;
+ resolvedStops[index].offset = (nextOffset * index) / firstKnown;
+ }
+
+ for (let i = 0; i < knownIndices.length - 1; i += 1) {
+ const startIndex = knownIndices[i];
+ const endIndex = knownIndices[i + 1];
+ const startOffset = resolvedStops[startIndex].offset ?? 0;
+ const endOffset = resolvedStops[endIndex].offset ?? startOffset;
+ const gap = endIndex - startIndex;
+
+ if (gap <= 1) {
+ continue;
+ }
+
+ const step = (endOffset - startOffset) / gap;
+ for (let index = 1; index < gap; index += 1) {
+ resolvedStops[startIndex + index].offset = startOffset + step * index;
+ }
+ }
+
+ const lastKnown = knownIndices[knownIndices.length - 1];
+ const lastOffset = resolvedStops[lastKnown].offset ?? 1;
+ if (lastKnown < resolvedStops.length - 1) {
+ const gap = resolvedStops.length - 1 - lastKnown;
+ const step = (1 - lastOffset) / gap;
+ for (let index = 1; index <= gap; index += 1) {
+ resolvedStops[lastKnown + index].offset = lastOffset + step * index;
+ }
+ }
+
+ return clampStops({ stops: resolvedStops });
+};
+
+const clampStops = ({
+ stops,
+}: {
+ stops: Array<{ color: string; offset: number | null }>;
+}): Array<{ color: string; offset: number }> => {
+ return stops.map((stop) => ({
+ color: stop.color,
+ offset: clamp01({ value: stop.offset ?? 0 }),
+ }));
+};
+
+const resolveStopOffset = ({
+ stop,
+ gradientLength,
+}: {
+ stop: ColorStop;
+ gradientLength: number;
+}): number | null => {
+ if (!stop.length) {
+ return null;
+ }
+
+ if (stop.length.type === "%") {
+ return Number.parseFloat(stop.length.value) / 100;
+ }
+
+ if (stop.length.type === "px") {
+ return Number.parseFloat(stop.length.value) / gradientLength;
+ }
+
+ if (stop.length.type === "em") {
+ return (Number.parseFloat(stop.length.value) * 16) / gradientLength;
+ }
+
+ return null;
+};
+
+const clamp01 = ({ value }: { value: number }): number => {
+ if (value < 0) {
+ return 0;
+ }
+ if (value > 1) {
+ return 1;
+ }
+ return value;
+};
+
+const colorToString = ({ color }: { color: Color }): string => {
+ if (color.type === "hex") {
+ return `#${color.value}`;
+ }
+
+ if (color.type === "literal") {
+ return color.value;
+ }
+
+ if (color.type === "rgb") {
+ return `rgb(${color.value.join(",")})`;
+ }
+
+ if (color.type === "rgba") {
+ return `rgba(${color.value.join(",")})`;
+ }
+
+ if (color.type === "hsl") {
+ return `hsl(${color.value.join(",")})`;
+ }
+
+ if (color.type === "hsla") {
+ return `hsla(${color.value.join(",")})`;
+ }
+
+ return `var(${color.value})`;
+};
diff --git a/apps/web/src/lib/gradients/index.ts b/apps/web/src/lib/gradients/index.ts
new file mode 100644
index 00000000..490a8fbf
--- /dev/null
+++ b/apps/web/src/lib/gradients/index.ts
@@ -0,0 +1,2 @@
+export * from "./parser";
+export * from "./canvas";
diff --git a/apps/web/src/lib/gradients/parser.ts b/apps/web/src/lib/gradients/parser.ts
new file mode 100644
index 00000000..55440e3c
--- /dev/null
+++ b/apps/web/src/lib/gradients/parser.ts
@@ -0,0 +1,652 @@
+/*
+ * Original source: https://github.com/rafaelcaricio/gradient-parser/blob/master/lib/parser.js
+ */
+
+type GradientType =
+ | "linear-gradient"
+ | "repeating-linear-gradient"
+ | "radial-gradient"
+ | "repeating-radial-gradient";
+
+type DirectionalOrientation = { type: "directional"; value: string };
+type AngularOrientation = { type: "angular"; value: string };
+type LinearOrientation = DirectionalOrientation | AngularOrientation;
+
+type Distance =
+ | { type: "%"; value: string }
+ | { type: "position-keyword"; value: string }
+ | { type: "calc"; value: string }
+ | { type: "px"; value: string }
+ | { type: "em"; value: string };
+
+type PositionValue = { x?: Distance; y?: Distance };
+type Position = { type: "position"; value: PositionValue };
+
+type ExtentKeyword = { type: "extent-keyword"; value: string };
+
+type ShapeValue = "circle" | "ellipse";
+type Shape = {
+ type: "shape";
+ value: ShapeValue;
+ style?: Distance | ExtentKeyword | Position;
+ at?: Position;
+};
+
+type DefaultRadial = { type: "default-radial"; at: Position };
+
+type RadialOrientation =
+ | Shape
+ | (ExtentKeyword & { at?: Position })
+ | DefaultRadial;
+
+export type GradientOrientation = LinearOrientation | Array<RadialOrientation>;
+
+export type Color =
+ | { type: "hex"; value: string }
+ | { type: "literal"; value: string }
+ | { type: "rgb"; value: Array<string> }
+ | { type: "rgba"; value: Array<string> }
+ | { type: "hsl"; value: [string, string, string] }
+ | { type: "hsla"; value: [string, string, string, string] }
+ | { type: "var"; value: string };
+
+export type ColorStop = Color & { length?: Distance };
+
+export type GradientAst = {
+ type: GradientType;
+ orientation: GradientOrientation | undefined;
+ colorStops: Array<ColorStop>;
+};
+
+type Tokens = {
+ linearGradient: RegExp;
+ repeatingLinearGradient: RegExp;
+ radialGradient: RegExp;
+ repeatingRadialGradient: RegExp;
+ sideOrCorner: RegExp;
+ extentKeywords: RegExp;
+ positionKeywords: RegExp;
+ pixelValue: RegExp;
+ percentageValue: RegExp;
+ emValue: RegExp;
+ angleValue: RegExp;
+ radianValue: RegExp;
+ startCall: RegExp;
+ endCall: RegExp;
+ comma: RegExp;
+ hexColor: RegExp;
+ literalColor: RegExp;
+ rgbColor: RegExp;
+ rgbaColor: RegExp;
+ varColor: RegExp;
+ calcValue: RegExp;
+ variableName: RegExp;
+ number: RegExp;
+ hslColor: RegExp;
+ hslaColor: RegExp;
+};
+
+const tokens: Tokens = {
+ linearGradient: /^(-(webkit|o|ms|moz)-)?(linear-gradient)/i,
+ repeatingLinearGradient:
+ /^(-(webkit|o|ms|moz)-)?(repeating-linear-gradient)/i,
+ radialGradient: /^(-(webkit|o|ms|moz)-)?(radial-gradient)/i,
+ repeatingRadialGradient:
+ /^(-(webkit|o|ms|moz)-)?(repeating-radial-gradient)/i,
+ sideOrCorner:
+ /^to (left (top|bottom)|right (top|bottom)|top (left|right)|bottom (left|right)|left|right|top|bottom)/i,
+ extentKeywords:
+ /^(closest-side|closest-corner|farthest-side|farthest-corner|contain|cover)/,
+ positionKeywords: /^(left|center|right|top|bottom)/i,
+ pixelValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))px/,
+ percentageValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))%/,
+ emValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))em/,
+ angleValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))deg/,
+ radianValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))rad/,
+ startCall: /^\(/,
+ endCall: /^\)/,
+ comma: /^,/,
+ hexColor: /^#([0-9a-fA-F]+)/,
+ literalColor: /^([a-zA-Z]+)/,
+ rgbColor: /^rgb/i,
+ rgbaColor: /^rgba/i,
+ varColor: /^var/i,
+ calcValue: /^calc/i,
+ variableName: /^(--[a-zA-Z0-9-,\s#]+)/,
+ number: /^(([0-9]*\.[0-9]+)|([0-9]+\.?))/,
+ hslColor: /^hsl/i,
+ hslaColor: /^hsla/i,
+};
+
+let input = "";
+
+const error = ({ message }: { message: string }): never => {
+ const err = new Error(`${input}: ${message}`);
+ (err as Error & { source?: string }).source = input;
+ throw err;
+};
+
+const getAst = (): Array<GradientAst> => {
+ const ast = matchListDefinitions();
+
+ if (input.length > 0) {
+ error({ message: "Invalid input not EOF" });
+ }
+
+ return ast;
+};
+
+const matchListDefinitions = (): Array<GradientAst> =>
+ matchListing({ matcher: matchDefinition });
+
+const matchDefinition = (): GradientAst | undefined =>
+ matchGradient({
+ gradientType: "linear-gradient",
+ pattern: tokens.linearGradient,
+ orientationMatcher: matchLinearOrientation,
+ }) ||
+ matchGradient({
+ gradientType: "repeating-linear-gradient",
+ pattern: tokens.repeatingLinearGradient,
+ orientationMatcher: matchLinearOrientation,
+ }) ||
+ matchGradient({
+ gradientType: "radial-gradient",
+ pattern: tokens.radialGradient,
+ orientationMatcher: matchListRadialOrientations,
+ }) ||
+ matchGradient({
+ gradientType: "repeating-radial-gradient",
+ pattern: tokens.repeatingRadialGradient,
+ orientationMatcher: matchListRadialOrientations,
+ });
+
+const matchGradient = ({
+ gradientType,
+ pattern,
+ orientationMatcher,
+}: {
+ gradientType: GradientType;
+ pattern: RegExp;
+ orientationMatcher: () => GradientOrientation | undefined;
+}): GradientAst | undefined =>
+ matchCall({
+ pattern,
+ callback: () => {
+ const orientation = orientationMatcher();
+ if (orientation && !scan({ regexp: tokens.comma })) {
+ error({ message: "Missing comma before color stops" });
+ }
+
+ return {
+ type: gradientType,
+ orientation,
+ colorStops: matchListing({ matcher: matchColorStop }),
+ };
+ },
+ });
+
+const matchCall = <T>({
+ pattern,
+ callback,
+}: {
+ pattern: RegExp;
+ callback: (captures: RegExpExecArray) => T;
+}): T | undefined => {
+ const captures = scan({ regexp: pattern });
+
+ if (!captures) {
+ return undefined;
+ }
+
+ if (!scan({ regexp: tokens.startCall })) {
+ error({ message: "Missing (" });
+ }
+
+ const result = callback(captures);
+
+ if (!scan({ regexp: tokens.endCall })) {
+ error({ message: "Missing )" });
+ }
+
+ return result;
+};
+
+const matchLinearOrientation = (): LinearOrientation | undefined => {
+ const sideOrCorner = matchSideOrCorner();
+ if (sideOrCorner) {
+ return sideOrCorner;
+ }
+
+ const legacyDirection = match({
+ type: "position-keyword",
+ pattern: tokens.positionKeywords,
+ captureIndex: 1,
+ });
+ if (legacyDirection) {
+ return {
+ type: "directional",
+ value: legacyDirection.value,
+ };
+ }
+
+ return matchAngle();
+};
+
+const matchSideOrCorner = (): DirectionalOrientation | undefined =>
+ match({ type: "directional", pattern: tokens.sideOrCorner, captureIndex: 1 });
+
+const matchAngle = (): AngularOrientation | undefined =>
+ match({ type: "angular", pattern: tokens.angleValue, captureIndex: 1 }) ||
+ match({ type: "angular", pattern: tokens.radianValue, captureIndex: 1 });
+
+const matchListRadialOrientations = ():
+ | Array<RadialOrientation>
+ | undefined => {
+ const radialOrientation = matchRadialOrientation();
+ if (!radialOrientation) {
+ return undefined;
+ }
+
+ const radialOrientations: Array<RadialOrientation> = [radialOrientation];
+ const lookaheadCache = input;
+
+ if (!scan({ regexp: tokens.comma })) {
+ return radialOrientations;
+ }
+
+ const nextRadial = matchRadialOrientation();
+ if (!nextRadial) {
+ input = lookaheadCache;
+ return radialOrientations;
+ }
+
+ radialOrientations.push(nextRadial);
+ return radialOrientations;
+};
+
+const matchRadialOrientation = (): RadialOrientation | undefined => {
+ const radialType = matchCircle() || matchEllipse();
+ if (radialType) {
+ radialType.at = matchAtPosition();
+ return radialType;
+ }
+
+ const extent = matchExtentKeyword();
+ if (extent) {
+ const positionAt = matchAtPosition();
+ if (positionAt) {
+ return { ...extent, at: positionAt };
+ }
+ return extent;
+ }
+
+ const implicitEllipse = matchImplicitEllipse();
+ if (implicitEllipse) {
+ return implicitEllipse;
+ }
+
+ const atPosition = matchAtPosition();
+ if (atPosition) {
+ return { type: "default-radial", at: atPosition };
+ }
+
+ const defaultPosition = matchPositioning();
+ if (defaultPosition) {
+ return { type: "default-radial", at: defaultPosition };
+ }
+
+ return undefined;
+};
+
+const matchImplicitEllipse = (): Shape | undefined => {
+ const lookaheadCache = input;
+
+ const width = matchDistance();
+ if (!width) {
+ return undefined;
+ }
+
+ const height = matchDistance();
+ if (!height) {
+ input = lookaheadCache;
+ return undefined;
+ }
+
+ const atPos = matchAtPosition();
+ if (!atPos) {
+ input = lookaheadCache;
+ return undefined;
+ }
+
+ return {
+ type: "shape",
+ value: "ellipse",
+ style: { type: "position", value: { x: width, y: height } },
+ at: atPos,
+ };
+};
+
+const matchCircle = (): Shape | undefined => {
+ const circle = match({
+ type: "shape",
+ pattern: /^(circle)/i,
+ captureIndex: 0,
+ }) as Shape | undefined;
+
+ if (!circle) {
+ return undefined;
+ }
+
+ circle.style = matchLength() || matchExtentKeyword();
+ circle.value = "circle";
+ return circle;
+};
+
+const matchEllipse = (): Shape | undefined => {
+ const ellipse = match({
+ type: "shape",
+ pattern: /^(ellipse)/i,
+ captureIndex: 0,
+ }) as Shape | undefined;
+
+ if (!ellipse) {
+ return undefined;
+ }
+
+ ellipse.style = matchPositioning() || matchDistance() || matchExtentKeyword();
+ ellipse.value = "ellipse";
+ return ellipse;
+};
+
+const matchExtentKeyword = (): ExtentKeyword | undefined =>
+ match({
+ type: "extent-keyword",
+ pattern: tokens.extentKeywords,
+ captureIndex: 1,
+ });
+
+const matchAtPosition = (): Position | undefined => {
+ if (!match({ type: "position", pattern: /^at/, captureIndex: 0 })) {
+ return undefined;
+ }
+
+ const positioning = matchPositioning();
+ if (!positioning) {
+ error({ message: "Missing positioning value" });
+ }
+
+ return positioning;
+};
+
+const matchPositioning = (): Position | undefined => {
+ const location = matchCoordinates();
+
+ if (!location.x && !location.y) {
+ return undefined;
+ }
+
+ return {
+ type: "position",
+ value: location,
+ };
+};
+
+const matchCoordinates = (): PositionValue => ({
+ x: matchDistance(),
+ y: matchDistance(),
+});
+
+const matchListing = <T>({
+ matcher,
+}: {
+ matcher: () => T | undefined;
+}): Array<T> => {
+ const captures = matcher();
+ const result: Array<T> = [];
+
+ if (!captures) {
+ return result;
+ }
+
+ result.push(captures);
+ while (scan({ regexp: tokens.comma })) {
+ const nextCapture = matcher() ?? error({ message: "One extra comma" });
+ result.push(nextCapture);
+ }
+
+ return result;
+};
+
+const matchColorStop = (): ColorStop => {
+ const color = matchColor() ?? error({ message: "Expected color definition" });
+ const length = matchDistance();
+ return { ...color, length };
+};
+
+const matchColor = (): Color | undefined =>
+ matchHexColor() ||
+ matchHSLAColor() ||
+ matchHSLColor() ||
+ matchRGBAColor() ||
+ matchRGBColor() ||
+ matchVarColor() ||
+ matchLiteralColor();
+
+const matchLiteralColor = (): Color | undefined =>
+ match({ type: "literal", pattern: tokens.literalColor, captureIndex: 0 });
+
+const matchHexColor = (): Color | undefined =>
+ match({ type: "hex", pattern: tokens.hexColor, captureIndex: 1 });
+
+const matchRGBColor = (): Color | undefined =>
+ matchCall({
+ pattern: tokens.rgbColor,
+ callback: () => ({
+ type: "rgb",
+ value: matchListing({ matcher: matchNumber }),
+ }),
+ });
+
+const matchRGBAColor = (): Color | undefined =>
+ matchCall({
+ pattern: tokens.rgbaColor,
+ callback: () => ({
+ type: "rgba",
+ value: matchListing({ matcher: matchNumber }),
+ }),
+ });
+
+const matchVarColor = (): Color | undefined =>
+ matchCall({
+ pattern: tokens.varColor,
+ callback: () => ({
+ type: "var",
+ value: matchVariableName(),
+ }),
+ });
+
+const matchHSLColor = (): Color | undefined =>
+ matchCall({
+ pattern: tokens.hslColor,
+ callback: () => {
+ const lookahead = scan({ regexp: tokens.percentageValue });
+ if (lookahead) {
+ error({
+ message:
+ "HSL hue value must be a number in degrees (0-360) or normalized (-360 to 360), not a percentage",
+ });
+ }
+
+ const hue = matchNumber();
+ scan({ regexp: tokens.comma });
+ let captures = scan({ regexp: tokens.percentageValue });
+ const sat = captures ? captures[1] : null;
+ scan({ regexp: tokens.comma });
+ captures = scan({ regexp: tokens.percentageValue });
+ const light = captures ? captures[1] : null;
+ const ensuredSat =
+ sat ??
+ error({
+ message:
+ "Expected percentage value for saturation and lightness in HSL",
+ });
+ const ensuredLight =
+ light ??
+ error({
+ message:
+ "Expected percentage value for saturation and lightness in HSL",
+ });
+ return {
+ type: "hsl",
+ value: [hue, ensuredSat, ensuredLight],
+ };
+ },
+ });
+
+const matchHSLAColor = (): Color | undefined =>
+ matchCall({
+ pattern: tokens.hslaColor,
+ callback: () => {
+ const hue = matchNumber();
+ scan({ regexp: tokens.comma });
+ let captures = scan({ regexp: tokens.percentageValue });
+ const sat = captures ? captures[1] : null;
+ scan({ regexp: tokens.comma });
+ captures = scan({ regexp: tokens.percentageValue });
+ const light = captures ? captures[1] : null;
+ scan({ regexp: tokens.comma });
+ const alpha = matchNumber();
+ const ensuredSat =
+ sat ??
+ error({
+ message:
+ "Expected percentage value for saturation and lightness in HSLA",
+ });
+ const ensuredLight =
+ light ??
+ error({
+ message:
+ "Expected percentage value for saturation and lightness in HSLA",
+ });
+ return {
+ type: "hsla",
+ value: [hue, ensuredSat, ensuredLight, alpha],
+ };
+ },
+ });
+
+const matchVariableName = (): string => {
+ const captures =
+ scan({ regexp: tokens.variableName }) ??
+ error({ message: "Expected CSS variable name" });
+ return captures[1];
+};
+
+const matchNumber = (): string => {
+ const captures =
+ scan({ regexp: tokens.number }) ?? error({ message: "Expected number" });
+ return captures[1];
+};
+
+const matchDistance = (): Distance | undefined =>
+ match({ type: "%", pattern: tokens.percentageValue, captureIndex: 1 }) ||
+ matchPositionKeyword() ||
+ matchCalc() ||
+ matchLength();
+
+const matchPositionKeyword = (): Distance | undefined =>
+ match({
+ type: "position-keyword",
+ pattern: tokens.positionKeywords,
+ captureIndex: 1,
+ });
+
+const matchCalc = (): Distance | undefined =>
+ matchCall({
+ pattern: tokens.calcValue,
+ callback: () => {
+ let openParenCount = 1;
+ let index = 0;
+
+ while (openParenCount > 0 && index < input.length) {
+ const char = input.charAt(index);
+ if (char === "(") {
+ openParenCount++;
+ } else if (char === ")") {
+ openParenCount--;
+ }
+ index++;
+ }
+
+ if (openParenCount > 0) {
+ error({ message: "Missing closing parenthesis in calc() expression" });
+ }
+
+ const calcContent = input.slice(0, index - 1);
+ consume({ size: index - 1 });
+
+ return {
+ type: "calc",
+ value: calcContent,
+ };
+ },
+ });
+
+const matchLength = (): Distance | undefined =>
+ match({ type: "px", pattern: tokens.pixelValue, captureIndex: 1 }) ||
+ match({ type: "em", pattern: tokens.emValue, captureIndex: 1 });
+
+const match = <TType extends string>({
+ type,
+ pattern,
+ captureIndex,
+}: {
+ type: TType;
+ pattern: RegExp;
+ captureIndex: number;
+}): { type: TType; value: string } | undefined => {
+ const captures = scan({ regexp: pattern });
+ if (!captures) {
+ return undefined;
+ }
+
+ return {
+ type,
+ value: captures[captureIndex],
+ };
+};
+
+const scan = ({ regexp }: { regexp: RegExp }): RegExpExecArray | null => {
+ const blankCaptures = /^[\n\r\t\s]+/.exec(input);
+ if (blankCaptures) {
+ consume({ size: blankCaptures[0].length });
+ }
+
+ const captures = regexp.exec(input);
+ if (captures) {
+ consume({ size: captures[0].length });
+ }
+
+ return captures;
+};
+
+const consume = ({ size }: { size: number }): void => {
+ input = input.slice(size);
+};
+
+export const parseGradient = ({
+ code,
+}: {
+ code: string;
+}): Array<GradientAst> => {
+ input = code.toString().trim();
+ if (input.endsWith(";")) {
+ input = input.slice(0, -1);
+ }
+ return getAst();
+};
+
+export const GradientParser = {
+ parse: parseGradient,
+};
diff --git a/apps/web/src/lib/iconify-api.ts b/apps/web/src/lib/iconify-api.ts
index 9d1bcb97..e4afe111 100644
--- a/apps/web/src/lib/iconify-api.ts
+++ b/apps/web/src/lib/iconify-api.ts
@@ -1,242 +1,241 @@
-
export const ICONIFY_HOSTS = [
- "https://api.iconify.design",
- "https://api.simplesvg.com",
- "https://api.unisvg.com",
+ "https://api.iconify.design",
+ "https://api.simplesvg.com",
+ "https://api.unisvg.com",
];
let currentHost = ICONIFY_HOSTS[0];
async function fetchWithFallback(path: string): Promise<Response> {
- for (const host of ICONIFY_HOSTS) {
- try {
- const response = await fetch(`${host}${path}`, {
- signal: AbortSignal.timeout(2000),
- });
- if (response.ok) {
- currentHost = host;
- return response;
- }
- } catch (error) {
- console.warn(`Failed to fetch from ${host}:`, error);
- }
- }
- throw new Error("All API hosts failed");
+ for (const host of ICONIFY_HOSTS) {
+ try {
+ const response = await fetch(`${host}${path}`, {
+ signal: AbortSignal.timeout(2000),
+ });
+ if (response.ok) {
+ currentHost = host;
+ return response;
+ }
+ } catch (error) {
+ console.warn(`Failed to fetch from ${host}:`, error);
+ }
+ }
+ throw new Error("All API hosts failed");
}
export interface IconSet {
- prefix: string;
- name: string;
- total: number;
- author?: {
- name: string;
- url?: string;
- };
- license?: {
- title: string;
- spdx?: string;
- url?: string;
- };
- samples?: string[];
- category?: string;
- palette?: boolean;
+ prefix: string;
+ name: string;
+ total: number;
+ author?: {
+ name: string;
+ url?: string;
+ };
+ license?: {
+ title: string;
+ spdx?: string;
+ url?: string;
+ };
+ samples?: string[];
+ category?: string;
+ palette?: boolean;
}
export interface IconSearchResult {
- icons: string[];
- total: number;
- limit: number;
- start: number;
- collections: Record<string, IconSet>;
+ icons: string[];
+ total: number;
+ limit: number;
+ start: number;
+ collections: Record<string, IconSet>;
}
export interface CollectionInfo {
- prefix: string;
- total: number;
- title?: string;
- uncategorized?: string[];
- categories?: Record<string, string[]>;
- hidden?: string[];
- aliases?: Record<string, string>;
+ prefix: string;
+ total: number;
+ title?: string;
+ uncategorized?: string[];
+ categories?: Record<string, string[]>;
+ hidden?: string[];
+ aliases?: Record<string, string>;
}
export async function getCollections(
- category?: string
+ category?: string,
): Promise<Record<string, IconSet>> {
- try {
- const response = await fetchWithFallback("/collections?pretty=1");
- const data = (await response.json()) as Record<string, IconSet>;
+ try {
+ const response = await fetchWithFallback("/collections?pretty=1");
+ const data = (await response.json()) as Record<string, IconSet>;
- if (category) {
- const filtered = Object.fromEntries(
- Object.entries(data).filter(
- ([_key, info]) => info.category === category
- )
- ) as Record<string, IconSet>;
- return filtered;
- }
+ if (category) {
+ const filtered = Object.fromEntries(
+ Object.entries(data).filter(
+ ([_key, info]) => info.category === category,
+ ),
+ ) as Record<string, IconSet>;
+ return filtered;
+ }
- return data;
- } catch (error) {
- console.error("Failed to fetch collections:", error);
- return {};
- }
+ return data;
+ } catch (error) {
+ console.error("Failed to fetch collections:", error);
+ return {};
+ }
}
export async function getCollection(
- prefix: string
+ prefix: string,
): Promise<CollectionInfo | null> {
- try {
- const response = await fetchWithFallback(
- `/collection?prefix=${prefix}&pretty=1`
- );
- return await response.json();
- } catch (error) {
- console.error(`Failed to fetch collection ${prefix}:`, error);
- return null;
- }
+ try {
+ const response = await fetchWithFallback(
+ `/collection?prefix=${prefix}&pretty=1`,
+ );
+ return await response.json();
+ } catch (error) {
+ console.error(`Failed to fetch collection ${prefix}:`, error);
+ return null;
+ }
}
export async function searchIcons(
- query: string,
- limit: number = 64,
- prefixes?: string[],
- category?: string
+ query: string,
+ limit: number = 64,
+ prefixes?: string[],
+ category?: string,
): Promise<IconSearchResult> {
- const params = new URLSearchParams({
- query,
- limit: limit.toString(),
- pretty: "1",
- });
+ const params = new URLSearchParams({
+ query,
+ limit: limit.toString(),
+ pretty: "1",
+ });
- if (prefixes?.length) {
- params.append("prefixes", prefixes.join(","));
- }
+ if (prefixes?.length) {
+ params.append("prefixes", prefixes.join(","));
+ }
- if (category) {
- params.append("category", category);
- }
+ if (category) {
+ params.append("category", category);
+ }
- try {
- const response = await fetchWithFallback(`/search?${params}`);
- return await response.json();
- } catch (error) {
- console.error("Failed to search icons:", error);
- return {
- icons: [],
- total: 0,
- limit,
- start: 0,
- collections: {},
- };
- }
+ try {
+ const response = await fetchWithFallback(`/search?${params}`);
+ return await response.json();
+ } catch (error) {
+ console.error("Failed to search icons:", error);
+ return {
+ icons: [],
+ total: 0,
+ limit,
+ start: 0,
+ collections: {},
+ };
+ }
}
export function buildIconSvgUrl(
- host: string,
- iconName: string,
- params?: {
- color?: string;
- width?: number;
- height?: number;
- flip?: "horizontal" | "vertical" | "horizontal,vertical";
- rotate?: number | string;
- }
+ host: string,
+ iconName: string,
+ params?: {
+ color?: string;
+ width?: number;
+ height?: number;
+ flip?: "horizontal" | "vertical" | "horizontal,vertical";
+ rotate?: number | string;
+ },
): string {
- const [prefix, name] = iconName.includes(":")
- ? iconName.split(":")
- : ["", iconName];
+ const [prefix, name] = iconName.includes(":")
+ ? iconName.split(":")
+ : ["", iconName];
- if (!prefix || !name) {
- throw new Error('Invalid icon name format. Expected "prefix:name"');
- }
+ if (!prefix || !name) {
+ throw new Error('Invalid icon name format. Expected "prefix:name"');
+ }
- const urlParams = new URLSearchParams();
+ const urlParams = new URLSearchParams();
- if (params?.color) {
- urlParams.append("color", params.color.replace("#", "%23"));
- }
+ if (params?.color) {
+ urlParams.append("color", params.color.replace("#", "%23"));
+ }
- if (params?.width) {
- urlParams.append("width", params.width.toString());
- }
+ if (params?.width) {
+ urlParams.append("width", params.width.toString());
+ }
- if (params?.height) {
- urlParams.append("height", params.height.toString());
- }
+ if (params?.height) {
+ urlParams.append("height", params.height.toString());
+ }
- if (params?.flip) {
- urlParams.append("flip", params.flip);
- }
+ if (params?.flip) {
+ urlParams.append("flip", params.flip);
+ }
- if (params?.rotate) {
- urlParams.append("rotate", params.rotate.toString());
- }
+ if (params?.rotate) {
+ urlParams.append("rotate", params.rotate.toString());
+ }
- const queryString = urlParams.toString();
- return `${host}/${prefix}/${name}.svg${queryString ? "?" + queryString : ""}`;
+ const queryString = urlParams.toString();
+ return `${host}/${prefix}/${name}.svg${queryString ? `?${queryString}` : ""}`;
}
export function getIconSvgUrl(
- iconName: string,
- params?: Parameters<typeof buildIconSvgUrl>[2]
+ iconName: string,
+ params?: Parameters<typeof buildIconSvgUrl>[2],
): string {
- return buildIconSvgUrl(currentHost, iconName, params);
+ return buildIconSvgUrl(currentHost, iconName, params);
}
export async function downloadSvgAsText(
- iconName: string,
- params?: Parameters<typeof getIconSvgUrl>[1]
+ iconName: string,
+ params?: Parameters<typeof getIconSvgUrl>[1],
): Promise<string> {
- const url = getIconSvgUrl(iconName, params);
- const response = await fetch(url);
- if (!response.ok) {
- throw new Error(`Failed to download SVG: ${response.statusText}`);
- }
- return await response.text();
+ const url = getIconSvgUrl(iconName, params);
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Failed to download SVG: ${response.statusText}`);
+ }
+ return await response.text();
}
export function svgToFile(svgText: string, fileName: string): File {
- const blob = new Blob([svgText], { type: "image/svg+xml" });
- return new File([blob], fileName, { type: "image/svg+xml" });
+ const blob = new Blob([svgText], { type: "image/svg+xml" });
+ return new File([blob], fileName, { type: "image/svg+xml" });
}
export const POPULAR_COLLECTIONS = {
- general: [
- { prefix: "mdi", name: "Material Design Icons" },
- { prefix: "ic", name: "Google Material Icons" },
- { prefix: "ph", name: "Phosphor" },
- { prefix: "heroicons", name: "Heroicons" },
- { prefix: "lucide", name: "Lucide" },
- { prefix: "tabler", name: "Tabler Icons" },
- { prefix: "fe", name: "Feather Icons" },
- { prefix: "bi", name: "Bootstrap Icons" },
- ],
- brands: [
- { prefix: "simple-icons", name: "Simple Icons" },
- { prefix: "logos", name: "SVG Logos" },
- { prefix: "skill-icons", name: "Skill Icons" },
- { prefix: "devicon", name: "Devicon" },
- { prefix: "fa-brands", name: "Font Awesome Brands" },
- ],
- emoji: [
- { prefix: "noto", name: "Noto Emoji" },
- { prefix: "twemoji", name: "Twemoji" },
- { prefix: "fluent-emoji", name: "Fluent Emoji" },
- { prefix: "fluent-emoji-flat", name: "Fluent Emoji Flat" },
- { prefix: "emojione", name: "EmojiOne" },
- { prefix: "openmoji", name: "OpenMoji" },
- ],
+ general: [
+ { prefix: "mdi", name: "Material Design Icons" },
+ { prefix: "ic", name: "Google Material Icons" },
+ { prefix: "ph", name: "Phosphor" },
+ { prefix: "heroicons", name: "Heroicons" },
+ { prefix: "lucide", name: "Lucide" },
+ { prefix: "tabler", name: "Tabler Icons" },
+ { prefix: "fe", name: "Feather Icons" },
+ { prefix: "bi", name: "Bootstrap Icons" },
+ ],
+ brands: [
+ { prefix: "simple-icons", name: "Simple Icons" },
+ { prefix: "logos", name: "SVG Logos" },
+ { prefix: "skill-icons", name: "Skill Icons" },
+ { prefix: "devicon", name: "Devicon" },
+ { prefix: "fa-brands", name: "Font Awesome Brands" },
+ ],
+ emoji: [
+ { prefix: "noto", name: "Noto Emoji" },
+ { prefix: "twemoji", name: "Twemoji" },
+ { prefix: "fluent-emoji", name: "Fluent Emoji" },
+ { prefix: "fluent-emoji-flat", name: "Fluent Emoji Flat" },
+ { prefix: "emojione", name: "EmojiOne" },
+ { prefix: "openmoji", name: "OpenMoji" },
+ ],
};
export function getCategoriesFromCollections(
- collections: Record<string, IconSet>
+ collections: Record<string, IconSet>,
): string[] {
- const categories = new Set<string>();
- Object.values(collections).forEach((collection) => {
- if (collection.category) {
- categories.add(collection.category);
- }
- });
- return Array.from(categories).sort();
+ const categories = new Set<string>();
+ Object.values(collections).forEach((collection) => {
+ if (collection.category) {
+ categories.add(collection.category);
+ }
+ });
+ return Array.from(categories).sort();
}
diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts
deleted file mode 100644
index 7a31466f..00000000
--- a/apps/web/src/lib/media-processing.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import { toast } from "sonner";
-import {
- getFileType,
- getMediaDuration,
- getImageDimensions,
-} from "@/stores/media-store";
-import { MediaFile } from "@/types/media";
-import { generateThumbnail, getVideoInfo } from "./mediabunny-utils";
-
-export interface ProcessedMediaItem extends Omit<MediaFile, "id"> {}
-
-export async function processMediaFiles(
- files: FileList | File[],
- onProgress?: (progress: number) => void
-): Promise<ProcessedMediaItem[]> {
- const fileArray = Array.from(files);
- const processedItems: ProcessedMediaItem[] = [];
-
- const total = fileArray.length;
- let completed = 0;
-
- for (const file of fileArray) {
- const fileType = getFileType(file);
-
- if (!fileType) {
- toast.error(`Unsupported file type: ${file.name}`);
- continue;
- }
-
- const url = URL.createObjectURL(file);
- let thumbnailUrl: string | undefined;
- let duration: number | undefined;
- let width: number | undefined;
- let height: number | undefined;
- let fps: number | undefined;
-
- try {
- if (fileType === "image") {
- const dimensions = await getImageDimensions(file);
- width = dimensions.width;
- height = dimensions.height;
- } else if (fileType === "video") {
- try {
- const videoInfo = await getVideoInfo({ videoFile: file });
- duration = videoInfo.duration;
- width = videoInfo.width;
- height = videoInfo.height;
- fps = videoInfo.fps;
-
- thumbnailUrl = await generateThumbnail({
- videoFile: file,
- timeInSeconds: 1,
- });
- } catch (error) {
- console.warn("Video processing failed", error);
- }
- } else if (fileType === "audio") {
- // For audio, we don't set width/height/fps (they'll be undefined)
- duration = await getMediaDuration(file);
- }
-
- processedItems.push({
- name: file.name,
- type: fileType,
- file,
- url,
- thumbnailUrl,
- duration,
- width,
- height,
- fps,
- });
-
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- completed += 1;
- if (onProgress) {
- const percent = Math.round((completed / total) * 100);
- onProgress(percent);
- }
- } catch (error) {
- console.error("Error processing file:", file.name, error);
- toast.error(`Failed to process ${file.name}`);
- URL.revokeObjectURL(url); // Clean up on error
- }
- }
-
- return processedItems;
-}
diff --git a/apps/web/src/lib/media/audio.ts b/apps/web/src/lib/media/audio.ts
new file mode 100644
index 00000000..b45e348e
--- /dev/null
+++ b/apps/web/src/lib/media/audio.ts
@@ -0,0 +1,459 @@
+import type {
+ AudioElement,
+ LibraryAudioElement,
+ TimelineElement,
+ TimelineTrack,
+} from "@/types/timeline";
+import type { MediaAsset } from "@/types/assets";
+import { canElementHaveAudio } from "@/lib/timeline/element-utils";
+import { canTracktHaveAudio } from "@/lib/timeline";
+import { mediaSupportsAudio } from "@/lib/media/media-utils";
+
+export type CollectedAudioElement = Omit<
+ AudioElement,
+ "type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
+> & { buffer: AudioBuffer };
+
+export function createAudioContext(): AudioContext {
+ const AudioContextConstructor =
+ window.AudioContext ||
+ (window as typeof window & { webkitAudioContext?: typeof AudioContext })
+ .webkitAudioContext;
+
+ return new AudioContextConstructor();
+}
+
+export interface DecodedAudio {
+ samples: Float32Array;
+ sampleRate: number;
+}
+
+export async function decodeAudioToFloat32({
+ audioBlob,
+}: {
+ audioBlob: Blob;
+}): Promise<DecodedAudio> {
+ const audioContext = createAudioContext();
+ const arrayBuffer = await audioBlob.arrayBuffer();
+ const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
+
+ // mix down to mono
+ const numChannels = audioBuffer.numberOfChannels;
+ const length = audioBuffer.length;
+ const samples = new Float32Array(length);
+
+ for (let i = 0; i < length; i++) {
+ let sum = 0;
+ for (let channel = 0; channel < numChannels; channel++) {
+ sum += audioBuffer.getChannelData(channel)[i];
+ }
+ samples[i] = sum / numChannels;
+ }
+
+ return { samples, sampleRate: audioBuffer.sampleRate };
+}
+
+export async function collectAudioElements({
+ tracks,
+ mediaAssets,
+ audioContext,
+}: {
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
+ audioContext: AudioContext;
+}): Promise<CollectedAudioElement[]> {
+ const mediaMap = new Map<string, MediaAsset>(
+ mediaAssets.map((media) => [media.id, media]),
+ );
+ const pendingElements: Array<Promise<CollectedAudioElement | null>> = [];
+
+ for (const track of tracks) {
+ if (canTracktHaveAudio(track) && track.muted) continue;
+
+ for (const element of track.elements) {
+ if (element.type !== "audio") continue;
+ if (element.duration <= 0) continue;
+
+ const isTrackMuted = canTracktHaveAudio(track) && track.muted;
+ pendingElements.push(
+ resolveAudioBufferForElement({
+ element,
+ mediaMap,
+ audioContext,
+ }).then((audioBuffer) => {
+ if (!audioBuffer) return null;
+ return {
+ buffer: audioBuffer,
+ startTime: element.startTime,
+ duration: element.duration,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ muted: element.muted || isTrackMuted,
+ };
+ }),
+ );
+ }
+ }
+
+ const resolvedElements = await Promise.all(pendingElements);
+ const audioElements: CollectedAudioElement[] = [];
+ for (const element of resolvedElements) {
+ if (element) audioElements.push(element);
+ }
+ return audioElements;
+}
+
+async function resolveAudioBufferForElement({
+ element,
+ mediaMap,
+ audioContext,
+}: {
+ element: AudioElement;
+ mediaMap: Map<string, MediaAsset>;
+ audioContext: AudioContext;
+}): Promise<AudioBuffer | null> {
+ try {
+ if (element.sourceType === "upload") {
+ const asset = mediaMap.get(element.mediaId);
+ if (!asset || asset.type !== "audio") return null;
+
+ const arrayBuffer = await asset.file.arrayBuffer();
+ return await audioContext.decodeAudioData(arrayBuffer.slice(0));
+ }
+
+ if (element.buffer) return element.buffer;
+
+ const response = await fetch(element.sourceUrl);
+ if (!response.ok) {
+ throw new Error(`Library audio fetch failed: ${response.status}`);
+ }
+
+ const arrayBuffer = await response.arrayBuffer();
+ return await audioContext.decodeAudioData(arrayBuffer.slice(0));
+ } catch (error) {
+ console.warn("Failed to decode audio:", error);
+ return null;
+ }
+}
+
+interface AudioMixSource {
+ file: File;
+ startTime: number;
+ duration: number;
+ trimStart: number;
+ trimEnd: number;
+}
+
+export interface AudioClipSource {
+ id: string;
+ sourceKey: string;
+ file: File;
+ startTime: number;
+ duration: number;
+ trimStart: number;
+ trimEnd: number;
+ muted: boolean;
+}
+
+async function fetchLibraryAudioSource({
+ element,
+}: {
+ element: LibraryAudioElement;
+}): Promise<AudioMixSource | null> {
+ try {
+ const response = await fetch(element.sourceUrl);
+ if (!response.ok) {
+ throw new Error(`Library audio fetch failed: ${response.status}`);
+ }
+
+ const blob = await response.blob();
+ const file = new File([blob], `${element.name}.mp3`, {
+ type: "audio/mpeg",
+ });
+
+ return {
+ file,
+ startTime: element.startTime,
+ duration: element.duration,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ };
+ } catch (error) {
+ console.warn("Failed to fetch library audio:", error);
+ return null;
+ }
+}
+
+async function fetchLibraryAudioClip({
+ element,
+ muted,
+}: {
+ element: LibraryAudioElement;
+ muted: boolean;
+}): Promise<AudioClipSource | null> {
+ try {
+ const response = await fetch(element.sourceUrl);
+ if (!response.ok) {
+ throw new Error(`Library audio fetch failed: ${response.status}`);
+ }
+
+ const blob = await response.blob();
+ const file = new File([blob], `${element.name}.mp3`, {
+ type: "audio/mpeg",
+ });
+
+ return {
+ id: element.id,
+ sourceKey: element.id,
+ file,
+ startTime: element.startTime,
+ duration: element.duration,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ muted,
+ };
+ } catch (error) {
+ console.warn("Failed to fetch library audio:", error);
+ return null;
+ }
+}
+
+function collectMediaAudioSource({
+ element,
+ mediaAsset,
+}: {
+ element: TimelineElement;
+ mediaAsset: MediaAsset;
+}): AudioMixSource {
+ return {
+ file: mediaAsset.file,
+ startTime: element.startTime,
+ duration: element.duration,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ };
+}
+
+function collectMediaAudioClip({
+ element,
+ mediaAsset,
+ muted,
+}: {
+ element: TimelineElement;
+ mediaAsset: MediaAsset;
+ muted: boolean;
+}): AudioClipSource {
+ return {
+ id: element.id,
+ sourceKey: mediaAsset.id,
+ file: mediaAsset.file,
+ startTime: element.startTime,
+ duration: element.duration,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ muted,
+ };
+}
+
+export async function collectAudioMixSources({
+ tracks,
+ mediaAssets,
+}: {
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
+}): Promise<AudioMixSource[]> {
+ const audioMixSources: AudioMixSource[] = [];
+ const mediaMap = new Map<string, MediaAsset>(
+ mediaAssets.map((asset) => [asset.id, asset]),
+ );
+ const pendingLibrarySources: Array<Promise<AudioMixSource | null>> = [];
+
+ for (const track of tracks) {
+ if (canTracktHaveAudio(track) && track.muted) continue;
+
+ for (const element of track.elements) {
+ if (!canElementHaveAudio(element)) continue;
+
+ if (element.type === "audio") {
+ if (element.sourceType === "upload") {
+ const mediaAsset = mediaMap.get(element.mediaId);
+ if (!mediaAsset) continue;
+
+ audioMixSources.push(
+ collectMediaAudioSource({ element, mediaAsset }),
+ );
+ } else {
+ pendingLibrarySources.push(fetchLibraryAudioSource({ element }));
+ }
+ continue;
+ }
+
+ if (element.type === "video") {
+ const mediaAsset = mediaMap.get(element.mediaId);
+ if (!mediaAsset) continue;
+
+ if (mediaSupportsAudio({ media: mediaAsset })) {
+ audioMixSources.push(
+ collectMediaAudioSource({ element, mediaAsset }),
+ );
+ }
+ }
+ }
+ }
+
+ const resolvedLibrarySources = await Promise.all(pendingLibrarySources);
+ for (const source of resolvedLibrarySources) {
+ if (source) audioMixSources.push(source);
+ }
+
+ return audioMixSources;
+}
+
+export async function collectAudioClips({
+ tracks,
+ mediaAssets,
+}: {
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
+}): Promise<AudioClipSource[]> {
+ const clips: AudioClipSource[] = [];
+ const mediaMap = new Map<string, MediaAsset>(
+ mediaAssets.map((asset) => [asset.id, asset]),
+ );
+ const pendingLibraryClips: Array<Promise<AudioClipSource | null>> = [];
+
+ for (const track of tracks) {
+ const isTrackMuted = canTracktHaveAudio(track) && track.muted;
+
+ for (const element of track.elements) {
+ if (!canElementHaveAudio(element)) continue;
+
+ const isElementMuted =
+ "muted" in element ? (element.muted ?? false) : false;
+ const muted = isTrackMuted || isElementMuted;
+
+ if (element.type === "audio") {
+ if (element.sourceType === "upload") {
+ const mediaAsset = mediaMap.get(element.mediaId);
+ if (!mediaAsset) continue;
+
+ clips.push(
+ collectMediaAudioClip({
+ element,
+ mediaAsset,
+ muted,
+ }),
+ );
+ } else {
+ pendingLibraryClips.push(fetchLibraryAudioClip({ element, muted }));
+ }
+ continue;
+ }
+
+ if (element.type === "video") {
+ const mediaAsset = mediaMap.get(element.mediaId);
+ if (!mediaAsset) continue;
+
+ if (mediaSupportsAudio({ media: mediaAsset })) {
+ clips.push(
+ collectMediaAudioClip({
+ element,
+ mediaAsset,
+ muted,
+ }),
+ );
+ }
+ }
+ }
+ }
+
+ const resolvedLibraryClips = await Promise.all(pendingLibraryClips);
+ for (const clip of resolvedLibraryClips) {
+ if (clip) clips.push(clip);
+ }
+
+ return clips;
+}
+
+export async function createTimelineAudioBuffer({
+ tracks,
+ mediaAssets,
+ duration,
+ sampleRate = 44100,
+ audioContext,
+}: {
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
+ duration: number;
+ sampleRate?: number;
+ audioContext?: AudioContext;
+}): Promise<AudioBuffer | null> {
+ const context = audioContext ?? createAudioContext();
+
+ const audioElements = await collectAudioElements({
+ tracks,
+ mediaAssets,
+ audioContext: context,
+ });
+
+ if (audioElements.length === 0) return null;
+
+ const outputChannels = 2;
+ const outputLength = Math.ceil(duration * sampleRate);
+ const outputBuffer = context.createBuffer(
+ outputChannels,
+ outputLength,
+ sampleRate,
+ );
+
+ for (const element of audioElements) {
+ if (element.muted) continue;
+
+ mixAudioChannels({
+ element,
+ outputBuffer,
+ outputLength,
+ sampleRate,
+ });
+ }
+
+ return outputBuffer;
+}
+
+function mixAudioChannels({
+ element,
+ outputBuffer,
+ outputLength,
+ sampleRate,
+}: {
+ element: CollectedAudioElement;
+ outputBuffer: AudioBuffer;
+ outputLength: number;
+ sampleRate: number;
+}): void {
+ const { buffer, startTime, trimStart, duration: elementDuration } = element;
+
+ const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
+ const sourceLengthSamples = Math.floor(elementDuration * buffer.sampleRate);
+ const outputStartSample = Math.floor(startTime * sampleRate);
+
+ const resampleRatio = sampleRate / buffer.sampleRate;
+ const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
+
+ const outputChannels = 2;
+ for (let channel = 0; channel < outputChannels; channel++) {
+ const outputData = outputBuffer.getChannelData(channel);
+ const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
+ const sourceData = buffer.getChannelData(sourceChannel);
+
+ for (let i = 0; i < resampledLength; i++) {
+ const outputIndex = outputStartSample + i;
+ if (outputIndex >= outputLength) break;
+
+ const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
+ if (sourceIndex >= sourceData.length) break;
+
+ outputData[outputIndex] += sourceData[sourceIndex];
+ }
+ }
+}
diff --git a/apps/web/src/lib/media/media-utils.ts b/apps/web/src/lib/media/media-utils.ts
new file mode 100644
index 00000000..4b487bf2
--- /dev/null
+++ b/apps/web/src/lib/media/media-utils.ts
@@ -0,0 +1,32 @@
+import type { MediaAsset, MediaType } from "@/types/assets";
+
+export const SUPPORTS_AUDIO: readonly MediaType[] = ["audio", "video"];
+
+export function mediaSupportsAudio({
+ media,
+}: {
+ media: MediaAsset | null | undefined;
+}): boolean {
+ if (!media) return false;
+ return SUPPORTS_AUDIO.includes(media.type);
+}
+
+export const getMediaTypeFromFile = ({
+ file,
+}: {
+ file: File;
+}): MediaType | null => {
+ const { type } = file;
+
+ if (type.startsWith("image/")) {
+ return "image";
+ }
+ if (type.startsWith("video/")) {
+ return "video";
+ }
+ if (type.startsWith("audio/")) {
+ return "audio";
+ }
+
+ return null;
+};
diff --git a/apps/web/src/lib/media/mediabunny.ts b/apps/web/src/lib/media/mediabunny.ts
new file mode 100644
index 00000000..11fe9837
--- /dev/null
+++ b/apps/web/src/lib/media/mediabunny.ts
@@ -0,0 +1,226 @@
+import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
+import { collectAudioMixSources } from "@/lib/media/audio";
+import type { TimelineTrack } from "@/types/timeline";
+import type { MediaAsset } from "@/types/assets";
+
+export async function getVideoInfo({
+ videoFile,
+}: {
+ videoFile: File;
+}): Promise<{
+ duration: number;
+ width: number;
+ height: number;
+ fps: number;
+}> {
+ const input = new Input({
+ source: new BlobSource(videoFile),
+ formats: ALL_FORMATS,
+ });
+
+ const duration = await input.computeDuration();
+ const videoTrack = await input.getPrimaryVideoTrack();
+
+ if (!videoTrack) {
+ throw new Error("No video track found in the file");
+ }
+
+ const packetStats = await videoTrack.computePacketStats(100);
+ const fps = packetStats.averagePacketRate;
+
+ return {
+ duration,
+ width: videoTrack.displayWidth,
+ height: videoTrack.displayHeight,
+ fps,
+ };
+}
+
+const SAMPLE_RATE = 44100;
+const NUM_CHANNELS = 2;
+
+export const extractTimelineAudio = async ({
+ tracks,
+ mediaAssets,
+ totalDuration,
+ onProgress,
+}: {
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
+ totalDuration: number;
+ onProgress?: (progress: number) => void;
+}): Promise<Blob> => {
+ if (totalDuration === 0) {
+ return createWavBlob({ samples: new Float32Array(SAMPLE_RATE * 0.1) });
+ }
+
+ const audioMixSources = await collectAudioMixSources({
+ tracks,
+ mediaAssets,
+ });
+
+ if (audioMixSources.length === 0) {
+ const silentDuration = Math.max(1, totalDuration);
+ const silentSamples = new Float32Array(
+ Math.ceil(silentDuration * SAMPLE_RATE) * NUM_CHANNELS,
+ );
+ return createWavBlob({ samples: silentSamples });
+ }
+
+ const totalSamples = Math.ceil(totalDuration * SAMPLE_RATE);
+ const mixBuffers = [
+ new Float32Array(totalSamples),
+ new Float32Array(totalSamples),
+ ];
+
+ for (let i = 0; i < audioMixSources.length; i++) {
+ const source = audioMixSources[i];
+
+ if (onProgress) {
+ onProgress((i / audioMixSources.length) * 90);
+ }
+
+ try {
+ await decodeAndMixAudioSource({
+ source,
+ mixBuffers,
+ totalSamples,
+ });
+ } catch (error) {
+ console.warn(
+ `Failed to process audio source ${source.file.name}:`,
+ error,
+ );
+ }
+ }
+
+ // clamp to prevent clipping
+ for (const channel of mixBuffers) {
+ for (let i = 0; i < channel.length; i++) {
+ channel[i] = Math.max(-1, Math.min(1, channel[i]));
+ }
+ }
+
+ // interleave channels for wav output
+ const interleavedSamples = new Float32Array(totalSamples * NUM_CHANNELS);
+ for (let i = 0; i < totalSamples; i++) {
+ interleavedSamples[i * 2] = mixBuffers[0][i];
+ interleavedSamples[i * 2 + 1] = mixBuffers[1][i];
+ }
+
+ if (onProgress) {
+ onProgress(100);
+ }
+
+ return createWavBlob({ samples: interleavedSamples });
+};
+
+async function decodeAndMixAudioSource({
+ source,
+ mixBuffers,
+ totalSamples,
+}: {
+ source: {
+ file: File;
+ startTime: number;
+ duration: number;
+ trimStart: number;
+ };
+ mixBuffers: Float32Array[];
+ totalSamples: number;
+}): Promise<void> {
+ const input = new Input({
+ source: new BlobSource(source.file),
+ formats: ALL_FORMATS,
+ });
+
+ const audioTrack = await input.getPrimaryAudioTrack();
+ if (!audioTrack) return;
+
+ const sink = new AudioBufferSink(audioTrack);
+ const trimEnd = source.trimStart + source.duration;
+
+ for await (const { buffer, timestamp } of sink.buffers(
+ source.trimStart,
+ trimEnd,
+ )) {
+ const relativeTime = timestamp - source.trimStart;
+ const outputStartSample = Math.floor(
+ (source.startTime + relativeTime) * SAMPLE_RATE,
+ );
+
+ // resample if needed
+ const resampleRatio = SAMPLE_RATE / buffer.sampleRate;
+
+ for (let ch = 0; ch < NUM_CHANNELS; ch++) {
+ const sourceChannel = Math.min(ch, buffer.numberOfChannels - 1);
+ const channelData = buffer.getChannelData(sourceChannel);
+ const outputChannel = mixBuffers[ch];
+
+ const resampledLength = Math.floor(channelData.length * resampleRatio);
+ for (let i = 0; i < resampledLength; i++) {
+ const outputIdx = outputStartSample + i;
+ if (outputIdx < 0 || outputIdx >= totalSamples) continue;
+
+ const sourceIdx = Math.floor(i / resampleRatio);
+ if (sourceIdx < channelData.length) {
+ outputChannel[outputIdx] += channelData[sourceIdx];
+ }
+ }
+ }
+ }
+}
+
+function createWavBlob({ samples }: { samples: Float32Array }): Blob {
+ const numChannels = NUM_CHANNELS;
+ const bitsPerSample = 16;
+ const bytesPerSample = bitsPerSample / 8;
+ const numSamples = samples.length / numChannels;
+ const dataSize = numSamples * numChannels * bytesPerSample;
+ const buffer = new ArrayBuffer(44 + dataSize);
+ const view = new DataView(buffer);
+
+ // riff header
+ writeString({ view, offset: 0, str: "RIFF" });
+ view.setUint32(4, 36 + dataSize, true);
+ writeString({ view, offset: 8, str: "WAVE" });
+
+ // fmt chunk
+ writeString({ view, offset: 12, str: "fmt " });
+ view.setUint32(16, 16, true);
+ view.setUint16(20, 1, true);
+ view.setUint16(22, numChannels, true);
+ view.setUint32(24, SAMPLE_RATE, true);
+ view.setUint32(28, SAMPLE_RATE * numChannels * bytesPerSample, true);
+ view.setUint16(32, numChannels * bytesPerSample, true);
+ view.setUint16(34, bitsPerSample, true);
+
+ // data chunk
+ writeString({ view, offset: 36, str: "data" });
+ view.setUint32(40, dataSize, true);
+
+ // convert float32 to int16 and write
+ let offset = 44;
+ for (let i = 0; i < samples.length; i++) {
+ const sample = Math.max(-1, Math.min(1, samples[i]));
+ const int16 = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
+ view.setInt16(offset, int16, true);
+ offset += 2;
+ }
+
+ return new Blob([buffer], { type: "audio/wav" });
+}
+
+function writeString({
+ view,
+ offset,
+ str,
+}: {
+ view: DataView;
+ offset: number;
+ str: string;
+}): void {
+ for (let i = 0; i < str.length; i++) {
+ view.setUint8(offset + i, str.charCodeAt(i));
+ }
+}
diff --git a/apps/web/src/lib/media/processing.ts b/apps/web/src/lib/media/processing.ts
new file mode 100644
index 00000000..8f9c045c
--- /dev/null
+++ b/apps/web/src/lib/media/processing.ts
@@ -0,0 +1,282 @@
+import { toast } from "sonner";
+import type { MediaAsset } from "@/types/assets";
+import { getMediaTypeFromFile } from "@/lib/media/media-utils";
+import { getVideoInfo } from "./mediabunny";
+import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
+
+export interface ProcessedMediaAsset extends Omit<MediaAsset, "id"> {}
+
+const THUMBNAIL_MAX_WIDTH = 1280;
+const THUMBNAIL_MAX_HEIGHT = 720;
+
+const getThumbnailSize = ({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): { width: number; height: number } => {
+ const aspectRatio = width / height;
+ let targetWidth = width;
+ let targetHeight = height;
+
+ if (targetWidth > THUMBNAIL_MAX_WIDTH) {
+ targetWidth = THUMBNAIL_MAX_WIDTH;
+ targetHeight = Math.round(targetWidth / aspectRatio);
+ }
+ if (targetHeight > THUMBNAIL_MAX_HEIGHT) {
+ targetHeight = THUMBNAIL_MAX_HEIGHT;
+ targetWidth = Math.round(targetHeight * aspectRatio);
+ }
+
+ return { width: targetWidth, height: targetHeight };
+};
+
+const renderToThumbnailDataUrl = ({
+ width,
+ height,
+ draw,
+}: {
+ width: number;
+ height: number;
+ draw: ({
+ context,
+ width,
+ height,
+ }: {
+ context: CanvasRenderingContext2D;
+ width: number;
+ height: number;
+ }) => void;
+}): string => {
+ const size = getThumbnailSize({ width, height });
+ const canvas = document.createElement("canvas");
+ canvas.width = size.width;
+ canvas.height = size.height;
+ const context = canvas.getContext("2d");
+
+ if (!context) {
+ throw new Error("Could not get canvas context");
+ }
+
+ draw({ context, width: size.width, height: size.height });
+ return canvas.toDataURL("image/jpeg", 0.8);
+};
+
+export async function generateThumbnail({
+ videoFile,
+ timeInSeconds,
+}: {
+ videoFile: File;
+ timeInSeconds: number;
+}): Promise<string> {
+ const input = new Input({
+ source: new BlobSource(videoFile),
+ formats: ALL_FORMATS,
+ });
+
+ const videoTrack = await input.getPrimaryVideoTrack();
+ if (!videoTrack) {
+ throw new Error("No video track found in the file");
+ }
+
+ const canDecode = await videoTrack.canDecode();
+ if (!canDecode) {
+ throw new Error("Video codec not supported for decoding");
+ }
+
+ const sink = new VideoSampleSink(videoTrack);
+
+ const frame = await sink.getSample(timeInSeconds);
+
+ if (!frame) {
+ throw new Error("Could not get frame at specified time");
+ }
+
+ try {
+ return renderToThumbnailDataUrl({
+ width: videoTrack.displayWidth,
+ height: videoTrack.displayHeight,
+ draw: ({ context, width, height }) => {
+ frame.draw(context, 0, 0, width, height);
+ },
+ });
+ } finally {
+ frame.close();
+ }
+}
+
+export async function generateImageThumbnail({
+ imageFile,
+}: {
+ imageFile: File;
+}): Promise<string> {
+ return new Promise((resolve, reject) => {
+ const image = new window.Image();
+ const objectUrl = URL.createObjectURL(imageFile);
+
+ image.addEventListener("load", () => {
+ try {
+ const dataUrl = renderToThumbnailDataUrl({
+ width: image.naturalWidth,
+ height: image.naturalHeight,
+ draw: ({ context, width, height }) => {
+ context.drawImage(image, 0, 0, width, height);
+ },
+ });
+ resolve(dataUrl);
+ } catch (error) {
+ reject(
+ error instanceof Error ? error : new Error("Could not render image"),
+ );
+ } finally {
+ URL.revokeObjectURL(objectUrl);
+ image.remove();
+ }
+ });
+
+ image.addEventListener("error", () => {
+ URL.revokeObjectURL(objectUrl);
+ image.remove();
+ reject(new Error("Could not load image"));
+ });
+
+ image.src = objectUrl;
+ });
+}
+
+export async function processMediaAssets({
+ files,
+ onProgress,
+}: {
+ files: FileList | File[];
+ onProgress?: ({ progress }: { progress: number }) => void;
+}): Promise<ProcessedMediaAsset[]> {
+ const fileArray = Array.from(files);
+ const processedAssets: ProcessedMediaAsset[] = [];
+
+ const total = fileArray.length;
+ let completed = 0;
+
+ for (const file of fileArray) {
+ const fileType = getMediaTypeFromFile({ file });
+
+ if (!fileType) {
+ toast.error(`Unsupported file type: ${file.name}`);
+ continue;
+ }
+
+ const url = URL.createObjectURL(file);
+ let thumbnailUrl: string | undefined;
+ let duration: number | undefined;
+ let width: number | undefined;
+ let height: number | undefined;
+ let fps: number | undefined;
+
+ try {
+ if (fileType === "image") {
+ const dimensions = await getImageDimensions({ file });
+ width = dimensions.width;
+ height = dimensions.height;
+ thumbnailUrl = await generateImageThumbnail({ imageFile: file });
+ } else if (fileType === "video") {
+ try {
+ const videoInfo = await getVideoInfo({ videoFile: file });
+ duration = videoInfo.duration;
+ width = videoInfo.width;
+ height = videoInfo.height;
+ fps = Number.isFinite(videoInfo.fps)
+ ? Math.round(videoInfo.fps * 1000) / 1000
+ : undefined;
+
+ thumbnailUrl = await generateThumbnail({
+ videoFile: file,
+ timeInSeconds: 1,
+ });
+ } catch (error) {
+ console.warn("Video processing failed", error);
+ }
+ } else if (fileType === "audio") {
+ // For audio, we don't set width/height/fps (they'll be undefined)
+ duration = await getMediaDuration({ file });
+ }
+
+ processedAssets.push({
+ name: file.name,
+ type: fileType,
+ file,
+ url,
+ thumbnailUrl,
+ duration,
+ width,
+ height,
+ fps,
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ completed += 1;
+ if (onProgress) {
+ const percent = Math.round((completed / total) * 100);
+ onProgress({ progress: percent });
+ }
+ } catch (error) {
+ console.error("Error processing file:", file.name, error);
+ toast.error(`Failed to process ${file.name}`);
+ URL.revokeObjectURL(url); // Clean up on error
+ }
+ }
+
+ return processedAssets;
+}
+
+const getImageDimensions = ({
+ file,
+}: {
+ file: File;
+}): Promise<{ width: number; height: number }> => {
+ return new Promise((resolve, reject) => {
+ const img = new window.Image();
+ const objectUrl = URL.createObjectURL(file);
+
+ img.addEventListener("load", () => {
+ const width = img.naturalWidth;
+ const height = img.naturalHeight;
+ resolve({ width, height });
+ URL.revokeObjectURL(objectUrl);
+ img.remove();
+ });
+
+ img.addEventListener("error", () => {
+ reject(new Error("Could not load image"));
+ URL.revokeObjectURL(objectUrl);
+ img.remove();
+ });
+
+ img.src = objectUrl;
+ });
+};
+
+const getMediaDuration = ({ file }: { file: File }): Promise<number> => {
+ return new Promise((resolve, reject) => {
+ const element = document.createElement(
+ file.type.startsWith("video/") ? "video" : "audio",
+ ) as HTMLVideoElement;
+ const objectUrl = URL.createObjectURL(file);
+
+ element.addEventListener("loadedmetadata", () => {
+ resolve(element.duration);
+ URL.revokeObjectURL(objectUrl);
+ element.remove();
+ });
+
+ element.addEventListener("error", () => {
+ reject(new Error("Could not load media"));
+ URL.revokeObjectURL(objectUrl);
+ element.remove();
+ });
+
+ element.src = objectUrl;
+ element.load();
+ });
+};
diff --git a/apps/web/src/lib/mediabunny-utils.ts b/apps/web/src/lib/mediabunny-utils.ts
deleted file mode 100644
index fcb3da0f..00000000
--- a/apps/web/src/lib/mediabunny-utils.ts
+++ /dev/null
@@ -1,311 +0,0 @@
-import { FFmpeg } from "@ffmpeg/ffmpeg";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
-
-let ffmpeg: FFmpeg | null = null;
-
-export const initFFmpeg = async (): Promise<FFmpeg> => {
- if (ffmpeg) return ffmpeg;
-
- ffmpeg = new FFmpeg();
- await ffmpeg.load(); // Use default config
-
- return ffmpeg;
-};
-
-export async function generateThumbnail({
- videoFile,
- timeInSeconds,
-}: {
- videoFile: File;
- timeInSeconds: number;
-}): Promise<string> {
- const input = new Input({
- source: new BlobSource(videoFile),
- formats: ALL_FORMATS,
- });
-
- const videoTrack = await input.getPrimaryVideoTrack();
- if (!videoTrack) {
- throw new Error("No video track found in the file");
- }
-
- // Check if we can decode this video
- const canDecode = await videoTrack.canDecode();
- if (!canDecode) {
- throw new Error("Video codec not supported for decoding");
- }
-
- const sink = new VideoSampleSink(videoTrack);
-
- const frame = await sink.getSample(timeInSeconds);
-
- if (!frame) {
- throw new Error("Could not get frame at specified time");
- }
-
- const canvas = document.createElement("canvas");
- canvas.width = 320;
- canvas.height = 240;
- const ctx = canvas.getContext("2d");
-
- if (!ctx) {
- throw new Error("Could not get canvas context");
- }
-
- frame.draw(ctx, 0, 0, 320, 240);
-
- return new Promise((resolve, reject) => {
- canvas.toBlob(
- (blob) => {
- if (blob) {
- resolve(URL.createObjectURL(blob));
- } else {
- reject(new Error("Failed to create thumbnail blob"));
- }
- },
- "image/jpeg",
- 0.8
- );
- });
-}
-
-export async function getVideoInfo({
- videoFile,
-}: {
- videoFile: File;
-}): Promise<{
- duration: number;
- width: number;
- height: number;
- fps: number;
-}> {
- const input = new Input({
- source: new BlobSource(videoFile),
- formats: ALL_FORMATS,
- });
-
- const duration = await input.computeDuration();
- const videoTrack = await input.getPrimaryVideoTrack();
-
- if (!videoTrack) {
- throw new Error("No video track found in the file");
- }
-
- // Get frame rate from packet statistics
- const packetStats = await videoTrack.computePacketStats(100);
- const fps = packetStats.averagePacketRate;
-
- return {
- duration,
- width: videoTrack.displayWidth,
- height: videoTrack.displayHeight,
- fps,
- };
-}
-
-// Audio mixing for timeline - keeping FFmpeg for now due to complexity
-// TODO: Replace with Mediabunny audio processing when implementing canvas preview
-export const extractTimelineAudio = async (
- onProgress?: (progress: number) => void
-): Promise<Blob> => {
- // Create fresh FFmpeg instance for this operation
- const ffmpeg = new FFmpeg();
-
- try {
- await ffmpeg.load();
- } catch (error) {
- console.error("Failed to load fresh FFmpeg instance:", error);
- throw new Error("Unable to initialize audio processing. Please try again.");
- }
-
- const timeline = useTimelineStore.getState();
- const mediaStore = useMediaStore.getState();
-
- const tracks = timeline.tracks;
- const totalDuration = timeline.getTotalDuration();
-
- if (totalDuration === 0) {
- const emptyAudioData = new ArrayBuffer(44);
- return new Blob([emptyAudioData], { type: "audio/wav" });
- }
-
- if (onProgress) {
- ffmpeg.on("progress", ({ progress }) => {
- onProgress(progress * 100);
- });
- }
-
- const audioElements: Array<{
- file: File;
- startTime: number;
- duration: number;
- trimStart: number;
- trimEnd: number;
- trackMuted: boolean;
- }> = [];
-
- for (const track of tracks) {
- if (track.muted) continue;
-
- for (const element of track.elements) {
- if (element.type === "media") {
- const mediaFile = mediaStore.mediaFiles.find(
- (m) => m.id === element.mediaId
- );
- if (!mediaFile) continue;
-
- if (mediaFile.type === "video" || mediaFile.type === "audio") {
- audioElements.push({
- file: mediaFile.file,
- startTime: element.startTime,
- duration: element.duration,
- trimStart: element.trimStart,
- trimEnd: element.trimEnd,
- trackMuted: track.muted || false,
- });
- }
- }
- }
- }
-
- if (audioElements.length === 0) {
- // Return silent audio if no audio elements
- const silentDuration = Math.max(1, totalDuration); // At least 1 second
- try {
- const silentAudio = await generateSilentAudio(silentDuration);
- return silentAudio;
- } catch (error) {
- console.error("Failed to generate silent audio:", error);
- throw new Error("Unable to generate audio for empty timeline.");
- }
- }
-
- // Create a complex filter to mix all audio sources
- const inputFiles: string[] = [];
- const filterInputs: string[] = [];
-
- try {
- for (let i = 0; i < audioElements.length; i++) {
- const element = audioElements[i];
- const inputName = `input_${i}.${element.file.name.split(".").pop()}`;
- inputFiles.push(inputName);
-
- try {
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await element.file.arrayBuffer())
- );
- } catch (error) {
- console.error(`Failed to write file ${element.file.name}:`, error);
- throw new Error(
- `Unable to process file: ${element.file.name}. The file may be corrupted or in an unsupported format.`
- );
- }
-
- const actualStart = element.trimStart;
- const actualDuration =
- element.duration - element.trimStart - element.trimEnd;
-
- const filterName = `audio_${i}`;
- filterInputs.push(
- `[${i}:a]atrim=start=${actualStart}:duration=${actualDuration},asetpts=PTS-STARTPTS,adelay=${element.startTime * 1000}|${element.startTime * 1000}[${filterName}]`
- );
- }
-
- const mixFilter =
- audioElements.length === 1
- ? `[audio_0]aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[out]`
- : `${filterInputs.map((_, i) => `[audio_${i}]`).join("")}amix=inputs=${audioElements.length}:duration=longest:dropout_transition=2,aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[out]`;
-
- const complexFilter = [...filterInputs, mixFilter].join(";");
- const outputName = "timeline_audio.wav";
-
- const ffmpegArgs = [
- ...inputFiles.flatMap((name) => ["-i", name]),
- "-filter_complex",
- complexFilter,
- "-map",
- "[out]",
- "-t",
- totalDuration.toString(),
- "-c:a",
- "pcm_s16le",
- "-ar",
- "44100",
- outputName,
- ];
-
- try {
- await ffmpeg.exec(ffmpegArgs);
- } catch (error) {
- console.error("FFmpeg execution failed:", error);
- throw new Error(
- "Audio processing failed. Some audio files may be corrupted or incompatible."
- );
- }
-
- const data = await ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: "audio/wav" });
-
- return blob;
- } catch (error) {
- for (const inputFile of inputFiles) {
- try {
- await ffmpeg.deleteFile(inputFile);
- } catch (cleanupError) {
- console.warn(`Failed to cleanup file ${inputFile}:`, cleanupError);
- }
- }
- try {
- await ffmpeg.deleteFile("timeline_audio.wav");
- } catch (cleanupError) {
- console.warn("Failed to cleanup output file:", cleanupError);
- }
-
- throw error;
- } finally {
- for (const inputFile of inputFiles) {
- try {
- await ffmpeg.deleteFile(inputFile);
- } catch (cleanupError) {}
- }
- try {
- await ffmpeg.deleteFile("timeline_audio.wav");
- } catch (cleanupError) {}
- }
-};
-
-const generateSilentAudio = async (durationSeconds: number): Promise<Blob> => {
- const ffmpeg = await initFFmpeg();
- const outputName = "silent.wav";
-
- try {
- await ffmpeg.exec([
- "-f",
- "lavfi",
- "-i",
- `anullsrc=channel_layout=stereo:sample_rate=44100`,
- "-t",
- durationSeconds.toString(),
- "-c:a",
- "pcm_s16le",
- outputName,
- ]);
-
- const data = await ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: "audio/wav" });
-
- return blob;
- } catch (error) {
- console.error("Failed to generate silent audio:", error);
- throw error;
- } finally {
- try {
- await ffmpeg.deleteFile(outputName);
- } catch (cleanupError) {
- // Silent cleanup
- }
- }
-};
diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts
index 92f70805..d18889af 100644
--- a/apps/web/src/lib/rate-limit.ts
+++ b/apps/web/src/lib/rate-limit.ts
@@ -1,16 +1,21 @@
-// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
-import { env } from "@/env";
+import { webEnv } from "@opencut/env/web";
const redis = new Redis({
- url: env.UPSTASH_REDIS_REST_URL,
- token: env.UPSTASH_REDIS_REST_TOKEN,
+ url: webEnv.UPSTASH_REDIS_REST_URL,
+ token: webEnv.UPSTASH_REDIS_REST_TOKEN,
});
export const baseRateLimit = new Ratelimit({
- redis,
- limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
- analytics: true,
- prefix: "rate-limit",
+ redis,
+ limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
+ analytics: true,
+ prefix: "rate-limit",
});
+
+export async function checkRateLimit({ request }: { request: Request }) {
+ const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
+ const { success } = await baseRateLimit.limit(ip);
+ return { success, limited: !success };
+}
diff --git a/apps/web/src/lib/scenes.ts b/apps/web/src/lib/scenes.ts
new file mode 100644
index 00000000..8c043e83
--- /dev/null
+++ b/apps/web/src/lib/scenes.ts
@@ -0,0 +1,103 @@
+import type { TScene } from "@/types/timeline";
+import { generateUUID } from "@/utils/id";
+import { calculateTotalDuration } from "@/lib/timeline";
+import { ensureMainTrack } from "@/lib/timeline/track-utils";
+
+export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
+ return scenes.find((scene) => scene.isMain) || null;
+}
+
+export function ensureMainScene({ scenes }: { scenes: TScene[] }): TScene[] {
+ const hasMain = scenes.some((scene) => scene.isMain);
+ if (!hasMain) {
+ const mainScene = buildDefaultScene({ name: "Main scene", isMain: true });
+ return [mainScene, ...scenes];
+ }
+ return scenes;
+}
+
+export function buildDefaultScene({
+ name,
+ isMain,
+}: {
+ name: string;
+ isMain: boolean;
+}): TScene {
+ const tracks = ensureMainTrack({ tracks: [] });
+ return {
+ id: generateUUID(),
+ name,
+ isMain,
+ tracks,
+ bookmarks: [],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+}
+
+export function canDeleteScene({ scene }: { scene: TScene }): {
+ canDelete: boolean;
+ reason?: string;
+} {
+ if (scene.isMain) {
+ return { canDelete: false, reason: "Cannot delete main scene" };
+ }
+ return { canDelete: true };
+}
+
+export function getFallbackSceneAfterDelete({
+ scenes,
+ deletedSceneId,
+ currentSceneId,
+}: {
+ scenes: TScene[];
+ deletedSceneId: string;
+ currentSceneId: string | null;
+}): TScene | null {
+ if (currentSceneId !== deletedSceneId) {
+ return scenes.find((s) => s.id === currentSceneId) || null;
+ }
+ return getMainScene({ scenes });
+}
+
+export function findCurrentScene({
+ scenes,
+ currentSceneId,
+}: {
+ scenes: TScene[];
+ currentSceneId: string;
+}): TScene | null {
+ return (
+ scenes.find((s) => s.id === currentSceneId) ||
+ getMainScene({ scenes }) ||
+ scenes[0] ||
+ null
+ );
+}
+
+export function getProjectDurationFromScenes({
+ scenes,
+}: {
+ scenes: TScene[];
+}): number {
+ const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
+ if (!mainScene) {
+ return 0;
+ }
+
+ return calculateTotalDuration({ tracks: mainScene.tracks });
+}
+
+export function updateSceneInArray({
+ scenes,
+ sceneId,
+ updates,
+}: {
+ scenes: TScene[];
+ sceneId: string;
+ updates: Partial<TScene>;
+}): TScene[] {
+ return scenes.map((scene) =>
+ scene.id === sceneId ? { ...scene, ...updates } : scene,
+ );
+}
diff --git a/apps/web/src/lib/schemas/waitlist.ts b/apps/web/src/lib/schemas/waitlist.ts
deleted file mode 100644
index c45540d5..00000000
--- a/apps/web/src/lib/schemas/waitlist.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { z } from "zod";
-
-export const exportWaitlistSchema = z.object({
- email: z.string().email().max(320),
-});
-
-export const exportWaitlistResponseSchema = z.object({
- success: z.boolean(),
- alreadySubscribed: z.boolean().optional(),
-});
-
-export type ExportWaitlistInput = z.infer<typeof exportWaitlistSchema>;
-export type ExportWaitlistResponse = z.infer<
- typeof exportWaitlistResponseSchema
->;
diff --git a/apps/web/src/lib/storage/indexeddb-adapter.ts b/apps/web/src/lib/storage/indexeddb-adapter.ts
deleted file mode 100644
index 597305c7..00000000
--- a/apps/web/src/lib/storage/indexeddb-adapter.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import { StorageAdapter } from "./types";
-
-export class IndexedDBAdapter<T> implements StorageAdapter<T> {
- private dbName: string;
- private storeName: string;
- private version: number;
-
- constructor(dbName: string, storeName: string, version = 1) {
- this.dbName = dbName;
- this.storeName = storeName;
- this.version = version;
- }
-
- private async getDB(): Promise<IDBDatabase> {
- return new Promise((resolve, reject) => {
- const request = indexedDB.open(this.dbName, this.version);
-
- request.onerror = () => reject(request.error);
- request.onsuccess = () => resolve(request.result);
-
- request.onupgradeneeded = (event) => {
- const db = (event.target as IDBOpenDBRequest).result;
- if (!db.objectStoreNames.contains(this.storeName)) {
- db.createObjectStore(this.storeName, { keyPath: "id" });
- }
- };
- });
- }
-
- async get(key: string): Promise<T | null> {
- const db = await this.getDB();
- const transaction = db.transaction([this.storeName], "readonly");
- const store = transaction.objectStore(this.storeName);
-
- return new Promise((resolve, reject) => {
- const request = store.get(key);
- request.onerror = () => reject(request.error);
- request.onsuccess = () => resolve(request.result || null);
- });
- }
-
- async set(key: string, value: T): Promise<void> {
- const db = await this.getDB();
- const transaction = db.transaction([this.storeName], "readwrite");
- const store = transaction.objectStore(this.storeName);
-
- return new Promise((resolve, reject) => {
- const request = store.put({ id: key, ...value });
- request.onerror = () => reject(request.error);
- request.onsuccess = () => resolve();
- });
- }
-
- async remove(key: string): Promise<void> {
- const db = await this.getDB();
- const transaction = db.transaction([this.storeName], "readwrite");
- const store = transaction.objectStore(this.storeName);
-
- return new Promise((resolve, reject) => {
- const request = store.delete(key);
- request.onerror = () => reject(request.error);
- request.onsuccess = () => resolve();
- });
- }
-
- async list(): Promise<string[]> {
- const db = await this.getDB();
- const transaction = db.transaction([this.storeName], "readonly");
- const store = transaction.objectStore(this.storeName);
-
- return new Promise((resolve, reject) => {
- const request = store.getAllKeys();
- request.onerror = () => reject(request.error);
- request.onsuccess = () => resolve(request.result as string[]);
- });
- }
-
- async clear(): Promise<void> {
- const db = await this.getDB();
- const transaction = db.transaction([this.storeName], "readwrite");
- const store = transaction.objectStore(this.storeName);
-
- return new Promise((resolve, reject) => {
- const request = store.clear();
- request.onerror = () => reject(request.error);
- request.onsuccess = () => resolve();
- });
- }
-}
diff --git a/apps/web/src/lib/storage/opfs-adapter.ts b/apps/web/src/lib/storage/opfs-adapter.ts
deleted file mode 100644
index 87b5c886..00000000
--- a/apps/web/src/lib/storage/opfs-adapter.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { StorageAdapter } from "./types";
-
-export class OPFSAdapter implements StorageAdapter<File> {
- private directoryName: string;
-
- constructor(directoryName = "media") {
- this.directoryName = directoryName;
- }
-
- private async getDirectory(): Promise<FileSystemDirectoryHandle> {
- const opfsRoot = await navigator.storage.getDirectory();
- return await opfsRoot.getDirectoryHandle(this.directoryName, {
- create: true,
- });
- }
-
- async get(key: string): Promise<File | null> {
- try {
- const directory = await this.getDirectory();
- const fileHandle = await directory.getFileHandle(key);
- return await fileHandle.getFile();
- } catch (error) {
- if ((error as Error).name === "NotFoundError") {
- return null;
- }
- throw error;
- }
- }
-
- async set(key: string, file: File): Promise<void> {
- const directory = await this.getDirectory();
- const fileHandle = await directory.getFileHandle(key, { create: true });
- const writable = await fileHandle.createWritable();
-
- await writable.write(file);
- await writable.close();
- }
-
- async remove(key: string): Promise<void> {
- try {
- const directory = await this.getDirectory();
- await directory.removeEntry(key);
- } catch (error) {
- if ((error as Error).name !== "NotFoundError") {
- throw error;
- }
- }
- }
-
- async list(): Promise<string[]> {
- const directory = await this.getDirectory();
- const keys: string[] = [];
-
- for await (const name of directory.keys()) {
- keys.push(name);
- }
-
- return keys;
- }
-
- async clear(): Promise<void> {
- const directory = await this.getDirectory();
-
- for await (const name of directory.keys()) {
- await directory.removeEntry(name);
- }
- }
-
- // Helper method to check OPFS support
- static isSupported(): boolean {
- return "storage" in navigator && "getDirectory" in navigator.storage;
- }
-}
diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts
deleted file mode 100644
index 2688f6e8..00000000
--- a/apps/web/src/lib/storage/storage-service.ts
+++ /dev/null
@@ -1,480 +0,0 @@
-import { TProject } from "@/types/project";
-import { MediaFile } from "@/types/media";
-import { IndexedDBAdapter } from "./indexeddb-adapter";
-import { OPFSAdapter } from "./opfs-adapter";
-import {
- MediaFileData,
- StorageConfig,
- SerializedProject,
- SerializedScene,
- TimelineData,
-} from "./types";
-import { TimelineTrack } from "@/types/timeline";
-import { SavedSoundsData, SavedSound, SoundEffect } from "@/types/sounds";
-
-class StorageService {
- private projectsAdapter: IndexedDBAdapter<SerializedProject>;
- private savedSoundsAdapter: IndexedDBAdapter<SavedSoundsData>;
- private config: StorageConfig;
-
- constructor() {
- this.config = {
- projectsDb: "video-editor-projects",
- mediaDb: "video-editor-media",
- timelineDb: "video-editor-timelines",
- savedSoundsDb: "video-editor-saved-sounds",
- version: 1,
- };
-
- this.projectsAdapter = new IndexedDBAdapter<SerializedProject>(
- this.config.projectsDb,
- "projects",
- this.config.version
- );
-
- this.savedSoundsAdapter = new IndexedDBAdapter<SavedSoundsData>(
- this.config.savedSoundsDb,
- "saved-sounds",
- this.config.version
- );
- }
-
- // Helper to get project-specific media adapters
- private getProjectMediaAdapters({ projectId }: { projectId: string }) {
- const mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>(
- `${this.config.mediaDb}-${projectId}`,
- "media-metadata",
- this.config.version
- );
-
- const mediaFilesAdapter = new OPFSAdapter(`media-files-${projectId}`);
-
- return { mediaMetadataAdapter, mediaFilesAdapter };
- }
-
- // Helper to get project-specific timeline adapter
- private getProjectTimelineAdapter({
- projectId,
- sceneId,
- }: {
- projectId: string;
- sceneId?: string;
- }) {
- const dbName = sceneId
- ? `${this.config.timelineDb}-${projectId}-${sceneId}`
- : `${this.config.timelineDb}-${projectId}`;
-
- return new IndexedDBAdapter<TimelineData>(
- dbName,
- "timeline",
- this.config.version
- );
- }
-
- // Project operations
- async saveProject({ project }: { project: TProject }): Promise<void> {
- // Convert TProject to serializable format
- const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({
- id: scene.id,
- name: scene.name,
- isMain: scene.isMain,
- createdAt: scene.createdAt.toISOString(),
- updatedAt: scene.updatedAt.toISOString(),
- }));
-
- const serializedProject: SerializedProject = {
- id: project.id,
- name: project.name,
- thumbnail: project.thumbnail,
- createdAt: project.createdAt.toISOString(),
- updatedAt: project.updatedAt.toISOString(),
- scenes: serializedScenes,
- currentSceneId: project.currentSceneId,
- backgroundColor: project.backgroundColor,
- backgroundType: project.backgroundType,
- blurIntensity: project.blurIntensity,
- bookmarks: project.bookmarks,
- fps: project.fps,
- canvasSize: project.canvasSize,
- canvasMode: project.canvasMode,
- };
-
- await this.projectsAdapter.set(project.id, serializedProject);
- }
-
- async loadProject({ id }: { id: string }): Promise<TProject | null> {
- const serializedProject = await this.projectsAdapter.get(id);
-
- if (!serializedProject) return null;
-
- // Now convert serialized scenes back to Scene objects
- const scenes =
- serializedProject.scenes?.map((scene) => ({
- id: scene.id,
- name: scene.name,
- isMain: scene.isMain,
- createdAt: new Date(scene.createdAt),
- updatedAt: new Date(scene.updatedAt),
- })) || [];
-
- // Convert back to TProject format
- const project = {
- id: serializedProject.id,
- name: serializedProject.name,
- thumbnail: serializedProject.thumbnail,
- createdAt: new Date(serializedProject.createdAt),
- updatedAt: new Date(serializedProject.updatedAt),
- scenes,
- currentSceneId: serializedProject.currentSceneId || "",
- backgroundColor: serializedProject.backgroundColor,
- backgroundType: serializedProject.backgroundType,
- blurIntensity: serializedProject.blurIntensity,
- bookmarks: serializedProject.bookmarks,
- fps: serializedProject.fps,
- canvasSize: serializedProject.canvasSize,
- canvasMode: serializedProject.canvasMode,
- };
- return project;
- }
-
- async loadAllProjects(): Promise<TProject[]> {
- const projectIds = await this.projectsAdapter.list();
- const projects: TProject[] = [];
-
- for (const id of projectIds) {
- const project = await this.loadProject({ id });
- if (project) {
- projects.push(project);
- }
- }
-
- // Sort by last updated (most recent first)
- return projects.sort(
- (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
- );
- }
-
- async deleteProject({ id }: { id: string }): Promise<void> {
- await this.projectsAdapter.remove(id);
- }
-
- // Media operations
- async saveMediaFile({
- projectId,
- mediaItem,
- }: {
- projectId: string;
- mediaItem: MediaFile;
- }): Promise<void> {
- const { mediaMetadataAdapter, mediaFilesAdapter } =
- this.getProjectMediaAdapters({ projectId });
-
- // Save file to project-specific OPFS
- await mediaFilesAdapter.set(mediaItem.id, mediaItem.file);
-
- // Save metadata to project-specific IndexedDB
- const metadata: MediaFileData = {
- id: mediaItem.id,
- name: mediaItem.name,
- type: mediaItem.type,
- size: mediaItem.file.size,
- lastModified: mediaItem.file.lastModified,
- width: mediaItem.width,
- height: mediaItem.height,
- duration: mediaItem.duration,
- ephemeral: mediaItem.ephemeral,
- };
-
- await mediaMetadataAdapter.set(mediaItem.id, metadata);
- }
-
- async loadMediaFile({
- projectId,
- id,
- }: {
- projectId: string;
- id: string;
- }): Promise<MediaFile | null> {
- const { mediaMetadataAdapter, mediaFilesAdapter } =
- this.getProjectMediaAdapters({ projectId });
-
- const [file, metadata] = await Promise.all([
- mediaFilesAdapter.get(id),
- mediaMetadataAdapter.get(id),
- ]);
-
- if (!file || !metadata) return null;
-
- let url: string;
- if (metadata.type === "image" && (!file.type || file.type === "")) {
- try {
- const text = await file.text();
- if (text.trim().startsWith("<svg")) {
- const svgBlob = new Blob([text], { type: "image/svg+xml" });
- url = URL.createObjectURL(svgBlob);
- } else {
- url = URL.createObjectURL(file);
- }
- } catch {
- url = URL.createObjectURL(file);
- }
- } else {
- url = URL.createObjectURL(file);
- }
-
- return {
- id: metadata.id,
- name: metadata.name,
- type: metadata.type,
- file,
- url,
- width: metadata.width,
- height: metadata.height,
- duration: metadata.duration,
- ephemeral: metadata.ephemeral,
- };
- }
-
- async loadAllMediaFiles({
- projectId,
- }: {
- projectId: string;
- }): Promise<MediaFile[]> {
- const { mediaMetadataAdapter } = this.getProjectMediaAdapters({
- projectId,
- });
-
- const mediaIds = await mediaMetadataAdapter.list();
- const mediaItems: MediaFile[] = [];
-
- for (const id of mediaIds) {
- const item = await this.loadMediaFile({ projectId, id });
- if (item) {
- mediaItems.push(item);
- }
- }
-
- return mediaItems;
- }
-
- async deleteMediaFile({
- projectId,
- id,
- }: {
- projectId: string;
- id: string;
- }): Promise<void> {
- const { mediaMetadataAdapter, mediaFilesAdapter } =
- this.getProjectMediaAdapters({ projectId });
-
- await Promise.all([
- mediaFilesAdapter.remove(id),
- mediaMetadataAdapter.remove(id),
- ]);
- }
-
- async deleteProjectMedia({
- projectId,
- }: {
- projectId: string;
- }): Promise<void> {
- const { mediaMetadataAdapter, mediaFilesAdapter } =
- this.getProjectMediaAdapters({ projectId });
-
- await Promise.all([
- mediaMetadataAdapter.clear(),
- mediaFilesAdapter.clear(),
- ]);
- }
-
- // Timeline operations - supports both legacy and scene-based storage
- async saveTimeline({
- projectId,
- tracks,
- sceneId,
- }: {
- projectId: string;
- tracks: TimelineTrack[];
- sceneId?: string;
- }): Promise<void> {
- const timelineAdapter = this.getProjectTimelineAdapter({
- projectId,
- sceneId,
- });
- const timelineData: TimelineData = {
- tracks,
- lastModified: new Date().toISOString(),
- };
- await timelineAdapter.set("timeline", timelineData);
- }
-
- async loadTimeline({
- projectId,
- sceneId,
- }: {
- projectId: string;
- sceneId?: string;
- }): Promise<TimelineTrack[] | null> {
- const timelineAdapter = this.getProjectTimelineAdapter({
- projectId,
- sceneId,
- });
- const timelineData = await timelineAdapter.get("timeline");
- return timelineData ? timelineData.tracks : null;
- }
-
- async deleteProjectTimeline({
- projectId,
- }: {
- projectId: string;
- }): Promise<void> {
- const timelineAdapter = this.getProjectTimelineAdapter({ projectId });
- await timelineAdapter.remove("timeline");
- }
-
- // Utility methods
- async clearAllData(): Promise<void> {
- // Clear all projects
- await this.projectsAdapter.clear();
-
- // Note: Project-specific media and timelines will be cleaned up when projects are deleted
- }
-
- async getStorageInfo(): Promise<{
- projects: number;
- isOPFSSupported: boolean;
- isIndexedDBSupported: boolean;
- }> {
- const projectIds = await this.projectsAdapter.list();
-
- return {
- projects: projectIds.length,
- isOPFSSupported: this.isOPFSSupported(),
- isIndexedDBSupported: this.isIndexedDBSupported(),
- };
- }
-
- async getProjectStorageInfo({ projectId }: { projectId: string }): Promise<{
- mediaItems: number;
- hasTimeline: boolean;
- }> {
- const { mediaMetadataAdapter } = this.getProjectMediaAdapters({
- projectId,
- });
- const timelineAdapter = this.getProjectTimelineAdapter({ projectId });
-
- const [mediaIds, timelineData] = await Promise.all([
- mediaMetadataAdapter.list(),
- timelineAdapter.get("timeline"),
- ]);
-
- return {
- mediaItems: mediaIds.length,
- hasTimeline: !!timelineData,
- };
- }
-
- async loadSavedSounds(): Promise<SavedSoundsData> {
- try {
- const savedSoundsData = await this.savedSoundsAdapter.get("user-sounds");
- return (
- savedSoundsData || {
- sounds: [],
- lastModified: new Date().toISOString(),
- }
- );
- } catch (error) {
- console.error("Failed to load saved sounds:", error);
- return { sounds: [], lastModified: new Date().toISOString() };
- }
- }
-
- async saveSoundEffect({
- soundEffect,
- }: {
- soundEffect: SoundEffect;
- }): Promise<void> {
- try {
- const currentData = await this.loadSavedSounds();
-
- // Check if sound is already saved
- if (currentData.sounds.some((sound) => sound.id === soundEffect.id)) {
- return; // Already saved
- }
-
- const savedSound: SavedSound = {
- id: soundEffect.id,
- name: soundEffect.name,
- username: soundEffect.username,
- previewUrl: soundEffect.previewUrl,
- downloadUrl: soundEffect.downloadUrl,
- duration: soundEffect.duration,
- tags: soundEffect.tags,
- license: soundEffect.license,
- savedAt: new Date().toISOString(),
- };
-
- const updatedData: SavedSoundsData = {
- sounds: [...currentData.sounds, savedSound],
- lastModified: new Date().toISOString(),
- };
-
- await this.savedSoundsAdapter.set("user-sounds", updatedData);
- } catch (error) {
- console.error("Failed to save sound effect:", error);
- throw error;
- }
- }
-
- async removeSavedSound({ soundId }: { soundId: number }): Promise<void> {
- try {
- const currentData = await this.loadSavedSounds();
-
- const updatedData: SavedSoundsData = {
- sounds: currentData.sounds.filter((sound) => sound.id !== soundId),
- lastModified: new Date().toISOString(),
- };
-
- await this.savedSoundsAdapter.set("user-sounds", updatedData);
- } catch (error) {
- console.error("Failed to remove saved sound:", error);
- throw error;
- }
- }
-
- async isSoundSaved({ soundId }: { soundId: number }): Promise<boolean> {
- try {
- const currentData = await this.loadSavedSounds();
- return currentData.sounds.some((sound) => sound.id === soundId);
- } catch (error) {
- console.error("Failed to check if sound is saved:", error);
- return false;
- }
- }
-
- async clearSavedSounds(): Promise<void> {
- try {
- await this.savedSoundsAdapter.remove("user-sounds");
- } catch (error) {
- console.error("Failed to clear saved sounds:", error);
- throw error;
- }
- }
-
- // Check browser support
- isOPFSSupported(): boolean {
- return OPFSAdapter.isSupported();
- }
-
- isIndexedDBSupported(): boolean {
- return "indexedDB" in window;
- }
-
- isFullySupported(): boolean {
- return this.isIndexedDBSupported() && this.isOPFSSupported();
- }
-}
-
-// Export singleton instance
-export const storageService = new StorageService();
-export { StorageService };
diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts
deleted file mode 100644
index e66239cd..00000000
--- a/apps/web/src/lib/storage/types.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { TProject, Scene } from "@/types/project";
-import { TimelineTrack } from "@/types/timeline";
-
-export interface StorageAdapter<T> {
- get(key: string): Promise<T | null>;
- set(key: string, value: T): Promise<void>;
- remove(key: string): Promise<void>;
- list(): Promise<string[]>;
- clear(): Promise<void>;
-}
-
-export interface MediaFileData {
- id: string;
- name: string;
- type: "image" | "video" | "audio";
- size: number;
- lastModified: number;
- width?: number;
- height?: number;
- duration?: number;
- ephemeral?: boolean;
- sourceStickerIconName?: string;
- // File will be stored separately in OPFS
-}
-
-// Legacy timeline data, kept for backward compatibility
-export interface TimelineData {
- tracks: TimelineTrack[];
- lastModified: string;
-}
-
-export interface SceneTimelineData {
- sceneId: string;
- tracks: TimelineTrack[];
- lastModified: string;
-}
-
-export type SerializedScene = Omit<Scene, "createdAt" | "updatedAt"> & {
- createdAt: string;
- updatedAt: string;
-};
-
-export interface StorageConfig {
- projectsDb: string;
- mediaDb: string;
- timelineDb: string;
- savedSoundsDb: string;
- version: number;
-}
-
-// Helper type for serialization - converts Date objects to strings
-export type SerializedProject = Omit<
- TProject,
- "createdAt" | "updatedAt" | "scenes"
-> & {
- createdAt: string;
- updatedAt: string;
- scenes: SerializedScene[];
- bookmarks?: number[];
-};
-
-// Extend FileSystemDirectoryHandle with missing async iterator methods
-declare global {
- interface FileSystemDirectoryHandle {
- keys(): AsyncIterableIterator<string>;
- values(): AsyncIterableIterator<FileSystemHandle>;
- entries(): AsyncIterableIterator<[string, FileSystemHandle]>;
- }
-}
diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts
index 20e2ff6d..b6761b0d 100644
--- a/apps/web/src/lib/time.ts
+++ b/apps/web/src/lib/time.ts
@@ -1,137 +1,220 @@
-// Time-related utility functions
+import type { TTimeCode } from "@/types/time";
-import { DEFAULT_FPS } from "@/stores/project-store";
+export function roundToFrame({
+ time,
+ fps,
+}: {
+ time: number;
+ fps: number;
+}): number {
+ return Math.round(time * fps) / fps;
+}
-export type TimeCode = "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF";
+export function formatTimeCode({
+ timeInSeconds,
+ format = "HH:MM:SS:CS",
+ fps,
+}: {
+ timeInSeconds: number;
+ format?: TTimeCode;
+ fps?: number;
+}): string {
+ const hours = Math.floor(timeInSeconds / 3600);
+ const minutes = Math.floor((timeInSeconds % 3600) / 60);
+ const seconds = Math.floor(timeInSeconds % 60);
+ const centiseconds = Math.floor((timeInSeconds % 1) * 100);
+ const frames = fps ? Math.floor((timeInSeconds % 1) * fps) : 0;
-// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS, HH:MM:SS:FF)
-export const formatTimeCode = (
- timeInSeconds: number,
- format: TimeCode = "HH:MM:SS:CS",
- fps = DEFAULT_FPS
-): string => {
- const hours = Math.floor(timeInSeconds / 3600);
- const minutes = Math.floor((timeInSeconds % 3600) / 60);
- const seconds = Math.floor(timeInSeconds % 60);
- const centiseconds = Math.floor((timeInSeconds % 1) * 100);
- const frames = Math.floor((timeInSeconds % 1) * fps);
+ switch (format) {
+ case "MM:SS":
+ return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
+ case "HH:MM:SS":
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
+ case "HH:MM:SS:CS":
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
+ case "HH:MM:SS:FF":
+ if (!fps) throw new Error("FPS is required for HH:MM:SS:FF format");
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
+ }
+}
- switch (format) {
- case "MM:SS":
- return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
- case "HH:MM:SS":
- return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
- case "HH:MM:SS:CS":
- return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
- case "HH:MM:SS:FF":
- return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
- }
-};
+export function parseTimeCode({
+ timeCode,
+ format = "HH:MM:SS:CS",
+ fps,
+}: {
+ timeCode: string;
+ format?: TTimeCode;
+ fps: number;
+}): number | null {
+ if (!timeCode || typeof timeCode !== "string") return null;
-export const parseTimeCode = (
- timeCode: string,
- format: TimeCode = "HH:MM:SS:CS",
- fps = DEFAULT_FPS
-): number | null => {
- if (!timeCode || typeof timeCode !== "string") return null;
+ const cleanTimeCode = timeCode.trim();
- // Remove any extra whitespace
- const cleanTimeCode = timeCode.trim();
+ try {
+ switch (format) {
+ case "MM:SS": {
+ const parts = cleanTimeCode.split(":");
+ if (parts.length !== 2) return null;
+ const [minutes, seconds] = parts.map((part) => parseInt(part, 10));
+ if (Number.isNaN(minutes) || Number.isNaN(seconds)) return null;
+ if (minutes < 0 || seconds < 0 || seconds >= 60) return null;
+ return minutes * 60 + seconds;
+ }
- try {
- switch (format) {
- case "MM:SS": {
- const parts = cleanTimeCode.split(":");
- if (parts.length !== 2) return null;
- const [minutes, seconds] = parts.map((part) => parseInt(part, 10));
- if (isNaN(minutes) || isNaN(seconds)) return null;
- if (minutes < 0 || seconds < 0 || seconds >= 60) return null;
- return minutes * 60 + seconds;
- }
+ case "HH:MM:SS": {
+ const parts = cleanTimeCode.split(":");
+ if (parts.length !== 3) return null;
+ const [hours, minutes, seconds] = parts.map((part) =>
+ parseInt(part, 10),
+ );
+ if (
+ Number.isNaN(hours) ||
+ Number.isNaN(minutes) ||
+ Number.isNaN(seconds)
+ )
+ return null;
+ if (
+ hours < 0 ||
+ minutes < 0 ||
+ seconds < 0 ||
+ minutes >= 60 ||
+ seconds >= 60
+ )
+ return null;
+ return hours * 3600 + minutes * 60 + seconds;
+ }
- case "HH:MM:SS": {
- const parts = cleanTimeCode.split(":");
- if (parts.length !== 3) return null;
- const [hours, minutes, seconds] = parts.map((part) =>
- parseInt(part, 10)
- );
- if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) return null;
- if (
- hours < 0 ||
- minutes < 0 ||
- seconds < 0 ||
- minutes >= 60 ||
- seconds >= 60
- )
- return null;
- return hours * 3600 + minutes * 60 + seconds;
- }
+ case "HH:MM:SS:CS": {
+ const parts = cleanTimeCode.split(":");
+ if (parts.length !== 4) return null;
+ const [hours, minutes, seconds, centiseconds] = parts.map((part) =>
+ parseInt(part, 10),
+ );
+ if (
+ Number.isNaN(hours) ||
+ Number.isNaN(minutes) ||
+ Number.isNaN(seconds) ||
+ Number.isNaN(centiseconds)
+ )
+ return null;
+ if (
+ hours < 0 ||
+ minutes < 0 ||
+ seconds < 0 ||
+ centiseconds < 0 ||
+ minutes >= 60 ||
+ seconds >= 60 ||
+ centiseconds >= 100
+ )
+ return null;
+ return hours * 3600 + minutes * 60 + seconds + centiseconds / 100;
+ }
- case "HH:MM:SS:CS": {
- const parts = cleanTimeCode.split(":");
- if (parts.length !== 4) return null;
- const [hours, minutes, seconds, centiseconds] = parts.map((part) =>
- parseInt(part, 10)
- );
- if (
- isNaN(hours) ||
- isNaN(minutes) ||
- isNaN(seconds) ||
- isNaN(centiseconds)
- )
- return null;
- if (
- hours < 0 ||
- minutes < 0 ||
- seconds < 0 ||
- centiseconds < 0 ||
- minutes >= 60 ||
- seconds >= 60 ||
- centiseconds >= 100
- )
- return null;
- return hours * 3600 + minutes * 60 + seconds + centiseconds / 100;
- }
+ case "HH:MM:SS:FF": {
+ const parts = cleanTimeCode.split(":");
+ if (parts.length !== 4) return null;
+ const [hours, minutes, seconds, frames] = parts.map((part) =>
+ parseInt(part, 10),
+ );
+ if (
+ Number.isNaN(hours) ||
+ Number.isNaN(minutes) ||
+ Number.isNaN(seconds) ||
+ Number.isNaN(frames)
+ )
+ return null;
+ if (
+ hours < 0 ||
+ minutes < 0 ||
+ seconds < 0 ||
+ frames < 0 ||
+ minutes >= 60 ||
+ seconds >= 60 ||
+ frames >= fps
+ )
+ return null;
+ return hours * 3600 + minutes * 60 + seconds + frames / fps;
+ }
+ }
+ } catch {
+ return null;
+ }
+}
- case "HH:MM:SS:FF": {
- const parts = cleanTimeCode.split(":");
- if (parts.length !== 4) return null;
- const [hours, minutes, seconds, frames] = parts.map((part) =>
- parseInt(part, 10)
- );
- if (isNaN(hours) || isNaN(minutes) || isNaN(seconds) || isNaN(frames))
- return null;
- if (
- hours < 0 ||
- minutes < 0 ||
- seconds < 0 ||
- frames < 0 ||
- minutes >= 60 ||
- seconds >= 60 ||
- frames >= fps
- )
- return null;
- return hours * 3600 + minutes * 60 + seconds + frames / fps;
- }
- }
- } catch {
- return null;
- }
+export function guessTimeCodeFormat({
+ timeCode,
+}: {
+ timeCode: string;
+}): TTimeCode | null {
+ if (!timeCode || typeof timeCode !== "string") return null;
- return null;
-};
+ const numbers = timeCode.split(":");
-export const guessTimeCodeFormat = (timeCode: string): TimeCode | null => {
- if (!timeCode || typeof timeCode !== "string") return null;
+ if (!numbers.every((n) => !Number.isNaN(Number(n)))) return null;
- const numbers = timeCode.split(":");
+ if (numbers.length === 2) return "MM:SS";
+ if (numbers.length === 3) return "HH:MM:SS";
+ // todo: how to tell frames apart from cs?
+ if (numbers.length === 4) return "HH:MM:SS:FF";
- if (!numbers.every((n) => !isNaN(Number(n)))) return null;
+ return null;
+}
- if (numbers.length === 2) return "MM:SS";
- if (numbers.length === 3) return "HH:MM:SS";
- // todo: how to tell frames apart from cs?
- if (numbers.length === 4) return "HH:MM:SS:FF";
+export function timeToFrame({
+ time,
+ fps,
+}: {
+ time: number;
+ fps: number;
+}): number {
+ return Math.round(time * fps);
+}
- return null;
-};
+export function frameToTime({
+ frame,
+ fps,
+}: {
+ frame: number;
+ fps: number;
+}): number {
+ return frame / fps;
+}
+
+export function snapTimeToFrame({
+ time,
+ fps,
+}: {
+ time: number;
+ fps: number;
+}): number {
+ if (fps <= 0) return time;
+ const frame = timeToFrame({ time, fps });
+ return frameToTime({ frame, fps });
+}
+
+export function getSnappedSeekTime({
+ rawTime,
+ duration,
+ fps,
+}: {
+ rawTime: number;
+ duration: number;
+ fps: number;
+}): number {
+ const snappedTime = snapTimeToFrame({ time: rawTime, fps });
+ return Math.max(0, Math.min(duration, snappedTime));
+}
+
+export function getLastFrameTime({
+ duration,
+ fps,
+}: {
+ duration: number;
+ fps: number;
+}): number {
+ if (duration <= 0) return 0;
+ if (fps <= 0) return duration;
+ const frameOffset = 1 / fps;
+ return Math.max(0, duration - frameOffset);
+}
diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts
deleted file mode 100644
index b2650b7c..00000000
--- a/apps/web/src/lib/timeline-renderer.ts
+++ /dev/null
@@ -1,279 +0,0 @@
-import type { TimelineTrack } from "@/types/timeline";
-import type { MediaFile } from "@/types/media";
-import type { BlurIntensity } from "@/types/project";
-import { videoCache } from "./video-cache";
-import { drawCssBackground } from "./canvas-gradients";
-
-export interface RenderContext {
- ctx: CanvasRenderingContext2D;
- time: number;
- canvasWidth: number;
- canvasHeight: number;
- tracks: TimelineTrack[];
- mediaFiles: MediaFile[];
- backgroundColor?: string;
- backgroundType?: "color" | "blur";
- blurIntensity?: BlurIntensity;
- projectCanvasSize?: { width: number; height: number };
-}
-
-const imageElementCache = new Map<string, HTMLImageElement>();
-
-async function getImageElement(
- mediaItem: MediaFile
-): Promise<HTMLImageElement> {
- const cacheKey = mediaItem.id;
- const cached = imageElementCache.get(cacheKey);
- if (cached) return cached;
- const img = new Image();
- await new Promise<void>((resolve, reject) => {
- img.onload = () => resolve();
- img.onerror = () => reject(new Error("Image load failed"));
- img.src = mediaItem.url || URL.createObjectURL(mediaItem.file);
- });
- imageElementCache.set(cacheKey, img);
- return img;
-}
-
-export async function renderTimelineFrame({
- ctx,
- time,
- canvasWidth,
- canvasHeight,
- tracks,
- mediaFiles,
- backgroundColor,
- backgroundType,
- blurIntensity,
- projectCanvasSize,
-}: RenderContext): Promise<void> {
- // Background
- ctx.clearRect(0, 0, canvasWidth, canvasHeight);
- if (
- backgroundColor &&
- backgroundColor !== "transparent" &&
- !backgroundColor.includes("gradient")
- ) {
- ctx.fillStyle = backgroundColor;
- ctx.fillRect(0, 0, canvasWidth, canvasHeight);
- }
-
- // If backgroundColor is a CSS gradient string, draw it
- if (backgroundColor && backgroundColor.includes("gradient")) {
- drawCssBackground(ctx, canvasWidth, canvasHeight, backgroundColor);
- }
-
- const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1;
- const scaleY = projectCanvasSize
- ? canvasHeight / projectCanvasSize.height
- : 1;
- const idToMedia = new Map(mediaFiles.map((m) => [m.id, m] as const));
- const active: Array<{
- track: TimelineTrack;
- element: TimelineTrack["elements"][number];
- mediaItem: MediaFile | null;
- }> = [];
-
- for (let t = tracks.length - 1; t >= 0; t -= 1) {
- const track = tracks[t];
- for (const element of track.elements) {
- if (element.hidden) continue;
- const elementStart = element.startTime;
- const elementEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- if (time >= elementStart && time < elementEnd) {
- let mediaItem: MediaFile | null = null;
- if (element.type === "media") {
- mediaItem =
- element.mediaId === "test"
- ? null
- : idToMedia.get(element.mediaId) || null;
- }
- active.push({ track, element, mediaItem });
- }
- }
- }
-
- // If background is set to blur, draw the active media as a blurred cover layer first
- if (backgroundType === "blur") {
- const blurPx = Math.max(0, blurIntensity ?? 8);
- // Find a suitable media element (video/image) among active elements
- const bgCandidate = active.find(({ element, mediaItem }) => {
- return (
- element.type === "media" &&
- mediaItem !== null &&
- (mediaItem.type === "video" || mediaItem.type === "image")
- );
- });
- if (bgCandidate && bgCandidate.mediaItem) {
- const { element, mediaItem } = bgCandidate;
- try {
- if (mediaItem.type === "video") {
- const localTime = time - element.startTime + element.trimStart;
- const frame = await videoCache.getFrameAt(
- mediaItem.id,
- mediaItem.file,
- Math.max(0, localTime)
- );
- if (frame) {
- const mediaW = Math.max(1, mediaItem.width || canvasWidth);
- const mediaH = Math.max(1, mediaItem.height || canvasHeight);
- const coverScale = Math.max(
- canvasWidth / mediaW,
- canvasHeight / mediaH
- );
- const drawW = mediaW * coverScale;
- const drawH = mediaH * coverScale;
- const drawX = (canvasWidth - drawW) / 2;
- const drawY = (canvasHeight - drawH) / 2;
- ctx.save();
- ctx.filter = `blur(${blurPx}px)`;
- ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH);
- ctx.restore();
- }
- } else if (mediaItem.type === "image") {
- const img = await getImageElement(mediaItem);
- const mediaW = Math.max(
- 1,
- mediaItem.width || img.naturalWidth || canvasWidth
- );
- const mediaH = Math.max(
- 1,
- mediaItem.height || img.naturalHeight || canvasHeight
- );
- const coverScale = Math.max(
- canvasWidth / mediaW,
- canvasHeight / mediaH
- );
- const drawW = mediaW * coverScale;
- const drawH = mediaH * coverScale;
- const drawX = (canvasWidth - drawW) / 2;
- const drawY = (canvasHeight - drawH) / 2;
- ctx.save();
- ctx.filter = `blur(${blurPx}px)`;
- ctx.drawImage(img, drawX, drawY, drawW, drawH);
- ctx.restore();
- }
- } catch {
- // Ignore background blur failures; foreground will still render
- }
- }
- }
-
- for (const { element, mediaItem } of active) {
- if (element.type === "media" && mediaItem) {
- if (mediaItem.type === "video") {
- try {
- const localTime = time - element.startTime + element.trimStart;
-
- const frame = await videoCache.getFrameAt(
- mediaItem.id,
- mediaItem.file,
- localTime
- );
- if (!frame) continue;
-
- const mediaW = Math.max(1, mediaItem.width || canvasWidth);
- const mediaH = Math.max(1, mediaItem.height || canvasHeight);
- const containScale = Math.min(
- canvasWidth / mediaW,
- canvasHeight / mediaH
- );
- const drawW = mediaW * containScale;
- const drawH = mediaH * containScale;
- const drawX = (canvasWidth - drawW) / 2;
- const drawY = (canvasHeight - drawH) / 2;
-
- ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH);
- } catch (error) {
- console.warn(
- `Failed to render video frame for ${mediaItem.name}:`,
- error
- );
- }
- }
- if (mediaItem.type === "image") {
- const img = new Image();
- await new Promise<void>((resolve, reject) => {
- img.onload = () => resolve();
- img.onerror = () => reject(new Error("Image load failed"));
- img.src = mediaItem.url || URL.createObjectURL(mediaItem.file);
- });
- const mediaW = Math.max(
- 1,
- mediaItem.width || img.naturalWidth || canvasWidth
- );
- const mediaH = Math.max(
- 1,
- mediaItem.height || img.naturalHeight || canvasHeight
- );
- const containScale = Math.min(
- canvasWidth / mediaW,
- canvasHeight / mediaH
- );
- const drawW = mediaW * containScale;
- const drawH = mediaH * containScale;
- const drawX = (canvasWidth - drawW) / 2;
- const drawY = (canvasHeight - drawH) / 2;
- ctx.drawImage(img, drawX, drawY, drawW, drawH);
- }
- }
- if (element.type === "text") {
- const text = element;
- const posX = canvasWidth / 2 + text.x * scaleX;
- const posY = canvasHeight / 2 + text.y * scaleY;
- ctx.save();
- ctx.translate(posX, posY);
- ctx.rotate((text.rotation * Math.PI) / 180);
- ctx.globalAlpha = Math.max(0, Math.min(1, text.opacity));
- const px = text.fontSize * scaleX;
- const weight = text.fontWeight === "bold" ? "bold " : "";
- const style = text.fontStyle === "italic" ? "italic " : "";
- ctx.font = `${style}${weight}${px}px ${text.fontFamily}`;
- ctx.fillStyle = text.color;
- ctx.textAlign = text.textAlign as CanvasTextAlign;
- ctx.textBaseline = "middle";
- const metrics = ctx.measureText(text.content);
- const hasBoxMetrics =
- "actualBoundingBoxAscent" in metrics &&
- "actualBoundingBoxDescent" in metrics;
- const ascent = hasBoxMetrics
- ? (
- metrics as TextMetrics & {
- actualBoundingBoxAscent: number;
- actualBoundingBoxDescent: number;
- }
- ).actualBoundingBoxAscent
- : px * 0.8;
- const descent = hasBoxMetrics
- ? (
- metrics as TextMetrics & {
- actualBoundingBoxAscent: number;
- actualBoundingBoxDescent: number;
- }
- ).actualBoundingBoxDescent
- : px * 0.2;
- const textW = metrics.width;
- const textH = ascent + descent;
- const padX = 8 * scaleX;
- const padY = 4 * scaleX;
- if (text.backgroundColor) {
- ctx.save();
- ctx.fillStyle = text.backgroundColor;
- let bgLeft = -textW / 2;
- if (ctx.textAlign === "left") bgLeft = 0;
- if (ctx.textAlign === "right") bgLeft = -textW;
- ctx.fillRect(
- bgLeft - padX,
- -textH / 2 - padY,
- textW + padX * 2,
- textH + padY * 2
- );
- ctx.restore();
- }
- ctx.fillText(text.content, 0, 0);
- ctx.restore();
- }
- }
-}
diff --git a/apps/web/src/lib/timeline.ts b/apps/web/src/lib/timeline.ts
deleted file mode 100644
index 85fcabe6..00000000
--- a/apps/web/src/lib/timeline.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { TimelineElement } from "@/types/timeline";
-
-// Helper function to check for element overlaps and prevent invalid timeline states
-export const checkElementOverlaps = (elements: TimelineElement[]): boolean => {
- // Sort elements by start time
- const sortedElements = [...elements].sort(
- (a, b) => a.startTime - b.startTime
- );
-
- for (let i = 0; i < sortedElements.length - 1; i++) {
- const current = sortedElements[i];
- const next = sortedElements[i + 1];
-
- const currentEnd =
- current.startTime +
- (current.duration - current.trimStart - current.trimEnd);
-
- // Check if current element overlaps with next element
- if (currentEnd > next.startTime) return true; // Overlap detected
- }
-
- return false; // No overlaps
-};
-
-// Helper function to resolve overlaps by adjusting element positions
-export const resolveElementOverlaps = (
- elements: TimelineElement[]
-): TimelineElement[] => {
- // Sort elements by start time
- const sortedElements = [...elements].sort(
- (a, b) => a.startTime - b.startTime
- );
- const resolvedElements: TimelineElement[] = [];
-
- for (let i = 0; i < sortedElements.length; i++) {
- const current = { ...sortedElements[i] };
-
- if (resolvedElements.length > 0) {
- const previous = resolvedElements[resolvedElements.length - 1];
- const previousEnd =
- previous.startTime +
- (previous.duration - previous.trimStart - previous.trimEnd);
-
- // If current element would overlap with previous, push it after previous ends
- if (current.startTime < previousEnd) {
- current.startTime = previousEnd;
- }
- }
-
- resolvedElements.push(current);
- }
-
- return resolvedElements;
-};
diff --git a/apps/web/src/lib/timeline/bookmarks.ts b/apps/web/src/lib/timeline/bookmarks.ts
new file mode 100644
index 00000000..0b06c2f6
--- /dev/null
+++ b/apps/web/src/lib/timeline/bookmarks.ts
@@ -0,0 +1,61 @@
+import { roundToFrame } from "@/lib/time";
+
+export function findBookmarkIndex({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: number[];
+ frameTime: number;
+}): number {
+ return bookmarks.findIndex(
+ (bookmark) => Math.abs(bookmark - frameTime) < 0.001,
+ );
+}
+
+export function isBookmarkAtTime({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: number[];
+ frameTime: number;
+}): boolean {
+ return bookmarks.some((bookmark) => Math.abs(bookmark - frameTime) < 0.001);
+}
+
+export function toggleBookmarkInArray({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: number[];
+ frameTime: number;
+}): number[] {
+ const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
+
+ if (bookmarkIndex !== -1) {
+ return bookmarks.filter((_, i) => i !== bookmarkIndex);
+ }
+
+ return [...bookmarks, frameTime].sort((a, b) => a - b);
+}
+
+export function removeBookmarkFromArray({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: number[];
+ frameTime: number;
+}): number[] {
+ return bookmarks.filter(
+ (bookmark) => Math.abs(bookmark - frameTime) >= 0.001,
+ );
+}
+
+export function getFrameTime({
+ time,
+ fps,
+}: {
+ time: number;
+ fps: number;
+}): number {
+ return roundToFrame({ time, fps });
+}
diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts
new file mode 100644
index 00000000..8c22194a
--- /dev/null
+++ b/apps/web/src/lib/timeline/drop-utils.ts
@@ -0,0 +1,234 @@
+import type { TimelineTrack, ElementType } from "@/types/timeline";
+import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
+import { wouldElementOverlap } from "./element-utils";
+import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
+import { isMainTrack } from "./track-utils";
+
+function getTrackAtY({
+ mouseY,
+ tracks,
+ verticalDragDirection,
+}: {
+ mouseY: number;
+ tracks: TimelineTrack[];
+ verticalDragDirection?: "up" | "down" | null;
+}): { trackIndex: number; relativeY: number } | null {
+ let cumulativeHeight = 0;
+
+ for (let i = 0; i < tracks.length; i++) {
+ const trackHeight = TRACK_HEIGHTS[tracks[i].type];
+ const trackTop = cumulativeHeight;
+ const trackBottom = trackTop + trackHeight;
+
+ if (mouseY >= trackTop && mouseY < trackBottom) {
+ return {
+ trackIndex: i,
+ relativeY: mouseY - trackTop,
+ };
+ }
+
+ if (i < tracks.length - 1 && verticalDragDirection) {
+ const gapTop = trackBottom;
+ const gapBottom = gapTop + TRACK_GAP;
+ if (mouseY >= gapTop && mouseY < gapBottom) {
+ const isDraggingUp = verticalDragDirection === "up";
+ return {
+ trackIndex: isDraggingUp ? i : i + 1,
+ relativeY: isDraggingUp ? trackHeight - 1 : 0,
+ };
+ }
+ }
+
+ cumulativeHeight += trackHeight + TRACK_GAP;
+ }
+
+ return null;
+}
+
+function isCompatible({
+ elementType,
+ trackType,
+}: {
+ elementType: ElementType;
+ trackType: TimelineTrack["type"];
+}): boolean {
+ if (elementType === "text") return trackType === "text";
+ if (elementType === "audio") return trackType === "audio";
+ if (elementType === "sticker") return trackType === "sticker";
+ if (elementType === "video" || elementType === "image") {
+ return trackType === "video";
+ }
+ return false;
+}
+
+function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number {
+ return tracks.findIndex((track) => isMainTrack(track));
+}
+
+function findInsertIndex({
+ elementType,
+ tracks,
+ preferredIndex,
+ insertAbove,
+}: {
+ elementType: ElementType;
+ tracks: TimelineTrack[];
+ preferredIndex: number;
+ insertAbove: boolean;
+}): { index: number; position: "above" | "below" } {
+ const mainTrackIndex = getMainTrackIndex({ tracks });
+
+ if (elementType === "audio") {
+ if (preferredIndex <= mainTrackIndex) {
+ return { index: mainTrackIndex + 1, position: "below" };
+ }
+ return {
+ index: insertAbove ? preferredIndex : preferredIndex + 1,
+ position: insertAbove ? "above" : "below",
+ };
+ }
+
+ const overlayInsertIndex = insertAbove ? preferredIndex : preferredIndex + 1;
+
+ if (mainTrackIndex >= 0 && overlayInsertIndex > mainTrackIndex) {
+ return { index: mainTrackIndex, position: "above" };
+ }
+
+ return {
+ index: overlayInsertIndex,
+ position: insertAbove ? "above" : "below",
+ };
+}
+
+export function computeDropTarget({
+ elementType,
+ mouseX,
+ mouseY,
+ tracks,
+ playheadTime,
+ isExternalDrop,
+ elementDuration,
+ pixelsPerSecond,
+ zoomLevel,
+ verticalDragDirection,
+ startTimeOverride,
+ excludeElementId,
+}: ComputeDropTargetParams): DropTarget {
+ const xPosition =
+ typeof startTimeOverride === "number"
+ ? startTimeOverride
+ : isExternalDrop
+ ? playheadTime
+ : Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
+
+ const mainTrackIndex = getMainTrackIndex({ tracks });
+
+ if (tracks.length === 0) {
+ if (elementType === "audio") {
+ return {
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: "below",
+ xPosition,
+ };
+ }
+ return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition };
+ }
+
+ const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
+
+ if (!trackAtMouse) {
+ const isAboveAllTracks = mouseY < 0;
+
+ if (elementType === "audio") {
+ return {
+ trackIndex: tracks.length,
+ isNewTrack: true,
+ insertPosition: "below",
+ xPosition,
+ };
+ }
+
+ if (isAboveAllTracks) {
+ return {
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: "above",
+ xPosition,
+ };
+ }
+
+ return {
+ trackIndex: Math.max(0, mainTrackIndex),
+ isNewTrack: true,
+ insertPosition: "above",
+ xPosition,
+ };
+ }
+
+ const { trackIndex, relativeY } = trackAtMouse;
+ const track = tracks[trackIndex];
+ const trackHeight = TRACK_HEIGHTS[track.type];
+ const isInUpperHalf = relativeY < trackHeight / 2;
+
+ const isTrackCompatible = isCompatible({
+ elementType,
+ trackType: track.type,
+ });
+
+ const endTime = xPosition + elementDuration;
+ const hasOverlap = wouldElementOverlap({
+ elements: track.elements,
+ startTime: xPosition,
+ endTime,
+ excludeElementId,
+ });
+
+ if (isTrackCompatible && !hasOverlap) {
+ return {
+ trackIndex,
+ isNewTrack: false,
+ insertPosition: null,
+ xPosition,
+ };
+ }
+
+ let insertAbove = isInUpperHalf;
+ if (!isTrackCompatible && verticalDragDirection) {
+ insertAbove = verticalDragDirection === "up";
+ }
+
+ const { index, position } = findInsertIndex({
+ elementType,
+ tracks,
+ preferredIndex: trackIndex,
+ insertAbove,
+ });
+
+ return {
+ trackIndex: index,
+ isNewTrack: true,
+ insertPosition: position,
+ xPosition,
+ };
+}
+
+export function getDropLineY({
+ dropTarget,
+ tracks,
+}: {
+ dropTarget: DropTarget;
+ tracks: TimelineTrack[];
+}): number {
+ const safeTrackIndex = Math.min(
+ Math.max(dropTarget.trackIndex, 0),
+ tracks.length,
+ );
+ let y = 0;
+
+ for (let i = 0; i < safeTrackIndex; i++) {
+ y += TRACK_HEIGHTS[tracks[i].type] + TRACK_GAP;
+ }
+
+ return y;
+}
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts
new file mode 100644
index 00000000..a167e548
--- /dev/null
+++ b/apps/web/src/lib/timeline/element-utils.ts
@@ -0,0 +1,304 @@
+import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import type {
+ CreateTimelineElement,
+ CreateVideoElement,
+ CreateImageElement,
+ CreateStickerElement,
+ CreateUploadAudioElement,
+ CreateLibraryAudioElement,
+ TextElement,
+ TimelineElement,
+ TimelineTrack,
+ AudioElement,
+ VideoElement,
+ ImageElement,
+ StickerElement,
+ UploadAudioElement,
+} from "@/types/timeline";
+
+export function canElementHaveAudio(
+ element: TimelineElement,
+): element is AudioElement | VideoElement {
+ return element.type === "audio" || element.type === "video";
+}
+
+export function canElementBeHidden(
+ element: TimelineElement,
+): element is VideoElement | ImageElement | TextElement | StickerElement {
+ return element.type !== "audio";
+}
+
+export function hasMediaId(
+ element: TimelineElement,
+): element is UploadAudioElement | VideoElement | ImageElement {
+ return "mediaId" in element;
+}
+
+export function requiresMediaId({
+ element,
+}: {
+ element: CreateTimelineElement;
+}): boolean {
+ return (
+ element.type === "video" ||
+ element.type === "image" ||
+ (element.type === "audio" && element.sourceType === "upload")
+ );
+}
+
+export function checkElementOverlaps({
+ elements,
+}: {
+ elements: TimelineElement[];
+}): boolean {
+ const sortedElements = [...elements].sort(
+ (a, b) => a.startTime - b.startTime,
+ );
+
+ for (let i = 0; i < sortedElements.length - 1; i++) {
+ const current = sortedElements[i];
+ const next = sortedElements[i + 1];
+
+ const currentEnd = current.startTime + current.duration;
+
+ if (currentEnd > next.startTime) return true;
+ }
+
+ return false;
+}
+
+export function resolveElementOverlaps({
+ elements,
+}: {
+ elements: TimelineElement[];
+}): TimelineElement[] {
+ const sortedElements = [...elements].sort(
+ (a, b) => a.startTime - b.startTime,
+ );
+ const resolvedElements: TimelineElement[] = [];
+
+ for (let i = 0; i < sortedElements.length; i++) {
+ const current = { ...sortedElements[i] };
+
+ if (resolvedElements.length > 0) {
+ const previous = resolvedElements[resolvedElements.length - 1];
+ const previousEnd = previous.startTime + previous.duration;
+
+ if (current.startTime < previousEnd) {
+ current.startTime = previousEnd;
+ }
+ }
+
+ resolvedElements.push(current);
+ }
+
+ return resolvedElements;
+}
+
+export function wouldElementOverlap({
+ elements,
+ startTime,
+ endTime,
+ excludeElementId,
+}: {
+ elements: TimelineElement[];
+ startTime: number;
+ endTime: number;
+ excludeElementId?: string;
+}): boolean {
+ return elements.some((el) => {
+ if (excludeElementId && el.id === excludeElementId) return false;
+ const elEnd = el.startTime + el.duration;
+ return startTime < elEnd && endTime > el.startTime;
+ });
+}
+
+export function buildTextElement({
+ raw,
+ startTime,
+}: {
+ raw: Partial<Omit<TextElement, "type" | "id">>;
+ startTime: number;
+}): CreateTimelineElement {
+ const t = raw as Partial<TextElement>;
+
+ return {
+ type: "text",
+ name: t.name ?? DEFAULT_TEXT_ELEMENT.name,
+ content: t.content ?? DEFAULT_TEXT_ELEMENT.content,
+ duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize:
+ typeof t.fontSize === "number"
+ ? t.fontSize
+ : DEFAULT_TEXT_ELEMENT.fontSize,
+ fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
+ color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
+ backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor,
+ textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
+ fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
+ fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
+ textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration,
+ transform: t.transform ?? DEFAULT_TEXT_ELEMENT.transform,
+ opacity: t.opacity ?? DEFAULT_TEXT_ELEMENT.opacity,
+ };
+}
+
+export function buildStickerElement({
+ iconName,
+ startTime,
+}: {
+ iconName: string;
+ startTime: number;
+}): CreateStickerElement {
+ return {
+ type: "sticker",
+ name: iconName.split(":")[1] || iconName,
+ iconName,
+ duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
+ opacity: 1,
+ };
+}
+
+export function buildVideoElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+}: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+}): CreateVideoElement {
+ return {
+ type: "video",
+ mediaId,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ muted: false,
+ hidden: false,
+ transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
+ opacity: 1,
+ };
+}
+
+export function buildImageElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+}: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+}): CreateImageElement {
+ return {
+ type: "image",
+ mediaId,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ hidden: false,
+ transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
+ opacity: 1,
+ };
+}
+
+export function buildUploadAudioElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+ buffer,
+}: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
+}): CreateUploadAudioElement {
+ const element: CreateUploadAudioElement = {
+ type: "audio",
+ sourceType: "upload",
+ mediaId,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ volume: 1,
+ muted: false,
+ };
+ if (buffer) {
+ element.buffer = buffer;
+ }
+ return element;
+}
+
+export function buildLibraryAudioElement({
+ sourceUrl,
+ name,
+ duration,
+ startTime,
+ buffer,
+}: {
+ sourceUrl: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
+}): CreateLibraryAudioElement {
+ const element: CreateLibraryAudioElement = {
+ type: "audio",
+ sourceType: "library",
+ sourceUrl,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ volume: 1,
+ muted: false,
+ };
+ if (buffer) {
+ element.buffer = buffer;
+ }
+ return element;
+}
+
+export function getElementsAtTime({
+ tracks,
+ time,
+}: {
+ tracks: TimelineTrack[];
+ time: number;
+}): { trackId: string; elementId: string }[] {
+ const result: { trackId: string; elementId: string }[] = [];
+
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ const elementStart = element.startTime;
+ const elementEnd = element.startTime + element.duration;
+
+ if (time > elementStart && time < elementEnd) {
+ result.push({ trackId: track.id, elementId: element.id });
+ }
+ }
+ }
+
+ return result;
+}
diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts
new file mode 100644
index 00000000..f3ace94f
--- /dev/null
+++ b/apps/web/src/lib/timeline/index.ts
@@ -0,0 +1,23 @@
+import type { TimelineTrack } from "@/types/timeline";
+
+export * from "./track-utils";
+export * from "./element-utils";
+export * from "./zoom-utils";
+export * from "./ruler-utils";
+
+export function calculateTotalDuration({
+ tracks,
+}: {
+ tracks: TimelineTrack[];
+}): number {
+ if (tracks.length === 0) return 0;
+
+ const trackEndTimes = tracks.map((track) =>
+ track.elements.reduce((maxEnd, element) => {
+ const elementEnd = element.startTime + element.duration;
+ return Math.max(maxEnd, elementEnd);
+ }, 0),
+ );
+
+ return Math.max(...trackEndTimes, 0);
+}
diff --git a/apps/web/src/lib/timeline/ruler-utils.ts b/apps/web/src/lib/timeline/ruler-utils.ts
new file mode 100644
index 00000000..d0a67857
--- /dev/null
+++ b/apps/web/src/lib/timeline/ruler-utils.ts
@@ -0,0 +1,228 @@
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+/**
+ * Frame intervals for labels - starts at 2 so there's always at least
+ * one tick between labels even at max zoom.
+ * Pattern: 2, 3, 5, 10, 15 (matches CapCut)
+ */
+const LABEL_FRAME_INTERVALS = [2, 3, 5, 10, 15] as const;
+
+/**
+ * Frame intervals for ticks - can go down to 1 for max granularity.
+ */
+const TICK_FRAME_INTERVALS = [1, 2, 3, 5, 10, 15] as const;
+
+/**
+ * Second intervals for when we're zoomed out past frame-level detail.
+ */
+const SECOND_MULTIPLIERS = [1, 2, 3, 5, 10, 15, 30, 60] as const;
+
+/**
+ * Minimum pixel spacing between labels to keep them readable
+ */
+const MIN_LABEL_SPACING_PX = 120;
+
+/**
+ * Minimum pixel spacing between ticks. Much denser than labels.
+ */
+const MIN_TICK_SPACING_PX = 18;
+
+export interface RulerConfig {
+ /** Time interval in seconds between each label */
+ labelIntervalSeconds: number;
+ /** Time interval in seconds between each tick */
+ tickIntervalSeconds: number;
+}
+
+/**
+ * Determines the optimal label and tick intervals based on zoom level and FPS.
+ *
+ * Labels and ticks scale independently:
+ * - Labels need wide spacing (~50px) to stay readable
+ * - Ticks can be denser (~8px) to show finer subdivisions
+ *
+ * Example at different zoom levels:
+ * - Very zoomed in: labels every 2f, ticks every 1f
+ * - Zoomed in: labels every 10f, ticks every 1f
+ * - Zoomed out: labels every 15f, ticks every 3f
+ * - Very zoomed out: labels every 1s, ticks every 5f
+ */
+export function getRulerConfig({
+ zoomLevel,
+ fps,
+}: {
+ zoomLevel: number;
+ fps: number;
+}): RulerConfig {
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const pixelsPerFrame = pixelsPerSecond / fps;
+
+ const labelIntervalSeconds = findOptimalInterval({
+ pixelsPerFrame,
+ pixelsPerSecond,
+ fps,
+ minSpacingPx: MIN_LABEL_SPACING_PX,
+ frameIntervals: LABEL_FRAME_INTERVALS,
+ });
+
+ const rawTickIntervalSeconds = findOptimalInterval({
+ pixelsPerFrame,
+ pixelsPerSecond,
+ fps,
+ minSpacingPx: MIN_TICK_SPACING_PX,
+ frameIntervals: TICK_FRAME_INTERVALS,
+ });
+
+ // Ensure tick interval divides evenly into label interval so labels always land on ticks
+ const tickIntervalSeconds = ensureTickDividesLabel({
+ tickIntervalSeconds: rawTickIntervalSeconds,
+ labelIntervalSeconds,
+ pixelsPerFrame,
+ fps,
+ });
+
+ return { labelIntervalSeconds, tickIntervalSeconds };
+}
+
+/**
+ * Adjusts tick interval to ensure it divides evenly into the label interval.
+ * This guarantees labels always land on tick positions.
+ */
+function ensureTickDividesLabel({
+ tickIntervalSeconds,
+ labelIntervalSeconds,
+ pixelsPerFrame,
+ fps,
+}: {
+ tickIntervalSeconds: number;
+ labelIntervalSeconds: number;
+ pixelsPerFrame: number;
+ fps: number;
+}): number {
+ const labelFrames = Math.round(labelIntervalSeconds * fps);
+ const tickFrames = Math.round(tickIntervalSeconds * fps);
+
+ // If tick already divides label evenly, we're good
+ if (labelFrames % tickFrames === 0) {
+ return tickIntervalSeconds;
+ }
+
+ // Find the smallest tick interval that divides the label interval and has adequate spacing
+ for (const candidateFrames of TICK_FRAME_INTERVALS) {
+ if (labelFrames % candidateFrames === 0) {
+ const candidateSpacing = pixelsPerFrame * candidateFrames;
+ // Accept if spacing meets minimum threshold
+ if (candidateSpacing >= MIN_TICK_SPACING_PX) {
+ return candidateFrames / fps;
+ }
+ }
+ }
+
+ // Fallback: use the label interval itself (no intermediate ticks)
+ return labelIntervalSeconds;
+}
+
+function findOptimalInterval({
+ pixelsPerFrame,
+ pixelsPerSecond,
+ fps,
+ minSpacingPx,
+ frameIntervals,
+}: {
+ pixelsPerFrame: number;
+ pixelsPerSecond: number;
+ fps: number;
+ minSpacingPx: number;
+ frameIntervals: readonly number[];
+}): number {
+ // Try frame-level intervals first
+ for (const frameInterval of frameIntervals) {
+ const pixelSpacing = pixelsPerFrame * frameInterval;
+ if (pixelSpacing >= minSpacingPx) {
+ return frameInterval / fps;
+ }
+ }
+
+ // Then try second-level intervals
+ for (const secondMultiplier of SECOND_MULTIPLIERS) {
+ const pixelSpacing = pixelsPerSecond * secondMultiplier;
+ if (pixelSpacing >= minSpacingPx) {
+ return secondMultiplier;
+ }
+ }
+
+ return 60;
+}
+
+/**
+ * Checks if a time should have a label based on the label interval.
+ */
+export function shouldShowLabel({
+ time,
+ labelIntervalSeconds,
+}: {
+ time: number;
+ labelIntervalSeconds: number;
+}): boolean {
+ const epsilon = 0.0001;
+ const remainder = time % labelIntervalSeconds;
+ return remainder < epsilon || remainder > labelIntervalSeconds - epsilon;
+}
+
+/**
+ * Formats a ruler tick label.
+ *
+ * - On second boundaries: "MM:SS" (e.g., "00:00", "01:30")
+ * - Between seconds: "Xf" (e.g., "5f", "15f")
+ */
+export function formatRulerLabel({
+ timeInSeconds,
+ fps,
+}: {
+ timeInSeconds: number;
+ fps: number;
+}): string {
+ if (isSecondBoundary({ timeInSeconds })) {
+ return formatTimestamp({ timeInSeconds });
+ }
+
+ const frameWithinSecond = getFrameWithinSecond({ timeInSeconds, fps });
+ return `${frameWithinSecond}f`;
+}
+
+/**
+ * Checks if a time falls exactly on a second boundary.
+ */
+function isSecondBoundary({
+ timeInSeconds,
+}: {
+ timeInSeconds: number;
+}): boolean {
+ const epsilon = 0.0001;
+ const remainder = timeInSeconds % 1;
+ return remainder < epsilon || remainder > 1 - epsilon;
+}
+
+/**
+ * Gets the frame number within the current second.
+ */
+function getFrameWithinSecond({
+ timeInSeconds,
+ fps,
+}: {
+ timeInSeconds: number;
+ fps: number;
+}): number {
+ const fractionalPart = timeInSeconds % 1;
+ return Math.round(fractionalPart * fps);
+}
+
+/**
+ * Formats a timestamp as MM:SS.
+ */
+function formatTimestamp({ timeInSeconds }: { timeInSeconds: number }): string {
+ const totalSeconds = Math.round(timeInSeconds);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
+}
diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts
new file mode 100644
index 00000000..b6261569
--- /dev/null
+++ b/apps/web/src/lib/timeline/track-utils.ts
@@ -0,0 +1,244 @@
+import type {
+ TrackType,
+ TimelineTrack,
+ ElementType,
+ VideoTrack,
+ AudioTrack,
+ StickerTrack,
+ TextTrack,
+} from "@/types/timeline";
+import {
+ TRACK_COLORS,
+ TRACK_HEIGHTS,
+ TRACK_GAP,
+} from "@/constants/timeline-constants";
+import { generateUUID } from "@/utils/id";
+
+export function canTracktHaveAudio(
+ track: TimelineTrack,
+): track is VideoTrack | AudioTrack {
+ return track.type === "audio" || track.type === "video";
+}
+
+export function canTrackBeHidden(
+ track: TimelineTrack,
+): track is VideoTrack | TextTrack | StickerTrack {
+ return track.type !== "audio";
+}
+
+export function getTrackColor({ type }: { type: TrackType }) {
+ return TRACK_COLORS[type];
+}
+
+export function getTrackClasses({ type }: { type: TrackType }) {
+ const colors = TRACK_COLORS[type];
+ return `${colors.background}`.trim();
+}
+
+export function getTrackHeight({ type }: { type: TrackType }): number {
+ return TRACK_HEIGHTS[type];
+}
+
+export function getCumulativeHeightBefore({
+ tracks,
+ trackIndex,
+}: {
+ tracks: Array<{ type: TrackType }>;
+ trackIndex: number;
+}): number {
+ return tracks
+ .slice(0, trackIndex)
+ .reduce(
+ (sum, track) => sum + getTrackHeight({ type: track.type }) + TRACK_GAP,
+ 0,
+ );
+}
+
+export function getTotalTracksHeight({
+ tracks,
+}: {
+ tracks: Array<{ type: TrackType }>;
+}): number {
+ const tracksHeight = tracks.reduce(
+ (sum, track) => sum + getTrackHeight({ type: track.type }),
+ 0,
+ );
+ const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
+ return tracksHeight + gapsHeight;
+}
+
+export function buildEmptyTrack({
+ id,
+ type,
+ name,
+}: {
+ id: string;
+ type: TrackType;
+ name?: string;
+}): TimelineTrack {
+ const trackName =
+ name ??
+ (type === "video"
+ ? "Video track"
+ : type === "text"
+ ? "Text track"
+ : type === "audio"
+ ? "Audio track"
+ : type === "sticker"
+ ? "Sticker track"
+ : "Track");
+
+ switch (type) {
+ case "video":
+ return {
+ id,
+ name: trackName,
+ type: "video",
+ elements: [],
+ hidden: false,
+ muted: false,
+ isMain: false,
+ };
+ case "text":
+ return {
+ id,
+ name: trackName,
+ type: "text",
+ elements: [],
+ hidden: false,
+ };
+ case "sticker":
+ return {
+ id,
+ name: trackName,
+ type: "sticker",
+ elements: [],
+ hidden: false,
+ };
+ case "audio":
+ return {
+ id,
+ name: trackName,
+ type: "audio",
+ elements: [],
+ muted: false,
+ };
+ default:
+ throw new Error(`Unsupported track type: ${type}`);
+ }
+}
+
+export function getDefaultInsertIndexForTrack({
+ tracks,
+ trackType,
+}: {
+ tracks: TimelineTrack[];
+ trackType: TrackType;
+}): number {
+ if (trackType === "audio") {
+ return tracks.length;
+ }
+
+ const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
+ if (mainTrackIndex >= 0) {
+ return mainTrackIndex;
+ }
+
+ const firstAudioTrackIndex = tracks.findIndex(
+ (track) => track.type === "audio",
+ );
+ if (firstAudioTrackIndex >= 0) {
+ return firstAudioTrackIndex;
+ }
+
+ return tracks.length;
+}
+
+export function getHighestInsertIndexForTrack({
+ tracks,
+ trackType,
+}: {
+ tracks: TimelineTrack[];
+ trackType: TrackType;
+}): number {
+ const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
+
+ if (trackType === "audio") {
+ return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length;
+ }
+
+ return 0;
+}
+
+export function isMainTrack(track: TimelineTrack): track is VideoTrack {
+ return track.type === "video" && track.isMain === true;
+}
+
+export function getMainTrack({
+ tracks,
+}: {
+ tracks: TimelineTrack[];
+}): TimelineTrack | null {
+ return tracks.find((track) => isMainTrack(track)) ?? null;
+}
+
+export function ensureMainTrack({
+ tracks,
+}: {
+ tracks: TimelineTrack[];
+}): TimelineTrack[] {
+ const hasMainTrack = tracks.some((track) => isMainTrack(track));
+
+ if (!hasMainTrack) {
+ const mainTrack: TimelineTrack = {
+ id: generateUUID(),
+ name: "Main Track",
+ type: "video",
+ elements: [],
+ muted: false,
+ isMain: true,
+ hidden: false,
+ };
+ return [mainTrack, ...tracks];
+ }
+
+ return tracks;
+}
+
+export function canElementGoOnTrack({
+ elementType,
+ trackType,
+}: {
+ elementType: ElementType;
+ trackType: TrackType;
+}): boolean {
+ if (elementType === "text") return trackType === "text";
+ if (elementType === "audio") return trackType === "audio";
+ if (elementType === "sticker") return trackType === "sticker";
+ if (elementType === "video" || elementType === "image") {
+ return trackType === "video";
+ }
+ return false;
+}
+
+export function validateElementTrackCompatibility({
+ element,
+ track,
+}: {
+ element: { type: ElementType };
+ track: { type: TrackType };
+}): { isValid: boolean; errorMessage?: string } {
+ const isValid = canElementGoOnTrack({
+ elementType: element.type,
+ trackType: track.type,
+ });
+
+ if (!isValid) {
+ return {
+ isValid: false,
+ errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
+ };
+ }
+
+ return { isValid: true };
+}
diff --git a/apps/web/src/lib/timeline/zoom-utils.ts b/apps/web/src/lib/timeline/zoom-utils.ts
new file mode 100644
index 00000000..49f7fcb6
--- /dev/null
+++ b/apps/web/src/lib/timeline/zoom-utils.ts
@@ -0,0 +1,53 @@
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+const PADDING_MAX_RATIO = 0.75;
+const PADDING_MIN_RATIO = 0.15;
+const PADDING_MIN_AT_ZOOM_PERCENT = 0.2;
+
+export function getTimelineZoomMin({
+ duration,
+ containerWidth,
+}: {
+ duration: number;
+ containerWidth: number | null | undefined;
+}): number {
+ const safeDuration = Math.max(duration, 1);
+ const safeContainerWidth = containerWidth ?? 1000;
+ const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO;
+ const availableWidth = safeContainerWidth * contentRatioAtMinZoom;
+ const zoomToFit =
+ availableWidth / (safeDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND);
+
+ return Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, zoomToFit);
+}
+
+export function getTimelinePaddingPx({
+ containerWidth,
+ zoomLevel,
+ minZoom,
+}: {
+ containerWidth: number;
+ zoomLevel: number;
+ minZoom: number;
+}): number {
+ const zoomPercent = getZoomPercent({ zoomLevel, minZoom });
+ const paddingTransitionPercent = Math.min(
+ zoomPercent / PADDING_MIN_AT_ZOOM_PERCENT,
+ 1,
+ );
+ const paddingRatio =
+ PADDING_MAX_RATIO -
+ (PADDING_MAX_RATIO - PADDING_MIN_RATIO) * paddingTransitionPercent;
+
+ return containerWidth * paddingRatio;
+}
+
+export function getZoomPercent({
+ zoomLevel,
+ minZoom,
+}: {
+ zoomLevel: number;
+ minZoom: number;
+}): number {
+ return (zoomLevel - minZoom) / (TIMELINE_CONSTANTS.ZOOM_MAX - minZoom);
+}
diff --git a/apps/web/src/lib/transcription-utils.ts b/apps/web/src/lib/transcription-utils.ts
deleted file mode 100644
index 6388a3b6..00000000
--- a/apps/web/src/lib/transcription-utils.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { env } from "@/env";
-
-export function isTranscriptionConfigured() {
- const missingVars = [];
-
- if (!env.CLOUDFLARE_ACCOUNT_ID) missingVars.push("CLOUDFLARE_ACCOUNT_ID");
- if (!env.R2_ACCESS_KEY_ID) missingVars.push("R2_ACCESS_KEY_ID");
- if (!env.R2_SECRET_ACCESS_KEY) missingVars.push("R2_SECRET_ACCESS_KEY");
- if (!env.R2_BUCKET_NAME) missingVars.push("R2_BUCKET_NAME");
- if (!env.MODAL_TRANSCRIPTION_URL) missingVars.push("MODAL_TRANSCRIPTION_URL");
-
- return { configured: missingVars.length === 0, missingVars };
-}
diff --git a/apps/web/src/lib/transcription/caption.ts b/apps/web/src/lib/transcription/caption.ts
new file mode 100644
index 00000000..c773b7b0
--- /dev/null
+++ b/apps/web/src/lib/transcription/caption.ts
@@ -0,0 +1,49 @@
+import type { TranscriptionSegment, CaptionChunk } from "@/types/transcription";
+import {
+ DEFAULT_WORDS_PER_CAPTION,
+ MIN_CAPTION_DURATION_SECONDS,
+} from "@/constants/transcription-constants";
+
+export function buildCaptionChunks({
+ segments,
+ wordsPerChunk = DEFAULT_WORDS_PER_CAPTION,
+ minDuration = MIN_CAPTION_DURATION_SECONDS,
+}: {
+ segments: TranscriptionSegment[];
+ wordsPerChunk?: number;
+ minDuration?: number;
+}): CaptionChunk[] {
+ const captions: CaptionChunk[] = [];
+ let globalEndTime = 0;
+
+ for (const segment of segments) {
+ const words = segment.text.trim().split(/\s+/);
+ if (words.length === 0 || (words.length === 1 && words[0] === "")) continue;
+
+ const segmentDuration = segment.end - segment.start;
+ const wordsPerSecond = words.length / segmentDuration;
+
+ const chunks: string[] = [];
+ for (let i = 0; i < words.length; i += wordsPerChunk) {
+ chunks.push(words.slice(i, i + wordsPerChunk).join(" "));
+ }
+
+ let chunkStartTime = segment.start;
+ for (const chunk of chunks) {
+ const chunkWords = chunk.split(/\s+/).length;
+ const chunkDuration = Math.max(minDuration, chunkWords / wordsPerSecond);
+ const adjustedStartTime = Math.max(chunkStartTime, globalEndTime);
+
+ captions.push({
+ text: chunk,
+ startTime: adjustedStartTime,
+ duration: chunkDuration,
+ });
+
+ globalEndTime = adjustedStartTime + chunkDuration;
+ chunkStartTime += chunkDuration;
+ }
+ }
+
+ return captions;
+}
diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
deleted file mode 100644
index 42159b2e..00000000
--- a/apps/web/src/lib/utils.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-// Generic utilities
-
-import { type ClassValue, clsx } from "clsx";
-import { twMerge } from "tailwind-merge";
-
-export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs));
-}
-
-export function uppercase(str: string) {
- return str.toUpperCase();
-}
-
-/**
- * Generates a UUID v4 string
- * Uses crypto.randomUUID() if available, otherwise falls back to a custom implementation
- */
-export function generateUUID(): string {
- // Use the native crypto.randomUUID if available
- if (
- typeof crypto !== "undefined" &&
- typeof crypto.randomUUID === "function"
- ) {
- return crypto.randomUUID();
- }
-
- // Secure fallback using crypto.getRandomValues
- const bytes = new Uint8Array(16);
- crypto.getRandomValues(bytes);
-
- // Set version 4 (UUIDv4)
- bytes[6] = (bytes[6] & 0x0f) | 0x40;
- // Set variant 10xxxxxx
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
-
- const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0"));
-
- return (
- hex.slice(0, 4).join("") +
- "-" +
- hex.slice(4, 6).join("") +
- "-" +
- hex.slice(6, 8).join("") +
- "-" +
- hex.slice(8, 10).join("") +
- "-" +
- hex.slice(10, 16).join("")
- );
-}
-
-export function isDOMElement(el: any): el is HTMLElement {
- return !!el && (el instanceof Element || el instanceof HTMLElement);
-}
-
-export function isTypableElement(el: HTMLElement): boolean {
- // If content editable, then it is editable
- if (el.isContentEditable) return true;
-
- // If element is an input and the input is enabled, then it is typable
- if (el.tagName === "INPUT") {
- return !(el as HTMLInputElement).disabled;
- }
- // If element is a textarea and the input is enabled, then it is typable
- if (el.tagName === "TEXTAREA") {
- return !(el as HTMLTextAreaElement).disabled;
- }
-
- return false;
-}
-export function isAppleDevice() {
- return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
-}
-
-export function getPlatformSpecialKey() {
- return isAppleDevice() ? "⌘" : "Ctrl";
-}
-
-export function getPlatformAlternateKey() {
- return isAppleDevice() ? "⌥" : "Alt";
-}
diff --git a/apps/web/src/lib/video-cache.ts b/apps/web/src/lib/video-cache.ts
deleted file mode 100644
index aeb62d1c..00000000
--- a/apps/web/src/lib/video-cache.ts
+++ /dev/null
@@ -1,186 +0,0 @@
-import {
- Input,
- ALL_FORMATS,
- BlobSource,
- CanvasSink,
- WrappedCanvas,
-} from "mediabunny";
-
-interface VideoSinkData {
- sink: CanvasSink;
- iterator: AsyncGenerator<WrappedCanvas, void, unknown> | null;
- currentFrame: WrappedCanvas | null;
- lastTime: number;
-}
-export class VideoCache {
- private sinks = new Map<string, VideoSinkData>();
- private initPromises = new Map<string, Promise<void>>();
-
- async getFrameAt(
- mediaId: string,
- file: File,
- time: number
- ): Promise<WrappedCanvas | null> {
- await this.ensureSink(mediaId, file);
-
- const sinkData = this.sinks.get(mediaId);
- if (!sinkData) return null;
-
- if (
- sinkData.currentFrame &&
- this.isFrameValid(sinkData.currentFrame, time)
- ) {
- return sinkData.currentFrame;
- }
-
- if (
- sinkData.iterator &&
- sinkData.currentFrame &&
- time >= sinkData.lastTime &&
- time < sinkData.lastTime + 2.0
- ) {
- const frame = await this.iterateToTime(sinkData, time);
- if (frame) return frame;
- }
-
- return await this.seekToTime(sinkData, time);
- }
-
- private isFrameValid(frame: WrappedCanvas, time: number): boolean {
- return time >= frame.timestamp && time < frame.timestamp + frame.duration;
- }
- private async iterateToTime(
- sinkData: VideoSinkData,
- targetTime: number
- ): Promise<WrappedCanvas | null> {
- if (!sinkData.iterator) return null;
-
- try {
- while (true) {
- const { value: frame, done } = await sinkData.iterator.next();
-
- if (done || !frame) break;
-
- sinkData.currentFrame = frame;
- sinkData.lastTime = frame.timestamp;
-
- if (this.isFrameValid(frame, targetTime)) {
- return frame;
- }
-
- if (frame.timestamp > targetTime + 1.0) break;
- }
- } catch (error) {
- console.warn("Iterator failed, will restart:", error);
- sinkData.iterator = null;
- }
-
- return null;
- }
- private async seekToTime(
- sinkData: VideoSinkData,
- time: number
- ): Promise<WrappedCanvas | null> {
- try {
- if (sinkData.iterator) {
- await sinkData.iterator.return();
- sinkData.iterator = null;
- }
-
- sinkData.iterator = sinkData.sink.canvases(time);
- sinkData.lastTime = time;
-
- const { value: frame } = await sinkData.iterator.next();
-
- if (frame) {
- sinkData.currentFrame = frame;
- return frame;
- }
- } catch (error) {
- console.warn("Failed to seek video:", error);
- }
-
- return null;
- }
- private async ensureSink(mediaId: string, file: File): Promise<void> {
- if (this.sinks.has(mediaId)) return;
-
- if (this.initPromises.has(mediaId)) {
- await this.initPromises.get(mediaId);
- return;
- }
-
- const initPromise = this.initializeSink(mediaId, file);
- this.initPromises.set(mediaId, initPromise);
-
- try {
- await initPromise;
- } finally {
- this.initPromises.delete(mediaId);
- }
- }
- private async initializeSink(mediaId: string, file: File): Promise<void> {
- try {
- const input = new Input({
- source: new BlobSource(file),
- formats: ALL_FORMATS,
- });
-
- const videoTrack = await input.getPrimaryVideoTrack();
- if (!videoTrack) {
- throw new Error("No video track found");
- }
-
- const canDecode = await videoTrack.canDecode();
- if (!canDecode) {
- throw new Error("Video codec not supported for decoding");
- }
-
- const sink = new CanvasSink(videoTrack, {
- poolSize: 3,
- fit: "contain",
- });
-
- this.sinks.set(mediaId, {
- sink,
- iterator: null,
- currentFrame: null,
- lastTime: -1,
- });
- } catch (error) {
- console.error(`Failed to initialize video sink for ${mediaId}:`, error);
- throw error;
- }
- }
-
- clearVideo(mediaId: string): void {
- const sinkData = this.sinks.get(mediaId);
- if (sinkData) {
- if (sinkData.iterator) {
- sinkData.iterator.return();
- }
-
- this.sinks.delete(mediaId);
- }
-
- this.initPromises.delete(mediaId);
- }
-
- clearAll(): void {
- for (const [mediaId] of this.sinks) {
- this.clearVideo(mediaId);
- }
- }
-
- getStats() {
- return {
- totalSinks: this.sinks.size,
- activeSinks: Array.from(this.sinks.values()).filter((s) => s.iterator)
- .length,
- cachedFrames: Array.from(this.sinks.values()).filter(
- (s) => s.currentFrame
- ).length,
- };
- }
-}
-export const videoCache = new VideoCache();
diff --git a/apps/web/src/lib/zk-encryption.ts b/apps/web/src/lib/zk-encryption.ts
deleted file mode 100644
index 58734807..00000000
--- a/apps/web/src/lib/zk-encryption.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * True zero-knowledge encryption utilities
- * Keys are generated randomly in the browser and never derived from server secrets
- */
-
-export interface ZeroKnowledgeEncryptionResult {
- encryptedData: ArrayBuffer;
- key: ArrayBuffer;
- iv: ArrayBuffer;
-}
-
-/**
- * Encrypt data with a randomly generated key (true zero-knowledge)
- */
-export async function encryptWithRandomKey(
- data: ArrayBuffer
-): Promise<ZeroKnowledgeEncryptionResult> {
- // Generate a truly random 256-bit key
- const key = crypto.getRandomValues(new Uint8Array(32));
-
- // Generate random IV
- const iv = crypto.getRandomValues(new Uint8Array(12));
-
- // Import the key for encryption
- const cryptoKey = await crypto.subtle.importKey(
- "raw",
- key,
- { name: "AES-GCM" },
- false,
- ["encrypt"]
- );
-
- // Encrypt the data
- const encryptedResult = await crypto.subtle.encrypt(
- { name: "AES-GCM", iv },
- cryptoKey,
- data
- );
-
- // For AES-GCM, we need to append the authentication tag
- // The encrypted result contains both ciphertext and tag
- return {
- encryptedData: encryptedResult,
- key: key.buffer,
- iv: iv.buffer,
- };
-}
-
-/**
- * Convert ArrayBuffer to base64 string for transmission
- */
-export function arrayBufferToBase64(buffer: ArrayBuffer): string {
- const bytes = new Uint8Array(buffer);
- let binary = "";
- for (let i = 0; i < bytes.byteLength; i++) {
- binary += String.fromCharCode(bytes[i]);
- }
- return btoa(binary);
-}
-
-/**
- * Convert base64 string back to ArrayBuffer
- */
-export function base64ToArrayBuffer(base64: string): ArrayBuffer {
- const binary = atob(base64);
- const bytes = new Uint8Array(binary.length);
- for (let i = 0; i < binary.length; i++) {
- bytes[i] = binary.charCodeAt(i);
- }
- return bytes.buffer;
-}