From 7bf0984698e43b9aa2dd879f65951318ed410390 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:20:04 +0100 Subject: codebase overhaul (#697) --- apps/web/src/lib/actions/definitions.ts | 162 ++++ apps/web/src/lib/actions/index.ts | 3 + apps/web/src/lib/actions/registry.ts | 61 ++ apps/web/src/lib/actions/types.ts | 42 + apps/web/src/lib/auth/client.ts | 6 + apps/web/src/lib/auth/server.ts | 43 + apps/web/src/lib/blog-query.ts | 64 -- apps/web/src/lib/blog/query.ts | 72 ++ apps/web/src/lib/canvas-gradients.ts | 219 ----- apps/web/src/lib/commands/base-command.ts | 11 + apps/web/src/lib/commands/index.ts | 6 + apps/web/src/lib/commands/media/add-media-asset.ts | 61 ++ apps/web/src/lib/commands/media/index.ts | 2 + .../src/lib/commands/media/remove-media-asset.ts | 85 ++ apps/web/src/lib/commands/project/index.ts | 1 + .../commands/project/update-project-settings.ts | 46 + apps/web/src/lib/commands/scene/create-scene.ts | 40 + apps/web/src/lib/commands/scene/delete-scene.ts | 59 ++ apps/web/src/lib/commands/scene/index.ts | 5 + apps/web/src/lib/commands/scene/remove-bookmark.ts | 59 ++ apps/web/src/lib/commands/scene/rename-scene.ts | 46 + apps/web/src/lib/commands/scene/toggle-bookmark.ts | 52 ++ .../src/lib/commands/timeline/clipboard/index.ts | 1 + .../src/lib/commands/timeline/clipboard/paste.ts | 263 ++++++ .../commands/timeline/element/delete-elements.ts | 48 ++ .../timeline/element/duplicate-elements.ts | 114 +++ .../web/src/lib/commands/timeline/element/index.ts | 11 + .../commands/timeline/element/insert-element.ts | 311 +++++++ .../lib/commands/timeline/element/move-elements.ts | 127 +++ .../commands/timeline/element/split-elements.ts | 133 +++ .../timeline/element/toggle-elements-muted.ts | 57 ++ .../timeline/element/toggle-elements-visibility.ts | 47 ++ .../timeline/element/update-element-duration.ts | 37 + .../timeline/element/update-element-start-time.ts | 48 ++ .../timeline/element/update-element-trim.ts | 48 ++ .../timeline/element/update-text-element.ts | 52 ++ apps/web/src/lib/commands/timeline/index.ts | 3 + .../src/lib/commands/timeline/track/add-track.ts | 53 ++ apps/web/src/lib/commands/timeline/track/index.ts | 4 + .../lib/commands/timeline/track/remove-track.ts | 35 + .../commands/timeline/track/toggle-track-mute.ts | 39 + .../timeline/track/toggle-track-visibility.ts | 40 + apps/web/src/lib/db/index.ts | 19 + apps/web/src/lib/db/schema.ts | 59 ++ apps/web/src/lib/drag-data.ts | 48 ++ apps/web/src/lib/editor-utils.ts | 46 - apps/web/src/lib/export.ts | 313 +------ apps/web/src/lib/font-config.ts | 39 - apps/web/src/lib/gradients/canvas.ts | 923 +++++++++++++++++++++ apps/web/src/lib/gradients/index.ts | 2 + apps/web/src/lib/gradients/parser.ts | 652 +++++++++++++++ apps/web/src/lib/iconify-api.ts | 367 ++++---- apps/web/src/lib/media-processing.ts | 89 -- apps/web/src/lib/media/audio.ts | 459 ++++++++++ apps/web/src/lib/media/media-utils.ts | 32 + apps/web/src/lib/media/mediabunny.ts | 226 +++++ apps/web/src/lib/media/processing.ts | 282 +++++++ apps/web/src/lib/mediabunny-utils.ts | 311 ------- apps/web/src/lib/rate-limit.ts | 21 +- apps/web/src/lib/scenes.ts | 103 +++ apps/web/src/lib/schemas/waitlist.ts | 15 - apps/web/src/lib/storage/indexeddb-adapter.ts | 89 -- apps/web/src/lib/storage/opfs-adapter.ts | 73 -- apps/web/src/lib/storage/storage-service.ts | 480 ----------- apps/web/src/lib/storage/types.ts | 69 -- apps/web/src/lib/time.ts | 357 +++++--- apps/web/src/lib/timeline-renderer.ts | 279 ------- apps/web/src/lib/timeline.ts | 54 -- apps/web/src/lib/timeline/bookmarks.ts | 61 ++ apps/web/src/lib/timeline/drop-utils.ts | 234 ++++++ apps/web/src/lib/timeline/element-utils.ts | 304 +++++++ apps/web/src/lib/timeline/index.ts | 23 + apps/web/src/lib/timeline/ruler-utils.ts | 228 +++++ apps/web/src/lib/timeline/track-utils.ts | 244 ++++++ apps/web/src/lib/timeline/zoom-utils.ts | 53 ++ apps/web/src/lib/transcription-utils.ts | 13 - apps/web/src/lib/transcription/caption.ts | 49 ++ apps/web/src/lib/utils.ts | 80 -- apps/web/src/lib/video-cache.ts | 186 ----- apps/web/src/lib/zk-encryption.ts | 71 -- 80 files changed, 6765 insertions(+), 2804 deletions(-) create mode 100644 apps/web/src/lib/actions/definitions.ts create mode 100644 apps/web/src/lib/actions/index.ts create mode 100644 apps/web/src/lib/actions/registry.ts create mode 100644 apps/web/src/lib/actions/types.ts create mode 100644 apps/web/src/lib/auth/client.ts create mode 100644 apps/web/src/lib/auth/server.ts delete mode 100644 apps/web/src/lib/blog-query.ts create mode 100644 apps/web/src/lib/blog/query.ts delete mode 100644 apps/web/src/lib/canvas-gradients.ts create mode 100644 apps/web/src/lib/commands/base-command.ts create mode 100644 apps/web/src/lib/commands/index.ts create mode 100644 apps/web/src/lib/commands/media/add-media-asset.ts create mode 100644 apps/web/src/lib/commands/media/index.ts create mode 100644 apps/web/src/lib/commands/media/remove-media-asset.ts create mode 100644 apps/web/src/lib/commands/project/index.ts create mode 100644 apps/web/src/lib/commands/project/update-project-settings.ts create mode 100644 apps/web/src/lib/commands/scene/create-scene.ts create mode 100644 apps/web/src/lib/commands/scene/delete-scene.ts create mode 100644 apps/web/src/lib/commands/scene/index.ts create mode 100644 apps/web/src/lib/commands/scene/remove-bookmark.ts create mode 100644 apps/web/src/lib/commands/scene/rename-scene.ts create mode 100644 apps/web/src/lib/commands/scene/toggle-bookmark.ts create mode 100644 apps/web/src/lib/commands/timeline/clipboard/index.ts create mode 100644 apps/web/src/lib/commands/timeline/clipboard/paste.ts create mode 100644 apps/web/src/lib/commands/timeline/element/delete-elements.ts create mode 100644 apps/web/src/lib/commands/timeline/element/duplicate-elements.ts create mode 100644 apps/web/src/lib/commands/timeline/element/index.ts create mode 100644 apps/web/src/lib/commands/timeline/element/insert-element.ts create mode 100644 apps/web/src/lib/commands/timeline/element/move-elements.ts create mode 100644 apps/web/src/lib/commands/timeline/element/split-elements.ts create mode 100644 apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts create mode 100644 apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts create mode 100644 apps/web/src/lib/commands/timeline/element/update-element-duration.ts create mode 100644 apps/web/src/lib/commands/timeline/element/update-element-start-time.ts create mode 100644 apps/web/src/lib/commands/timeline/element/update-element-trim.ts create mode 100644 apps/web/src/lib/commands/timeline/element/update-text-element.ts create mode 100644 apps/web/src/lib/commands/timeline/index.ts create mode 100644 apps/web/src/lib/commands/timeline/track/add-track.ts create mode 100644 apps/web/src/lib/commands/timeline/track/index.ts create mode 100644 apps/web/src/lib/commands/timeline/track/remove-track.ts create mode 100644 apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts create mode 100644 apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts create mode 100644 apps/web/src/lib/db/index.ts create mode 100644 apps/web/src/lib/db/schema.ts create mode 100644 apps/web/src/lib/drag-data.ts delete mode 100644 apps/web/src/lib/editor-utils.ts delete mode 100644 apps/web/src/lib/font-config.ts create mode 100644 apps/web/src/lib/gradients/canvas.ts create mode 100644 apps/web/src/lib/gradients/index.ts create mode 100644 apps/web/src/lib/gradients/parser.ts delete mode 100644 apps/web/src/lib/media-processing.ts create mode 100644 apps/web/src/lib/media/audio.ts create mode 100644 apps/web/src/lib/media/media-utils.ts create mode 100644 apps/web/src/lib/media/mediabunny.ts create mode 100644 apps/web/src/lib/media/processing.ts delete mode 100644 apps/web/src/lib/mediabunny-utils.ts create mode 100644 apps/web/src/lib/scenes.ts delete mode 100644 apps/web/src/lib/schemas/waitlist.ts delete mode 100644 apps/web/src/lib/storage/indexeddb-adapter.ts delete mode 100644 apps/web/src/lib/storage/opfs-adapter.ts delete mode 100644 apps/web/src/lib/storage/storage-service.ts delete mode 100644 apps/web/src/lib/storage/types.ts delete mode 100644 apps/web/src/lib/timeline-renderer.ts delete mode 100644 apps/web/src/lib/timeline.ts create mode 100644 apps/web/src/lib/timeline/bookmarks.ts create mode 100644 apps/web/src/lib/timeline/drop-utils.ts create mode 100644 apps/web/src/lib/timeline/element-utils.ts create mode 100644 apps/web/src/lib/timeline/index.ts create mode 100644 apps/web/src/lib/timeline/ruler-utils.ts create mode 100644 apps/web/src/lib/timeline/track-utils.ts create mode 100644 apps/web/src/lib/timeline/zoom-utils.ts delete mode 100644 apps/web/src/lib/transcription-utils.ts create mode 100644 apps/web/src/lib/transcription/caption.ts delete mode 100644 apps/web/src/lib/utils.ts delete mode 100644 apps/web/src/lib/video-cache.ts delete mode 100644 apps/web/src/lib/zk-encryption.ts (limited to 'apps/web/src/lib') 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; +} + +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; + +export type TAction = keyof typeof ACTIONS; + +export function getActionDefinition(action: TAction): TActionDefinition { + return ACTIONS[action]; +} + +export function getDefaultShortcuts(): Record { + const shortcuts: Record = {}; + + 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; +} 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> = {}; + +export function bindAction( + action: A, + handler: TActionFunc, +) { + const handlers = boundActions[action]; + const typedHandler = handler as ActionHandler; + if (handlers) { + handlers.push(typedHandler); + } else { + boundActions[action] = [typedHandler]; + } +} + +export function unbindAction( + action: A, + handler: TActionFunc, +) { + 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; + ( + action: A, + args: TActionArgsMap[A], + trigger?: TInvocationTrigger, + ): void; +}; + +export const invokeAction: InvokeActionFunc = ( + action: A, + args?: TArgOfAction, + 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 = { + [K in keyof T]: undefined extends T[K] ? K : never; +}[keyof T]; + +export type TActionWithArgs = keyof TActionArgsMap; + +export type TActionWithOptionalArgs = + | TActionWithNoArgs + | TKeysWithValueUndefined; + +export type TActionWithNoArgs = Exclude; + +export type TArgOfAction = A extends TActionWithArgs + ? TActionArgsMap[A] + : undefined; + +export type TActionFunc = A extends TActionWithArgs + ? (arg: TArgOfAction, trigger?: TInvocationTrigger) => void + : (_?: undefined, trigger?: TInvocationTrigger) => void; + +export type TInvocationTrigger = "keypress" | "mouseclick"; + +export type TBoundActionList = { + [A in TAction]?: Array>; +}; + +export type TActionHandlerOptions = + | MutableRefObject + | 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(endpoint: string): Promise { - 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("posts"); -} - -export async function getTags() { - return fetchFromMarble("tags"); -} - -export async function getSinglePost(slug: string) { - return fetchFromMarble(`posts/${slug}`); -} - -export async function getCategories() { - return fetchFromMarble("categories"); -} - -export async function getAuthors() { - return fetchFromMarble("authors"); -} - -export async function processHtmlContent(html: string): Promise { - 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({ + endpoint, +}: { + endpoint: string; +}): Promise { + 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({ endpoint: "posts" }); +} + +export async function getTags() { + return fetchFromMarble({ endpoint: "tags" }); +} + +export async function getSinglePost({ slug }: { slug: string }) { + return fetchFromMarble({ endpoint: `posts/${slug}` }); +} + +export async function getCategories() { + return fetchFromMarble({ endpoint: "categories" }); +} + +export async function getAuthors() { + return fetchFromMarble({ endpoint: "authors" }); +} + +export async function processHtmlContent({ + html, +}: { + html: string; +}): Promise { + 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, + ) { + 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) { + 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 { + const groupedItems = new Map(); + + 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 | 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"; - -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 { - // 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(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 { - 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"; +import { EXPORT_MIME_TYPES } from "@/constants/export-constants"; +import type { ExportFormat } from "@/types/export"; + +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 => { + const segments = splitCssLayers({ css }); + const layers: Array = []; + + 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 => { + const layers: Array = []; + 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; + 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; + +export type Color = + | { type: "hex"; value: string } + | { type: "literal"; value: string } + | { type: "rgb"; value: Array } + | { type: "rgba"; value: Array } + | { 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; +}; + +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 => { + const ast = matchListDefinitions(); + + if (input.length > 0) { + error({ message: "Invalid input not EOF" }); + } + + return ast; +}; + +const matchListDefinitions = (): Array => + 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 = ({ + 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 + | undefined => { + const radialOrientation = matchRadialOrientation(); + if (!radialOrientation) { + return undefined; + } + + const radialOrientations: Array = [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 = ({ + matcher, +}: { + matcher: () => T | undefined; +}): Array => { + const captures = matcher(); + const result: Array = []; + + 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 = ({ + 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 => { + 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 { - 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; + icons: string[]; + total: number; + limit: number; + start: number; + collections: Record; } export interface CollectionInfo { - prefix: string; - total: number; - title?: string; - uncategorized?: string[]; - categories?: Record; - hidden?: string[]; - aliases?: Record; + prefix: string; + total: number; + title?: string; + uncategorized?: string[]; + categories?: Record; + hidden?: string[]; + aliases?: Record; } export async function getCollections( - category?: string + category?: string, ): Promise> { - try { - const response = await fetchWithFallback("/collections?pretty=1"); - const data = (await response.json()) as Record; - - if (category) { - const filtered = Object.fromEntries( - Object.entries(data).filter( - ([_key, info]) => info.category === category - ) - ) as Record; - return filtered; - } - - return data; - } catch (error) { - console.error("Failed to fetch collections:", error); - return {}; - } + try { + const response = await fetchWithFallback("/collections?pretty=1"); + const data = (await response.json()) as Record; + + if (category) { + const filtered = Object.fromEntries( + Object.entries(data).filter( + ([_key, info]) => info.category === category, + ), + ) as Record; + return filtered; + } + + return data; + } catch (error) { + console.error("Failed to fetch collections:", error); + return {}; + } } export async function getCollection( - prefix: string + prefix: string, ): Promise { - 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 { - const params = new URLSearchParams({ - query, - limit: limit.toString(), - pretty: "1", - }); - - if (prefixes?.length) { - params.append("prefixes", prefixes.join(",")); - } - - 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: {}, - }; - } + const params = new URLSearchParams({ + query, + limit: limit.toString(), + pretty: "1", + }); + + if (prefixes?.length) { + params.append("prefixes", prefixes.join(",")); + } + + 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: {}, + }; + } } 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[2] + iconName: string, + params?: Parameters[2], ): string { - return buildIconSvgUrl(currentHost, iconName, params); + return buildIconSvgUrl(currentHost, iconName, params); } export async function downloadSvgAsText( - iconName: string, - params?: Parameters[1] + iconName: string, + params?: Parameters[1], ): Promise { - 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 + collections: Record, ): string[] { - const categories = new Set(); - Object.values(collections).forEach((collection) => { - if (collection.category) { - categories.add(collection.category); - } - }); - return Array.from(categories).sort(); + const categories = new Set(); + 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 {} - -export async function processMediaFiles( - files: FileList | File[], - onProgress?: (progress: number) => void -): Promise { - 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 { + 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 { + const mediaMap = new Map( + mediaAssets.map((media) => [media.id, media]), + ); + const pendingElements: Array> = []; + + 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; + audioContext: AudioContext; +}): Promise { + 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 { + 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 { + 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 { + const audioMixSources: AudioMixSource[] = []; + const mediaMap = new Map( + mediaAssets.map((asset) => [asset.id, asset]), + ); + const pendingLibrarySources: Array> = []; + + 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 { + const clips: AudioClipSource[] = []; + const mediaMap = new Map( + mediaAssets.map((asset) => [asset.id, asset]), + ); + const pendingLibraryClips: Array> = []; + + 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 { + 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 => { + 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 { + 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 {} + +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 { + 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 { + 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 { + 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 => { + 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 => { - 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 { - 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 => { - // 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 => { - 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[] { + 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; -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 implements StorageAdapter { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - private directoryName: string; - - constructor(directoryName = "media") { - this.directoryName = directoryName; - } - - private async getDirectory(): Promise { - const opfsRoot = await navigator.storage.getDirectory(); - return await opfsRoot.getDirectoryHandle(this.directoryName, { - create: true, - }); - } - - async get(key: string): Promise { - 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 { - 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 { - try { - const directory = await this.getDirectory(); - await directory.removeEntry(key); - } catch (error) { - if ((error as Error).name !== "NotFoundError") { - throw error; - } - } - } - - async list(): Promise { - const directory = await this.getDirectory(); - const keys: string[] = []; - - for await (const name of directory.keys()) { - keys.push(name); - } - - return keys; - } - - async clear(): Promise { - 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; - private savedSoundsAdapter: IndexedDBAdapter; - 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( - this.config.projectsDb, - "projects", - this.config.version - ); - - this.savedSoundsAdapter = new IndexedDBAdapter( - this.config.savedSoundsDb, - "saved-sounds", - this.config.version - ); - } - - // Helper to get project-specific media adapters - private getProjectMediaAdapters({ projectId }: { projectId: string }) { - const mediaMetadataAdapter = new IndexedDBAdapter( - `${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( - dbName, - "timeline", - this.config.version - ); - } - - // Project operations - async saveProject({ project }: { project: TProject }): Promise { - // 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 { - 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 { - 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 { - await this.projectsAdapter.remove(id); - } - - // Media operations - async saveMediaFile({ - projectId, - mediaItem, - }: { - projectId: string; - mediaItem: MediaFile; - }): Promise { - 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 { - 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(" { - 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 { - const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters({ projectId }); - - await Promise.all([ - mediaFilesAdapter.remove(id), - mediaMetadataAdapter.remove(id), - ]); - } - - async deleteProjectMedia({ - projectId, - }: { - projectId: string; - }): Promise { - 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 { - 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 { - const timelineAdapter = this.getProjectTimelineAdapter({ - projectId, - sceneId, - }); - const timelineData = await timelineAdapter.get("timeline"); - return timelineData ? timelineData.tracks : null; - } - - async deleteProjectTimeline({ - projectId, - }: { - projectId: string; - }): Promise { - const timelineAdapter = this.getProjectTimelineAdapter({ projectId }); - await timelineAdapter.remove("timeline"); - } - - // Utility methods - async clearAllData(): Promise { - // 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - get(key: string): Promise; - set(key: string, value: T): Promise; - remove(key: string): Promise; - list(): Promise; - clear(): Promise; -} - -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 & { - 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; - values(): AsyncIterableIterator; - 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 { DEFAULT_FPS } from "@/stores/project-store"; - -export type TimeCode = "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF"; - -// 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": - return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`; - } -}; - -export const parseTimeCode = ( - timeCode: string, - format: TimeCode = "HH:MM:SS:CS", - fps = DEFAULT_FPS -): number | null => { - if (!timeCode || typeof timeCode !== "string") return null; - - // 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 (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 (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 ( - 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 (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; - } - - return null; -}; - -export const guessTimeCodeFormat = (timeCode: string): TimeCode | null => { - if (!timeCode || typeof timeCode !== "string") return null; - - const numbers = timeCode.split(":"); - - if (!numbers.every((n) => !isNaN(Number(n)))) 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"; - - return null; -}; +import type { TTimeCode } from "@/types/time"; + +export function roundToFrame({ + time, + fps, +}: { + time: number; + fps: number; +}): number { + return Math.round(time * fps) / fps; +} + +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; + + 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")}`; + } +} + +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; + + 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; + } + + 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: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: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; + } +} + +export function guessTimeCodeFormat({ + timeCode, +}: { + timeCode: string; +}): TTimeCode | null { + if (!timeCode || typeof timeCode !== "string") return null; + + const numbers = timeCode.split(":"); + + if (!numbers.every((n) => !Number.isNaN(Number(n)))) 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"; + + return null; +} + +export function timeToFrame({ + time, + fps, +}: { + time: number; + fps: number; +}): number { + return Math.round(time * fps); +} + +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(); - -async function getImageElement( - mediaItem: MediaFile -): Promise { - const cacheKey = mediaItem.id; - const cached = imageElementCache.get(cacheKey); - if (cached) return cached; - const img = new Image(); - await new Promise((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 { - // 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((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>; + startTime: number; +}): CreateTimelineElement { + const t = raw as Partial; + + 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 | null; - currentFrame: WrappedCanvas | null; - lastTime: number; -} -export class VideoCache { - private sinks = new Map(); - private initPromises = new Map>(); - - async getFrameAt( - mediaId: string, - file: File, - time: number - ): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - // 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; -} -- cgit v1.3.1