Page video editor: Mount and make public (#1606)

Co-authored-by: bflatastic <bombayapirat@gmail.com>
This commit is contained in:
Tanish Manku
2026-06-05 08:21:55 -07:00
committed by GitHub
parent 8e754b4b8f
commit d09d44b7e2
546 changed files with 95954 additions and 24 deletions
@@ -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"
}
}
+12 -5
View File
@@ -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 (
<div
@@ -143,6 +148,8 @@ export function App() {
<Route path="/background-change" element={<CreateVFX />} />
<Route path="/edit-3d" element={<PageScene />} />
<Route path="/edit-3d/:sceneToken" element={<PageScene />} />
<Route path="/video-editor" element={<VideoEditorPage />} />
<Route path="/video-editor/:projectId" element={<VideoEditorPage />} />
<Route path="/support" element={<Support />} />
<Route path="/pricing" element={<Pricing />} />
{/* Welcome is public so it stays reachable right after signup
@@ -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({
<>
<FontAwesomeIcon icon={item.icon} />
<span>{item.label}</span>
{item.badge && (
<span className="ml-auto rounded-sm bg-amber-500 px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none text-white group-data-[collapsible=icon]:hidden">
{item.badge}
</span>
)}
</>
);
return (
@@ -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 <VideoEditor> 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 <VideoEditor>. 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<GalleryControlState>({
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<string[]>([]);
const resolverRef = useRef<Resolver | null>(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<AssetGalleryAdapter>(
() => ({
openPicker({ kinds }) {
return new Promise<MediaPickerSelection[]>((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 = (
<GalleryModal
mode="select"
isOpen={state.open}
forceFilter={state.filter}
selectedItemIds={selectedItemIds}
onSelectItem={handleSelectItem}
onUseSelected={handleUseSelected}
onClose={handleClose}
/>
);
return { adapter, modal };
}
@@ -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);
});
},
};
@@ -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 <a download> 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<void> {
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<string>();
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 };
@@ -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<string, string> = {
// 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<MediaHandle> {
const kind = kindFromMime(file.type);
const id = await uploadByKind({
kind,
blob: file,
fileName: file.name,
});
return { id, kind };
},
async resolveMedia(handle: MediaHandle): Promise<ResolvedMedia> {
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 };
@@ -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));
},
};
@@ -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<string> {
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;
}
@@ -0,0 +1 @@
export { default } from "./video-editor";
@@ -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<Partial<VideoEditorAdapters>>(
() => ({
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<string>(`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 <VideoEditor> 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 (
<div className="h-full w-full overflow-hidden">
<VideoEditor
projectId={projectId}
adapters={adapters}
headerEndSlot={<TopBarActions />}
/>
{galleryModal}
</div>
);
}
@@ -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%;
}
}
@@ -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: [],
@@ -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"
}
]
}
+8 -5
View File
@@ -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"
},
+3 -1
View File
@@ -1,6 +1,8 @@
/// <reference types='vitest' />
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() ],
@@ -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") {
@@ -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 ? <div><Storyboard /></div> : null;
case "BACKGROUND_CHANGE":
return <div><PageBackgroundChange /></div>;
case "VIDEO_EDITOR":
return <div className="h-[calc(100vh-3rem)] w-full"><PageVideoEditor /></div>;
default:
return null;
}
@@ -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 <VideoEditor>. 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<GalleryControlState>({
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<string[]>([]);
const resolverRef = useRef<Resolver | null>(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<AssetGalleryAdapter>(
() => ({
openPicker({ kinds }) {
return new Promise<MediaPickerSelection[]>((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 = (
<GalleryModal
mode="select"
isOpen={state.open}
forceFilter={state.filter}
selectedItemIds={selectedItemIds}
onSelectItem={handleSelectItem}
onUseSelected={handleUseSelected}
onClose={handleClose}
/>
);
return { adapter, modal };
}
@@ -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);
});
},
};
@@ -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<string>();
async function ensureSavePath(
url: string,
filename: string,
): Promise<string | null> {
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/<filename>.
const dir = await downloadDir();
return `${dir}/${filename}`;
}
async function uploadToLibrary(artifact: ExportArtifact): Promise<void> {
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;
},
};
@@ -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<string, string> = {
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<MediaHandle> {
const kind = kindFromMime(file.type);
const id = await uploadByKind({
kind,
blob: file,
fileName: file.name,
});
return { id, kind };
},
async resolveMedia(handle: MediaHandle): Promise<ResolvedMedia> {
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 };
@@ -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 +
// <Toaster /> 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));
},
};
@@ -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<string> {
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;
}
@@ -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<Partial<VideoEditorAdapters>>(
() => ({
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<string>(`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 (
<div className="h-full w-full overflow-hidden">
<VideoEditor adapters={adapters} />
{galleryModal}
</div>
);
}
@@ -16,7 +16,8 @@ export type TabId =
| "REMOVE_BACKGROUND"
| "ANGLES"
| "STORYBOARD"
| "BACKGROUND_CHANGE";
| "BACKGROUND_CHANGE"
| "VIDEO_EDITOR";
const DEFAULT_TAB: TabId = "IMAGE";
@@ -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 */
+2
View File
@@ -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",
+31
View File
@@ -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",
+3
View File
@@ -40,6 +40,9 @@
{
"path": "../../libs/components/loading-spinner"
},
{
"path": "../../libs/components/video-editor"
},
{
"path": "../../libs/components/toaster"
},
+2 -1
View File
@@ -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],
@@ -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";
@@ -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";
@@ -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";
@@ -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"
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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<VideoEditorAdapters | null>(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<VideoEditorAdapters> | 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<VideoEditorAdapters>(() => {
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 <VideoEditor> (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 <Ctx.Provider value={resolved}>{children}</Ctx.Provider>;
}
export function useEditorAdapters(): VideoEditorAdapters {
const ctx = useContext(Ctx);
if (!ctx) {
throw new Error(
"useEditorAdapters must be used inside <EditorProvider>. " +
"Mount your <VideoEditor> below an <EditorProvider>.",
);
}
return ctx;
}
@@ -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<VideoEditorAdapters> | 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 (
<EditorProvider adapters={adapters}>
<MobileGate>
{/* 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. */}
<EditorErrorBoundary>
<div className="bg-background flex h-full w-full flex-col overflow-hidden">
<DegradedRendererBanner />
<EditorHeader endSlot={headerEndSlot} />
<div className="min-h-0 min-w-0 flex-1">
<EditorLayout />
</div>
<Onboarding />
</div>
</EditorErrorBoundary>
</MobileGate>
</EditorProvider>
);
}
function DegradedRendererBanner() {
const isDegraded = useEditor((e) => e.renderer.isDegraded);
const [dismissed, setDismissed] = useState(false);
if (!isDegraded || dismissed) return null;
return (
<div className="bg-accent border-b h-9 flex items-center justify-center gap-2 text-xs text-muted-foreground">
<span>For the best experience, open this editor in Chrome.</span>
<Button
variant="text"
size="icon"
className="p-0 w-auto [&_svg]:size-3.5"
onClick={() => setDismissed(true)}
aria-label="Dismiss"
>
<HugeiconsIcon icon={Cancel01Icon} />
</Button>
</div>
);
}
function EditorLayout() {
// Boot the wasm-backed GPU compositor and gate the panel render on
// it. We need to *await* init before <PreviewCanvas> 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 <VideoEditor> conditionally on their own ready state.
const hasProject = useEditor(
(editor) => editor.project.getActive() !== null,
);
if (!gpuReady || !hasProject) return null;
return <ReadyEditorLayout />;
}
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 (
<ResizablePanelGroup
direction="vertical"
className="size-full gap-[0.18rem]"
onLayout={(sizes) => {
setPanel({
panel: "mainContent",
size: sizes[0] ?? panels.mainContent,
});
setPanel({
panel: "timeline",
size: sizes[1] ?? panels.timeline,
});
}}
>
<ResizablePanel
defaultSize={panels.mainContent}
minSize={30}
maxSize={85}
className="min-h-0"
>
<ResizablePanelGroup
direction="horizontal"
className="size-full gap-[0.19rem] px-3"
onLayout={(sizes) => {
setPanel({ panel: "tools", size: sizes[0] ?? panels.tools });
setPanel({ panel: "preview", size: sizes[1] ?? panels.preview });
setPanel({
panel: "properties",
size: sizes[2] ?? panels.properties,
});
}}
>
<ResizablePanel
defaultSize={panels.tools}
minSize={15}
maxSize={40}
className="min-w-0"
>
<AssetsPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={panels.preview}
minSize={30}
className="min-h-0 min-w-0 flex-1"
>
<PreviewPanel
overlayControls={overlayControls}
overlayInstances={overlaySource.instances}
onOverlayVisibilityChange={setOverlayVisibility}
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={panels.properties}
minSize={15}
maxSize={40}
className="min-w-0"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={panels.timeline}
minSize={15}
maxSize={70}
className="min-h-0 px-3 pb-3"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
);
}
@@ -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<string, unknown>;
}
export interface TActionDefinition extends TActionBaseDefinition {
defaultShortcuts?: readonly ShortcutKey[];
}
export const ACTIONS = {
"toggle-play": {
description: "Play/Pause",
category: "playback",
},
"stop-playback": {
description: "Stop playback",
category: "playback",
},
"seek-forward": {
description: "Seek forward 1 second",
category: "playback",
args: { seconds: "number" },
},
"seek-backward": {
description: "Seek backward 1 second",
category: "playback",
args: { seconds: "number" },
},
"frame-step-forward": {
description: "Frame step forward",
category: "navigation",
},
"frame-step-backward": {
description: "Frame step backward",
category: "navigation",
},
"jump-forward": {
description: "Jump forward 5 seconds",
category: "navigation",
args: { seconds: "number" },
},
"jump-backward": {
description: "Jump backward 5 seconds",
category: "navigation",
args: { seconds: "number" },
},
"goto-start": {
description: "Go to timeline start",
category: "navigation",
},
"goto-end": {
description: "Go to timeline end",
category: "navigation",
},
split: {
description: "Split elements at playhead",
category: "editing",
},
"split-left": {
description: "Split and remove left",
category: "editing",
},
"split-right": {
description: "Split and remove right",
category: "editing",
},
"delete-selected": {
description: "Delete current selection",
category: "editing",
},
"copy-selected": {
description: "Copy selected elements",
category: "editing",
},
"paste-copied": {
description: "Paste elements at playhead",
category: "editing",
},
"toggle-snapping": {
description: "Toggle snapping",
category: "editing",
},
"toggle-ripple-editing": {
description: "Toggle ripple editing",
category: "editing",
},
"toggle-source-audio": {
description: "Extract or recover source audio",
category: "editing",
},
"select-all": {
description: "Select all elements",
category: "selection",
},
"cancel-interaction": {
description: "Cancel current interaction",
category: "controls",
},
"deselect-all": {
description: "Deselect all elements",
category: "selection",
},
"duplicate-selected": {
description: "Duplicate selected element",
category: "selection",
},
"toggle-elements-muted-selected": {
description: "Mute/unmute selected elements",
category: "selection",
},
"toggle-elements-visibility-selected": {
description: "Show/hide selected elements",
category: "selection",
},
"toggle-bookmark": {
description: "Toggle bookmark at playhead",
category: "timeline",
},
undo: {
description: "Undo",
category: "history",
},
redo: {
description: "Redo",
category: "history",
},
"remove-media-asset": {
description: "Remove media asset",
category: "assets",
args: { projectId: "string", assetId: "string" },
},
"remove-media-assets": {
description: "Remove media assets",
category: "assets",
args: { projectId: "string", assetIds: "string[]" },
},
} as const satisfies Record<string, TActionBaseDefinition>;
export type TAction = keyof typeof ACTIONS;
const ACTION_DEFAULT_SHORTCUTS = [
["toggle-play", ["space", "k"]],
["seek-forward", ["l"]],
["seek-backward", ["j"]],
["frame-step-forward", ["right"]],
["frame-step-backward", ["left"]],
["jump-forward", ["shift+right"]],
["jump-backward", ["shift+left"]],
["goto-start", ["home", "enter"]],
["goto-end", ["end"]],
["split", ["s"]],
["split-left", ["q"]],
["split-right", ["w"]],
["delete-selected", ["backspace", "delete"]],
["copy-selected", ["ctrl+c"]],
["paste-copied", ["ctrl+v"]],
["toggle-snapping", ["n"]],
["select-all", ["ctrl+a"]],
["cancel-interaction", ["escape"]],
["duplicate-selected", ["ctrl+d"]],
["undo", ["ctrl+z"]],
["redo", ["ctrl+shift+z", "ctrl+y"]],
] as const satisfies 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<ShortcutKey, TActionWithOptionalArgs>();
for (const [action, defaultShortcuts] of ACTION_DEFAULT_SHORTCUTS) {
for (const shortcut of defaultShortcuts) {
shortcuts.set(shortcut, action);
}
}
return shortcuts;
}
@@ -0,0 +1,3 @@
export * from "./definitions";
export * from "./types";
export * from "./registry";
@@ -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<string> = 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;
};
@@ -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<ShortcutKey, TActionWithOptionalArgs>;
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<string, TActionWithOptionalArgs>;
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<string, TActionWithOptionalArgs>;
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<KeybindingsState>()(
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<ShortcutKey, TActionWithOptionalArgs>();
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;
}
@@ -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<number, MigrationFn> = {
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;
}
@@ -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<string, string> = {
"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 };
}
@@ -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<string, string> = {
"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 };
}
@@ -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 };
}
@@ -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 };
}
@@ -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 };
}
@@ -0,0 +1,37 @@
export type PersistedKeybindingConfig = Record<string, string | undefined>;
export interface PersistedKeybindingsState {
keybindings: PersistedKeybindingConfig;
isCustomized: boolean;
}
function isRecord(value: unknown): value is Record<string, unknown> {
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,
};
}
@@ -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<Record<TAction, ActionHandler[]>> = {};
// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler).
export function bindAction<A extends TAction>(
action: A,
handler: TActionFunc<A>,
) {
const handlers = boundActions[action];
const typedHandler = handler as ActionHandler;
if (handlers) {
handlers.push(typedHandler);
} else {
boundActions[action] = [typedHandler];
}
}
// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler).
export function unbindAction<A extends TAction>(
action: A,
handler: TActionFunc<A>,
) {
const handlers = boundActions[action];
if (!handlers) return;
const typedHandler = handler as ActionHandler;
boundActions[action] = handlers.filter((h) => h !== typedHandler);
if (boundActions[action]?.length === 0) {
delete boundActions[action];
}
}
type InvokeActionFunc = {
(
action: TActionWithOptionalArgs,
args?: undefined,
trigger?: TInvocationTrigger,
): void;
<A extends TActionWithArgs>(
action: A,
args: TActionArgsMap[A],
trigger?: TInvocationTrigger,
): void;
};
// eslint-disable-next-line opencut/prefer-object-params -- dispatchers conventionally separate action, payload, and trigger.
export const invokeAction: InvokeActionFunc = <A extends TAction>(
action: A,
args?: TArgOfAction<A>,
trigger?: TInvocationTrigger,
) => {
boundActions[action]?.forEach((handler) => {
handler(args, trigger);
});
};
@@ -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<KeyboardShortcut | null>(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 (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0">
<DialogHeader>
<DialogTitle>Keyboard shortcuts</DialogTitle>
</DialogHeader>
<DialogBody className="scrollbar-thin grow overflow-y-auto">
<div className="flex flex-col gap-6">
{categories.map((category) => (
<div key={category} className="flex flex-col gap-1">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{category}
</h3>
<div className="flex flex-col gap-1">
{shortcuts
.filter((shortcut) => shortcut.category === category)
.map((shortcut) => (
<ShortcutItem
key={shortcut.action}
shortcut={shortcut}
isRecording={
shortcut.action === recordingShortcut?.action
}
onStartRecording={() => handleStartRecording(shortcut)}
/>
))}
</div>
</div>
))}
</div>
</DialogBody>
<DialogFooter>
<Button variant="destructive" onClick={resetToDefaults}>
Reset to default
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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 (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{shortcut.icon && (
<div className="text-muted-foreground">{shortcut.icon}</div>
)}
<span className="text-sm">{shortcut.description}</span>
</div>
<div className="flex items-center gap-2">
{displayKeys.map((key: string, index: number) => (
<div key={key} className="flex items-center gap-2">
<div className="flex items-center gap-1">
{key.split("+").map((keyPart: string, partIndex: number) => {
const keyId = `${shortcut.id}-${index}-${partIndex}`;
return (
<EditableShortcutKey
key={keyId}
isRecording={isRecording}
onStartRecording={() => onStartRecording({ shortcut })}
>
{keyPart}
</EditableShortcutKey>
);
})}
</div>
{index < displayKeys.length - 1 && (
<span className="text-muted-foreground text-xs">or</span>
)}
</div>
))}
</div>
</div>
);
}
function EditableShortcutKey({
children,
isRecording,
onStartRecording,
}: {
children: React.ReactNode;
isRecording: boolean;
onStartRecording: () => void;
}) {
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onStartRecording();
};
return (
<Button
variant="outline"
size="sm"
onClick={handleClick}
title={
isRecording ? "Press any key combination..." : "Click to edit shortcut"
}
>
{children}
</Button>
);
}
@@ -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<T> = {
[K in keyof T]: undefined extends T[K] ? K : never;
}[keyof T];
export type TActionWithArgs = keyof TActionArgsMap;
export type TActionWithOptionalArgs =
| TActionWithNoArgs
| TKeysWithValueUndefined<TActionArgsMap>;
export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
? TActionArgsMap[A]
: undefined;
export type TActionFunc<A extends TAction> = A extends TActionWithArgs
? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
: (_?: undefined, trigger?: TInvocationTrigger) => void;
export type TInvocationTrigger = "keypress" | "mouseclick";
export type TBoundActionList = {
[A in TAction]?: Array<TActionFunc<A>>;
};
export type TActionHandlerOptions =
| MutableRefObject<boolean>
| boolean
| undefined;
@@ -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<A extends TAction>(
action: A,
handler: TActionFunc<A>,
isActive: TActionHandlerOptions,
) {
const handlerRef = useRef<TActionFunc<A>>(handler);
const isBoundRef = useRef(false);
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
const stableHandler = useCallback(
(...parameters: [TArgOfAction<A>, TInvocationTrigger?]) => {
(
handlerRef.current as (
...handlerParameters: [TArgOfAction<A>, TInvocationTrigger?]
) => void
)(...parameters);
},
[],
) as TActionFunc<A>;
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]);
}
@@ -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<ScopeEntry>(() => ({
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,
);
}
@@ -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,
]);
}
@@ -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<TActionWithOptionalArgs, string[]>();
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,
};
}
@@ -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<MediaPickerSelection[]>;
}
@@ -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;
}
@@ -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,
};
@@ -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));
},
};
@@ -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;
}
},
};
@@ -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 "";
},
};
@@ -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,
};
}
@@ -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<IDBDatabase> {
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<T>(
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest<T> | void,
): Promise<T> {
return openDb().then(
(db) =>
new Promise<T>((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<EditorProject | undefined>("readonly", (s) =>
s.get(id) as IDBRequest<EditorProject | undefined>,
);
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<EditorProject[]>("readonly", (s) =>
s.getAll() as IDBRequest<EditorProject[]>,
);
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;
},
};
}
@@ -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<string, Entry>();
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);
},
};
}
@@ -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<string | null>;
}
@@ -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;
}
@@ -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";
}
@@ -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<ResolvedMedia>;
uploadLocalFile(file: File): Promise<MediaHandle>;
probe?(handle: MediaHandle): Promise<MediaProbe>;
// 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<void>;
}
@@ -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<EditorProject | null>;
saveProject(project: EditorProject): Promise<void>;
deleteProject(id: string): Promise<void>;
listProjects(): Promise<ProjectMeta[]>;
// 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<EditorProject>;
}
@@ -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<SoundsSearchResult>;
// Returns the curated "top sounds" list shown when the search input
// is empty. `commercialOnly` filters licensing where supported.
getTopSounds(args: { commercialOnly: boolean }): Promise<SoundEffect[]>;
// Loads the user's saved-sounds collection.
loadSavedSounds(): Promise<SavedSound[]>;
// Persists a sound to the saved-sounds collection.
saveSound(args: { sound: SoundEffect }): Promise<void>;
// Removes a sound from the saved-sounds collection.
unsaveSound(args: { soundId: number }): Promise<void>;
// Clears the entire saved-sounds collection.
clearSavedSounds(): Promise<void>;
// 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<string>;
}
@@ -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<string, unknown>;
}
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;
}
@@ -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 <video>/<audio>/<img> src. Can be a blob:, file://,
// https://, or any other scheme the host supports in the active runtime.
url: string;
mime: string;
durationMs?: number;
}
// Lightweight summary of a project — enough to list and choose one
// without loading the whole timeline payload.
export interface ProjectMeta {
id: string;
name: string;
updatedAt: number;
thumbnailUrl?: string;
}
// The full serialized project. Shape stays opaque to the host — the
// editor reads/writes it as a JSON-stringifiable value. The exact
// inner shape evolves with the timeline/scene format and is not part
// of the adapter contract.
export interface EditorProject {
id: string;
name: string;
updatedAt: number;
// Editor-controlled payload. Hosts must not touch this.
data: unknown;
}
export interface AuthUser {
id: string;
displayName: string;
}
// What an export job produces. The shape mirrors what a renderer
// would emit; ExportSinkAdapter consumes it.
export interface ExportArtifact {
// The encoded file as a Blob in browser contexts, or a path/handle
// the host can interpret in Tauri.
blob: Blob;
// Suggested filename, including extension.
filename: string;
mime: string;
durationMs?: number;
}
@@ -0,0 +1,96 @@
import type { ScalarAnimationKey } from "./types";
// Cubic-Bezier interpolation helpers. Used by the scalar curve channel
// to map elapsed time → progress along a custom-shaped easing curve.
// solveBezierProgressForTime uses bisection (20 iterations is enough
// for sub-pixel accuracy at typical timeline scales) since the Bezier
// is not invertible in closed form.
const BEZIER_SOLVE_ITERATIONS = 20;
export function getBezierPoint({
progress,
p0,
p1,
p2,
p3,
}: {
progress: number;
p0: number;
p1: number;
p2: number;
p3: number;
}) {
const mt = 1 - progress;
return (
mt * mt * mt * p0 +
3 * mt * mt * progress * p1 +
3 * mt * progress * progress * p2 +
progress * progress * progress * p3
);
}
export function getDefaultRightHandle({
leftKey,
rightKey,
}: {
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
}) {
const span = rightKey.time - leftKey.time;
const valueDelta = rightKey.value - leftKey.value;
return {
dt: span / 3,
dv: valueDelta / 3,
};
}
export function getDefaultLeftHandle({
leftKey,
rightKey,
}: {
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
}) {
const span = rightKey.time - leftKey.time;
const valueDelta = rightKey.value - leftKey.value;
return {
dt: -span / 3,
dv: -valueDelta / 3,
};
}
export function solveBezierProgressForTime({
time,
leftKey,
rightKey,
}: {
time: number;
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
}) {
let lower = 0;
let upper = 1;
const rightHandle =
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
const leftHandle =
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
for (let iteration = 0; iteration < BEZIER_SOLVE_ITERATIONS; iteration++) {
const mid = (lower + upper) / 2;
const estimate = getBezierPoint({
progress: mid,
p0: leftKey.time,
p1: leftKey.time + rightHandle.dt,
p2: rightKey.time + leftHandle.dt,
p3: rightKey.time,
});
if (estimate < time) {
lower = mid;
} else {
upper = mid;
}
}
return (lower + upper) / 2;
}
@@ -0,0 +1,69 @@
import type {
AnimationChannel,
ChannelData,
CompositeChannelData,
} from "./types";
// Tagged-union helpers for ChannelData. A param's animation storage is
// either a leaf channel (key array) or a composite (string-keyed
// record of leaf channels — used for colors which animate RGBA per
// component). These guards let callers handle both shapes without
// pattern-matching open-coded everywhere.
//
// `LEGACY_ANIMATION_STORAGE_KEYS` filters out two old storage-format
// keys that used to live alongside per-path channels at the same map
// level. Encountering them on read means the project was saved before
// the channel-data refactor — skip them rather than treat them as
// channels.
const LEGACY_ANIMATION_STORAGE_KEYS = new Set(["bindings", "channels"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function isLeafChannelData(
data: ChannelData | undefined,
): data is AnimationChannel {
return isRecord(data) && Array.isArray(data.keys);
}
export function isCompositeChannelData(
data: ChannelData | undefined,
): data is CompositeChannelData {
return isRecord(data) && !Array.isArray(data.keys);
}
export function getChannelsFromData({
data,
}: {
data: ChannelData | undefined;
}): AnimationChannel[] {
if (isLeafChannelData(data)) {
return [data];
}
if (!isCompositeChannelData(data)) {
return [];
}
return Object.values(data).filter(isLeafChannelData);
}
export function getChannelEntriesFromData({
data,
}: {
data: ChannelData | undefined;
}): Array<[string, AnimationChannel]> {
if (isLeafChannelData(data)) {
return [["value", data]];
}
if (!isCompositeChannelData(data)) {
return [];
}
return Object.entries(data).flatMap(([componentKey, channel]) =>
isLeafChannelData(channel) ? [[componentKey, channel]] : [],
);
}
export function isAnimationStorageKey({ key }: { key: string }): boolean {
return !LEGACY_ANIMATION_STORAGE_KEYS.has(key);
}
@@ -0,0 +1,102 @@
import {
getDefaultLeftHandle,
getDefaultRightHandle,
} from "./bezier";
import type {
CurveHandle,
NormalizedCubicBezier,
ScalarAnimationKey,
} from "./types";
import { roundMediaTime } from "../wasm";
// Maps between the two representations of a Bezier segment:
// - Internal: per-key dt/dv handles in time/value space
// - "Normalized" cubic Bezier: [x1, y1, x2, y2] tuple in 0..1 space
// used by the visual curve picker (CSS-easing-style)
// The bridge lets the curve picker UI work in normalized space while
// the underlying animation channel stores absolute handles.
const VALUE_EPSILON = 1e-6;
function clamp01({ value }: { value: number }): number {
return Math.max(0, Math.min(1, value));
}
export function getNormalizedCubicBezierForScalarSegment({
leftKey,
rightKey,
referenceSpanValue,
}: {
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
// Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0).
referenceSpanValue?: number;
}): NormalizedCubicBezier | null {
const spanTime = rightKey.time - leftKey.time;
const spanValue = rightKey.value - leftKey.value;
const effectiveSpanValue =
Math.abs(spanValue) > VALUE_EPSILON
? spanValue
: referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON
? referenceSpanValue
: null;
if (spanTime === 0 || effectiveSpanValue === null) {
return null;
}
const rightHandle =
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
const leftHandle =
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
return [
clamp01({ value: rightHandle.dt / spanTime }),
rightHandle.dv / effectiveSpanValue,
clamp01({ value: 1 + leftHandle.dt / spanTime }),
1 + leftHandle.dv / effectiveSpanValue,
];
}
export function getCurveHandlesForNormalizedCubicBezier({
leftKey,
rightKey,
cubicBezier,
referenceSpanValue,
}: {
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
cubicBezier: NormalizedCubicBezier;
referenceSpanValue?: number;
}): {
rightHandle: CurveHandle;
leftHandle: CurveHandle;
} | null {
const spanTime = rightKey.time - leftKey.time;
const spanValue = rightKey.value - leftKey.value;
const effectiveSpanValue =
Math.abs(spanValue) > VALUE_EPSILON
? spanValue
: referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON
? referenceSpanValue
: null;
if (spanTime === 0 || effectiveSpanValue === null) {
return null;
}
const [rawX1, y1, rawX2, y2] = cubicBezier;
const x1 = clamp01({ value: rawX1 });
const x2 = clamp01({ value: rawX2 });
return {
rightHandle: {
dt: roundMediaTime({ time: spanTime * x1 }),
dv: effectiveSpanValue * y1,
},
leftHandle: {
dt: roundMediaTime({ time: spanTime * (x2 - 1) }),
dv: effectiveSpanValue * (y2 - 1),
},
};
}
@@ -0,0 +1,108 @@
import type {
ElementAnimations,
EffectParamPath,
} from "./types";
import type { ParamValues } from "../params";
import { removeElementKeyframe } from "./keyframes";
import { resolveAnimationPathValueAtTime } from "./resolve";
// Effect parameters animate via paths of the form
// "effects.<effectId>.params.<paramKey>"
// stored in element.animations. The helpers here pack/unpack that
// scheme so callers in the effects subsystem don't string-concat
// raw paths.
export const EFFECT_PARAM_PATH_PREFIX = "effects.";
export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
export function buildEffectParamPath({
effectId,
paramKey,
}: {
effectId: string;
paramKey: string;
}): EffectParamPath {
return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`;
}
export function isEffectParamPath(
propertyPath: string,
): propertyPath is EffectParamPath {
return (
propertyPath.startsWith(EFFECT_PARAM_PATH_PREFIX) &&
propertyPath.includes(EFFECT_PARAM_PATH_SUFFIX)
);
}
export function parseEffectParamPath({
propertyPath,
}: {
propertyPath: string;
}): { effectId: string; paramKey: string } | null {
if (!isEffectParamPath(propertyPath)) {
return null;
}
const withoutPrefix = propertyPath.slice(EFFECT_PARAM_PATH_PREFIX.length);
const separatorIndex = withoutPrefix.indexOf(EFFECT_PARAM_PATH_SUFFIX);
if (separatorIndex <= 0) {
return null;
}
const effectId = withoutPrefix.slice(0, separatorIndex);
const paramKey = withoutPrefix.slice(
separatorIndex + EFFECT_PARAM_PATH_SUFFIX.length,
);
if (!effectId || !paramKey) {
return null;
}
return { effectId, paramKey };
}
export function resolveEffectParamsAtTime({
effectId,
params,
animations,
localTime,
}: {
effectId: string;
params: ParamValues;
animations: ElementAnimations | undefined;
localTime: number;
}): ParamValues {
const safeLocalTime = Math.max(0, localTime);
const resolved: ParamValues = {};
for (const [paramKey, staticValue] of Object.entries(params)) {
const path = buildEffectParamPath({ effectId, paramKey });
resolved[paramKey] = animations?.[path]
? resolveAnimationPathValueAtTime({
animations,
propertyPath: path,
localTime: safeLocalTime,
fallbackValue: staticValue,
})
: staticValue;
}
return resolved;
}
export function removeEffectParamKeyframe({
animations,
effectId,
paramKey,
keyframeId,
}: {
animations: ElementAnimations | undefined;
effectId: string;
paramKey: string;
keyframeId: string;
}): ElementAnimations | undefined {
return removeElementKeyframe({
animations,
propertyPath: buildEffectParamPath({ effectId, paramKey }),
keyframeId,
});
}
@@ -0,0 +1,114 @@
import type {
AnimationPath,
ElementAnimations,
ChannelData,
ScalarAnimationChannel,
ScalarGraphChannel,
ScalarGraphKeyframeContext,
} from "./types";
import type { ChannelEasingMode } from "../params";
import { isCompositeChannelData, isLeafChannelData } from "./channel-data";
import { isScalarChannel } from "./interpolation";
// Lens over ElementAnimations that exposes only the scalar channels
// the curve graph editor can edit (handles, tangents, etc.). Composite
// (RGBA) channels are flattened into per-component scalar channels;
// callers see them as four siblings under r/g/b/a componentKeys.
export interface EditableScalarChannels {
easingMode: ChannelEasingMode;
channels: ScalarGraphChannel[];
}
function isScalarAnimationChannel(
channel: ChannelData | undefined,
): channel is ScalarAnimationChannel {
return isLeafChannelData(channel) && isScalarChannel(channel);
}
function getEasingModeForChannelData({
data,
}: {
data: ChannelData | undefined;
}): ChannelEasingMode {
return isCompositeChannelData(data) &&
["r", "g", "b", "a"].every((componentKey) => componentKey in data)
? "shared"
: "independent";
}
export function getEditableScalarChannels({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
}): EditableScalarChannels | null {
const data = animations?.[propertyPath];
if (!data) {
return null;
}
const channelEntries: Array<[string, ChannelData | undefined]> =
isLeafChannelData(data)
? [["value", data]]
: Object.entries(data);
const channels: ScalarGraphChannel[] = [];
for (const [componentKey, channel] of channelEntries) {
if (!isScalarAnimationChannel(channel)) {
continue;
}
channels.push({ propertyPath, componentKey, channel });
}
return { easingMode: getEasingModeForChannelData({ data }), channels };
}
export function getEditableScalarChannel({
animations,
propertyPath,
componentKey,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
componentKey: string;
}): ScalarGraphChannel | null {
const result = getEditableScalarChannels({ animations, propertyPath });
return result?.channels.find((channel) => channel.componentKey === componentKey) ?? null;
}
export function getScalarKeyframeContext({
animations,
propertyPath,
componentKey,
keyframeId,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
componentKey: string;
keyframeId: string;
}): ScalarGraphKeyframeContext | null {
const scalarChannel = getEditableScalarChannel({
animations,
propertyPath,
componentKey,
});
if (!scalarChannel) {
return null;
}
const keyframeIndex = scalarChannel.channel.keys.findIndex(
(keyframe) => keyframe.id === keyframeId,
);
if (keyframeIndex < 0) {
return null;
}
return {
...scalarChannel,
keyframe: scalarChannel.channel.keys[keyframeIndex],
keyframeIndex,
previousKey: scalarChannel.channel.keys[keyframeIndex - 1] ?? null,
nextKey: scalarChannel.channel.keys[keyframeIndex + 1] ?? null,
};
}
@@ -0,0 +1,71 @@
import type {
ElementAnimations,
GraphicParamPath,
} from "./types";
import type { ParamDefinition, ParamValues } from "../params";
import { resolveAnimationPathValueAtTime } from "./resolve";
// Graphic-element params animate via paths of the form
// "params.<paramKey>"
// (no element-id prefix because each graphic element owns its own
// animations map). Effect params share the same channel storage but
// use a more elaborate path scheme — see effect-param-channel.
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
export function buildGraphicParamPath({
paramKey,
}: {
paramKey: string;
}): GraphicParamPath {
return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
}
export function isGraphicParamPath(
propertyPath: string,
): propertyPath is GraphicParamPath {
return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
}
export function parseGraphicParamPath({
propertyPath,
}: {
propertyPath: string;
}): { paramKey: string } | null {
if (!isGraphicParamPath(propertyPath)) {
return null;
}
const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
return paramKey.length > 0 ? { paramKey } : null;
}
export function resolveGraphicParamsAtTime({
params,
definitions,
animations,
localTime,
}: {
params: ParamValues;
definitions: ParamDefinition[];
animations?: ElementAnimations;
localTime: number;
}): ParamValues {
const resolved: ParamValues = { ...params };
for (const param of definitions) {
const path = buildGraphicParamPath({ paramKey: param.key });
if (!animations?.[path]) {
continue;
}
resolved[param.key] = resolveAnimationPathValueAtTime({
animations,
propertyPath: path,
localTime: Math.max(0, localTime),
fallbackValue: params[param.key] ?? param.default,
});
}
return resolved;
}
@@ -0,0 +1,72 @@
export {
getChannelValueAtTime,
getDiscreteChannelValueAtTime,
getScalarChannelValueAtTime,
getScalarSegmentInterpolation,
normalizeChannel,
} from "./interpolation";
export {
clampAnimationsToDuration,
cloneAnimations,
getChannel,
removeElementKeyframe,
retimeElementKeyframe,
setBindingComponentChannel,
setChannel,
splitAnimationsAtTime,
updateScalarKeyframeCurve,
upsertPathKeyframe,
} from "./keyframes";
export {
getElementLocalTime,
resolveAnimationPathValueAtTime,
} from "./resolve";
export {
getElementKeyframes,
getKeyframeById,
getKeyframeAtTime,
hasKeyframesForPath,
} from "./keyframe-query";
export {
type EditableScalarChannels,
getEditableScalarChannel,
getEditableScalarChannels,
getScalarKeyframeContext,
} from "./graph-channels";
export {
getCurveHandlesForNormalizedCubicBezier,
getNormalizedCubicBezierForScalarSegment,
} from "./curve-bridge";
export {
buildGraphicParamPath,
isGraphicParamPath,
parseGraphicParamPath,
resolveGraphicParamsAtTime,
} from "./graphic-param-channel";
export {
buildEffectParamPath,
isEffectParamPath,
parseEffectParamPath,
removeEffectParamKeyframe,
resolveEffectParamsAtTime,
} from "./effect-param-channel";
export {
getGroupKeyframesAtTime,
hasGroupKeyframeAtTime,
type GroupKeyframeRef,
} from "./property-groups";
export {
isAnimationPath,
isAnimationPropertyPath,
} from "./path";
export type { NumericSpec } from "./types";
@@ -0,0 +1,434 @@
import type {
AnimationChannel,
AnimationInterpolation,
Channel,
DiscreteAnimationChannel,
DiscreteValue,
ScalarAnimationChannel,
ScalarAnimationKey,
ScalarSegmentType,
} from "./types";
import type { ParamValue } from "../params";
import { mediaTime } from "../wasm";
import {
getBezierPoint,
getDefaultLeftHandle,
getDefaultRightHandle,
solveBezierProgressForTime,
} from "./bezier";
import { clamp } from "../utils/math";
// Per-channel value-at-time evaluator. Scalar channels support
// hold/linear/bezier interpolation between adjacent keys and configurable
// before/after extrapolation at the channel edges. Discrete channels
// step-hold their value until the next key.
//
// normalizeChannel() sorts keys, clamps handle offsets to the segment
// span, and fills in default tangent/segment modes — produces the shape
// downstream code expects without mutating the source channel.
function byTimeAscending({
leftTime,
rightTime,
}: {
leftTime: number;
rightTime: number;
}): number {
return leftTime - rightTime;
}
function isWithinTimePair({
time,
leftTime,
rightTime,
}: {
time: number;
leftTime: number;
rightTime: number;
}): boolean {
return time >= leftTime && time <= rightTime;
}
function lerpNumber({
leftValue,
rightValue,
progress,
}: {
leftValue: number;
rightValue: number;
progress: number;
}): number {
return leftValue + (rightValue - leftValue) * progress;
}
function normalizeRightHandle({
handle,
leftKey,
rightKey,
}: {
handle: ScalarAnimationKey["rightHandle"];
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
}) {
if (!handle) {
return undefined;
}
const span = mediaTime({
ticks: Math.max(1, rightKey.time - leftKey.time),
});
return {
dt: mediaTime({
ticks: Math.min(span, Math.max(0, handle.dt)),
}),
dv: handle.dv,
};
}
function normalizeLeftHandle({
handle,
leftKey,
rightKey,
}: {
handle: ScalarAnimationKey["leftHandle"];
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
}) {
if (!handle) {
return undefined;
}
const span = mediaTime({
ticks: Math.max(1, rightKey.time - leftKey.time),
});
return {
dt: mediaTime({
ticks: Math.max(-span, Math.min(0, handle.dt)),
}),
dv: handle.dv,
};
}
function normalizeScalarKey({
key,
}: {
key: ScalarAnimationKey;
}): ScalarAnimationKey {
return {
...key,
tangentMode: key.tangentMode ?? "flat",
segmentToNext: key.segmentToNext ?? "linear",
};
}
export function normalizeScalarChannel({
channel,
}: {
channel: ScalarAnimationChannel;
}): ScalarAnimationChannel {
const sortedKeys = [...channel.keys]
.map((key) => normalizeScalarKey({ key }))
.sort((leftKey, rightKey) =>
byTimeAscending({
leftTime: leftKey.time,
rightTime: rightKey.time,
}),
);
const nextKeys = sortedKeys.map((key, index) => {
const previousKey = sortedKeys[index - 1];
const nextKey = sortedKeys[index + 1];
return {
...key,
leftHandle:
previousKey != null
? normalizeLeftHandle({
handle: key.leftHandle,
leftKey: previousKey,
rightKey: key,
})
: undefined,
rightHandle:
nextKey != null
? normalizeRightHandle({
handle: key.rightHandle,
leftKey: key,
rightKey: nextKey,
})
: undefined,
};
});
return {
...channel,
keys: nextKeys,
};
}
export function normalizeDiscreteChannel({
channel,
}: {
channel: DiscreteAnimationChannel;
}): DiscreteAnimationChannel {
return {
...channel,
keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
byTimeAscending({
leftTime: leftKeyframe.time,
rightTime: rightKeyframe.time,
}),
),
};
}
export function isScalarChannel(channel: AnimationChannel): channel is ScalarAnimationChannel {
return (
"extrapolation" in channel ||
channel.keys.some((keyframe) => "segmentToNext" in keyframe)
);
}
export function normalizeChannel({
channel,
}: {
channel: ScalarAnimationChannel;
}): ScalarAnimationChannel;
export function normalizeChannel({
channel,
}: {
channel: DiscreteAnimationChannel;
}): DiscreteAnimationChannel;
export function normalizeChannel({
channel,
}: {
channel: AnimationChannel;
}): AnimationChannel;
export function normalizeChannel({
channel,
}: {
channel: AnimationChannel;
}): AnimationChannel {
return isScalarChannel(channel)
? normalizeScalarChannel({ channel })
: normalizeDiscreteChannel({ channel });
}
function extrapolateScalarEdge({
mode,
edgeKey,
neighborKey,
time,
}: {
mode: "hold" | "linear";
edgeKey: ScalarAnimationKey;
neighborKey: ScalarAnimationKey | undefined;
time: number;
}) {
if (mode === "hold" || !neighborKey) {
return edgeKey.value;
}
const span = neighborKey.time - edgeKey.time;
if (span === 0) {
return edgeKey.value;
}
return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value);
}
export function getScalarSegmentInterpolation({
segment,
}: {
segment: ScalarSegmentType;
}): AnimationInterpolation {
if (segment === "step") {
return "hold";
}
return segment === "bezier" ? "bezier" : "linear";
}
export function getScalarChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: Channel<number> | undefined;
time: number;
fallbackValue: number;
}): number {
if (!channel || channel.keys.length === 0) {
return fallbackValue;
}
const normalizedChannel = normalizeScalarChannel({ channel });
const firstKey = normalizedChannel.keys[0];
const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
if (!firstKey || !lastKey) {
return fallbackValue;
}
if (time <= firstKey.time) {
if (time < firstKey.time) {
return extrapolateScalarEdge({
mode: normalizedChannel.extrapolation?.before ?? "hold",
edgeKey: firstKey,
neighborKey: normalizedChannel.keys[1],
time,
});
}
return firstKey.value;
}
if (time >= lastKey.time) {
if (time > lastKey.time) {
return extrapolateScalarEdge({
mode: normalizedChannel.extrapolation?.after ?? "hold",
edgeKey: lastKey,
neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2],
time,
});
}
return lastKey.value;
}
for (
let keyIndex = 0;
keyIndex < normalizedChannel.keys.length - 1;
keyIndex++
) {
const leftKey = normalizedChannel.keys[keyIndex];
const rightKey = normalizedChannel.keys[keyIndex + 1];
if (time === rightKey.time) {
return rightKey.value;
}
if (
!isWithinTimePair({
time,
leftTime: leftKey.time,
rightTime: rightKey.time,
})
) {
continue;
}
if (leftKey.segmentToNext === "step") {
return leftKey.value;
}
const span = rightKey.time - leftKey.time;
if (span === 0) {
return rightKey.value;
}
const progress = clamp({
value: (time - leftKey.time) / span,
min: 0,
max: 1,
});
if (leftKey.segmentToNext === "linear") {
return lerpNumber({
leftValue: leftKey.value,
rightValue: rightKey.value,
progress,
});
}
const curveProgress = solveBezierProgressForTime({
time,
leftKey,
rightKey,
});
const rightHandle =
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
const leftHandle =
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
return getBezierPoint({
progress: curveProgress,
p0: leftKey.value,
p1: leftKey.value + rightHandle.dv,
p2: rightKey.value + leftHandle.dv,
p3: rightKey.value,
});
}
return lastKey.value;
}
export function getDiscreteChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: Channel<DiscreteValue> | undefined;
time: number;
fallbackValue: DiscreteValue;
}): DiscreteValue {
if (!channel || channel.keys.length === 0) {
return fallbackValue;
}
const normalizedChannel = normalizeDiscreteChannel({ channel });
let currentValue = fallbackValue;
for (const key of normalizedChannel.keys) {
if (time < key.time) {
break;
}
currentValue = key.value;
}
return currentValue;
}
export function getChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: Channel<number> | undefined;
time: number;
fallbackValue: number;
}): number;
export function getChannelValueAtTime<TValue extends DiscreteValue>({
channel,
time,
fallbackValue,
}: {
channel: DiscreteAnimationChannel | undefined;
time: number;
fallbackValue: TValue;
}): TValue;
export function getChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: AnimationChannel | undefined;
time: number;
fallbackValue: ParamValue;
}): ParamValue {
if (!channel || channel.keys.length === 0) {
return fallbackValue;
}
if (typeof fallbackValue === "number") {
return isScalarChannel(channel)
? getScalarChannelValueAtTime({
channel,
time,
fallbackValue,
})
: fallbackValue;
}
if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
return fallbackValue;
}
return getDiscreteChannelValueAtTime({
channel: isScalarChannel(channel) ? undefined : channel,
time,
fallbackValue,
});
}
@@ -0,0 +1,322 @@
import type {
AnimationChannel,
AnimationPath,
ChannelData,
ElementAnimations,
ElementKeyframe,
} from "./types";
import { formatLinearRgba } from "../params";
import {
getChannelEntriesFromData,
isAnimationStorageKey,
} from "./channel-data";
import {
getChannelValueAtTime,
getScalarSegmentInterpolation,
isScalarChannel,
} from "./interpolation";
import { isAnimationPath } from "./path";
// Read-only queries over element animations. The properties panel and
// the animation-snap-points module use these to list / find keyframes
// without going through the keyframes.ts mutation path.
//
// Composite (RGBA color) channels: getUniqueChannelKeyframeMatches
// dedupes by time so a single timestamp produces one ElementKeyframe
// even when each of r/g/b/a has its own key at that instant.
function getChannelFallbackValue({
channel,
}: {
channel: AnimationChannel;
}) {
if (channel.keys.length === 0) {
return isScalarChannel(channel) ? 0 : false;
}
return channel.keys[0].value;
}
interface ChannelKeyframeMatch {
componentKey: string;
componentIndex: number;
channel: AnimationChannel;
keyframe: AnimationChannel["keys"][number];
}
function getChannelKeyframeMatches({
data,
}: {
data: ChannelData | undefined;
}): ChannelKeyframeMatch[] {
return getChannelEntriesFromData({ data }).flatMap(
([componentKey, channel], componentIndex) => {
if (channel.keys.length === 0) {
return [];
}
return channel.keys.map((keyframe) => ({
componentKey,
componentIndex,
channel,
keyframe,
}));
},
);
}
function getUniqueChannelKeyframeMatches({
data,
}: {
data: ChannelData | undefined;
}): ChannelKeyframeMatch[] {
const sortedMatches = getChannelKeyframeMatches({
data,
}).sort(
(leftMatch, rightMatch) =>
leftMatch.keyframe.time - rightMatch.keyframe.time ||
leftMatch.componentIndex - rightMatch.componentIndex,
);
const uniqueMatches: ChannelKeyframeMatch[] = [];
for (const match of sortedMatches) {
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
if (
!previousMatch ||
previousMatch.keyframe.time !== match.keyframe.time
) {
uniqueMatches.push(match);
continue;
}
if (
previousMatch.componentIndex !== 0 &&
match.componentIndex === 0
) {
uniqueMatches[uniqueMatches.length - 1] = match;
}
}
return uniqueMatches;
}
function getPreferredChannelKeyframeMatch({
matches,
}: {
matches: ChannelKeyframeMatch[];
}): ChannelKeyframeMatch | null {
return (
matches.find((match) => match.componentIndex === 0) ??
matches[0] ??
null
);
}
function getChannelValue({
channel,
time,
}: {
channel: AnimationChannel;
time: number;
}) {
const fallbackValue = getChannelFallbackValue({ channel });
if (typeof fallbackValue === "number") {
return getChannelValueAtTime({
channel: isScalarChannel(channel) ? channel : undefined,
time,
fallbackValue,
});
}
return getChannelValueAtTime({
channel: !isScalarChannel(channel) ? channel : undefined,
time,
fallbackValue,
});
}
function getComposedChannelDataValueAtTime({
data,
time,
}: {
data: ChannelData | undefined;
time: number;
}) {
const entries = getChannelEntriesFromData({ data });
if (entries.length === 0) {
return null;
}
if (entries.length === 1 && entries[0]?.[0] === "value") {
return getChannelValue({ channel: entries[0][1], time });
}
const componentValues = Object.fromEntries(
entries.map(([componentKey, channel]) => [
componentKey,
getChannelValue({ channel, time }),
]),
);
if (
typeof componentValues.r === "number" &&
typeof componentValues.g === "number" &&
typeof componentValues.b === "number" &&
typeof componentValues.a === "number"
) {
return formatLinearRgba({
color: {
r: componentValues.r,
g: componentValues.g,
b: componentValues.b,
a: componentValues.a,
},
});
}
return null;
}
function getKeyframeInterpolation({
channel,
keyframe,
}: {
channel: AnimationChannel;
keyframe: AnimationChannel["keys"][number];
}) {
return isScalarChannel(channel) && "segmentToNext" in keyframe
? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
: "hold";
}
function toElementKeyframe({
data,
propertyPath,
keyframeMatch,
}: {
data: ChannelData | undefined;
propertyPath: AnimationPath;
keyframeMatch: ChannelKeyframeMatch;
}): ElementKeyframe | null {
const value = getComposedChannelDataValueAtTime({
data,
time: keyframeMatch.keyframe.time,
});
if (value === null) {
return null;
}
return {
propertyPath,
id: keyframeMatch.keyframe.id,
time: keyframeMatch.keyframe.time,
value,
interpolation: getKeyframeInterpolation({
channel: keyframeMatch.channel,
keyframe: keyframeMatch.keyframe,
}),
};
}
export function getElementKeyframes({
animations,
}: {
animations: ElementAnimations | undefined;
}): ElementKeyframe[] {
if (!animations) {
return [];
}
return Object.entries(animations).filter(([key]) =>
isAnimationStorageKey({ key }),
).flatMap(
([propertyPath, data]) => {
if (!data || !isAnimationPath(propertyPath)) {
return [];
}
return getUniqueChannelKeyframeMatches({
data,
}).flatMap((keyframeMatch) => {
const keyframe = toElementKeyframe({
data,
propertyPath,
keyframeMatch,
});
if (!keyframe) {
return [];
}
return [keyframe];
});
},
);
}
export function hasKeyframesForPath({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
}): boolean {
return getChannelEntriesFromData({ data: animations?.[propertyPath] }).some(
([, channel]) => channel.keys.length > 0,
);
}
export function getKeyframeAtTime({
animations,
propertyPath,
time,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
time: number;
}): ElementKeyframe | null {
const data = animations?.[propertyPath];
if (!data) {
return null;
}
const keyframeMatch = getPreferredChannelKeyframeMatch({
matches: getChannelKeyframeMatches({ data }).filter(
({ keyframe }) => keyframe.time === time,
),
});
if (!keyframeMatch) {
return null;
}
return toElementKeyframe({
data,
propertyPath,
keyframeMatch,
});
}
export function getKeyframeById({
animations,
propertyPath,
keyframeId,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
keyframeId: string;
}): ElementKeyframe | null {
const data = animations?.[propertyPath];
if (!data) {
return null;
}
const keyframeMatch = getPreferredChannelKeyframeMatch({
matches: getChannelKeyframeMatches({ data }).filter(
({ keyframe }) => keyframe.id === keyframeId,
),
});
if (!keyframeMatch) {
return null;
}
return toElementKeyframe({
data,
propertyPath,
keyframeMatch,
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
import type { AnimationPath, AnimationPropertyPath } from "./types";
import { ANIMATION_PROPERTY_PATHS } from "./types";
import { isEffectParamPath } from "./effect-param-channel";
import { isGraphicParamPath } from "./graphic-param-channel";
// AnimationPath discriminator. A path is one of:
// - a known transform/opacity property (ANIMATION_PROPERTY_PATHS)
// - a graphic param path: "params.<key>"
// - an effect param path: "effects.<effectId>.params.<key>"
// isAnimationPath returns true for any of them; the more specific
// guards live in path-name-channel modules.
const ANIMATION_PROPERTY_PATH_SET = new Set<string>(ANIMATION_PROPERTY_PATHS);
export function isAnimationPropertyPath(
propertyPath: string,
): propertyPath is AnimationPropertyPath {
return ANIMATION_PROPERTY_PATH_SET.has(propertyPath);
}
export function isAnimationPath(
propertyPath: string,
): propertyPath is AnimationPath {
return (
isAnimationPropertyPath(propertyPath) ||
isGraphicParamPath(propertyPath) ||
isEffectParamPath(propertyPath)
);
}
@@ -0,0 +1,44 @@
import type {
AnimationPropertyGroup,
AnimationPropertyPath,
ElementAnimations,
} from "./types";
import { ANIMATION_PROPERTY_GROUPS } from "./types";
import { getKeyframeAtTime } from "./keyframe-query";
// "Logical" keyframe groups — e.g. the user keyframes "scale" and we
// store separate scaleX/scaleY keys under the hood. These helpers
// answer "does the group have a key at this time?" by checking each
// member path.
export interface GroupKeyframeRef {
propertyPath: AnimationPropertyPath;
keyframeId: string;
}
export function getGroupKeyframesAtTime({
animations,
group,
time,
}: {
animations: ElementAnimations | undefined;
group: AnimationPropertyGroup;
time: number;
}): GroupKeyframeRef[] {
return ANIMATION_PROPERTY_GROUPS[group].flatMap((propertyPath) => {
const keyframe = getKeyframeAtTime({ animations, propertyPath, time });
return keyframe ? [{ propertyPath, keyframeId: keyframe.id }] : [];
});
}
export function hasGroupKeyframeAtTime({
animations,
group,
time,
}: {
animations: ElementAnimations | undefined;
group: AnimationPropertyGroup;
time: number;
}): boolean {
return getGroupKeyframesAtTime({ animations, group, time }).length > 0;
}
@@ -0,0 +1,162 @@
import type {
AnimationPath,
ElementAnimations,
} from "./types";
import { formatLinearRgba, parseColorToLinearRgba } from "../params";
import type { ParamValue } from "../params";
import { isCompositeChannelData, isLeafChannelData } from "./channel-data";
import {
getChannelValueAtTime,
isScalarChannel,
} from "./interpolation";
// Computes the resolved param value at a given local time, accounting
// for animations. The path can address a leaf channel (single value)
// or a composite channel (RGBA color, decomposed into 4 scalar
// sub-channels at the lib's render boundary).
//
// localTime is element-local: 0 at the element's start, clamped to
// [0, duration] at the edges. getElementLocalTime maps from timeline
// time to local time.
export function getElementLocalTime({
timelineTime,
elementStartTime,
elementDuration,
}: {
timelineTime: number;
elementStartTime: number;
elementDuration: number;
}): number {
const localTime = timelineTime - elementStartTime;
if (localTime <= 0) {
return 0;
}
if (localTime >= elementDuration) {
return elementDuration;
}
return localTime;
}
export function resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
localTime: number;
fallbackValue: number;
}): number;
export function resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
localTime: number;
fallbackValue: string;
}): string;
export function resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
localTime: number;
fallbackValue: boolean;
}): boolean;
export function resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
localTime: number;
fallbackValue: ParamValue;
}): ParamValue;
export function resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
localTime: number;
fallbackValue: ParamValue;
}): ParamValue {
const data = animations?.[propertyPath];
if (!data) {
return fallbackValue;
}
if (isLeafChannelData(data)) {
if (typeof fallbackValue === "number") {
return getChannelValueAtTime({
channel: isScalarChannel(data) ? data : undefined,
time: localTime,
fallbackValue,
});
}
if (typeof fallbackValue === "string" || typeof fallbackValue === "boolean") {
return getChannelValueAtTime({
channel: !isScalarChannel(data) ? data : undefined,
time: localTime,
fallbackValue,
});
}
return fallbackValue;
}
if (!isCompositeChannelData(data)) {
return fallbackValue;
}
if (
typeof fallbackValue !== "string" ||
!("r" in data) ||
!("g" in data) ||
!("b" in data) ||
!("a" in data)
) {
return fallbackValue;
}
const fallbackComponents = parseColorToLinearRgba({ color: fallbackValue });
if (fallbackComponents === null) {
return fallbackValue;
}
return formatLinearRgba({
color: {
r: getChannelValueAtTime({
channel: data.r && isScalarChannel(data.r) ? data.r : undefined,
time: localTime,
fallbackValue: fallbackComponents.r,
}),
g: getChannelValueAtTime({
channel: data.g && isScalarChannel(data.g) ? data.g : undefined,
time: localTime,
fallbackValue: fallbackComponents.g,
}),
b: getChannelValueAtTime({
channel: data.b && isScalarChannel(data.b) ? data.b : undefined,
time: localTime,
fallbackValue: fallbackComponents.b,
}),
a: getChannelValueAtTime({
channel: data.a && isScalarChannel(data.a) ? data.a : undefined,
time: localTime,
fallbackValue: fallbackComponents.a,
}),
},
});
}
@@ -0,0 +1,4 @@
// Minimum allowed scale for any element. Prevents pathological zero/
// negative scales from collapsing the bounding box and breaking the
// preview hit-test and resize handles.
export const MIN_TRANSFORM_SCALE = 0.01;
@@ -0,0 +1,157 @@
import type { MediaTime } from "../wasm";
import type { ParamValue } from "../params";
export const ANIMATION_PROPERTY_PATHS = [
"transform.positionX",
"transform.positionY",
"transform.scaleX",
"transform.scaleY",
"transform.rotate",
"opacity",
"volume",
"color",
"background.color",
"background.paddingX",
"background.paddingY",
"background.offsetX",
"background.offsetY",
"background.cornerRadius",
] as const;
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
export type GraphicParamPath = `params.${string}`;
export type EffectParamPath = `effects.${string}.params.${string}`;
export type AnimationPath = string;
export const ANIMATION_PROPERTY_GROUPS = {
"transform.scale": ["transform.scaleX", "transform.scaleY"],
} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
export type DiscreteValue = boolean | string;
export interface NumericSpec {
min?: number;
max?: number;
step?: number;
}
export type AnimationColorPropertyPath = Extract<
AnimationPropertyPath,
"color" | "background.color"
>;
export type AnimationNumericPropertyPath = Exclude<
AnimationPropertyPath,
AnimationColorPropertyPath
>;
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
export type DiscreteKeyframeInterpolation = "hold";
export type AnimationInterpolation =
| ContinuousKeyframeInterpolation
| DiscreteKeyframeInterpolation;
export type ScalarSegmentType = "step" | "linear" | "bezier";
export type TangentMode = "auto" | "aligned" | "broken" | "flat";
export type ChannelExtrapolationMode = "hold" | "linear";
export interface CurveHandle {
dt: MediaTime;
dv: number;
}
interface BaseAnimationKeyframe<TValue extends ParamValue> {
id: string;
time: MediaTime; // relative to element start time
value: TValue;
}
export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
leftHandle?: CurveHandle;
rightHandle?: CurveHandle;
segmentToNext: ScalarSegmentType;
tangentMode: TangentMode;
}
export type DiscreteAnimationKey = BaseAnimationKeyframe<DiscreteValue>;
export type Keyframe<TValue extends ParamValue = ParamValue> =
TValue extends number
? ScalarAnimationKey
: TValue extends DiscreteValue
? DiscreteAnimationKey
: never;
export interface ScalarChannel {
keys: ScalarAnimationKey[];
extrapolation?: {
before: ChannelExtrapolationMode;
after: ChannelExtrapolationMode;
};
}
export interface DiscreteChannel {
keys: DiscreteAnimationKey[];
}
export type Channel<TValue extends ParamValue = ParamValue> =
TValue extends number
? ScalarChannel
: TValue extends DiscreteValue
? DiscreteChannel
: never;
export type ScalarAnimationChannel = Channel<number>;
export type DiscreteAnimationChannel = Channel<DiscreteValue>;
export type AnimationChannel = Channel;
export type CompositeChannelData = Record<string, AnimationChannel | undefined>;
export type ChannelData = AnimationChannel | CompositeChannelData;
export interface ElementAnimations {
[propertyPath: AnimationPath]: ChannelData | undefined;
}
export type NormalizedCubicBezier = [number, number, number, number];
export interface ScalarGraphChannelTarget {
propertyPath: AnimationPath;
componentKey: string;
}
export interface ScalarGraphChannel extends ScalarGraphChannelTarget {
channel: ScalarAnimationChannel;
}
export interface ScalarGraphKeyframeRef extends ScalarGraphChannelTarget {
keyframeId: string;
}
export interface ScalarGraphKeyframeContext extends ScalarGraphChannel {
keyframe: ScalarAnimationKey;
keyframeIndex: number;
previousKey: ScalarAnimationKey | null;
nextKey: ScalarAnimationKey | null;
}
export interface ScalarCurveKeyframePatch {
leftHandle?: CurveHandle | null;
rightHandle?: CurveHandle | null;
segmentToNext?: ScalarSegmentType;
tangentMode?: TangentMode;
}
export interface ElementKeyframe {
propertyPath: AnimationPath;
id: string;
time: MediaTime;
value: ParamValue;
interpolation: AnimationInterpolation;
}
export interface SelectedKeyframeRef {
trackId: string;
elementId: string;
propertyPath: AnimationPath;
keyframeId: string;
}
@@ -0,0 +1,65 @@
import type {
AnimationColorPropertyPath,
AnimationNumericPropertyPath,
ElementAnimations,
} from "./types";
import { resolveAnimationPathValueAtTime } from "./resolve";
// Thin wrappers over resolveAnimationPathValueAtTime that constrain the
// propertyPath to the right type-family (numeric vs color), so callers
// don't have to cast their fallback values at the boundary.
export function resolveOpacityAtTime({
baseOpacity,
animations,
localTime,
}: {
baseOpacity: number;
animations: ElementAnimations | undefined;
localTime: number;
}): number {
return resolveAnimationPathValueAtTime({
animations,
propertyPath: "opacity",
localTime: Math.max(0, localTime),
fallbackValue: baseOpacity,
});
}
export function resolveNumberAtTime({
baseValue,
animations,
propertyPath,
localTime,
}: {
baseValue: number;
animations: ElementAnimations | undefined;
propertyPath: AnimationNumericPropertyPath;
localTime: number;
}): number {
return resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime: Math.max(0, localTime),
fallbackValue: baseValue,
});
}
export function resolveColorAtTime({
baseColor,
animations,
propertyPath,
localTime,
}: {
baseColor: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationColorPropertyPath;
localTime: number;
}): string {
return resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime: Math.max(0, localTime),
fallbackValue: baseColor,
});
}
@@ -0,0 +1,10 @@
export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{
label: string;
value: number;
}> = [
{ label: "Light", value: 100 },
{ label: "Medium", value: 200 },
{ label: "Heavy", value: 500 },
] as const;
export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10;
@@ -0,0 +1 @@
export const DEFAULT_BACKGROUND_COLOR = "#000000";
@@ -0,0 +1,10 @@
import type { TCanvasSize } from "../project/types";
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
{ width: 1920, height: 1080 },
{ width: 1080, height: 1920 },
{ width: 1080, height: 1080 },
{ width: 1440, height: 1080 },
];
export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 };
@@ -0,0 +1,48 @@
import { PasteCommand } from "../../commands/timeline";
import type { ClipboardHandler } from "../types";
export const ElementsClipboardHandler = {
type: "elements",
canCopy({ selectedElements }) {
return selectedElements.length > 0;
},
copy({ editor, selectedElements }) {
if (selectedElements.length === 0) {
return null;
}
const results = editor.timeline.getElementsWithTracks({
elements: selectedElements,
});
const items = results.map(({ track, element }) => {
const { id: _elementId, ...elementWithoutId } = element;
return {
trackId: track.id,
trackType: track.type,
element: elementWithoutId,
};
});
if (items.length === 0) {
return null;
}
return {
type: "elements",
items,
};
},
paste({ entry, context }) {
if (entry.items.length === 0) {
return null;
}
return new PasteCommand({
time: context.time,
clipboardItems: entry.items,
});
},
} satisfies ClipboardHandler<"elements">;
@@ -0,0 +1,49 @@
import type {
ClipboardEntry,
ClipboardEntryType,
ClipboardHandler,
ClipboardHandlerMap,
CopyContext,
PasteContext,
} from "../types";
import { ElementsClipboardHandler } from "./elements";
import { KeyframesClipboardHandler } from "./keyframes";
export const clipboardHandlers = {
elements: ElementsClipboardHandler,
keyframes: KeyframesClipboardHandler,
} satisfies ClipboardHandlerMap;
export const clipboardCopyHandlers = [
KeyframesClipboardHandler,
ElementsClipboardHandler,
] as const satisfies readonly ClipboardHandler<ClipboardEntryType>[];
export function copyClipboardEntry({
context,
}: {
context: CopyContext;
}): ClipboardEntry | null {
for (const handler of clipboardCopyHandlers) {
if (!handler.canCopy(context)) {
continue;
}
return handler.copy(context);
}
return null;
}
export function buildPasteClipboardCommand({
entry,
context,
}: {
entry: ClipboardEntry;
context: PasteContext;
}) {
const handler = clipboardHandlers[entry.type] as ClipboardHandler<
typeof entry.type
>;
return handler.paste({ entry: entry as never, context });
}
@@ -0,0 +1,176 @@
import { getKeyframeById } from "../../animation";
import { getChannelEntriesFromData } from "../../animation/channel-data";
import { isScalarChannel } from "../../animation/interpolation";
import type { SelectedKeyframeRef } from "../../animation/types";
import type { TimelineElement } from "../../timeline/types";
import { PasteKeyframesCommand } from "../../commands/timeline";
import type {
ClipboardHandler,
KeyframeClipboardCurvePatch,
KeyframeClipboardItem,
} from "../types";
import { roundMediaTime, subMediaTime, type MediaTime } from "../../wasm";
function resolveSingleSourceElement({
selectedKeyframes,
}: {
selectedKeyframes: SelectedKeyframeRef[];
}) {
const firstKeyframe = selectedKeyframes[0];
if (!firstKeyframe) {
return null;
}
const sourceElement = {
trackId: firstKeyframe.trackId,
elementId: firstKeyframe.elementId,
};
const isSingleSource = selectedKeyframes.every(
(keyframe) =>
keyframe.trackId === sourceElement.trackId &&
keyframe.elementId === sourceElement.elementId,
);
return isSingleSource ? sourceElement : null;
}
function getCurvePatches({
element,
propertyPath,
keyframeId,
}: {
element: TimelineElement;
propertyPath: KeyframeClipboardItem["propertyPath"];
keyframeId: string;
}): KeyframeClipboardCurvePatch[] {
const data = element.animations?.[propertyPath];
if (!data) {
return [];
}
return getChannelEntriesFromData({ data }).flatMap(([componentKey, channel]) => {
if (!channel || !isScalarChannel(channel)) {
return [];
}
const keyframe = channel.keys.find(
(candidate) => candidate.id === keyframeId,
);
if (!keyframe) {
return [];
}
return [
{
componentKey,
patch: {
leftHandle: keyframe.leftHandle ?? null,
rightHandle: keyframe.rightHandle ?? null,
segmentToNext: keyframe.segmentToNext,
tangentMode: keyframe.tangentMode,
},
},
];
});
}
function buildClipboardItem({
element,
propertyPath,
keyframeId,
}: {
element: TimelineElement;
propertyPath: KeyframeClipboardItem["propertyPath"];
keyframeId: string;
}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: MediaTime }) | null {
const keyframe = getKeyframeById({
animations: element.animations,
propertyPath,
keyframeId,
});
if (!keyframe) {
return null;
}
return {
propertyPath,
time: keyframe.time,
value: keyframe.value,
interpolation: keyframe.interpolation,
curvePatches: getCurvePatches({
element,
propertyPath,
keyframeId,
}),
};
}
export const KeyframesClipboardHandler = {
type: "keyframes",
canCopy({ selectedKeyframes }) {
return selectedKeyframes.length > 0;
},
copy({ editor, selectedKeyframes }) {
const sourceElement = resolveSingleSourceElement({ selectedKeyframes });
if (!sourceElement) {
return null;
}
const [result] = editor.timeline.getElementsWithTracks({
elements: [sourceElement],
});
if (!result) {
return null;
}
const rawItems = selectedKeyframes.flatMap((keyframeRef) => {
const item = buildClipboardItem({
element: result.element,
propertyPath: keyframeRef.propertyPath,
keyframeId: keyframeRef.keyframeId,
});
return item ? [item] : [];
});
if (rawItems.length === 0) {
return null;
}
const minTime = Math.min(...rawItems.map((item) => item.time));
const minTimeMedia = roundMediaTime({ time: minTime });
const items = rawItems
.map(({ time, ...item }) => ({
...item,
timeOffset: subMediaTime({ a: time, b: minTimeMedia }),
}))
.sort(
(left, right) =>
left.timeOffset - right.timeOffset ||
left.propertyPath.localeCompare(right.propertyPath),
);
return {
type: "keyframes",
sourceElement,
items,
};
},
paste({
entry,
context: { selectedElements, time },
}) {
const targetElement = selectedElements[0];
if (!targetElement || entry.items.length === 0) {
return null;
}
return new PasteKeyframesCommand({
trackId: targetElement.trackId,
elementId: targetElement.elementId,
time,
clipboardItems: entry.items,
});
},
} satisfies ClipboardHandler<"keyframes">;
@@ -0,0 +1,2 @@
export * from "./types";
export * from "./handlers";
@@ -0,0 +1,89 @@
import type { EditorCore } from "../core";
import type {
AnimationInterpolation,
AnimationPath,
ScalarCurveKeyframePatch,
SelectedKeyframeRef,
} from "../animation/types";
import type { ParamValue } from "../params";
import type { Command } from "../commands/base-command";
import type {
CreateTimelineElement,
ElementRef,
TrackType,
} from "../timeline/types";
import type { MediaTime } from "../wasm";
// Clipboard payload variants. Two kinds today — elements (copied
// timeline clips, pasted at the playhead) and keyframes (copied
// animation keys, pasted relative to the destination element's
// current playhead position).
//
// Each handler in ClipboardHandlerMap knows how to copy + paste one
// variant. The ClipboardManager owns the singleton entry and routes
// copy/paste through the right handler based on what's selected.
export interface ElementClipboardItem {
trackId: string;
trackType: TrackType;
element: CreateTimelineElement;
}
export interface KeyframeClipboardCurvePatch {
componentKey: string;
patch: ScalarCurveKeyframePatch;
}
export interface KeyframeClipboardItem {
propertyPath: AnimationPath;
timeOffset: MediaTime;
value: ParamValue;
interpolation: AnimationInterpolation;
curvePatches: KeyframeClipboardCurvePatch[];
}
export interface ElementsClipboardEntry {
type: "elements";
items: ElementClipboardItem[];
}
export interface KeyframesClipboardEntry {
type: "keyframes";
sourceElement: ElementRef;
items: KeyframeClipboardItem[];
}
export interface ClipboardEntryByType {
elements: ElementsClipboardEntry;
keyframes: KeyframesClipboardEntry;
}
export type ClipboardEntry = ClipboardEntryByType[keyof ClipboardEntryByType];
export type ClipboardEntryType = keyof ClipboardEntryByType;
export interface CopyContext {
editor: EditorCore;
selectedElements: ElementRef[];
selectedKeyframes: SelectedKeyframeRef[];
}
export interface PasteContext {
editor: EditorCore;
selectedElements: ElementRef[];
selectedKeyframes: SelectedKeyframeRef[];
time: MediaTime;
}
export interface ClipboardHandler<TType extends ClipboardEntryType> {
type: TType;
canCopy(context: CopyContext): boolean;
copy(context: CopyContext): ClipboardEntryByType[TType] | null;
paste(args: {
entry: ClipboardEntryByType[TType];
context: PasteContext;
}): Command | null;
}
export type ClipboardHandlerMap = {
[TType in ClipboardEntryType]: ClipboardHandler<TType>;
};
@@ -0,0 +1,38 @@
import type { EditorSelectionPatch } from "../selection/editor-selection";
import type { ElementRef } from "../timeline/types";
// Command pattern foundation. Subclasses override `execute()` and
// `undo()` for their domain operation, and optionally `redo()` if it
// must differ from execute (rare). The CommandResult.selection patch
// lets a command request a selection change as part of its execution
// without coupling to SelectionManager directly — the command queue
// applies the patch after the command runs.
export interface CommandResult {
selection?: EditorSelectionPatch;
}
export function createElementSelectionResult(
selectedElements: ElementRef[],
): CommandResult {
return {
selection: {
selectedElements,
selectedKeyframes: [],
keyframeSelectionAnchor: null,
selectedMaskPoints: null,
},
};
}
export abstract class Command {
abstract execute(): CommandResult | undefined;
undo(): void {
throw new Error("Undo not implemented for this command");
}
redo(): CommandResult | undefined {
return this.execute();
}
}
@@ -0,0 +1,44 @@
import { Command, type CommandResult } from "./base-command";
// Groups multiple commands so they undo/redo as a single user-visible
// step. Subcommand `execute` results are folded into the last
// selection-bearing one — useful for "drop multiple elements" flows
// where only the final selection matters. Undo reverses subcommand
// order so dependent state unwinds in the right direction.
export class BatchCommand extends Command {
constructor(private commands: Command[]) {
super();
}
execute(): CommandResult | undefined {
let latestSelectionResult: CommandResult | undefined;
for (const command of this.commands) {
const result = command.execute();
if (result?.selection !== undefined) {
latestSelectionResult = result;
}
}
return latestSelectionResult;
}
undo(): void {
for (const command of [...this.commands].reverse()) {
command.undo();
}
}
redo(): CommandResult | undefined {
let latestSelectionResult: CommandResult | undefined;
for (const command of this.commands) {
const result = command.redo();
if (result?.selection !== undefined) {
latestSelectionResult = result;
}
}
return latestSelectionResult;
}
}
@@ -0,0 +1,9 @@
export { Command } from "./base-command";
export type { CommandResult } from "./base-command";
export { BatchCommand } from "./batch-command";
export { PreviewTracker } from "./preview-tracker";
export * from "./timeline";
export * from "./scene";
export * from "./media";
export * from "./project";

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