watermark removal tools ui (#902)
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
+287
@@ -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";
|
||||
|
||||
+212
-192
@@ -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>
|
||||
)}
|
||||
|
||||
+452
@@ -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';
|
||||
|
||||
|
||||
Reference in new issue
Block a user