diff --git a/frontend/apps/editor3d/app/src/components/signaled/TopBar/TopBar.tsx b/frontend/apps/editor3d/app/src/components/signaled/TopBar/TopBar.tsx
index ff3c201d4..63767ffc0 100644
--- a/frontend/apps/editor3d/app/src/components/signaled/TopBar/TopBar.tsx
+++ b/frontend/apps/editor3d/app/src/components/signaled/TopBar/TopBar.tsx
@@ -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";
}
diff --git a/frontend/apps/editor3d/app/src/config/appMenu.tsx b/frontend/apps/editor3d/app/src/config/appMenu.tsx
index 31ec5bc08..6cbff8a8b 100644
--- a/frontend/apps/editor3d/app/src/config/appMenu.tsx
+++ b/frontend/apps/editor3d/app/src/config/appMenu.tsx
@@ -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,
)
) {
diff --git a/frontend/apps/editor3d/app/src/pages/PageEdit/BaseImageSelector.tsx b/frontend/apps/editor3d/app/src/pages/PageEdit/BaseImageSelector.tsx
index e7328376a..e70efe26b 100644
--- a/frontend/apps/editor3d/app/src/pages/PageEdit/BaseImageSelector.tsx
+++ b/frontend/apps/editor3d/app/src/pages/PageEdit/BaseImageSelector.tsx
@@ -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 (
<>
-
+
+
{isLoading || showLoading ? (
-
-
Uploading image...
+
+ Uploading image...
) : (
-
-
-
- Click to upload or drag and drop an image here to edit
-
-
-
-
+ >
)}
diff --git a/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx b/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx
index 978f93834..4f040e7e3 100644
--- a/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx
+++ b/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx
@@ -323,18 +323,22 @@ const PageEdit = () => {
return (
-
{
- addHistoryImageBundle({ images: [image] });
- store.setBaseImageInfo(image);
- }}
- showLoading={
- store.baseImageInfo !== null && store.baseImageInfo === null
- }
- />
+
+
+ {
+ addHistoryImageBundle({ images: [image] });
+ store.setBaseImageInfo(image);
+ }}
+ showLoading={
+ store.baseImageInfo !== null && store.baseImageInfo === null
+ }
+ />
+
+
);
}
diff --git a/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx b/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx
index f9fb7244d..0d50e4b12 100644
--- a/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx
+++ b/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx
@@ -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 = () => {
)}
+ {tabStore.activeTabId == "VIDEO_WATERMARK_REMOVAL" && (
+
+
+
+ )}
+ {tabStore.activeTabId == "IMAGE_WATERMARK_REMOVAL" && (
+
+
+
+ )}
);
};
diff --git a/frontend/apps/editor3d/app/src/pages/PageImageWatermarkRemover/ImageWatermarkRemover.tsx b/frontend/apps/editor3d/app/src/pages/PageImageWatermarkRemover/ImageWatermarkRemover.tsx
new file mode 100644
index 000000000..449bf2bf4
--- /dev/null
+++ b/frontend/apps/editor3d/app/src/pages/PageImageWatermarkRemover/ImageWatermarkRemover.tsx
@@ -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
("");
+ const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
+ const [selectedGalleryImages, setSelectedGalleryImages] = useState(
+ [],
+ );
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [isLoadingFromGallery, setIsLoadingFromGallery] = useState(false);
+ const [imageDimensions, setImageDimensions] = useState<{
+ width: number;
+ height: number;
+ } | null>(null);
+
+ const fileInputRef = useRef(null);
+ const imageRef = useRef(null);
+
+ useEffect(() => {
+ return () => {
+ if (imageUrl && imageUrl.startsWith("blob:")) {
+ URL.revokeObjectURL(imageUrl);
+ }
+ };
+ }, [imageUrl]);
+
+ const handleFileSelect = (event: React.ChangeEvent) => {
+ 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 = () => (
+
+
+
+
+
+
+ Remove Image Watermark
+
+
+ Erase watermarks from your images seamlessly. Upload your image and
+ get a clean, watermark-free result.
+
+
+
+
+
+ Select Image
+
+ setIsGalleryModalOpen(true)}
+ className="border-2 px-8 py-3 text-base font-semibold"
+ >
+ Pick from Library
+
+
+
+ );
+
+ return (
+
+
{}}
+ />
+
+
+ {!imageUrl ? (
+
+
+ {renderUploadArea()}
+
+
+ ) : (
+
+
+
+
{
+ 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
+
+
+

{
+ const img = e.currentTarget;
+ setImageDimensions({
+ width: img.naturalWidth,
+ height: img.naturalHeight,
+ });
+ }}
+ />
+
+
+
+
+
+
+ {isProcessing ? "Processing..." : "Remove Watermark"}
+
+
+
+
+
+
+
+ Image Information
+
+ {imageDimensions ? (
+
+
+
+ Resolution
+
+
+ {imageDimensions.width} × {imageDimensions.height}
+
+
+
+
+ Aspect Ratio
+
+
+ {(
+ imageDimensions.width / imageDimensions.height
+ ).toFixed(2)}
+ :1
+
+
+
+ ) : (
+
Loading...
+ )}
+
+
+
+ )}
+
+
+
+ {
+ if (!isLoadingFromGallery) {
+ setIsGalleryModalOpen(false);
+ setSelectedGalleryImages([]);
+ }
+ }}
+ mode="select"
+ selectedItemIds={selectedGalleryImages}
+ onSelectItem={handleImageSelect}
+ maxSelections={1}
+ onUseSelected={handleGallerySelect}
+ onDownloadClicked={downloadFileFromUrl}
+ forceFilter="image"
+ />
+
+ );
+};
diff --git a/frontend/apps/editor3d/app/src/pages/PageImageWatermarkRemover/index.ts b/frontend/apps/editor3d/app/src/pages/PageImageWatermarkRemover/index.ts
new file mode 100644
index 000000000..d1a427024
--- /dev/null
+++ b/frontend/apps/editor3d/app/src/pages/PageImageWatermarkRemover/index.ts
@@ -0,0 +1,2 @@
+export { ImageWatermarkRemover } from "./ImageWatermarkRemover";
+
diff --git a/frontend/apps/editor3d/app/src/pages/PageVideoFrameExtractor/VideoFrameExtractor.tsx b/frontend/apps/editor3d/app/src/pages/PageVideoFrameExtractor/VideoFrameExtractor.tsx
index 312e7851f..df08aa52d 100644
--- a/frontend/apps/editor3d/app/src/pages/PageVideoFrameExtractor/VideoFrameExtractor.tsx
+++ b/frontend/apps/editor3d/app/src/pages/PageVideoFrameExtractor/VideoFrameExtractor.tsx
@@ -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 = () => (
-
+
{
accept="video/*"
onChange={handleFileSelect}
/>
-
-
-
+
+
-
-
- Select a video for frame extraction
+
+
+ Extract Video Frames
-
- Add a video from your computer or pick from your library
+
+ Capture perfect moments from your videos. Select frames at precise
+ timestamps and save them as images.
-
+
Select Video
@@ -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
@@ -534,8 +536,8 @@ export const VideoFrameExtractor = () => {
}
>
{!videoUrl ? (
-
-
+
@@ -562,7 +564,7 @@ export const VideoFrameExtractor = () => {