fix: complete Velocity-OS feature migration wiring
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
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
This commit is contained in:
@@ -1,236 +1,229 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
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';
|
||||
|
||||
/**
|
||||
* ReimaginePanel
|
||||
* One-tap AI room staging. Replaces the raw Dream Weaver tab.
|
||||
* UX Master Plan §4.4 + Part 2 §2.4 spec:
|
||||
*
|
||||
* - Tap "Reimagine" → 3 style tiles stagger-reveal (50ms intervals)
|
||||
* - Tiles show AI-representative preview images, not labels
|
||||
* - Select style → "Generate" materializes with spring
|
||||
* - Generation → property view blurs + shimmer sweeps
|
||||
* - Result → cross-fade, two actions: Use This / Try Another
|
||||
*
|
||||
* All ComfyUI machinery hidden. No job IDs, no queue position.
|
||||
* User sees: "Reimagining your space…"
|
||||
*/
|
||||
|
||||
type StylePreset = 'modern-luxury' | 'warm-contemporary' | 'minimalist-zen';
|
||||
type Phase = 'idle' | 'selecting' | 'generating' | 'result';
|
||||
type RoomType = 'bedroom' | 'living_room' | 'bathroom' | 'kitchen' | 'dining_room' | 'office';
|
||||
type Phase = 'idle' | 'ready' | 'generating' | 'result' | 'error';
|
||||
|
||||
interface ReimagineResult {
|
||||
imageUrl: string;
|
||||
jobId: string; // internal only, never shown
|
||||
url: string;
|
||||
jobId: string;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
interface ReimaginePanelProps {
|
||||
propertyId: string;
|
||||
roomImageUrl?: string; // Source room photo
|
||||
roomImageUrl?: string;
|
||||
onResultSaved?: (url: string) => void;
|
||||
}
|
||||
|
||||
const STYLE_PRESETS: { id: StylePreset; label: string; previewUrl: string }[] = [
|
||||
{
|
||||
id: 'modern-luxury',
|
||||
label: 'Modern Luxury',
|
||||
// Curated representative preview images
|
||||
previewUrl: '/assets/style-previews/modern-luxury.jpg',
|
||||
},
|
||||
{
|
||||
id: 'warm-contemporary',
|
||||
label: 'Warm Contemporary',
|
||||
previewUrl: '/assets/style-previews/warm-contemporary.jpg',
|
||||
},
|
||||
{
|
||||
id: 'minimalist-zen',
|
||||
label: 'Minimalist Zen',
|
||||
previewUrl: '/assets/style-previews/minimalist-zen.jpg',
|
||||
},
|
||||
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' },
|
||||
];
|
||||
|
||||
export function ReimaginePanel({
|
||||
propertyId,
|
||||
roomImageUrl,
|
||||
onResultSaved,
|
||||
}: ReimaginePanelProps) {
|
||||
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 [selectedStyle, setSelectedStyle] = useState<StylePreset | null>(null);
|
||||
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);
|
||||
|
||||
const handleReimagineClick = () => setPhase('selecting');
|
||||
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; };
|
||||
}, []);
|
||||
|
||||
const handleStyleSelect = (style: StylePreset) => {
|
||||
setSelectedStyle(style);
|
||||
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 (!selectedStyle) return;
|
||||
if (!sourceFile) {
|
||||
setError('Upload a room image before generating.');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
setPhase('generating');
|
||||
setError(null);
|
||||
setPollCount(0);
|
||||
|
||||
try {
|
||||
// POST to Dream Weaver gateway (hidden from user)
|
||||
const resp = await fetch('/dream-weaver', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
property_id: propertyId,
|
||||
style_preset: selectedStyle,
|
||||
source_image_url: roomImageUrl,
|
||||
}),
|
||||
});
|
||||
const { job_id } = await resp.json();
|
||||
const submitted = await submitDreamWeaverJob({ image: sourceFile, roomType, keywords });
|
||||
setJob(submitted);
|
||||
|
||||
// Poll silently (user sees shimmer, not polling)
|
||||
const imageUrl = await pollForResult(job_id);
|
||||
setResult({ imageUrl, jobId: job_id });
|
||||
setPhase('result');
|
||||
} catch {
|
||||
setPhase('selecting');
|
||||
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');
|
||||
}
|
||||
}, [selectedStyle, propertyId, roomImageUrl]);
|
||||
}, [keywords, roomType, sourceFile]);
|
||||
|
||||
const handleUseThis = async () => {
|
||||
const handleDownload = () => {
|
||||
if (!result) return;
|
||||
// Save to property media library + generate vault link (handled by Studio)
|
||||
onResultSaved?.(result.imageUrl);
|
||||
setPhase('idle');
|
||||
setResult(null);
|
||||
setSelectedStyle(null);
|
||||
};
|
||||
|
||||
const handleTryAnother = () => {
|
||||
setPhase('selecting');
|
||||
setResult(null);
|
||||
setSelectedStyle(null);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = result.url;
|
||||
anchor.download = `${propertyId}-reimagine-${result.jobId}.png`;
|
||||
anchor.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{/* ── Idle: single button ─────────────────────────── */}
|
||||
{phase === 'idle' && (
|
||||
<motion.button
|
||||
className={`btn-primary ${styles.reimagineBtn}`}
|
||||
onClick={handleReimagineClick}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
✨ Reimagine
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{/* ── Selecting: staggered style tiles ─────────────── */}
|
||||
{(phase === 'selecting' || phase === 'result') && (
|
||||
<div className={styles.presetsSection}>
|
||||
<div className={styles.presetGrid}>
|
||||
{STYLE_PRESETS.map(({ id, label, previewUrl }, idx) => (
|
||||
<motion.button
|
||||
key={id}
|
||||
className={`${styles.presetTile} ${selectedStyle === id ? styles.selected : ''}`}
|
||||
onClick={() => handleStyleSelect(id)}
|
||||
initial={{ opacity: 0, scale: 0.92 }}
|
||||
animate={{
|
||||
opacity: selectedStyle && selectedStyle !== id ? 0.4 : 1,
|
||||
scale: selectedStyle === id ? 1.04 : 1,
|
||||
}}
|
||||
transition={{
|
||||
delay: idx * 0.05,
|
||||
duration: 0.25,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
}}
|
||||
aria-pressed={selectedStyle === id}
|
||||
aria-label={`Style: ${label}`}
|
||||
>
|
||||
<div className={styles.previewImg}>
|
||||
<img src={previewUrl} alt={label} />
|
||||
</div>
|
||||
<span className={styles.presetLabel}>{label}</span>
|
||||
|
||||
{/* Active glow border */}
|
||||
{selectedStyle === id && (
|
||||
<motion.div
|
||||
layoutId="preset-selection"
|
||||
className={styles.selectionRing}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
<section className={styles.sourcePanel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<span className={styles.eyebrow}>Source Room</span>
|
||||
<h3>Reimagine this space</h3>
|
||||
</div>
|
||||
|
||||
{/* Generate button materializes when style selected */}
|
||||
<AnimatePresence>
|
||||
{selectedStyle && phase !== 'result' && (
|
||||
<motion.button
|
||||
className={`btn-primary ${styles.generateBtn}`}
|
||||
onClick={handleGenerate}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 25 }}
|
||||
>
|
||||
Generate
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<span className={health?.online ? styles.healthOk : styles.healthWarn}>{healthLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Generating: shimmer overlay ──────────────────── */}
|
||||
{phase === 'generating' && (
|
||||
<motion.div
|
||||
className={styles.generatingState}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<div className={`${styles.imageFrame} shimmer`} />
|
||||
<motion.p
|
||||
className={styles.generatingText}
|
||||
animate={{ opacity: [0.4, 0.7, 0.4] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
>
|
||||
Reimagining your space…
|
||||
</motion.p>
|
||||
</motion.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} />
|
||||
|
||||
{/* ── Result: cross-fade to generated image ─────────── */}
|
||||
{phase === 'result' && result && (
|
||||
<motion.div
|
||||
className={styles.resultState}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<div className={styles.resultImage}>
|
||||
<img src={result.imageUrl} alt="AI-generated staging" />
|
||||
</div>
|
||||
<div className={styles.resultActions}>
|
||||
<button className="btn-primary" onClick={handleUseThis}>
|
||||
Use This
|
||||
<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>
|
||||
<button className="btn-ghost" onClick={handleTryAnother}>
|
||||
Try Another Style
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Internal: silent polling (hidden from user) ───────────────
|
||||
async function pollForResult(jobId: string): Promise<string> {
|
||||
const MAX_ATTEMPTS = 60;
|
||||
const INTERVAL_MS = 5000;
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
await new Promise(r => setTimeout(r, INTERVAL_MS));
|
||||
const resp = await fetch(`/dream-weaver/status/${jobId}`);
|
||||
const { status, result_url } = await resp.json();
|
||||
if (status === 'complete' && result_url) return result_url;
|
||||
if (status === 'failed') throw new Error('Generation failed');
|
||||
}
|
||||
throw new Error('Generation timed out');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user