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. +

+
+
+
+ + +
+
+ ); + + return ( +
+ {}} + /> +
+
+ {!imageUrl ? ( +
+
+ {renderUploadArea()} +
+
+ ) : ( +
+
+
+ +
+ Selected pic { + const img = e.currentTarget; + setImageDimensions({ + width: img.naturalWidth, + height: img.naturalHeight, + }); + }} + /> +
+
+
+ +
+ +
+ +
+
+
+ + 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.

-
+
@@ -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 ? ( -
-
+
+
{renderUploadArea()}
@@ -562,7 +564,7 @@ export const VideoFrameExtractor = () => {
)} diff --git a/frontend/apps/editor3d/app/src/pages/PageVideoWatermarkRemover/VideoWatermarkRemover.tsx b/frontend/apps/editor3d/app/src/pages/PageVideoWatermarkRemover/VideoWatermarkRemover.tsx new file mode 100644 index 000000000..6c25154a4 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/PageVideoWatermarkRemover/VideoWatermarkRemover.tsx @@ -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(""); + 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( + [], + ); + const [isProcessing, setIsProcessing] = useState(false); + const [isLoadingFromGallery, setIsLoadingFromGallery] = useState(false); + + const videoRef = useRef(null); + const fileInputRef = useRef(null); + const progressBarRef = useRef(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) => { + 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) => { + 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 = () => ( +
+ +
+
+
+ +
+
+
+

+ Remove Video Watermark +

+

+ Clean up your videos by removing unwanted watermarks. Upload your + video and let AI do the magic. +

+
+
+
+ + +
+
+ ); + + return ( +
+ {}} + /> +
+
+ {!videoUrl ? ( +
+
+ {renderUploadArea()} +
+
+ ) : ( +
+
+
+ +
+ )} +
+
+ + { + if (!isLoadingFromGallery) { + setIsGalleryModalOpen(false); + setSelectedGalleryVideos([]); + } + }} + mode="select" + selectedItemIds={selectedGalleryVideos} + onSelectItem={handleVideoSelect} + maxSelections={1} + onUseSelected={handleGallerySelect} + onDownloadClicked={downloadFileFromUrl} + forceFilter="video" + /> +
+ ); +}; diff --git a/frontend/apps/editor3d/app/src/pages/PageVideoWatermarkRemover/index.ts b/frontend/apps/editor3d/app/src/pages/PageVideoWatermarkRemover/index.ts new file mode 100644 index 000000000..8d2dcdf3c --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/PageVideoWatermarkRemover/index.ts @@ -0,0 +1,2 @@ +export { VideoWatermarkRemover } from "./VideoWatermarkRemover"; + diff --git a/frontend/apps/editor3d/app/src/pages/Stores/TabState.ts b/frontend/apps/editor3d/app/src/pages/Stores/TabState.ts index 2a2964230..6a3610199 100644 --- a/frontend/apps/editor3d/app/src/pages/Stores/TabState.ts +++ b/frontend/apps/editor3d/app/src/pages/Stores/TabState.ts @@ -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';