Webapp: Video Frame Extractor (#1809)

Ports the Video Frame Extractor from the desktop app to the webapp at
`/frame-extractor`, with a reworked UI.

### What's new
- New Frame Extractor page: upload a video, pick one from your library,
or drag and drop
- Scrub to any moment and capture the exact frame at the playhead with
one click
- Frame stepping buttons and keyboard shortcuts (arrows to step, Space
to play/pause, C or Enter to capture)
- Burst capture: grab a sequence of frames to find the sharpest one,
with progress and cancel
- Each captured frame can be used as an image reference, animated into a
video, saved to the library, or downloaded
- Save all and clear actions for captured frames

### Integrations
- "Extract frames" action on library videos (selection bar and lightbox)
opens the extractor with that video preloaded
- Sends frames straight into the create-image / create-video reference
deck, same flow as "Send to prompt"
- New sidebar entry (purple NEW badge) and home page card
- Page is public; saving and references prompt sign-up when logged out
This commit is contained in:
BFlat authored and GitHub committed 2026-07-27 18:52:47 +07:00
1 parent b741cdd76c
commit ffa86f0ba8
15 files changed
+1773 -3

No files matched your search

@@ -18,6 +18,7 @@ import CreateAudio from "../pages/create-audio";
import CreateObject from "../pages/create-object";
import CreateWorld from "../pages/create-world";
import CreateVFX from "../pages/create-vfx";
import FrameExtractor from "../pages/frame-extractor";
import PageScene from "../pages/pagescene";
import PageDraw from "../pages/pagedraw";
import VideoEditorPage from "../pages/video-editor";
@@ -160,6 +161,7 @@ export function App() {
<Route path="/create-object" element={<CreateObject />} />
<Route path="/create-world" element={<CreateWorld />} />
<Route path="/background-change" element={<CreateVFX />} />
<Route path="/frame-extractor" element={<FrameExtractor />} />
<Route path="/moodboard" element={<MoodboardPage />} />
<Route path="/edit-3d" element={<PageScene />} />
<Route path="/edit-3d/:sceneToken" element={<PageScene />} />
@@ -15,12 +15,12 @@ import {
faLink,
faPause,
faPencil,
faPhotoFilm,
faPlay,
faSpinnerThird,
faTrashCan,
faUser,
faVideo,
faWaveformLines,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import {
@@ -77,6 +77,7 @@ export interface LightboxDetailsProps {
onRecreate?: () => void;
onMakeVideo?: () => void;
onEditOnCanvas?: () => void;
onExtractFrames?: () => void;
onDelete?: () => void;
showDownloadAppCta?: boolean;
}
@@ -93,6 +94,7 @@ export function LightboxDetails({
onRecreate,
onMakeVideo,
onEditOnCanvas,
onExtractFrames,
onDelete,
showDownloadAppCta,
}: LightboxDetailsProps) {
@@ -495,6 +497,16 @@ export function LightboxDetails({
Edit on Canvas
</Button>
)}
{onExtractFrames && (
<Button
icon={faPhotoFilm}
className="w-full border border-ui-panel-border bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
variant="secondary"
onClick={onExtractFrames}
>
Extract Frames
</Button>
)}
{onRecreate && promptData.hasToken && (
<Button
icon={faArrowRotateRight}
@@ -352,6 +352,12 @@ export function Lightbox({
applyEditOnCanvasFromImage(selectedMediaToken, selectedImageUrl, navigate);
}, [selectedMediaToken, selectedImageUrl, canEditOnCanvas, navigate, onClose]);
const handleExtractFrames = useCallback(() => {
if (!selectedMediaToken) return;
onClose();
navigate(`/frame-extractor?media=${selectedMediaToken}`);
}, [selectedMediaToken, navigate, onClose]);
return (
<>
<Modal
@@ -559,6 +565,9 @@ export function Lightbox({
? handleEditOnCanvas
: undefined
}
onExtractFrames={
isVideo && selectedMediaToken ? handleExtractFrames : undefined
}
onDelete={
selectedMediaToken
? () => setConfirmDeleteOpen(true)
@@ -18,6 +18,7 @@ import {
faDownload,
faGift,
faPencil,
faPhotoFilm,
} from "@fortawesome/pro-solid-svg-icons";
import { faDiscord } from "@fortawesome/free-brands-svg-icons";
import { Button } from "@storyteller/ui-button";
@@ -78,6 +79,12 @@ const STUDIO_ITEMS_STATIC: NavItem[] = [
icon: faObjectGroup,
badge: "BETA",
},
{
label: "Frame Extract",
href: "/frame-extractor",
icon: faPhotoFilm,
badge: "NEW",
},
];
// Rewrite the "Edit 3D" item's href to point at the user's last visited
@@ -165,7 +172,11 @@ function NavMenuItem({
/>
<span className="group-data-[collapsible=icon]:hidden">{item.label}</span>
{item.badge && (
<span className="ml-auto bg-amber-600 px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none text-white group-data-[collapsible=icon]:hidden rounded-full">
<span
className={`ml-auto px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none text-white group-data-[collapsible=icon]:hidden rounded-full ${
item.badge === "NEW" ? "bg-purple-600" : "bg-amber-600"
}`}
>
{item.badge}
</span>
)}
@@ -7,12 +7,22 @@ const ROUTE_CRUMBS: Record<string, Crumb[]> = {
"/": [{ label: "Home" }],
"/create-image": [{ label: "Create", href: "/" }, { label: "Image" }],
"/create-video": [{ label: "Create", href: "/" }, { label: "Video" }],
"/create-audio": [{ label: "Create", href: "/" }, { label: "Audio" }],
"/create-object": [{ label: "Create", href: "/" }, { label: "3D Object" }],
"/create-world": [{ label: "Create", href: "/" }, { label: "3D World" }],
"/background-change": [
{ label: "Create", href: "/" },
{ label: "Studio", href: "/" },
{ label: "Background Change" },
],
"/edit-image": [{ label: "Studio", href: "/" }, { label: "Edit Image" }],
"/edit-3d": [{ label: "Studio", href: "/" }, { label: "Edit 3D" }],
"/video-editor": [{ label: "Studio", href: "/" }, { label: "Edit Video" }],
"/moodboard": [{ label: "Studio", href: "/" }, { label: "Moodboard" }],
"/frame-extractor": [
{ label: "Studio", href: "/" },
{ label: "Frame Extractor" },
],
"/referrals": [{ label: "Referrals" }],
"/media": [{ label: "Library", href: "/library" }, { label: "Media" }],
"/pricing": [{ label: "Pricing" }],
"/support": [{ label: "Support" }],
@@ -0,0 +1,197 @@
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCamera,
faChevronDown,
faLayerGroup,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { EASE_EMPHASIS } from "../../lib/motion";
import { formatTime, formatTimePrecise } from "./lib/frame-actions";
interface ExtractionPanelProps {
currentTime: number;
duration: number;
resolution: { w: number; h: number } | null;
numFrames: number;
spacingMs: number;
onNumFramesChange: (value: number) => void;
onSpacingChange: (value: number) => void;
isExtracting: boolean;
progress: { done: number; total: number } | null;
onCaptureCurrent: () => void;
onExtractBurst: () => void;
onCancel: () => void;
disabled?: boolean;
}
export const ExtractionPanel = ({
currentTime,
duration,
resolution,
numFrames,
spacingMs,
onNumFramesChange,
onSpacingChange,
isExtracting,
progress,
onCaptureCurrent,
onExtractBurst,
onCancel,
disabled,
}: ExtractionPanelProps) => {
const [burstOpen, setBurstOpen] = useState(false);
// Burst timing math needs a real duration; live/streamed sources report
// Infinity and capture-current is the only mode that still makes sense.
const burstAvailable = Number.isFinite(duration) && duration > 0;
return (
<div className="flex h-fit flex-col divide-y divide-ui-divider rounded-xl border border-ui-panel-border bg-ui-panel">
<div className="space-y-3 p-4">
<div className="flex items-center justify-between text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
<span>Playhead</span>
<span className="font-mono text-xs normal-case tracking-normal text-base-fg/80">
{disabled ? "--:--.---" : formatTimePrecise(currentTime)}
</span>
</div>
<Button
variant="primary"
icon={faCamera}
onClick={onCaptureCurrent}
disabled={disabled || isExtracting}
className="w-full py-2 text-sm font-medium"
>
Capture frame
</Button>
<div className="text-center text-[11px] text-base-fg/40">
{disabled ? "Load a video to start" : "Press C or Enter to capture"}
</div>
</div>
<div>
<button
onClick={() => setBurstOpen((open) => !open)}
disabled={disabled || !burstAvailable}
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-base-fg/75 transition-colors hover:text-base-fg disabled:cursor-default disabled:opacity-40"
>
<span className="flex items-center gap-2">
<FontAwesomeIcon
icon={faLayerGroup}
className="text-xs text-base-fg/40"
/>
Burst capture
</span>
<FontAwesomeIcon
icon={faChevronDown}
className={`text-xs text-base-fg/40 transition-transform ${burstOpen ? "rotate-180" : ""}`}
/>
</button>
<AnimatePresence initial={false}>
{burstOpen && !disabled && burstAvailable && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: EASE_EMPHASIS }}
className="overflow-hidden"
>
<div className="space-y-3 px-4 pb-4">
<p className="text-xs leading-relaxed text-base-fg/50">
Captures a sequence of frames from the playhead — useful for
finding the sharpest one.
</p>
<div className="grid grid-cols-2 gap-2">
<label className="block">
<span className="mb-1 block text-[11px] font-medium text-base-fg/55">
Frames
</span>
<input
type="number"
min={1}
max={50}
value={numFrames}
disabled={isExtracting}
onChange={(e) =>
onNumFramesChange(
Math.max(1, Math.min(50, parseInt(e.target.value) || 1)),
)
}
className="w-full rounded-md border border-ui-controls-border bg-ui-controls px-2.5 py-1.5 text-sm text-base-fg focus:border-primary focus:outline-none"
/>
</label>
<label className="block">
<span className="mb-1 block text-[11px] font-medium text-base-fg/55">
Spacing (ms)
</span>
<input
type="number"
min={1}
max={10000}
value={spacingMs}
disabled={isExtracting}
onChange={(e) =>
onSpacingChange(
Math.max(1, Math.min(10000, parseInt(e.target.value) || 1)),
)
}
className="w-full rounded-md border border-ui-controls-border bg-ui-controls px-2.5 py-1.5 text-sm text-base-fg focus:border-primary focus:outline-none"
/>
</label>
</div>
{isExtracting && progress ? (
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-primary transition-all"
style={{
width: `${(progress.done / progress.total) * 100}%`,
}}
/>
</div>
<span className="font-mono text-[11px] text-base-fg/60">
{progress.done}/{progress.total}
</span>
<button
onClick={onCancel}
className="flex h-6 w-6 items-center justify-center rounded-md text-base-fg/50 transition-colors hover:bg-white/10 hover:text-base-fg"
aria-label="Cancel extraction"
>
<FontAwesomeIcon icon={faXmark} className="text-xs" />
</button>
</div>
) : (
<Button
variant="action"
onClick={onExtractBurst}
disabled={isExtracting}
className="w-full py-1.5 text-sm font-medium"
>
Extract {numFrames} {numFrames === 1 ? "frame" : "frames"}
</Button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="space-y-1.5 p-4 text-xs text-base-fg/45">
<div className="flex items-center justify-between">
<span>Duration</span>
<span className="font-mono text-base-fg/70">
{!disabled && Number.isFinite(duration) ? formatTime(duration) : "—"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Resolution</span>
<span className="font-mono text-base-fg/70">
{!disabled && resolution ? `${resolution.w} × ${resolution.h}` : "—"}
</span>
</div>
</div>
</div>
);
};
@@ -0,0 +1,529 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { faArrowRotateRight } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { MediaFilesApi } from "@storyteller/api";
import { addCorsParam } from "@storyteller/common";
import Seo from "../../components/seo";
import { toast } from "../../components/toast/toast";
import { useSignupCta } from "../../components/signup-cta-modal";
import { isVideoUrl } from "../../components/lightbox/shared";
import { VideoDropZone } from "./video-drop-zone";
import { VideoScrubber } from "./video-scrubber";
import { ExtractionPanel } from "./extraction-panel";
import { FramesGrid, FrameActionState } from "./frames-grid";
import {
captureFrameAt,
extractFrames,
FrameExtractionError,
type ExtractedFrame,
} from "./lib/extract-frames";
import {
downloadFrame,
sendFrameToCreate,
uploadFrame,
} from "./lib/frame-actions";
type VideoSource =
| { kind: "local"; url: string }
| { kind: "library"; url: string; mediaToken: string };
// Tokens minted per frame: a visible library save and a hidden reference
// upload are different files server-side, but a library save also works as a
// reference, so `library` is preferred when both could apply.
interface FrameTokens {
library?: string;
intermediate?: string;
}
export default function FrameExtractor() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { loggedIn, openSignupCta } = useSignupCta();
const [source, setSource] = useState<VideoSource | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [resolution, setResolution] = useState<{ w: number; h: number } | null>(
null,
);
const [numFrames, setNumFrames] = useState(10);
const [spacingMs, setSpacingMs] = useState(100);
const [frames, setFrames] = useState<ExtractedFrame[]>([]);
const [actionState, setActionState] = useState<
Record<string, FrameActionState>
>({});
const [isExtracting, setIsExtracting] = useState(false);
const [isSavingAll, setIsSavingAll] = useState(false);
const [progress, setProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [isGalleryOpen, setIsGalleryOpen] = useState(false);
const [gallerySelection, setGallerySelection] = useState<string[]>([]);
const videoRef = useRef<HTMLVideoElement>(null);
const abortRef = useRef<AbortController | null>(null);
const frameTokensRef = useRef<Map<string, FrameTokens>>(new Map());
// Frames handed off as prompt references keep their object URL alive — the
// reference deck on the create page renders it.
const handedOffUrlsRef = useRef<Set<string>>(new Set());
// ── Source loading ─────────────────────────────────────────────────────────
const resetForNewSource = useCallback(() => {
abortRef.current?.abort();
setFrames((prev) => {
prev.forEach((frame) => {
if (!handedOffUrlsRef.current.has(frame.objectUrl)) {
URL.revokeObjectURL(frame.objectUrl);
}
});
return [];
});
setActionState({});
frameTokensRef.current.clear();
setCurrentTime(0);
setDuration(0);
setResolution(null);
setProgress(null);
setIsExtracting(false);
}, []);
const setLocalSource = useCallback(
(files: FileList) => {
const file = files[0];
if (!file || !file.type.startsWith("video/")) {
toast.error("Please choose a video file");
return;
}
resetForNewSource();
setSource((prev) => {
if (prev?.kind === "local") URL.revokeObjectURL(prev.url);
return { kind: "local", url: URL.createObjectURL(file) };
});
},
[resetForNewSource],
);
const setLibrarySource = useCallback(
(url: string, mediaToken: string) => {
resetForNewSource();
setSource((prev) => {
if (prev?.kind === "local") URL.revokeObjectURL(prev.url);
return { kind: "library", url: addCorsParam(url) || url, mediaToken };
});
},
[resetForNewSource],
);
const clearSource = useCallback(() => {
resetForNewSource();
setSource((prev) => {
if (prev?.kind === "local") URL.revokeObjectURL(prev.url);
return null;
});
}, [resetForNewSource]);
// Deep link from the library: /frame-extractor?media=<token>. Consumed once
// so switching videos later doesn't resurrect the param's source.
useEffect(() => {
const token = searchParams.get("media");
if (!token) return;
setSearchParams({}, { replace: true });
(async () => {
try {
const response = await new MediaFilesApi().GetMediaFileByToken({
mediaFileToken: token,
});
const file = response.success ? response.data : null;
const url = file?.media_links?.cdn_url;
// MediaFileClass isn't exported from the @storyteller/api barrel;
// compare the string value (same approach as the video editor's
// media-source-adapter). isVideoUrl covers legacy "unknown" rows.
const isVideo =
(file?.media_class as string | null) === "video" ||
(!!url && isVideoUrl(url));
if (!url || !isVideo) {
toast.error("That media item isn't a video");
return;
}
setLibrarySource(url, token);
} catch {
toast.error("Failed to load media");
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Final cleanup: abort any in-flight burst and release blob URLs that no
// other page is displaying.
useEffect(() => {
return () => {
abortRef.current?.abort();
setFrames((prev) => {
prev.forEach((frame) => {
if (!handedOffUrlsRef.current.has(frame.objectUrl)) {
URL.revokeObjectURL(frame.objectUrl);
}
});
return prev;
});
setSource((prev) => {
if (prev?.kind === "local") URL.revokeObjectURL(prev.url);
return prev;
});
};
}, []);
// ── Extraction ─────────────────────────────────────────────────────────────
const reportExtractionError = useCallback(
(error: unknown) => {
if (!(error instanceof FrameExtractionError)) {
toast.error("Failed to capture frame");
return;
}
switch (error.kind) {
case "aborted":
break;
case "cors":
toast.error(
"This video can't be captured due to cross-origin protection. Download it and upload the file directly.",
);
break;
case "timeout":
toast.error("The video didn't finish loading — try again.");
break;
default:
toast.error("Couldn't read this video. Try an MP4 (H.264) file.");
}
},
[],
);
const handleCaptureCurrent = useCallback(async () => {
const video = videoRef.current;
if (!video || isExtracting) return;
setIsExtracting(true);
try {
const frame = await captureFrameAt(video, video.currentTime);
setFrames((prev) => [frame, ...prev]);
} catch (error) {
reportExtractionError(error);
} finally {
setIsExtracting(false);
}
}, [isExtracting, reportExtractionError]);
const handleExtractBurst = useCallback(async () => {
const video = videoRef.current;
if (!video || isExtracting) return;
const abort = new AbortController();
abortRef.current = abort;
setIsExtracting(true);
setProgress({ done: 0, total: numFrames });
try {
const extracted = await extractFrames(video, {
startSec: video.currentTime,
count: numFrames,
spacingMs,
signal: abort.signal,
onProgress: (done, total) => setProgress({ done, total }),
});
if (extracted.length === 0) {
toast.error("No frames could be captured before the video ended");
} else {
setFrames((prev) => [...extracted.reverse(), ...prev]);
}
} catch (error) {
reportExtractionError(error);
} finally {
setIsExtracting(false);
setProgress(null);
abortRef.current = null;
}
}, [isExtracting, numFrames, spacingMs, reportExtractionError]);
const handleCancelBurst = useCallback(() => {
abortRef.current?.abort();
}, []);
// ── Frame actions ──────────────────────────────────────────────────────────
const patchFrameState = useCallback(
(frameId: string, patch: FrameActionState) => {
setActionState((prev) => ({
...prev,
[frameId]: { ...prev[frameId], ...patch },
}));
},
[],
);
// A frame saved to the library is also a valid reference; a hidden
// reference upload never doubles as a library save.
const tokenForReference = useCallback(
async (frame: ExtractedFrame): Promise<string | null> => {
const cached = frameTokensRef.current.get(frame.id);
if (cached?.library || cached?.intermediate) {
return cached.library ?? cached.intermediate ?? null;
}
const result = await uploadFrame(frame, { intermediate: true });
if (!result.success) {
toast.error(result.error);
return null;
}
frameTokensRef.current.set(frame.id, {
...cached,
intermediate: result.mediaToken,
});
return result.mediaToken;
},
[],
);
const handleSendToCreate = useCallback(
async (frame: ExtractedFrame, destination: "image" | "video") => {
if (!loggedIn) {
openSignupCta();
return;
}
patchFrameState(frame.id, { sending: true });
const token = await tokenForReference(frame);
patchFrameState(frame.id, { sending: false });
if (!token) return;
handedOffUrlsRef.current.add(frame.objectUrl);
sendFrameToCreate(frame, token, destination, navigate);
},
[loggedIn, openSignupCta, patchFrameState, tokenForReference, navigate],
);
const saveFrameToLibrary = useCallback(
async (frame: ExtractedFrame): Promise<boolean> => {
const cached = frameTokensRef.current.get(frame.id);
if (cached?.library) return true;
const result = await uploadFrame(frame, { intermediate: false });
if (!result.success) return false;
frameTokensRef.current.set(frame.id, {
...cached,
library: result.mediaToken,
});
return true;
},
[],
);
const handleSave = useCallback(
async (frame: ExtractedFrame) => {
if (!loggedIn) {
openSignupCta();
return;
}
const state = actionState[frame.id];
if (state?.saved || state?.saving) return;
patchFrameState(frame.id, { saving: true });
const saved = await saveFrameToLibrary(frame);
patchFrameState(frame.id, { saving: false, saved });
if (saved) {
toast.success("Frame saved to library");
} else {
toast.error("Failed to save frame");
}
},
[loggedIn, openSignupCta, actionState, patchFrameState, saveFrameToLibrary],
);
const handleSaveAll = useCallback(async () => {
if (!loggedIn) {
openSignupCta();
return;
}
if (isSavingAll) return;
setIsSavingAll(true);
let failures = 0;
// Sequential on purpose — parallel uploads of dozens of PNGs invite rate
// limiting, and per-frame progress reads better anyway.
for (const frame of frames) {
if (actionState[frame.id]?.saved) continue;
patchFrameState(frame.id, { saving: true });
const saved = await saveFrameToLibrary(frame);
patchFrameState(frame.id, { saving: false, saved });
if (!saved) failures++;
}
setIsSavingAll(false);
if (failures === 0) {
toast.success("All frames saved to library");
} else {
toast.error(`Failed to save ${failures} ${failures === 1 ? "frame" : "frames"}`);
}
}, [
loggedIn,
openSignupCta,
isSavingAll,
frames,
actionState,
patchFrameState,
saveFrameToLibrary,
]);
const handleRemove = useCallback((frame: ExtractedFrame) => {
setFrames((prev) => prev.filter((f) => f.id !== frame.id));
if (!handedOffUrlsRef.current.has(frame.objectUrl)) {
URL.revokeObjectURL(frame.objectUrl);
}
frameTokensRef.current.delete(frame.id);
setActionState((prev) => {
const next = { ...prev };
delete next[frame.id];
return next;
});
}, []);
const handleClear = useCallback(() => {
setFrames((prev) => {
prev.forEach((frame) => {
if (!handedOffUrlsRef.current.has(frame.objectUrl)) {
URL.revokeObjectURL(frame.objectUrl);
}
});
return [];
});
frameTokensRef.current.clear();
setActionState({});
}, []);
// ── Library picker ─────────────────────────────────────────────────────────
const handleOpenGallery = useCallback(() => {
if (!loggedIn) {
openSignupCta();
return;
}
setIsGalleryOpen(true);
}, [loggedIn, openSignupCta]);
const handleGallerySelect = useCallback(
(id: string) => {
setGallerySelection((prev) => (prev.includes(id) ? [] : [id]));
},
[],
);
const handleGalleryUse = useCallback(
(selectedItems: GalleryItem[]) => {
const item = selectedItems[0];
if (!item?.fullImage) {
toast.error("No video selected");
return;
}
setLibrarySource(item.fullImage, item.id);
setIsGalleryOpen(false);
setGallerySelection([]);
},
[setLibrarySource],
);
const handleVideoError = useCallback(() => {
toast.error("Your browser can't play this video. Try an MP4 (H.264) file.");
clearSource();
}, [clearSource]);
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className="relative min-h-full w-full shrink-0 bg-[#101014] px-3 pb-8 sm:px-4 md:px-8 lg:px-12">
<Seo
title="Video Frame Extractor - ArtCraft"
description="Grab a still frame from any video — capture, save to your library, or use it as an image prompt reference."
/>
<div className="mx-auto max-w-[1200px] pt-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold text-base-fg">
Frame Extractor
</h1>
{source && (
<Button
variant="action"
icon={faArrowRotateRight}
onClick={clearSource}
className="px-3 py-1.5 text-sm"
>
Switch Video
</Button>
)}
</div>
<div className="grid gap-4 lg:grid-cols-[1fr_340px]">
{source ? (
<VideoScrubber
src={source.url}
useCrossOrigin={source.kind === "library"}
videoRef={videoRef}
onTimeChange={setCurrentTime}
onDurationChange={setDuration}
onResolutionChange={setResolution}
onCaptureRequest={handleCaptureCurrent}
onVideoError={handleVideoError}
disabled={isExtracting}
/>
) : (
<VideoDropZone
onFilesSelected={setLocalSource}
onPickFromLibrary={handleOpenGallery}
/>
)}
<ExtractionPanel
currentTime={currentTime}
duration={duration}
resolution={resolution}
numFrames={numFrames}
spacingMs={spacingMs}
onNumFramesChange={setNumFrames}
onSpacingChange={setSpacingMs}
isExtracting={isExtracting}
progress={progress}
onCaptureCurrent={handleCaptureCurrent}
onExtractBurst={handleExtractBurst}
onCancel={handleCancelBurst}
disabled={!source}
/>
</div>
<FramesGrid
frames={frames}
actionState={actionState}
onUseAsImageRef={(frame) => handleSendToCreate(frame, "image")}
onUseForVideo={(frame) => handleSendToCreate(frame, "video")}
onSave={handleSave}
onDownload={downloadFrame}
onRemove={handleRemove}
onSaveAll={handleSaveAll}
isSavingAll={isSavingAll}
onClear={handleClear}
/>
</div>
</div>
<GalleryModal
mode="select"
isOpen={isGalleryOpen}
onClose={() => {
setIsGalleryOpen(false);
setGallerySelection([]);
}}
selectedItemIds={gallerySelection}
onSelectItem={handleGallerySelect}
maxSelections={1}
onUseSelected={handleGalleryUse}
forceFilter="video"
hideFilter
/>
</div>
);
}
@@ -0,0 +1,188 @@
import { AnimatePresence, motion } from "framer-motion";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowDownToLine,
faCheck,
faImage,
faSave,
faSpinnerThird,
faVideo,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { Tooltip } from "@storyteller/ui-tooltip";
import { EASE_EMPHASIS } from "../../lib/motion";
import type { ExtractedFrame } from "./lib/extract-frames";
import { formatTimePrecise } from "./lib/frame-actions";
export interface FrameActionState {
saving?: boolean;
saved?: boolean;
sending?: boolean;
}
interface FramesGridProps {
frames: ExtractedFrame[];
actionState: Record<string, FrameActionState>;
onUseAsImageRef: (frame: ExtractedFrame) => void;
onUseForVideo: (frame: ExtractedFrame) => void;
onSave: (frame: ExtractedFrame) => void;
onDownload: (frame: ExtractedFrame) => void;
onRemove: (frame: ExtractedFrame) => void;
onSaveAll: () => void;
isSavingAll: boolean;
onClear: () => void;
}
export const FramesGrid = ({
frames,
actionState,
onUseAsImageRef,
onUseForVideo,
onSave,
onDownload,
onRemove,
onSaveAll,
isSavingAll,
onClear,
}: FramesGridProps) => {
if (frames.length === 0) return null;
const hasUnsaved = frames.some((frame) => !actionState[frame.id]?.saved);
return (
<div className="rounded-xl border border-ui-panel-border bg-ui-panel p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-base-fg">
Captured frames
<span className="ml-2 font-normal text-base-fg/40">
{frames.length}
</span>
</h3>
<div className="flex items-center gap-1">
{hasUnsaved && (
<button
onClick={onSaveAll}
disabled={isSavingAll}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-base-fg/70 transition-colors hover:bg-white/5 hover:text-base-fg disabled:opacity-50"
>
<FontAwesomeIcon
icon={isSavingAll ? faSpinnerThird : faSave}
className={isSavingAll ? "animate-spin" : ""}
/>
{isSavingAll ? "Saving…" : "Save all"}
</button>
)}
<button
onClick={onClear}
className="rounded-md px-2.5 py-1.5 text-xs font-medium text-base-fg/50 transition-colors hover:bg-white/5 hover:text-base-fg"
>
Clear
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-4">
<AnimatePresence initial={false}>
{frames.map((frame) => {
const state = actionState[frame.id] ?? {};
return (
<motion.div
key={frame.id}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.18, ease: EASE_EMPHASIS }}
className="group/frame overflow-hidden rounded-lg border border-ui-panel-border bg-ui-background"
>
<div className="relative aspect-video bg-black">
<img
src={frame.objectUrl}
alt={`Frame at ${formatTimePrecise(frame.timestamp)}`}
className="h-full w-full object-contain"
/>
<button
onClick={() => onRemove(frame)}
className="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-md bg-black/60 text-white/60 opacity-0 backdrop-blur-sm transition-all hover:text-white group-hover/frame:opacity-100"
aria-label="Remove frame"
>
<FontAwesomeIcon icon={faXmark} className="text-xs" />
</button>
<span className="absolute bottom-1.5 left-1.5 rounded bg-black/60 px-1.5 py-0.5 font-mono text-[10px] text-white/85 backdrop-blur-sm">
{formatTimePrecise(frame.timestamp)}
</span>
</div>
<div className="flex items-center justify-around border-t border-ui-panel-border/60 p-1">
<FrameAction
icon={faImage}
tooltip="Use as image reference"
onClick={() => onUseAsImageRef(frame)}
loading={state.sending}
/>
<FrameAction
icon={faVideo}
tooltip="Animate into a video"
onClick={() => onUseForVideo(frame)}
loading={state.sending}
/>
<FrameAction
icon={state.saved ? faCheck : faSave}
tooltip={state.saved ? "Saved to library" : "Save to library"}
onClick={() => onSave(frame)}
loading={state.saving}
disabled={state.saved}
success={state.saved}
/>
<FrameAction
icon={faArrowDownToLine}
tooltip="Download PNG"
onClick={() => onDownload(frame)}
/>
</div>
</motion.div>
);
})}
</AnimatePresence>
</div>
</div>
);
};
interface FrameActionProps {
icon: IconDefinition;
tooltip: string;
onClick: () => void;
loading?: boolean;
disabled?: boolean;
success?: boolean;
}
const FrameAction = ({
icon,
tooltip,
onClick,
loading,
disabled,
success,
}: FrameActionProps) => (
// fixed strategy: the card is overflow-hidden (rounded corners), which
// would clip an absolutely-positioned tooltip.
<Tooltip content={tooltip} position="top" strategy="fixed" className="z-50">
<button
onClick={onClick}
disabled={loading || disabled}
aria-label={tooltip}
className={`flex h-7 w-8 items-center justify-center rounded-md text-xs transition-colors disabled:cursor-default ${
success
? "text-green"
: "text-base-fg/60 hover:bg-white/5 hover:text-base-fg"
}`}
>
<FontAwesomeIcon
icon={loading ? faSpinnerThird : icon}
className={loading ? "animate-spin" : ""}
/>
</button>
</Tooltip>
);
@@ -0,0 +1 @@
export { default } from "./frame-extractor";
@@ -0,0 +1,239 @@
// Client-side video frame extraction: seek the <video> element, wait for the
// frame to be presented, draw it to a canvas, and encode a PNG blob. No server
// round-trips — but the video must not taint the canvas (cross-origin sources
// need CORS headers + crossOrigin="anonymous" on the element).
export interface ExtractedFrame {
id: string;
blob: Blob;
// Preview URL for the blob. Owned by the caller: revoke when the frame is
// discarded (but not while a handed-off reference still displays it).
objectUrl: string;
timestamp: number;
width: number;
height: number;
}
export type FrameErrorKind = "cors" | "timeout" | "decode" | "aborted";
export class FrameExtractionError extends Error {
kind: FrameErrorKind;
constructor(kind: FrameErrorKind, message: string) {
super(message);
this.name = "FrameExtractionError";
this.kind = kind;
}
}
export interface ExtractFramesOptions {
startSec: number;
count: number;
spacingMs: number;
onProgress?: (done: number, total: number) => void;
signal?: AbortSignal;
}
const METADATA_TIMEOUT_MS = 5000;
const SEEK_TIMEOUT_MS = 3000;
// Best-effort wait for the seeked-to frame to be presented; bounded because a
// paused video that doesn't present a new frame never fires a video-frame
// callback at all.
const PAINT_WAIT_MS = 300;
// Seeking to (almost) the current position presents no new frame; skip the
// seek entirely rather than wait on events that may never fire.
const SEEK_EPSILON_SEC = 0.001;
// Capture a single frame at the given time. Seeks the video there and leaves
// it there (the natural behavior for "capture at playhead" — the playhead
// already is at timeSec, modulo clamping).
export async function captureFrameAt(
video: HTMLVideoElement,
timeSec: number,
): Promise<ExtractedFrame> {
await ensureVideoReady(video);
const canvas = createCanvasFor(video);
const target = clampToDuration(timeSec, video.duration);
// Seek even when the playhead already sits at the target: it's cheap, and it
// guarantees the presented frame matches currentTime (a paused video that
// was never seeked can still be showing the poster frame).
await seekTo(video, target);
return drawFrame(video, canvas, target);
}
// Burst capture: `count` frames starting at `startSec`, `spacingMs` apart.
// Restores the original playhead and play state when finished, cancelled, or
// failed. Throws FrameExtractionError; on "aborted" any frames captured so
// far are lost (callers treat cancel as a full stop).
export async function extractFrames(
video: HTMLVideoElement,
{ startSec, count, spacingMs, onProgress, signal }: ExtractFramesOptions,
): Promise<ExtractedFrame[]> {
await ensureVideoReady(video);
const originalTime = video.currentTime;
const wasPlaying = !video.paused;
if (wasPlaying) video.pause();
const canvas = createCanvasFor(video);
const frames: ExtractedFrame[] = [];
try {
for (let i = 0; i < count; i++) {
if (signal?.aborted) {
throw new FrameExtractionError("aborted", "Extraction cancelled");
}
const timestamp = startSec + (i * spacingMs) / 1000;
if (timestamp > video.duration) break;
const target = clampToDuration(timestamp, video.duration);
await seekTo(video, target);
frames.push(await drawFrame(video, canvas, target));
onProgress?.(frames.length, count);
}
return frames;
} catch (error) {
frames.forEach((frame) => URL.revokeObjectURL(frame.objectUrl));
throw error;
} finally {
video.currentTime = originalTime;
if (wasPlaying) video.play().catch(() => {});
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
async function ensureVideoReady(video: HTMLVideoElement): Promise<void> {
if (video.readyState >= 2 && video.videoWidth && video.videoHeight) return;
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(
new FrameExtractionError("timeout", "Video failed to load metadata"),
);
}, METADATA_TIMEOUT_MS);
const handleLoadedData = () => {
cleanup();
resolve();
};
const handleError = () => {
cleanup();
reject(new FrameExtractionError("decode", "Video failed to load"));
};
const cleanup = () => {
clearTimeout(timeout);
video.removeEventListener("loadeddata", handleLoadedData);
video.removeEventListener("error", handleError);
};
video.addEventListener("loadeddata", handleLoadedData);
video.addEventListener("error", handleError);
});
if (!video.videoWidth || !video.videoHeight) {
throw new FrameExtractionError("decode", "Video has no visible frames");
}
}
function createCanvasFor(video: HTMLVideoElement): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
return canvas;
}
// Clamp into [0, duration - 1ms]: seeking exactly to `duration` after `ended`
// can present a black frame. Non-finite durations (live/webm streams) pass
// through — the seek target is whatever the caller had.
function clampToDuration(timeSec: number, duration: number): number {
if (!Number.isFinite(duration)) return Math.max(0, timeSec);
return Math.max(0, Math.min(timeSec, Math.max(0, duration - 0.001)));
}
async function seekTo(video: HTMLVideoElement, timeSec: number): Promise<void> {
// Already there (e.g. the first burst frame starts at the playhead): the
// current frame is already presented and drawable.
if (Math.abs(video.currentTime - timeSec) < SEEK_EPSILON_SEC) return;
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new FrameExtractionError("timeout", "Video seek timed out"));
}, SEEK_TIMEOUT_MS);
const handleSeeked = () => {
cleanup();
resolve();
};
const cleanup = () => {
clearTimeout(timeout);
video.removeEventListener("seeked", handleSeeked);
};
video.addEventListener("seeked", handleSeeked, { once: true });
video.currentTime = timeSec;
});
// "seeked" can fire before the new frame is actually presented (notably
// Safari). Wait for a video-frame callback when available — but bounded,
// since a paused video presents no further frames once composition settles
// and the callback would otherwise never fire.
await new Promise<void>((resolve) => {
const timeout = setTimeout(resolve, PAINT_WAIT_MS);
const done = () => {
clearTimeout(timeout);
resolve();
};
if (typeof video.requestVideoFrameCallback === "function") {
video.requestVideoFrameCallback(() => done());
} else {
requestAnimationFrame(() => requestAnimationFrame(() => done()));
}
});
}
async function drawFrame(
video: HTMLVideoElement,
canvas: HTMLCanvasElement,
timestamp: number,
): Promise<ExtractedFrame> {
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new FrameExtractionError("decode", "Canvas 2D context unavailable");
}
try {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
} catch (error) {
if (error instanceof Error && error.name === "SecurityError") {
throw new FrameExtractionError(
"cors",
"Video is cross-origin protected",
);
}
throw new FrameExtractionError("decode", "Failed to draw video frame");
}
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((result) => {
if (result) {
resolve(result);
} else {
// toBlob also returns null on a tainted canvas.
reject(new FrameExtractionError("cors", "Failed to encode frame"));
}
}, "image/png");
});
return {
id: crypto.randomUUID(),
blob,
objectUrl: URL.createObjectURL(blob),
timestamp,
width: canvas.width,
height: canvas.height,
};
}
@@ -0,0 +1,92 @@
import type { NavigateFunction } from "react-router-dom";
import { MediaUploadApi, EIntermediateFile } from "@storyteller/api";
import type { RefImage } from "../../../components/prompt-box/types";
import { useCreateImageStore } from "../../create-image/create-image-store";
import { useCreateVideoStore } from "../../create-video/create-video-store";
import type { ExtractedFrame } from "./extract-frames";
export type UploadFrameResult =
| { success: true; mediaToken: string }
| { success: false; error: string };
// Upload a captured frame as a PNG. `intermediate: false` = a visible library
// save; `intermediate: true` = a hidden helper file backing a prompt reference.
export async function uploadFrame(
frame: ExtractedFrame,
{ intermediate }: { intermediate: boolean },
): Promise<UploadFrameResult> {
try {
const response = await new MediaUploadApi().UploadImage({
uuid: crypto.randomUUID(),
blob: frame.blob,
fileName: frameFileName(frame),
maybe_title: `video-frame-${frame.timestamp}`,
is_intermediate_system_file: intermediate
? EIntermediateFile.true
: EIntermediateFile.false,
});
if (response.success && response.data) {
return { success: true, mediaToken: response.data };
}
return {
success: false,
error: response.errorMessage || "Failed to upload frame",
};
} catch {
return { success: false, error: "Failed to upload frame" };
}
}
// Park the frame as a pending prompt reference and navigate to the create
// page, which merges it under the selected model's reference cap (same
// channel as the library's "Send to prompt").
export function sendFrameToCreate(
frame: ExtractedFrame,
mediaToken: string,
destination: "image" | "video",
navigate: NavigateFunction,
): void {
const ref: RefImage = {
id: crypto.randomUUID(),
url: frame.objectUrl,
file: new File([frame.blob], frameFileName(frame), { type: "image/png" }),
mediaToken,
};
if (destination === "image") {
useCreateImageStore.getState().setPendingRefImages([ref]);
navigate("/create-image");
} else {
useCreateVideoStore.getState().setPendingRefImages([ref]);
navigate("/create-video");
}
}
export function downloadFrame(frame: ExtractedFrame): void {
const anchor = document.createElement("a");
anchor.style.display = "none";
anchor.href = frame.objectUrl;
anchor.download = frameFileName(frame);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}
export function formatTime(seconds: number): string {
const safe = Number.isFinite(seconds) ? seconds : 0;
const mins = Math.floor(safe / 60);
const secs = Math.floor(safe % 60);
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
export function formatTimePrecise(seconds: number): string {
const safe = Number.isFinite(seconds) ? seconds : 0;
const millis = Math.floor((safe % 1) * 1000);
return `${formatTime(safe)}.${millis.toString().padStart(3, "0")}`;
}
function frameFileName(frame: ExtractedFrame): string {
// Colons aren't filename-safe; mm-ss.mmm keeps the timestamp readable.
return `frame-${formatTimePrecise(frame.timestamp).replace(":", "-")}.png`;
}
@@ -0,0 +1,85 @@
import { useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFilm, faImages, faUpload } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { twMerge } from "tailwind-merge";
// Stand-in for the video player while no video is loaded: same card footprint
// as the scrubber, accepts click-to-browse, library pick, and drag & drop.
interface VideoDropZoneProps {
onFilesSelected: (files: FileList) => void;
onPickFromLibrary: () => void;
}
export const VideoDropZone = ({
onFilesSelected,
onPickFromLibrary,
}: VideoDropZoneProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragActive, setIsDragActive] = useState(false);
const handleFiles = (files?: FileList | null) => {
if (!files || files.length === 0) return;
onFilesSelected(files);
if (fileInputRef.current) fileInputRef.current.value = "";
};
return (
<div className="overflow-hidden rounded-xl border border-ui-panel-border bg-ui-panel p-3">
<div
className={twMerge(
"flex aspect-video flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-ui-border bg-ui-background/40 p-6 text-center transition-colors",
isDragActive && "border-primary/60 bg-primary/5",
)}
onDragEnter={(e) => {
e.preventDefault();
setIsDragActive(true);
}}
onDragOver={(e) => {
e.preventDefault();
setIsDragActive(true);
}}
onDragLeave={(e) => {
e.preventDefault();
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
setIsDragActive(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDragActive(false);
handleFiles(e.dataTransfer?.files);
}}
>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="video/*"
onChange={(e) => handleFiles(e.target.files)}
/>
<FontAwesomeIcon icon={faFilm} className="text-2xl text-base-fg/25" />
<div className="text-sm text-base-fg/55">
Drag &amp; drop a video, or
</div>
<div className="flex flex-wrap justify-center gap-2">
<Button
variant="primary"
icon={faUpload}
onClick={() => fileInputRef.current?.click()}
className="px-4 py-1.5 text-sm font-medium"
>
Select video
</Button>
<Button
variant="action"
icon={faImages}
onClick={onPickFromLibrary}
className="px-4 py-1.5 text-sm font-medium"
>
From library
</Button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,365 @@
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPlay,
faPause,
faVolumeHigh,
faVolumeMute,
faBackwardStep,
faForwardStep,
faChevronLeft,
faChevronRight,
} from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import { formatTime, formatTimePrecise } from "./lib/frame-actions";
// Browsers don't expose the real frame rate, so stepping assumes 30fps —
// close enough for "nudge to the sharp frame" purposes.
const FRAME_STEP_SEC = 1 / 30;
interface VideoScrubberProps {
src: string;
// Set for remote (library) sources so canvas capture isn't tainted.
useCrossOrigin: boolean;
// Parent owns the element ref — extraction needs direct access to it.
videoRef: RefObject<HTMLVideoElement | null>;
onTimeChange: (time: number) => void;
onDurationChange: (duration: number) => void;
onResolutionChange: (resolution: { w: number; h: number }) => void;
onCaptureRequest: () => void;
onVideoError: () => void;
disabled?: boolean;
}
export const VideoScrubber = ({
src,
useCrossOrigin,
videoRef,
onTimeChange,
onDurationChange,
onResolutionChange,
onCaptureRequest,
onVideoError,
disabled,
}: VideoScrubberProps) => {
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [hoverRatio, setHoverRatio] = useState<number | null>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const isScrubbingRef = useRef(false);
// Mirror media element state into React (play/pause/seek can come from
// anywhere: clicks, keyboard, extraction restoring the playhead).
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
onTimeChange(video.currentTime);
};
const handleLoadedMetadata = () => {
setDuration(video.duration);
setCurrentTime(0);
onDurationChange(video.duration);
onTimeChange(0);
if (video.videoWidth && video.videoHeight) {
onResolutionChange({ w: video.videoWidth, h: video.videoHeight });
}
};
const handleEnded = () => setIsPlaying(false);
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
const handleVolumeChange = () => {
setVolume(video.volume);
setIsMuted(video.muted);
};
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("loadedmetadata", handleLoadedMetadata);
video.addEventListener("ended", handleEnded);
video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause);
video.addEventListener("volumechange", handleVolumeChange);
return () => {
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
video.removeEventListener("ended", handleEnded);
video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause);
video.removeEventListener("volumechange", handleVolumeChange);
};
}, [videoRef, src, onTimeChange, onDurationChange, onResolutionChange]);
// Page-level shortcuts, active whenever a video is loaded. Window-scoped
// (not focus-scoped): clicking anywhere on the page must not kill them.
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (disabledRef.current || e.defaultPrevented) return;
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable)
) {
return;
}
switch (e.key) {
case "ArrowLeft":
e.preventDefault();
seekByRef.current(e.shiftKey ? -1 : -FRAME_STEP_SEC);
break;
case "ArrowRight":
e.preventDefault();
seekByRef.current(e.shiftKey ? 1 : FRAME_STEP_SEC);
break;
case " ":
e.preventDefault();
togglePlayPauseRef.current();
break;
case "Enter":
case "c":
case "C":
e.preventDefault();
onCaptureRequestRef.current();
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
const seekTo = (time: number) => {
const video = videoRef.current;
if (!video || !Number.isFinite(duration) || duration === 0) return;
const clamped = Math.max(0, Math.min(time, duration));
video.currentTime = clamped;
// timeupdate can lag a seek; reflect it immediately for a snappy readout.
setCurrentTime(clamped);
onTimeChange(clamped);
};
const seekBy = (deltaSec: number) => {
const video = videoRef.current;
if (!video) return;
video.pause();
seekTo(video.currentTime + deltaSec);
};
const togglePlayPause = () => {
const video = videoRef.current;
if (!video || disabled) return;
if (isPlaying) {
video.pause();
} else {
video.play().catch(() => {});
}
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !isMuted;
};
// Latest-value refs so the mount-once window key listener never calls into
// stale closures.
const disabledRef = useRef(!!disabled);
disabledRef.current = !!disabled;
const seekByRef = useRef(seekBy);
seekByRef.current = seekBy;
const togglePlayPauseRef = useRef(togglePlayPause);
togglePlayPauseRef.current = togglePlayPause;
const onCaptureRequestRef = useRef(onCaptureRequest);
onCaptureRequestRef.current = onCaptureRequest;
const ratioFromPointer = (clientX: number): number => {
const timeline = timelineRef.current;
if (!timeline) return 0;
const rect = timeline.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
};
// One pointer interaction covers click-seek and drag-scrub, and works for
// touch via pointer capture.
const handleTimelinePointerDown = (
e: React.PointerEvent<HTMLDivElement>,
) => {
if (disabled || !Number.isFinite(duration) || duration === 0) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
isScrubbingRef.current = true;
videoRef.current?.pause();
seekTo(ratioFromPointer(e.clientX) * duration);
};
const handleTimelinePointerMove = (
e: React.PointerEvent<HTMLDivElement>,
) => {
if (isScrubbingRef.current) {
seekTo(ratioFromPointer(e.clientX) * duration);
} else {
setHoverRatio(ratioFromPointer(e.clientX));
}
};
const handleTimelinePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (!isScrubbingRef.current) return;
isScrubbingRef.current = false;
e.currentTarget.releasePointerCapture(e.pointerId);
};
const progressPercent =
Number.isFinite(duration) && duration > 0
? (currentTime / duration) * 100
: 0;
const stepButtons: {
label: string;
tooltip: string;
icon: typeof faChevronLeft;
delta: number;
}[] = [
{ label: "-1s", tooltip: "Back 1s (Shift+←)", icon: faBackwardStep, delta: -1 },
{ label: "-1f", tooltip: "Back 1 frame (←)", icon: faChevronLeft, delta: -FRAME_STEP_SEC },
{ label: "+1f", tooltip: "Forward 1 frame (→)", icon: faChevronRight, delta: FRAME_STEP_SEC },
{ label: "+1s", tooltip: "Forward 1s (Shift+→)", icon: faForwardStep, delta: 1 },
];
return (
<div className="group/scrubber overflow-hidden rounded-xl border border-ui-panel-border bg-ui-panel">
<div className="relative aspect-video w-full bg-black">
<video
ref={videoRef}
src={src}
className="h-full w-full bg-black"
onClick={togglePlayPause}
onError={onVideoError}
preload="metadata"
playsInline
crossOrigin={useCrossOrigin ? "anonymous" : undefined}
/>
{!isPlaying && !disabled && (
<button
onClick={togglePlayPause}
className="absolute inset-0 m-auto flex h-12 w-12 items-center justify-center rounded-full bg-black/55 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover/scrubber:opacity-100"
aria-label="Play"
>
<FontAwesomeIcon icon={faPlay} className="ml-0.5" />
</button>
)}
</div>
<div className={`space-y-3 p-4 ${disabled ? "pointer-events-none opacity-50" : ""}`}>
<div
ref={timelineRef}
className="group/timeline relative h-8 cursor-pointer touch-none select-none"
onPointerDown={handleTimelinePointerDown}
onPointerMove={handleTimelinePointerMove}
onPointerUp={handleTimelinePointerUp}
onPointerCancel={handleTimelinePointerUp}
onPointerLeave={() => setHoverRatio(null)}
>
<div className="absolute top-1/2 h-2 w-full -translate-y-1/2 rounded-full bg-white/10 transition-all group-hover/timeline:h-3">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div
className="absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow"
style={{ left: `${progressPercent}%` }}
/>
{hoverRatio !== null &&
Number.isFinite(duration) &&
duration > 0 && (
<div
className="pointer-events-none absolute -top-6 -translate-x-1/2 rounded-md bg-black/80 px-2 py-0.5 font-mono text-[10px] text-white"
style={{ left: `${hoverRatio * 100}%` }}
>
{formatTimePrecise(hoverRatio * duration)}
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
{stepButtons.slice(0, 2).map((step) => (
<Tooltip key={step.label} content={step.tooltip} position="top" className="z-50">
<button
onClick={() => seekBy(step.delta)}
className="flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white"
aria-label={step.tooltip}
>
<FontAwesomeIcon icon={step.icon} className="text-xs" />
</button>
</Tooltip>
))}
<Tooltip content="Play/Pause (Space)" position="top" className="z-50">
<button
onClick={togglePlayPause}
className="mx-0.5 flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
aria-label="Play or pause"
>
<FontAwesomeIcon icon={isPlaying ? faPause : faPlay} />
</button>
</Tooltip>
{stepButtons.slice(2).map((step) => (
<Tooltip key={step.label} content={step.tooltip} position="top" className="z-50">
<button
onClick={() => seekBy(step.delta)}
className="flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white"
aria-label={step.tooltip}
>
<FontAwesomeIcon icon={step.icon} className="text-xs" />
</button>
</Tooltip>
))}
</div>
<div className="flex items-center gap-3">
<div className="hidden items-center gap-2 sm:flex">
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-md text-white/60 transition-colors hover:bg-white/10 hover:text-white"
aria-label={isMuted ? "Unmute" : "Mute"}
>
<FontAwesomeIcon
icon={isMuted ? faVolumeMute : faVolumeHigh}
className="text-xs"
/>
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) => {
const video = videoRef.current;
if (video) video.volume = parseFloat(e.target.value);
}}
className="w-20 accent-primary"
aria-label="Volume"
/>
</div>
<div className="font-mono text-sm text-base-fg/80">
{formatTimePrecise(currentTime)}
<span className="text-base-fg/40"> / {formatTime(duration)}</span>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -10,6 +10,7 @@ import {
faGlobe,
faFilm,
faObjectGroup,
faPhotoFilm,
faArrowRight,
} from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
@@ -114,6 +115,16 @@ const APPS: AppCard[] = [
iconColor: "text-indigo-300",
badge: "BETA",
},
{
label: "Frame Extractor",
description: "Grab a still frame from any video.",
href: "/frame-extractor",
icon: faPhotoFilm,
accent: "from-orange-500/20 to-orange-500/0",
iconBg: "bg-orange-500/25 border-orange-400/30",
iconColor: "text-orange-300",
badge: "NEW",
},
];
export function Home() {
@@ -55,6 +55,7 @@ import {
faCloud,
faList,
faSpinnerThird,
faPhotoFilm,
} from "@fortawesome/pro-solid-svg-icons";
import { Lightbox } from "../../components/lightbox/lightbox";
import { toast } from "../../components/toast/toast";
@@ -2247,6 +2248,24 @@ function BulkSelectionBar({
</div>
)}
{/* Extract frames (single selected video → frame extractor tool). */}
{selectedItems.length === 1 &&
selectedItems[0].mediaClass === "video" && (
<button
type="button"
title="Extract frames"
onClick={() => {
const token = selectedItems[0].id;
clear();
navigate(`/frame-extractor?media=${token}`);
}}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white hover:bg-ui-controls/90 transition-colors"
>
<FontAwesomeIcon icon={faPhotoFilm} className="text-xs" />
<span className="hidden sm:inline">Extract frames</span>
</button>
)}
{/* Add to folder */}
<div className="relative">
<button