diff --git a/frontend/apps/artcraft-webapp/src/app/app.tsx b/frontend/apps/artcraft-webapp/src/app/app.tsx index 6e7f705587..fb0420e68e 100644 --- a/frontend/apps/artcraft-webapp/src/app/app.tsx +++ b/frontend/apps/artcraft-webapp/src/app/app.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/apps/artcraft-webapp/src/components/lightbox/LightboxDetails.tsx b/frontend/apps/artcraft-webapp/src/components/lightbox/LightboxDetails.tsx index 9fcf08b820..0081a5e347 100644 --- a/frontend/apps/artcraft-webapp/src/components/lightbox/LightboxDetails.tsx +++ b/frontend/apps/artcraft-webapp/src/components/lightbox/LightboxDetails.tsx @@ -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 )} + {onExtractFrames && ( + + Extract Frames + + )} {onRecreate && promptData.hasToken && ( { + if (!selectedMediaToken) return; + onClose(); + navigate(`/frame-extractor?media=${selectedMediaToken}`); + }, [selectedMediaToken, navigate, onClose]); + return ( <> setConfirmDeleteOpen(true) diff --git a/frontend/apps/artcraft-webapp/src/components/sidebar/app-sidebar.tsx b/frontend/apps/artcraft-webapp/src/components/sidebar/app-sidebar.tsx index 4aa411c9a2..55d18016ba 100644 --- a/frontend/apps/artcraft-webapp/src/components/sidebar/app-sidebar.tsx +++ b/frontend/apps/artcraft-webapp/src/components/sidebar/app-sidebar.tsx @@ -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({ /> {item.label} {item.badge && ( - + {item.badge} )} diff --git a/frontend/apps/artcraft-webapp/src/components/topbar/breadcrumbs.tsx b/frontend/apps/artcraft-webapp/src/components/topbar/breadcrumbs.tsx index a334e031b8..5192b007d6 100644 --- a/frontend/apps/artcraft-webapp/src/components/topbar/breadcrumbs.tsx +++ b/frontend/apps/artcraft-webapp/src/components/topbar/breadcrumbs.tsx @@ -7,12 +7,22 @@ const ROUTE_CRUMBS: Record = { "/": [{ 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" }], diff --git a/frontend/apps/artcraft-webapp/src/pages/frame-extractor/extraction-panel.tsx b/frontend/apps/artcraft-webapp/src/pages/frame-extractor/extraction-panel.tsx new file mode 100644 index 0000000000..6856141466 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/frame-extractor/extraction-panel.tsx @@ -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 ( + + + + Playhead + + {disabled ? "--:--.---" : formatTimePrecise(currentTime)} + + + + Capture frame + + + {disabled ? "Load a video to start" : "Press C or Enter to capture"} + + + + + 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" + > + + + Burst capture + + + + + {burstOpen && !disabled && burstAvailable && ( + + + + Captures a sequence of frames from the playhead — useful for + finding the sharpest one. + + + + + Frames + + + 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" + /> + + + + Spacing (ms) + + + 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" + /> + + + {isExtracting && progress ? ( + + + + + + {progress.done}/{progress.total} + + + + + + ) : ( + + Extract {numFrames} {numFrames === 1 ? "frame" : "frames"} + + )} + + + )} + + + + + + Duration + + {!disabled && Number.isFinite(duration) ? formatTime(duration) : "—"} + + + + Resolution + + {!disabled && resolution ? `${resolution.w} × ${resolution.h}` : "—"} + + + + + ); +}; diff --git a/frontend/apps/artcraft-webapp/src/pages/frame-extractor/frame-extractor.tsx b/frontend/apps/artcraft-webapp/src/pages/frame-extractor/frame-extractor.tsx new file mode 100644 index 0000000000..584f0761e0 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/frame-extractor/frame-extractor.tsx @@ -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(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([]); + const [actionState, setActionState] = useState< + Record + >({}); + 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([]); + + const videoRef = useRef(null); + const abortRef = useRef(null); + const frameTokensRef = useRef>(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>(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=. 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 => { + 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 => { + 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 ( + + + + + + + Frame Extractor + + {source && ( + + Switch Video + + )} + + + + {source ? ( + + ) : ( + + )} + + + + handleSendToCreate(frame, "image")} + onUseForVideo={(frame) => handleSendToCreate(frame, "video")} + onSave={handleSave} + onDownload={downloadFrame} + onRemove={handleRemove} + onSaveAll={handleSaveAll} + isSavingAll={isSavingAll} + onClear={handleClear} + /> + + + + { + setIsGalleryOpen(false); + setGallerySelection([]); + }} + selectedItemIds={gallerySelection} + onSelectItem={handleGallerySelect} + maxSelections={1} + onUseSelected={handleGalleryUse} + forceFilter="video" + hideFilter + /> + + ); +} diff --git a/frontend/apps/artcraft-webapp/src/pages/frame-extractor/frames-grid.tsx b/frontend/apps/artcraft-webapp/src/pages/frame-extractor/frames-grid.tsx new file mode 100644 index 0000000000..fd4c19a6dc --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/frame-extractor/frames-grid.tsx @@ -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; + 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 ( + + + + Captured frames + + {frames.length} + + + + {hasUnsaved && ( + + + {isSavingAll ? "Saving…" : "Save all"} + + )} + + Clear + + + + + + + {frames.map((frame) => { + const state = actionState[frame.id] ?? {}; + return ( + + + + 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" + > + + + + {formatTimePrecise(frame.timestamp)} + + + + onUseAsImageRef(frame)} + loading={state.sending} + /> + onUseForVideo(frame)} + loading={state.sending} + /> + onSave(frame)} + loading={state.saving} + disabled={state.saved} + success={state.saved} + /> + onDownload(frame)} + /> + + + ); + })} + + + + ); +}; + +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. + + + + + +); diff --git a/frontend/apps/artcraft-webapp/src/pages/frame-extractor/index.ts b/frontend/apps/artcraft-webapp/src/pages/frame-extractor/index.ts new file mode 100644 index 0000000000..21a4fb2ea0 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/frame-extractor/index.ts @@ -0,0 +1 @@ +export { default } from "./frame-extractor"; diff --git a/frontend/apps/artcraft-webapp/src/pages/frame-extractor/lib/extract-frames.ts b/frontend/apps/artcraft-webapp/src/pages/frame-extractor/lib/extract-frames.ts new file mode 100644 index 0000000000..b7f40f2644 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/frame-extractor/lib/extract-frames.ts @@ -0,0 +1,239 @@ +// Client-side video frame extraction: seek the 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 { + 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 { + 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 { + if (video.readyState >= 2 && video.videoWidth && video.videoHeight) return; + + await new Promise((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 { + // 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((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((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 { + 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((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, + }; +} diff --git a/frontend/apps/artcraft-webapp/src/pages/frame-extractor/lib/frame-actions.ts b/frontend/apps/artcraft-webapp/src/pages/frame-extractor/lib/frame-actions.ts new file mode 100644 index 0000000000..cbe779a041 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/frame-extractor/lib/frame-actions.ts @@ -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 { + 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`; +} diff --git a/frontend/apps/artcraft-webapp/src/pages/frame-extractor/video-drop-zone.tsx b/frontend/apps/artcraft-webapp/src/pages/frame-extractor/video-drop-zone.tsx new file mode 100644 index 0000000000..4ec29c890b --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/frame-extractor/video-drop-zone.tsx @@ -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(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 ( + + { + 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); + }} + > + handleFiles(e.target.files)} + /> + + + Drag & drop a video, or + + + fileInputRef.current?.click()} + className="px-4 py-1.5 text-sm font-medium" + > + Select video + + + From library + + + + + ); +}; diff --git a/frontend/apps/artcraft-webapp/src/pages/frame-extractor/video-scrubber.tsx b/frontend/apps/artcraft-webapp/src/pages/frame-extractor/video-scrubber.tsx new file mode 100644 index 0000000000..c1e210f7ad --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/frame-extractor/video-scrubber.tsx @@ -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; + 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(null); + + const timelineRef = useRef(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, + ) => { + 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, + ) => { + if (isScrubbingRef.current) { + seekTo(ratioFromPointer(e.clientX) * duration); + } else { + setHoverRatio(ratioFromPointer(e.clientX)); + } + }; + + const handleTimelinePointerUp = (e: React.PointerEvent) => { + 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 ( + + + + {!isPlaying && !disabled && ( + + + + )} + + + + setHoverRatio(null)} + > + + + + + {hoverRatio !== null && + Number.isFinite(duration) && + duration > 0 && ( + + {formatTimePrecise(hoverRatio * duration)} + + )} + + + + + {stepButtons.slice(0, 2).map((step) => ( + + 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} + > + + + + ))} + + + + + + {stepButtons.slice(2).map((step) => ( + + 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} + > + + + + ))} + + + + + + + + { + const video = videoRef.current; + if (video) video.volume = parseFloat(e.target.value); + }} + className="w-20 accent-primary" + aria-label="Volume" + /> + + + {formatTimePrecise(currentTime)} + / {formatTime(duration)} + + + + + + ); +}; diff --git a/frontend/apps/artcraft-webapp/src/pages/home/home.tsx b/frontend/apps/artcraft-webapp/src/pages/home/home.tsx index 2afba51477..6b783192cc 100644 --- a/frontend/apps/artcraft-webapp/src/pages/home/home.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/home/home.tsx @@ -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() { diff --git a/frontend/apps/artcraft-webapp/src/pages/library/library.tsx b/frontend/apps/artcraft-webapp/src/pages/library/library.tsx index 980f34f1e0..76f932b31c 100644 --- a/frontend/apps/artcraft-webapp/src/pages/library/library.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/library/library.tsx @@ -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({ )} + {/* Extract frames (single selected video → frame extractor tool). */} + {selectedItems.length === 1 && + selectedItems[0].mediaClass === "video" && ( + { + 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" + > + + Extract frames + + )} + {/* Add to folder */}
+ Captures a sequence of frames from the playhead — useful for + finding the sharpest one. +