watermark removal tools ui (#902)

This commit is contained in:
BFlat authored and GitHub committed 2025-11-17 23:41:34 +07:00
1 parent 957bb7ff2f
commit ea80dab67b
11 files changed
+1043 -236

No files matched your search

@@ -242,6 +242,10 @@ export const TopBar = ({ pageName }: Props) => {
return "Edit Image";
case "VIDEO_FRAME_EXTRACTOR":
return "Video Frame Extractor";
case "VIDEO_WATERMARK_REMOVAL":
return "Video Watermark Remover";
case "IMAGE_WATERMARK_REMOVAL":
return "Image Watermark Remover";
default:
return "Artcraft";
}
@@ -16,7 +16,9 @@ export type AppId =
| "EDIT"
| "2D"
| "3D"
| "VIDEO_FRAME_EXTRACTOR";
| "VIDEO_FRAME_EXTRACTOR"
| "VIDEO_WATERMARK_REMOVAL"
| "IMAGE_WATERMARK_REMOVAL";
export interface AppDescriptor {
id: AppId;
@@ -115,7 +117,7 @@ export const ALL_APPS: FullAppItem[] = [
},
{
id: "video-watermark-removal",
label: "Video Watermark Removal",
label: "Video Watermark Remover",
description: "Remove watermarks from videos",
icon: faDroplet,
category: "edit",
@@ -124,7 +126,7 @@ export const ALL_APPS: FullAppItem[] = [
},
{
id: "image-watermark-removal",
label: "Image Watermark Removal",
label: "Image Watermark Remover",
description: "Remove watermarks from images",
icon: faDroplet,
category: "edit",
@@ -173,7 +175,7 @@ export const getBadgeStyles = (badge?: string) => {
export const goToApp = (action?: string) => {
if (
action &&
["IMAGE", "VIDEO", "EDIT", "2D", "3D", "VIDEO_FRAME_EXTRACTOR"].includes(
["IMAGE", "VIDEO", "EDIT", "2D", "3D", "VIDEO_FRAME_EXTRACTOR", "VIDEO_WATERMARK_REMOVAL", "IMAGE_WATERMARK_REMOVAL"].includes(
action,
)
) {
@@ -1,4 +1,4 @@
import { faImage } from "@fortawesome/pro-solid-svg-icons";
import { faImage, faUpload, faImages } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { downloadFileFromUrl } from "libs/api/src/lib/LocalApi";
import { Button } from "@storyteller/ui-button";
@@ -65,7 +65,7 @@ export const BaseImageSelector = ({
}
const item = selectedItems[0];
if (!item.fullImage) {
return
return;
}
const referenceImage: BaseSelectorImage = {
url: item.fullImage,
@@ -115,9 +115,10 @@ export const BaseImageSelector = ({
// NB(bt,2025-10-09): I think `thumbnail_template` is wrong and that
// `maybe_thumbnail_template` is the correct API field, but upstream
// seems to imply otherwise. Let's simply check both keys for now.
thumbnailUrlTemplate =
thumbnailUrlTemplate =
result.data.media_links?.thumbnail_template ||
(result.data.media_links as any)?.maybe_thumbnail_template;
(result.data.media_links as any)
?.maybe_thumbnail_template;
}
} catch (e) {
console.warn(
@@ -160,38 +161,59 @@ export const BaseImageSelector = ({
return (
<>
<div className="flex h-1/2 w-1/2 items-center justify-center rounded-xl border-8 border-ui-panel-border bg-ui-background">
<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={handleFileUpload}
multiple
/>
{isLoading || showLoading ? (
<div className="flex flex-col items-center gap-4">
<span>Uploading image...</span>
<div className="relative z-10 flex flex-col items-center gap-4">
<span className="text-base-fg">Uploading image...</span>
<LoadingSpinner />
</div>
) : (
<div className="flex flex-col gap-8">
<FontAwesomeIcon
icon={faImage}
className="text-base-fg text-6xl opacity-50"
/>
<span className="text-base-fg ml-2 text-xl opacity-50">
Click to upload or drag and drop an image here to edit
</span>
<div className="mt-4 flex justify-center gap-2.5">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileUpload}
multiple
/>
<Button variant="primary" onClick={handleUploadClick}>
Upload an image
<>
<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-blue-400/30 bg-blue-500/40 shadow-xl backdrop-blur-sm">
<FontAwesomeIcon
icon={faImage}
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">
Edit Image
</h3>
<p className="max-w-md text-base leading-relaxed text-base-fg/70">
Click to upload or drag and drop an image here to edit
</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="secondary" onClick={handleGalleryClick}>
Select from library
<Button
variant="action"
icon={faImages}
onClick={handleGalleryClick}
className="border-2 px-8 py-3 text-base font-semibold"
>
Pick from Library
</Button>
</div>
</div>
</>
)}
</div>
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
@@ -323,18 +323,22 @@ const PageEdit = () => {
return (
<div
className={
"flex h-[calc(100vh-56px)] w-full items-center justify-center bg-ui-panel"
"flex h-[calc(100vh-56px)] w-full items-center justify-center bg-ui-panel p-8"
}
>
<BaseImageSelector
onImageSelect={(image: BaseSelectorImage) => {
addHistoryImageBundle({ images: [image] });
store.setBaseImageInfo(image);
}}
showLoading={
store.baseImageInfo !== null && store.baseImageInfo === null
}
/>
<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">
<BaseImageSelector
onImageSelect={(image: BaseSelectorImage) => {
addHistoryImageBundle({ images: [image] });
store.setBaseImageInfo(image);
}}
showLoading={
store.baseImageInfo !== null && store.baseImageInfo === null
}
/>
</div>
</div>
</div>
);
}
@@ -12,6 +12,8 @@ import ImageToVideo from "../PageVideo/ImageToVideo";
import TextToImage from "../PageImage/TextToImage";
import { AppsIndexPage } from "../PageApps/AppsIndexPage";
import { VideoFrameExtractor } from "../PageVideoFrameExtractor";
import { VideoWatermarkRemover } from "../PageVideoWatermarkRemover";
import { ImageWatermarkRemover } from "../PageImageWatermarkRemover";
import {
timelineHeight,
@@ -597,6 +599,16 @@ export const PageEditor = () => {
<VideoFrameExtractor />
</div>
)}
{tabStore.activeTabId == "VIDEO_WATERMARK_REMOVAL" && (
<div>
<VideoWatermarkRemover />
</div>
)}
{tabStore.activeTabId == "IMAGE_WATERMARK_REMOVAL" && (
<div>
<ImageWatermarkRemover />
</div>
)}
</div>
);
};
@@ -0,0 +1,287 @@
import { useState, useRef, useEffect } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faUpload,
faImages,
faDroplet,
faArrowRotateRight,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/api";
import { TopBar } from "~/components";
import toast from "react-hot-toast";
export const ImageWatermarkRemover = () => {
const [imageUrl, setImageUrl] = useState<string>("");
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[],
);
const [isProcessing, setIsProcessing] = useState(false);
const [isLoadingFromGallery, setIsLoadingFromGallery] = useState(false);
const [imageDimensions, setImageDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
useEffect(() => {
return () => {
if (imageUrl && imageUrl.startsWith("blob:")) {
URL.revokeObjectURL(imageUrl);
}
};
}, [imageUrl]);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file && file.type.startsWith("image/")) {
if (imageUrl && imageUrl.startsWith("blob:")) {
URL.revokeObjectURL(imageUrl);
}
const url = URL.createObjectURL(file);
setImageUrl(url);
setImageDimensions(null);
}
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleImageSelect = (id: string) => {
setSelectedGalleryImages((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
const maxSelections = 1;
if (prev.length >= maxSelections) {
return maxSelections === 1 ? [id] : prev;
}
return [...prev, id];
});
};
const handleGallerySelect = async (selectedItems: GalleryItem[]) => {
const item = selectedItems[0];
if (!item || !item.fullImage) {
toast.error("No image selected");
return;
}
if (isLoadingFromGallery) {
return;
}
setIsLoadingFromGallery(true);
if (imageUrl && imageUrl.startsWith("blob:")) {
URL.revokeObjectURL(imageUrl);
}
setImageUrl(item.fullImage);
setImageDimensions(null);
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
setIsLoadingFromGallery(false);
};
const handleRemoveWatermark = async () => {
if (!imageUrl) {
toast.error("Please select an image first");
return;
}
setIsProcessing(true);
toast.loading("Processing watermark removal...", {
id: "watermark-removal",
});
try {
await new Promise((resolve) => setTimeout(resolve, 2000));
toast.success("Watermark removal completed!", {
id: "watermark-removal",
});
} catch (error) {
toast.error("Failed to remove watermark", { id: "watermark-removal" });
console.error("Error removing watermark:", error);
} finally {
setIsProcessing(false);
}
};
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>
</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>
</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>
)}
</main>
</div>
<GalleryModal
isOpen={!!isGalleryModalOpen}
onClose={() => {
if (!isLoadingFromGallery) {
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
}
}}
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={1}
onUseSelected={handleGallerySelect}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
</div>
);
};
@@ -0,0 +1,2 @@
export { ImageWatermarkRemover } from "./ImageWatermarkRemover";
@@ -14,7 +14,6 @@ import {
faArrowRotateRight,
faSave,
faVideo,
faSpinnerThird,
faCheck,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
@@ -477,7 +476,7 @@ export const VideoFrameExtractor = () => {
};
const renderUploadArea = () => (
<div className="flex h-full flex-col items-center justify-center gap-6">
<div className="relative flex h-full flex-col items-center justify-center gap-8 overflow-hidden">
<input
type="file"
ref={fileInputRef}
@@ -485,28 +484,31 @@ export const VideoFrameExtractor = () => {
accept="video/*"
onChange={handleFileSelect}
/>
<div className="flex flex-col items-center gap-4">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-ui-controls/40">
<FontAwesomeIcon
icon={faPhotoFilm}
className="text-4xl text-base-fg/60"
/>
<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="text-center">
<h3 className="text-xl font-semibold text-base-fg">
Select a video for frame extraction
<div className="space-y-3 text-center">
<h3 className="text-4xl font-bold tracking-tight text-base-fg">
Extract Video Frames
</h3>
<p className="text-sm text-base-fg/60">
Add a video from your computer or pick from your library
<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="mt-2 flex gap-3">
<div className="relative z-10 mt-4 flex gap-4">
<Button
variant="primary"
icon={faUpload}
onClick={handleUploadClick}
className="px-6"
className="px-8 py-3 text-base font-semibold shadow-lg"
>
Select Video
</Button>
@@ -514,7 +516,7 @@ export const VideoFrameExtractor = () => {
variant="action"
icon={faImages}
onClick={() => setIsGalleryModalOpen(true)}
className="px-6"
className="border-2 px-8 py-3 text-base font-semibold"
>
Pick from Library
</Button>
@@ -534,8 +536,8 @@ export const VideoFrameExtractor = () => {
}
>
{!videoUrl ? (
<div className="w-full max-w-6xl">
<div className="aspect-video overflow-hidden rounded-xl border border-ui-panel-border bg-ui-controls/40">
<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>
@@ -562,7 +564,7 @@ export const VideoFrameExtractor = () => {
<video
ref={videoRef}
src={videoUrl}
className="h-full w-full"
className="h-full w-full bg-black"
onClick={togglePlayPause}
preload="metadata"
playsInline
@@ -571,7 +573,7 @@ export const VideoFrameExtractor = () => {
<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 transition-all hover:h-4"
className="group relative mb-3 h-3 cursor-pointer rounded-full bg-white/20"
onClick={handleProgressBarClick}
>
<div
@@ -625,7 +627,7 @@ export const VideoFrameExtractor = () => {
<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 transition-colors hover:bg-white/20"
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="First Frame"
>
<FontAwesomeIcon
@@ -635,7 +637,7 @@ export const VideoFrameExtractor = () => {
</button>
<button
onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/20 transition-colors hover:bg-white/30"
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
>
<FontAwesomeIcon
icon={isPlaying ? faPause : faPlay}
@@ -644,7 +646,7 @@ export const VideoFrameExtractor = () => {
</button>
<button
onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 transition-colors hover:bg-white/20"
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="Last Frame"
>
<FontAwesomeIcon
@@ -655,7 +657,7 @@ export const VideoFrameExtractor = () => {
<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 transition-colors hover:bg-white/20"
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
>
<FontAwesomeIcon
icon={isMuted ? faVolumeMute : faVolumeHigh}
@@ -685,9 +687,10 @@ export const VideoFrameExtractor = () => {
<div className="flex justify-center">
<Button
variant="primary"
icon={faSparkles}
icon={isExtracting ? undefined : faSparkles}
loading={isExtracting}
onClick={handleExtract}
className="px-10 py-2 text-lg font-semibold"
className="px-12 py-3 text-lg font-semibold"
disabled={isExtracting}
>
{isExtracting ? "Extracting..." : "Extract Frames"}
@@ -696,98 +699,113 @@ export const VideoFrameExtractor = () => {
<div className="grid w-full grid-cols-1 gap-5 md:grid-cols-2">
<div className="flex flex-col gap-5">
<div className="rounded-xl border border-ui-panel-border bg-ui-controls/40 p-5">
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-base-fg/60">
Start Time
</div>
<div className="font-mono text-2xl font-bold text-primary-300">
{formatTimePrecise(startTime)}
</div>
<div className="mt-1.5 text-xs text-base-fg/60">
Adjust via the yellow marker on the video
<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 className="rounded-xl border border-ui-panel-border bg-ui-controls/40 p-5">
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-base-fg/60">
Video Information
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-base-fg/60">Duration</span>
<span className="font-mono 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 justify-between">
<span className="text-base-fg/60">Resolution</span>
<span className="font-mono text-base-fg">
{videoRef.current.videoWidth} ×{" "}
{videoRef.current.videoHeight}
<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>
)}
{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 className="rounded-xl border border-ui-panel-border bg-ui-controls/40 p-5">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-base-fg/60">
Extraction Settings
</h3>
<div className="space-y-3">
<div>
<label
htmlFor="numFrames"
className="mb-1.5 block text-sm font-medium 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="bg-ui-background/50 w-full rounded-lg px-4 py-2.5 text-base-fg focus:outline-none focus:ring-2 focus:ring-primary"
<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"
/>
</div>
Extraction Settings
</h3>
<div>
<label
htmlFor="frameDistance"
className="mb-1.5 block text-sm font-medium 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="bg-ui-background/50 w-full rounded-lg px-4 py-2.5 text-base-fg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</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 className="pt-1 text-xs text-base-fg/60">
<strong>Tip:</strong> Extracting multiple frames helps
capture a sharp, non-blurred frame.
<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>
@@ -796,97 +814,99 @@ export const VideoFrameExtractor = () => {
{extractedFrames.length > 0 && (
<div
ref={extractedFramesRef}
className="rounded-xl border border-ui-panel-border bg-ui-controls/40 p-5"
className="rounded-2xl border border-ui-panel-border bg-ui-controls p-6 shadow-lg"
>
<h3 className="mb-4 text-base font-semibold uppercase tracking-wide text-base-fg/60">
Extracted Frames ({extractedFrames.length})
</h3>
<div className="grid grid-cols-2 gap-4">
{extractedFrames.map((frame, index) => (
<div
key={frame.id}
className="group relative overflow-hidden rounded-lg border border-ui-panel-border"
>
<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-controls/80 p-2">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-base-fg">
#{index + 1}
</span>
<div className="font-mono text-xs text-base-fg/60">
{formatTimePrecise(frame.timestamp)}
<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 className="flex gap-2">
<Button
variant="action"
icon={
savingFrames.has(frame.id)
? undefined
: savedFrames.has(frame.id)
? faCheck
: faSave
}
onClick={() =>
handleSaveFrame(
frame.id,
frame.url,
frame.timestamp,
)
}
disabled={
savingFrames.has(frame.id) ||
savedFrames.has(frame.id)
}
className="flex-1 bg-white/15 px-2 py-1 text-xs hover:bg-white/20"
>
{savingFrames.has(frame.id) ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin"
/>
) : savedFrames.has(frame.id) ? (
"Saved"
) : (
"Save"
)}
</Button>
<Button
variant="primary"
icon={
convertingFrames.has(frame.id)
? undefined
: faVideo
}
onClick={() =>
handleTurnIntoVideo(
frame.id,
frame.url,
frame.timestamp,
)
}
disabled={convertingFrames.has(frame.id)}
className="flex-1 px-2 py-1 text-xs"
>
{convertingFrames.has(frame.id) ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin"
/>
) : (
"Turn into Video"
)}
</Button>
</div>
</div>
</div>
))}
))}
</div>
</div>
</div>
)}
@@ -0,0 +1,452 @@
import { useState, useRef, useEffect } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPlay,
faPause,
faVolumeHigh,
faVolumeMute,
faUpload,
faImages,
faStepBackward,
faStepForward,
faArrowRotateRight,
faDroplet,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/api";
import { TopBar } from "~/components";
import toast from "react-hot-toast";
export const VideoWatermarkRemover = () => {
const [videoUrl, setVideoUrl] = useState<string>("");
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryVideos, setSelectedGalleryVideos] = useState<string[]>(
[],
);
const [isProcessing, setIsProcessing] = useState(false);
const [isLoadingFromGallery, setIsLoadingFromGallery] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
};
const handleLoadedMetadata = () => {
setDuration(video.duration);
setCurrentTime(0);
};
const handleEnded = () => setIsPlaying(false);
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
const handleVolumeChange = () => {
setVolume(video.volume);
setIsMuted(video.muted);
};
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("loadedmetadata", handleLoadedMetadata);
video.addEventListener("ended", handleEnded);
video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause);
video.addEventListener("volumechange", handleVolumeChange);
return () => {
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
video.removeEventListener("ended", handleEnded);
video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause);
video.removeEventListener("volumechange", handleVolumeChange);
};
}, [videoUrl]);
useEffect(() => {
if (videoRef.current) {
videoRef.current.volume = volume;
}
}, [volume]);
useEffect(() => {
if (videoRef.current && videoUrl) {
videoRef.current.load();
}
}, [videoUrl]);
useEffect(() => {
return () => {
if (videoUrl && videoUrl.startsWith("blob:")) {
URL.revokeObjectURL(videoUrl);
}
};
}, [videoUrl]);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file && file.type.startsWith("video/")) {
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
if (videoUrl && videoUrl.startsWith("blob:")) {
URL.revokeObjectURL(videoUrl);
}
const url = URL.createObjectURL(file);
setVideoUrl(url);
}
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleVideoSelect = (id: string) => {
setSelectedGalleryVideos((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
const maxSelections = 1;
if (prev.length >= maxSelections) {
return maxSelections === 1 ? [id] : prev;
}
return [...prev, id];
});
};
const handleGallerySelect = async (selectedItems: GalleryItem[]) => {
const item = selectedItems[0];
if (!item || !item.fullImage) {
toast.error("No video selected");
return;
}
if (isLoadingFromGallery) {
return;
}
setIsLoadingFromGallery(true);
if (videoUrl && videoUrl.startsWith("blob:")) {
URL.revokeObjectURL(videoUrl);
}
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setVideoUrl(item.fullImage);
setIsGalleryModalOpen(false);
setSelectedGalleryVideos([]);
setIsLoadingFromGallery(false);
};
const togglePlayPause = () => {
if (!videoRef.current) return;
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play().catch((err) => {
console.error("Play failed:", err);
});
}
};
const toggleMute = () => {
if (!videoRef.current) return;
videoRef.current.muted = !isMuted;
setIsMuted(!isMuted);
};
const handleProgressBarClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (!videoRef.current || !progressBarRef.current || !duration) return;
const rect = progressBarRef.current.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, clickX / rect.width));
const newTime = percentage * duration;
videoRef.current.currentTime = newTime;
};
const seekToFrame = (direction: "first" | "last") => {
if (!videoRef.current || !duration) return;
if (direction === "first") {
videoRef.current.currentTime = 0;
} else {
videoRef.current.currentTime = duration;
}
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
};
const handleRemoveWatermark = async () => {
if (!videoRef.current || !videoUrl) {
toast.error("Please select a video first");
return;
}
setIsProcessing(true);
toast.loading("Processing watermark removal...", {
id: "watermark-removal",
});
try {
await new Promise((resolve) => setTimeout(resolve, 2000));
toast.success("Watermark removal completed!", {
id: "watermark-removal",
});
} catch (error) {
toast.error("Failed to remove watermark", { id: "watermark-removal" });
console.error("Error removing watermark:", error);
} finally {
setIsProcessing(false);
}
};
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="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
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"
/>
</div>
<div className="font-mono text-sm text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</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="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>
{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>
)}
</main>
</div>
<GalleryModal
isOpen={!!isGalleryModalOpen}
onClose={() => {
if (!isLoadingFromGallery) {
setIsGalleryModalOpen(false);
setSelectedGalleryVideos([]);
}
}}
mode="select"
selectedItemIds={selectedGalleryVideos}
onSelectItem={handleVideoSelect}
maxSelections={1}
onUseSelected={handleGallerySelect}
onDownloadClicked={downloadFileFromUrl}
forceFilter="video"
/>
</div>
);
};
@@ -0,0 +1,2 @@
export { VideoWatermarkRemover } from "./VideoWatermarkRemover";
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { useSceneStore } from '../PageDraw/stores/SceneState';
export type TabId = '2D' | '3D' | 'VIDEO' | 'IMAGE' | 'EDIT' | 'APPS' | 'VIDEO_FRAME_EXTRACTOR';
export type TabId = '2D' | '3D' | 'VIDEO' | 'IMAGE' | 'EDIT' | 'APPS' | 'VIDEO_FRAME_EXTRACTOR' | 'VIDEO_WATERMARK_REMOVAL' | 'IMAGE_WATERMARK_REMOVAL';
const DEFAULT_TAB : TabId = 'IMAGE';