diff --git a/public/banners/ai-furniture-compositing-with-flux-kontext.png b/public/banners/ai-furniture-compositing-with-flux-kontext.png
new file mode 100644
index 0000000..1151d47
Binary files /dev/null and b/public/banners/ai-furniture-compositing-with-flux-kontext.png differ
diff --git a/public/og/ai-furniture-compositing-with-flux-kontext.png b/public/og/ai-furniture-compositing-with-flux-kontext.png
new file mode 100644
index 0000000..46c45ba
Binary files /dev/null and b/public/og/ai-furniture-compositing-with-flux-kontext.png differ
diff --git a/scripts/banner-gen/generate.mjs b/scripts/banner-gen/generate.mjs
index ec28c41..8f7e1a9 100644
--- a/scripts/banner-gen/generate.mjs
+++ b/scripts/banner-gen/generate.mjs
@@ -267,6 +267,23 @@ const BANNERS = {
{ n: '5', label: 'verify ✓' },
],
},
+
+ 'ai-furniture-compositing-with-flux-kontext': {
+ titlebar: 'client — furniture compositing PoC',
+ lines: [
+ { t: 'prompt', text: '$' }, { t: 'cmd', text: 'POST fal-ai/flux-pro/kontext/multi · 2 photos in' },
+ { t: 'err', text: 'toDataURL: tainted canvas · may not be exported' },
+ { t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = same-origin paths · enhance_prompt:false · name objects' },
+ { t: 'prompt', text: '' }, { t: 'ok', text: '→ 1 staged room out ✓ ($0.04, ~17s)' },
+ ],
+ flow: [
+ { n: '1', label: '2 photos in' },
+ { n: '2', label: 'tainted canvas', err: true },
+ { n: '3', label: 'same-origin fix' },
+ { n: '4', label: 'prompt anchoring' },
+ { n: '5', label: '1 room out ✓' },
+ ],
+ },
};
const DEFAULT_BANNER = {
diff --git a/scripts/og-gen/generate.mjs b/scripts/og-gen/generate.mjs
index cfb2584..81c8f4d 100644
--- a/scripts/og-gen/generate.mjs
+++ b/scripts/og-gen/generate.mjs
@@ -93,6 +93,11 @@ const TERMINALS = {
$git pull --ff-only origin main
refusing: local divergence · never --force
$diff -rq live skills · merge on conflict→ synced ✓
`,
+
+ 'ai-furniture-compositing-with-flux-kontext': `
+ $fetch fal-ai/flux-pro/kontext/multi · 2 photos in
+ toDataURL: tainted canvas · may not be exported
+ $enhance_prompt:false · same-origin paths→ 1 room out ✓
`,
};
const DEFAULT_TERMINAL = `
diff --git a/src/content/posts/ai-furniture-compositing-with-flux-kontext.md b/src/content/posts/ai-furniture-compositing-with-flux-kontext.md
new file mode 100644
index 0000000..1a75e1c
--- /dev/null
+++ b/src/content/posts/ai-furniture-compositing-with-flux-kontext.md
@@ -0,0 +1,262 @@
+---
+title: "How I Built an AI Furniture Compositing Demo With FLUX.1 Kontext"
+description: "Compositing furniture photos into styled room scenes with fal.ai FLUX.1 Kontext — the multi-image endpoint, the tainted-canvas fix, and why prompt enhancement had to go."
+pubDate: 2026-09-11
+category: case-studies
+tags: [fal-ai, flux, ai-image, javascript, canvas, ecommerce]
+ogImage: /og/ai-furniture-compositing-with-flux-kontext.png
+banner: /banners/ai-furniture-compositing-with-flux-kontext.png
+---
+
+A furniture client came to me with a problem that every small e-commerce seller
+eventually hits: their catalogue photos are **isolated product shots** — a
+table on white, a chair on white — but customers don't buy furniture from a
+white void. They buy the *room*. Staging a real photoshoot for every product,
+in every interior style, is out of the question for a one-person business.
+
+The ask: take two product photos (a dining table, a chair) and place them
+together into a believable, high-end interior scene — generated, not
+photographed. This post is the story of the proof-of-concept I built to prove
+that's possible, the model I chose, and the three bugs that tried to eat it.
+
+## Why FLUX.1 Kontext — and the endpoint that matters
+
+The obvious first instinct is a general text-to-image model: *"a dining table
+and a chair in a modern living room."* That fails the moment the client says
+*"no — MY table, MY chair, the exact one on my product page."* Generating a
+lookalike product is worthless; the whole point is to keep the **real product
+unchanged** and only change the room around it.
+
+That's the specific problem **FLUX.1 Kontext** (via [fal.ai](https://fal.ai))
+is built for: image-conditioned generation, where the reference image *anchors*
+the product and the prompt describes the scene. But there's a subtlety that cost
+me an afternoon: there are **two** endpoints.
+
+| Endpoint | Reference input | Use case |
+|---|---|---|
+| `fal-ai/flux-pro/kontext` | `image_url` (single) | edit / re-context one image |
+| `fal-ai/flux-pro/kontext/multi` | `image_urls` (array) | **combine multiple reference images** |
+
+I needed to *fuse two separate products into one scene*, so plain `kontext`
+wasn't enough — it only takes one reference image. The `multi` variant accepts
+an array of reference images and is what makes "table + chair → one room"
+possible. It's flagged experimental, but it's the only game in town for this.
+
+## Calling fal.ai from a plain HTML file — no SDK, no server
+
+For a proof-of-concept I didn't want to stand up a backend. fal.ai exposes a
+plain REST queue API, so a single `index.html` with vanilla JavaScript can do
+the whole job. There are three steps, not one:
+
+```js
+// 1. Submit — returns request_id + polling URLs
+const submit = await fetch(
+ "https://queue.fal.run/fal-ai/flux-pro/kontext/multi",
+ {
+ method: "POST",
+ headers: {
+ Authorization: "Key " + FAL_KEY,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ prompt: "...",
+ image_urls: [tableDataUrl, chairDataUrl],
+ enhance_prompt: false,
+ }),
+ }
+);
+const { request_id, status_url, response_url } = await submit.json();
+
+// 2. Poll status until COMPLETED (takes ~5–30s)
+let status;
+do {
+ await new Promise((r) => setTimeout(r, 2000));
+ status = (await (await fetch(status_url, {
+ headers: { Authorization: "Key " + FAL_KEY },
+ })).json()).status;
+} while (status === "IN_QUEUE" || status === "IN_PROGRESS");
+
+// 3. Fetch the result
+const result = await (await fetch(response_url, {
+ headers: { Authorization: "Key " + FAL_KEY },
+})).json();
+// result.images[0].url is the generated image
+```
+
+Two things I verified early, because they make or break the "single file"
+approach:
+
+1. **CORS is open.** `queue.fal.run` returns a permissive
+ `access-control-allow-origin` and allows the `authorization` header, so a
+ browser can call it directly with no proxy. I confirmed this with a
+ preflight before writing any UI.
+2. **Local images go in as base64 data URIs.** fal.ai accepts `data:` URIs in
+ `image_urls`, so I never had to upload the client's photos to a storage
+ bucket first — the demo reads a file, compresses it, and ships it straight
+ in the request body.
+
+## The canvas compression layer
+
+Phone photos are 5–10 MB. Two of those, base64-encoded, bloats the request to
+unusable size and slows generation. So before submitting, the demo runs each
+image through a `