diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 9920e5c..1e5369a 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -45,7 +45,7 @@ export default function Home() { navigate(`/visualizer/${newId}`, { state: { initialImage: saved.sourceImage, - initialRendered: saved.renderedImage || null, + initialRender: saved.renderedImage || null, name } }); diff --git a/app/routes/visualizer.$id.tsx b/app/routes/visualizer.$id.tsx index 28cc746..4ee037a 100644 --- a/app/routes/visualizer.$id.tsx +++ b/app/routes/visualizer.$id.tsx @@ -1,23 +1,122 @@ -import React from 'react'; -import {useLocation} from "react-router"; +import React, {useEffect, useRef, useState} from 'react'; +import {useLocation, useNavigate} from "react-router"; +import {generate3DView} from "../../lib/ai.action"; +import {Box, Download, RefreshCcw, Share2, X} from "lucide-react"; +import Button from "../../components/ui/Button"; -function VisualizerId(props) { +function VisualizerId() { + + const navigate = useNavigate(); const location = useLocation(); - const { initialImage, name } = location.state || {}; - return ( -
-

{name || 'Untitled Project'}

+ const { initialImage, initialRender, name } = location.state || {}; + + const hasInitialGenerated = useRef(false); + + const [isProcessing, setIsProcessing] = useState(false); + const [isMounted, setIsMounted] = useState(false); + const [currentImage, setCurrentImage] = useState(initialRender || null); + + useEffect(() => { + setIsMounted(true); + }, []); + + const handleBack = () => navigate('/'); + + const runGeneration = async () => { + if(!initialImage) return; + + try { + setIsProcessing(true); + const result = await generate3DView({ sourceImage: initialImage}) + + if(result.renderedImage) { + setCurrentImage(result.renderedImage); + // update the project with the rendered image. + } + } catch(e) { + console.error(`Generation failed: `, e) + } finally { + setIsProcessing(false); + } + } + + useEffect(() => { + if(!initialImage || hasInitialGenerated.current) return; + + hasInitialGenerated.current = true; + if(initialRender) { + setCurrentImage(initialRender); + } else { + runGeneration(); + } + + }, [initialImage, initialRender]) + + return (
- {initialImage && ( -
-

Source Image

- source + +
+
+
+
+

Project

+

{'Untitled Project'}

+

Created by You

+
+ +
+ + +
+
+ +
+ {isMounted && ( + <> + {currentImage ? ( + Rendered Image + ) : ( +
+ {initialImage && ( + Original + )} +
+ )} + {isProcessing && ( +
+
+ + Rendering... + Generating your 3D visualization +
+
+ )} + + )} +
+
- )} +
-
); } diff --git a/assets/example-floor-plan-1.jpg b/assets/example-floor-plan-1.jpg new file mode 100644 index 0000000..45b05cd Binary files /dev/null and b/assets/example-floor-plan-1.jpg differ diff --git a/lib/ai.action.ts b/lib/ai.action.ts new file mode 100644 index 0000000..1a18287 --- /dev/null +++ b/lib/ai.action.ts @@ -0,0 +1,42 @@ +import {getImageExtension} from "./utils"; +import puter from "@heyputer/puter.js"; +import {PLANLY_AI_RENDER_PROMPT} from "./constants"; + +export const fetchaDataUrl = async (url: string): Promise => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.statusText}`); + } + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); +} + +export const generate3DView = async ({sourceImage}: Generate3DViewParams) => { + const dataUrl = sourceImage.startsWith('data:') ? sourceImage : await fetchaDataUrl(sourceImage); + + const base64Data = dataUrl.split(`,`)[1]; + const mimeType = dataUrl.split(';')[0].split(':')[1]; + + if(!mimeType || !base64Data) throw new Error("Invalid image data"); + + const response = await puter.ai.txt2img(PLANLY_AI_RENDER_PROMPT, { + provider: 'gemini', + model: 'gemini-2.5-flash-image-preview', + input_image: base64Data, + input_image_mime_type: mimeType, + ratio: { w: 1024, h: 1024} + }) + + const rawImageUrl = (response as HTMLImageElement).src ?? null; + + if (!rawImageUrl) return { renderedImage: null, renderedPath: undefined}; + + const renderedImage = rawImageUrl.startsWith('data:image') ? rawImageUrl : await fetchaDataUrl(rawImageUrl); + + return { renderedImage, renderedPath: undefined}; +} \ No newline at end of file diff --git a/lib/ai.actions.ts b/lib/ai.actions.ts deleted file mode 100644 index e69de29..0000000 diff --git a/lib/constants.ts b/lib/constants.ts index f419fbe..466c402 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -2,9 +2,9 @@ export const PUTER_WORKER_URL = import.meta.env.VITE_PUTER_WORKER_URL || ""; // Storage Paths export const STORAGE_PATHS = { - ROOT: "roomify", - SOURCES: "roomify/sources", - RENDERS: "roomify/renders", + ROOT: "planly", + SOURCES: "planly/sources", + RENDERS: "planly/renders", } as const; // Timing Constants (in milliseconds) @@ -29,7 +29,7 @@ export const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50MB export const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/jpg', 'image/png']; export const ALLOWED_FILE_EXTENSIONS = ['.jpg', '.jpeg', '.png']; -export const ROOMIFY_RENDER_PROMPT = ` +export const PLANLY_AI_RENDER_PROMPT = ` TASK: Convert the input 2D floor plan into a **photorealistic, top‑down 3D architectural render**. STRICT REQUIREMENTS (do not violate):