mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-09 19:41:32 +00:00
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:
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";
|
||||
|
||||
+113
-155
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+371
-413
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+159
-201
@@ -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 };
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user