diff --git a/frontend/apps/editor3d/app/src/components/experiences/ImageTo3DExperience.tsx b/frontend/apps/editor3d/app/src/components/experiences/ImageTo3DExperience.tsx new file mode 100644 index 0000000000..78f5968f7e --- /dev/null +++ b/frontend/apps/editor3d/app/src/components/experiences/ImageTo3DExperience.tsx @@ -0,0 +1,394 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button, ToggleButton } from "@storyteller/ui-button"; +import { TabSelector } from "@storyteller/ui-tab-selector"; +import { Tooltip } from "@storyteller/ui-tooltip"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faCube, + faCubes, + faGlobe, + faImages, + faPlus, + faSparkles, + faUpload, + faXmark, +} from "@fortawesome/pro-solid-svg-icons"; +import { twMerge } from "tailwind-merge"; + +type Mode = "image" | "text"; +type Variant = "object" | "world"; + +interface ImageTo3DExperienceProps { + title: string; + subtitle: string; + variant: Variant; +} + +interface GeneratedResult { + id: string; + mode: Mode; + timestamp: number; + note?: string; + previewUrl?: string; + meshOnly?: boolean; +} + +const MODE_TABS = [ + { id: "image", label: "Image to 3D" }, + { id: "text", label: "Text to 3D" }, +] satisfies { id: Mode; label: string }[]; + +const formatTime = (timestamp: number) => { + const date = new Date(timestamp); + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +}; + +const generateId = () => + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : Math.random().toString(36).slice(2, 10); + +export const ImageTo3DExperience = ({ + title, + subtitle, + variant, +}: ImageTo3DExperienceProps) => { + const [activeMode, setActiveMode] = useState("image"); + const [uploadedPreview, setUploadedPreview] = useState(null); + const [uploadedName, setUploadedName] = useState(null); + const [prompt, setPrompt] = useState(""); + const [results, setResults] = useState([]); + const [isGenerating, setIsGenerating] = useState(false); + const [dragActive, setDragActive] = useState(false); + const [meshOnly, setMeshOnly] = useState(false); + const timeoutRef = useRef(null); + const fileInputRef = useRef(null); + const textareaRef = useRef(null); + + useEffect( + () => () => { + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + if (uploadedPreview && uploadedPreview.startsWith("blob:")) { + URL.revokeObjectURL(uploadedPreview); + } + }, + [uploadedPreview], + ); + + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; + } + }); + + const handleFiles = (files?: FileList | null) => { + if (!files || files.length === 0) return; + const file = files[0]; + if (!file.type.startsWith("image/")) return; + if (uploadedPreview && uploadedPreview.startsWith("blob:")) { + URL.revokeObjectURL(uploadedPreview); + } + const objectUrl = URL.createObjectURL(file); + setUploadedPreview(objectUrl); + setUploadedName(file.name); + }; + + const handleFileChange = (event: React.ChangeEvent) => { + handleFiles(event.target.files); + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setDragActive(false); + handleFiles(event.dataTransfer?.files); + }; + + const handlePickFromLibrary = () => { + console.info("TODO: integrate gallery modal for ImageTo3DExperience"); + }; + + const handleGenerate = () => { + if (isGenerating) return; + setIsGenerating(true); + const snapshotPrompt = prompt.trim(); + const snapshotPreview = uploadedPreview || undefined; + const newResult: GeneratedResult = { + id: generateId(), + mode: activeMode, + timestamp: Date.now(), + note: activeMode === "text" ? snapshotPrompt : uploadedName || undefined, + previewUrl: snapshotPreview, + meshOnly, + }; + + timeoutRef.current = window.setTimeout(() => { + setResults((prev) => [newResult, ...prev]); + setIsGenerating(false); + if (activeMode === "text") { + setPrompt(""); + } + }, 1200); + }; + + const canGenerate = useMemo(() => { + if (isGenerating) return false; + if (activeMode === "image") { + return Boolean(uploadedPreview); + } + if (activeMode === "text") { + return prompt.trim().length > 3; + } + return true; + }, [activeMode, uploadedPreview, prompt, isGenerating]); + + const resultTitle = + variant === "object" ? "3D Object Preview" : "3D World Preview"; + + const renderAddImageTile = () => ( + + + + + } + > +
{ + event.preventDefault(); + event.stopPropagation(); + setDragActive(true); + }} + onDragOver={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onDragLeave={(event) => { + event.preventDefault(); + event.stopPropagation(); + if (!event.currentTarget.contains(event.relatedTarget as Node)) { + setDragActive(false); + } + }} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + fileInputRef.current?.click(); + } + }} + > + + + Add Image + +
+
+ ); + + const renderImageMode = () => ( +
+ {uploadedPreview ? ( +
fileInputRef.current?.click()} + > + Reference + +
+ ) : ( + renderAddImageTile() + )} +
+ ); + + const promptInputId = `image-to-3d-${variant}-prompt`; + + const renderTextMode = () => ( +
+