mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-29 04:52:51 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75c4af5bd7 | ||
|
|
3f596e351a | ||
|
|
afd3012405 | ||
|
|
7ee5a6aa9a | ||
|
|
f5cd65be9c |
+16
-15
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user