Files
Velocity-OS/webos/src/pillars/studio/ReimaginePanel.tsx
Sagnik Ghosh 8b2d836589
Some checks failed
Velocity-OS Deployment Pipeline / lint (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (agents) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (core) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (media-engine) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (webos) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (agents) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (core) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (media-engine) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (webos) (push) Has been cancelled
Velocity-OS Deployment Pipeline / notify-ingress (push) Has been cancelled
fix: complete Velocity-OS feature migration wiring
2026-05-02 16:39:10 +05:30

230 lines
8.8 KiB
TypeScript

import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Download, ExternalLink, ImagePlus, Loader2, Sparkles, UploadCloud } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
checkDreamWeaverHealth,
fetchDreamWeaverResult,
getDreamWeaverStatus,
submitDreamWeaverJob,
type DreamWeaverHealth,
type DreamWeaverJobResponse,
} from '@/shared/lib/dreamWeaverApi';
import styles from './ReimaginePanel.module.css';
type RoomType = 'bedroom' | 'living_room' | 'bathroom' | 'kitchen' | 'dining_room' | 'office';
type Phase = 'idle' | 'ready' | 'generating' | 'result' | 'error';
interface ReimagineResult {
url: string;
jobId: string;
blob: Blob;
}
interface ReimaginePanelProps {
propertyId: string;
roomImageUrl?: string;
onResultSaved?: (url: string) => void;
}
const ROOM_TYPES: { id: RoomType; label: string }[] = [
{ id: 'bedroom', label: 'Bedroom' },
{ id: 'living_room', label: 'Living Room' },
{ id: 'bathroom', label: 'Bathroom' },
{ id: 'kitchen', label: 'Kitchen' },
{ id: 'dining_room', label: 'Dining Room' },
{ id: 'office', label: 'Office' },
];
const DEFAULT_PROMPT = 'Modern luxury staging, warm practical lighting, premium materials, uncluttered real estate brochure finish';
export function ReimaginePanel({ propertyId, roomImageUrl, onResultSaved }: ReimaginePanelProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [phase, setPhase] = useState<Phase>('idle');
const [health, setHealth] = useState<DreamWeaverHealth | null>(null);
const [roomType, setRoomType] = useState<RoomType>('bedroom');
const [keywords, setKeywords] = useState(DEFAULT_PROMPT);
const [sourceFile, setSourceFile] = useState<File | null>(null);
const [sourcePreview, setSourcePreview] = useState<string | null>(roomImageUrl ?? null);
const [job, setJob] = useState<DreamWeaverJobResponse | null>(null);
const [result, setResult] = useState<ReimagineResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [pollCount, setPollCount] = useState(0);
useEffect(() => {
let alive = true;
checkDreamWeaverHealth()
.then((value) => {
if (!alive) return;
setHealth(value);
setPhase('ready');
})
.catch((err) => {
if (!alive) return;
setError(err instanceof Error ? err.message : 'Dream Weaver health check failed.');
setPhase('error');
});
return () => { alive = false; };
}, []);
useEffect(() => {
return () => {
if (sourcePreview?.startsWith('blob:')) URL.revokeObjectURL(sourcePreview);
if (result?.url.startsWith('blob:')) URL.revokeObjectURL(result.url);
};
}, [sourcePreview, result]);
const canGenerate = Boolean(sourceFile) && phase !== 'generating';
const healthLabel = useMemo(() => {
if (!health) return 'Checking Dream Weaver';
if (health.online && health.routeMounted) return 'Dream Weaver online';
return health.detail || health.status || 'Dream Weaver unavailable';
}, [health]);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
if (!file) return;
if (sourcePreview?.startsWith('blob:')) URL.revokeObjectURL(sourcePreview);
setSourceFile(file);
setSourcePreview(URL.createObjectURL(file));
setResult(null);
setError(null);
setPhase('ready');
};
const handleGenerate = useCallback(async () => {
if (!sourceFile) {
setError('Upload a room image before generating.');
setPhase('error');
return;
}
setPhase('generating');
setError(null);
setPollCount(0);
try {
const submitted = await submitDreamWeaverJob({ image: sourceFile, roomType, keywords });
setJob(submitted);
for (let attempt = 1; attempt <= 180; attempt += 1) {
setPollCount(attempt);
const status = await getDreamWeaverStatus(submitted);
if (status.status === 'failed') {
throw new Error(status.error || 'Dream Weaver generation failed.');
}
if (status.ready || status.status === 'complete') {
const blob = await fetchDreamWeaverResult(submitted.job_id, status.result_url ?? submitted.result_url);
const url = URL.createObjectURL(blob);
setResult({ url, jobId: submitted.job_id, blob });
setPhase('result');
return;
}
await new Promise((resolve) => window.setTimeout(resolve, 2000));
}
throw new Error('Dream Weaver timed out before returning an image.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Dream Weaver request failed.');
setPhase('error');
}
}, [keywords, roomType, sourceFile]);
const handleDownload = () => {
if (!result) return;
const anchor = document.createElement('a');
anchor.href = result.url;
anchor.download = `${propertyId}-reimagine-${result.jobId}.png`;
anchor.click();
};
return (
<div className={styles.root}>
<section className={styles.sourcePanel}>
<div className={styles.panelHeader}>
<div>
<span className={styles.eyebrow}>Source Room</span>
<h3>Reimagine this space</h3>
</div>
<span className={health?.online ? styles.healthOk : styles.healthWarn}>{healthLabel}</span>
</div>
<button className={styles.uploadFrame} onClick={() => fileInputRef.current?.click()} type="button">
{sourcePreview ? <img src={sourcePreview} alt="Selected room" /> : (
<span className={styles.uploadEmpty}>
<UploadCloud size={28} />
Upload room image
</span>
)}
</button>
<input ref={fileInputRef} className={styles.fileInput} type="file" accept="image/*" onChange={handleFileChange} />
<div className={styles.roomGrid}>
{ROOM_TYPES.map((room) => (
<button
key={room.id}
type="button"
className={roomType === room.id ? styles.roomActive : styles.roomButton}
onClick={() => setRoomType(room.id)}
>
{room.label}
</button>
))}
</div>
<label className={styles.promptLabel}>
Styling prompt
<textarea value={keywords} onChange={(event) => setKeywords(event.target.value)} />
</label>
<button className={styles.generateButton} onClick={() => void handleGenerate()} disabled={!canGenerate}>
{phase === 'generating' ? <Loader2 size={18} className={styles.spin} /> : <Sparkles size={18} />}
Generate
</button>
</section>
<section className={styles.resultPanel}>
<AnimatePresence mode="wait">
{phase === 'generating' && (
<motion.div key="generating" className={styles.resultState} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<Loader2 className={styles.spin} size={34} />
<strong>Reimagining your space</strong>
<span>Render processing - poll {pollCount}/180</span>
</motion.div>
)}
{phase === 'result' && result && (
<motion.div key="result" className={styles.resultReady} initial={{ opacity: 0, scale: 0.98 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }}>
<img src={result.url} alt="Generated staging" />
<div className={styles.resultActions}>
<button className="btn-primary" onClick={() => onResultSaved?.(result.url)}>
<ImagePlus size={16} /> Use This
</button>
<button className="btn-ghost" onClick={handleDownload}>
<Download size={16} /> Download
</button>
<button className="btn-ghost" onClick={() => window.open(result.url, '_blank', 'noopener,noreferrer')}>
<ExternalLink size={16} /> Open
</button>
</div>
</motion.div>
)}
{(phase === 'ready' || phase === 'idle') && (
<motion.div key="empty" className={styles.resultState} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<Sparkles size={34} />
<strong>No generated image yet</strong>
<span>Upload an image, tune the prompt, and generate a staged render.</span>
</motion.div>
)}
{phase === 'error' && (
<motion.div key="error" className={styles.errorState} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<strong>Dream Weaver unavailable</strong>
<span>{error}</span>
<button className="btn-ghost" onClick={() => setPhase(sourceFile ? 'ready' : 'idle')}>Dismiss</button>
</motion.div>
)}
</AnimatePresence>
</section>
</div>
);
}