Everyone's building AI wrappers. We built an AI product.
There's a running joke in the dev community: every new SaaS is just a ChatGPT wrapper with a $29/month price tag. And honestly? Most of them are.
But when a professional interior designer came to us and said "I spend 4 hours per client creating mood boards and visualizations — can AI do this?" — we couldn't just slap a prompt on a text box and call it a day.
This is the story of building Obrazno, an AI-powered interior design tool that actually replaces a manual creative workflow. Here's what the architecture looks like under the hood.
The Workflow We Had to Replicate
Maria (our client and domain expert) had a very specific process:
- Client sends photos of their room
- Designer creates a mood board from reference images
- Designer manually edits room photos to show proposed changes
- Multiple iterations of "what if we change the sofa?" / "try darker walls"
- Final presentation deck
Steps 2-4 take 4-6 hours per client. That's the bottleneck we attacked.
Architecture Overview
┌──────────────────────────────────────────────┐
│ Turborepo Monorepo │
├──────────────┬──────────────┬────────────────┤
│ apps/web │ apps/api │ packages/ │
│ (Next.js) │ (NestJS 11) │ shared types │
└──────┬───────┴──────┬───────┴────────────────┘
│ │
│ ┌────▼────┐
│ │ BullMQ │──── Redis
│ │ Workers │
│ └────┬────┘
│ │
│ ┌────▼────────┐
│ │ OpenAI API │
│ │ (DALL-E / │
│ │ GPT-4V) │
│ └─────────────┘
We went with a NestJS 11 + Turborepo monorepo setup. The monorepo wasn't vanity — we share TypeScript types between frontend and backend, and the AI pipeline has multiple processing stages that benefit from shared utilities.
The Hard Part: AI In-Painting That Doesn't Look Like Garbage
The naive approach: send a room photo to DALL-E with "replace the sofa with a modern gray sectional." Result? A fever dream that vaguely resembles a room.
The approach that actually works:
Step 1: Mask Editor
We built a browser-based mask editor that lets users paint over the area they want to change:
// Simplified mask editor component
interface MaskEditorProps {
sourceImage: string;
onMaskComplete: (mask: Blob) => void;
}
function MaskEditor({ sourceImage, onMaskComplete }: MaskEditorProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [brushSize, setBrushSize] = useState(20);
const [isDrawing, setIsDrawing] = useState(false);
const handlePointerMove = (e: PointerEvent) => {
if (!isDrawing || !canvasRef.current) return;
const ctx = canvasRef.current.getContext('2d')!;
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'white'; // White = area to replace
ctx.beginPath();
ctx.arc(e.offsetX, e.offsetY, brushSize, 0, Math.PI * 2);
ctx.fill();
};
const exportMask = async () => {
const blob = await new Promise<Blob>((resolve) =>
canvasRef.current!.toBlob((b) => resolve(b!), 'image/png')
);
onMaskComplete(blob);
};
// ... render canvas overlay on source image
}
The mask is a black-and-white PNG: white pixels = "replace this", black = "keep this."
Step 2: Contextual Prompt Engineering
We don't let users write raw prompts. Instead, they select from curated style parameters that we compose into an engineered prompt:
interface DesignParams {
style: 'scandinavian' | 'industrial' | 'minimalist' | 'art-deco' | 'japandi';
element: 'sofa' | 'wall-color' | 'lighting' | 'flooring' | 'full-room';
colorPalette: string[]; // Hex values from moodboard
preserveLayout: boolean;
}
function buildInpaintPrompt(params: DesignParams): string {
const styleGuides: Record<string, string> = {
scandinavian: 'clean lines, natural wood tones, hygge atmosphere, muted colors',
industrial: 'exposed brick, metal fixtures, Edison bulbs, raw textures',
minimalist: 'uncluttered, monochromatic, essential furniture only',
'art-deco': 'geometric patterns, gold accents, luxurious materials, bold symmetry',
japandi: 'wabi-sabi meets Nordic, neutral palette, organic shapes, craft details',
};
return [
`Interior design photograph, professional lighting, 8K quality.`,
`Style: ${styleGuides[params.style]}.`,
`Replace the selected area with: ${params.element}.`,
`Color palette reference: ${params.colorPalette.join(', ')}.`,
params.preserveLayout ? 'Maintain existing room proportions and perspective.' : '',
`Photorealistic result matching the existing room's lighting direction and shadows.`,
].filter(Boolean).join(' ');
}
Step 3: Async Processing with BullMQ
AI image generation takes 10-30 seconds. You can't block an HTTP request for that. We use BullMQ for job orchestration:
// Job producer (API endpoint)
@Post('generate')
async generateDesign(@Body() dto: GenerateDesignDto) {
const job = await this.designQueue.add('inpaint', {
sourceImageUrl: dto.sourceImageUrl,
maskUrl: dto.maskUrl,
prompt: buildInpaintPrompt(dto.params),
userId: dto.userId,
projectId: dto.projectId,
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
});
return { jobId: job.id, status: 'processing' };
}
// Job consumer (worker)
@Processor('design')
export class DesignProcessor {
@Process('inpaint')
async handleInpaint(job: Job<InpaintJobData>) {
const { sourceImageUrl, maskUrl, prompt } = job.data;
// Download source + mask
const [sourceBuffer, maskBuffer] = await Promise.all([
this.storageService.download(sourceImageUrl),
this.storageService.download(maskUrl),
]);
// Call OpenAI
const result = await this.openai.images.edit({
image: sourceBuffer,
mask: maskBuffer,
prompt,
n: 3, // Generate 3 variants
size: '1024x1024',
});
// Store results and notify via WebSocket
const urls = await this.storageService.uploadBatch(result.data);
await this.notificationService.emit(job.data.userId, {
type: 'design-ready',
projectId: job.data.projectId,
variants: urls,
});
}
}
We generate 3 variants per request — letting designers pick the best one is faster than regenerating.
The AI Moodboard Generator
This was the surprise feature that users love most. Feed it a text description of the desired vibe, and it generates a cohesive moodboard:
async function generateMoodboard(description: string): Promise<Moodboard> {
// Step 1: GPT extracts structured style parameters
const styleAnalysis = await this.openai.chat.completions.create({
model: 'gpt-4o',
response_format: { type: 'json_object' },
messages: [{
role: 'system',
content: 'Extract interior design parameters as JSON: colors (hex array), materials, furniture_styles, mood_keywords, lighting_type'
}, {
role: 'user',
content: description
}],
});
const params = JSON.parse(styleAnalysis.choices[0].message.content!);
// Step 2: Generate cohesive reference images
const imagePromises = params.mood_keywords.map((keyword: string) =>
this.openai.images.generate({
prompt: `Interior design moodboard element: ${keyword}, ${params.materials.join(', ')}, color palette ${params.colors.join(' ')}`,
size: '512x512',
quality: 'standard',
})
);
const images = await Promise.all(imagePromises);
// Step 3: Compose into grid layout
return this.composeMoodboard(images, params.colors);
}
Performance: API Response < 200ms (for what we control)
The OpenAI calls take however long they take (usually 10-25s). But everything we control — API routing, database queries, WebSocket notifications — stays under 200ms. Key optimizations:
- Connection pooling for OpenAI client (reuse HTTP/2 connections)
- Redis caching for style templates and recently generated images
- CDN for generated assets — once generated, images serve from edge
- Streaming WebSocket updates — users see progress, not a loading spinner
What Didn't Work
Attempt 1: Fully automated room redesign
We tried feeding a whole room photo and asking AI to redesign everything. Results were hallucinated furniture, broken perspective, and rooms that looked like they existed in a parallel dimension. The mask-based approach was non-negotiable.
Attempt 2: Using GPT-4 Vision for style analysis of existing rooms
Great at describing what it sees, terrible at translating that into actionable design parameters. We ended up using it for inspiration tagging only.
Attempt 3: Letting users write free-form prompts
"Make it look nice" → garbage output. Constrained parameters with curated options produce 10x better results. Domain expertise embedded in the UI beats user creativity every time.
Results
After 3 months in production:
- AI accuracy (designer approval rate): >90% — 9 out of 10 generated variants are usable
- Designer workflow reduced from 4-6 hours to 45 minutes per client
- Client revision cycles dropped by 60%
Takeaways for AI Product Builders
- AI is the engine, not the product. The mask editor, moodboard composer, and style system are 70% of the value. The AI call is one step in a pipeline.
- Constrain inputs ruthlessly. Free-form prompts are a UX anti-pattern for professional tools.
- Async-first architecture. If your AI call takes more than 2 seconds, you need a job queue. BullMQ + WebSockets is a proven pattern.
- Generate variants, not singles. Giving users 3 options to pick from is faster than one option + regenerate loops.
Built by Gerus Lab. We help domain experts turn their expertise into AI-powered products — from architecture to production. See our full portfolio of blockchain, AI, and Telegram-native projects.
Top comments (0)