The problem
An empty apartment photographs badly and sells slowly. Staging one for a shoot costs between €150 and €400 and takes days to schedule, for a listing that might sell in a week.
So most empty flats simply went online empty, and underperformed.
What I built
An internal web form: upload a photo, pick the room and a furnishing style, get back a staged interior in about thirty seconds. Fifty-three nodes behind a shared access code, with a daily quota the manager can change from a dashboard, and a page showing exactly what it has cost this month.
- Four actions (furnish, declutter, empty, upscale), detected from explicit fields, never from free text.
- Thirty furnishing styles, filtered by room, so a kitchen never offers a bedroom style.
- An optional floor plan passed as a second image, so the model respects the real layout.
- A before/after slider, and a copy-image button that puts a PNG straight on the clipboard.
How it works
The quota is reserved before generation, never after.
Under the hood
The model kept redecorating the ceiling
Early renders moved the camera and invented a chandelier. The client wanted their room, furnished. Not a different, nicer room.
FIX A block of hard constraints pinned to the very end of the prompt, because image models weight the end most heavily, plus reframing the whole task as “an in-place photo edit, not a new render”. It is prompt steering, not a guarantee: when a render still drifts, you regenerate.
A random job ID that sometimes said “4K”
The action was detected by scanning the whole request payload for keywords. Job IDs were base-36 random strings, so roughly two percent of them contained 4K or HD, and silently triggered a phantom upscale. A free-text client comment saying “clean and empty” could hijack the action the same way.
FIX Read the explicit action and room fields first. Fall back to scanning only after stripping the job ID, the comments, and internal keys.
Reserve the quota before generating, not after
The obvious design puts the ledger write on a parallel branch. It isn't on the critical path, after all. n8n then runs it last, so the “mark done” update finds no row, and every job stays stuck at pending.
TRADE-OFF Reserving up front means a failed generation still burns a credit. That is the correct trade: it also closes the race where two people spend the last unit at once.
The before/after slider that never moved
The handle dragged, the CSS variable updated, and the image stayed frozen at fifty percent. The cause was one line added weeks earlier: @property --pos { inherits: false }. The variable changed on the parent; the children reading it no longer inherited it.
Every test I had written checked the variable. None checked the rendered pixel.
FIX inherits: true, then a rewrite onto a native <input type="range">. The lesson outlived the bug: assert on what is painted, not on what you set.
Results
Running in production. The staging workflow is the agency's most-used internal tool, and the same pipeline was later repackaged as a product that any agency can self-host, rebuilt generically, with no trace of the original.
Still open. Image quality is judged by a human, not a test. There is no automated check that a render kept the room's geometry; the fallback is the regenerate button.
Stack
- n8n, self-hosted: form, orchestration, quota, cost dashboard
- Gemini image model: furnish, declutter, empty, upscale
- Google Drive (shared drive): renders survive the person who made them
- n8n Data Tables: the job ledger
- Vanilla JS front end, served straight from a webhook
What I took away
Image models weight the end of the prompt. Whatever you care about most goes last, after the client's free-text wishes, not before them.
And the bug that cost me the most hours was not in the AI at all. It was a CSS inheritance flag, hidden behind tests that asserted on the wrong thing.