diff --git a/frontend/apps/artcraft-webapp/package.json b/frontend/apps/artcraft-webapp/package.json index 2c1a2270df..059c5104d0 100644 --- a/frontend/apps/artcraft-webapp/package.json +++ b/frontend/apps/artcraft-webapp/package.json @@ -3,6 +3,10 @@ "version": "0.0.1", "private": true, "dependencies": { + "@storyteller/ui-gallery-modal": "workspace:*", + "@storyteller/ui-pricing-table": "workspace:*", + "@storyteller/ui-video-editor": "workspace:*", + "react-aria": "^3.48.0", "react-masonry-css": "^1.0.16" } } diff --git a/frontend/apps/artcraft-webapp/src/app/app.tsx b/frontend/apps/artcraft-webapp/src/app/app.tsx index fd509d619f..a2155462ce 100644 --- a/frontend/apps/artcraft-webapp/src/app/app.tsx +++ b/frontend/apps/artcraft-webapp/src/app/app.tsx @@ -16,6 +16,7 @@ import CreateImage from "../pages/create-image"; import CreateVideo from "../pages/create-video"; import CreateVFX from "../pages/create-vfx"; import PageScene from "../pages/pagescene"; +import VideoEditorPage from "../pages/video-editor"; import Pricing from "../pages/pricing"; import Support from "../pages/support/support"; import Login from "../pages/login"; @@ -87,13 +88,17 @@ function ProtectedContent() { ? "var(--sidebar-width)" : "calc(var(--sidebar-width-icon) + 1.5rem)"; - // The Edit 3D editor hosts the header's actions (pricing/credits/task - // queue/profile) inside its own canvas toolbar to reclaim vertical - // space, so the global header is hidden there — desktop only, since the - // mobile route shows a gate that still needs the header's nav chrome. + // The Edit 3D and video editors host the header's actions + // (pricing/credits/task queue/profile) inside their own toolbar/header to + // reclaim vertical space, so the global header is hidden there — desktop + // only, since the mobile route shows a gate that still needs the header's + // nav chrome. const hideTopBar = !isMobile && - (pathname === "/edit-3d" || pathname.startsWith("/edit-3d/")); + (pathname === "/edit-3d" || + pathname.startsWith("/edit-3d/") || + pathname === "/video-editor" || + pathname.startsWith("/video-editor/")); return (
} /> } /> } /> + } /> + } /> } /> } /> {/* Welcome is public so it stays reachable right after signup diff --git a/frontend/apps/artcraft-webapp/src/components/sidebar/app-sidebar.tsx b/frontend/apps/artcraft-webapp/src/components/sidebar/app-sidebar.tsx index 6d729b15be..5eccfa81fd 100644 --- a/frontend/apps/artcraft-webapp/src/components/sidebar/app-sidebar.tsx +++ b/frontend/apps/artcraft-webapp/src/components/sidebar/app-sidebar.tsx @@ -6,6 +6,7 @@ import { faVideo, faImage, faCube, + faFilm, faWandMagicSparkles, faGrid2, faGraduationCap, @@ -41,6 +42,7 @@ type NavItem = { href: string; icon: IconDefinition; external?: boolean; + badge?: string; }; const PRIMARY_ITEMS: NavItem[] = [{ label: "Home", href: "/", icon: faHouse }]; @@ -52,6 +54,7 @@ const CREATE_ITEMS_STATIC: NavItem[] = [ { label: "Image", href: "/create-image", icon: faImage }, { label: "Video", href: "/create-video", icon: faVideo }, { label: "Edit 3D", href: "/edit-3d", icon: faCube }, + { label: "Edit Video", href: "/video-editor", icon: faFilm, badge: "BETA" }, { label: "BG Change", href: "/background-change", @@ -140,6 +143,11 @@ function NavMenuItem({ <> {item.label} + {item.badge && ( + + {item.badge} + + )} ); return ( diff --git a/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/asset-gallery-adapter.tsx b/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/asset-gallery-adapter.tsx new file mode 100644 index 0000000000..d11d7d47b7 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/asset-gallery-adapter.tsx @@ -0,0 +1,132 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type ReactElement, +} from "react"; +import type { + AssetGalleryAdapter, + MediaKind, + MediaPickerSelection, +} from "@storyteller/ui-video-editor"; +import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal"; + +// Webapp AssetGalleryAdapter — wraps @storyteller/ui-gallery-modal in a +// promise-shaped openPicker. The modal lives alongside in +// the host page, controlled via React state. openPicker stores a +// resolver in a ref and opens the modal; the onUseSelected callback +// fires the resolver with the picked items, and onClose fires it with +// an empty array. + +type Resolver = (selections: MediaPickerSelection[]) => void; + +interface GalleryControlState { + open: boolean; + filter: string | undefined; +} + +interface AssetGalleryHostBundle { + adapter: AssetGalleryAdapter; + // Render this alongside . The host doesn't need to wire + // any props — state is owned by the hook. + modal: ReactElement; +} + +function kindsToFilter(kinds: MediaKind[]): string | undefined { + if (kinds.length === 1) return kinds[0]; + return undefined; +} + +function mediaClassToKind(mediaClass: string | undefined): MediaKind { + if (mediaClass === "video") return "video"; + if (mediaClass === "audio") return "audio"; + return "image"; +} + +// React hook that returns both the adapter and the JSX to render. The +// page composes its own JSX tree, so this is preferable to mounting +// a portal — co-located with the editor, no surprises. +export function useWebappAssetGalleryAdapter(): AssetGalleryHostBundle { + const [state, setState] = useState({ + open: false, + filter: undefined, + }); + // GalleryModal at mode="select" only registers user clicks when an + // onSelectItem handler is wired; without selectedItemIds tracking + // the "Use selected" button hands back an empty array. + const [selectedItemIds, setSelectedItemIds] = useState([]); + const resolverRef = useRef(null); + + // Reject (resolve-empty) any pending resolver. Used when a second + // openPicker call arrives before the first one resolved, or when the + // hook unmounts mid-flow. Without this the first promise hangs + // forever and the caller's UI stays stuck in its "processing" state. + const settlePending = (selections: MediaPickerSelection[]) => { + const resolver = resolverRef.current; + resolverRef.current = null; + resolver?.(selections); + }; + + // Unmount cleanup — fail any in-flight picker rather than leak it. + useEffect(() => { + return () => { + settlePending([]); + }; + // settlePending closes over the ref; safe to omit from deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const adapter = useMemo( + () => ({ + openPicker({ kinds }) { + return new Promise((resolve) => { + // If a previous picker hasn't settled (user clicked Browse + // again before the modal closed, or two consumers raced for + // the picker), resolve it empty so its caller can unwind. + settlePending([]); + setSelectedItemIds([]); + resolverRef.current = resolve; + setState({ open: true, filter: kindsToFilter(kinds) }); + }); + }, + }), + [], + ); + + const handleSelectItem = (id: string) => { + setSelectedItemIds((prev) => + prev.includes(id) ? prev.filter((existing) => existing !== id) : [...prev, id], + ); + }; + + const handleUseSelected = (items: GalleryItem[]) => { + const selections: MediaPickerSelection[] = items.map((item) => ({ + handle: { id: item.id, kind: mediaClassToKind(item.mediaClass) }, + name: item.label, + })); + setState({ open: false, filter: undefined }); + setSelectedItemIds([]); + settlePending(selections); + }; + + const handleClose = () => { + setState({ open: false, filter: undefined }); + setSelectedItemIds([]); + settlePending([]); + }; + + const modal = ( + + ); + + return { adapter, modal }; +} diff --git a/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/auth-user-adapter.ts b/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/auth-user-adapter.ts new file mode 100644 index 0000000000..60f5c4f6b9 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/auth-user-adapter.ts @@ -0,0 +1,58 @@ +import type { AuthUser, AuthUserAdapter } from "@storyteller/ui-video-editor"; +import type { UserInfo } from "@storyteller/api"; +import { useSessionStore } from "../../../lib/session"; + +// Webapp AuthUserAdapter — reads the current signed-in user from +// `useSessionStore` (Zustand) and subscribes to its changes so the +// editor chrome re-renders on sign-in / sign-out without a remount. +// +// currentUser() must return a stable reference across calls when the +// underlying user hasn't changed — otherwise React's +// useSyncExternalStore loop sees a new snapshot every render and +// infinite-loops. We cache the projection at module scope keyed on the +// source UserInfo identity. **Only snapshot() mutates the cache.** +// Subscribers each maintain their own `previous` baseline so the cache +// can't be observed in a way that races between concurrent subscribers +// (the bug a previous version of this file had, which silently dropped +// sign-in/sign-out notifications to the second-and-later useAuthUser +// consumer when one ran before the others). + +function projectUser(user: UserInfo | undefined): AuthUser | null { + if (!user) return null; + return { id: user.user_token, displayName: user.display_name }; +} + +function authUsersEqual(a: AuthUser | null, b: AuthUser | null): boolean { + if (a === b) return true; + if (a === null || b === null) return false; + return a.id === b.id && a.displayName === b.displayName; +} + +let cachedSource: UserInfo | undefined = undefined; +let cachedResult: AuthUser | null = null; + +function snapshot(): AuthUser | null { + const source = useSessionStore.getState().user; + if (source === cachedSource) return cachedResult; + cachedSource = source; + cachedResult = projectUser(source); + return cachedResult; +} + +export const webappAuthUserAdapter: AuthUserAdapter = { + currentUser() { + return snapshot(); + }, + subscribe(listener) { + // Per-listener `previous` — closed-over, not shared. Each + // subscriber computes its own baseline so it doesn't observe other + // subscribers' cache writes. + let previous = projectUser(useSessionStore.getState().user); + return useSessionStore.subscribe((state) => { + const next = projectUser(state.user); + if (authUsersEqual(previous, next)) return; + previous = next; + listener(next); + }); + }, +}; diff --git a/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/export-sink-adapter.ts b/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/export-sink-adapter.ts new file mode 100644 index 0000000000..fd413339f0 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/export-sink-adapter.ts @@ -0,0 +1,127 @@ +import type { + ExportArtifact, + ExportSinkAdapter, + ExportSinkOptions, +} from "@storyteller/ui-video-editor"; +import { kindFromMime } from "@storyteller/ui-video-editor"; +import { uploadByKind } from "./upload-by-kind"; + +// Webapp ExportSinkAdapter. +// +// Two destinations the user can pick independently from the export +// popover: a browser download (`saveToDisk`) and an Artcraft media +// library upload (`saveToLibrary`). The progress callback fires once +// per destination so the lib can render inline status rows; accept() +// resolves only after every requested destination has settled so the +// "Close" button in the popover only appears when everything is done. +// +// accept() always returns `null`: the browser's flow +// does not expose the actual on-disk path, so callers that want a +// real path must use the Tauri adapter instead. This matches the +// ExportSinkAdapter contract. + +function triggerDownload(artifact: ExportArtifact): string { + const url = URL.createObjectURL(artifact.blob); + const a = document.createElement("a"); + a.href = url; + a.download = artifact.filename; + document.body.appendChild(a); + a.click(); + a.remove(); + return url; +} + +async function uploadToLibrary(artifact: ExportArtifact): Promise { + const baseTitle = artifact.filename.replace(/\.[^.]+$/, ""); + await uploadByKind({ + kind: kindFromMime(artifact.mime), + blob: artifact.blob, + fileName: artifact.filename, + title: baseTitle, + }); +} + +// In-flight gate keyed by filename. Without this, double-clicking +// Export or any concurrent invocation launches two simultaneous +// UploadNewVideo / UploadAudio / UploadImage calls for the same Blob. +const inFlight = new Set(); + +export const webappExportSinkAdapter: ExportSinkAdapter = { + async accept(artifact, options) { + const saveToDisk = options?.saveToDisk ?? true; + const saveToLibrary = options?.saveToLibrary ?? false; + const onProgress = options?.onProgress; + + if (!saveToDisk && !saveToLibrary) { + return null; + } + + // Disk: download is effectively synchronous; we still emit a + // pending → success transition so the popover row can render the + // spinner-to-checkmark animation rather than snapping straight to + // success. + let downloadUrl: string | null = null; + if (saveToDisk) { + onProgress?.({ destination: "disk", status: "pending" }); + try { + downloadUrl = triggerDownload(artifact); + onProgress?.({ destination: "disk", status: "success" }); + } catch (error) { + onProgress?.({ + destination: "disk", + status: "error", + error: error instanceof Error ? error.message : "Download failed", + }); + } + } + + if (!saveToLibrary) { + // Disk-only path: schedule blob URL revoke and return. The 60s + // delay outlasts large file writes on slow disks. + if (downloadUrl) { + setTimeout(() => URL.revokeObjectURL(downloadUrl as string), 60_000); + } + return null; + } + + // Library path. Dedupe concurrent invocations of the same artifact + // so a double-click doesn't double-upload. + if (inFlight.has(artifact.filename)) { + onProgress?.({ + destination: "library", + status: "error", + error: "Already uploading this export", + }); + if (downloadUrl) { + setTimeout(() => URL.revokeObjectURL(downloadUrl as string), 60_000); + } + return null; + } + inFlight.add(artifact.filename); + + onProgress?.({ destination: "library", status: "uploading" }); + try { + await uploadToLibrary(artifact); + onProgress?.({ destination: "library", status: "success" }); + } catch (error) { + onProgress?.({ + destination: "library", + status: "error", + error: error instanceof Error ? error.message : "Upload failed", + }); + } finally { + inFlight.delete(artifact.filename); + // Revoke after the upload settles so the parallel disk write + // and the library read can't race against the URL release. + if (downloadUrl) { + URL.revokeObjectURL(downloadUrl); + } + } + + return null; + }, +}; + +// Helper re-exported so other adapters in this folder can build their +// own ExportSinkOptions when delegating to the webapp adapter. +export type { ExportSinkOptions }; diff --git a/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/media-source-adapter.ts b/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/media-source-adapter.ts new file mode 100644 index 0000000000..bf4a98019a --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/media-source-adapter.ts @@ -0,0 +1,156 @@ +import type { + MediaHandle, + MediaKind, + MediaSourceAdapter, + ResolvedMedia, +} from "@storyteller/ui-video-editor"; +import { kindFromMime } from "@storyteller/ui-video-editor"; +import { MediaFilesApi } from "@storyteller/api"; +import { uploadByKind } from "./upload-by-kind"; + +// MediaFileClass enum values from @storyteller/api as string literals. +// @storyteller/api doesn't re-export the enum from its main entry, so +// we compare against the string values directly. Keep in sync with +// libs/api/src/lib/enums/MediaFileClass.ts. +type ApiMediaFileClass = "video" | "audio" | "image" | "unknown" | string; + +// Webapp MediaSourceAdapter — uploads files through MediaUploadApi +// (video / image / audio endpoints) and resolves stored media via +// MediaFilesApi.GetMediaFileByToken. The MediaHandle.id is the +// media_file_token returned by upload; resolveMedia maps the token to +// the CDN URL stored on the MediaFile model. + +const filesApi = new MediaFilesApi(); + +function kindFromMediaClass(mediaClass: ApiMediaFileClass | null): MediaKind { + if (mediaClass === "video") return "video"; + if (mediaClass === "audio") return "audio"; + return "image"; +} + +const EXT_TO_MIME: Record = { + // video + mp4: "video/mp4", + webm: "video/webm", + mov: "video/quicktime", + m4v: "video/x-m4v", + mkv: "video/x-matroska", + avi: "video/x-msvideo", + // audio + mp3: "audio/mpeg", + wav: "audio/wav", + ogg: "audio/ogg", + oga: "audio/ogg", + m4a: "audio/mp4", + aac: "audio/aac", + flac: "audio/flac", + // image + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + bmp: "image/bmp", + avif: "image/avif", +}; + +function extensionOf(path: string | null | undefined): string | null { + if (!path) return null; + // Trim querystring/hash before extracting the extension so URLs like + // /file.webm?token=abc still produce "webm". + const trimmed = path.split(/[?#]/)[0]; + const dot = trimmed.lastIndexOf("."); + if (dot === -1 || dot === trimmed.length - 1) return null; + return trimmed.slice(dot + 1).toLowerCase(); +} + +// Resolve mime by reading the real file extension (from +// maybe_original_filename, falling back to the CDN URL and then to the +// public bucket path). Drops back to a coarse class-based default only +// when none of those carry a recognised extension. The class default +// loses container-level info — playing a .webm video through a path +// that asserts video/mp4 made mediabunny pick the wrong demuxer. +function mimeForMediaFile({ + maybeOriginalFilename, + cdnUrl, + publicBucketPath, + mediaClass, +}: { + maybeOriginalFilename: string | null; + cdnUrl: string; + publicBucketPath: string; + mediaClass: ApiMediaFileClass | null; +}): string { + const candidates = [maybeOriginalFilename, cdnUrl, publicBucketPath]; + for (const candidate of candidates) { + const ext = extensionOf(candidate); + if (ext && EXT_TO_MIME[ext]) return EXT_TO_MIME[ext]; + } + if (mediaClass === "video") return "video/mp4"; + if (mediaClass === "audio") return "audio/mpeg"; + if (mediaClass === "image") return "image/png"; + return "application/octet-stream"; +} + +export const webappMediaSourceAdapter: MediaSourceAdapter = { + async uploadLocalFile(file: File): Promise { + const kind = kindFromMime(file.type); + const id = await uploadByKind({ + kind, + blob: file, + fileName: file.name, + }); + return { id, kind }; + }, + + async resolveMedia(handle: MediaHandle): Promise { + const response = await filesApi.GetMediaFileByToken({ + mediaFileToken: handle.id, + }); + if (!response.success || !response.data) { + throw new Error( + response.errorMessage || `Failed to resolve media ${handle.id}`, + ); + } + const media = response.data; + return { + url: media.media_links.cdn_url, + mime: mimeForMediaFile({ + maybeOriginalFilename: media.maybe_original_filename, + cdnUrl: media.media_links.cdn_url, + publicBucketPath: media.public_bucket_path, + mediaClass: media.media_class, + }), + durationMs: media.maybe_duration_millis ?? undefined, + }; + }, + + // HTTP CDN URLs don't need explicit release; the browser tears them + // down on tab close. Override only if the host needs reference + // counting (e.g. signed-URL TTL management). + releaseResolved() { + // no-op + }, + + // Called by processMediaAssets when a fresh upload made it to the + // server but a later step failed (resolveMedia error, decode error) + // and the asset never landed in a project. Delete the orphan so the + // user's media library doesn't accumulate dangling entries. + async deleteHandle(handle) { + const response = await filesApi.DeleteMediaFileByToken({ + mediaFileToken: handle.id, + asMod: false, + }); + if (!response.success) { + throw new Error( + response.errorMessage || `Failed to delete media ${handle.id}`, + ); + } + }, +}; + +// Re-exported so other adapters in this folder (e.g. asset gallery) +// can map MediaFile.media_class → MediaKind without re-importing the +// enum. +export { kindFromMediaClass }; diff --git a/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/toast-adapter.ts b/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/toast-adapter.ts new file mode 100644 index 0000000000..afccfe998f --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/toast-adapter.ts @@ -0,0 +1,30 @@ +import type { ToastAdapter, ToastOptions } from "@storyteller/ui-video-editor"; +import { showToast } from "../../../components/toast/toast"; + +// Webapp ToastAdapter — routes lib toasts through the existing +// `showToast` event dispatcher in apps/artcraft-webapp/src/components/ +// toast/toast.tsx. The webapp toast surface only renders success/error +// styles, so info/warning collapse to success/error respectively. +// +// ToastOptions.description is appended after a newline because the +// webapp toast UI doesn't render a separate description slot. + +function formatMessage(message: string, options?: ToastOptions): string { + if (!options?.description) return message; + return `${message}\n${options.description}`; +} + +export const webappToastAdapter: ToastAdapter = { + info(message, options) { + showToast("success", formatMessage(message, options)); + }, + success(message, options) { + showToast("success", formatMessage(message, options)); + }, + warning(message, options) { + showToast("error", formatMessage(message, options)); + }, + error(message, options) { + showToast("error", formatMessage(message, options)); + }, +}; diff --git a/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/upload-by-kind.ts b/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/upload-by-kind.ts new file mode 100644 index 0000000000..358344c928 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/video-editor/adapters/upload-by-kind.ts @@ -0,0 +1,36 @@ +import type { MediaKind } from "@storyteller/ui-video-editor"; +import { MediaUploadApi } from "@storyteller/api"; + +// Single dispatch from MediaKind to the right MediaUploadApi endpoint. +// Both the media-source adapter (uploadLocalFile) and the export-sink +// adapter (uploadToLibrary) need this. Factoring it here means a future +// upload-API change (e.g. a new required field) lands in one place. + +const api = new MediaUploadApi(); + +export async function uploadByKind({ + kind, + blob, + fileName, + title, +}: { + kind: MediaKind; + blob: Blob; + fileName: string; + title?: string; +}): Promise { + const uuid = crypto.randomUUID(); + const maybe_title = title ?? fileName; + + const response = + kind === "video" + ? await api.UploadNewVideo({ uuid, blob, fileName, maybe_title }) + : kind === "audio" + ? await api.UploadAudio({ uuid, blob, fileName, maybe_title }) + : await api.UploadImage({ uuid, blob, fileName, maybe_title }); + + if (!response.success || !response.data) { + throw new Error(response.errorMessage || "Upload failed"); + } + return response.data; +} diff --git a/frontend/apps/artcraft-webapp/src/pages/video-editor/index.ts b/frontend/apps/artcraft-webapp/src/pages/video-editor/index.ts new file mode 100644 index 0000000000..928073a4e2 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/video-editor/index.ts @@ -0,0 +1 @@ +export { default } from "./video-editor"; diff --git a/frontend/apps/artcraft-webapp/src/pages/video-editor/video-editor.tsx b/frontend/apps/artcraft-webapp/src/pages/video-editor/video-editor.tsx new file mode 100644 index 0000000000..c7772f3685 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/video-editor/video-editor.tsx @@ -0,0 +1,110 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useParams } from "react-router-dom"; +import { + VideoEditor, + EditorCore, + buildDefaultScene, + createDefaultAdapters, + ZERO_MEDIA_TIME, + type TProject, + type VideoEditorAdapters, +} from "@storyteller/ui-video-editor"; +import { webappToastAdapter } from "./adapters/toast-adapter"; +import { webappAuthUserAdapter } from "./adapters/auth-user-adapter"; +import { webappMediaSourceAdapter } from "./adapters/media-source-adapter"; +import { webappExportSinkAdapter } from "./adapters/export-sink-adapter"; +import { useWebappAssetGalleryAdapter } from "./adapters/asset-gallery-adapter"; +import { TopBarActions } from "../../components/topbar/TopBarActions"; + +// Webapp host for the @storyteller/ui-video-editor lib. +// +// Bootstraps a default in-memory project so the editor shell can mount +// with an active scene (replace with a real load via +// ProjectStorageAdapter once the backend exists), and injects the +// webapp adapters that route through Artcraft's MediaUploadApi / +// MediaFilesApi / gallery modal / session store / toast event bus. + +function buildBootstrapProject({ id }: { id: string }): TProject { + const scene = buildDefaultScene({ name: "Main scene", isMain: true }); + const now = new Date(); + return { + metadata: { + id, + name: "Untitled project", + duration: ZERO_MEDIA_TIME, + createdAt: now, + updatedAt: now, + }, + scenes: [scene], + currentSceneId: scene.id, + settings: { + fps: { numerator: 30, denominator: 1 } as never, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + version: 1, + }; +} + +export default function VideoEditorPage() { + const { projectId } = useParams<{ projectId?: string }>(); + const [ready, setReady] = useState(false); + + const { adapter: assetGalleryAdapter, modal: galleryModal } = + useWebappAssetGalleryAdapter(); + + const adapters = useMemo>( + () => ({ + toast: webappToastAdapter, + authUser: webappAuthUserAdapter, + mediaSource: webappMediaSourceAdapter, + assetGallery: assetGalleryAdapter, + exportSink: webappExportSinkAdapter, + }), + [assetGalleryAdapter], + ); + + // Stable transient id for the "no projectId in route" case. Computed + // once on first mount and reused across StrictMode double-mount, + // adapter ref churn, and any other dep change so we never overwrite + // the in-memory project with a fresh bootstrap. + const transientIdRef = useRef(`local-${crypto.randomUUID()}`); + const resolvedProjectId = projectId ?? transientIdRef.current; + + useEffect(() => { + // Explicit initialize ensures the webapp adapters are installed + // before getInstance() lazy-creates with defaults. EditorProvider's + // own initialize call (later, when mounts) is a no-op + // because the instance now exists with our adapter bundle. + EditorCore.initialize({ + adapters: { ...createDefaultAdapters(), ...adapters }, + }); + const editor = EditorCore.getInstance(); + + // Bootstrap only when the active project doesn't already match the + // requested id. StrictMode mount → unmount → re-mount with the same + // id is a no-op; a route nav to a different :projectId reloads. + if (editor.project.getActive()?.metadata.id !== resolvedProjectId) { + const project = buildBootstrapProject({ id: resolvedProjectId }); + editor.project.setActiveProject({ project }); + editor.scenes.initializeScenes({ + scenes: project.scenes, + currentSceneId: project.currentSceneId, + }); + } + setReady(true); + }, [resolvedProjectId, adapters]); + + if (!ready) return null; + + return ( +
+ } + /> + {galleryModal} +
+ ); +} diff --git a/frontend/apps/artcraft-webapp/src/styles.css b/frontend/apps/artcraft-webapp/src/styles.css index b083344992..4a78ce3704 100644 --- a/frontend/apps/artcraft-webapp/src/styles.css +++ b/frontend/apps/artcraft-webapp/src/styles.css @@ -12,6 +12,29 @@ --sidebar-accent: rgba(255, 255, 255, 0.06); --sidebar-accent-fg: #ffffff; --sidebar-ring: rgba(45, 129, 255, 0.4); + + /* shadcn design tokens consumed by @storyteller/ui-video-editor. + Dark-theme palette (the editor only runs in dark mode today). + --primary matches the webapp's brand blue (#2d81ff). */ + --background: 0 0% 9%; + --foreground: 0 0% 98%; + --card: 0 0% 12%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 12%; + --popover-foreground: 0 0% 98%; + --primary: 216 100% 59%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 24%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 18%; + --muted-foreground: 0 0% 64%; + --accent: 0 0% 16%; + --accent-foreground: 0 0% 98%; + --destructive: 0 70% 50%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 18%; + --input: 0 0% 18%; + --ring: 217 91% 60%; } } diff --git a/frontend/apps/artcraft-webapp/tailwind.config.js b/frontend/apps/artcraft-webapp/tailwind.config.js index ba5d24b66f..7c84ed1204 100644 --- a/frontend/apps/artcraft-webapp/tailwind.config.js +++ b/frontend/apps/artcraft-webapp/tailwind.config.js @@ -170,6 +170,34 @@ module.exports = { "axis-x": "#D33242", "axis-y": "#308752", "axis-z": "#2E70FF", + + // shadcn tokens consumed by @storyteller/ui-video-editor. CSS + // variables live in apps/artcraft-webapp/src/styles.css. + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", }, }, plugins: [], diff --git a/frontend/apps/artcraft-webapp/tsconfig.app.json b/frontend/apps/artcraft-webapp/tsconfig.app.json index 3332488252..55de523f88 100644 --- a/frontend/apps/artcraft-webapp/tsconfig.app.json +++ b/frontend/apps/artcraft-webapp/tsconfig.app.json @@ -46,9 +46,6 @@ { "path": "../../libs/components/slider-v2/tsconfig.lib.json" }, - { - "path": "../../libs/components/vfx/tsconfig.lib.json" - }, { "path": "../../libs/components/gallery-modal/tsconfig.lib.json" }, @@ -86,10 +83,10 @@ "path": "../../libs/components/loading-spinner/tsconfig.lib.json" }, { - "path": "../../libs/components/tooltip/tsconfig.lib.json" + "path": "../../libs/components/popover/tsconfig.lib.json" }, { - "path": "../../libs/components/popover/tsconfig.lib.json" + "path": "../../libs/components/tooltip/tsconfig.lib.json" }, { "path": "../../libs/components/modal/tsconfig.lib.json" @@ -100,11 +97,17 @@ { "path": "../../libs/components/button/tsconfig.lib.json" }, + { + "path": "../../libs/components/vfx/tsconfig.lib.json" + }, { "path": "../../libs/common/tsconfig.lib.json" }, { "path": "../../libs/api/tsconfig.lib.json" + }, + { + "path": "../../libs/components/video-editor/tsconfig.lib.json" } ] } diff --git a/frontend/apps/artcraft-webapp/tsconfig.json b/frontend/apps/artcraft-webapp/tsconfig.json index 121ce58afc..768afcb8a0 100644 --- a/frontend/apps/artcraft-webapp/tsconfig.json +++ b/frontend/apps/artcraft-webapp/tsconfig.json @@ -11,9 +11,6 @@ { "path": "../../libs/components/slider-v2" }, - { - "path": "../../libs/components/vfx" - }, { "path": "../../libs/components/gallery-modal" }, @@ -51,10 +48,10 @@ "path": "../../libs/components/loading-spinner" }, { - "path": "../../libs/components/tooltip" + "path": "../../libs/components/popover" }, { - "path": "../../libs/components/popover" + "path": "../../libs/components/tooltip" }, { "path": "../../libs/components/modal" @@ -65,12 +62,18 @@ { "path": "../../libs/components/button" }, + { + "path": "../../libs/components/vfx" + }, { "path": "../../libs/common" }, { "path": "../../libs/api" }, + { + "path": "../../libs/components/video-editor" + }, { "path": "./tsconfig.app.json" }, diff --git a/frontend/apps/artcraft-webapp/vite.config.ts b/frontend/apps/artcraft-webapp/vite.config.ts index e3455431ad..de78ac621e 100644 --- a/frontend/apps/artcraft-webapp/vite.config.ts +++ b/frontend/apps/artcraft-webapp/vite.config.ts @@ -1,6 +1,8 @@ /// import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import wasm from 'vite-plugin-wasm'; +import topLevelAwait from 'vite-plugin-top-level-await'; import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; import { execSync } from 'child_process'; import path from 'path'; @@ -42,7 +44,7 @@ export default defineConfig(() => ({ port: 4301, host: 'localhost', }, - plugins: [generateNewsPlugin(), nxViteTsPaths(), react()], + plugins: [generateNewsPlugin(), nxViteTsPaths(), react(), wasm(), topLevelAwait()], // Uncomment this if you are using workers. // worker: { // plugins: [ nxViteTsPaths() ], diff --git a/frontend/apps/artcraft/app/src/config/appMenu.tsx b/frontend/apps/artcraft/app/src/config/appMenu.tsx index 53c9f217dc..e2683deccd 100644 --- a/frontend/apps/artcraft/app/src/config/appMenu.tsx +++ b/frontend/apps/artcraft/app/src/config/appMenu.tsx @@ -33,7 +33,8 @@ export type AppId = | "REMOVE_BACKGROUND" | "ANGLES" | "STORYBOARD" - | "BACKGROUND_CHANGE"; + | "BACKGROUND_CHANGE" + | "VIDEO_EDITOR"; export interface AppDescriptor { id: AppId; @@ -79,7 +80,7 @@ export interface FullAppItem { description: string; icon: IconDefinition; category: "generate" | "edit"; - badge?: "NEW" | "BEST" | "SOON"; + badge?: "NEW" | "BEST" | "SOON" | "BETA"; action?: AppId; color?: string; } @@ -197,6 +198,16 @@ export const ALL_APPS: FullAppItem[] = [ color: "bg-orange-500/40", badge: "NEW", }, + { + id: "video-editor", + label: "Video Editor", + description: "Edit and assemble videos on a timeline", + icon: faFilm, + category: "edit", + action: "VIDEO_EDITOR", + color: "bg-teal-500/40", + badge: "BETA", + }, { id: "2d-canvas", label: "Image Editor", @@ -259,7 +270,9 @@ export const getBadgeStyles = (badge?: string) => { case "BEST": return "bg-primary text-white"; case "SOON": - return "bg-ui-controls text-base-fg"; + return "bg-gray-600 text-white"; + case "BETA": + return "bg-amber-500 text-white"; default: return ""; } @@ -282,6 +295,7 @@ export const goToApp = (action?: string) => { "ANGLES", "STORYBOARD", "BACKGROUND_CHANGE", + "VIDEO_EDITOR", ].includes(action) ) { if (action === "STORYBOARD") { diff --git a/frontend/apps/artcraft/app/src/pages/MainApp.tsx b/frontend/apps/artcraft/app/src/pages/MainApp.tsx index c116a91a35..be67064f04 100644 --- a/frontend/apps/artcraft/app/src/pages/MainApp.tsx +++ b/frontend/apps/artcraft/app/src/pages/MainApp.tsx @@ -73,6 +73,7 @@ import { Angles } from "./PageAngles"; import { Storyboard } from "./PageStoryboard"; import { PageBackgroundChange } from "./PageBackgroundChange"; import { PageScene } from "./PageScene"; +import { PageVideoEditor } from "./PageVideoEditor"; import { topNavMediaId, topNavMediaUrl, @@ -254,6 +255,8 @@ const TabBody = ({ sceneToken }: { sceneToken?: string }) => { return storyboardPageEnabled ?
: null; case "BACKGROUND_CHANGE": return
; + case "VIDEO_EDITOR": + return
; default: return null; } diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/asset-gallery-adapter.tsx b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/asset-gallery-adapter.tsx new file mode 100644 index 0000000000..98bf6b1b24 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/asset-gallery-adapter.tsx @@ -0,0 +1,125 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type ReactElement, +} from "react"; +import type { + AssetGalleryAdapter, + MediaKind, + MediaPickerSelection, +} from "@storyteller/ui-video-editor"; +import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal"; + +// Tauri AssetGalleryAdapter — wraps @storyteller/ui-gallery-modal in a +// promise-shaped openPicker, matching the webapp's pattern verbatim. +// The gallery modal is session-agnostic — it talks to the same HTTPS +// `/v1/session` endpoint as the webapp, so it works in the Tauri +// WebView with no Tauri-specific wiring. + +type Resolver = (selections: MediaPickerSelection[]) => void; + +interface GalleryControlState { + open: boolean; + filter: string | undefined; +} + +interface AssetGalleryHostBundle { + adapter: AssetGalleryAdapter; + // Render this alongside . The host doesn't need to wire + // any props — state is owned by the hook. + modal: ReactElement; +} + +function kindsToFilter(kinds: MediaKind[]): string | undefined { + if (kinds.length === 1) return kinds[0]; + return undefined; +} + +function mediaClassToKind(mediaClass: string | undefined): MediaKind { + if (mediaClass === "video") return "video"; + if (mediaClass === "audio") return "audio"; + return "image"; +} + +export function useTauriAssetGalleryAdapter(): AssetGalleryHostBundle { + const [state, setState] = useState({ + open: false, + filter: undefined, + }); + // GalleryModal at mode="select" only registers user clicks when an + // onSelectItem handler is wired; without selectedItemIds tracking + // the "Use selected" button hands back an empty array. + const [selectedItemIds, setSelectedItemIds] = useState([]); + const resolverRef = useRef(null); + + // Settle any pending resolver (resolve-empty). Used when a second + // openPicker arrives before the first one resolved, or when the + // hook unmounts mid-flow. Without this the first promise hangs + // forever and the caller's UI stays stuck in its "processing" state. + const settlePending = (selections: MediaPickerSelection[]) => { + const resolver = resolverRef.current; + resolverRef.current = null; + resolver?.(selections); + }; + + // Unmount cleanup — fail any in-flight picker rather than leak it. + useEffect(() => { + return () => { + settlePending([]); + }; + // settlePending closes over the ref; safe to omit from deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const adapter = useMemo( + () => ({ + openPicker({ kinds }) { + return new Promise((resolve) => { + settlePending([]); + setSelectedItemIds([]); + resolverRef.current = resolve; + setState({ open: true, filter: kindsToFilter(kinds) }); + }); + }, + }), + [], + ); + + const handleSelectItem = (id: string) => { + setSelectedItemIds((prev) => + prev.includes(id) ? prev.filter((existing) => existing !== id) : [...prev, id], + ); + }; + + const handleUseSelected = (items: GalleryItem[]) => { + const selections: MediaPickerSelection[] = items.map((item) => ({ + handle: { id: item.id, kind: mediaClassToKind(item.mediaClass) }, + name: item.label, + })); + setState({ open: false, filter: undefined }); + setSelectedItemIds([]); + settlePending(selections); + }; + + const handleClose = () => { + setState({ open: false, filter: undefined }); + setSelectedItemIds([]); + settlePending([]); + }; + + const modal = ( + + ); + + return { adapter, modal }; +} diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/auth-user-adapter.ts b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/auth-user-adapter.ts new file mode 100644 index 0000000000..479794a141 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/auth-user-adapter.ts @@ -0,0 +1,56 @@ +import type { AuthUser, AuthUserAdapter } from "@storyteller/ui-video-editor"; +import type { UserInfo } from "~/models"; +import { authentication } from "~/signals/authentication/authentication"; + +// Tauri AuthUserAdapter — wraps the Preact signal that holds the +// signed-in user. currentUser() reads the signal's value; subscribe() +// re-derives the projected AuthUser and notifies only on actual change. +// +// currentUser() must return a stable reference across calls while the +// underlying UserInfo identity is unchanged — React's +// useSyncExternalStore loop sees a new snapshot on every render +// otherwise and infinite-loops. The cache lives at module scope and +// is **only mutated by snapshot()**. Subscribers each maintain their +// own `previous` baseline so the cache can't be observed in a way +// that races between concurrent subscribers (the bug a webapp version +// of this file had earlier, which silently dropped sign-in/sign-out +// notifications to the second-and-later useAuthUser consumer when one +// ran before the others). + +function projectUser(user: UserInfo | undefined): AuthUser | null { + if (!user) return null; + return { id: user.user_token, displayName: user.display_name }; +} + +function authUsersEqual(a: AuthUser | null, b: AuthUser | null): boolean { + if (a === b) return true; + if (a === null || b === null) return false; + return a.id === b.id && a.displayName === b.displayName; +} + +let cachedSource: UserInfo | undefined = undefined; +let cachedResult: AuthUser | null = null; + +function snapshot(): AuthUser | null { + const source = authentication.userInfo.value; + if (source === cachedSource) return cachedResult; + cachedSource = source; + cachedResult = projectUser(source); + return cachedResult; +} + +export const tauriAuthUserAdapter: AuthUserAdapter = { + currentUser() { + return snapshot(); + }, + subscribe(listener) { + // Per-listener `previous` — closed-over, not shared. + let previous = projectUser(authentication.userInfo.value); + return authentication.userInfo.subscribe((value) => { + const next = projectUser(value); + if (authUsersEqual(previous, next)) return; + previous = next; + listener(next); + }); + }, +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/export-sink-adapter.ts b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/export-sink-adapter.ts new file mode 100644 index 0000000000..c9621749cb --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/export-sink-adapter.ts @@ -0,0 +1,140 @@ +import type { + ExportArtifact, + ExportSinkAdapter, +} from "@storyteller/ui-video-editor"; +import { kindFromMime } from "@storyteller/ui-video-editor"; +import { + promptDownloadLocationIfNeeded, + downloadUrlToPath, +} from "@storyteller/api"; +import { downloadDir } from "@tauri-apps/api/path"; +import { uploadByKind } from "./upload-by-kind"; + +// Tauri ExportSinkAdapter. +// +// Two destinations the user controls from the export popover: +// - saveToDisk: native save-as dialog (promptDownloadLocationIfNeeded +// falls back to the OS Downloads dir when the "Ask location before +// download" setting is off) → downloadUrlToPath writes the file. +// - saveToLibrary: UploadNewVideo / UploadAudio / UploadImage mirrors +// the render into the user's Artcraft media library. +// +// Progress events fire per destination so the lib's popover can render +// inline status rows; accept() resolves only after every requested +// destination settled. In-flight Set dedupes concurrent invocations. +// +// Return value: the absolute path of the file written to disk when +// saveToDisk succeeded, otherwise `null`. Matches the +// ExportSinkAdapter contract — the webapp adapter always returns +// `null` because the browser can't observe the chosen download path. + +const inFlight = new Set(); + +async function ensureSavePath( + url: string, + filename: string, +): Promise { + const chosen = await promptDownloadLocationIfNeeded(url); + if (chosen === null) { + // User explicitly cancelled the dialog. + return null; + } + if (typeof chosen === "string") return chosen; + // "Ask location before download" is off — default to Downloads/. + const dir = await downloadDir(); + return `${dir}/${filename}`; +} + +async function uploadToLibrary(artifact: ExportArtifact): Promise { + const baseTitle = artifact.filename.replace(/\.[^.]+$/, ""); + await uploadByKind({ + kind: kindFromMime(artifact.mime), + blob: artifact.blob, + fileName: artifact.filename, + title: baseTitle, + }); +} + +export const tauriExportSinkAdapter: ExportSinkAdapter = { + async accept(artifact, options) { + const saveToDisk = options?.saveToDisk ?? true; + const saveToLibrary = options?.saveToLibrary ?? false; + const onProgress = options?.onProgress; + + if (!saveToDisk && !saveToLibrary) { + return null; + } + + const downloadUrl = URL.createObjectURL(artifact.blob); + + if (inFlight.has(artifact.filename)) { + if (saveToDisk) { + onProgress?.({ + destination: "disk", + status: "error", + error: "Already saving this export", + }); + } + if (saveToLibrary) { + onProgress?.({ + destination: "library", + status: "error", + error: "Already uploading this export", + }); + } + setTimeout(() => URL.revokeObjectURL(downloadUrl), 60_000); + return null; + } + inFlight.add(artifact.filename); + + let savedPath: string | null = null; + try { + if (saveToDisk) { + onProgress?.({ destination: "disk", status: "pending" }); + try { + const targetPath = await ensureSavePath(downloadUrl, artifact.filename); + if (targetPath) { + await downloadUrlToPath(downloadUrl, targetPath); + savedPath = targetPath; + onProgress?.({ destination: "disk", status: "success" }); + } else { + // User cancelled the dialog — treat as error so the row + // doesn't look like a silent success. + onProgress?.({ + destination: "disk", + status: "error", + error: "Save dialog cancelled", + }); + } + } catch (error) { + console.error("Export disk save failed:", error); + onProgress?.({ + destination: "disk", + status: "error", + error: error instanceof Error ? error.message : "Disk save failed", + }); + } + } + + if (saveToLibrary) { + onProgress?.({ destination: "library", status: "uploading" }); + try { + await uploadToLibrary(artifact); + onProgress?.({ destination: "library", status: "success" }); + } catch (error) { + console.error("Export library upload failed:", error); + onProgress?.({ + destination: "library", + status: "error", + error: error instanceof Error ? error.message : "Upload failed", + }); + } + } + } finally { + inFlight.delete(artifact.filename); + URL.revokeObjectURL(downloadUrl); + } + + return savedPath; + }, +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/media-source-adapter.ts b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/media-source-adapter.ts new file mode 100644 index 0000000000..f3361d2d37 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/media-source-adapter.ts @@ -0,0 +1,137 @@ +import type { + MediaHandle, + MediaKind, + MediaSourceAdapter, + ResolvedMedia, +} from "@storyteller/ui-video-editor"; +import { kindFromMime } from "@storyteller/ui-video-editor"; +import { MediaFilesApi } from "@storyteller/api"; +import { uploadByKind } from "./upload-by-kind"; + +// Tauri MediaSourceAdapter — same shape as the webapp's adapter. +// Uploads route through MediaUploadApi (HTTPS), resolveMedia hits +// MediaFilesApi.GetMediaFileByToken, deleteHandle calls +// DeleteMediaFileByToken to roll back failed-mid-pipeline uploads. +// +// Tauri-specific drag-drops (OS-level paths through +// appWindow.onDragDropEvent) are converted to File objects in the +// PageVideoEditor's drop-bridge component before being fed into +// processMediaAssets — so this adapter never sees a Tauri-shaped +// payload, only standard Files. That keeps the adapter contract +// portable between webapp and desktop. + +type ApiMediaFileClass = "video" | "audio" | "image" | "unknown" | string; + +const filesApi = new MediaFilesApi(); + +function kindFromMediaClass(mediaClass: ApiMediaFileClass | null): MediaKind { + if (mediaClass === "video") return "video"; + if (mediaClass === "audio") return "audio"; + return "image"; +} + +const EXT_TO_MIME: Record = { + mp4: "video/mp4", + webm: "video/webm", + mov: "video/quicktime", + m4v: "video/x-m4v", + mkv: "video/x-matroska", + avi: "video/x-msvideo", + mp3: "audio/mpeg", + wav: "audio/wav", + ogg: "audio/ogg", + oga: "audio/ogg", + m4a: "audio/mp4", + aac: "audio/aac", + flac: "audio/flac", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + bmp: "image/bmp", + avif: "image/avif", +}; + +function extensionOf(path: string | null | undefined): string | null { + if (!path) return null; + const trimmed = path.split(/[?#]/)[0]; + const dot = trimmed.lastIndexOf("."); + if (dot === -1 || dot === trimmed.length - 1) return null; + return trimmed.slice(dot + 1).toLowerCase(); +} + +function mimeForMediaFile({ + maybeOriginalFilename, + cdnUrl, + publicBucketPath, + mediaClass, +}: { + maybeOriginalFilename: string | null; + cdnUrl: string; + publicBucketPath: string; + mediaClass: ApiMediaFileClass | null; +}): string { + const candidates = [maybeOriginalFilename, cdnUrl, publicBucketPath]; + for (const candidate of candidates) { + const ext = extensionOf(candidate); + if (ext && EXT_TO_MIME[ext]) return EXT_TO_MIME[ext]; + } + if (mediaClass === "video") return "video/mp4"; + if (mediaClass === "audio") return "audio/mpeg"; + if (mediaClass === "image") return "image/png"; + return "application/octet-stream"; +} + +export const tauriMediaSourceAdapter: MediaSourceAdapter = { + async uploadLocalFile(file: File): Promise { + const kind = kindFromMime(file.type); + const id = await uploadByKind({ + kind, + blob: file, + fileName: file.name, + }); + return { id, kind }; + }, + + async resolveMedia(handle: MediaHandle): Promise { + const response = await filesApi.GetMediaFileByToken({ + mediaFileToken: handle.id, + }); + if (!response.success || !response.data) { + throw new Error( + response.errorMessage || `Failed to resolve media ${handle.id}`, + ); + } + const media = response.data; + return { + url: media.media_links.cdn_url, + mime: mimeForMediaFile({ + maybeOriginalFilename: media.maybe_original_filename, + cdnUrl: media.media_links.cdn_url, + publicBucketPath: media.public_bucket_path, + mediaClass: media.media_class, + }), + durationMs: media.maybe_duration_millis ?? undefined, + }; + }, + + releaseResolved() { + // no-op — HTTP URLs don't need explicit release + }, + + async deleteHandle(handle) { + const response = await filesApi.DeleteMediaFileByToken({ + mediaFileToken: handle.id, + asMod: false, + }); + if (!response.success) { + throw new Error( + response.errorMessage || `Failed to delete media ${handle.id}`, + ); + } + }, +}; + +export { kindFromMediaClass }; diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/toast-adapter.ts b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/toast-adapter.ts new file mode 100644 index 0000000000..a93fb06aa6 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/toast-adapter.ts @@ -0,0 +1,31 @@ +import type { ToastAdapter, ToastOptions } from "@storyteller/ui-video-editor"; +import { ToastTypes } from "~/enums"; +import { addToast } from "~/signals/toasts"; + +// Tauri ToastAdapter — routes lib toasts through the Artcraft desktop +// app's existing signal-based toast system (signals/toasts.ts + +// mounted in MainApp). +// +// ToastTypes only has SUCCESS / WARNING / ERROR, so `info` collapses to +// SUCCESS (closest visual fallback). ToastOptions.description is +// concatenated after a newline because the toast UI is single-line. + +function formatMessage(message: string, options?: ToastOptions): string { + if (!options?.description) return message; + return `${message}\n${options.description}`; +} + +export const tauriToastAdapter: ToastAdapter = { + info(message, options) { + addToast(ToastTypes.SUCCESS, formatMessage(message, options)); + }, + success(message, options) { + addToast(ToastTypes.SUCCESS, formatMessage(message, options)); + }, + warning(message, options) { + addToast(ToastTypes.WARNING, formatMessage(message, options)); + }, + error(message, options) { + addToast(ToastTypes.ERROR, formatMessage(message, options)); + }, +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/upload-by-kind.ts b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/upload-by-kind.ts new file mode 100644 index 0000000000..af113ae4fa --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/upload-by-kind.ts @@ -0,0 +1,41 @@ +import type { MediaKind } from "@storyteller/ui-video-editor"; +import { MediaUploadApi } from "@storyteller/api"; + +// Single dispatch from MediaKind to the right MediaUploadApi endpoint. +// Both the media-source adapter (uploadLocalFile) and the export-sink +// adapter (uploadToLibrary) need this. Factoring it here means a future +// upload-API change (e.g. a new required field) lands in one place. +// +// Mirror of the webapp's helper at +// apps/artcraft-webapp/src/pages/video-editor/adapters/upload-by-kind.ts — +// the two adapters could share a workspace lib later but the helper +// is tiny, so duplication is cheap during initial Tauri integration. + +const api = new MediaUploadApi(); + +export async function uploadByKind({ + kind, + blob, + fileName, + title, +}: { + kind: MediaKind; + blob: Blob; + fileName: string; + title?: string; +}): Promise { + const uuid = crypto.randomUUID(); + const maybe_title = title ?? fileName; + + const response = + kind === "video" + ? await api.UploadNewVideo({ uuid, blob, fileName, maybe_title }) + : kind === "audio" + ? await api.UploadAudio({ uuid, blob, fileName, maybe_title }) + : await api.UploadImage({ uuid, blob, fileName, maybe_title }); + + if (!response.success || !response.data) { + throw new Error(response.errorMessage || "Upload failed"); + } + return response.data; +} diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/index.tsx b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/index.tsx new file mode 100644 index 0000000000..a4352d4a3d --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/index.tsx @@ -0,0 +1,100 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + VideoEditor, + EditorCore, + buildDefaultScene, + createDefaultAdapters, + ZERO_MEDIA_TIME, + type TProject, + type VideoEditorAdapters, +} from "@storyteller/ui-video-editor"; +import { tauriToastAdapter } from "./adapters/toast-adapter"; +import { tauriAuthUserAdapter } from "./adapters/auth-user-adapter"; +import { tauriMediaSourceAdapter } from "./adapters/media-source-adapter"; +import { tauriExportSinkAdapter } from "./adapters/export-sink-adapter"; +import { useTauriAssetGalleryAdapter } from "./adapters/asset-gallery-adapter"; + +// Tauri host for the @storyteller/ui-video-editor lib. +// +// Bootstraps an in-memory project (the IndexedDB project-storage default +// still persists across WebView restarts; a Tauri-specific filesystem- +// backed adapter is a future option but not needed yet) and wires +// every adapter through Tauri-native infra: addToast signal, +// authentication.userInfo signal, MediaUploadApi / MediaFilesApi, +// promptDownloadLocationIfNeeded for save-as. +// +// Mounted inside MainApp's tab switch — see MainApp.tsx case +// "VIDEO_EDITOR". + +function buildBootstrapProject({ id }: { id: string }): TProject { + const scene = buildDefaultScene({ name: "Main scene", isMain: true }); + const now = new Date(); + return { + metadata: { + id, + name: "Untitled project", + duration: ZERO_MEDIA_TIME, + createdAt: now, + updatedAt: now, + }, + scenes: [scene], + currentSceneId: scene.id, + settings: { + fps: { numerator: 30, denominator: 1 } as never, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + version: 1, + }; +} + +export function PageVideoEditor() { + const [ready, setReady] = useState(false); + + const { adapter: assetGalleryAdapter, modal: galleryModal } = + useTauriAssetGalleryAdapter(); + + const adapters = useMemo>( + () => ({ + toast: tauriToastAdapter, + authUser: tauriAuthUserAdapter, + mediaSource: tauriMediaSourceAdapter, + assetGallery: assetGalleryAdapter, + exportSink: tauriExportSinkAdapter, + }), + [assetGalleryAdapter], + ); + + // Stable transient id so StrictMode + adapter ref churn don't + // reset the project on every effect re-run. + const transientIdRef = useRef(`local-${crypto.randomUUID()}`); + const resolvedProjectId = transientIdRef.current; + + useEffect(() => { + // Explicit initialize before EditorProvider mounts so the Tauri + // adapter bundle wins the first-call-wins idempotent semantics. + EditorCore.initialize({ + adapters: { ...createDefaultAdapters(), ...adapters }, + }); + const editor = EditorCore.getInstance(); + + if (editor.project.getActive()?.metadata.id !== resolvedProjectId) { + const project = buildBootstrapProject({ id: resolvedProjectId }); + editor.project.setActiveProject({ project }); + editor.scenes.initializeScenes({ + scenes: project.scenes, + currentSceneId: project.currentSceneId, + }); + } + setReady(true); + }, [resolvedProjectId, adapters]); + + if (!ready) return null; + + return ( +
+ + {galleryModal} +
+ ); +} diff --git a/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts b/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts index 4291562f4e..3e470ac1b9 100644 --- a/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts +++ b/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts @@ -16,7 +16,8 @@ export type TabId = | "REMOVE_BACKGROUND" | "ANGLES" | "STORYBOARD" - | "BACKGROUND_CHANGE"; + | "BACKGROUND_CHANGE" + | "VIDEO_EDITOR"; const DEFAULT_TAB: TabId = "IMAGE"; diff --git a/frontend/apps/artcraft/app/src/styles/base.css b/frontend/apps/artcraft/app/src/styles/base.css index 53ea74a5b9..0d93b4cf3d 100644 --- a/frontend/apps/artcraft/app/src/styles/base.css +++ b/frontend/apps/artcraft/app/src/styles/base.css @@ -79,6 +79,30 @@ body { --st-photo-grayscale: 1; /* Theme-aware badge alpha (default dark) */ --st-badge-alpha: 0.4; + + /* shadcn design tokens consumed by @storyteller/ui-video-editor. + Dark-theme palette — the editor only runs in dark mode today. + --primary matches the webapp's brand blue (#2d81ff) so the + selection ring is consistent across hosts. */ + --background: 0 0% 9%; + --foreground: 0 0% 98%; + --card: 0 0% 12%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 12%; + --popover-foreground: 0 0% 98%; + --primary: 216 100% 59%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 24%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 18%; + --muted-foreground: 0 0% 64%; + --accent: 0 0% 16%; + --accent-foreground: 0 0% 98%; + --destructive: 0 70% 50%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 18%; + --input: 0 0% 18%; + --ring: 217 91% 60%; } /* Light theme */ diff --git a/frontend/apps/artcraft/package.json b/frontend/apps/artcraft/package.json index 6b236682f5..ebf7596a99 100644 --- a/frontend/apps/artcraft/package.json +++ b/frontend/apps/artcraft/package.json @@ -36,6 +36,8 @@ "@mediapipe/tasks-vision": "^0.10.20", "@preact/signals-react": "^2.0.1", "@radix-ui/react-tooltip": "^1.2.7", + "@storyteller/ui-gallery-modal": "workspace:*", + "@storyteller/ui-video-editor": "workspace:*", "@remix-run/deno": "^2.8.1", "@remix-run/node": "^2.8.1", "@remix-run/react": "^2.17.4", diff --git a/frontend/apps/artcraft/tailwind.config.ts b/frontend/apps/artcraft/tailwind.config.ts index 5598a82b40..7097e7b4d1 100644 --- a/frontend/apps/artcraft/tailwind.config.ts +++ b/frontend/apps/artcraft/tailwind.config.ts @@ -248,6 +248,37 @@ export default { "axis-x": "#D33242", "axis-y": "#308752", "axis-z": "#2E70FF", + + // shadcn tokens consumed by @storyteller/ui-video-editor. CSS + // variables live in app/src/styles/base.css. primary/secondary + // are intentionally NOT mapped here — the Tauri app already has + // its own brand colors elsewhere in this palette and the editor + // chrome inherits them via the --st-* surfaces. + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", facebook: "#4267b2", reddit: "#ff5700", whatsapp: "#25d366", diff --git a/frontend/apps/artcraft/tsconfig.json b/frontend/apps/artcraft/tsconfig.json index 50f7f82710..e9ad9b4136 100644 --- a/frontend/apps/artcraft/tsconfig.json +++ b/frontend/apps/artcraft/tsconfig.json @@ -40,6 +40,9 @@ { "path": "../../libs/components/loading-spinner" }, + { + "path": "../../libs/components/video-editor" + }, { "path": "../../libs/components/toaster" }, diff --git a/frontend/apps/artcraft/vite.config.ts b/frontend/apps/artcraft/vite.config.ts index 548c96224f..7f253d9082 100644 --- a/frontend/apps/artcraft/vite.config.ts +++ b/frontend/apps/artcraft/vite.config.ts @@ -3,6 +3,7 @@ import tsconfigPaths from "vite-tsconfig-paths"; import path from "path"; import { resolve } from "node:path"; import wasm from "vite-plugin-wasm"; +import topLevelAwait from "vite-plugin-top-level-await"; // NB(bt): This configuration file can specify bundler rollup options, compiler plugins, // import path resolution, dev server HTTP headers, CORS options, path rewriting, etc. @@ -56,7 +57,7 @@ export default defineConfig({ }, }, }, - plugins: [sparkWasmDataUrlFix(), tsconfigPaths(), wasm()], + plugins: [sparkWasmDataUrlFix(), tsconfigPaths(), wasm(), topLevelAwait()], server: { fs: { allow: [workspaceRoot], diff --git a/frontend/libs/components/upload-modal/src/lib/UploadModal3D/UploadFiles3D.tsx b/frontend/libs/components/upload-modal/src/lib/UploadModal3D/UploadFiles3D.tsx index 10a53de379..7b81c1e92e 100644 --- a/frontend/libs/components/upload-modal/src/lib/UploadModal3D/UploadFiles3D.tsx +++ b/frontend/libs/components/upload-modal/src/lib/UploadModal3D/UploadFiles3D.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from "react-resizable-panels"; +import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels"; import { ListDropdown } from "@storyteller/ui-list-dropdown"; import { Button } from "@storyteller/ui-button"; import { FileUploader } from "@storyteller/ui-file-uploader"; diff --git a/frontend/libs/components/upload-modal/src/lib/UploadModalImage/UploadFilesImage.tsx b/frontend/libs/components/upload-modal/src/lib/UploadModalImage/UploadFilesImage.tsx index 85a7beea70..d2c88deb63 100644 --- a/frontend/libs/components/upload-modal/src/lib/UploadModalImage/UploadFilesImage.tsx +++ b/frontend/libs/components/upload-modal/src/lib/UploadModalImage/UploadFilesImage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from "react-resizable-panels"; +import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels"; import { Button } from "@storyteller/ui-button"; import { FileUploader } from "@storyteller/ui-file-uploader"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; diff --git a/frontend/libs/components/upload-modal/src/lib/UploadModalSplat/UploadFilesSplat.tsx b/frontend/libs/components/upload-modal/src/lib/UploadModalSplat/UploadFilesSplat.tsx index e8c6f562df..18a57998e7 100644 --- a/frontend/libs/components/upload-modal/src/lib/UploadModalSplat/UploadFilesSplat.tsx +++ b/frontend/libs/components/upload-modal/src/lib/UploadModalSplat/UploadFilesSplat.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from "react-resizable-panels"; +import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels"; import { ListDropdown } from "@storyteller/ui-list-dropdown"; import { Button } from "@storyteller/ui-button"; import { FileUploader } from "@storyteller/ui-file-uploader"; diff --git a/frontend/libs/components/video-editor/package.json b/frontend/libs/components/video-editor/package.json new file mode 100644 index 0000000000..50a7d28ee2 --- /dev/null +++ b/frontend/libs/components/video-editor/package.json @@ -0,0 +1,17 @@ +{ + "name": "@storyteller/ui-video-editor", + "version": "0.0.1", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + } +} diff --git a/frontend/libs/components/video-editor/src/index.ts b/frontend/libs/components/video-editor/src/index.ts new file mode 100644 index 0000000000..2656877153 --- /dev/null +++ b/frontend/libs/components/video-editor/src/index.ts @@ -0,0 +1,1233 @@ +// Public API for @storyteller/ui-video-editor. + +// --- Top-level component --- +// The bundled editor shell: provider + header + assets / preview / +// properties / timeline split + onboarding + mobile gate. Most hosts +// just mount ; those that want their own shell can +// compose with and the individual panel exports below. +export { VideoEditor } from "./lib/VideoEditor"; +export type { VideoEditorProps } from "./lib/VideoEditor"; + +// --- Provider + hook (use only when mounting the inner shell directly) --- +export { EditorProvider, useEditorAdapters } from "./lib/EditorProvider"; +export type { EditorProviderProps } from "./lib/EditorProvider"; + +// --- Adapter interfaces — hosts implement these --- +export type { + MediaKind, + MediaHandle, + MediaProbe, + ResolvedMedia, + ProjectMeta, + EditorProject, + AuthUser, + ExportArtifact, + ProjectStorageAdapter, + MediaSourceAdapter, + AssetGalleryAdapter, + MediaPickerSelection, + AuthUserAdapter, + ExportSinkAdapter, + ExportSinkOptions, + ExportSinkProgressEvent, + ExportDestination, + ToastAdapter, + ToastOptions, + VideoEditorAdapters, +} from "./lib/adapters"; + +// --- Default adapters --- +// Useful for tests and as a baseline for hosts that want to mix in +// just one Artcraft-specific implementation. +export { + createDefaultAdapters, + createIndexedDBProjectStorage, + createLocalFileMediaSource, + anonymousAuthUser, + downloadExportSink, + consoleToast, +} from "./lib/adapters/default"; + +// --- Adapter helpers --- +// Small utilities every MediaSourceAdapter implementation needs; +// exposed so hosts don't re-implement them inconsistently. +export { kindFromMime } from "./lib/adapters"; + +// --- MediaTime + frame math (the wasm boundary) --- +export { + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, + mediaTime, + roundMediaTime, + mediaTimeFromSeconds, + mediaTimeToSeconds, + addMediaTime, + subMediaTime, + maxMediaTime, + minMediaTime, + clampMediaTime, + roundFrameTime, + roundFrameTicks, + snapSeekMediaTime, + lastFrameMediaTime, + parseMediaTimecode, +} from "./lib/wasm"; +export type { MediaTime } from "./lib/wasm"; + +// --- Timeline math + scale --- +export { + BASE_TIMELINE_PIXELS_PER_SECOND, + TIMELINE_ZOOM_MIN, + TIMELINE_ZOOM_MAX, +} from "./lib/timeline/scale"; +export { + TIMELINE_INDICATOR_LINE_WIDTH_PX, + getTimelinePixelsPerSecond, + timelineTimeToPixels, + snapPixelToDeviceGrid, + timelineTimeToSnappedPixels, + getCenteredLineLeft, +} from "./lib/timeline/pixel-utils"; +export { + getTimelineZoomMin, + getTimelinePaddingPx, + getZoomPercent, + sliderToZoom, + zoomToSlider, +} from "./lib/timeline/zoom-utils"; + +// --- Timeline controllers — the smoothness-critical layer --- +// Each takes a *ConfigRef whose `.current` the host updates on each +// render. The controllers attach window-level mousemove/mouseup during +// active sessions and write playhead position imperatively. Do not +// wrap the scrub callbacks with React state writes — break the +// contract and scrubbing visibly stutters. +export { SeekController } from "./lib/timeline/controllers/seek-controller"; +export type { + SeekConfig, + SeekConfigRef, +} from "./lib/timeline/controllers/seek-controller"; +export { ZoomController } from "./lib/timeline/controllers/zoom-controller"; +export type { + ZoomConfig, + ZoomConfigRef, +} from "./lib/timeline/controllers/zoom-controller"; +export { PlayheadController } from "./lib/timeline/controllers/playhead-controller"; +export type { + PlayheadConfig, + PlayheadConfigRef, +} from "./lib/timeline/controllers/playhead-controller"; + +// --- Remaining timeline controllers --- +// Same smoothness contract as the controllers above — class-based, +// configRef pattern, window-level mousemove/mouseup during active +// sessions, no React state in the hot path. +export { DragDropController } from "./lib/timeline/controllers/drag-drop-controller"; +export type { + DragDropConfig, + DragDropConfigRef, + ProcessedMediaAsset, +} from "./lib/timeline/controllers/drag-drop-controller"; +export { ElementInteractionController } from "./lib/timeline/controllers/element-interaction-controller"; +export type { + ElementInteractionDeps, + ElementInteractionDepsRef, + ViewportAdapter, + InputAdapter, + SceneReader, + ElementSelectionApi, + PlaybackReader, + TimelineOps, + SnapConfig, +} from "./lib/timeline/controllers/element-interaction-controller"; +export { KeyframeDragController } from "./lib/timeline/controllers/keyframe-drag-controller"; +export type { + KeyframeDragConfig, + KeyframeDragConfigRef, + KeyframeDragState, +} from "./lib/timeline/controllers/keyframe-drag-controller"; +export { ResizeController } from "./lib/timeline/controllers/resize-controller"; +export type { + ResizeConfig, + ResizeConfigRef, +} from "./lib/timeline/controllers/resize-controller"; + +// --- Timeline snapping --- +export { + buildTimelineSnapPoints, + resolveTimelineSnap, + getTimelineSnapThresholdInTicks, +} from "./lib/timeline/snapping"; +export type { + SnapPoint, + SnapPointType, + SnapResult, + TimelineSnapPointSource, +} from "./lib/timeline/snapping"; + +// --- Editor stores --- +export { useEditorStore } from "./lib/editor/editor-store"; +export { usePanelStore } from "./lib/editor/panel-store"; +export type { PanelSizes, PanelId } from "./lib/editor/panel-store"; +export { + registerCanceller, + cancelInteraction, +} from "./lib/editor/cancel-interaction"; + +// --- Timeline element constructors + type guards --- +// Hosts that want to inject elements directly (drag-and-drop from the +// Artcraft gallery, etc.) build CreateTimelineElement values via these +// helpers, then dispatch them through the EditorCore.timeline manager +// (once that lands) or directly into the store. +export { + canElementHaveAudio, + isVisualElement, + isMaskableElement, + isRetimableElement, + canElementBeHidden, + hasElementEffects, + hasMediaId, + requiresMediaId, + buildTextElement, + buildEffectElement, + buildStickerElement, + buildGraphicElement, + buildElementFromMedia, + buildLibraryAudioElement, + getElementsAtTime, + getElementFontFamilies, +} from "./lib/timeline/element-utils"; + +// --- Scenes --- +export { + getMainScene, + ensureMainScene, + buildDefaultScene, + canDeleteScene, + getFallbackSceneAfterDelete, + findCurrentScene, + getProjectDurationFromScenes, + updateSceneInArray, +} from "./lib/timeline/scenes"; +export { calculateTotalDuration } from "./lib/timeline/calculate-duration"; + +// --- Effects + Graphics + Animation registries --- +// Hosts that want to register custom effects or graphics call into +// these registries at startup. The default sets land via +// registerDefaultEffects / registerDefaultGraphics (called from +// EditorCore once that ports; safe to call manually before then). +export { + effectsRegistry, + registerDefaultEffects, + resolveEffectPasses, + buildDefaultEffectInstance, + EFFECT_TARGET_ELEMENT_TYPES, +} from "./lib/effects"; +export type { + Effect, + EffectDefinition, + EffectPass, + EffectPassTemplate, + EffectRendererConfig, + EffectUniformValue, +} from "./lib/effects/types"; + +export { + graphicsRegistry, + registerDefaultGraphics, + getGraphicDefinition, + buildDefaultGraphicInstance, + resolveGraphicParams, + resolveGraphicElementParamsAtTime, + buildGraphicPreviewUrl, + DEFAULT_GRAPHIC_SOURCE_SIZE, + rectangleGraphicDefinition, + ellipseGraphicDefinition, + polygonGraphicDefinition, + starGraphicDefinition, +} from "./lib/graphics"; +export type { + GraphicDefinition, + GraphicInstance, + GraphicRenderContext, +} from "./lib/graphics/types"; + +// --- Animation surface --- +export { + getChannelValueAtTime, + resolveAnimationPathValueAtTime, + getElementLocalTime, + getElementKeyframes, + upsertPathKeyframe, + removeElementKeyframe, + retimeElementKeyframe, + cloneAnimations, + splitAnimationsAtTime, + clampAnimationsToDuration, + isAnimationPath, + isAnimationPropertyPath, +} from "./lib/animation"; +export type { + ElementAnimations, + ElementKeyframe, + AnimationChannel, + AnimationPath, + AnimationPropertyPath, + ScalarAnimationChannel, + ScalarAnimationKey, +} from "./lib/animation/types"; + +// --- Rendering primitives --- +export { + buildTransformFromParams, + readOpacityFromParams, + readBlendModeFromParams, +} from "./lib/rendering"; +export { resolveTransformAtTime } from "./lib/rendering/animation-values"; +export type { Transform, BlendMode } from "./lib/rendering"; + +// --- Params + registry --- +export type { + ParamValue, + ParamValues, + ParamDefinition, + ParamChannelLayout, + LinearRgba, +} from "./lib/params"; +export { + buildDefaultParamValues, + getElementParams, + getBuiltInElementParams, + readElementParamValue, + writeElementParamValue, + buildElementParamValues, +} from "./lib/params/registry"; + +// --- Timeline domain types --- +export type { + TimelineElement, + TimelineTrack, + SceneTracks, + TScene, + Bookmark, + ElementRef, + ElementType, + TrackType, + VideoElement, + ImageElement, + AudioElement, + TextElement, + GraphicElement, + StickerElement, + EffectElement, + VisualElement, + MaskableElement, + RetimableElement, + DropTarget, + ComputeDropTargetParams, + ClipboardItem, + CreateTimelineElement, +} from "./lib/timeline/types"; +export type { + TProject, + TProjectMetadata, + TProjectSettings, + TCanvasSize, + TBackground, + TTimelineViewState, +} from "./lib/project/types"; + +// --- Masks --- +// Full mask subsystem (including freeform pen tool). Hosts can list +// definitions via getMaskDefinitionsForMenu, register their own via +// masksRegistry.registerMask, or build a mask instance via +// buildDefaultMaskInstance + the mask shape's params. +export { + masksRegistry, + registerDefaultMasks, + getMaskDefinition, + getMaskDefinitionsForMenu, + buildDefaultMaskInstance, +} from "./lib/masks"; +export type { + Mask, + MaskType, + MaskDefinition, + MaskInteractionDefinition, + MaskInteractionResult, + MaskHandlePosition, + MaskHandleId, + MaskOverlay, + MaskRenderer, + MaskFeatures, + MaskSnapArgs, + MaskSnapResult, + MaskParamUpdateArgs, + BaseMaskParams, + RectangleMaskParams, + SplitMaskParams, + TextMaskParams, + FreeformPathPoint, +} from "./lib/masks/types"; + +// --- Preview canvas math --- +export { + getVisibleElementsWithBounds, + getCornerPosition, + getEdgeHandlePosition, + ROTATION_HANDLE_OFFSET, +} from "./lib/preview/element-bounds"; +export type { + ElementBounds, + ElementWithBounds, + Corner, + Edge, +} from "./lib/preview/element-bounds"; +export { hitTest, getHitElements, resolvePreferredHit } from "./lib/preview/hit-test"; +export { + snapPosition, + snapScale, + snapScaleAxes, + snapRotation, + MIN_SCALE, + SNAP_THRESHOLD_SCREEN_PIXELS, +} from "./lib/preview/preview-snap"; +export type { + SnapLine, + ScaleEdgePreference, + SnapResult as PreviewSnapResult, + ScaleSnapResult, + AxisSnapResult, + RotationSnapResult, +} from "./lib/preview/preview-snap"; + +// --- Retime (audio mastering) --- +export { + DEFAULT_RETIME_RATE, + MIN_RETIME_RATE, + MAX_RETIME_RATE, + clampRetimeRate, + buildConstantRetime, + getSourceTimeAtClipTime, + getClipTimeAtSourceTime, + getEffectiveRateAt, + getTimelineDurationForSourceSpan, + getSourceSpanAtClipTime, + renderRetimedBuffer, +} from "./lib/retime"; +export type { RetimeConfig } from "./lib/timeline/types"; + +// --- Audio state (gain/mute/volume animation) --- +export { + clampDb, + dBToLinear, + getElementVolume, + isElementMuted, + hasAnimatedVolume, + resolveEffectiveAudioGain, + buildWaveformGainSamples, + buildAudioGainAutomation, +} from "./lib/timeline/audio-state"; +export { VOLUME_DB_MIN, VOLUME_DB_MAX } from "./lib/timeline/audio-constants"; + +// --- Timeline placement --- +export { + resolveTrackPlacement, + applyPlacement, + canElementGoOnTrack, + validateElementTrackCompatibility, + buildEmptyTrack, + MAIN_TRACK_NAME, + enforceMainTrackStart, + getEarliestMainTrackElement, +} from "./lib/timeline/placement"; +export type { + PlacementResult, + PlacementStrategy, + PlacementSubject, + PlacementTimeSpan, +} from "./lib/timeline/placement"; + +// --- Group move + resize solvers --- +export { + buildMoveGroup, + resolveGroupMove, + snapGroupEdges, +} from "./lib/timeline/group-move"; +export type { + MoveGroup, + GroupMember, + GroupMoveResult, + GroupTrackSection, + PlannedElementMove, + PlannedTrackCreation, +} from "./lib/timeline/group-move"; +export { computeGroupResize } from "./lib/timeline/group-resize"; +export type { + ComputeGroupResizeArgs, + GroupResizeMember, + GroupResizeResult, + GroupResizeUpdate, + ResizeSide, +} from "./lib/timeline/group-resize"; + +// --- Timeline animation targets + update pipeline --- +export { + resolveAnimationTarget, +} from "./lib/timeline/animation-targets"; +export type { AnimationPathDescriptor } from "./lib/timeline/animation-targets"; +export { applyElementUpdate } from "./lib/timeline/update-pipeline"; +export type { ElementUpdateContext } from "./lib/timeline/update-pipeline"; + +// --- Audio separation (extract / recover source audio) --- +export { + isSourceAudioEnabled, + isSourceAudioSeparated, + canExtractSourceAudio, + canRecoverSourceAudio, + canToggleSourceAudio, + doesElementHaveEnabledAudio, + buildSeparatedAudioElement, + getSourceAudioActionLabel, +} from "./lib/timeline/audio-separation"; + +// --- Ripple (gap-closing after deletes/shrinks) --- +export { + applyRippleAdjustments, + computeRippleAdjustments, + rippleShiftElements, +} from "./lib/ripple"; +export type { RippleAdjustment } from "./lib/ripple"; + +// --- Commands base + clipboard types --- +export { Command } from "./lib/commands/base-command"; +export { BatchCommand } from "./lib/commands/batch-command"; +export { PreviewTracker } from "./lib/commands/preview-tracker"; +export type { CommandResult } from "./lib/commands/base-command"; + +// --- Timeline commands --- +export { + TracksSnapshotCommand, + // Element commands + InsertElementCommand, + DeleteElementsCommand, + DuplicateElementsCommand, + SplitElementsCommand, + UpdateElementsCommand, + ToggleSourceAudioSeparationCommand, + MoveElementCommand, + // Effect commands + AddClipEffectCommand, + RemoveClipEffectCommand, + ToggleClipEffectCommand, + UpdateClipEffectParamsCommand, + ReorderClipEffectsCommand, + // Keyframe commands + RemoveEffectParamKeyframeCommand, + RemoveKeyframeCommand, + RetimeKeyframeCommand, + UpdateScalarKeyframeCurveCommand, + UpsertEffectParamKeyframeCommand, + UpsertKeyframeCommand, + // Mask commands + DeleteFreeformPathMaskPointsCommand, + InsertFreeformPathMaskPointCommand, + RemoveMaskCommand, + ToggleMaskInvertedCommand, + // Track commands + AddTrackCommand, + RemoveTrackCommand, + ToggleTrackMuteCommand, + ToggleTrackVisibilityCommand, + // Clipboard commands + PasteCommand, + PasteKeyframesCommand, +} from "./lib/commands/timeline"; +export type { + ClipboardEntry, + ClipboardEntryType, + ClipboardEntryByType, + ClipboardHandler, + ClipboardHandlerMap, + CopyContext, + PasteContext, + ElementClipboardItem, + ElementsClipboardEntry, + KeyframeClipboardItem, + KeyframesClipboardEntry, +} from "./lib/clipboard/types"; + +// --- Selection types --- +export type { + EditorSelectionPatch, + EditorSelectionSnapshot, + EditorSelectionKind, + SelectedMaskPointSelection, +} from "./lib/selection/editor-selection"; + +// --- Media runtime (audio decode + mixing, waveform sampling) --- +export { + createAudioContext, + decodeAudioToFloat32, + collectAudibleCandidates, + collectAudioElements, + collectAudioMixSources, + collectAudioClips, + createTimelineAudioBuffer, + timelineHasAudio, + extractPeakRange, + extractRmsRange, + extractRmsBuckets, + getSampleBucketRange, +} from "./lib/media/audio"; +export type { + CollectedAudioElement, + DecodedAudio, + AudibleElementCandidate, + AudioClipSource, +} from "./lib/media/audio"; +export { + buildSourceWaveformSummary, + buildWaveformSampleBuckets, + sampleSourceWaveformSummary, + computeRmsBuckets, +} from "./lib/media/waveform-summary"; +export type { + SourceWaveformSummary, + SampleBucket, +} from "./lib/media/waveform-summary"; +export { + applyAudioMasteringToBuffer, + createAudioMasteringChain, + getAudioBufferPeak, +} from "./lib/media/audio-mastering"; +export { + getMediaTypeFromFile, + mediaSupportsAudio, + SUPPORTS_AUDIO, +} from "./lib/media/media-utils"; +export type { MediaAsset, MediaType } from "./lib/media/types"; + +// --- Caching services (decoded video frames + waveform summaries) --- +export { VideoCache, videoCache } from "./lib/services/video-cache"; +export { WaveformCache, waveformCache } from "./lib/services/waveform-cache"; + +// --- EditorCore + managers --- +// Hosts that need direct access (e.g. command palette wiring, +// keyboard shortcuts) call EditorCore.getInstance(). The instance is +// created on first access with bundled default adapters; the +// EditorProvider re-initializes it with the host's adapters on mount. +export { EditorCore } from "./lib/core"; +export { CommandManager } from "./lib/core/managers/commands"; +export { TimelineManager } from "./lib/core/managers/timeline-manager"; +export { PlaybackManager } from "./lib/core/managers/playback-manager"; +export { ScenesManager } from "./lib/core/managers/scenes-manager"; +export { ProjectManager } from "./lib/core/managers/project-manager"; +export { MediaManager } from "./lib/core/managers/media-manager"; +export { RendererManager } from "./lib/core/managers/renderer-manager"; +export { SaveManager } from "./lib/core/managers/save-manager"; +export { AudioManager } from "./lib/core/managers/audio-manager"; +export { SelectionManager } from "./lib/core/managers/selection-manager"; +export { ClipboardManager } from "./lib/core/managers/clipboard-manager"; +export { DiagnosticsManager } from "./lib/core/managers/diagnostics-manager"; + +// --- Clipboard handlers --- +export { + clipboardHandlers, + clipboardCopyHandlers, + copyClipboardEntry, + buildPasteClipboardCommand, +} from "./lib/clipboard/handlers"; + +// --- Rendering subsystem --- +// CanvasRenderer + SceneExporter wrap the wasm compositor; buildScene +// turns timeline state into the RootNode tree the renderer consumes. +// Node classes are exported for hosts that need to construct render +// trees manually (e.g. preview composites or custom export pipelines). +export { CanvasRenderer } from "./lib/services/renderer/canvas-renderer"; +export type { CanvasRendererParams } from "./lib/services/renderer/canvas-renderer"; +export { SceneExporter } from "./lib/services/renderer/scene-exporter"; +export type { SceneExporterEvents } from "./lib/services/renderer/scene-exporter"; +export { buildScene } from "./lib/services/renderer/scene-builder"; +export type { BuildSceneParams } from "./lib/services/renderer/scene-builder"; +export { BaseNode } from "./lib/services/renderer/nodes/base-node"; +export type { AnyBaseNode, BaseNodeParams } from "./lib/services/renderer/nodes/base-node"; +export { RootNode } from "./lib/services/renderer/nodes/root-node"; +export type { RootNodeParams } from "./lib/services/renderer/nodes/root-node"; +export { ColorNode } from "./lib/services/renderer/nodes/color-node"; +export type { ColorNodeParams } from "./lib/services/renderer/nodes/color-node"; +export { VideoNode } from "./lib/services/renderer/nodes/video-node"; +export type { VideoNodeParams } from "./lib/services/renderer/nodes/video-node"; +export { ImageNode, loadImageSource } from "./lib/services/renderer/nodes/image-node"; +export type { + ImageNodeParams, + CachedImageSource, +} from "./lib/services/renderer/nodes/image-node"; +export { StickerNode, loadStickerSource } from "./lib/services/renderer/nodes/sticker-node"; +export type { StickerNodeParams } from "./lib/services/renderer/nodes/sticker-node"; +export { GraphicNode } from "./lib/services/renderer/nodes/graphic-node"; +export type { + GraphicNodeParams, + ResolvedGraphicNodeState, +} from "./lib/services/renderer/nodes/graphic-node"; +export { TextNode, renderTextToContext } from "./lib/services/renderer/nodes/text-node"; +export type { + TextNodeParams, + ResolvedTextNodeState, +} from "./lib/services/renderer/nodes/text-node"; +export { BlurBackgroundNode } from "./lib/services/renderer/nodes/blur-background-node"; +export type { + BlurBackgroundNodeParams, + BackdropSource, + ResolvedBlurBackgroundNodeState, +} from "./lib/services/renderer/nodes/blur-background-node"; +export { EffectLayerNode } from "./lib/services/renderer/nodes/effect-layer-node"; +export type { + EffectLayerNodeParams, + ResolvedEffectLayerNodeState, +} from "./lib/services/renderer/nodes/effect-layer-node"; +export { VisualNode } from "./lib/services/renderer/nodes/visual-node"; +export type { + VisualNodeParams, + ResolvedVisualNodeState, + ResolvedVisualSourceNodeState, +} from "./lib/services/renderer/nodes/visual-node"; +export { effectPreviewService } from "./lib/services/renderer/effect-preview"; +export { + gpuRenderer, + initializeGpuRenderer, + isGpuAvailable, +} from "./lib/services/renderer/gpu-renderer"; +export { applyMaskFeather } from "./lib/services/renderer/mask-feather"; +export { createCanvasSurface } from "./lib/services/renderer/canvas-utils"; + +// --- Export module --- +export { + EXPORT_FORMAT_VALUES, + EXPORT_QUALITY_VALUES, + getExportFileExtension, + getExportMimeType, + downloadBuffer, +} from "./lib/export"; +export type { + ExportFormat, + ExportQuality, + ExportOptions, + ExportResult, + ExportState, +} from "./lib/export"; +export { DEFAULT_EXPORT_OPTIONS } from "./lib/export/defaults"; +export { EXPORT_MIME_TYPES } from "./lib/export/mime-types"; + +// --- Gradients --- +export { drawCssBackground, parseGradient, GradientParser } from "./lib/gradients"; +export type { + GradientAst, + GradientOrientation, + Color, + ColorStop, +} from "./lib/gradients"; + +// --- Background defaults --- +export { + BACKGROUND_BLUR_INTENSITY_PRESETS, + DEFAULT_BACKGROUND_BLUR_INTENSITY, +} from "./lib/background/blur"; +export { DEFAULT_BACKGROUND_COLOR } from "./lib/background/color"; + +// --- Timeline UI --- +// The full Timeline subsystem ported from opencut-classic: ruler, tracks, +// elements, playhead, snap indicator, audio waveform/volume line, drag line, +// toolbar (zoom slider, snap/ripple toggles, graph editor popover), and the +// timeline-store for UI prefs (snapping, ripple, expanded keyframe lanes). +// +// Hosts mount below an wrapping their state +// managers. The component is the single render surface for the timeline panel. +export { Timeline } from "./lib/timeline/components"; +export { TimelineToolbar } from "./lib/timeline/components/timeline-toolbar"; +export { TimelinePlayhead } from "./lib/timeline/components/timeline-playhead"; +export { TimelineElement as TimelineElementView } from "./lib/timeline/components/timeline-element"; +export { AudioWaveform } from "./lib/timeline/components/audio-waveform"; +export { AudioVolumeLine } from "./lib/timeline/components/audio-volume-line"; +export { SnapIndicator } from "./lib/timeline/components/snap-indicator"; +export { GraphEditorPopover } from "./lib/timeline/components/graph-editor/popover"; +export { useGraphEditorController } from "./lib/timeline/components/graph-editor/use-controller"; +export { TimelineTick } from "./lib/timeline/components/timeline-tick"; +export { DragLine } from "./lib/timeline/components/drag-line"; +export { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./lib/timeline/components/graph-editor/bezier-graph"; + +// --- Timeline UI store (snapping + ripple toggles, expanded keyframe IDs) --- +export { useTimelineStore } from "./lib/timeline/timeline-store"; + +// --- Timeline component layout constants + helpers --- +// Re-exported so hosts can lay out custom timeline-adjacent UI consistently. +export { + TIMELINE_TRACK_HEIGHTS_PX, + KEYFRAME_LANE_HEIGHT_PX, + KEYFRAME_DIAMOND_SIZE_PX, + EXPANDED_GROUP_HEADER_HEIGHT_PX, + TIMELINE_TRACK_GAP_PX, + TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX, + TIMELINE_RULER_HEIGHT_PX, + TIMELINE_BOOKMARK_ROW_HEIGHT_PX, + TIMELINE_SCROLLBAR_SIZE_PX, + TIMELINE_CONTENT_TOP_PADDING_PX, +} from "./lib/timeline/components/layout"; +export { + getTrackHeight, + getExpandedTrackHeight, + getCumulativeHeightBefore, + getTotalTracksHeight, +} from "./lib/timeline/components/track-layout"; +export { TIMELINE_LAYERS } from "./lib/timeline/components/layers"; +export { + TIMELINE_TRACK_THEME, + TIMELINE_AUDIO_WAVEFORM_COLOR, + SELECTED_TRACK_ROW_CLASS, + DEFAULT_TIMELINE_BOOKMARK_COLOR, + getTimelineElementClassName, +} from "./lib/timeline/components/theme"; +export { + TIMELINE_DRAG_THRESHOLD_PX, + TIMELINE_HORIZONTAL_WHEEL_STEP_PX, + TIMELINE_ZOOM_BUTTON_FACTOR, + TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD, +} from "./lib/timeline/components/interaction"; +export { + computeTrackExpansionHeight, + getTrackExpandedRows, + getExpandedRows, + getExpansionHeight, + getPropertyLabel, +} from "./lib/timeline/components/expanded-layout"; +export type { ExpandedRow } from "./lib/timeline/components/expanded-layout"; +export { + computeDropTarget, + getDropLineY, +} from "./lib/timeline/components/drop-target"; +export { resolveTimelineElementIntersections } from "./lib/timeline/components/selection-hit-testing"; + +// --- Audio display curves (db ↔ line-position mapping for AudioVolumeLine) --- +export { + getLinePosFromDb, + getDbFromLinePos, + getBarFractionFromOutputAmplitude, +} from "./lib/timeline/audio-display"; + +// --- Timeline bookmarks --- +export { + TimelineBookmarksRow, + useBookmarkDrag, + findBookmarkIndex, + isBookmarkAtTime, + toggleBookmarkInArray, + removeBookmarkFromArray, + updateBookmarkInArray, + moveBookmarkInArray, + getFrameTime, + getBookmarkAtTime, + getBookmarksActiveAtTime, + getBookmarkSnapPoints, + bookmarkNotesPreviewOverlay, + getBookmarkPreviewOverlaySource, +} from "./lib/timeline/bookmarks"; +export type { BookmarkDragState } from "./lib/timeline/bookmarks"; + +// --- Graph editor (curve editor popover for keyframe easing) --- +export { + BUILTIN_PRESETS, + PRESET_MATCH_TOLERANCE, +} from "./lib/timeline/components/graph-editor/easing-presets"; +export type { EasingPreset } from "./lib/timeline/components/graph-editor/easing-presets"; +export { + useCustomPresets, + savePreset, + removePreset, +} from "./lib/timeline/components/graph-editor/custom-presets-store"; + + +// --- PreviewPanel + preview canvas surface --- +// The PreviewPanel is the main top component for the preview viewport. It +// owns the compositor canvas, the transform/mask handle overlays, the +// snap-guide overlay, the zoom/pan gestures, and the right-click context +// menu. Mount it inside and pass it the overlay catalog +// (definitions + instances) plus a visibility callback — the lib reads +// overlay visibility state from `usePreviewStore`. PreviewViewport is +// exposed for hosts that want to construct a custom outer shell without +// re-implementing the zoom/pan/cursor math; usePreviewViewport gives +// access to the same context value inside child trees. +export { PreviewPanel } from "./lib/preview/components"; +export { + PreviewViewportProvider, + usePreviewViewport, + usePreviewViewportState, +} from "./lib/preview/components/preview-viewport"; +export { PreviewOverlayLayer } from "./lib/preview/components/overlay-layer"; +export { PreviewInteractionOverlay } from "./lib/preview/components/preview-interaction-overlay"; +export { TransformHandles } from "./lib/preview/components/transform-handles"; +export { MaskHandles } from "./lib/preview/components/mask-handles"; +export { SnapGuides } from "./lib/preview/components/snap-guides"; +export { TextEditOverlay } from "./lib/preview/components/text-edit-overlay"; +export { PreviewToolbar } from "./lib/preview/components/toolbar"; +export { PreviewContextMenu } from "./lib/preview/components/context-menu"; +export { GridPopover } from "./lib/preview/components/guide-popover"; +export { PEN_CURSOR } from "./lib/preview/components/cursors"; + +// Preview store (Zustand) — tracks the active guide, grid config, and +// overlay visibility. Hosts that mount their own preview shell can read +// `state.overlays[overlayId]` and pipe overlay control checkboxes through +// `setOverlayVisibility`. +export { usePreviewStore } from "./lib/preview/preview-store"; + +// Preview interaction hooks. Pointer + transform-handle controllers wrap +// the smoothness-critical drag/scale/rotate gestures. These are useful for +// hosts that want a custom preview shell but keep the canonical gesture +// stack. Both call `useEditor()` internally and write through the +// EditorCore managers — no extra wiring beyond . +export { usePreviewInteraction } from "./lib/preview/hooks/use-preview-interaction"; +export type { OnSnapLinesChange } from "./lib/preview/hooks/use-preview-interaction"; +export { useTransformHandles } from "./lib/preview/hooks/use-transform-handles"; + +// Preview interaction controllers (class-based, same smoothness contract +// as the timeline controllers). Hosts building entirely custom React +// glue can drive these directly with their own depsRef. +export { PreviewInteractionController } from "./lib/preview/controllers/preview-interaction-controller"; +export type { + PreviewInteractionDeps, + PreviewInteractionDepsRef, + EditingTextState, + PreviewViewportAdapter as PreviewInteractionViewportAdapter, +} from "./lib/preview/controllers/preview-interaction-controller"; +export { TransformHandleController } from "./lib/preview/controllers/transform-handle-controller"; +export type { + TransformHandleDeps, + TransformHandleDepsRef, + PreviewViewportAdapter as TransformHandleViewportAdapter, +} from "./lib/preview/controllers/transform-handle-controller"; + +// Preview coords (logical-canvas <-> screen-overlay math) and zoom presets. +export { + screenToCanvas, + canvasToOverlay, + positionToOverlay, + getDisplayScale, + screenPixelsToLogicalThreshold, +} from "./lib/preview/preview-coords"; +export type { PreviewViewportGeometry } from "./lib/preview/preview-coords"; +export { PREVIEW_ZOOM, PREVIEW_ZOOM_PRESETS } from "./lib/preview/zoom"; + +// Preview overlays — registry types for hosts that want to publish +// overlay instances (e.g. an export-progress HUD, a custom guide overlay) +// into the preview surface. +export { + EMPTY_PREVIEW_OVERLAY_SOURCE_RESULT, + isPreviewOverlayVisible, + createPreviewOverlayControl, + mergePreviewOverlaySources, +} from "./lib/preview/overlays"; +export type { + PreviewOverlayHudAnchor, + PreviewOverlayMount, + PreviewOverlayPlane, + PreviewOverlayRenderContext, + PreviewOverlayInstance, + PreviewOverlayDefinition, + PreviewOverlayControl, + PreviewOverlaySourceResult, +} from "./lib/preview/overlays"; + +// Visual handle primitives — exported so hosts building bespoke overlays +// (e.g. a third-party mask renderer) can reuse the same look + hit areas. +export { + HandleButton, + CornerHandle, + CircleHandle, + EdgeHandle, + IconHandle, + BoundingBoxOutline, + ShapeOutline, + CanvasPathOutline, + LineOverlay, + getResizeCursor, + HANDLE_SIZE, + HANDLE_HIT_AREA_SIZE, + ICON_HANDLE_RADIUS, + EDGE_HANDLE_THIN_SIZE, + EDGE_HANDLE_THICK_SIZE, + LINE_HIT_AREA_SIZE, +} from "./lib/preview/components/handle-primitives"; + +// Editor + interaction hooks now reachable from the public surface so +// hosts can subscribe to EditorCore changes the same way the lib does. +export { useEditor } from "./lib/editor/use-editor"; +export { useCommittedRef } from "./lib/hooks/use-committed-ref"; +export { useShiftKey } from "./lib/hooks/use-shift-key"; +export { useRafLoop } from "./lib/hooks/use-raf-loop"; +export { useContainerSize } from "./lib/hooks/use-container-size"; +export { useResizeObserver } from "./lib/hooks/use-resize-observer"; +export { useFullscreen } from "./lib/hooks/use-fullscreen"; + +// --- PropertiesPanel --- +// The full PropertiesPanel subsystem ported from opencut-classic. Owns the +// element-type-to-tab registry (transform/blending/audio/speed/text/etc.), +// the per-param input rendering pipeline (NumberField / Switch / ColorPicker +// / Select / Textarea), the keyframe toggle, and the per-element animated +// param channel logic (preview/commit + upsert-keyframe-at-playhead). +// +// All five tab content builders (MasksTab, SpeedTab, GraphicTab, +// ClipEffectsTab, StandaloneEffectTab) are now wired through the +// registry. PropertiesPanel reads selection via useElementSelection. +// +// Mount inside ; the panel reads/writes its UI prefs through +// `usePropertiesStore` (active-tab-per-element-type, transform scale lock). +export { PropertiesPanel } from "./lib/panels/properties"; +export { usePropertiesStore } from "./lib/panels/properties/stores/properties-store"; +export { + getPropertiesConfig, +} from "./lib/panels/properties/registry"; +export type { + TabContentProps, + PropertiesTabDef, + ElementPropertiesConfig, +} from "./lib/panels/properties/registry"; +export { PropertyParamField } from "./lib/panels/properties/components/property-param-field"; +export { KeyframeToggle } from "./lib/panels/properties/components/keyframe-toggle"; +export { ElementParamsTab } from "./lib/panels/properties/components/element-params-tab"; +export { EmptyView as PropertiesEmptyView } from "./lib/panels/properties/empty-view"; +export { useElementPlayhead } from "./lib/panels/properties/hooks/use-element-playhead"; +export { + useKeyframedParamProperty, +} from "./lib/panels/properties/hooks/use-keyframed-param-property"; +export type { + KeyframedParamPropertyResult, +} from "./lib/panels/properties/hooks/use-keyframed-param-property"; +export { usePropertyDraft } from "./lib/panels/properties/hooks/use-property-draft"; + +// --- Section primitive (used by PropertiesPanel + future tab content) --- +export { + Section, + SectionHeader, + SectionTitle, + SectionFields, + SectionField, + SectionContent, +} from "./lib/components/section"; + +// --- Extra UI primitives ported for PropertiesPanel --- +export { NumberField } from "./lib/components/ui/number-field"; +export { Switch } from "./lib/components/ui/switch"; +export { Textarea } from "./lib/components/ui/textarea"; + +// --- Masks: pointer/handle interaction hook (unblocks PreviewPanel's +// mask-handles overlay; PreviewPanel now drives the real MaskHandles +// component end-to-end). --- +export { useMaskHandles } from "./lib/masks/use-mask-handles"; +export { useFocusLock } from "./lib/hooks/use-focus-lock"; + +// --- Editor shell --- +// The chrome around the editor surface ported from opencut-classic: +// header (project menu + editable name + export button), the export +// popover, the mobile-blocker gate, the first-run onboarding dialog, +// and the scene switcher sheet. Hosts that want a turnkey shell can +// mount these directly; hosts that ship their own chrome can pick +// individual pieces. +export { EditorHeader } from "./lib/components/editor/editor-header"; +export { ExportButton } from "./lib/components/editor/export-button"; +export { MobileGate } from "./lib/components/editor/mobile-gate"; +export { Onboarding } from "./lib/components/editor/onboarding"; +export { ScenesView } from "./lib/components/editor/scenes-view"; + +// --- Actions subsystem --- +// Registry + dispatcher for editor-wide commands. `invokeAction` fires +// a registered handler (split, undo, toggle-play, etc.) without +// coupling the caller to the manager that performs the work. +// `useEditorActions` wires the canonical handler set against +// EditorCore; `useKeybindingsListener` attaches the global keydown +// dispatcher. Both are mounted automatically by . Hosts +// that compose their own shell with directly must +// call them inside that tree; hosts that want a subset of handlers +// can call `useActionHandler` directly instead. +export { + invokeAction, + bindAction, + unbindAction, + ACTIONS, + getActionDefinition, + getDefaultShortcuts, +} from "./lib/actions"; +export type { + TAction, + TActionArgsMap, + TActionWithArgs, + TActionWithNoArgs, + TActionWithOptionalArgs, + TActionFunc, + TActionHandlerOptions, + TActionDefinition, + TActionBaseDefinition, + TActionCategory, + TArgOfAction, + TInvocationTrigger, +} from "./lib/actions"; +export { useEditorActions } from "./lib/actions/use-editor-actions"; +export { useActionHandler } from "./lib/actions/use-action-handler"; +export { useKeybindingsListener } from "./lib/actions/use-keybindings"; +export { useKeybindingsStore } from "./lib/actions/keybindings-store"; +export type { KeybindingConflict } from "./lib/actions/keybindings-store"; +export { + useKeyboardShortcutsHelp, +} from "./lib/actions/use-keyboard-shortcuts-help"; +export type { KeyboardShortcut } from "./lib/actions/use-keyboard-shortcuts-help"; +export { ShortcutsDialog } from "./lib/actions/shortcuts-dialog"; +export { + isKey, +} from "./lib/actions/keybinding"; +export type { + Key, + KeybindingConfig, + ModifierKeys, + ModifierBasedShortcutKey, + ShortcutKey, + SingleCharacterShortcutKey, +} from "./lib/actions/keybinding"; + +// --- Selection scope (escape-key cancellation registry shared by +// editor-actions and timeline selection scopes) --- +export { + activateScope, + clearActiveScope, +} from "./lib/selection/scope"; +export type { ScopeEntry } from "./lib/selection/scope"; + +// --- AssetsPanel --- +// The full AssetsPanel subsystem ported from opencut-classic. Renders +// the left-rail tab bar (media/sounds/text/stickers/effects/captions/ +// settings), the media gallery view (grid or list, sortable, with +// drag-to-import + context-menu deletion), the project settings view +// (frame rate, aspect ratio, custom canvas size), and the background +// settings view (blur previews + solid colors + pattern-craft + +// syntax-ui gradient swatches). +// +// Captions still renders a "coming soon" placeholder — depends on the +// transcription subsystem (deferred per port scope). +// +// Mount inside ; the panel reads/writes its UI prefs +// (active tab, media view mode, sort order) through `useAssetsPanelStore`. +export { AssetsPanel } from "./lib/panels/assets"; +export { TabBar as AssetsPanelTabBar } from "./lib/panels/assets/tabbar"; +export { MediaDragOverlay } from "./lib/panels/assets/drag-overlay"; +export { DraggableItem } from "./lib/panels/assets/draggable-item"; +export type { DraggableItemProps } from "./lib/panels/assets/draggable-item"; +export { + TAB_KEYS as ASSETS_PANEL_TAB_KEYS, + tabs as assetsPanelTabs, + useAssetsPanelStore, +} from "./lib/panels/assets/assets-panel-store"; +export type { + Tab as AssetsPanelTab, + MediaViewMode, + MediaSortKey, + MediaSortOrder, +} from "./lib/panels/assets/assets-panel-store"; + +// --- Fonts --- +// Atlas-backed Google Fonts picker ported from opencut-classic. The +// FontPicker drives masks/text-mask params today and the upcoming +// TextView once that lands. Hosts that need to preload a Google font +// outside the picker (e.g. before rendering an export frame) call +// loadFullFont / loadFonts directly. SYSTEM_FONTS is the bundled +// short-list that never needs network loading. +export { FontPicker } from "./lib/components/ui/font-picker"; +export { + SYSTEM_FONTS, + useFontAtlas, + loadFontAtlas, + getCachedFontAtlas, + clearFontAtlasCache, + loadFullFont, + loadFonts, +} from "./lib/fonts"; +export type { + FontOption, + GoogleFontMeta, + FontAtlas, + FontAtlasEntry, +} from "./lib/fonts"; + +// --- Text view --- +// AssetsPanel "Text" tab content. Drags a default text element onto the +// active scene's timeline at the current playhead time. +export { TextView } from "./lib/text/components/assets-view"; + +// --- Effects view --- +// AssetsPanel "Effects" tab content. Renders a grid of effect previews +// (live-rendered via effectPreviewService) drawn from effectsRegistry. +// Each item is draggable onto the timeline as a standalone effect clip. +export { EffectsView } from "./lib/effects/components/assets-view"; + +// --- Project dialogs --- +// Confirmation dialogs ported from opencut-classic. RenameProjectDialog +// drives the rename flow; DeleteProjectDialog drives single- and +// multi-project delete confirmation. Both are wired into EditorHeader's +// ProjectDropdown via the `openDialog` state. +export { RenameProjectDialog } from "./lib/project/components/rename-project-dialog"; +export { DeleteProjectDialog } from "./lib/project/components/delete-project-dialog"; + +// --- Sounds --- +// AssetsPanel "Sounds" tab content. SoundsView renders the sound-effects +// + saved-sounds tabs with search, infinite scroll, previewing, and +// timeline insertion. Data flows through SoundsAdapter — the lib ships +// an emptySoundsAdapter default; hosts wire their own search / saved- +// sound backend (Artcraft routes through the storyteller-web sounds +// proxy). +export { SoundsView } from "./lib/sounds/components/assets-view"; +export { useSoundsStore } from "./lib/sounds/sounds-store"; +export { useSoundSearch } from "./lib/sounds/use-sound-search"; +export type { + SoundEffect, + SavedSound, + SavedSoundsData, +} from "./lib/sounds/types"; +export type { SoundsAdapter, SoundsSearchResult } from "./lib/adapters"; +export { emptySoundsAdapter } from "./lib/adapters/default"; + +// --- Stickers --- +// AssetsPanel "Stickers" tab content. StickersView renders the +// category-grouped browse view (Shapes drives the only non-empty +// category today; Logos is intentionally empty) plus a search box. +// Items are inserted onto the timeline via DraggableItem (graphic for +// shapes, sticker for non-shape providers). Hosts that need direct +// access to the provider registry can register their own providers +// via `stickersRegistry.register({ key, definition })` before mounting. +export { StickersView } from "./lib/stickers/components/assets-view"; +export { useStickersStore } from "./lib/stickers/stickers-store"; +export { + STICKER_CATEGORIES, +} from "./lib/stickers/categories"; +export { + searchStickers, + searchAll, + browseAll, + browseCategory, + resolveStickerIntrinsicSize, + resolveStickerId, + registerDefaultStickerProviders, + stickersRegistry, + StickersRegistry, + STICKER_INTRINSIC_SIZE_FALLBACK, + parseStickerId, + buildStickerId, +} from "./lib/stickers"; +export { shapesProvider, parseShapeStickerId } from "./lib/stickers/providers/shapes"; +export { logosProvider } from "./lib/stickers/providers/logos"; +export type { + StickerBrowseResult, + StickerBrowseSection, + StickerCategory, + StickerItem, + StickerProvider, + StickerProviderBrowseOptions, + StickerProviderSearchOptions, + StickerResolveOptions, + StickerSearchResult, +} from "./lib/stickers"; diff --git a/frontend/libs/components/video-editor/src/lib/EditorProvider.tsx b/frontend/libs/components/video-editor/src/lib/EditorProvider.tsx new file mode 100644 index 0000000000..5f78a8f1e2 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/EditorProvider.tsx @@ -0,0 +1,50 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import type { VideoEditorAdapters } from "./adapters"; +import { createDefaultAdapters } from "./adapters/default"; +import { EditorCore } from "./core"; + +// Internal context. Components inside the editor read adapters via +// `useEditorAdapters()` rather than threading them through props. +const Ctx = createContext(null); + +export interface EditorProviderProps { + // Pass `null` (or omit) to use the bundled defaults — useful for + // the lib's standalone smoke test. Hosts that want real Artcraft + // adapters pass a partial bundle; defaults fill in the rest. + adapters?: Partial | null; + children: ReactNode; +} + +export function EditorProvider({ adapters, children }: EditorProviderProps) { + // Defaults instantiated once per provider mount. Re-evaluating on + // every render would churn IndexedDB connections and blob URLs. + const resolved = useMemo(() => { + const defaults = createDefaultAdapters(); + if (!adapters) return defaults; + return { ...defaults, ...adapters }; + }, [adapters]); + + // Bootstrap EditorCore with the resolved adapter bundle. Commands + // and managers read `EditorCore.getInstance().adapters.X` after + // this. EditorCore.initialize is first-wins idempotent — a host that + // calls initialize before mounting (the recommended + // pattern, to lock in their adapters before any getInstance() race) + // wins; this call is a no-op. Hosts that want to hot-swap adapters + // should call EditorCore.reset() first then remount. + useMemo(() => { + EditorCore.initialize({ adapters: resolved }); + }, [resolved]); + + return {children}; +} + +export function useEditorAdapters(): VideoEditorAdapters { + const ctx = useContext(Ctx); + if (!ctx) { + throw new Error( + "useEditorAdapters must be used inside . " + + "Mount your below an .", + ); + } + return ctx; +} diff --git a/frontend/libs/components/video-editor/src/lib/VideoEditor.tsx b/frontend/libs/components/video-editor/src/lib/VideoEditor.tsx new file mode 100644 index 0000000000..ad43dd303b --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/VideoEditor.tsx @@ -0,0 +1,273 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { EditorProvider } from "./EditorProvider"; +import type { VideoEditorAdapters } from "./adapters"; +import { initializeGpuRenderer } from "./services/renderer/gpu-renderer"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "./components/ui/resizable"; +import { AssetsPanel } from "./panels/assets"; +import { PropertiesPanel } from "./panels/properties"; +import { Timeline } from "./timeline/components"; +import { PreviewPanel } from "./preview/components"; +import { EditorHeader } from "./components/editor/editor-header"; +import { EditorErrorBoundary } from "./components/editor/editor-error-boundary"; +import { Onboarding } from "./components/editor/onboarding"; +import { MobileGate } from "./components/editor/mobile-gate"; +import { usePanelStore } from "./editor/panel-store"; +import { usePasteMedia } from "./media/use-paste-media"; +import { useEditor } from "./editor/use-editor"; +import { useEditorActions } from "./actions/use-editor-actions"; +import { useKeybindingsListener } from "./actions/use-keybindings"; +import { Button } from "./components/ui/button"; +import { Cancel01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + createPreviewOverlayControl, + isPreviewOverlayVisible, + mergePreviewOverlaySources, +} from "./preview/overlays"; +import { usePreviewStore } from "./preview/preview-store"; +import { getGuidePreviewOverlaySource } from "./guides"; +import { + bookmarkNotesPreviewOverlay, + getBookmarkPreviewOverlaySource, +} from "./timeline/bookmarks"; + +export interface VideoEditorProps { + projectId?: string; + // Hosts pass a partial set of adapters; the rest fall back to the + // bundled defaults (IndexedDB project storage, blob-URL media, etc). + adapters?: Partial | null; + // Optional host chrome rendered in the editor header, to the right of + // the export button. Webapp hosts that hide the global topbar inject + // its actions (pricing/credits/task queue/profile) here. + headerEndSlot?: ReactNode; +} + +// Public entry. Wraps the inner shell with EditorProvider so callers +// don't have to mount the provider themselves. Hosts that want to share +// one provider across multiple editor instances can mount EditorProvider +// + VideoEditorShell separately. +export function VideoEditor({ + projectId: _projectId, + adapters, + headerEndSlot, +}: VideoEditorProps) { + return ( + + + {/* Boundary contains any descendant throw — without this, an + unhandled exception in a panel or renderer node propagates + to the host shell and the user sees a blank app. The + boundary's recovery button remounts the editor subtree + (key bump); EditorCore + the host's project state stay + alive across the recovery. */} + +
+ + +
+ +
+ +
+
+
+
+ ); +} + +function DegradedRendererBanner() { + const isDegraded = useEditor((e) => e.renderer.isDegraded); + const [dismissed, setDismissed] = useState(false); + if (!isDegraded || dismissed) return null; + + return ( +
+ For the best experience, open this editor in Chrome. + +
+ ); +} + +function EditorLayout() { + // Boot the wasm-backed GPU compositor and gate the panel render on + // it. We need to *await* init before mounts — + // child useEffects fire bottom-up, so kicking GPU off in a sibling + // useEffect lets PreviewCanvas race ahead and panic with + // "GPU context not initialized" the moment opencut-wasm's + // initCompositor runs. initializeGpuRenderer is idempotent and + // catches WebGPU failures internally, so the gate eventually opens + // even on hardware without WebGPU (rendering falls back via + // RendererManager.isDegraded). + // + // The actions / keybindings / paste-media listeners live in + // ReadyEditorLayout below so they only attach once both gates are + // open; before then, a Space/Delete keypress or a media paste would + // dispatch against an editor whose preview canvas hasn't mounted. + const [gpuReady, setGpuReady] = useState(false); + useEffect(() => { + let cancelled = false; + void initializeGpuRenderer().then(() => { + if (!cancelled) setGpuReady(true); + }); + return () => { + cancelled = true; + }; + }, []); + + // Also gate on the host having bootstrapped a project. Panels read + // editor.scenes.getActiveScene() / editor.project.getActive() on + // mount; without a project they crash with "No active scene". Hosts + // can bootstrap via setActiveProject at any time — this gate flips + // as soon as they do. Avoids the historical pattern of hosts having + // to render conditionally on their own ready state. + const hasProject = useEditor( + (editor) => editor.project.getActive() !== null, + ); + + if (!gpuReady || !hasProject) return null; + return ; +} + +function ReadyEditorLayout() { + useEditorActions(); + useKeybindingsListener(); + usePasteMedia(); + const { panels, setPanel } = usePanelStore(); + const activeScene = useEditor((editor) => + editor.scenes.getActiveSceneOrNull(), + ); + const currentTime = useEditor((editor) => editor.playback.getCurrentTime()); + const activeGuide = usePreviewStore((state) => state.activeGuide); + const overlays = usePreviewStore((state) => state.overlays); + const setOverlayVisibility = usePreviewStore( + (state) => state.setOverlayVisibility, + ); + const showBookmarkNotes = isPreviewOverlayVisible({ + overlay: bookmarkNotesPreviewOverlay, + overlays, + }); + + const overlaySource = useMemo( + () => + mergePreviewOverlaySources({ + sources: [ + getGuidePreviewOverlaySource({ + guideId: activeGuide, + }), + activeScene + ? getBookmarkPreviewOverlaySource({ + bookmarks: activeScene.bookmarks, + time: currentTime, + isVisible: showBookmarkNotes, + }) + : { + definitions: [bookmarkNotesPreviewOverlay], + instances: [], + }, + ], + }), + [activeGuide, activeScene, currentTime, showBookmarkNotes], + ); + + const overlayControls = useMemo( + () => + overlaySource.definitions.map((overlay) => + createPreviewOverlayControl({ overlay, overlays }), + ), + [overlaySource.definitions, overlays], + ); + + return ( + { + setPanel({ + panel: "mainContent", + size: sizes[0] ?? panels.mainContent, + }); + setPanel({ + panel: "timeline", + size: sizes[1] ?? panels.timeline, + }); + }} + > + + { + setPanel({ panel: "tools", size: sizes[0] ?? panels.tools }); + setPanel({ panel: "preview", size: sizes[1] ?? panels.preview }); + setPanel({ + panel: "properties", + size: sizes[2] ?? panels.properties, + }); + }} + > + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/libs/components/video-editor/src/lib/actions/definitions.ts b/frontend/libs/components/video-editor/src/lib/actions/definitions.ts new file mode 100644 index 0000000000..09146c0957 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/definitions.ts @@ -0,0 +1,210 @@ +import type { ShortcutKey } from "./keybinding"; +import type { TActionWithOptionalArgs } from "./types"; + +export type TActionCategory = + | "playback" + | "navigation" + | "editing" + | "selection" + | "history" + | "timeline" + | "controls" + | "assets"; + +export interface TActionBaseDefinition { + description: string; + category: TActionCategory; + args?: Record; +} + +export interface TActionDefinition extends TActionBaseDefinition { + defaultShortcuts?: readonly ShortcutKey[]; +} + +export const ACTIONS = { + "toggle-play": { + description: "Play/Pause", + category: "playback", + }, + "stop-playback": { + description: "Stop playback", + category: "playback", + }, + "seek-forward": { + description: "Seek forward 1 second", + category: "playback", + args: { seconds: "number" }, + }, + "seek-backward": { + description: "Seek backward 1 second", + category: "playback", + args: { seconds: "number" }, + }, + "frame-step-forward": { + description: "Frame step forward", + category: "navigation", + }, + "frame-step-backward": { + description: "Frame step backward", + category: "navigation", + }, + "jump-forward": { + description: "Jump forward 5 seconds", + category: "navigation", + args: { seconds: "number" }, + }, + "jump-backward": { + description: "Jump backward 5 seconds", + category: "navigation", + args: { seconds: "number" }, + }, + "goto-start": { + description: "Go to timeline start", + category: "navigation", + }, + "goto-end": { + description: "Go to timeline end", + category: "navigation", + }, + split: { + description: "Split elements at playhead", + category: "editing", + }, + "split-left": { + description: "Split and remove left", + category: "editing", + }, + "split-right": { + description: "Split and remove right", + category: "editing", + }, + "delete-selected": { + description: "Delete current selection", + category: "editing", + }, + "copy-selected": { + description: "Copy selected elements", + category: "editing", + }, + "paste-copied": { + description: "Paste elements at playhead", + category: "editing", + }, + "toggle-snapping": { + description: "Toggle snapping", + category: "editing", + }, + "toggle-ripple-editing": { + description: "Toggle ripple editing", + category: "editing", + }, + "toggle-source-audio": { + description: "Extract or recover source audio", + category: "editing", + }, + "select-all": { + description: "Select all elements", + category: "selection", + }, + "cancel-interaction": { + description: "Cancel current interaction", + category: "controls", + }, + "deselect-all": { + description: "Deselect all elements", + category: "selection", + }, + "duplicate-selected": { + description: "Duplicate selected element", + category: "selection", + }, + "toggle-elements-muted-selected": { + description: "Mute/unmute selected elements", + category: "selection", + }, + "toggle-elements-visibility-selected": { + description: "Show/hide selected elements", + category: "selection", + }, + "toggle-bookmark": { + description: "Toggle bookmark at playhead", + category: "timeline", + }, + undo: { + description: "Undo", + category: "history", + }, + redo: { + description: "Redo", + category: "history", + }, + "remove-media-asset": { + description: "Remove media asset", + category: "assets", + args: { projectId: "string", assetId: "string" }, + }, + "remove-media-assets": { + description: "Remove media assets", + category: "assets", + args: { projectId: "string", assetIds: "string[]" }, + }, +} as const satisfies Record; + +export type TAction = keyof typeof ACTIONS; + +const ACTION_DEFAULT_SHORTCUTS = [ + ["toggle-play", ["space", "k"]], + ["seek-forward", ["l"]], + ["seek-backward", ["j"]], + ["frame-step-forward", ["right"]], + ["frame-step-backward", ["left"]], + ["jump-forward", ["shift+right"]], + ["jump-backward", ["shift+left"]], + ["goto-start", ["home", "enter"]], + ["goto-end", ["end"]], + ["split", ["s"]], + ["split-left", ["q"]], + ["split-right", ["w"]], + ["delete-selected", ["backspace", "delete"]], + ["copy-selected", ["ctrl+c"]], + ["paste-copied", ["ctrl+v"]], + ["toggle-snapping", ["n"]], + ["select-all", ["ctrl+a"]], + ["cancel-interaction", ["escape"]], + ["duplicate-selected", ["ctrl+d"]], + ["undo", ["ctrl+z"]], + ["redo", ["ctrl+shift+z", "ctrl+y"]], +] as const satisfies ReadonlyArray< + readonly [TActionWithOptionalArgs, readonly ShortcutKey[]] +>; + +const ACTION_DEFAULT_SHORTCUTS_BY_ACTION = new Map< + TAction, + readonly ShortcutKey[] +>(ACTION_DEFAULT_SHORTCUTS); + +export function getActionDefinition({ + action, +}: { + action: TAction; +}): TActionDefinition { + return { + ...ACTIONS[action], + defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION.get(action), + }; +} + +export function getDefaultShortcuts(): Map< + ShortcutKey, + TActionWithOptionalArgs +> { + const shortcuts = new Map(); + + for (const [action, defaultShortcuts] of ACTION_DEFAULT_SHORTCUTS) { + for (const shortcut of defaultShortcuts) { + shortcuts.set(shortcut, action); + } + } + + return shortcuts; +} diff --git a/frontend/libs/components/video-editor/src/lib/actions/index.ts b/frontend/libs/components/video-editor/src/lib/actions/index.ts new file mode 100644 index 0000000000..801a846a70 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/index.ts @@ -0,0 +1,3 @@ +export * from "./definitions"; +export * from "./types"; +export * from "./registry"; diff --git a/frontend/libs/components/video-editor/src/lib/actions/keybinding.ts b/frontend/libs/components/video-editor/src/lib/actions/keybinding.ts new file mode 100644 index 0000000000..018b84ffcf --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/keybinding.ts @@ -0,0 +1,43 @@ +import type { TActionWithOptionalArgs } from "./types"; + +/** + * Alt is also regarded as macOS OPTION (⌥) key + * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!) + */ +export type ModifierKeys = + | "ctrl" + | "alt" + | "shift" + | "ctrl+shift" + | "alt+shift" + | "ctrl+alt" + | "ctrl+alt+shift"; + +const KEYS = [ + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", + "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", + "u", "v", "w", "x", "y", "z", + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", + "up", "down", "left", "right", + "/", "?", ".", + "enter", "tab", "space", "escape", "esc", + "backspace", "delete", "home", "end", +] as const; + +export type Key = (typeof KEYS)[number]; + +const KEY_SET: ReadonlySet = new Set(KEYS); + +export function isKey(value: string): value is Key { + return KEY_SET.has(value); +} + +export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`; +// Singular keybindings (these will be disabled when an input-ish area has been focused) +export type SingleCharacterShortcutKey = `${Key}`; + +export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey; + +export type KeybindingConfig = { + [key in ShortcutKey]?: TActionWithOptionalArgs; +}; diff --git a/frontend/libs/components/video-editor/src/lib/actions/keybindings-store.ts b/frontend/libs/components/video-editor/src/lib/actions/keybindings-store.ts new file mode 100644 index 0000000000..0aa40ac27f --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/keybindings-store.ts @@ -0,0 +1,274 @@ +"use client"; + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { TActionWithOptionalArgs } from "."; +import { getDefaultShortcuts } from "."; +import { isTypableDOMElement } from "../utils/browser"; +import { isAppleDevice } from "../utils/platform"; +import type { + Key, + KeybindingConfig, + ModifierKeys, + ShortcutKey, +} from "./keybinding"; +import { isKey } from "./keybinding"; +import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations"; + +export interface KeybindingConflict { + key: ShortcutKey; + existingAction: TActionWithOptionalArgs; + newAction: TActionWithOptionalArgs; +} + +interface KeybindingsState { + keybindings: Map; + isCustomized: boolean; + overlayDepth: number; + openOverlayIds: string[]; + isLoadingProject: boolean; + isRecording: boolean; + + updateKeybinding: (params: { + key: ShortcutKey; + action: TActionWithOptionalArgs; + }) => void; + removeKeybinding: (key: ShortcutKey) => void; + resetToDefaults: () => void; + importKeybindings: (config: KeybindingConfig) => void; + exportKeybindings: () => Record; + openOverlay: (overlayId: string) => void; + closeOverlay: (overlayId: string) => void; + setLoadingProject: (loading: boolean) => void; + setIsRecording: (isRecording: boolean) => void; + validateKeybinding: (params: { + key: ShortcutKey; + action: TActionWithOptionalArgs; + }) => KeybindingConflict | null; + getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[]; + getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; +} + +type PersistedState = { + keybindings: Record; + isCustomized: boolean; +}; + +function isDOMElement(element: EventTarget | null): element is HTMLElement { + return element instanceof HTMLElement; +} + +function isPersistedState(value: unknown): value is PersistedState { + if (!value || typeof value !== "object") return false; + if (!("keybindings" in value) || !("isCustomized" in value)) return false; + const { keybindings, isCustomized } = value; + return ( + typeof keybindings === "object" && + keybindings !== null && + typeof isCustomized === "boolean" + ); +} + +export const useKeybindingsStore = create()( + persist( + (set, get) => ({ + keybindings: getDefaultShortcuts(), + isCustomized: false, + overlayDepth: 0, + openOverlayIds: [], + isLoadingProject: false, + isRecording: false, + + openOverlay: (overlayId) => + set((s) => { + const openOverlayIds = s.openOverlayIds.includes(overlayId) + ? s.openOverlayIds + : [...s.openOverlayIds, overlayId]; + return { + openOverlayIds, + overlayDepth: openOverlayIds.length, + }; + }), + closeOverlay: (overlayId) => + set((s) => { + const openOverlayIds = s.openOverlayIds.filter( + (id) => id !== overlayId, + ); + return { + openOverlayIds, + overlayDepth: openOverlayIds.length, + }; + }), + setLoadingProject: (loading) => { + set({ isLoadingProject: loading }); + }, + + updateKeybinding: ({ key, action }) => { + set((state) => { + const next = new Map(state.keybindings); + next.set(key, action); + return { + keybindings: next, + isCustomized: true, + }; + }); + }, + + removeKeybinding: (key) => { + set((state) => { + const next = new Map(state.keybindings); + next.delete(key); + return { + keybindings: next, + isCustomized: true, + }; + }); + }, + + resetToDefaults: () => { + set({ + keybindings: getDefaultShortcuts(), + isCustomized: false, + }); + }, + + importKeybindings: (config) => { + const next = new Map(); + for (const [key, action] of Object.entries(config)) { + if (typeof key !== "string" || key.length === 0) { + throw new Error(`Invalid key format: ${key}`); + } + if (action !== undefined) { + // Public type's keys are `ShortcutKey`; trust the caller's typing. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + next.set(key as ShortcutKey, action); + } + } + set({ + keybindings: next, + isCustomized: true, + }); + }, + + exportKeybindings: () => { + return Object.fromEntries(get().keybindings); + }, + + validateKeybinding: ({ key, action }) => { + const existingAction = get().keybindings.get(key); + if (existingAction && existingAction !== action) { + return { + key, + existingAction, + newAction: action, + }; + } + return null; + }, + setIsRecording: (isRecording) => { + set({ isRecording }); + }, + + getKeybindingsForAction: (action) => { + const result: ShortcutKey[] = []; + for (const [key, mapped] of get().keybindings) { + if (mapped === action) result.push(key); + } + return result; + }, + + getKeybindingString: (ev) => generateKeybindingString(ev), + }), + { + name: "opencut-keybindings", + version: CURRENT_VERSION, + partialize: (state): PersistedState => ({ + keybindings: Object.fromEntries(state.keybindings), + isCustomized: state.isCustomized, + }), + migrate: (persisted, version) => + runMigrations({ state: persisted, fromVersion: version }), + merge: (persisted, current) => { + if (!isPersistedState(persisted)) return current; + const entries = Object.entries(persisted.keybindings); + // Persistence boundary: keys are normalized by the migration chain. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const typedEntries = entries as Array< + [ShortcutKey, TActionWithOptionalArgs] + >; + return { + ...current, + keybindings: new Map(typedEntries), + isCustomized: persisted.isCustomized, + }; + }, + }, + ), +); + +function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { + const target = ev.target; + const modifierKey = getActiveModifier(ev); + const key = getPressedKey(ev); + if (!key) return null; + + if (modifierKey) { + if ( + modifierKey === "shift" && + isDOMElement(target) && + isTypableDOMElement({ element: target }) + ) { + return null; + } + + return `${modifierKey}+${key}`; + } + + if (isDOMElement(target) && isTypableDOMElement({ element: target })) { + return null; + } + + return key; +} + +function getPressedKey(ev: KeyboardEvent): Key | null { + const raw = (ev.key ?? "").toLowerCase(); + const code = ev.code ?? ""; + + if (code === "Space" || raw === " " || raw === "spacebar" || raw === "space") + return "space"; + + if (raw === "arrowup") return "up"; + if (raw === "arrowdown") return "down"; + if (raw === "arrowleft") return "left"; + if (raw === "arrowright") return "right"; + + if (code.startsWith("Key")) { + const letter = code.slice(3).toLowerCase(); + if (isKey(letter)) return letter; + } + + // Use physical key position for AZERTY and other non-QWERTY layouts. + if (code.startsWith("Digit")) { + const digit = code.slice(5); + if (isKey(digit)) return digit; + } + + if (isKey(raw)) return raw; + return null; +} + +function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null { + const ctrl = isAppleDevice() ? ev.metaKey : ev.ctrlKey; + const alt = ev.altKey; + const shift = ev.shiftKey; + + if (ctrl && alt && shift) return "ctrl+alt+shift"; + if (ctrl && alt) return "ctrl+alt"; + if (ctrl && shift) return "ctrl+shift"; + if (alt && shift) return "alt+shift"; + if (ctrl) return "ctrl"; + if (alt) return "alt"; + if (shift) return "shift"; + return null; +} diff --git a/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/index.ts b/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/index.ts new file mode 100644 index 0000000000..725ff663c3 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/index.ts @@ -0,0 +1,32 @@ +import { v2ToV3 } from "./v2-to-v3"; +import { v3ToV4 } from "./v3-to-v4"; +import { v4ToV5 } from "./v4-to-v5"; +import { v5ToV6 } from "./v5-to-v6"; +import { v6ToV7 } from "./v6-to-v7"; + +type MigrationFn = ({ state }: { state: unknown }) => unknown; + +const migrations: Record = { + 2: v2ToV3, + 3: v3ToV4, + 4: v4ToV5, + 5: v5ToV6, + 6: v6ToV7, +}; + +export const CURRENT_VERSION = 7; + +export function runMigrations({ + state, + fromVersion, +}: { + state: unknown; + fromVersion: number; +}): unknown { + let current = state; + for (let version = fromVersion; version < CURRENT_VERSION; version++) { + const migrate = migrations[version]; + if (migrate) current = migrate({ state: current }); + } + return current; +} diff --git a/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/v2-to-v3.ts b/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/v2-to-v3.ts new file mode 100644 index 0000000000..74cc793da2 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/v2-to-v3.ts @@ -0,0 +1,22 @@ +import { getPersistedKeybindingsState } from "../persisted-state"; + +export function v2ToV3({ state }: { state: unknown }): unknown { + const v2 = getPersistedKeybindingsState({ state }); + if (!v2) return state; + + const renames: Record = { + "split-selected": "split", + "split-selected-left": "split-left", + "split-selected-right": "split-right", + }; + + const migrated = { ...v2.keybindings }; + for (const [key, action] of Object.entries(migrated)) { + const renamedAction = action ? renames[action] : undefined; + if (renamedAction) { + migrated[key] = renamedAction; + } + } + + return { ...v2, keybindings: migrated }; +} diff --git a/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/v3-to-v4.ts b/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/v3-to-v4.ts new file mode 100644 index 0000000000..5ba6d5c894 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/v3-to-v4.ts @@ -0,0 +1,20 @@ +import { getPersistedKeybindingsState } from "../persisted-state"; + +export function v3ToV4({ state }: { state: unknown }): unknown { + const v3 = getPersistedKeybindingsState({ state }); + if (!v3) return state; + + const renames: Record = { + "paste-selected": "paste-copied", + }; + + const migrated = { ...v3.keybindings }; + for (const [key, action] of Object.entries(migrated)) { + const renamedAction = action ? renames[action] : undefined; + if (renamedAction) { + migrated[key] = renamedAction; + } + } + + return { ...v3, keybindings: migrated }; +} diff --git a/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/v4-to-v5.ts b/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/v4-to-v5.ts new file mode 100644 index 0000000000..c2f1f21d7b --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/v4-to-v5.ts @@ -0,0 +1,13 @@ +import { getPersistedKeybindingsState } from "../persisted-state"; + +export function v4ToV5({ state }: { state: unknown }): unknown { + const v4 = getPersistedKeybindingsState({ state }); + if (!v4) return state; + const keybindings = { ...v4.keybindings }; + + if (!keybindings.escape) { + keybindings.escape = "deselect-all"; + } + + return { ...v4, keybindings }; +} diff --git a/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/v5-to-v6.ts b/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/v5-to-v6.ts new file mode 100644 index 0000000000..ac893a165d --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/v5-to-v6.ts @@ -0,0 +1,13 @@ +import { getPersistedKeybindingsState } from "../persisted-state"; + +export function v5ToV6({ state }: { state: unknown }): unknown { + const v5 = getPersistedKeybindingsState({ state }); + if (!v5) return state; + const keybindings = { ...v5.keybindings }; + + if (keybindings.escape === "deselect-all") { + keybindings.escape = "cancel-interaction"; + } + + return { ...v5, keybindings }; +} diff --git a/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/v6-to-v7.ts b/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/v6-to-v7.ts new file mode 100644 index 0000000000..1d56276b92 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/keybindings/migrations/v6-to-v7.ts @@ -0,0 +1,15 @@ +import { getPersistedKeybindingsState } from "../persisted-state"; + +export function v6ToV7({ state }: { state: unknown }): unknown { + const v6 = getPersistedKeybindingsState({ state }); + if (!v6) return state; + const keybindings = { ...v6.keybindings }; + + for (const [key, action] of Object.entries(keybindings)) { + if (action === "split-element") { + keybindings[key] = "split"; + } + } + + return { ...v6, keybindings }; +} diff --git a/frontend/libs/components/video-editor/src/lib/actions/keybindings/persisted-state.ts b/frontend/libs/components/video-editor/src/lib/actions/keybindings/persisted-state.ts new file mode 100644 index 0000000000..a8edd8b5e4 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/keybindings/persisted-state.ts @@ -0,0 +1,37 @@ +export type PersistedKeybindingConfig = Record; + +export interface PersistedKeybindingsState { + keybindings: PersistedKeybindingConfig; + isCustomized: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function getPersistedKeybindingsState({ + state, +}: { + state: unknown; +}): PersistedKeybindingsState | null { + if (!isRecord(state)) return null; + + const { keybindings, isCustomized } = state; + if (!isRecord(keybindings) || typeof isCustomized !== "boolean") { + return null; + } + + const normalizedKeybindings: PersistedKeybindingConfig = {}; + for (const [key, action] of Object.entries(keybindings)) { + if (action !== undefined && typeof action !== "string") { + return null; + } + + normalizedKeybindings[key] = action as string | undefined; + } + + return { + keybindings: normalizedKeybindings, + isCustomized, + }; +} diff --git a/frontend/libs/components/video-editor/src/lib/actions/registry.ts b/frontend/libs/components/video-editor/src/lib/actions/registry.ts new file mode 100644 index 0000000000..6a0f262694 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/registry.ts @@ -0,0 +1,66 @@ +import type { + TAction, + TActionFunc, + TActionWithArgs, + TActionWithOptionalArgs, + TActionArgsMap, + TArgOfAction, + TInvocationTrigger, +} from "./types"; + +type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void; +const boundActions: Partial> = {}; + +// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler). +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]; + } +} + +// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler). +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; +}; + +// eslint-disable-next-line opencut/prefer-object-params -- dispatchers conventionally separate action, payload, and trigger. +export const invokeAction: InvokeActionFunc = ( + action: A, + args?: TArgOfAction, + trigger?: TInvocationTrigger, +) => { + boundActions[action]?.forEach((handler) => { + handler(args, trigger); + }); +}; diff --git a/frontend/libs/components/video-editor/src/lib/actions/shortcuts-dialog.tsx b/frontend/libs/components/video-editor/src/lib/actions/shortcuts-dialog.tsx new file mode 100644 index 0000000000..47b87d881d --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/shortcuts-dialog.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + type KeyboardShortcut, + useKeyboardShortcutsHelp, +} from "./use-keyboard-shortcuts-help"; +import { useKeybindingsStore } from "./keybindings-store"; +import { useEditorAdapters } from "../EditorProvider"; +import { Button } from "../components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../components/ui/dialog"; + +export function ShortcutsDialog({ + isOpen, + onOpenChange, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [recordingShortcut, setRecordingShortcut] = + useState(null); + const { toast } = useEditorAdapters(); + + const { + updateKeybinding, + removeKeybinding, + getKeybindingString, + validateKeybinding, + getKeybindingsForAction, + setIsRecording, + resetToDefaults, + isRecording, + } = useKeybindingsStore(); + + const { shortcuts } = useKeyboardShortcutsHelp(); + + const categories = Array.from(new Set(shortcuts.map((s) => s.category))); + + useEffect(() => { + if (!isRecording || !recordingShortcut) return undefined; + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const keyString = getKeybindingString(e); + if (keyString) { + const conflict = validateKeybinding({ + key: keyString, + action: recordingShortcut.action, + }); + if (conflict) { + toast.error( + `Key "${keyString}" is already bound to "${conflict.existingAction}"`, + ); + setRecordingShortcut(null); + return; + } + + const oldKeys = getKeybindingsForAction(recordingShortcut.action); + for (const key of oldKeys) { + removeKeybinding(key); + } + + updateKeybinding({ + key: keyString, + action: recordingShortcut.action, + }); + + setIsRecording(false); + setRecordingShortcut(null); + } + }; + + const handleClickOutside = () => { + setRecordingShortcut(null); + setIsRecording(false); + }; + + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("click", handleClickOutside); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("click", handleClickOutside); + }; + }, [ + recordingShortcut, + getKeybindingString, + updateKeybinding, + removeKeybinding, + validateKeybinding, + getKeybindingsForAction, + setIsRecording, + isRecording, + toast, + ]); + + const handleStartRecording = (shortcut: KeyboardShortcut) => { + setRecordingShortcut(shortcut); + setIsRecording(true); + }; + + return ( + + + + Keyboard shortcuts + + + +
+ {categories.map((category) => ( +
+

+ {category} +

+
+ {shortcuts + .filter((shortcut) => shortcut.category === category) + .map((shortcut) => ( + handleStartRecording(shortcut)} + /> + ))} +
+
+ ))} +
+
+ + + +
+
+ ); +} + +function ShortcutItem({ + shortcut, + isRecording, + onStartRecording, +}: { + shortcut: KeyboardShortcut; + isRecording: boolean; + onStartRecording: (params: { shortcut: KeyboardShortcut }) => void; +}) { + const displayKeys = shortcut.keys.filter((key: string) => { + if ( + key.includes("Cmd") && + shortcut.keys.includes(key.replace("Cmd", "Ctrl")) + ) + return false; + + return true; + }); + + return ( +
+
+ {shortcut.icon && ( +
{shortcut.icon}
+ )} + {shortcut.description} +
+
+ {displayKeys.map((key: string, index: number) => ( +
+
+ {key.split("+").map((keyPart: string, partIndex: number) => { + const keyId = `${shortcut.id}-${index}-${partIndex}`; + return ( + onStartRecording({ shortcut })} + > + {keyPart} + + ); + })} +
+ {index < displayKeys.length - 1 && ( + or + )} +
+ ))} +
+
+ ); +} + +function EditableShortcutKey({ + children, + isRecording, + onStartRecording, +}: { + children: React.ReactNode; + isRecording: boolean; + onStartRecording: () => void; +}) { + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onStartRecording(); + }; + + return ( + + ); +} diff --git a/frontend/libs/components/video-editor/src/lib/actions/types.ts b/frontend/libs/components/video-editor/src/lib/actions/types.ts new file mode 100644 index 0000000000..4ce32ad258 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/types.ts @@ -0,0 +1,44 @@ +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; + "remove-media-asset": { projectId: string; assetId: string }; + "remove-media-assets": { projectId: string; assetIds: string[] }; +}; + +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/frontend/libs/components/video-editor/src/lib/actions/use-action-handler.ts b/frontend/libs/components/video-editor/src/lib/actions/use-action-handler.ts new file mode 100644 index 0000000000..c48b0cd3d7 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/use-action-handler.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { + TAction, + TActionFunc, + TActionHandlerOptions, + TArgOfAction, + TInvocationTrigger, +} from "."; +import { bindAction, unbindAction } from "."; + +// eslint-disable-next-line opencut/prefer-object-params -- action subscriptions read best as (action, handler, isActive). +export function useActionHandler( + action: A, + handler: TActionFunc, + isActive: TActionHandlerOptions, +) { + const handlerRef = useRef>(handler); + const isBoundRef = useRef(false); + + useEffect(() => { + handlerRef.current = handler; + }, [handler]); + + const stableHandler = useCallback( + (...parameters: [TArgOfAction, TInvocationTrigger?]) => { + ( + handlerRef.current as ( + ...handlerParameters: [TArgOfAction, TInvocationTrigger?] + ) => void + )(...parameters); + }, + [], + ) as TActionFunc; + + useEffect(() => { + const shouldBind = + isActive === undefined || + (typeof isActive === "boolean" ? isActive : isActive.current); + + if (shouldBind && !isBoundRef.current) { + bindAction(action, stableHandler); + isBoundRef.current = true; + } else if (!shouldBind && isBoundRef.current) { + unbindAction(action, stableHandler); + isBoundRef.current = false; + } + + return () => { + unbindAction(action, stableHandler); + isBoundRef.current = false; + }; + }, [action, stableHandler, isActive]); +} diff --git a/frontend/libs/components/video-editor/src/lib/actions/use-editor-actions.ts b/frontend/libs/components/video-editor/src/lib/actions/use-editor-actions.ts new file mode 100644 index 0000000000..cf20ddf45a --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/use-editor-actions.ts @@ -0,0 +1,521 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTimelineStore } from "../timeline/timeline-store"; +import { useActionHandler } from "./use-action-handler"; +import { useEditor } from "../editor/use-editor"; +import { useElementSelection } from "../timeline/hooks/element/use-element-selection"; +import { useKeyframeSelection } from "../timeline/hooks/element/use-keyframe-selection"; +import { + addMediaTime, + maxMediaTime, + mediaTime, + mediaTimeFromSeconds, + minMediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "../wasm"; +import { getElementsAtTime, hasMediaId } from "../timeline/element-utils"; +import { cancelInteraction } from "../editor/cancel-interaction"; +import { invokeAction } from "."; +import { canToggleSourceAudio } from "../timeline/audio-separation"; +import { + activateScope, + clearActiveScope, + type ScopeEntry, +} from "../selection/scope"; +import { useCommittedRef } from "../hooks/use-committed-ref"; +import type { ElementRef } from "../timeline/types"; + +export function useEditorActions() { + const editor = useEditor(); + const { selectedElements, setElementSelection } = useElementSelection(); + const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection(); + const selectedMaskPointSelection = useEditor((e) => + e.selection.getSelectedMaskPointSelection(), + ); + const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); + const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); + const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); + const hasTimelineSelection = + selectedElements.length > 0 || + selectedKeyframes.length > 0 || + selectedMaskPointSelection !== null; + const hasTimelineSelectionRef = useCommittedRef(hasTimelineSelection); + const clearTimelineSelectionRef = useCommittedRef(() => { + editor.selection.clearSelection(); + }); + const clearTimelineActiveSelectionRef = useCommittedRef(() => { + editor.selection.clearMostSpecificSelection(); + }); + const [timelineScope] = useState(() => ({ + hasSelection: () => hasTimelineSelectionRef.current, + clear: () => { + clearTimelineSelectionRef.current(); + }, + clearActive: () => { + clearTimelineActiveSelectionRef.current(); + }, + })); + + useEffect(() => { + if (!hasTimelineSelection) { + return undefined; + } + + return activateScope({ entry: timelineScope }); + }, [hasTimelineSelection, timelineScope]); + + useActionHandler( + "toggle-play", + () => { + editor.playback.toggle(); + }, + undefined, + ); + + useActionHandler( + "stop-playback", + () => { + if (editor.playback.getIsPlaying()) { + editor.playback.toggle(); + } + editor.playback.seek({ time: ZERO_MEDIA_TIME }); + }, + undefined, + ); + + useActionHandler( + "seek-forward", + (args) => { + const seconds = args?.seconds ?? 1; + const delta = mediaTimeFromSeconds({ seconds }); + editor.playback.seek({ + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), + }); + }, + undefined, + ); + + useActionHandler( + "seek-backward", + (args) => { + const seconds = args?.seconds ?? 1; + const delta = mediaTimeFromSeconds({ seconds }); + editor.playback.seek({ + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), + }); + }, + undefined, + ); + + useActionHandler( + "frame-step-forward", + () => { + const fps = editor.project.getActive().settings.fps; + const ticksPerFrame = mediaTime({ + ticks: Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ), + }); + editor.playback.seek({ + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: ticksPerFrame, + }), + }), + }); + }, + undefined, + ); + + useActionHandler( + "frame-step-backward", + () => { + const fps = editor.project.getActive().settings.fps; + const ticksPerFrame = mediaTime({ + ticks: Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ), + }); + editor.playback.seek({ + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: ticksPerFrame, + }), + }), + }); + }, + undefined, + ); + + useActionHandler( + "jump-forward", + (args) => { + const seconds = args?.seconds ?? 5; + const delta = mediaTimeFromSeconds({ seconds }); + editor.playback.seek({ + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), + }); + }, + undefined, + ); + + useActionHandler( + "jump-backward", + (args) => { + const seconds = args?.seconds ?? 5; + const delta = mediaTimeFromSeconds({ seconds }); + editor.playback.seek({ + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), + }); + }, + undefined, + ); + + useActionHandler( + "goto-start", + () => { + editor.playback.seek({ time: ZERO_MEDIA_TIME }); + }, + undefined, + ); + + useActionHandler( + "goto-end", + () => { + editor.playback.seek({ time: editor.timeline.getTotalDuration() }); + }, + undefined, + ); + + useActionHandler( + "split", + () => { + const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.scenes.getActiveScene().tracks; + const elementsToSplit = + selectedElements.length > 0 + ? selectedElements + : getElementsAtTime({ + tracks, + time: currentTime, + }); + + if (elementsToSplit.length === 0) return; + + editor.timeline.splitElements({ + elements: elementsToSplit, + splitTime: currentTime, + }); + }, + undefined, + ); + + useActionHandler( + "split-left", + () => { + const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.scenes.getActiveScene().tracks; + const elementsToSplit = + selectedElements.length > 0 + ? selectedElements + : getElementsAtTime({ + tracks, + time: currentTime, + }); + + if (elementsToSplit.length === 0) return; + + const rightSideElements = editor.timeline.splitElements({ + elements: elementsToSplit, + splitTime: currentTime, + retainSide: "right", + }); + + if (rippleEditingEnabled && rightSideElements.length > 0) { + const firstRightElement = editor.timeline.getElementsWithTracks({ + elements: [rightSideElements[0]], + })[0]; + if (firstRightElement) { + editor.playback.seek({ time: firstRightElement.element.startTime }); + } + } + }, + undefined, + ); + + useActionHandler( + "split-right", + () => { + const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.scenes.getActiveScene().tracks; + const elementsToSplit = + selectedElements.length > 0 + ? selectedElements + : getElementsAtTime({ + tracks, + time: currentTime, + }); + + if (elementsToSplit.length === 0) return; + + editor.timeline.splitElements({ + elements: elementsToSplit, + splitTime: currentTime, + retainSide: "left", + }); + }, + undefined, + ); + + useActionHandler( + "delete-selected", + () => { + switch (editor.selection.getActiveSelectionKind()) { + case "mask-points": + if (!selectedMaskPointSelection) { + return; + } + editor.timeline.deleteFreeformPathMaskPoints({ + trackId: selectedMaskPointSelection.trackId, + elementId: selectedMaskPointSelection.elementId, + maskId: selectedMaskPointSelection.maskId, + pointIds: selectedMaskPointSelection.pointIds, + }); + return; + case "keyframes": + if (selectedKeyframes.length === 0) { + return; + } + editor.timeline.removeKeyframes({ keyframes: selectedKeyframes }); + clearKeyframeSelection(); + return; + case "elements": + if (selectedElements.length === 0) { + return; + } + editor.timeline.deleteElements({ + elements: selectedElements, + }); + return; + default: + return; + } + }, + undefined, + ); + + useActionHandler( + "toggle-source-audio", + () => { + if (selectedElements.length !== 1) { + return; + } + + const selectedElement = editor.timeline.getElementsWithTracks({ + elements: selectedElements, + })[0]; + if (!selectedElement) { + return; + } + + const mediaAsset = (() => { + const { element } = selectedElement; + if (!hasMediaId(element)) { + return null; + } + + return ( + editor.media + .getAssets() + .find((asset) => asset.id === element.mediaId) ?? null + ); + })(); + if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) { + return; + } + + editor.timeline.toggleSourceAudioSeparation({ + trackId: selectedElement.track.id, + elementId: selectedElement.element.id, + }); + }, + undefined, + ); + + useActionHandler( + "select-all", + () => { + const scene = editor.scenes.getActiveScene(); + const allElements = [ + ...scene.tracks.overlay, + scene.tracks.main, + ...scene.tracks.audio, + ].flatMap((track) => + track.elements.map((element) => ({ + trackId: track.id, + elementId: element.id, + })), + ); + setElementSelection({ elements: allElements }); + }, + undefined, + ); + + useActionHandler( + "cancel-interaction", + () => { + if (!cancelInteraction()) { + invokeAction("deselect-all"); + } + }, + undefined, + ); + + useActionHandler( + "deselect-all", + () => { + if (!clearActiveScope()) { + editor.selection.clearMostSpecificSelection(); + } + }, + undefined, + ); + + useActionHandler( + "duplicate-selected", + () => { + editor.timeline.duplicateElements({ + elements: selectedElements, + }); + }, + undefined, + ); + + useActionHandler( + "toggle-elements-muted-selected", + () => { + editor.timeline.toggleElementsMuted({ elements: selectedElements }); + }, + undefined, + ); + + useActionHandler( + "toggle-elements-visibility-selected", + () => { + editor.timeline.toggleElementsVisibility({ elements: selectedElements }); + }, + undefined, + ); + + useActionHandler( + "toggle-bookmark", + () => { + editor.scenes.toggleBookmark({ time: editor.playback.getCurrentTime() }); + }, + undefined, + ); + + useActionHandler( + "copy-selected", + () => { + editor.clipboard.copy(); + }, + undefined, + ); + + useActionHandler( + "paste-copied", + () => { + editor.clipboard.paste(); + }, + undefined, + ); + + useActionHandler( + "toggle-snapping", + () => { + toggleSnapping(); + }, + undefined, + ); + + useActionHandler( + "toggle-ripple-editing", + () => { + toggleRippleEditing(); + }, + undefined, + ); + + useActionHandler( + "undo", + () => { + editor.command.undo(); + }, + undefined, + ); + + useActionHandler( + "redo", + () => { + editor.command.redo(); + }, + undefined, + ); + + // todo: potnetially unify these two actions: + useActionHandler( + "remove-media-asset", + (args) => { + if (!args) return; + editor.media.removeMediaAsset({ + projectId: args.projectId, + id: args.assetId, + }); + }, + undefined, + ); + + useActionHandler( + "remove-media-assets", + (args) => { + if (!args) return; + editor.media.removeMediaAssets({ + projectId: args.projectId, + ids: args.assetIds, + }); + }, + undefined, + ); +} diff --git a/frontend/libs/components/video-editor/src/lib/actions/use-keybindings.ts b/frontend/libs/components/video-editor/src/lib/actions/use-keybindings.ts new file mode 100644 index 0000000000..6c2ce5241c --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/use-keybindings.ts @@ -0,0 +1,87 @@ +import { useEffect } from "react"; +import { invokeAction } from "."; +import { useEditor } from "../editor/use-editor"; +import { useKeybindingsStore } from "./keybindings-store"; +import { isTypableDOMElement } from "../utils/browser"; + +/** + * a composable that hooks to the caller component's + * lifecycle and hooks to the keyboard events to fire + * the appropriate actions based on keybindings + */ +export function useKeybindingsListener() { + const editor = useEditor(); + const { + keybindings, + getKeybindingString, + overlayDepth, + isLoadingProject, + isRecording, + } = useKeybindingsStore(); + + useEffect(() => { + const eventOptions: AddEventListenerOptions = { capture: true }; + const handleKeyDown = (ev: KeyboardEvent) => { + const normalizedKey = (ev.key ?? "").toLowerCase(); + + if (overlayDepth > 0 || isLoadingProject || isRecording) { + return; + } + + const binding = getKeybindingString(ev); + const activeElement = document.activeElement; + const isTextInput = + activeElement instanceof HTMLElement && + isTypableDOMElement({ element: activeElement }); + const boundAction = binding ? keybindings.get(binding) : undefined; + + if (normalizedKey === "escape" && isTextInput) { + activeElement.blur(); + return; + } + + if (!binding) return; + if (!boundAction) return; + + if (isTextInput) return; + if (boundAction === "paste-copied") { + if (!editor.clipboard.hasEntry()) return; + ev.preventDefault(); + invokeAction("paste-copied", undefined, "keypress"); + return; + } + + ev.preventDefault(); + + switch (boundAction) { + case "seek-forward": + invokeAction("seek-forward", { seconds: 1 }, "keypress"); + break; + case "seek-backward": + invokeAction("seek-backward", { seconds: 1 }, "keypress"); + break; + case "jump-forward": + invokeAction("jump-forward", { seconds: 5 }, "keypress"); + break; + case "jump-backward": + invokeAction("jump-backward", { seconds: 5 }, "keypress"); + break; + default: + invokeAction(boundAction, undefined, "keypress"); + } + }; + + document.addEventListener("keydown", handleKeyDown, eventOptions); + + return () => { + document.removeEventListener("keydown", handleKeyDown, eventOptions); + }; + }, [ + keybindings, + getKeybindingString, + overlayDepth, + isLoadingProject, + isRecording, + editor, + ]); +} diff --git a/frontend/libs/components/video-editor/src/lib/actions/use-keyboard-shortcuts-help.ts b/frontend/libs/components/video-editor/src/lib/actions/use-keyboard-shortcuts-help.ts new file mode 100644 index 0000000000..89c0b2aa2d --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/actions/use-keyboard-shortcuts-help.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useMemo } from "react"; +import { useKeybindingsStore } from "./keybindings-store"; +import { ACTIONS, type TActionWithOptionalArgs } from "."; +import { + getPlatformAlternateKey, + getPlatformSpecialKey, +} from "../utils/platform"; + +export interface KeyboardShortcut { + id: string; + keys: string[]; + description: string; + category: string; + action: TActionWithOptionalArgs; + icon?: React.ReactNode; +} + +function formatKey({ key }: { key: string }): string { + return key + .replace("ctrl", getPlatformSpecialKey()) + .replace("alt", getPlatformAlternateKey()) + .replace("shift", "Shift") + .replace("left", "←") + .replace("right", "→") + .replace("up", "↑") + .replace("down", "↓") + .replace("space", "Space") + .replace("home", "Home") + .replace("enter", "Enter") + .replace("end", "End") + .replace("delete", "Delete") + .replace("backspace", "Backspace") + .replace("-", "+"); +} + +export function useKeyboardShortcutsHelp() { + const { keybindings } = useKeybindingsStore(); + + const shortcuts = useMemo(() => { + const actionToKeys = new Map(); + + for (const [key, action] of keybindings) { + const existing = actionToKeys.get(action); + if (existing) { + existing.push(formatKey({ key })); + } else { + actionToKeys.set(action, [formatKey({ key })]); + } + } + + const result: KeyboardShortcut[] = []; + for (const [action, keys] of actionToKeys) { + const actionDef = ACTIONS[action]; + if (!actionDef) continue; + result.push({ + id: action, + keys, + description: actionDef.description, + category: actionDef.category, + action, + }); + } + + return result.sort((a, b) => { + if (a.category !== b.category) { + return a.category.localeCompare(b.category); + } + return a.description.localeCompare(b.description); + }); + }, [keybindings]); + + return { + shortcuts, + }; +} diff --git a/frontend/libs/components/video-editor/src/lib/adapters/asset-gallery.ts b/frontend/libs/components/video-editor/src/lib/adapters/asset-gallery.ts new file mode 100644 index 0000000000..9c9153ed33 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/asset-gallery.ts @@ -0,0 +1,17 @@ +import type { MediaHandle, MediaKind } from "./types"; + +// Optional slot for a host-provided asset picker (e.g. Artcraft's +// existing gallery modal). When this adapter is null/absent, the +// editor's "Browse gallery" button is hidden. When present, the button +// is shown and clicking it calls `openPicker`. Each returned selection +// carries the handle plus a display name (read from the host's gallery +// metadata) so the editor doesn't have to re-fetch it via resolveMedia +// just to label the asset in the media bin. +export interface MediaPickerSelection { + handle: MediaHandle; + name: string; +} + +export interface AssetGalleryAdapter { + openPicker(opts: { kinds: MediaKind[] }): Promise; +} diff --git a/frontend/libs/components/video-editor/src/lib/adapters/auth-user.ts b/frontend/libs/components/video-editor/src/lib/adapters/auth-user.ts new file mode 100644 index 0000000000..f05a0e798a --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/auth-user.ts @@ -0,0 +1,17 @@ +import type { AuthUser } from "./types"; + +// Read-only access to the currently signed-in user. The editor uses +// this only for chrome (avatar, display name, share-button gating) +// and never for authorizing requests — those go through the +// project-storage and media-source adapters which are owned by the +// host and already carry whatever auth context they need. +// +// Returns `null` for anonymous / signed-out users. The default impl +// is the anonymous adapter. +export interface AuthUserAdapter { + currentUser(): AuthUser | null; + // Optional: lets the editor subscribe to auth-state changes (sign in/out) + // so it can re-render chrome reactively. If absent, the editor reads + // the value once at mount and treats it as stable. + subscribe?(listener: (user: AuthUser | null) => void): () => void; +} diff --git a/frontend/libs/components/video-editor/src/lib/adapters/default/anonymous-auth-user.ts b/frontend/libs/components/video-editor/src/lib/adapters/default/anonymous-auth-user.ts new file mode 100644 index 0000000000..e10da7142e --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/default/anonymous-auth-user.ts @@ -0,0 +1,8 @@ +import type { AuthUserAdapter } from "../auth-user"; + +// Default auth adapter — reports no signed-in user. Hosts override +// this with an adapter that reads their real auth state (cookie, +// Tauri session, etc.). +export const anonymousAuthUser: AuthUserAdapter = { + currentUser: () => null, +}; diff --git a/frontend/libs/components/video-editor/src/lib/adapters/default/console-toast.ts b/frontend/libs/components/video-editor/src/lib/adapters/default/console-toast.ts new file mode 100644 index 0000000000..56890f869c --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/default/console-toast.ts @@ -0,0 +1,25 @@ +import type { ToastAdapter } from "../toast"; + +// Default ToastAdapter that logs to the browser console. Useful for +// the lib's standalone smoke test (where there's no host toaster +// mounted) and as a fallback. Hosts (Artcraft) replace this with an +// adapter that calls into @storyteller/ui-toaster. + +function format(message: string, description?: string): string[] { + return description ? [message, "\n", description] : [message]; +} + +export const consoleToast: ToastAdapter = { + info(message, options) { + console.info(...format(message, options?.description)); + }, + success(message, options) { + console.log(...format(message, options?.description)); + }, + warning(message, options) { + console.warn(...format(message, options?.description)); + }, + error(message, options) { + console.error(...format(message, options?.description)); + }, +}; diff --git a/frontend/libs/components/video-editor/src/lib/adapters/default/download-export-sink.ts b/frontend/libs/components/video-editor/src/lib/adapters/default/download-export-sink.ts new file mode 100644 index 0000000000..eada9dfc84 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/default/download-export-sink.ts @@ -0,0 +1,55 @@ +import type { ExportSinkAdapter } from "../export-sink"; + +// Default export sink: triggers a browser download. Hosts can replace +// with a Tauri save-dialog impl or an upload-to-artcraft impl. +// +// The revoke window has to outlast both the browser's initial fetch +// of the URL (which can be delayed by AV scanning on Windows) and the +// disk write for the full Blob (slow on HDDs / low-end SSDs for +// multi-GB exports). One minute is generous but safe — the Blob is +// only held in memory until the URL revokes, so a tab open for an +// hour still releases everything within a minute of each export. +const REVOKE_DELAY_MS = 60_000; + +export const downloadExportSink: ExportSinkAdapter = { + async accept(artifact, options) { + const saveToDisk = options?.saveToDisk ?? true; + const onProgress = options?.onProgress; + + // The default sink can't ship to a media library — fail loudly if + // a caller asks. Hosts that want library upload should swap this + // adapter for one that supports it. + if (options?.saveToLibrary) { + onProgress?.({ + destination: "library", + status: "error", + error: "Default export sink has no media library", + }); + } + + if (!saveToDisk) { + return null; + } + + onProgress?.({ destination: "disk", status: "pending" }); + try { + const url = URL.createObjectURL(artifact.blob); + const a = document.createElement("a"); + a.href = url; + a.download = artifact.filename; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), REVOKE_DELAY_MS); + onProgress?.({ destination: "disk", status: "success" }); + return artifact.filename; + } catch (error) { + onProgress?.({ + destination: "disk", + status: "error", + error: error instanceof Error ? error.message : "Download failed", + }); + throw error; + } + }, +}; diff --git a/frontend/libs/components/video-editor/src/lib/adapters/default/empty-sounds-adapter.ts b/frontend/libs/components/video-editor/src/lib/adapters/default/empty-sounds-adapter.ts new file mode 100644 index 0000000000..3fb588766a --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/default/empty-sounds-adapter.ts @@ -0,0 +1,30 @@ +import type { SoundsAdapter } from "../sounds"; + +// Empty default for SoundsAdapter. Every call resolves with a shape- +// correct empty result so the lib runs end-to-end with the sounds +// gallery visible but empty. Hosts (Artcraft) replace this with an +// adapter that calls into their real search / saved-sounds backend. + +export const emptySoundsAdapter: SoundsAdapter = { + async searchSounds() { + return { results: [], hasNextPage: false, totalCount: 0 }; + }, + async getTopSounds() { + return []; + }, + async loadSavedSounds() { + return []; + }, + async saveSound() { + // no-op + }, + async unsaveSound() { + // no-op + }, + async clearSavedSounds() { + // no-op + }, + async resolveAudioUrl() { + return ""; + }, +}; diff --git a/frontend/libs/components/video-editor/src/lib/adapters/default/index.ts b/frontend/libs/components/video-editor/src/lib/adapters/default/index.ts new file mode 100644 index 0000000000..984e0f08fa --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/default/index.ts @@ -0,0 +1,32 @@ +// Default adapter implementations — let the lib run end-to-end with +// no host wiring. Hosts opt out of any of these by passing their own +// implementation into EditorProvider. + +import { createIndexedDBProjectStorage } from "./indexeddb-project-storage"; +import { createLocalFileMediaSource } from "./local-file-media-source"; +import { anonymousAuthUser } from "./anonymous-auth-user"; +import { downloadExportSink } from "./download-export-sink"; +import { consoleToast } from "./console-toast"; +import { emptySoundsAdapter } from "./empty-sounds-adapter"; +import type { VideoEditorAdapters } from "../index"; + +export { createIndexedDBProjectStorage } from "./indexeddb-project-storage"; +export { createLocalFileMediaSource } from "./local-file-media-source"; +export { anonymousAuthUser } from "./anonymous-auth-user"; +export { downloadExportSink } from "./download-export-sink"; +export { consoleToast } from "./console-toast"; +export { emptySoundsAdapter } from "./empty-sounds-adapter"; + +// Convenience: a fully-wired default bundle. Useful for tests and +// for the standalone-lib smoke test in artcraft-webapp. +export function createDefaultAdapters(): VideoEditorAdapters { + return { + projectStorage: createIndexedDBProjectStorage(), + mediaSource: createLocalFileMediaSource(), + authUser: anonymousAuthUser, + exportSink: downloadExportSink, + toast: consoleToast, + soundsAdapter: emptySoundsAdapter, + assetGallery: null, + }; +} diff --git a/frontend/libs/components/video-editor/src/lib/adapters/default/indexeddb-project-storage.ts b/frontend/libs/components/video-editor/src/lib/adapters/default/indexeddb-project-storage.ts new file mode 100644 index 0000000000..e2a5e0d58c --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/default/indexeddb-project-storage.ts @@ -0,0 +1,93 @@ +import type { ProjectStorageAdapter } from "../project-storage"; +import type { EditorProject, ProjectMeta } from "../types"; + +// Minimal IndexedDB-backed implementation of ProjectStorageAdapter. +// Stores each project as one row in object store `projects`, keyed +// by project id. No migration logic — phase 1 schema only. + +const DB_NAME = "video-editor"; +const DB_VERSION = 1; +const STORE = "projects"; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE, { keyPath: "id" }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function tx( + mode: IDBTransactionMode, + run: (store: IDBObjectStore) => IDBRequest | void, +): Promise { + return openDb().then( + (db) => + new Promise((resolve, reject) => { + const t = db.transaction(STORE, mode); + const store = t.objectStore(STORE); + let result: T | undefined; + const req = run(store); + if (req) { + req.onsuccess = () => { + result = req.result; + }; + req.onerror = () => reject(req.error); + } + t.oncomplete = () => resolve(result as T); + t.onerror = () => reject(t.error); + t.onabort = () => reject(t.error); + }), + ); +} + +function newId(): string { + // Sufficient for local-only storage; the lib does not expose this + // id outside the host process. + return `proj_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`; +} + +export function createIndexedDBProjectStorage(): ProjectStorageAdapter { + return { + async loadProject(id) { + const project = await tx("readonly", (s) => + s.get(id) as IDBRequest, + ); + return project ?? null; + }, + async saveProject(project) { + await tx("readwrite", (s) => s.put(project)); + }, + async deleteProject(id) { + await tx("readwrite", (s) => s.delete(id)); + }, + async listProjects() { + const all = await tx("readonly", (s) => + s.getAll() as IDBRequest, + ); + const metas: ProjectMeta[] = (all ?? []).map((p) => ({ + id: p.id, + name: p.name, + updatedAt: p.updatedAt, + })); + metas.sort((a, b) => b.updatedAt - a.updatedAt); + return metas; + }, + async createProject(name) { + const project: EditorProject = { + id: newId(), + name, + updatedAt: Date.now(), + data: null, + }; + await tx("readwrite", (s) => s.put(project)); + return project; + }, + }; +} diff --git a/frontend/libs/components/video-editor/src/lib/adapters/default/local-file-media-source.ts b/frontend/libs/components/video-editor/src/lib/adapters/default/local-file-media-source.ts new file mode 100644 index 0000000000..e7d669d09a --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/default/local-file-media-source.ts @@ -0,0 +1,53 @@ +import type { MediaSourceAdapter } from "../media-source"; +import type { MediaHandle, ResolvedMedia } from "../types"; +import { kindFromMime } from "../kind-from-mime"; + +// Default media source: keeps File objects in memory and hands out +// blob: URLs. Persists nothing across reloads — projects only stay +// usable as long as the user keeps the browser tab open. +// +// Sufficient for the standalone-lib smoke test. Real hosts (artcraft, +// artcraft-webapp) swap this for an Artcraft-backed adapter. + +interface Entry { + handle: MediaHandle; + file: File; + blobUrl: string; + resolved: ResolvedMedia; +} + +function newId(): string { + return `media_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`; +} + +export function createLocalFileMediaSource(): MediaSourceAdapter { + const byId = new Map(); + + return { + async resolveMedia(handle) { + const entry = byId.get(handle.id); + if (!entry) { + throw new Error(`MediaHandle ${handle.id} is not loaded`); + } + return entry.resolved; + }, + async uploadLocalFile(file) { + const id = newId(); + const blobUrl = URL.createObjectURL(file); + const handle: MediaHandle = { id, kind: kindFromMime(file.type) }; + const resolved: ResolvedMedia = { + url: blobUrl, + mime: file.type || "application/octet-stream", + }; + byId.set(id, { handle, file, blobUrl, resolved }); + return handle; + }, + releaseResolved(resolved) { + // Only safe to revoke when no live entry still points at this URL. + for (const entry of byId.values()) { + if (entry.resolved.url === resolved.url) return; + } + URL.revokeObjectURL(resolved.url); + }, + }; +} diff --git a/frontend/libs/components/video-editor/src/lib/adapters/export-sink.ts b/frontend/libs/components/video-editor/src/lib/adapters/export-sink.ts new file mode 100644 index 0000000000..d91457f7aa --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/export-sink.ts @@ -0,0 +1,47 @@ +import type { ExportArtifact } from "./types"; + +// Receives the output of the editor's local renderer. Concrete sinks: +// - download to disk (default in-browser impl) +// - save via Tauri dialog (artcraft) +// - upload to Artcraft media library (webapp + Tauri impls) +// +// The editor calls `accept(artifact, options?)` once per finished +// render. Options let the host's export-popover UI pick which +// destinations to send to (disk, library, both); onProgress fires +// per-destination lifecycle events so the UI can render inline +// progress rows while the sinks run. Adapters resolve the returned +// promise only after every requested destination has settled. + +export type ExportDestination = "disk" | "library"; + +export type ExportSinkProgressEvent = + | { destination: ExportDestination; status: "pending" | "uploading" } + | { destination: ExportDestination; status: "success" } + | { destination: ExportDestination; status: "error"; error: string }; + +export interface ExportSinkOptions { + // Defaults to `true` when omitted so adapters keep their previous + // behavior for callers that pre-date the options arg. + saveToDisk?: boolean; + // Defaults to `false` — only hosts that wire a real library backend + // should opt in. The default downloadExportSink ignores this. + saveToLibrary?: boolean; + // Fires once per destination as it changes phase. Implementers are + // expected to call it at least once per requested destination so the + // UI can transition out of the initial "pending" state. + onProgress?: (event: ExportSinkProgressEvent) => void; +} + +export interface ExportSinkAdapter { + // Returns the absolute filesystem path of the saved disk artifact + // when the sink can determine it (e.g. a native save-dialog flow), + // or `null` otherwise. Browser-download sinks cannot observe the + // chosen path due to sandboxing and MUST return `null`. Library-only + // saves also return `null`. The lib does not currently consume the + // return value — it's surfaced for hosts that want to log or open + // the saved file. + accept( + artifact: ExportArtifact, + options?: ExportSinkOptions, + ): Promise; +} diff --git a/frontend/libs/components/video-editor/src/lib/adapters/index.ts b/frontend/libs/components/video-editor/src/lib/adapters/index.ts new file mode 100644 index 0000000000..540c0663ef --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/index.ts @@ -0,0 +1,43 @@ +// Adapter interfaces — the seam between the lib and its host (apps, +// artcraft, artcraft-webapp). Hosts implement these and pass them +// into `EditorProvider`. Defaults live in `./default/`. + +export type { + MediaKind, + MediaHandle, + MediaProbe, + ResolvedMedia, + ProjectMeta, + EditorProject, + AuthUser, + ExportArtifact, +} from "./types"; + +export { kindFromMime } from "./kind-from-mime"; +export type { ProjectStorageAdapter } from "./project-storage"; +export type { MediaSourceAdapter } from "./media-source"; +export type { + AssetGalleryAdapter, + MediaPickerSelection, +} from "./asset-gallery"; +export type { AuthUserAdapter } from "./auth-user"; +export type { + ExportSinkAdapter, + ExportSinkOptions, + ExportSinkProgressEvent, + ExportDestination, +} from "./export-sink"; +export type { ToastAdapter, ToastOptions } from "./toast"; +export type { SoundsAdapter, SoundsSearchResult } from "./sounds"; + +// Bundle that EditorProvider accepts. `assetGallery` is optional — +// when null/undefined, the editor uses its built-in file picker. +export interface VideoEditorAdapters { + projectStorage: import("./project-storage").ProjectStorageAdapter; + mediaSource: import("./media-source").MediaSourceAdapter; + authUser: import("./auth-user").AuthUserAdapter; + exportSink: import("./export-sink").ExportSinkAdapter; + toast: import("./toast").ToastAdapter; + soundsAdapter: import("./sounds").SoundsAdapter; + assetGallery?: import("./asset-gallery").AssetGalleryAdapter | null; +} diff --git a/frontend/libs/components/video-editor/src/lib/adapters/kind-from-mime.ts b/frontend/libs/components/video-editor/src/lib/adapters/kind-from-mime.ts new file mode 100644 index 0000000000..c55f0c16a6 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/kind-from-mime.ts @@ -0,0 +1,11 @@ +import type { MediaKind } from "./types"; + +// Map a mime type to a MediaKind discriminator. Exposed as part of the +// public API so every MediaSourceAdapter (default, webapp, Tauri, etc.) +// shares one classifier and stays in lockstep if MediaKind ever +// expands. +export function kindFromMime(mime: string): MediaKind { + if (mime.startsWith("video/")) return "video"; + if (mime.startsWith("audio/")) return "audio"; + return "image"; +} diff --git a/frontend/libs/components/video-editor/src/lib/adapters/media-source.ts b/frontend/libs/components/video-editor/src/lib/adapters/media-source.ts new file mode 100644 index 0000000000..c965e92168 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/media-source.ts @@ -0,0 +1,29 @@ +import type { MediaHandle, MediaProbe, ResolvedMedia } from "./types"; + +// Reads and writes media. The editor never opens files itself — it +// hands a `MediaHandle` to this adapter and expects a usable URL back. +// This is the single biggest seam for the future Artcraft integration: +// - artcraft-webapp will route uploads through MediaFilesApi +// - artcraft (Tauri) will route through @storyteller/tauri-api for +// local file imports and through MediaFilesApi for cloud-stored +// media. +// +// `probe` is optional. If the host can't provide metadata cheaply, the +// lib falls back to MediaBunny / browser-side decoding to discover +// duration/dimensions. +export interface MediaSourceAdapter { + resolveMedia(handle: MediaHandle): Promise; + uploadLocalFile(file: File): Promise; + probe?(handle: MediaHandle): Promise; + // Called when the editor no longer needs a previously resolved URL. + // For blob: URLs the default impl revokes them; HTTP-backed impls + // can no-op. + releaseResolved?(resolved: ResolvedMedia): void; + // Called when an uploaded handle should be torn down — e.g. when + // post-upload processing fails (probe error, decode error) and the + // editor never commits the asset to a project. Hosts that back + // uploads with persistent storage (Artcraft media library, S3 bucket) + // should delete the resource here so failed imports don't leak files. + // Local/blob-URL impls can no-op. + deleteHandle?(handle: MediaHandle): Promise; +} diff --git a/frontend/libs/components/video-editor/src/lib/adapters/project-storage.ts b/frontend/libs/components/video-editor/src/lib/adapters/project-storage.ts new file mode 100644 index 0000000000..c97a13fc86 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/project-storage.ts @@ -0,0 +1,19 @@ +import type { EditorProject, ProjectMeta } from "./types"; + +// Persistence for editor projects. Default impl writes to IndexedDB +// so the lib runs standalone. Hosts (artcraft, artcraft-webapp) will +// later swap this for an Artcraft-backed implementation that calls +// MediaFilesApi / the artcraft server. +// +// All methods are async because every realistic backend (IndexedDB, +// Tauri filesystem, HTTP API) is async. +export interface ProjectStorageAdapter { + loadProject(id: string): Promise; + saveProject(project: EditorProject): Promise; + deleteProject(id: string): Promise; + listProjects(): Promise; + // Creates a fresh project and returns its id. The implementation + // is free to assign the id (cuid, uuid, db-generated) so the lib + // does not need to know the scheme. + createProject(name: string): Promise; +} diff --git a/frontend/libs/components/video-editor/src/lib/adapters/sounds.ts b/frontend/libs/components/video-editor/src/lib/adapters/sounds.ts new file mode 100644 index 0000000000..878153cfe2 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/sounds.ts @@ -0,0 +1,50 @@ +// Sounds gallery surface. OpenCut Classic pulls sound effects from a +// Freesound-backed `/api/sounds/search` endpoint and persists saved +// sounds via its `storageService`. Artcraft has no equivalent backend +// yet, so we expose a SoundsAdapter and let hosts wire their own +// search / save / resolve pipelines. +// +// The empty default in `./default/empty-sounds-adapter.ts` returns +// shape-correct no-op results — the lib runs end-to-end but the +// sounds gallery is empty until a host injects a real impl. + +import type { SoundEffect, SavedSound } from "../sounds/types"; + +export interface SoundsSearchResult { + results: SoundEffect[]; + hasNextPage: boolean; + totalCount: number; +} + +export interface SoundsAdapter { + // Returns a page of sound effects matching `query`. Hosts paginate + // their own way; the adapter just tells the lib whether more pages + // exist. `commercialOnly` (when supported) filters licensing. + searchSounds(args: { + query: string; + page: number; + commercialOnly?: boolean; + }): Promise; + + // Returns the curated "top sounds" list shown when the search input + // is empty. `commercialOnly` filters licensing where supported. + getTopSounds(args: { commercialOnly: boolean }): Promise; + + // Loads the user's saved-sounds collection. + loadSavedSounds(): Promise; + + // Persists a sound to the saved-sounds collection. + saveSound(args: { sound: SoundEffect }): Promise; + + // Removes a sound from the saved-sounds collection. + unsaveSound(args: { soundId: number }): Promise; + + // Clears the entire saved-sounds collection. + clearSavedSounds(): Promise; + + // Returns a playable audio URL for a sound. Hosts can use this to + // attach auth tokens or proxy through a CDN; if the SoundEffect + // already carries a usable previewUrl, the adapter can return it + // as-is. + resolveAudioUrl(args: { soundId: number }): Promise; +} diff --git a/frontend/libs/components/video-editor/src/lib/adapters/toast.ts b/frontend/libs/components/video-editor/src/lib/adapters/toast.ts new file mode 100644 index 0000000000..49b031ecd8 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/toast.ts @@ -0,0 +1,25 @@ +// Toast notifications surface. The lib reports user-visible state +// changes (storage errors, render failures, "couldn't enqueue export") +// through this adapter so the host can route them into whatever toast +// system it already uses. OpenCut uses sonner directly; we go through +// this adapter so Artcraft can route into @storyteller/ui-toaster +// (or wrap, ignore, or log them differently). +// +// Levels match the typical toast library API. The `description` slot +// is optional context for the user — kept separate from `message` so +// hosts can render them in two visual tiers if desired. + +export interface ToastOptions { + description?: string; + // Optional bag for host-specific opts (icon, persist, duration, etc.) + // The lib never reads these — they're a pass-through for the host + // adapter to interpret. + meta?: Record; +} + +export interface ToastAdapter { + info(message: string, options?: ToastOptions): void; + success(message: string, options?: ToastOptions): void; + warning(message: string, options?: ToastOptions): void; + error(message: string, options?: ToastOptions): void; +} diff --git a/frontend/libs/components/video-editor/src/lib/adapters/types.ts b/frontend/libs/components/video-editor/src/lib/adapters/types.ts new file mode 100644 index 0000000000..8062776a49 --- /dev/null +++ b/frontend/libs/components/video-editor/src/lib/adapters/types.ts @@ -0,0 +1,71 @@ +// Shared types used across adapter surfaces. + +export type MediaKind = "video" | "audio" | "image"; + +// Opaque reference to a media asset. The lib stores these in +// project state; resolving them to a usable URL goes through the +// MediaSourceAdapter so the host can keep the backing storage +// (filesystem, R2, IndexedDB, Tauri-local-file) opaque to the +// editor. +export interface MediaHandle { + // Stable id assigned at upload/import time. + id: string; + // Discriminator so callers can dispatch on kind without resolving. + kind: MediaKind; +} + +export interface MediaProbe { + durationMs?: number; + widthPx?: number; + heightPx?: number; + // Best-guess container/codec mime ("video/mp4", "audio/ogg", ...). + mime?: string; + // Optional frame rate hint for video assets. + fps?: number; +} + +export interface ResolvedMedia { + // URL usable as a