Compare commits

...
Author SHA1 Message Date
Tanish Manku 75c4af5bd7 Remove bug description, looks good 2025-12-15 23:45:30 -08:00
Tanish Manku 3f596e351a Add splats cross-object compatibility and preview support 2025-12-15 23:21:50 -08:00
Tanish Manku afd3012405 Describe a found bug 2025-12-15 15:49:28 -08:00
BFlat 7ee5a6aa9a Feature/remove bg mini app (#929)
- remove background mini app
- button in lightbox to remove background leads to mini app as well
2025-12-16 05:45:35 +07:00
Tanish Manku f5cd65be9c Temporarily give splats meshes a name (#928) 2025-12-10 22:29:01 -08:00
17 changed files with 1141 additions and 113 deletions
@@ -8,6 +8,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCube } from "@fortawesome/pro-solid-svg-icons";
import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "../UploadModal3D/utilities";
import { WebGLRenderer } from "three";
import { upload3DObjects } from "../UploadModal3D/utilities/upload3DObjects";
interface Props {
title: string;
@@ -74,22 +75,22 @@ export const UploadFilesSplat = ({
// Use the filename as the title
const title = assetFile.value.name.split(".")[0];
const shouldFlip = title.endsWith("ceramic");
assetFile.value.arrayBuffer().then((arrayBuffer) => {
onLocalBytes(arrayBuffer, shouldFlip);
}).catch((error) => {
console.error("Error reading file as ArrayBuffer:", error);
});
// upload3DObjects({
// title: title,
// assetFile: assetFile.value,
// thumbnailSnapshot: thumbnailFile,
// engineCategory: engineCategory,
// animationType: subtype,
// progressCallback: onUploadProgress,
// const shouldFlip = title.search("ceramic") != -1;
//
// assetFile.value.arrayBuffer().then((arrayBuffer) => {
// onLocalBytes(arrayBuffer, shouldFlip);
// }).catch((error) => {
// console.error("Error reading file as ArrayBuffer:", error);
// });
upload3DObjects({
title: title,
assetFile: assetFile.value,
thumbnailSnapshot: thumbnailFile,
engineCategory: engineCategory,
animationType: subtype,
progressCallback: onUploadProgress,
});
};
useEffect(() => {
@@ -61,6 +61,7 @@ import { TaskQueue } from "./TaskQueue";
import { usePromptVideoStore, RefImage } from "@storyteller/ui-promptbox";
import { APP_DESCRIPTORS } from "~/config/appMenu";
import { AppsQuickMenu } from "./AppsQuickMenu";
import { useRemoveBackgroundStore } from "~/pages/PageRemoveBackground/RemoveBackgroundStore";
interface Props {
pageName: string;
@@ -228,6 +229,18 @@ export const TopBar = ({ pageName }: Props) => {
}
};
const handleRemoveBackgroundFromGallery = async (url: string) => {
try {
useRemoveBackgroundStore.getState().setPendingExternalUrl(url);
useTabStore.getState().setActiveTab("REMOVE_BACKGROUND");
galleryModalVisibleViewMode.value = false;
galleryModalVisibleDuringDrag.value = false;
galleryModalLightboxVisible.value = false;
} catch (e) {
// no-op
}
};
const getPageTitle = (): string => {
switch (tabStore.activeTabId) {
case "2D":
@@ -526,6 +539,7 @@ export const TopBar = ({ pageName }: Props) => {
onDownloadClicked={downloadFile}
onEditClicked={handleEditFromGallery}
onTurnIntoVideoClicked={handleTurnIntoVideoFromGallery}
onRemoveBackgroundClicked={handleRemoveBackgroundFromGallery}
/>
<ProviderSetupModal />
@@ -8,6 +8,7 @@ import {
faPhotoFilm,
faGlobe,
faPencil,
faWandMagicSparkles,
} from "@fortawesome/pro-solid-svg-icons";
import { useTabStore, TabId } from "~/pages/Stores/TabState";
import { set3DPageMounted } from "~/pages/PageEnigma/Editor/editor";
@@ -22,7 +23,8 @@ export type AppId =
| "VIDEO_WATERMARK_REMOVAL"
| "IMAGE_WATERMARK_REMOVAL"
| "IMAGE_TO_3D_OBJECT"
| "IMAGE_TO_3D_WORLD";
| "IMAGE_TO_3D_WORLD"
| "REMOVE_BACKGROUND";
export interface AppDescriptor {
id: AppId;
@@ -156,6 +158,16 @@ export const ALL_APPS: FullAppItem[] = [
badge: "SOON",
color: "bg-indigo-600/40",
},
{
id: "remove-background",
label: "Remove Background",
description: "Remove backgrounds from images",
icon: faWandMagicSparkles,
category: "edit",
action: "REMOVE_BACKGROUND",
color: "bg-violet-500/40",
badge: "NEW",
},
{
id: "2d-canvas",
@@ -209,6 +221,7 @@ export const goToApp = (action?: string) => {
"IMAGE_WATERMARK_REMOVAL",
"IMAGE_TO_3D_OBJECT",
"IMAGE_TO_3D_WORLD",
"REMOVE_BACKGROUND",
].includes(action)
) {
if (action === "3D") {
@@ -7,4 +7,5 @@ export enum AssetType {
OBJECT = "object",
SKYBOX = "skybox",
SHAPE = "shape",
SPLAT = "splat",
}
@@ -96,24 +96,20 @@ class DndAsset {
// if (dragItem.value.type === AssetType.CAMERA) {
// console.log("Dragged In Camera Type")
// }
if (dragItem.value.type === AssetType.OBJECT) {
addObject(dragItem.value);
}
if (dragItem.value.type === AssetType.SHAPE) {
addShape(dragItem.value);
}
/*
FIXME:
THIS IS A TEMPARARY SOLUTION TO A LONG PROBLEM WITH SKYBOXES
UPDATE THIS WHEN UPLOADING SKYBOXES ARE FULLY IMPLEMENTED.
THIS IS JUST TEMPARARY!!!
*/
if (dragItem.value.type === AssetType.SKYBOX) {
addObject(dragItem.value);
if (mediaItem.type === AssetType.OBJECT || mediaItem.type === AssetType.SPLAT || mediaItem.type === AssetType.SKYBOX) {
addObject(mediaItem);
}
if (mediaItem.type === AssetType.SHAPE) {
addShape(mediaItem);
}
this.endDrag();
return;
}
@@ -676,8 +676,8 @@ class Editor {
this.mouseOverEventHandler = this.mouseOverEventHandler.bind(this);
// Add spark renderer as a child of the camera
this.activeScene.scene.add(this.sparkRenderer);
this.camera?.add(this.sparkRenderer);
// this.activeScene.scene.add(this.sparkRenderer);
// this.camera?.add(this.sparkRenderer);
const onloadCallback = () => {
console.log("Setting Scene is loaded");
@@ -17,6 +17,7 @@ import { InfiniteGridHelper } from "./InfiniteGridHelper";
import { cameras, selectedCameraId } from "../signals/camera";
import { MediaFilesApi } from "@storyteller/api";
import toast from "react-hot-toast";
import { SplatMesh } from "@sparkjsdev/spark";
class Scene {
name: string;
@@ -597,7 +598,17 @@ class Scene {
obj.userData["media_file_type"] = MediaFileType.Image;
}
return obj;
} else if (
url.includes(".spz")
) {
return await this.loadSplatWithPlaceholder(
media_id,
name,
auto_add,
position,
);
}
return await this.loadGlbWithPlaceholder(
media_id,
name,
@@ -629,6 +640,43 @@ class Scene {
return false;
}
async loadSplatWithPlaceholder(
media_id: string,
name: string,
auto_add: boolean = true,
position: THREE.Vector3 = new THREE.Vector3(-0.5, 1.5, 0),
): Promise<THREE.Object3D> {
if (this.placeholder_manager === undefined) {
throw Error("Place holder Manager is undefined");
}
const url = await this.getMediaURL(media_id);
const key = media_id + name + generateUUID();
await this.placeholder_manager.add(key, `Loading: ${name}`, position);
const splat: SplatMesh = await this.load_splat_wrapped_no_cors(url, async () => {
if (this.placeholder_manager === undefined) {
throw Error("Place holder Manager is undefined");
}
await this.placeholder_manager.remove(key);
}).catch(async (error) => {
if (this.placeholder_manager !== undefined) {
await this.placeholder_manager.remove(key);
}
toast.error("Failed to load splat.");
throw error;
});
splat.userData["media_id"] = media_id;
splat.userData["media_file_type"] = MediaFileType.SPZ;
if (auto_add) {
this.scene.add(splat);
}
this.updateSurfaceIdAttributeToMesh(this.scene);
return splat;
}
async loadGlbWithPlaceholder(
media_id: string,
name: string,
@@ -809,6 +857,35 @@ class Scene {
});
}
/**
* Load a full media_url via Tauri (without browser cors)
* @param media_url
* @param onComplete
* @returns
*/
private async load_splat_wrapped_no_cors(
media_url: string,
onComplete: () => void,
): Promise<SplatMesh> {
return new Promise(async (resolve, reject) => {
let buffer;
try {
buffer = await LoadWithoutCors(media_url); // Load via Tauri!
} catch (error) {
console.error("load Splat from Tauri error:", error);
reject(error);
return;
}
new SplatMesh({
fileBytes: buffer, onLoad: (mesh) => {
resolve(mesh);
onComplete();
}
});
});
}
/**
* Using a constructed media_url load a glb, grab the progress.
* @param media_url
@@ -190,7 +190,7 @@ export class TimeLine {
this.queueNewObjectMessage(
newObject,
data.data as MediaItem,
AssetType.OBJECT,
newObject.type as AssetType
);
const result = this.editorEngine.sceneManager?.render_outliner(
this.characters,
@@ -321,7 +321,7 @@ export class TimeLine {
queueNewObjectMessage(
item: THREE.Object3D<THREE.Object3DEventMap>,
data: MediaItem,
asset_type: AssetType.OBJECT | AssetType.CHARACTER | AssetType.SHAPE,
asset_type: AssetType.OBJECT | AssetType.CHARACTER | AssetType.SHAPE | AssetType.SPLAT,
) {
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
@@ -400,6 +400,7 @@ export class TimeLine {
// and bypasses the queue system, that's kinda redundant anyway
public addLocalSplat(fileBytes: ArrayBuffer | Uint8Array, flipVertical: boolean = false) {
const splatMesh = new SplatMesh({ fileBytes: fileBytes });
splatMesh.name = `{SplatMesh-${Date.now()}}`;
if (flipVertical) {
splatMesh.rotateX(Math.PI);
@@ -16,6 +16,7 @@ import { VideoWatermarkRemover } from "../PageVideoWatermarkRemover";
import { ImageWatermarkRemover } from "../PageImageWatermarkRemover";
import { ImageTo3DObject } from "../PageImageTo3DObject";
import { ImageTo3DWorld } from "../PageImageTo3DWorld";
import { RemoveBackground } from "../PageRemoveBackground";
import {
timelineHeight,
@@ -621,6 +622,11 @@ export const PageEditor = () => {
<ImageTo3DWorld />
</div>
)}
{tabStore.activeTabId == "REMOVE_BACKGROUND" && (
<div>
<RemoveBackground />
</div>
)}
</div>
);
};
@@ -16,6 +16,9 @@ export enum MediaFileType {
// open source 3D file format.
GLB = "glb",
// SPLAT file type. SPZ format is a compressed and smaller format.
SPZ = "spz",
// MMD is the format for anime characters.
// open source 3D file format.
MMD = "mmd",
@@ -0,0 +1,766 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faWandMagicSparkles,
faImages,
faPlus,
faEye,
faDownload,
faUpload,
} 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 toast from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import {
useRemoveBackgroundStore,
ProcessedImage,
} from "./RemoveBackgroundStore";
import {
EnqueueImageBgRemoval,
useCanvasBgRemovedEvent,
} from "@storyteller/tauri-api";
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { twMerge } from "tailwind-merge";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
const convertFileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (reader.result) {
resolve(reader.result as string);
} else {
reject(new Error("Failed to convert file to base64."));
}
};
reader.onerror = () => reject(new Error("Error reading file."));
reader.readAsDataURL(file);
});
};
export const RemoveBackground = () => {
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[],
);
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
const [isHoldingCompare, setIsHoldingCompare] = useState(false);
const [animationComplete, setAnimationComplete] = useState(true);
const [isLoadingImage, setIsLoadingImage] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const clipLayerRef = useRef<HTMLDivElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const revealProgressRef = useRef(100);
const animationFrameRef = useRef<number | null>(null);
const processingExternalUrlRef = useRef<string | null>(null);
const store = useRemoveBackgroundStore();
const {
images,
activeImageId,
isProcessing,
currentOriginalUrl,
pendingJobId,
imageDimensions,
pendingExternalUrl,
setIsProcessing,
setCurrentOriginalUrl,
setPendingJobId,
setImageDimensions,
setPendingExternalUrl,
addImage,
setActiveImage,
getActiveImage,
} = store;
const activeImage = getActiveImage();
const pendingJobIdRef = useRef(pendingJobId);
const currentOriginalUrlRef = useRef(currentOriginalUrl);
useEffect(() => {
pendingJobIdRef.current = pendingJobId;
}, [pendingJobId]);
useEffect(() => {
currentOriginalUrlRef.current = currentOriginalUrl;
}, [currentOriginalUrl]);
useEffect(() => {
const handleResize = () => {
setWindowSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
useEffect(() => {
const processExternalUrl = async () => {
if (!pendingExternalUrl) return;
if (processingExternalUrlRef.current === pendingExternalUrl) return;
processingExternalUrlRef.current = pendingExternalUrl;
const imageUrl = pendingExternalUrl;
setPendingExternalUrl(null);
setIsLoadingImage(true);
try {
const response = await fetch(imageUrl);
const blob = await response.blob();
const file = new File([blob], "external-image.png", {
type: blob.type,
});
const base64Image = await convertFileToBase64(file);
await new Promise<void>((resolve, reject) => {
const img = new Image();
img.onload = () => {
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
resolve();
};
img.onerror = () => reject(new Error("Failed to load image"));
img.src = imageUrl;
});
setActiveImage(null);
setCurrentOriginalUrl(imageUrl);
setIsLoadingImage(false);
setIsProcessing(true);
const jobId = uuidv4();
setPendingJobId(jobId);
await EnqueueImageBgRemoval({
base64_image: base64Image,
frontend_caller: "mini_app",
frontend_subscriber_id: jobId,
});
} catch (error) {
console.error("Error processing external image:", error);
toast.error("Failed to process image");
setIsLoadingImage(false);
setIsProcessing(false);
setPendingJobId(null);
} finally {
processingExternalUrlRef.current = null;
}
};
processExternalUrl();
}, [
pendingExternalUrl,
setPendingExternalUrl,
setActiveImage,
setCurrentOriginalUrl,
setIsProcessing,
setPendingJobId,
setImageDimensions,
]);
const addMenuItems: PopoverItem[] = useMemo(
() => [
{
label: "Upload Image",
selected: false,
icon: <FontAwesomeIcon icon={faUpload} className="h-4 w-4" />,
action: "upload",
},
{
label: "Choose from Library",
selected: false,
icon: <FontAwesomeIcon icon={faImages} className="h-4 w-4" />,
action: "library",
},
],
[],
);
const handleAddMenuSelect = useCallback((item: PopoverItem) => {
if (item.action === "upload") {
fileInputRef.current?.click();
} else if (item.action === "library") {
setIsGalleryModalOpen(true);
}
}, []);
const startRevealAnimation = useCallback(() => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
revealProgressRef.current = 0;
setAnimationComplete(false);
const duration = 800;
const startTime = performance.now();
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const progressPercent = eased * 100;
revealProgressRef.current = progressPercent;
if (clipLayerRef.current) {
clipLayerRef.current.style.clipPath = `inset(0 0 0 ${progressPercent}%)`;
}
if (progressBarRef.current) {
progressBarRef.current.style.left = `${progressPercent}%`;
progressBarRef.current.style.display =
progressPercent < 100 ? "block" : "none";
}
if (progress < 1) {
animationFrameRef.current = requestAnimationFrame(animate);
} else {
setAnimationComplete(true);
animationFrameRef.current = null;
}
};
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setTimeout(() => {
animationFrameRef.current = requestAnimationFrame(animate);
}, 50);
});
});
}, []);
useEffect(() => {
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, []);
useCanvasBgRemovedEvent(async (event) => {
if (event.maybe_frontend_subscriber_id !== pendingJobIdRef.current) return;
const newImage: ProcessedImage = {
id: uuidv4(),
originalUrl: currentOriginalUrlRef.current,
processedUrl: event.image_cdn_url,
timestamp: Date.now(),
};
const loadImage = (src: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load: ${src}`));
img.src = src;
});
};
try {
const [originalImg] = await Promise.all([
loadImage(currentOriginalUrlRef.current),
loadImage(event.image_cdn_url),
]);
addImage(newImage);
setIsProcessing(false);
setPendingJobId(null);
toast.success("Saved to Library");
setImageDimensions({
width: originalImg.naturalWidth,
height: originalImg.naturalHeight,
});
startRevealAnimation();
} catch (error) {
console.error("Error loading images:", error);
addImage(newImage);
setIsProcessing(false);
setPendingJobId(null);
toast.success("Saved to Library");
}
});
const resetAnimationState = useCallback(() => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
revealProgressRef.current = 0;
setAnimationComplete(true);
setIsHoldingCompare(false);
}, []);
const handleLocalImageSelect = useCallback(
async (files: FileList) => {
const file = files[0];
if (!file || !file.type.startsWith("image/")) return;
setIsLoadingImage(true);
try {
const base64Image = await convertFileToBase64(file);
const objectUrl = URL.createObjectURL(file);
await new Promise<void>((resolve, reject) => {
const img = new Image();
img.onload = () => {
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
resolve();
};
img.onerror = () => reject(new Error("Failed to load image"));
img.src = objectUrl;
});
setActiveImage(null);
resetAnimationState();
setCurrentOriginalUrl(objectUrl);
setIsLoadingImage(false);
setIsProcessing(true);
const jobId = uuidv4();
setPendingJobId(jobId);
await EnqueueImageBgRemoval({
base64_image: base64Image,
frontend_caller: "mini_app",
frontend_subscriber_id: jobId,
});
} catch (error) {
console.error("Error processing image:", error);
toast.error("Failed to process image");
setIsLoadingImage(false);
setIsProcessing(false);
setPendingJobId(null);
}
},
[
setActiveImage,
resetAnimationState,
setCurrentOriginalUrl,
setIsProcessing,
setPendingJobId,
setImageDimensions,
],
);
const handleImageSelect = useCallback((id: string) => {
setSelectedGalleryImages((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
return [id];
});
}, []);
const handleGallerySelect = useCallback(
async (selectedItems: GalleryItem[]) => {
const item = selectedItems[0];
if (!item || !item.fullImage) {
toast.error("No image selected");
return;
}
const imageUrl = item.fullImage;
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
setIsLoadingImage(true);
try {
const response = await fetch(imageUrl);
const blob = await response.blob();
const file = new File([blob], "library-image.png", { type: blob.type });
const base64Image = await convertFileToBase64(file);
await new Promise<void>((resolve, reject) => {
const img = new Image();
img.onload = () => {
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
resolve();
};
img.onerror = () => reject(new Error("Failed to load image"));
img.src = imageUrl;
});
setActiveImage(null);
resetAnimationState();
setCurrentOriginalUrl(imageUrl);
setIsLoadingImage(false);
setIsProcessing(true);
const jobId = uuidv4();
setPendingJobId(jobId);
await EnqueueImageBgRemoval({
base64_image: base64Image,
frontend_caller: "mini_app",
frontend_subscriber_id: jobId,
});
} catch (error) {
console.error("Error processing gallery image:", error);
toast.error("Failed to process image");
setIsLoadingImage(false);
setIsProcessing(false);
setPendingJobId(null);
}
},
[
setActiveImage,
resetAnimationState,
setCurrentOriginalUrl,
setIsProcessing,
setPendingJobId,
setImageDimensions,
],
);
const handleDownload = useCallback(async () => {
const currentActiveImage = getActiveImage();
if (!currentActiveImage) {
toast.error("No image to download");
return;
}
try {
await downloadFileFromUrl(currentActiveImage.processedUrl);
toast.success("Image saved to Downloads folder");
} catch (error) {
console.error("Download failed:", error);
toast.error("Failed to download image");
}
}, [getActiveImage]);
const handleCompareMouseDown = useCallback(() => {
setIsHoldingCompare(true);
if (clipLayerRef.current) {
clipLayerRef.current.style.clipPath = "inset(0 0 0 0)";
}
if (progressBarRef.current) {
progressBarRef.current.style.display = "none";
}
}, []);
const handleCompareMouseUp = useCallback(() => {
setIsHoldingCompare(false);
if (clipLayerRef.current) {
clipLayerRef.current.style.clipPath = `inset(0 0 0 ${revealProgressRef.current}%)`;
}
if (progressBarRef.current && revealProgressRef.current < 100) {
progressBarRef.current.style.display = "block";
}
}, []);
useEffect(() => {
if (isHoldingCompare) {
document.addEventListener("mouseup", handleCompareMouseUp);
document.addEventListener("mouseleave", handleCompareMouseUp);
}
return () => {
document.removeEventListener("mouseup", handleCompareMouseUp);
document.removeEventListener("mouseleave", handleCompareMouseUp);
};
}, [isHoldingCompare, handleCompareMouseUp]);
const handleThumbnailClick = useCallback(
(img: ProcessedImage) => {
setActiveImage(img.id);
revealProgressRef.current = 100;
setAnimationComplete(true);
if (clipLayerRef.current) {
clipLayerRef.current.style.clipPath = "inset(0 0 0 100%)";
}
if (progressBarRef.current) {
progressBarRef.current.style.display = "none";
}
const loadImg = new Image();
loadImg.onload = () => {
setImageDimensions({
width: loadImg.naturalWidth,
height: loadImg.naturalHeight,
});
};
loadImg.src = img.originalUrl;
},
[setActiveImage, setImageDimensions],
);
const hasImages = images.length > 0;
const showUploadScreen = !hasImages && !isProcessing;
const imageContainerStyle = useMemo(() => {
if (!imageDimensions) {
return { width: "600px", height: "450px" };
}
const horizontalPadding = 128 + 32;
const verticalPadding = 128 + 150;
const availableWidth = windowSize.width - horizontalPadding;
const availableHeight = windowSize.height - 56 - verticalPadding;
const maxWidth = Math.min(availableWidth, 1400);
const maxHeight = Math.max(availableHeight, 200);
const imageAspect = imageDimensions.width / imageDimensions.height;
let width = maxWidth;
let height = width / imageAspect;
if (height > maxHeight) {
height = maxHeight;
width = height * imageAspect;
}
width = Math.max(width, 200);
height = Math.max(height, 150);
return { width: `${width}px`, height: `${height}px` };
}, [imageDimensions, windowSize.width, windowSize.height]);
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
handleLocalImageSelect(e.target.files);
e.target.value = "";
}
},
[handleLocalImageSelect],
);
const handleOpenGallery = useCallback(() => {
setIsGalleryModalOpen(true);
}, []);
const handleCloseGallery = useCallback(() => {
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
}, []);
return (
<>
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg">
<div className="flex flex-1 items-center justify-center overflow-y-auto p-16">
<main className="flex h-full w-full flex-col items-center justify-center">
{showUploadScreen ? (
<div className="w-full max-w-5xl">
<div className="relative aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<UploadEntryCard
icon={faWandMagicSparkles}
title="Remove Background"
description="Instantly remove backgrounds from your images with AI-powered precision."
accentBackgroundClass="bg-violet-500/40"
accentBorderClass="border-violet-400/30"
accept="image/*"
onFilesSelected={handleLocalImageSelect}
primaryLabel="Select Image"
secondaryLabel="Pick from Library"
secondaryIcon={faImages}
onSecondaryClick={handleOpenGallery}
disabled={isLoadingImage}
/>
{isLoadingImage && (
<div className="bg-ui-panel/80 absolute inset-0 flex items-center justify-center backdrop-blur-sm">
<LoadingSpinner className="h-12 w-12" />
</div>
)}
</div>
</div>
) : (
<div className="flex h-full w-full max-w-[1400px] flex-col items-center gap-4">
{(activeImage || isProcessing) && (
<div className="flex gap-3">
<Button
variant="action"
icon={faEye}
onMouseDown={handleCompareMouseDown}
disabled={!activeImage || isProcessing}
className={twMerge(
"border-ui-controls-border select-none border-2 px-6 py-2.5 text-sm font-semibold transition-all",
isHoldingCompare
? "border-primary bg-primary/20"
: "border-ui-controls-border",
(!activeImage || isProcessing) &&
"cursor-not-allowed opacity-50",
)}
>
{isHoldingCompare
? "Showing Original"
: "Hold to Compare"}
</Button>
<Button
variant="primary"
icon={faDownload}
onClick={handleDownload}
disabled={!activeImage || isProcessing}
className={twMerge(
"select-none border-2 border-primary px-6 py-2.5 text-sm font-semibold transition-all",
(!activeImage || isProcessing) &&
"cursor-not-allowed opacity-50",
)}
>
Download
</Button>
</div>
)}
<div
className="relative overflow-hidden rounded-2xl border border-ui-panel-border shadow-xl"
style={imageContainerStyle}
>
{isProcessing && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/60 backdrop-blur-sm">
{currentOriginalUrl && (
<img
src={currentOriginalUrl}
alt="Processing"
className="absolute inset-0 h-full w-full object-contain opacity-30"
/>
)}
<div className="relative z-10 flex flex-col items-center gap-4">
<div className="relative">
<div className="h-16 w-16 animate-spin rounded-full border-4 border-primary-500/30 border-t-primary-500" />
<FontAwesomeIcon
icon={faWandMagicSparkles}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-2xl text-primary-400"
/>
</div>
<span className="text-lg font-semibold text-white">
Removing Background...
</span>
</div>
</div>
)}
{activeImage && (
<>
<div
className="absolute inset-0"
style={{
backgroundImage: `
linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
linear-gradient(-45deg, #1a1a1a 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #1a1a1a 75%),
linear-gradient(-45deg, transparent 75%, #1a1a1a 75%)
`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
backgroundColor: "#2a2a2a",
}}
/>
<img
src={activeImage.processedUrl}
alt="Background Removed"
className="absolute inset-0 h-full w-full object-contain"
/>
<div
ref={clipLayerRef}
className={twMerge(
"absolute inset-0",
animationComplete &&
"transition-[clip-path] duration-300",
)}
style={{
clipPath: `inset(0 0 0 ${revealProgressRef.current}%)`,
}}
>
<img
src={activeImage.originalUrl}
alt="Original"
className="absolute inset-0 h-full w-full object-contain"
/>
</div>
<div
ref={progressBarRef}
className="absolute bottom-0 top-0 w-1 bg-primary-500 shadow-lg shadow-primary-500/50"
style={{
left: `${revealProgressRef.current}%`,
transform: "translateX(-50%)",
display:
animationComplete ||
revealProgressRef.current >= 100
? "none"
: "block",
}}
/>
</>
)}
</div>
<div className="flex items-center gap-3 rounded-xl border border-ui-panel-border bg-ui-background p-2">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileInputChange}
/>
<PopoverMenu
items={addMenuItems}
onSelect={handleAddMenuSelect}
mode="button"
position="top"
showIconsInList
buttonClassName={twMerge(
"h-14 w-14 border-2 border-dashed border-ui-panel-border bg-ui-controls/50",
isProcessing && "cursor-not-allowed opacity-50",
)}
triggerIcon={
<FontAwesomeIcon icon={faPlus} className="text-xl" />
}
/>
{images.map((img) => (
<button
key={img.id}
onClick={() => handleThumbnailClick(img)}
className={twMerge(
"relative h-14 w-14 overflow-hidden rounded-lg border-2 transition-all",
img.id === activeImageId
? "border-primary ring-2 ring-primary/30"
: "border-transparent hover:border-primary/50",
)}
>
<img
src={img.processedUrl}
alt="Processed"
className="h-full w-full object-cover"
/>
</button>
))}
</div>
</div>
)}
</main>
</div>
</div>
<GalleryModal
isOpen={isGalleryModalOpen}
onClose={handleCloseGallery}
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={1}
onUseSelected={handleGallerySelect}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
</>
);
};
@@ -0,0 +1,93 @@
import { create } from "zustand";
export interface ProcessedImage {
id: string;
originalUrl: string;
processedUrl: string;
timestamp: number;
}
export interface ImageDimensions {
width: number;
height: number;
}
interface RemoveBackgroundState {
images: ProcessedImage[];
activeImageId: string | null;
isProcessing: boolean;
currentOriginalUrl: string;
pendingJobId: string | null;
imageDimensions: ImageDimensions | null;
pendingExternalUrl: string | null;
addImage: (image: ProcessedImage) => void;
setActiveImage: (id: string | null) => void;
getActiveImage: () => ProcessedImage | null;
setIsProcessing: (value: boolean) => void;
setCurrentOriginalUrl: (url: string) => void;
setPendingJobId: (id: string | null) => void;
setImageDimensions: (dimensions: ImageDimensions | null) => void;
setPendingExternalUrl: (url: string | null) => void;
clearAll: () => void;
}
export const useRemoveBackgroundStore = create<RemoveBackgroundState>(
(set, get) => ({
images: [],
activeImageId: null,
isProcessing: false,
currentOriginalUrl: "",
pendingJobId: null,
imageDimensions: null,
pendingExternalUrl: null,
addImage: (image) => {
set((state) => ({
images: [...state.images, image],
activeImageId: image.id,
}));
},
setActiveImage: (id) => {
set({ activeImageId: id });
},
getActiveImage: () => {
const state = get();
return state.images.find((img) => img.id === state.activeImageId) ?? null;
},
setIsProcessing: (value) => {
set({ isProcessing: value });
},
setCurrentOriginalUrl: (url) => {
set({ currentOriginalUrl: url });
},
setPendingJobId: (id) => {
set({ pendingJobId: id });
},
setImageDimensions: (dimensions) => {
set({ imageDimensions: dimensions });
},
setPendingExternalUrl: (url) => {
set({ pendingExternalUrl: url });
},
clearAll: () => {
set({
images: [],
activeImageId: null,
isProcessing: false,
currentOriginalUrl: "",
pendingJobId: null,
imageDimensions: null,
pendingExternalUrl: null,
});
},
}),
);
@@ -0,0 +1,3 @@
export { RemoveBackground } from "./RemoveBackground";
export { useRemoveBackgroundStore } from "./RemoveBackgroundStore";
@@ -12,7 +12,8 @@ export type TabId =
| "VIDEO_WATERMARK_REMOVAL"
| "IMAGE_WATERMARK_REMOVAL"
| "IMAGE_TO_3D_OBJECT"
| "IMAGE_TO_3D_WORLD";
| "IMAGE_TO_3D_WORLD"
| "REMOVE_BACKGROUND";
const DEFAULT_TAB: TabId = "IMAGE";
@@ -89,6 +89,10 @@ interface GalleryModalProps {
url: string,
media_id?: string
) => Promise<void> | void;
onRemoveBackgroundClicked?: (
url: string,
media_id?: string
) => Promise<void> | void;
}
export const GalleryModal = React.memo(
@@ -105,6 +109,7 @@ export const GalleryModal = React.memo(
forceFilter,
onEditClicked,
onTurnIntoVideoClicked,
onRemoveBackgroundClicked,
}: GalleryModalProps) => {
const [loading, setLoading] = useState(false);
// Lightbox state is now handled via signals
@@ -745,6 +750,7 @@ export const GalleryModal = React.memo(
mediaClass={lightboxImageSignal.value?.mediaClass}
onEditClicked={onEditClicked}
onTurnIntoVideoClicked={onTurnIntoVideoClicked}
onRemoveBackgroundClicked={onRemoveBackgroundClicked}
/>
)}
</>
@@ -6,6 +6,7 @@ import {
faDownToLine,
faPencil,
faVideo,
faWandMagicSparkles,
} from "@fortawesome/pro-solid-svg-icons";
import {
EnqueueImageTo3dObject,
@@ -68,6 +69,10 @@ interface LightboxModalProps {
url: string,
media_id?: string,
) => Promise<void> | void;
onRemoveBackgroundClicked?: (
url: string,
media_id?: string,
) => Promise<void> | void;
batchImageToken?: string;
}
@@ -90,6 +95,7 @@ export function LightboxModal({
mediaClass,
onEditClicked,
onTurnIntoVideoClicked,
onRemoveBackgroundClicked,
batchImageToken,
}: LightboxModalProps) {
// NB(bt,2025-06-14): We add ?cors=1 to the image url to prevent caching "sec-fetch-mode: no-cors" from
@@ -660,6 +666,9 @@ export function LightboxModal({
onTurnIntoVideoClicked &&
actionUrl &&
derivedMediaClass === "image",
onRemoveBackgroundClicked &&
actionUrl &&
derivedMediaClass === "image",
onAddToSceneClicked && actionUrl,
derivedMediaClass === "image",
onDownloadClicked && actionUrl,
@@ -743,6 +752,27 @@ export function LightboxModal({
</Button>
)}
{onRemoveBackgroundClicked &&
actionUrl &&
derivedMediaClass === "image" && (
<Button
className={buttonClass}
icon={faWandMagicSparkles}
onClick={async (e) => {
e.stopPropagation();
gtagEvent("remove_background_clicked");
await onRemoveBackgroundClicked(
actionUrl,
selectedMediaToken,
);
onClose();
onCloseGallery();
}}
>
Remove BG
</Button>
)}
{onAddToSceneClicked && actionUrl && (
<Button
className={buttonClass}
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { SplatMesh } from "@sparkjsdev/spark";
export interface Viewer3DProps {
modelUrl?: string;
@@ -27,7 +28,7 @@ export function Viewer3D({
const controlsRef = useRef<OrbitControls | null>(null);
const animationFrameRef = useRef<number | null>(null);
const cubeRef = useRef<THREE.Mesh | null>(null);
const loadedModelRef = useRef<THREE.Group | null>(null);
const loadedModelRef = useRef<THREE.Object3D | null>(null);
const gridRef = useRef<THREE.GridHelper | null>(null);
const [isModelLoaded, setIsModelLoaded] = useState(false);
const thumbnailCapturedRef = useRef(false);
@@ -191,7 +192,6 @@ export function Viewer3D({
thumbnailCapturedRef.current = false;
const scene = sceneRef.current;
const loader = new GLTFLoader();
if (loadedModelRef.current) {
console.log("[Viewer3D] Removing previous model");
@@ -203,95 +203,112 @@ export function Viewer3D({
cubeRef.current.visible = false;
}
loader.load(
modelUrl,
(gltf) => {
console.log("[Viewer3D] Model loaded successfully");
const model = gltf.scene;
const onModelLoaded = (model: THREE.Object3D) => {
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
model.scale.multiplyScalar(scale);
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
model.scale.multiplyScalar(scale);
const scaledBox = new THREE.Box3().setFromObject(model);
const scaledCenter = scaledBox.getCenter(new THREE.Vector3());
const scaledSize = scaledBox.getSize(new THREE.Vector3());
const scaledBox = new THREE.Box3().setFromObject(model);
const scaledCenter = scaledBox.getCenter(new THREE.Vector3());
const scaledSize = scaledBox.getSize(new THREE.Vector3());
model.position.x = -scaledCenter.x;
model.position.z = -scaledCenter.z;
model.position.y = -scaledBox.min.y;
model.position.x = -scaledCenter.x;
model.position.z = -scaledCenter.z;
model.position.y = -scaledBox.min.y;
scene.add(model);
loadedModelRef.current = model;
setIsModelLoaded(true);
scene.add(model);
loadedModelRef.current = model;
setIsModelLoaded(true);
const modelHeight = scaledSize.y;
const maxModelDim = Math.max(scaledSize.x, scaledSize.y, scaledSize.z);
const modelHeight = scaledSize.y;
const maxModelDim = Math.max(scaledSize.x, scaledSize.y, scaledSize.z);
// Calculate distance needed to fit the model based on camera FOV (50 degrees)
const fov = 50;
const fovRad = (fov * Math.PI) / 180;
const fitDistance = maxModelDim / 2 / Math.tan(fovRad / 2);
// Calculate distance needed to fit the model based on camera FOV (50 degrees)
const fov = 50;
const fovRad = (fov * Math.PI) / 180;
const fitDistance = maxModelDim / 2 / Math.tan(fovRad / 2);
// Add padding (1.4x) to ensure model isn't touching edges
const cameraDistance = fitDistance * 1.4;
// Add padding (1.4x) to ensure model isn't touching edges
const cameraDistance = fitDistance * 1.4;
if (cameraRef.current && controlsRef.current) {
// Position camera at 45-degree angle
const angle = Math.PI / 4;
cameraRef.current.position.set(
Math.sin(angle) * cameraDistance,
modelHeight * 0.5 + cameraDistance * 0.35,
Math.cos(angle) * cameraDistance
);
controlsRef.current.target.set(0, modelHeight * 0.4, 0);
controlsRef.current.update();
}
// Capture thumbnail after a short delay to ensure rendering is complete
if (onThumbnailCapture && !thumbnailCapturedRef.current) {
thumbnailCapturedRef.current = true;
setTimeout(() => {
if (rendererRef.current && sceneRef.current && cameraRef.current) {
// Temporarily hide grid for thumbnail
if (gridRef.current) {
gridRef.current.visible = false;
}
// Also hide placeholder cube
if (cubeRef.current) {
cubeRef.current.visible = false;
}
// Render and capture
rendererRef.current.render(sceneRef.current, cameraRef.current);
const dataUrl =
rendererRef.current.domElement.toDataURL("image/png");
onThumbnailCapture(dataUrl);
// Restore grid visibility
if (gridRef.current) {
gridRef.current.visible = showGrid;
}
}
}, 100);
}
},
(progress) => {
console.log(
"[Viewer3D] Loading progress:",
((progress.loaded / progress.total) * 100).toFixed(2) + "%"
if (cameraRef.current && controlsRef.current) {
// Position camera at 45-degree angle
const angle = Math.PI / 4;
cameraRef.current.position.set(
Math.sin(angle) * cameraDistance,
modelHeight * 0.5 + cameraDistance * 0.35,
Math.cos(angle) * cameraDistance
);
},
(error) => {
console.error("[Viewer3D] Error loading model:", error);
if (cubeRef.current) {
cubeRef.current.visible = true;
}
controlsRef.current.target.set(0, modelHeight * 0.4, 0);
controlsRef.current.update();
}
);
// Capture thumbnail after a short delay to ensure rendering is complete
if (onThumbnailCapture && !thumbnailCapturedRef.current) {
thumbnailCapturedRef.current = true;
setTimeout(() => {
if (rendererRef.current && sceneRef.current && cameraRef.current) {
// Temporarily hide grid for thumbnail
if (gridRef.current) {
gridRef.current.visible = false;
}
// Also hide placeholder cube
if (cubeRef.current) {
cubeRef.current.visible = false;
}
// Render and capture
rendererRef.current.render(sceneRef.current, cameraRef.current);
const dataUrl =
rendererRef.current.domElement.toDataURL("image/png");
onThumbnailCapture(dataUrl);
// Restore grid visibility
if (gridRef.current) {
gridRef.current.visible = showGrid;
}
}
}, 100);
}
}
if (modelUrl.endsWith(".spz")) {
console.log("[Viewer3D] .spz format detected");
new SplatMesh({
url: modelUrl, onLoad: (mesh) => {
scene.add(mesh);
setIsModelLoaded(true);
// onModelLoaded(mesh);
console.log("Splat loaded")
}
});
} else {
const loader = new GLTFLoader();
loader.load(
modelUrl,
(gltf) => {
console.log("[Viewer3D] Model loaded successfully");
const model = gltf.scene;
onModelLoaded(model);
},
(progress) => {
console.log(
"[Viewer3D] Loading progress:",
((progress.loaded / progress.total) * 100).toFixed(2) + "%"
);
},
(error) => {
console.error("[Viewer3D] Error loading model:", error);
if (cubeRef.current) {
cubeRef.current.visible = true;
}
}
);
}
return () => {
if (loadedModelRef.current && sceneRef.current) {