3d tool pages + double gallery stack bug fix for extractor page (#904)

new pages disabled for now and coming soon tag
This commit is contained in:
BFlat authored and GitHub committed 2025-11-19 01:59:19 +07:00
1 parent ea80dab67b
commit e7da946e2d
16 files changed
+1353 -793

No files matched your search

@@ -0,0 +1,394 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Button, ToggleButton } from "@storyteller/ui-button";
import { TabSelector } from "@storyteller/ui-tab-selector";
import { Tooltip } from "@storyteller/ui-tooltip";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCube,
faCubes,
faGlobe,
faImages,
faPlus,
faSparkles,
faUpload,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
type Mode = "image" | "text";
type Variant = "object" | "world";
interface ImageTo3DExperienceProps {
title: string;
subtitle: string;
variant: Variant;
}
interface GeneratedResult {
id: string;
mode: Mode;
timestamp: number;
note?: string;
previewUrl?: string;
meshOnly?: boolean;
}
const MODE_TABS = [
{ id: "image", label: "Image to 3D" },
{ id: "text", label: "Text to 3D" },
] satisfies { id: Mode; label: string }[];
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
const generateId = () =>
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2, 10);
export const ImageTo3DExperience = ({
title,
subtitle,
variant,
}: ImageTo3DExperienceProps) => {
const [activeMode, setActiveMode] = useState<Mode>("image");
const [uploadedPreview, setUploadedPreview] = useState<string | null>(null);
const [uploadedName, setUploadedName] = useState<string | null>(null);
const [prompt, setPrompt] = useState("");
const [results, setResults] = useState<GeneratedResult[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [dragActive, setDragActive] = useState(false);
const [meshOnly, setMeshOnly] = useState(false);
const timeoutRef = useRef<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(
() => () => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
if (uploadedPreview && uploadedPreview.startsWith("blob:")) {
URL.revokeObjectURL(uploadedPreview);
}
},
[uploadedPreview],
);
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
});
const handleFiles = (files?: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
if (!file.type.startsWith("image/")) return;
if (uploadedPreview && uploadedPreview.startsWith("blob:")) {
URL.revokeObjectURL(uploadedPreview);
}
const objectUrl = URL.createObjectURL(file);
setUploadedPreview(objectUrl);
setUploadedName(file.name);
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
handleFiles(event.target.files);
};
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setDragActive(false);
handleFiles(event.dataTransfer?.files);
};
const handlePickFromLibrary = () => {
console.info("TODO: integrate gallery modal for ImageTo3DExperience");
};
const handleGenerate = () => {
if (isGenerating) return;
setIsGenerating(true);
const snapshotPrompt = prompt.trim();
const snapshotPreview = uploadedPreview || undefined;
const newResult: GeneratedResult = {
id: generateId(),
mode: activeMode,
timestamp: Date.now(),
note: activeMode === "text" ? snapshotPrompt : uploadedName || undefined,
previewUrl: snapshotPreview,
meshOnly,
};
timeoutRef.current = window.setTimeout(() => {
setResults((prev) => [newResult, ...prev]);
setIsGenerating(false);
if (activeMode === "text") {
setPrompt("");
}
}, 1200);
};
const canGenerate = useMemo(() => {
if (isGenerating) return false;
if (activeMode === "image") {
return Boolean(uploadedPreview);
}
if (activeMode === "text") {
return prompt.trim().length > 3;
}
return true;
}, [activeMode, uploadedPreview, prompt, isGenerating]);
const resultTitle =
variant === "object" ? "3D Object Preview" : "3D World Preview";
const renderAddImageTile = () => (
<Tooltip
interactive
position="top"
delay={100}
zIndex={50}
content={
<div className="flex flex-col gap-1.5 text-left">
<Button
variant="primary"
icon={faUpload}
onClick={() => fileInputRef.current?.click()}
className="w-full"
>
Upload image
</Button>
<Button
variant="action"
icon={faImages}
onClick={handlePickFromLibrary}
className="w-full"
>
Pick from library
</Button>
</div>
}
>
<div
role="button"
tabIndex={0}
className={twMerge(
"flex aspect-square w-48 flex-col items-center justify-center rounded-2xl border-[3px] border-dashed border-primary/40 bg-primary/5 text-center text-xs transition-colors hover:border-primary hover:bg-primary/10 focus:outline-none focus:ring-2 focus:ring-primary/40",
dragActive && "border-primary bg-primary/10",
)}
onDragEnter={(event) => {
event.preventDefault();
event.stopPropagation();
setDragActive(true);
}}
onDragOver={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onDragLeave={(event) => {
event.preventDefault();
event.stopPropagation();
if (!event.currentTarget.contains(event.relatedTarget as Node)) {
setDragActive(false);
}
}}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
fileInputRef.current?.click();
}
}}
>
<FontAwesomeIcon
icon={faPlus}
className="text-4xl text-base-fg opacity-90 drop-shadow"
/>
<span className="mt-3 text-[15px] font-medium text-base-fg opacity-60">
Add Image
</span>
</div>
</Tooltip>
);
const renderImageMode = () => (
<div className="flex justify-center">
{uploadedPreview ? (
<div
className="group relative aspect-square w-48 cursor-pointer overflow-hidden rounded-2xl border-[3px] border-primary/40 bg-black/30"
onClick={() => fileInputRef.current?.click()}
>
<img
src={uploadedPreview}
alt="Reference"
className="h-full w-full object-cover"
/>
<button
type="button"
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
if (uploadedPreview?.startsWith("blob:")) {
URL.revokeObjectURL(uploadedPreview);
}
setUploadedPreview(null);
setUploadedName(null);
}}
>
<FontAwesomeIcon icon={faXmark} className="text-xs" />
</button>
</div>
) : (
renderAddImageTile()
)}
</div>
);
const promptInputId = `image-to-3d-${variant}-prompt`;
const renderTextMode = () => (
<div>
<textarea
ref={textareaRef}
id={promptInputId}
rows={1}
className="text-md max-h-[5.5em] w-full resize-none overflow-y-auto rounded bg-transparent pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none"
value={prompt}
placeholder="Describe any object you want to generate from scratch..."
onChange={(event) => setPrompt(event.target.value)}
/>
</div>
);
const renderActiveMode = () => {
if (activeMode === "text") return renderTextMode();
return renderImageMode();
};
const renderResults = () => (
<div className="mb-10 space-y-6">
{results.map((result) => (
<div
key={result.id}
className="bg-ui-background/80 rounded-3xl border border-ui-panel-border p-5 shadow-xl"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm font-semibold uppercase tracking-widest text-base-fg/50">
{resultTitle}
</div>
<div className="text-xs text-base-fg/50">
{formatTime(result.timestamp)}
</div>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-3">
<div className="md:col-span-2">
<div className="aspect-video w-full overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-controls">
{result.previewUrl ? (
<img
src={result.previewUrl}
alt="Result preview"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-2 text-base-fg/60">
<FontAwesomeIcon
icon={variant === "object" ? faCube : faGlobe}
className="text-2xl"
/>
<span className="text-sm">Preview ready</span>
</div>
)}
</div>
</div>
<div className="flex flex-col gap-3">
<div className="rounded-2xl border border-ui-panel-border bg-ui-controls/60 p-3 text-sm text-base-fg">
<p className="font-semibold capitalize">
{result.mode} input
{result.meshOnly ? " · Mesh only" : ""}
</p>
<p className="text-xs text-base-fg/70">
{result.note ?? "Configuration snapshot saved."}
</p>
</div>
<Button variant="primary" className="w-full">
Open in Studio
</Button>
<Button variant="action" className="w-full">
Download GLB
</Button>
</div>
</div>
</div>
))}
</div>
);
return (
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full items-center justify-center bg-ui-panel px-4 text-base-fg">
<div className="flex w-full flex-col items-center gap-8 py-10">
<div className="mb-6 space-y-3 text-center">
<h1 className="text-7xl font-bold tracking-tight">{title}</h1>
<p className="text-xl text-base-fg/70">{subtitle}</p>
</div>
{results.length > 0 && (
<div className="max-h-[45vh] w-full overflow-y-auto pr-2">
{renderResults()}
</div>
)}
<div className="glass w-full max-w-md rounded-xl p-5">
<div className="space-y-5">{renderActiveMode()}</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
<ToggleButton
isActive={meshOnly}
icon={faCubes}
activeIcon={faCubes}
label="Mesh only"
onClick={() => setMeshOnly((prev) => !prev)}
/>
<div className="flex items-center gap-3">
<Button
variant="primary"
icon={faSparkles}
disabled={!canGenerate}
onClick={handleGenerate}
loading={isGenerating}
>
{`Generate ${variant === "object" ? "Object" : "World"}`}
</Button>
</div>
</div>
</div>
<div className="sticky top-6 mx-auto flex justify-center">
<TabSelector
tabs={MODE_TABS}
activeTab={activeMode}
onTabChange={(tabId) => setActiveMode(tabId as Mode)}
className="w-full max-w-md"
indicatorClassName="bg-primary/25"
/>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
);
};
export default ImageTo3DExperience;
@@ -0,0 +1,171 @@
import { useCallback, useRef, useState } from "react";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUpload } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { twMerge } from "tailwind-merge";
interface UploadEntryCardProps {
icon: IconDefinition;
title: string;
description: string;
accentBackgroundClass: string;
accentBorderClass?: string;
accept?: string;
multiple?: boolean;
primaryLabel: string;
primaryIcon?: IconDefinition;
onFilesSelected: (files: FileList) => void;
secondaryLabel?: string;
secondaryIcon?: IconDefinition;
onSecondaryClick?: () => void;
disabled?: boolean;
}
export const UploadEntryCard = ({
icon,
title,
description,
accentBackgroundClass,
accentBorderClass,
accept,
multiple,
primaryLabel,
primaryIcon = faUpload,
onFilesSelected,
secondaryLabel,
secondaryIcon,
onSecondaryClick,
disabled,
}: UploadEntryCardProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragActive, setIsDragActive] = useState(false);
const resetInput = () => {
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleFiles = useCallback(
(files?: FileList | null) => {
if (!files || files.length === 0) return;
onFilesSelected(files);
resetInput();
},
[onFilesSelected],
);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
handleFiles(event.target.files);
};
const handlePrimaryClick = () => {
if (disabled) return;
fileInputRef.current?.click();
};
const handleDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
if (disabled) return;
setIsDragActive(true);
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
if (disabled) return;
setIsDragActive(true);
};
const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
if (disabled) return;
if (event.currentTarget.contains(event.relatedTarget as Node)) {
return;
}
setIsDragActive(false);
};
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
if (disabled) return;
setIsDragActive(false);
handleFiles(event.dataTransfer?.files);
};
return (
<div
className={twMerge(
"bg-ui-background/60 relative flex h-full flex-col items-center justify-center gap-8 overflow-hidden rounded-2xl border-2 border-dashed border-ui-panel-border p-10 text-center transition-colors",
isDragActive && "border-primary/80 bg-primary/5",
disabled && "pointer-events-none opacity-60",
)}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept={accept}
multiple={multiple}
onChange={handleFileChange}
disabled={disabled}
/>
<div className="flex flex-col items-center gap-6">
<div className="relative">
<div
className={twMerge(
"relative flex h-32 w-32 items-center justify-center rounded-2xl border-2 shadow-xl backdrop-blur-sm",
accentBackgroundClass,
accentBorderClass,
)}
>
<FontAwesomeIcon
icon={icon}
className="text-5xl text-white drop-shadow-lg"
/>
</div>
</div>
<div className="space-y-3">
<h3 className="text-4xl font-bold tracking-tight text-base-fg">
{title}
</h3>
<p className="mx-auto max-w-md text-base leading-relaxed text-base-fg/70">
{description}
</p>
</div>
<div className="mt-4 flex flex-wrap justify-center gap-4">
<Button
variant="primary"
icon={primaryIcon}
onClick={handlePrimaryClick}
className="px-8 py-3 text-base font-semibold shadow-lg"
disabled={disabled}
>
{primaryLabel}
</Button>
{secondaryLabel && onSecondaryClick && (
<Button
variant="action"
icon={secondaryIcon}
onClick={onSecondaryClick}
className="border-2 px-8 py-3 text-base font-semibold"
disabled={disabled}
>
{secondaryLabel}
</Button>
)}
</div>
</div>
</div>
);
};
export default UploadEntryCard;
@@ -246,6 +246,10 @@ export const TopBar = ({ pageName }: Props) => {
return "Video Watermark Remover";
case "IMAGE_WATERMARK_REMOVAL":
return "Image Watermark Remover";
case "IMAGE_TO_3D_OBJECT":
return "Image to 3D Object";
case "IMAGE_TO_3D_WORLD":
return "Image to 3D World";
default:
return "Artcraft";
}
@@ -7,6 +7,7 @@ import {
faPenNib,
faDroplet,
faPhotoFilm,
faGlobe,
} from "@fortawesome/pro-solid-svg-icons";
import { useTabStore, TabId } from "~/pages/Stores/TabState";
@@ -18,7 +19,9 @@ export type AppId =
| "3D"
| "VIDEO_FRAME_EXTRACTOR"
| "VIDEO_WATERMARK_REMOVAL"
| "IMAGE_WATERMARK_REMOVAL";
| "IMAGE_WATERMARK_REMOVAL"
| "IMAGE_TO_3D_OBJECT"
| "IMAGE_TO_3D_WORLD";
export interface AppDescriptor {
id: AppId;
@@ -96,6 +99,26 @@ export const ALL_APPS: FullAppItem[] = [
action: "VIDEO",
color: "bg-amber-500/40",
},
{
id: "image-to-3d-object",
label: "Image to 3D Object",
description: "Convert references into textured assets",
icon: faCube,
category: "generate",
// action: "IMAGE_TO_3D_OBJECT",
color: "bg-emerald-500/40",
badge: "SOON",
},
{
id: "image-to-3d-world",
label: "Image to 3D World",
description: "Turn mood boards into explorable worlds",
icon: faGlobe,
category: "generate",
// action: "IMAGE_TO_3D_WORLD",
color: "bg-blue-500/40",
badge: "SOON",
},
{
id: "edit-image",
label: "Edit Image",
@@ -175,9 +198,18 @@ export const getBadgeStyles = (badge?: string) => {
export const goToApp = (action?: string) => {
if (
action &&
["IMAGE", "VIDEO", "EDIT", "2D", "3D", "VIDEO_FRAME_EXTRACTOR", "VIDEO_WATERMARK_REMOVAL", "IMAGE_WATERMARK_REMOVAL"].includes(
action,
)
[
"IMAGE",
"VIDEO",
"EDIT",
"2D",
"3D",
"VIDEO_FRAME_EXTRACTOR",
"VIDEO_WATERMARK_REMOVAL",
"IMAGE_WATERMARK_REMOVAL",
"IMAGE_TO_3D_OBJECT",
"IMAGE_TO_3D_WORLD",
].includes(action)
) {
useTabStore.getState().setActiveTab(action as TabId);
}
@@ -14,6 +14,8 @@ import { AppsIndexPage } from "../PageApps/AppsIndexPage";
import { VideoFrameExtractor } from "../PageVideoFrameExtractor";
import { VideoWatermarkRemover } from "../PageVideoWatermarkRemover";
import { ImageWatermarkRemover } from "../PageImageWatermarkRemover";
import { ImageTo3DObject } from "../PageImageTo3DObject";
import { ImageTo3DWorld } from "../PageImageTo3DWorld";
import {
timelineHeight,
@@ -609,6 +611,16 @@ export const PageEditor = () => {
<ImageWatermarkRemover />
</div>
)}
{tabStore.activeTabId == "IMAGE_TO_3D_OBJECT" && (
<div>
<ImageTo3DObject />
</div>
)}
{tabStore.activeTabId == "IMAGE_TO_3D_WORLD" && (
<div>
<ImageTo3DWorld />
</div>
)}
</div>
);
};
@@ -0,0 +1,13 @@
import { ImageTo3DExperience } from "../../components/experiences/ImageTo3DExperience";
export const ImageTo3DObject = () => {
return (
<ImageTo3DExperience
title="Generate 3D Object"
subtitle="Transform your image into a 3D object with textures."
variant="object"
/>
);
};
export default ImageTo3DObject;
@@ -0,0 +1,2 @@
export * from "./ImageTo3DObject";
@@ -0,0 +1,14 @@
import { ImageTo3DExperience } from "../../components/experiences/ImageTo3DExperience";
export const ImageTo3DWorld = () => {
return (
<ImageTo3DExperience
title="Image to 3D World"
subtitle="Fuse mood boards, paint-overs, and prompts into explorable scenes with lighting suggestions."
variant="world"
/>
);
};
export default ImageTo3DWorld;
@@ -0,0 +1,2 @@
export * from "./ImageTo3DWorld";
@@ -1,7 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faUpload,
faImages,
faDroplet,
faArrowRotateRight,
@@ -9,7 +8,7 @@ import {
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/api";
import { TopBar } from "~/components";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import toast from "react-hot-toast";
export const ImageWatermarkRemover = () => {
@@ -25,7 +24,6 @@ export const ImageWatermarkRemover = () => {
height: number;
} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
useEffect(() => {
@@ -36,8 +34,8 @@ export const ImageWatermarkRemover = () => {
};
}, [imageUrl]);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
const handleLocalImageSelect = (files: FileList) => {
const file = files[0];
if (file && file.type.startsWith("image/")) {
if (imageUrl && imageUrl.startsWith("blob:")) {
URL.revokeObjectURL(imageUrl);
@@ -49,10 +47,6 @@ export const ImageWatermarkRemover = () => {
}
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleImageSelect = (id: string) => {
setSelectedGalleryImages((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
@@ -113,157 +107,121 @@ export const ImageWatermarkRemover = () => {
}
};
const renderUploadArea = () => (
<div className="relative flex h-full flex-col items-center justify-center gap-8 overflow-hidden">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileSelect}
/>
<div className="relative z-10 flex flex-col items-center gap-6">
<div className="relative">
<div className="relative flex h-32 w-32 items-center justify-center rounded-2xl border-2 border-indigo-400/30 bg-indigo-500/40 shadow-xl backdrop-blur-sm">
<FontAwesomeIcon
icon={faDroplet}
className="text-5xl text-white drop-shadow-lg"
/>
</div>
</div>
<div className="space-y-3 text-center">
<h3 className="text-4xl font-bold tracking-tight text-base-fg">
Remove Image Watermark
</h3>
<p className="max-w-md text-base leading-relaxed text-base-fg/70">
Erase watermarks from your images seamlessly. Upload your image and
get a clean, watermark-free result.
</p>
</div>
</div>
<div className="relative z-10 mt-4 flex gap-4">
<Button
variant="primary"
icon={faUpload}
onClick={handleUploadClick}
className="px-8 py-3 text-base font-semibold shadow-lg"
>
Select Image
</Button>
<Button
variant="action"
icon={faImages}
onClick={() => setIsGalleryModalOpen(true)}
className="border-2 px-8 py-3 text-base font-semibold"
>
Pick from Library
</Button>
</div>
</div>
);
return (
<div className="fixed inset-0 w-full overflow-hidden bg-ui-panel text-base-fg">
<TopBar
pageName="Image Watermark Remover"
loginSignUpPressed={() => {}}
/>
<div className="mt-[56px] h-[calc(100vh-56px)] w-full overflow-y-auto">
<main
className={
!imageUrl
? "flex min-h-full w-full items-center justify-center p-8"
: "flex w-full justify-center px-8 py-6"
}
>
{!imageUrl ? (
<div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
{renderUploadArea()}
<>
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg">
<div className="flex-1 overflow-y-auto">
<main
className={
!imageUrl
? "flex min-h-full w-full items-center justify-center p-8"
: "flex w-full justify-center px-8 py-6"
}
>
{!imageUrl ? (
<div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<UploadEntryCard
icon={faDroplet}
title="Remove Image Watermark"
description="Erase watermarks from your images seamlessly. Upload your image and get a clean, watermark-free result."
accentBackgroundClass="bg-indigo-500/40"
accentBorderClass="border-indigo-400/30"
accept="image/*"
onFilesSelected={handleLocalImageSelect}
primaryLabel="Select Image"
secondaryLabel="Pick from Library"
secondaryIcon={faImages}
onSecondaryClick={() => setIsGalleryModalOpen(true)}
/>
</div>
</div>
</div>
) : (
<div className="flex w-full max-w-5xl flex-col gap-5">
<div className="w-full overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<div className="relative w-full bg-black">
<Button
icon={faArrowRotateRight}
variant="action"
onClick={() => {
setImageUrl("");
setImageDimensions(null);
}}
className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
>
Switch Image
</Button>
<div className="flex min-h-[400px] items-center justify-center p-6">
<img
ref={imageRef}
src={imageUrl}
alt="Selected pic"
className="max-h-[70vh] max-w-full rounded-lg object-contain shadow-2xl"
onLoad={(e) => {
const img = e.currentTarget;
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
) : (
<div className="flex w-full max-w-5xl flex-col gap-5">
<div className="w-full overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<div className="relative w-full bg-black">
<Button
icon={faArrowRotateRight}
variant="action"
onClick={() => {
setImageUrl("");
setImageDimensions(null);
}}
/>
</div>
</div>
</div>
<div className="flex justify-center">
<Button
variant="primary"
icon={isProcessing ? undefined : faDroplet}
loading={isProcessing}
onClick={handleRemoveWatermark}
className="px-12 py-3 text-lg font-semibold"
disabled={isProcessing}
>
{isProcessing ? "Processing..." : "Remove Watermark"}
</Button>
</div>
<div className="rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-base-fg/60">
<FontAwesomeIcon icon={faImages} className="text-primary" />
Image Information
</div>
{imageDimensions ? (
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between border-b border-ui-divider py-2">
<span className="font-medium text-base-fg/70">
Resolution
</span>
<span className="font-mono text-lg font-bold text-base-fg">
{imageDimensions.width} × {imageDimensions.height}
</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="font-medium text-base-fg/70">
Aspect Ratio
</span>
<span className="font-mono text-lg font-bold text-base-fg">
{(
imageDimensions.width / imageDimensions.height
).toFixed(2)}
:1
</span>
</div>
className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
>
Switch Image
</Button>
<div className="flex min-h-[400px] items-center justify-center p-6">
<img
ref={imageRef}
src={imageUrl}
alt="Selected pic"
className="max-h-[70vh] max-w-full rounded-lg object-contain shadow-2xl"
onLoad={(e) => {
const img = e.currentTarget;
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
}}
/>
</div>
) : (
<div className="text-sm text-base-fg/50">Loading...</div>
)}
</div>
</div>
<div className="flex justify-center">
<Button
variant="primary"
icon={isProcessing ? undefined : faDroplet}
loading={isProcessing}
onClick={handleRemoveWatermark}
className="px-12 py-3 text-lg font-semibold"
disabled={isProcessing}
>
{isProcessing ? "Processing..." : "Remove Watermark"}
</Button>
</div>
<div className="rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-base-fg/60">
<FontAwesomeIcon
icon={faImages}
className="text-primary"
/>
Image Information
</div>
{imageDimensions ? (
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between border-b border-ui-divider py-2">
<span className="font-medium text-base-fg/70">
Resolution
</span>
<span className="font-mono text-lg font-bold text-base-fg">
{imageDimensions.width} × {imageDimensions.height}
</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="font-medium text-base-fg/70">
Aspect Ratio
</span>
<span className="font-mono text-lg font-bold text-base-fg">
{(
imageDimensions.width / imageDimensions.height
).toFixed(2)}
:1
</span>
</div>
</div>
) : (
<div className="text-sm text-base-fg/50">Loading...</div>
)}
</div>
</div>
</div>
</div>
)}
</main>
)}
</main>
</div>
</div>
<GalleryModal
@@ -282,6 +240,6 @@ export const ImageWatermarkRemover = () => {
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
</div>
</>
);
};
@@ -7,7 +7,6 @@ import {
faVolumeMute,
faPhotoFilm,
faSparkles,
faUpload,
faImages,
faStepBackward,
faStepForward,
@@ -23,10 +22,10 @@ import {
MediaUploadApi,
EIntermediateFile,
} from "@storyteller/api";
import { TopBar } from "~/components";
import toast from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
import { usePromptVideoStore, RefImage } from "@storyteller/ui-promptbox";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import { useTabStore } from "~/pages/Stores/TabState";
export const VideoFrameExtractor = () => {
@@ -55,7 +54,6 @@ export const VideoFrameExtractor = () => {
const [isLoadingFromGallery, setIsLoadingFromGallery] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const extractedFramesRef = useRef<HTMLDivElement>(null);
@@ -112,8 +110,8 @@ export const VideoFrameExtractor = () => {
};
}, [videoUrl]);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
const handleLocalVideoSelect = (files: FileList) => {
const file = files[0];
if (file && file.type.startsWith("video/")) {
setIsPlaying(false);
setCurrentTime(0);
@@ -133,10 +131,6 @@ export const VideoFrameExtractor = () => {
}
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleVideoSelect = (id: string) => {
setSelectedGalleryVideos((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
@@ -475,444 +469,408 @@ export const VideoFrameExtractor = () => {
}
};
const renderUploadArea = () => (
<div className="relative flex h-full flex-col items-center justify-center gap-8 overflow-hidden">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="video/*"
onChange={handleFileSelect}
/>
<div className="relative z-10 flex flex-col items-center gap-6">
<div className="relative">
<div className="relative flex h-32 w-32 items-center justify-center rounded-2xl border-2 border-rose-400/30 bg-rose-500/40 shadow-xl backdrop-blur-sm">
<FontAwesomeIcon
icon={faPhotoFilm}
className="text-5xl text-white drop-shadow-lg"
/>
</div>
</div>
<div className="space-y-3 text-center">
<h3 className="text-4xl font-bold tracking-tight text-base-fg">
Extract Video Frames
</h3>
<p className="max-w-md text-base leading-relaxed text-base-fg/70">
Capture perfect moments from your videos. Select frames at precise
timestamps and save them as images.
</p>
</div>
</div>
<div className="relative z-10 mt-4 flex gap-4">
<Button
variant="primary"
icon={faUpload}
onClick={handleUploadClick}
className="px-8 py-3 text-base font-semibold shadow-lg"
>
Select Video
</Button>
<Button
variant="action"
icon={faImages}
onClick={() => setIsGalleryModalOpen(true)}
className="border-2 px-8 py-3 text-base font-semibold"
>
Pick from Library
</Button>
</div>
</div>
);
return (
<div className="fixed inset-0 w-full overflow-hidden bg-ui-panel text-base-fg">
<TopBar pageName="Video Frame Extractor" loginSignUpPressed={() => {}} />
<div className="mt-[56px] h-[calc(100vh-56px)] w-full overflow-y-auto">
<main
className={
!videoUrl
? "flex min-h-full w-full items-center justify-center p-8"
: "flex w-full justify-center px-8 py-6"
}
>
{!videoUrl ? (
<div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
{renderUploadArea()}
</div>
</div>
) : (
<div className="flex w-full max-w-5xl flex-col gap-5">
<div className="w-full overflow-hidden rounded-xl border border-ui-panel-border bg-ui-controls/40">
<div className="relative aspect-video w-full bg-black">
<Button
icon={faArrowRotateRight}
variant="action"
onClick={() => {
setVideoUrl("");
setStartTime(0);
setCurrentTime(0);
setExtractedFrames([]);
setSavingFrames(new Set());
setSavedFrames(new Set());
setConvertingFrames(new Set());
}}
className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
>
Switch Video
</Button>
<video
ref={videoRef}
src={videoUrl}
className="h-full w-full bg-black"
onClick={togglePlayPause}
preload="metadata"
playsInline
crossOrigin="anonymous"
<>
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg">
<div className="flex-1 overflow-y-auto">
<main
className={
!videoUrl
? "flex min-h-full w-full items-center justify-center p-8"
: "flex w-full justify-center px-8 py-6"
}
>
{!videoUrl ? (
<div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<UploadEntryCard
icon={faPhotoFilm}
title="Extract Video Frames"
description="Capture perfect moments from your videos. Select frames at precise timestamps and save them as images."
accentBackgroundClass="bg-rose-500/40"
accentBorderClass="border-rose-400/30"
accept="video/*"
onFilesSelected={handleLocalVideoSelect}
primaryLabel="Select Video"
secondaryLabel="Pick from Library"
secondaryIcon={faImages}
onSecondaryClick={() => setIsGalleryModalOpen(true)}
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
<div
ref={progressBarRef}
className="group relative mb-3 h-3 cursor-pointer rounded-full bg-white/20"
onClick={handleProgressBarClick}
</div>
</div>
) : (
<div className="flex w-full max-w-5xl flex-col gap-5">
<div className="w-full overflow-hidden rounded-xl border border-ui-panel-border bg-ui-controls/40">
<div className="relative aspect-video w-full bg-black">
<Button
icon={faArrowRotateRight}
variant="action"
onClick={() => {
setVideoUrl("");
setStartTime(0);
setCurrentTime(0);
setExtractedFrames([]);
setSavingFrames(new Set());
setSavedFrames(new Set());
setConvertingFrames(new Set());
}}
className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
>
Switch Video
</Button>
<video
ref={videoRef}
src={videoUrl}
className="h-full w-full bg-black"
onClick={togglePlayPause}
preload="metadata"
playsInline
crossOrigin="anonymous"
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
<div
className="absolute h-full overflow-hidden rounded-full bg-primary"
style={{
width: `${duration ? (currentTime / duration) * 100 : 0}%`,
}}
/>
<div
className="absolute top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-ew-resize rounded-full bg-yellow-400 shadow-lg"
style={{
left: `${duration ? (startTime / duration) * 100 : 0}%`,
}}
onMouseDown={(e) => {
e.stopPropagation();
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!progressBarRef.current || !duration) return;
const rect =
progressBarRef.current.getBoundingClientRect();
const moveX = moveEvent.clientX - rect.left;
const percentage = Math.max(
0,
Math.min(1, moveX / rect.width),
);
const newTime = percentage * duration;
setStartTime(newTime);
if (videoRef.current) {
videoRef.current.currentTime = newTime;
setCurrentTime(newTime);
}
};
const handleMouseUp = () => {
document.removeEventListener(
ref={progressBarRef}
className="group relative mb-3 h-3 cursor-pointer rounded-full bg-white/20"
onClick={handleProgressBarClick}
>
<div
className="absolute h-full overflow-hidden rounded-full bg-primary"
style={{
width: `${duration ? (currentTime / duration) * 100 : 0}%`,
}}
/>
<div
className="absolute top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-ew-resize rounded-full bg-yellow-400 shadow-lg"
style={{
left: `${duration ? (startTime / duration) * 100 : 0}%`,
}}
onMouseDown={(e) => {
e.stopPropagation();
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!progressBarRef.current || !duration) return;
const rect =
progressBarRef.current.getBoundingClientRect();
const moveX = moveEvent.clientX - rect.left;
const percentage = Math.max(
0,
Math.min(1, moveX / rect.width),
);
const newTime = percentage * duration;
setStartTime(newTime);
if (videoRef.current) {
videoRef.current.currentTime = newTime;
setCurrentTime(newTime);
}
};
const handleMouseUp = () => {
document.removeEventListener(
"mousemove",
handleMouseMove,
);
document.removeEventListener(
"mouseup",
handleMouseUp,
);
};
document.addEventListener(
"mousemove",
handleMouseMove,
);
document.removeEventListener(
"mouseup",
handleMouseUp,
);
};
document.addEventListener(
"mousemove",
handleMouseMove,
);
document.addEventListener("mouseup", handleMouseUp);
}}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => seekToFrame("first")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="First Frame"
>
<FontAwesomeIcon
icon={faStepBackward}
className="text-sm text-white"
/>
</button>
<button
onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
>
<FontAwesomeIcon
icon={isPlaying ? faPause : faPlay}
className="text-white"
/>
</button>
<button
onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="Last Frame"
>
<FontAwesomeIcon
icon={faStepForward}
className="text-sm text-white"
/>
</button>
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
>
<FontAwesomeIcon
icon={isMuted ? faVolumeMute : faVolumeHigh}
className="text-sm text-white"
/>
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) =>
setVolume(parseFloat(e.target.value))
}
className="w-20 accent-white"
document.addEventListener("mouseup", handleMouseUp);
}}
/>
</div>
<div className="font-mono text-sm text-white">
{formatTime(currentTime)} / {formatTime(duration)}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => seekToFrame("first")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="First Frame"
>
<FontAwesomeIcon
icon={faStepBackward}
className="text-sm text-white"
/>
</button>
<button
onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
>
<FontAwesomeIcon
icon={isPlaying ? faPause : faPlay}
className="text-white"
/>
</button>
<button
onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="Last Frame"
>
<FontAwesomeIcon
icon={faStepForward}
className="text-sm text-white"
/>
</button>
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
>
<FontAwesomeIcon
icon={isMuted ? faVolumeMute : faVolumeHigh}
className="text-sm text-white"
/>
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) =>
setVolume(parseFloat(e.target.value))
}
className="w-20 accent-white"
/>
</div>
<div className="font-mono text-sm text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-center">
<Button
variant="primary"
icon={isExtracting ? undefined : faSparkles}
loading={isExtracting}
onClick={handleExtract}
className="px-12 py-3 text-lg font-semibold"
disabled={isExtracting}
>
{isExtracting ? "Extracting..." : "Extract Frames"}
</Button>
</div>
<div className="flex justify-center">
<Button
variant="primary"
icon={isExtracting ? undefined : faSparkles}
loading={isExtracting}
onClick={handleExtract}
className="px-12 py-3 text-lg font-semibold"
disabled={isExtracting}
>
{isExtracting ? "Extracting..." : "Extract Frames"}
</Button>
</div>
<div className="grid w-full grid-cols-1 gap-5 md:grid-cols-2">
<div className="flex flex-col gap-5">
<div className="group rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-base-fg/60">
Start Time
</div>
<div className="font-mono text-3xl font-bold text-primary drop-shadow-sm">
{formatTimePrecise(startTime)}
</div>
<div className="mt-3 flex items-center gap-2 text-xs text-base-fg/60">
<div className="h-2.5 w-2.5 animate-pulse rounded-full bg-yellow-400 shadow-lg shadow-yellow-400/50" />
<span>Adjust via the yellow marker on the video</span>
<div className="grid w-full grid-cols-1 gap-5 md:grid-cols-2">
<div className="flex flex-col gap-5">
<div className="group rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-base-fg/60">
Start Time
</div>
<div className="font-mono text-3xl font-bold text-primary drop-shadow-sm">
{formatTimePrecise(startTime)}
</div>
<div className="mt-3 flex items-center gap-2 text-xs text-base-fg/60">
<div className="h-2.5 w-2.5 animate-pulse rounded-full bg-yellow-400 shadow-lg shadow-yellow-400/50" />
<span>Adjust via the yellow marker on the video</span>
</div>
</div>
</div>
</div>
<div className="group rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<div className="mb-4 text-xs font-semibold uppercase tracking-wider text-base-fg/60">
Video Information
</div>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between border-b border-ui-divider py-2">
<span className="font-medium text-base-fg/70">
Duration
</span>
<span className="font-mono text-lg font-bold text-base-fg">
{formatTime(duration)}
</span>
<div className="group rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<div className="mb-4 text-xs font-semibold uppercase tracking-wider text-base-fg/60">
Video Information
</div>
{videoRef.current && (
<div className="flex items-center justify-between py-2">
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between border-b border-ui-divider py-2">
<span className="font-medium text-base-fg/70">
Resolution
Duration
</span>
<span className="font-mono font-bold text-base-fg">
{videoRef.current.videoWidth} ×{" "}
{videoRef.current.videoHeight}
<span className="font-mono text-lg font-bold text-base-fg">
{formatTime(duration)}
</span>
</div>
)}
{videoRef.current && (
<div className="flex items-center justify-between py-2">
<span className="font-medium text-base-fg/70">
Resolution
</span>
<span className="font-mono font-bold text-base-fg">
{videoRef.current.videoWidth} ×{" "}
{videoRef.current.videoHeight}
</span>
</div>
)}
</div>
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<h3 className="mb-5 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-base-fg/60">
<FontAwesomeIcon
icon={faSparkles}
className="text-primary"
/>
Extraction Settings
</h3>
<div className="space-y-5">
<div>
<label
htmlFor="numFrames"
className="mb-2.5 block text-sm font-semibold text-base-fg"
>
Number of Frames
</label>
<input
id="numFrames"
type="number"
min="1"
max="50"
value={numFrames}
onChange={(e) =>
setNumFrames(
Math.max(
1,
Math.min(50, parseInt(e.target.value) || 1),
),
)
}
className="border-ui-controls-border w-full rounded-xl border-2 bg-ui-controls px-4 py-3.5 text-base-fg focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label
htmlFor="frameDistance"
className="mb-2.5 block text-sm font-semibold text-base-fg"
>
Distance (ms)
</label>
<input
id="frameDistance"
type="number"
min="1"
max="10000"
value={frameDistance}
onChange={(e) =>
setFrameDistance(
Math.max(1, parseInt(e.target.value) || 1),
)
}
className="border-ui-controls-border w-full rounded-xl border-2 bg-ui-controls px-4 py-3.5 text-base-fg focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="text-xs leading-relaxed text-base-fg/80">
Extracting multiple frames helps capture a sharp,
non-blurred frame.
</div>
</div>
</div>
</div>
</div>
{extractedFrames.length > 0 && (
<div
ref={extractedFramesRef}
className="rounded-2xl border border-ui-panel-border bg-ui-controls p-6 shadow-lg"
>
<div>
<div className="mb-6 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-xl font-bold uppercase tracking-wider text-base-fg">
<div className="rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<h3 className="mb-5 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-base-fg/60">
<FontAwesomeIcon
icon={faPhotoFilm}
icon={faSparkles}
className="text-primary"
/>
Extracted Frames
Extraction Settings
</h3>
<div className="bg-ui-badge rounded-full border-2 border-primary/30 px-5 py-2 text-sm font-bold text-base-fg shadow-lg">
{extractedFrames.length}{" "}
{extractedFrames.length === 1 ? "Frame" : "Frames"}
</div>
</div>
<div className="grid grid-cols-2 gap-5">
{extractedFrames.map((frame, index) => (
<div
key={frame.id}
className="group relative overflow-hidden rounded-xl border-2 border-ui-panel-border bg-ui-controls"
>
<div className="aspect-video overflow-hidden bg-black">
<img
src={frame.url}
alt={`Frame ${index + 1}`}
className="h-full w-full object-contain"
/>
</div>
<div className="space-y-2 bg-ui-background p-3">
<div className="flex items-center justify-between">
<span className="bg-ui-badge rounded border border-ui-panel-border px-2 py-1 text-xs font-bold text-base-fg">
#{index + 1}
</span>
<div className="font-mono text-xs font-semibold text-base-fg/70">
{formatTimePrecise(frame.timestamp)}
</div>
</div>
<div className="flex gap-2">
<Button
variant="action"
icon={
savingFrames.has(frame.id)
? undefined
: savedFrames.has(frame.id)
? faCheck
: faSave
}
loading={savingFrames.has(frame.id)}
onClick={() =>
handleSaveFrame(
frame.id,
frame.url,
frame.timestamp,
)
}
disabled={
savingFrames.has(frame.id) ||
savedFrames.has(frame.id)
}
className="flex-1 px-3 py-1.5 text-xs font-semibold"
>
{savingFrames.has(frame.id)
? "Saving..."
: savedFrames.has(frame.id)
? "Saved"
: "Save"}
</Button>
<Button
variant="primary"
icon={
convertingFrames.has(frame.id)
? undefined
: faVideo
}
loading={convertingFrames.has(frame.id)}
onClick={() =>
handleTurnIntoVideo(
frame.id,
frame.url,
frame.timestamp,
)
}
disabled={convertingFrames.has(frame.id)}
className="flex-1 px-3 py-1.5 text-xs font-semibold"
>
Turn into Video
</Button>
</div>
</div>
<div className="space-y-5">
<div>
<label
htmlFor="numFrames"
className="mb-2.5 block text-sm font-semibold text-base-fg"
>
Number of Frames
</label>
<input
id="numFrames"
type="number"
min="1"
max="50"
value={numFrames}
onChange={(e) =>
setNumFrames(
Math.max(
1,
Math.min(50, parseInt(e.target.value) || 1),
),
)
}
className="border-ui-controls-border w-full rounded-xl border-2 bg-ui-controls px-4 py-3.5 text-base-fg focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
))}
<div>
<label
htmlFor="frameDistance"
className="mb-2.5 block text-sm font-semibold text-base-fg"
>
Distance (ms)
</label>
<input
id="frameDistance"
type="number"
min="1"
max="10000"
value={frameDistance}
onChange={(e) =>
setFrameDistance(
Math.max(1, parseInt(e.target.value) || 1),
)
}
className="border-ui-controls-border w-full rounded-xl border-2 bg-ui-controls px-4 py-3.5 text-base-fg focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="text-xs leading-relaxed text-base-fg/80">
Extracting multiple frames helps capture a sharp,
non-blurred frame.
</div>
</div>
</div>
</div>
</div>
)}
</div>
)}
</main>
{extractedFrames.length > 0 && (
<div
ref={extractedFramesRef}
className="rounded-2xl border border-ui-panel-border bg-ui-controls p-6 shadow-lg"
>
<div>
<div className="mb-6 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-xl font-bold uppercase tracking-wider text-base-fg">
<FontAwesomeIcon
icon={faPhotoFilm}
className="text-primary"
/>
Extracted Frames
</h3>
<div className="bg-ui-badge rounded-full border-2 border-primary/30 px-5 py-2 text-sm font-bold text-base-fg shadow-lg">
{extractedFrames.length}{" "}
{extractedFrames.length === 1 ? "Frame" : "Frames"}
</div>
</div>
<div className="grid grid-cols-2 gap-5">
{extractedFrames.map((frame, index) => (
<div
key={frame.id}
className="group relative overflow-hidden rounded-xl border-2 border-ui-panel-border bg-ui-controls"
>
<div className="aspect-video overflow-hidden bg-black">
<img
src={frame.url}
alt={`Frame ${index + 1}`}
className="h-full w-full object-contain"
/>
</div>
<div className="space-y-2 bg-ui-background p-3">
<div className="flex items-center justify-between">
<span className="bg-ui-badge rounded border border-ui-panel-border px-2 py-1 text-xs font-bold text-base-fg">
#{index + 1}
</span>
<div className="font-mono text-xs font-semibold text-base-fg/70">
{formatTimePrecise(frame.timestamp)}
</div>
</div>
<div className="flex gap-2">
<Button
variant="action"
icon={
savingFrames.has(frame.id)
? undefined
: savedFrames.has(frame.id)
? faCheck
: faSave
}
loading={savingFrames.has(frame.id)}
onClick={() =>
handleSaveFrame(
frame.id,
frame.url,
frame.timestamp,
)
}
disabled={
savingFrames.has(frame.id) ||
savedFrames.has(frame.id)
}
className="flex-1 px-3 py-1.5 text-xs font-semibold"
>
{savingFrames.has(frame.id)
? "Saving..."
: savedFrames.has(frame.id)
? "Saved"
: "Save"}
</Button>
<Button
variant="primary"
icon={
convertingFrames.has(frame.id)
? undefined
: faVideo
}
loading={convertingFrames.has(frame.id)}
onClick={() =>
handleTurnIntoVideo(
frame.id,
frame.url,
frame.timestamp,
)
}
disabled={convertingFrames.has(frame.id)}
className="flex-1 px-3 py-1.5 text-xs font-semibold"
>
Turn into Video
</Button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
</main>
</div>
</div>
<GalleryModal
@@ -931,6 +889,6 @@ export const VideoFrameExtractor = () => {
onDownloadClicked={downloadFileFromUrl}
forceFilter="video"
/>
</div>
</>
);
};
@@ -5,7 +5,6 @@ import {
faPause,
faVolumeHigh,
faVolumeMute,
faUpload,
faImages,
faStepBackward,
faStepForward,
@@ -16,7 +15,7 @@ import {
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/api";
import { TopBar } from "~/components";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import toast from "react-hot-toast";
export const VideoWatermarkRemover = () => {
@@ -34,7 +33,6 @@ export const VideoWatermarkRemover = () => {
const [isLoadingFromGallery, setIsLoadingFromGallery] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -93,8 +91,8 @@ export const VideoWatermarkRemover = () => {
};
}, [videoUrl]);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
const handleLocalVideoSelect = (files: FileList) => {
const file = files[0];
if (file && file.type.startsWith("video/")) {
setIsPlaying(false);
setCurrentTime(0);
@@ -109,10 +107,6 @@ export const VideoWatermarkRemover = () => {
}
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleVideoSelect = (id: string) => {
setSelectedGalleryVideos((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
@@ -218,217 +212,181 @@ export const VideoWatermarkRemover = () => {
}
};
const renderUploadArea = () => (
<div className="relative flex h-full flex-col items-center justify-center gap-8 overflow-hidden">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="video/*"
onChange={handleFileSelect}
/>
<div className="relative z-10 flex flex-col items-center gap-6">
<div className="relative">
<div className="relative flex h-32 w-32 items-center justify-center rounded-2xl border-2 border-cyan-400/30 bg-cyan-500/40 shadow-xl backdrop-blur-sm">
<FontAwesomeIcon
icon={faDroplet}
className="text-5xl text-white drop-shadow-lg"
/>
</div>
</div>
<div className="space-y-3 text-center">
<h3 className="text-4xl font-bold tracking-tight text-base-fg">
Remove Video Watermark
</h3>
<p className="max-w-md text-base leading-relaxed text-base-fg/70">
Clean up your videos by removing unwanted watermarks. Upload your
video and let AI do the magic.
</p>
</div>
</div>
<div className="relative z-10 mt-4 flex gap-4">
<Button
variant="primary"
icon={faUpload}
onClick={handleUploadClick}
className="px-8 py-3 text-base font-semibold shadow-lg"
>
Select Video
</Button>
<Button
variant="action"
icon={faImages}
onClick={() => setIsGalleryModalOpen(true)}
className="border-2 px-8 py-3 text-base font-semibold"
>
Pick from Library
</Button>
</div>
</div>
);
return (
<div className="fixed inset-0 w-full overflow-hidden bg-ui-panel text-base-fg">
<TopBar
pageName="Video Watermark Remover"
loginSignUpPressed={() => {}}
/>
<div className="mt-[56px] h-[calc(100vh-56px)] w-full overflow-y-auto">
<main
className={
!videoUrl
? "flex min-h-full w-full items-center justify-center p-8"
: "flex w-full justify-center px-8 py-6"
}
>
{!videoUrl ? (
<div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
{renderUploadArea()}
</div>
</div>
) : (
<div className="flex w-full max-w-5xl flex-col gap-5">
<div className="w-full overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<div className="relative aspect-video w-full bg-black">
<Button
icon={faArrowRotateRight}
variant="action"
onClick={() => {
setVideoUrl("");
setCurrentTime(0);
}}
className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
>
Switch Video
</Button>
<video
ref={videoRef}
src={videoUrl}
className="h-full w-full"
onClick={togglePlayPause}
preload="metadata"
playsInline
<>
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg">
<div className="flex-1 overflow-y-auto">
<main
className={
!videoUrl
? "flex min-h-full w-full items-center justify-center p-8"
: "flex w-full justify-center px-8 py-6"
}
>
{!videoUrl ? (
<div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<UploadEntryCard
icon={faDroplet}
title="Remove Video Watermark"
description="Clean up your videos by removing unwanted watermarks. Upload your video and let AI do the magic."
accentBackgroundClass="bg-cyan-500/40"
accentBorderClass="border-cyan-400/30"
accept="video/*"
onFilesSelected={handleLocalVideoSelect}
primaryLabel="Select Video"
secondaryLabel="Pick from Library"
secondaryIcon={faImages}
onSecondaryClick={() => setIsGalleryModalOpen(true)}
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
<div
ref={progressBarRef}
className="group relative mb-3 h-3 cursor-pointer rounded-full bg-white/20"
onClick={handleProgressBarClick}
</div>
</div>
) : (
<div className="flex w-full max-w-5xl flex-col gap-5">
<div className="w-full overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<div className="relative aspect-video w-full bg-black">
<Button
icon={faArrowRotateRight}
variant="action"
onClick={() => {
setVideoUrl("");
setCurrentTime(0);
}}
className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
>
Switch Video
</Button>
<video
ref={videoRef}
src={videoUrl}
className="h-full w-full"
onClick={togglePlayPause}
preload="metadata"
playsInline
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
<div
className="absolute h-full overflow-hidden rounded-full bg-primary"
style={{
width: `${duration ? (currentTime / duration) * 100 : 0}%`,
}}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => seekToFrame("first")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="First Frame"
>
<FontAwesomeIcon
icon={faStepBackward}
className="text-sm text-white"
/>
</button>
<button
onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
>
<FontAwesomeIcon
icon={isPlaying ? faPause : faPlay}
className="text-white"
/>
</button>
<button
onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="Last Frame"
>
<FontAwesomeIcon
icon={faStepForward}
className="text-sm text-white"
/>
</button>
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
>
<FontAwesomeIcon
icon={isMuted ? faVolumeMute : faVolumeHigh}
className="text-sm text-white"
/>
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) =>
setVolume(parseFloat(e.target.value))
}
className="w-20 accent-white"
ref={progressBarRef}
className="group relative mb-3 h-3 cursor-pointer rounded-full bg-white/20"
onClick={handleProgressBarClick}
>
<div
className="absolute h-full overflow-hidden rounded-full bg-primary"
style={{
width: `${duration ? (currentTime / duration) * 100 : 0}%`,
}}
/>
</div>
<div className="font-mono text-sm text-white">
{formatTime(currentTime)} / {formatTime(duration)}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => seekToFrame("first")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="First Frame"
>
<FontAwesomeIcon
icon={faStepBackward}
className="text-sm text-white"
/>
</button>
<button
onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
>
<FontAwesomeIcon
icon={isPlaying ? faPause : faPlay}
className="text-white"
/>
</button>
<button
onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="Last Frame"
>
<FontAwesomeIcon
icon={faStepForward}
className="text-sm text-white"
/>
</button>
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
>
<FontAwesomeIcon
icon={isMuted ? faVolumeMute : faVolumeHigh}
className="text-sm text-white"
/>
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) =>
setVolume(parseFloat(e.target.value))
}
className="w-20 accent-white"
/>
</div>
<div className="font-mono text-sm text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-center">
<Button
variant="primary"
icon={isProcessing ? undefined : faDroplet}
loading={isProcessing}
onClick={handleRemoveWatermark}
className="px-12 py-3 text-lg font-semibold"
disabled={isProcessing}
>
{isProcessing ? "Processing..." : "Remove Watermark"}
</Button>
</div>
<div className="flex justify-center">
<Button
variant="primary"
icon={isProcessing ? undefined : faDroplet}
loading={isProcessing}
onClick={handleRemoveWatermark}
className="px-12 py-3 text-lg font-semibold"
disabled={isProcessing}
>
{isProcessing ? "Processing..." : "Remove Watermark"}
</Button>
</div>
<div className="rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-base-fg/60">
<FontAwesomeIcon icon={faVideo} className="text-primary" />
Video Information
</div>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between border-b border-ui-divider py-2">
<span className="font-medium text-base-fg/70">
Duration
</span>
<span className="font-mono text-lg font-bold text-base-fg">
{formatTime(duration)}
</span>
<div className="rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-base-fg/60">
<FontAwesomeIcon
icon={faVideo}
className="text-primary"
/>
Video Information
</div>
{videoRef.current && (
<div className="flex items-center justify-between py-2">
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between border-b border-ui-divider py-2">
<span className="font-medium text-base-fg/70">
Resolution
Duration
</span>
<span className="font-mono font-bold text-base-fg">
{videoRef.current.videoWidth} ×{" "}
{videoRef.current.videoHeight}
<span className="font-mono text-lg font-bold text-base-fg">
{formatTime(duration)}
</span>
</div>
)}
{videoRef.current && (
<div className="flex items-center justify-between py-2">
<span className="font-medium text-base-fg/70">
Resolution
</span>
<span className="font-mono font-bold text-base-fg">
{videoRef.current.videoWidth} ×{" "}
{videoRef.current.videoHeight}
</span>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
</main>
)}
</main>
</div>
</div>
<GalleryModal
@@ -447,6 +405,6 @@ export const VideoWatermarkRemover = () => {
onDownloadClicked={downloadFileFromUrl}
forceFilter="video"
/>
</div>
</>
);
};
@@ -1,9 +1,20 @@
import { create } from 'zustand';
import { useSceneStore } from '../PageDraw/stores/SceneState';
import { create } from "zustand";
import { useSceneStore } from "../PageDraw/stores/SceneState";
export type TabId = '2D' | '3D' | 'VIDEO' | 'IMAGE' | 'EDIT' | 'APPS' | 'VIDEO_FRAME_EXTRACTOR' | 'VIDEO_WATERMARK_REMOVAL' | 'IMAGE_WATERMARK_REMOVAL';
export type TabId =
| "2D"
| "3D"
| "VIDEO"
| "IMAGE"
| "EDIT"
| "APPS"
| "VIDEO_FRAME_EXTRACTOR"
| "VIDEO_WATERMARK_REMOVAL"
| "IMAGE_WATERMARK_REMOVAL"
| "IMAGE_TO_3D_OBJECT"
| "IMAGE_TO_3D_WORLD";
const DEFAULT_TAB : TabId = 'IMAGE';
const DEFAULT_TAB: TabId = "IMAGE";
interface TabState {
// Current active tab
@@ -31,20 +42,20 @@ export const useTabStore = create<TabState>((set, get) => ({
try {
// Save current 2D state if we're leaving 2D tab
if (currentTabId === '2D') {
if (currentTabId === "2D") {
const sceneStore = useSceneStore.getState();
const sceneState = await sceneStore.serializeSceneToString();
set(state => ({
set((state) => ({
tabData: {
...state.tabData,
'2D': sceneState
}
"2D": sceneState,
},
}));
}
// Load 2D state if we're entering 2D tab
if (newTabId === '2D') {
const savedState = get().tabData['2D'];
if (newTabId === "2D") {
const savedState = get().tabData["2D"];
if (savedState) {
const sceneStore = useSceneStore.getState();
sceneStore.loadSceneFromString(savedState);
@@ -55,7 +66,7 @@ export const useTabStore = create<TabState>((set, get) => ({
set({ activeTabId: newTabId });
return true;
} catch (error) {
console.error('Error during tab change:', error);
console.error("Error during tab change:", error);
return false;
}
},
@@ -64,8 +75,8 @@ export const useTabStore = create<TabState>((set, get) => ({
set((state) => ({
tabData: {
...state.tabData,
[tabId]: JSON.stringify(data)
}
[tabId]: JSON.stringify(data),
},
}));
},
@@ -87,5 +98,5 @@ export const useTabStore = create<TabState>((set, get) => ({
delete newTabData[tabId];
return { tabData: newTabData };
});
}
},
}));
+12 -2
View File
@@ -22,12 +22,14 @@
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
/* Firefox */
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
body {
@@ -181,6 +183,16 @@ body {
.bg-ui-panel {
background: var(--st-panel);
}
.bg-ui-panel-gradient {
background:
radial-gradient(
60.68% 60.68% at 50% 0%,
rgba(255, 255, 255, 0.08) 0%,
rgba(0, 0, 0, 0) 70%
),
var(--st-panel);
background-color: var(--st-panel);
}
.bg-ui-modal {
background: var(--st-modal);
}
@@ -206,7 +218,6 @@ body {
/* Tauri custom titlebar drag regions */
[data-tauri-drag-region] {
-webkit-app-region: drag;
app-region: drag;
}
.tauri-no-drag,
@@ -217,7 +228,6 @@ body {
[data-tauri-drag-region] textarea,
[data-tauri-drag-region] select {
-webkit-app-region: no-drag;
app-region: no-drag;
}
img {
@@ -1,4 +1,5 @@
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "./button";
import { twMerge } from "tailwind-merge";
@@ -6,6 +7,7 @@ interface ToggleButtonProps {
isActive: boolean;
icon?: IconDefinition;
activeIcon?: IconDefinition;
label?: string;
onClick: () => void;
className?: string;
}
@@ -14,21 +16,37 @@ export const ToggleButton = ({
isActive,
icon,
activeIcon,
label,
onClick,
className,
}: ToggleButtonProps) => {
const displayIcon = isActive && activeIcon ? activeIcon : icon;
const hasLabel = Boolean(label);
return (
<Button
className={twMerge(
"flex h-9 w-9 items-center border-2 border-transparent text-sm text-white backdrop-blur-lg transition-all",
"flex h-9 items-center justify-center rounded-lg border-2 border-transparent text-sm text-white backdrop-blur-lg transition-all",
hasLabel ? "px-3" : "w-9",
isActive
? "border-white/20 bg-brand-primary/40 hover:border-white/30 hover:bg-brand-primary/40"
: "bg-[#5F5F68]/60 hover:bg-[#5F5F68]/90",
className
)}
variant="secondary"
icon={isActive && activeIcon ? activeIcon : icon}
onClick={onClick}
/>
>
<span className="flex items-center gap-2">
{displayIcon && (
<FontAwesomeIcon
icon={displayIcon}
className={twMerge("text-base", hasLabel && "text-sm")}
/>
)}
{label && (
<span className="text-sm font-medium text-white/90">{label}</span>
)}
</span>
</Button>
);
};
@@ -17,6 +17,7 @@ interface TooltipProps {
*/
interactive?: boolean;
onOpenChange?: (open: boolean) => void;
zIndex?: number;
}
export const Tooltip = ({
@@ -30,6 +31,7 @@ export const Tooltip = ({
description,
interactive = false,
onOpenChange,
zIndex = 10,
}: TooltipProps) => {
const [isShowing, setIsShowing] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
@@ -174,9 +176,10 @@ export const Tooltip = ({
transitionProperty: "opacity",
transitionDuration: "200ms",
transitionTimingFunction: "ease-out",
zIndex,
}}
className={twMerge(
"absolute w-max z-10 rounded-lg bg-ui-controls shadow-xl border border-ui-panel-border",
"absolute w-max rounded-lg bg-ui-controls shadow-xl border border-ui-panel-border",
interactive
? "pointer-events-auto p-3"
: "px-2.5 py-1.5 text-[13px] font-medium pointer-events-none",