Files
Project_Velocity/app/src/components/modules/CatalystDreamWeaverTab.tsx
Sayan Datta fefe8373ec
Some checks failed
Production Readiness / backend-contracts (pull_request) Has been cancelled
Production Readiness / webos-typecheck (pull_request) Has been cancelled
Production Readiness / ipad-parse (pull_request) Has been cancelled
feat: Ipad app features and Dream Weaver for Velocity WebOS
2026-04-28 10:59:07 +05:30

525 lines
22 KiB
TypeScript

import { useEffect, useRef, useState, type ChangeEvent, type CSSProperties } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
AlertTriangle,
Check,
Download,
ExternalLink,
Home,
Image as ImageIcon,
Loader2,
RefreshCw,
Server,
Sparkles,
Upload,
WandSparkles,
} from 'lucide-react';
import { useMarketingStore } from '@/store/useMarketingStore';
import {
DREAM_WEAVER_URL,
checkDreamWeaverHealth,
fetchDreamWeaverResult,
getDreamWeaverStatus,
submitDreamWeaverJob,
type DreamWeaverHealth,
type DreamWeaverJobResponse,
type DreamWeaverStatusResponse,
} from '@/lib/dreamWeaverApi';
const GLASS: CSSProperties = {
background: 'rgba(8, 10, 18, 0.82)',
border: '1px solid rgba(59,130,246,0.14)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
boxShadow: '0 0 0 1px rgba(255,255,255,0.04), 0 4px 32px rgba(0,0,0,0.55)',
};
const INNER: CSSProperties = {
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.07)',
};
const ROOM_TYPES = [
{ 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: 'home_office', label: 'Office' },
{ id: 'hallway', label: 'Hallway' },
{ id: 'balcony', label: 'Balcony' },
] as const;
type ProcessingState = 'idle' | 'checking' | 'submitting' | 'rendering' | 'downloading';
interface DreamWeaverOutput {
id: string;
roomLabel: string;
keywords: string;
imageUrl: string;
createdAt: Date;
}
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function isReadyStatus(status: DreamWeaverStatusResponse) {
const normalized = status.status?.toLowerCase() ?? '';
return Boolean(status.ready) || ['ready', 'completed', 'complete', 'succeeded', 'success', 'finished'].includes(normalized);
}
function isFailedStatus(status: DreamWeaverStatusResponse) {
const normalized = status.status?.toLowerCase() ?? '';
return ['failed', 'error', 'cancelled', 'canceled'].includes(normalized);
}
function statusLabel(state: ProcessingState, health: DreamWeaverHealth | null) {
if (state === 'checking') return 'Checking gateway';
if (state === 'submitting') return 'Submitting render';
if (state === 'rendering') return 'ComfyUI rendering';
if (state === 'downloading') return 'Fetching result';
if (!health) return 'Gateway unknown';
if (health.online && health.routeMounted && health.comfyuiOnline === false) return 'Gateway online · ComfyUI offline';
if (health.online && health.routeMounted && health.comfyuiOnline && health.checkpointReady === false) return 'Gateway online · Model missing';
return health.online && health.routeMounted ? 'Gateway online' : 'Gateway offline';
}
function ResultActions({ output }: { output: DreamWeaverOutput }) {
function downloadResult() {
const anchor = document.createElement('a');
anchor.href = output.imageUrl;
anchor.download = `dream-weaver-${output.id}.png`;
anchor.click();
}
return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={downloadResult}
className="h-9 w-9 rounded-xl flex items-center justify-center transition-colors hover:bg-white/10"
style={INNER}
title="Download generated image"
>
<Download className="w-4 h-4 text-white/75" />
</button>
<button
type="button"
onClick={() => window.open(output.imageUrl, '_blank', 'noopener,noreferrer')}
className="h-9 w-9 rounded-xl flex items-center justify-center transition-colors hover:bg-white/10"
style={INNER}
title="Open generated image"
>
<ExternalLink className="w-4 h-4 text-white/75" />
</button>
</div>
);
}
export function CatalystDreamWeaverTab() {
const { pushLiveEvent } = useMarketingStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const objectUrlsRef = useRef<Set<string>>(new Set());
const [sourceFile, setSourceFile] = useState<File | null>(null);
const [sourcePreview, setSourcePreview] = useState<string | null>(null);
const [selectedRoomType, setSelectedRoomType] = useState<(typeof ROOM_TYPES)[number]['id']>('bedroom');
const [keywords, setKeywords] = useState('');
const [health, setHealth] = useState<DreamWeaverHealth | null>(null);
const [processingState, setProcessingState] = useState<ProcessingState>('checking');
const [progress, setProgress] = useState('Checking Dream Weaver gateway...');
const [error, setError] = useState<string | null>(null);
const [currentOutput, setCurrentOutput] = useState<DreamWeaverOutput | null>(null);
const [history, setHistory] = useState<DreamWeaverOutput[]>([]);
const isProcessing = processingState !== 'idle' && processingState !== 'checking';
const roomLabel = ROOM_TYPES.find((room) => room.id === selectedRoomType)?.label ?? 'Bedroom';
useEffect(() => {
void refreshHealth();
return () => {
objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
objectUrlsRef.current.clear();
};
}, []);
function rememberObjectUrl(blobOrFile: Blob) {
const url = URL.createObjectURL(blobOrFile);
objectUrlsRef.current.add(url);
return url;
}
function setSourceFromFile(file: File) {
if (!file.type.startsWith('image/')) {
setError('Dream Weaver needs a source room image.');
return;
}
const previewUrl = rememberObjectUrl(file);
setSourceFile(file);
setSourcePreview(previewUrl);
setCurrentOutput(null);
setError(null);
}
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) {
setSourceFromFile(file);
}
event.target.value = '';
}
async function refreshHealth() {
setProcessingState('checking');
setProgress('Checking Dream Weaver gateway...');
const nextHealth = await checkDreamWeaverHealth();
setHealth(nextHealth);
setProcessingState('idle');
setProgress(nextHealth.online && nextHealth.routeMounted
? nextHealth.comfyuiOnline === false
? `Gateway is online and the Dream Weaver route is mounted. ComfyUI is offline${nextHealth.comfyuiUrl ? ` at ${nextHealth.comfyuiUrl}` : ''}.`
: nextHealth.checkpointReady === false
? `ComfyUI is online${nextHealth.comfyuiUrl ? ` at ${nextHealth.comfyuiUrl}` : ''}, but no checkpoint model is installed. Hydrate RealVisXL into ComfyUI/models/checkpoints.`
: 'Gateway is online and the Dream Weaver route is mounted.'
: nextHealth.detail ?? 'Dream Weaver gateway is not reachable.');
}
async function pollUntilReady(job: DreamWeaverJobResponse) {
let latestResultUrl = job.result_url;
for (let attempt = 1; attempt <= 150; attempt += 1) {
const status = await getDreamWeaverStatus(job);
latestResultUrl = status.result_url ?? latestResultUrl;
setProgress(status.status ? `Render ${status.status} · poll ${attempt}/150` : `Render queued · poll ${attempt}/150`);
if (isReadyStatus(status)) {
return latestResultUrl;
}
if (isFailedStatus(status) || status.error) {
throw new Error(status.error ?? `Dream Weaver render ${status.status ?? 'failed'}.`);
}
await sleep(2000);
}
throw new Error('Dream Weaver timed out after 5 minutes.');
}
async function generate() {
if (!sourceFile || isProcessing) return;
setError(null);
setCurrentOutput(null);
try {
setProcessingState('submitting');
setProgress(`Submitting ${roomLabel.toLowerCase()} staging request...`);
const job = await submitDreamWeaverJob({
image: sourceFile,
roomType: selectedRoomType,
keywords,
});
setProcessingState('rendering');
setProgress(`Job ${job.job_id} accepted. Waiting for ComfyUI output...`);
const resultUrl = await pollUntilReady(job);
setProcessingState('downloading');
setProgress('Fetching generated image...');
const resultBlob = await fetchDreamWeaverResult(job.job_id, resultUrl);
const imageUrl = rememberObjectUrl(resultBlob);
const output: DreamWeaverOutput = {
id: job.job_id,
roomLabel,
keywords: keywords.trim(),
imageUrl,
createdAt: new Date(),
};
setCurrentOutput(output);
setHistory((items) => [output, ...items].slice(0, 6));
setProgress('Dream Weaver render complete.');
pushLiveEvent({
id: `dw-${job.job_id}-${Date.now()}`,
type: 'create',
campaignName: 'Dream Weaver',
message: `${roomLabel} staging render completed.`,
timestamp: new Date(),
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Dream Weaver render failed.';
setError(message);
setProgress(message);
pushLiveEvent({
id: `dw-error-${Date.now()}`,
type: 'alert',
campaignName: 'Dream Weaver',
message,
timestamp: new Date(),
});
} finally {
setProcessingState('idle');
}
}
return (
<div className="space-y-5">
<motion.div
className="relative rounded-2xl p-5 overflow-hidden"
style={GLASS}
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
>
<div className="absolute inset-x-0 top-0 h-px pointer-events-none"
style={{ background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.10), transparent)' }} />
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h3 className="text-sm font-semibold text-white mb-0.5 flex items-center gap-2">
<WandSparkles className="w-4 h-4 text-blue-400" /> Dream Weaver
</h3>
<p className="text-xs" style={{ color: 'rgba(148,163,184,0.55)' }}>
Room image transformation pipeline using the same Dream Weaver gateway as the iPad app.
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-xl px-3 py-2 text-xs" style={INNER}>
<span className={`h-2 w-2 rounded-full ${health?.online && health.routeMounted ? 'bg-green-400' : 'bg-red-400'}`} />
<span className="text-white/75">{statusLabel(processingState, health)}</span>
</div>
<button
type="button"
onClick={() => void refreshHealth()}
className="h-9 w-9 rounded-xl flex items-center justify-center transition-colors hover:bg-white/10"
style={INNER}
title="Check Dream Weaver gateway"
>
<RefreshCw className={`w-4 h-4 text-white/75 ${processingState === 'checking' ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
</motion.div>
<div className="grid grid-cols-1 xl:grid-cols-[0.95fr_1.05fr] gap-5">
<motion.div
className="relative rounded-2xl p-5 overflow-hidden"
style={GLASS}
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.35, delay: 0.05, ease: [0.4, 0, 0.2, 1] }}
>
<div className="space-y-5">
<div>
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-blue-400" /> Source Room
</h3>
<p className="text-xs mt-1" style={{ color: 'rgba(148,163,184,0.55)' }}>
Upload a ground-truth room photograph, choose the target room type, then add optional styling keywords.
</p>
</div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
const file = event.dataTransfer.files?.[0];
if (file) setSourceFromFile(file);
}}
className="relative w-full min-h-[300px] rounded-2xl overflow-hidden flex items-center justify-center text-left transition-colors hover:border-blue-400/40"
style={{ ...INNER, background: sourcePreview ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.025)' }}
>
{sourcePreview ? (
<img src={sourcePreview} alt="Selected source room" className="absolute inset-0 h-full w-full object-cover" />
) : (
<div className="flex flex-col items-center gap-3 text-center px-6">
<div className="h-12 w-12 rounded-2xl flex items-center justify-center" style={INNER}>
<Upload className="h-5 w-5 text-blue-400" />
</div>
<div>
<p className="text-sm font-semibold text-white">Upload room image</p>
<p className="text-xs mt-1" style={{ color: 'rgba(148,163,184,0.55)' }}>
Click to browse or drop a photo here.
</p>
</div>
</div>
)}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
</button>
<div>
<p className="text-xs font-medium uppercase tracking-widest mb-2" style={{ color: 'rgba(148,163,184,0.65)' }}>
Room type
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{ROOM_TYPES.map((room) => {
const selected = selectedRoomType === room.id;
return (
<button
key={room.id}
type="button"
onClick={() => setSelectedRoomType(room.id)}
className="rounded-xl px-3 py-2 text-sm font-medium flex items-center gap-2 transition-colors"
style={{
background: selected ? 'rgba(59,130,246,0.18)' : 'rgba(255,255,255,0.04)',
border: selected ? '1px solid rgba(59,130,246,0.38)' : '1px solid rgba(255,255,255,0.07)',
color: selected ? '#fff' : 'rgba(226,232,240,0.72)',
}}
>
<Home className="w-3.5 h-3.5 text-blue-400" />
<span>{room.label}</span>
</button>
);
})}
</div>
</div>
<div>
<label className="text-xs font-medium uppercase tracking-widest mb-2 block" style={{ color: 'rgba(148,163,184,0.65)' }}>
Keywords
</label>
<textarea
value={keywords}
onChange={(event) => setKeywords(event.target.value)}
placeholder="gold, marble, luxury, soft daylight"
rows={3}
className="w-full resize-none rounded-xl px-3 py-2 text-sm text-white placeholder-white/20 outline-none focus:border-blue-400/50"
style={INNER}
/>
</div>
<AnimatePresence>
{error && (
<motion.div
className="rounded-xl p-3 flex items-start gap-2"
style={{ background: 'rgba(239,68,68,0.12)', border: '1px solid rgba(239,68,68,0.25)' }}
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
>
<AlertTriangle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-red-200 leading-relaxed">{error}</p>
</motion.div>
)}
</AnimatePresence>
<div className="flex items-center justify-between gap-3 rounded-2xl p-3" style={INNER}>
<div className="min-w-0">
<p className="text-xs font-medium text-white truncate">{progress}</p>
<p className="text-[11px] mt-1 truncate" style={{ color: 'rgba(148,163,184,0.5)' }}>
Gateway: {DREAM_WEAVER_URL}
</p>
</div>
<button
type="button"
onClick={() => void generate()}
disabled={!sourceFile || isProcessing || health?.routeMounted === false || health?.comfyuiOnline === false || health?.checkpointReady === false}
className="h-11 px-5 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-45 disabled:cursor-not-allowed transition-colors"
style={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}
>
{isProcessing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
Generate
</button>
</div>
</div>
</motion.div>
<motion.div
className="relative rounded-2xl p-5 overflow-hidden"
style={GLASS}
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.35, delay: 0.1, ease: [0.4, 0, 0.2, 1] }}
>
<div className="flex items-center justify-between gap-3 mb-4">
<div>
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<WandSparkles className="w-4 h-4 text-blue-400" /> Generated Staging
</h3>
<p className="text-xs mt-1" style={{ color: 'rgba(148,163,184,0.55)' }}>
The result appears here as soon as the gateway marks the job ready.
</p>
</div>
{currentOutput && <ResultActions output={currentOutput} />}
</div>
<div className="relative min-h-[460px] rounded-2xl overflow-hidden flex items-center justify-center" style={INNER}>
{currentOutput ? (
<img src={currentOutput.imageUrl} alt={`${currentOutput.roomLabel} Dream Weaver result`} className="absolute inset-0 h-full w-full object-contain bg-black" />
) : (
<div className="flex flex-col items-center gap-3 text-center px-8">
<div className="h-14 w-14 rounded-2xl flex items-center justify-center" style={INNER}>
{isProcessing ? <Loader2 className="h-6 w-6 text-blue-400 animate-spin" /> : <WandSparkles className="h-6 w-6 text-blue-400" />}
</div>
<div>
<p className="text-sm font-semibold text-white">{isProcessing ? 'Dream Weaver is rendering' : 'No generated image yet'}</p>
<p className="text-xs mt-1 max-w-md" style={{ color: 'rgba(148,163,184,0.55)' }}>
Upload a source image and generate a staging render to populate this canvas.
</p>
</div>
</div>
)}
</div>
{history.length > 0 && (
<div className="mt-5">
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-medium uppercase tracking-widest" style={{ color: 'rgba(148,163,184,0.65)' }}>
Recent renders
</p>
<span className="text-[11px]" style={{ color: 'rgba(148,163,184,0.45)' }}>{history.length}/6</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{history.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setCurrentOutput(item)}
className="group rounded-xl overflow-hidden text-left transition-colors hover:border-blue-400/40"
style={INNER}
>
<div className="aspect-[4/3] bg-black overflow-hidden">
<img src={item.imageUrl} alt={item.roomLabel} className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300" />
</div>
<div className="p-3">
<div className="flex items-center gap-1.5 text-xs font-semibold text-white">
<Check className="w-3.5 h-3.5 text-green-400" />
{item.roomLabel}
</div>
<p className="text-[11px] mt-1 truncate" style={{ color: 'rgba(148,163,184,0.55)' }}>
{item.keywords || item.createdAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</button>
))}
</div>
</div>
)}
</motion.div>
</div>
<motion.div
className="relative rounded-2xl p-5 overflow-hidden"
style={GLASS}
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.35, delay: 0.15, ease: [0.4, 0, 0.2, 1] }}
>
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Server className="w-4 h-4 text-blue-400" /> Gateway Contract
</h3>
<span className="text-xs font-medium" style={{ color: health?.routeMounted ? '#4ade80' : '#f87171' }}>
{health?.routeMounted ? 'Route mounted' : 'Route not verified'}
</span>
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
{['POST /dream-weaver', 'GET /dream-weaver/status/{job_id}', 'GET /dream-weaver/result/{job_id}'].map((endpoint) => (
<div key={endpoint} className="rounded-xl p-3" style={INNER}>
<p className="text-xs font-mono text-blue-200">{endpoint}</p>
</div>
))}
</div>
</motion.div>
</div>
);
}