mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-25 02:29:37 +00:00
Page video editor: Mount and make public (#1606)
Co-authored-by: bflatastic <bombayapirat@gmail.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+132
@@ -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 };
|
||||
+156
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+125
@@ -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;
|
||||
},
|
||||
};
|
||||
+137
@@ -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 */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
{
|
||||
"path": "../../libs/components/loading-spinner"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/video-editor"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/toaster"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+22
@@ -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 };
|
||||
}
|
||||
+20
@@ -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 };
|
||||
}
|
||||
+13
@@ -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 };
|
||||
}
|
||||
+13
@@ -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 };
|
||||
}
|
||||
+15
@@ -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));
|
||||
},
|
||||
};
|
||||
+55
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
+30
@@ -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,
|
||||
};
|
||||
}
|
||||
+93
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
+53
@@ -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
Reference in New Issue
Block a user