mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-05 17:58:55 +00:00
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:
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 & 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
|
||||
|
||||
Reference in new issue
Block a user