[object Object]

← back to Marketing Command Center

Add "Make it ours" DW-original creative system to #vendors amplify

6b6da55dd2551ea17bd4b808fcfab834092b11e5 · 2026-08-25 10:21:56 -0700 · Steve

Per-post "✦ Make it ours" button transforms a vendor pattern into a genuinely
DW-original branded asset (never reposts the vendor raw photo). Treatment 1
"Branded card" ($0, client Canvas compositor -> 1080x1080 + 1080x1350, DW wordmark
+ hairline frame + availability strip + pattern name). Treatment 2 "Room setting"
reuses the existing DW room-setting-generator pipeline (:8106, gemini-2.5-flash-image
~$0.03/render, cost shown before generating). Every asset runs the settlement
post-gen-vision gate (canonical checker port, fail-safe -> NEEDS_REVIEW) before save;
BLOCK is not saved. Restricted (never-front-facing) brands get no creative controls.
Assets save to public/amp-assets/ (gitignored + rsync-excluded) and REPLACE the raw
vendor photo in the staged draft (draft.dwAsset). No IG/Meta publish call anywhere;
engine-queue/channels-outbox/meta-pages untouched. Same-origin img-proxy (allowlisted)
lets the Canvas compositor read CORS-tainted IG images.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 6b6da55dd2551ea17bd4b808fcfab834092b11e5
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Aug 25 10:21:56 2026 -0700

    Add "Make it ours" DW-original creative system to #vendors amplify
    
    Per-post "✦ Make it ours" button transforms a vendor pattern into a genuinely
    DW-original branded asset (never reposts the vendor raw photo). Treatment 1
    "Branded card" ($0, client Canvas compositor -> 1080x1080 + 1080x1350, DW wordmark
    + hairline frame + availability strip + pattern name). Treatment 2 "Room setting"
    reuses the existing DW room-setting-generator pipeline (:8106, gemini-2.5-flash-image
    ~$0.03/render, cost shown before generating). Every asset runs the settlement
    post-gen-vision gate (canonical checker port, fail-safe -> NEEDS_REVIEW) before save;
    BLOCK is not saved. Restricted (never-front-facing) brands get no creative controls.
    Assets save to public/amp-assets/ (gitignored + rsync-excluded) and REPLACE the raw
    vendor photo in the staged draft (draft.dwAsset). No IG/Meta publish call anywhere;
    engine-queue/channels-outbox/meta-pages untouched. Same-origin img-proxy (allowlisted)
    lets the Canvas compositor read CORS-tainted IG images.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .deploy.conf               |   5 +-
 .gitignore                 |   9 ++
 lib/room-render.js         |  66 +++++++++++
 lib/settlement-gate.js     | 122 ++++++++++++++++++++
 public/amp-assets/.gitkeep |   0
 public/panels/vendors.js   | 279 ++++++++++++++++++++++++++++++++++++++++++++-
 server.js                  | 220 +++++++++++++++++++++++++++++++++++
 7 files changed, 694 insertions(+), 7 deletions(-)

diff --git a/.deploy.conf b/.deploy.conf
index 464768b..a6ec6d9 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -23,4 +23,7 @@ HEALTH_URL=http://127.0.0.1:9662/api/health
 # the deploy-side guard regardless.)
 # linkedin-feed.json is the openclaw harvest output (TK-10504) — generated on the
 # remote by the running MCC; a stale/absent local copy must never clobber it.
-RSYNC_EXTRA_EXCLUDES="quickpost-drafts.json vendor-amplify-drafts.json follow-counts-history.json follow-counts-accounts.json follow-counts-snapshot.out follow-counts-snapshot.err clients-notes.json channels-outbox.json channels-tokens.json meta-pages.json assets.json assets-catalog.cache.json engine-queue.json engine-config.json linkedin-feed.json linkedin-feed-curated.json ig-activity-tombstones.json renders"
+# amp-assets = the #vendors "Make it ours" DW-original branded-card + room-render
+# imagery, generated on whatever box the MCC runs; a deploy must never clobber the
+# remote's generated assets (TK-10842).
+RSYNC_EXTRA_EXCLUDES="quickpost-drafts.json vendor-amplify-drafts.json vendor-amplify-assets.json follow-counts-history.json follow-counts-accounts.json follow-counts-snapshot.out follow-counts-snapshot.err clients-notes.json channels-outbox.json channels-tokens.json meta-pages.json assets.json assets-catalog.cache.json engine-queue.json engine-config.json linkedin-feed.json linkedin-feed-curated.json ig-activity-tombstones.json renders amp-assets"
diff --git a/.gitignore b/.gitignore
index e152803..eb7ab0f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -69,3 +69,12 @@ videos/**/renders/
 videos/**/.media/
 videos/**/capture/
 data/linkedin-feed-curated.json
+
+# Vendor-amplify "Make it ours" generated DW-original assets — created on
+# whatever box the server runs; deploys ship code, not generated imagery, so a
+# deploy must never clobber remote-generated assets (also in .deploy.conf
+# RSYNC_EXTRA_EXCLUDES). Keep the dir tracked via .gitkeep, ignore its contents.
+public/amp-assets/*
+!public/amp-assets/.gitkeep
+data/vendor-amplify-assets/
+data/vendor-amplify-assets.json
diff --git a/lib/room-render.js b/lib/room-render.js
new file mode 100644
index 0000000..c7d2a55
--- /dev/null
+++ b/lib/room-render.js
@@ -0,0 +1,66 @@
+'use strict';
+// Thin client for the EXISTING DW room-setting-generator pipeline (Treatment 2).
+//
+// REUSE — this does NOT reinvent a renderer. It calls the canonical DW Room Setting
+// App documented in ~/.claude/skills/room-setting-generator/SKILL.md:
+//   POST http://127.0.0.1:8106/api/generate-room
+//   body: { patternBase64, roomType, angle, cameraDistance, patternWidth, patternHeight }
+//   -> { success:true, image:<base64 jpeg> }  (engine = gemini-2.5-flash-image)
+//
+// COST: the room app bills ~$0.01–$0.04 per render (Gemini 2.5 flash image). The
+// caller surfaces + logs that. This module returns the render bytes; the server
+// endpoint then runs the settlement post-gen-vision gate before saving/showing.
+
+const ROOM_APP = process.env.ROOM_APP_URL || 'http://127.0.0.1:8106';
+const GEN_ROOM_URL = ROOM_APP + '/api/generate-room';
+// Ballpark per-render cost for the cost line (Gemini 2.5 flash image, per skill doc).
+const ROOM_RENDER_COST = 0.03;
+
+// Is the room app reachable? (fast HEAD/GET so the UI can show a helpful message
+// instead of hanging when the pipeline app isn't running.)
+async function roomAppUp() {
+  try {
+    const c = new AbortController();
+    const t = setTimeout(() => c.abort(), 2500);
+    const r = await fetch(ROOM_APP + '/', { signal: c.signal });
+    clearTimeout(t);
+    return r.ok || r.status === 401 || r.status === 200;
+  } catch { return false; }
+}
+
+// Generate one room setting from a base64 pattern.
+// @returns {Promise<{ok:boolean, buffer?:Buffer, mime?:string, cost:number, error?:string}>}
+async function generateRoom(patternBase64, opts = {}) {
+  const body = {
+    patternBase64: String(patternBase64 || ''),
+    roomType: opts.roomType || 'living_room',
+    angle: opts.angle || 'straight_on',
+    cameraDistance: Number(opts.cameraDistance) || 8,
+    patternWidth: Number(opts.patternWidth) || 27,
+    patternHeight: Number(opts.patternHeight) || 27,
+  };
+  if (!body.patternBase64) return { ok: false, cost: 0, error: 'no pattern image' };
+  let json;
+  try {
+    const c = new AbortController();
+    const t = setTimeout(() => c.abort(), 120000);   // renders can take ~30-60s
+    const res = await fetch(GEN_ROOM_URL, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body),
+      signal: c.signal,
+    });
+    clearTimeout(t);
+    json = await res.json();
+  } catch (e) {
+    return { ok: false, cost: 0, error: 'room app: ' + String((e && e.message) || e).slice(0, 120) };
+  }
+  if (!json || !json.success || !json.image) {
+    return { ok: false, cost: 0, error: (json && json.error) ? String(json.error).slice(0, 160) : 'render failed' };
+  }
+  // The render happened -> the app billed for it, even if we later reject on the
+  // settlement gate. So the cost is real once success:true comes back.
+  return { ok: true, buffer: Buffer.from(json.image, 'base64'), mime: 'image/jpeg', cost: ROOM_RENDER_COST };
+}
+
+module.exports = { generateRoom, roomAppUp, ROOM_RENDER_COST, ROOM_APP };
diff --git a/lib/settlement-gate.js b/lib/settlement-gate.js
new file mode 100644
index 0000000..8cead8a
--- /dev/null
+++ b/lib/settlement-gate.js
@@ -0,0 +1,122 @@
+'use strict';
+// Server-side Settlement gate for the #vendors "Make it ours" creative system.
+//
+// This is a faithful JS port of the CANONICAL image-side settlement checker
+//   ~/Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/lib/settlement-checker.ts
+//   (checkSettlementCompliance) — the reference implementation the
+//   `settlement-post-gen-vision` skill defers to. We do NOT reinvent the rule;
+//   we run the SAME Gemini-vision post-gen check the skill prescribes, so any
+//   AI-generated OR composited DW-original asset is verified against the executed
+//   Settlement Agreement (Part A all-three AND Part B, no acceptable carve-out)
+//   BEFORE it can be staged/shown as final.
+//
+// HARD RULES honored from settlement-post-gen-vision/SKILL.md:
+//   - Gemini vision is GROUND TRUTH (text anti-prompts don't bind a generator).
+//   - FAIL-SAFE: any API/parse/fetch error returns NEEDS_REVIEW, never an auto-pass.
+//   - Verdict vocabulary is BLOCK / NEEDS_REVIEW / OK.
+//
+// COST: one Gemini vision call per gate (~$0.0006/image, gemini flash). The caller
+// logs it via the cost-tracker skill. A plain branded-card composite (Treatment 1)
+// is not AI-generated, but we still post-gen-vision-check it per the task spec
+// ("run it past settlement post-gen-vision to be safe").
+
+const GEMINI_KEY_NAMES = ['GEMINI_API_KEY', 'GOOGLE_API_KEY'];
+function geminiKey() {
+  for (const n of GEMINI_KEY_NAMES) if (process.env[n]) return process.env[n];
+  // Best-effort: read the secrets-manager master .env so the MCC doesn't need the
+  // key duplicated into its own .env (read-only, last-4 never logged).
+  try {
+    const fs = require('fs');
+    const os = require('os');
+    const path = require('path');
+    const p = path.join(os.homedir(), 'Projects', 'secrets-manager', '.env');
+    const s = fs.readFileSync(p, 'utf8');
+    for (const n of GEMINI_KEY_NAMES) {
+      const m = s.match(new RegExp('^' + n + '=(.*)$', 'm'));
+      if (m) return m[1].replace(/^["']|["']$/g, '').trim();
+    }
+  } catch { /* no secrets file — gate will fail-safe to NEEDS_REVIEW */ }
+  return '';
+}
+
+// Run the post-gen vision gate on a raw image buffer.
+// @param {Buffer} buf   the produced image bytes (branded card PNG, or room JPEG)
+// @param {string} mime  image mime (image/png | image/jpeg)
+// @param {string} title short label for the prompt (vendor/pattern name)
+// @returns {Promise<{verdict:'OK'|'NEEDS_REVIEW'|'BLOCK', reason:string, cost:number, detail:object}>}
+async function settlementGateBuffer(buf, mime, title) {
+  const failSafe = (why) => ({
+    verdict: 'NEEDS_REVIEW',
+    reason: `Vision check unavailable (${why}) — flagged for review rather than auto-passed.`,
+    cost: 0,
+    detail: { failSafe: true },
+  });
+
+  const key = geminiKey();
+  if (!key) return failSafe('GEMINI_API_KEY not set');
+  if (!Buffer.isBuffer(buf) || !buf.length) return failSafe('empty image buffer');
+
+  const b64 = buf.toString('base64');
+  const prompt = `You are performing a strict LEGAL compliance check on a wallcovering design image for "${String(title || '').slice(0, 120)}".
+Answer ONLY with valid JSON, no markdown:
+{
+  "a1_directional_leaves": <true if the design shows repeating patterns with DIRECTIONAL VARIATION amongst leaves, palm fronds, or similar foliage>,
+  "a2_open_space": <true if there is visible OPEN/NEGATIVE SPACE between the leaves (not edge-to-edge foliage coverage)>,
+  "a3_multiple_colors": <true if the leaf/foliage layer uses MORE THAN ONE ink color (ignore the background color)>,
+  "b_prohibited_elements": <true if the design contains ANY of: bananas, banana pods, grapes, birds, or butterflies>,
+  "b_elements_found": [<list which of banana/banana pod/grape/bird/butterfly appear, empty if none>],
+  "acceptable_carveouts": <true if the design clearly shows tree trunks, OR clearly represented branches, OR fruit/animal elements OTHER than bananas/grapes/birds/butterflies>,
+  "is_tropical_foliage_design": <true if this is a tropical/botanical foliage design at all>,
+  "visual_summary": "<one sentence describing what the design depicts>"
+}
+Be precise and conservative: only answer true when the visual evidence is clear.`;
+
+  let json;
+  try {
+    const res = await fetch(
+      `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${key}`,
+      {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          contents: [{ role: 'user', parts: [{ inline_data: { mime_type: mime || 'image/png', data: b64 } }, { text: prompt }] }],
+          generationConfig: { maxOutputTokens: 800, thinkingConfig: { thinkingBudget: 0 } },
+        }),
+      }
+    );
+    json = await res.json();
+  } catch (e) {
+    return failSafe('fetch ' + String((e && e.message) || e).slice(0, 100));
+  }
+  if (!json || json.error) return failSafe('Gemini: ' + String((json && json.error && json.error.message) || 'no response').slice(0, 120));
+
+  const text = (((json.candidates || [])[0] || {}).content || {}).parts
+    ? json.candidates[0].content.parts.map((p) => p.text || '').join('')
+    : '';
+  let v;
+  try {
+    v = JSON.parse(String(text).replace(/```json\n?|```\n?/g, '').trim());
+  } catch {
+    return failSafe('unparseable vision verdict');
+  }
+
+  // Defendant-favorable combination (identical to the canonical checker):
+  // VIOLATION requires FULL Part A (all three) AND Part B, no acceptable carve-out.
+  const partA = !!(v.a1_directional_leaves && v.a2_open_space && v.a3_multiple_colors);
+  const partB = !!v.b_prohibited_elements;
+  const carveout = !!v.acceptable_carveouts;
+  const violates = partA && partB && !carveout;
+  const needsReview = !violates && ((partA && !carveout) || (partB && !!v.is_tropical_foliage_design));
+
+  const reason =
+    `Gemini vision: A1=${!!v.a1_directional_leaves}, A2=${!!v.a2_open_space}, A3=${!!v.a3_multiple_colors} ` +
+    `(Part A ${partA ? 'MET' : 'not met'}); Part B=${partB}` +
+    `${(v.b_elements_found || []).length ? ` [${(v.b_elements_found || []).join(', ')}]` : ''}; ` +
+    `carve-out=${carveout}. ${v.visual_summary || ''}`.trim();
+
+  const verdict = violates ? 'BLOCK' : (needsReview ? 'NEEDS_REVIEW' : 'OK');
+  // ~$0.0006/image is the Gemini flash vision ballpark (per Steve's cost rule).
+  return { verdict, reason, cost: 0.0006, detail: { partA, partB, carveout, raw: v } };
+}
+
+module.exports = { settlementGateBuffer, geminiKey };
diff --git a/public/amp-assets/.gitkeep b/public/amp-assets/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/public/panels/vendors.js b/public/panels/vendors.js
index aee619f..f641d0f 100644
--- a/public/panels/vendors.js
+++ b/public/panels/vendors.js
@@ -239,6 +239,7 @@ window.MCC_PANELS['vendors'] = {
             : `<div class="amp-row">
             <button type="button" class="amp-btn amp-from" data-amp="from" data-amp-id="${id}" aria-haspopup="true" aria-expanded="false" title="Choose which owned DW accounts this post is for">Post from ▾${selCount ? ` <span class="amp-count">· ${selCount}</span>` : ''}</button>
             <button type="button" class="amp-btn amp-uniq" data-amp="unique" data-amp-id="${id}" title="Generate a unique caption per selected account (same photo, different text)">✦ Make unique</button>
+            <button type="button" class="amp-btn amp-ours" data-amp="ours" data-amp-id="${id}" title="Transform this pattern into a DW-ORIGINAL branded asset (never reposts the vendor's raw photo)">✦ Make it ours</button>
             <button type="button" class="amp-btn" data-amp="x" data-amp-id="${id}" title="Open X composer with a DW visit link">𝕏 Post</button>
             <button type="button" class="amp-btn" data-amp="copy" data-amp-id="${id}" title="Copy a paste-ready caption kit">⧉ Copy kit</button>
             <button type="button" class="amp-btn amp-strong" data-amp="amplify" data-amp-id="${id}" title="Copy the kit AND open the X composer">⚡ Amplify</button>
@@ -247,7 +248,10 @@ window.MCC_PANELS['vendors'] = {
           // handler can find + (re)fill it. Rebuilt every render from stored state.
           // Suppressed entirely for restricted (never-front-facing) brands.
           const review = restricted ? '' : `<div class="amp-review" data-amp-review="${id}" hidden></div>`;
-          return `<div class="ig-item" data-post-id="${esc(postId)}">${anchor}${ampRow}${review}</div>`;
+          // Creative review area ("Make it ours") — empty until the button is clicked;
+          // also suppressed for restricted brands (they get no creative controls).
+          const creative = restricted ? '' : `<div class="amp-creative" data-amp-creative="${id}" hidden></div>`;
+          return `<div class="ig-item" data-post-id="${esc(postId)}">${anchor}${ampRow}${review}${creative}</div>`;
         }).join('');
         return `<div class="ig-list">${items}</div>`;
       }
@@ -389,6 +393,166 @@ window.MCC_PANELS['vendors'] = {
         <div class="amp-review-note muted">Staging saves a draft for review — it does <b>not</b> publish. Posting to these accounts is Steve-gated.</div>`;
     };
 
+    // ── "Make it ours" creative image system (TK-10842) ─────────────────────────
+    // Transforms the vendor's pattern image (p.image, carried on the kit) into a
+    // GENUINELY DW-ORIGINAL branded asset DW can post as its own creative — so
+    // amplifying a vendor post NEVER reposts the vendor's raw copyrighted photo.
+    //
+    // Treatment 1 "Branded card" ($0 local): composites the pattern onto a DW-branded
+    //   canvas (wordmark + hairline frame + availability strip + pattern/vendor name)
+    //   with the browser Canvas API, at 1080x1080 AND 1080x1350. Transformative brand
+    //   creative, no external API. The finished PNG is POSTed to /api/vendor-amplify-assets,
+    //   which runs it past the settlement post-gen-vision gate and saves it.
+    // Treatment 2 "Room setting" (small $): POSTs the pattern to /api/vendor-amplify-room,
+    //   which calls the EXISTING DW room-setting-generator pipeline (:8106) and
+    //   settlement-gates the render. Cost is shown BEFORE the user generates.
+
+    // Load an image element from a URL, CORS-safe for canvas readback. IG/fbcdn
+    // images taint the canvas, so we route through the same-origin /api/img-proxy.
+    const loadPatternImage = (src) => new Promise((resolve, reject) => {
+      if (!src) return reject(new Error('no pattern image on this post'));
+      const isCrossCdn = /cdninstagram\.com|fbcdn\.net|instagram\.com|shopify/i.test(src);
+      const url = isCrossCdn ? (location.origin + '/api/img-proxy?u=' + encodeURIComponent(src)) : src;
+      const img = new Image();
+      img.crossOrigin = 'anonymous';
+      img.onload = () => resolve(img);
+      img.onerror = () => {
+        // last-ditch fallback: try the proxy even for non-CDN hosts
+        if (!isCrossCdn) {
+          const img2 = new Image();
+          img2.crossOrigin = 'anonymous';
+          img2.onload = () => resolve(img2);
+          img2.onerror = () => reject(new Error('pattern image failed to load'));
+          img2.src = location.origin + '/api/img-proxy?u=' + encodeURIComponent(src);
+        } else reject(new Error('pattern image failed to load'));
+      };
+      img.src = url;
+    });
+
+    // Compose a DW-branded card from a loaded pattern image at W×H. Returns a
+    // dataURL (PNG). This is the transformative brand creative — the vendor's photo
+    // becomes a cropped texture field inside DW-owned chrome (frame + wordmark +
+    // availability strip + pattern name), NOT a reposted photo.
+    const composeBrandedCard = (img, W, H, opts) => {
+      const brand = (opts && opts.brand) || '';
+      const label = (opts && opts.label) || '';
+      const cv = document.createElement('canvas');
+      cv.width = W; cv.height = H;
+      const ctx = cv.getContext('2d');
+      // Warm DW paper ground.
+      ctx.fillStyle = '#f7f2e8'; ctx.fillRect(0, 0, W, H);
+      // Inset the pattern into a framed field; reserve a bottom strip for the brand line.
+      const pad = Math.round(W * 0.055);
+      const stripH = Math.round(H * 0.135);
+      const fx = pad, fy = pad, fw = W - pad * 2, fh = H - pad * 2 - stripH;
+      // Cover-fit the pattern into the field (center-crop).
+      const ir = img.width / img.height, fr = fw / fh;
+      let sw = img.width, sh = img.height, sx = 0, sy = 0;
+      if (ir > fr) { sw = Math.round(img.height * fr); sx = Math.round((img.width - sw) / 2); }
+      else { sh = Math.round(img.width / fr); sy = Math.round((img.height - sh) / 2); }
+      ctx.save();
+      ctx.beginPath(); ctx.rect(fx, fy, fw, fh); ctx.clip();
+      ctx.drawImage(img, sx, sy, sw, sh, fx, fy, fw, fh);
+      ctx.restore();
+      // Hairline frame around the pattern field.
+      ctx.strokeStyle = '#3a3226'; ctx.lineWidth = Math.max(1.5, W * 0.0016);
+      ctx.strokeRect(fx + 0.5, fy + 0.5, fw - 1, fh - 1);
+      // Thin inner hairline (editorial double-rule).
+      const g = Math.round(W * 0.012);
+      ctx.strokeStyle = 'rgba(58,50,38,0.35)'; ctx.lineWidth = 1;
+      ctx.strokeRect(fx + g + 0.5, fy + g + 0.5, fw - g * 2 - 1, fh - g * 2 - 1);
+      // DW wordmark (top-left, over the paper margin so it never fights the photo).
+      ctx.fillStyle = '#2a2318';
+      ctx.textBaseline = 'alphabetic';
+      const wm = Math.round(W * 0.030);
+      ctx.font = `600 ${wm}px Georgia, "Times New Roman", serif`;
+      ctx.fillText('DESIGNER WALLCOVERINGS', pad, Math.round(pad * 0.72));
+      // Pattern / vendor name in the strip.
+      const cx = W / 2, sTop = H - pad - stripH;
+      ctx.textAlign = 'center';
+      const nm = (label || brand || 'Featured pattern').replace(/\s+/g, ' ').trim().slice(0, 48);
+      ctx.fillStyle = '#2a2318';
+      const nmSize = Math.round(W * 0.040);
+      ctx.font = `500 ${nmSize}px Georgia, "Times New Roman", serif`;
+      ctx.fillText(nm, cx, sTop + Math.round(stripH * 0.40));
+      // Availability strip.
+      ctx.fillStyle = '#6b5a3a';
+      const avSize = Math.round(W * 0.0225);
+      ctx.font = `500 ${avSize}px Georgia, "Times New Roman", serif`;
+      ctx.fillText('Available at Designer Wallcoverings · designerwallcoverings.com', cx, sTop + Math.round(stripH * 0.78));
+      ctx.textAlign = 'left';
+      return cv.toDataURL('image/png');
+    };
+
+    // Compose both feed sizes from one loaded pattern. Returns { '1080x1080', '1080x1350' }.
+    const composeBoth = (img, brand, label) => ({
+      '1080': composeBrandedCard(img, 1080, 1080, { brand, label }),
+      '1350': composeBrandedCard(img, 1080, 1350, { brand, label }),
+    });
+
+    // POST a data-URL asset to the server (settlement-gated save). Optionally attach
+    // to a staged draft (replacing the raw vendor photo). Returns the JSON response.
+    const stageBrandedCard = async (kit, dataUrl, variant, attachToDraftId) => {
+      const res = await fetch(location.origin + '/api/vendor-amplify-assets', {
+        method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
+        body: JSON.stringify({ vendor: kit.brand, treatment: 'branded-card', image: dataUrl, variant, attachToDraftId }),
+      });
+      return res.json();
+    };
+
+    // Find the newest staged draft id for this post's image (so "Use this in the draft"
+    // attaches to an existing Make-unique staged draft when one exists). Best-effort.
+    const findDraftIdForKit = async (kit) => {
+      try {
+        const j = await (await fetch(location.origin + '/api/vendor-amplify-drafts', { credentials: 'same-origin' })).json();
+        const list = (j && j.drafts) || [];
+        const hit = list.find(d => d && (d.permalink === kit.permalink || d.postImage === kit.image || d.originalPostImage === kit.image) && d.vendor === kit.brand);
+        return hit ? hit.id : null;
+      } catch { return null; }
+    };
+
+    // Render the creative review area for one post: branded-card generator (default,
+    // $0) + room-setting generator (shows cost before generating). Rebuilt on demand.
+    const creativeState = {};   // ampId -> { cards:{1080,1350}, saved:[], runningTotal }
+    const renderCreative = (ampId) => {
+      const kit = ampKits[ampId]; if (!kit) return;
+      const host = root.querySelector(`[data-amp-creative="${ampId}"]`);
+      if (!host) return;
+      host.hidden = false;
+      const st = creativeState[ampId] || (creativeState[ampId] = { runningTotal: 0 });
+      const cardHtml = st.cards
+        ? `<div class="amp-cre-cards">
+            <figure class="amp-cre-fig"><img src="${st.cards['1080']}" alt="branded card 1080×1080"><figcaption>Feed · 1080×1080</figcaption>
+              <button type="button" class="amp-mini" data-cre="use" data-var="1080" title="Save + attach this to the draft (replaces the raw vendor photo)">Use this in the draft</button></figure>
+            <figure class="amp-cre-fig"><img src="${st.cards['1350']}" alt="branded card 1080×1350"><figcaption>Portrait · 1080×1350</figcaption>
+              <button type="button" class="amp-mini" data-cre="use" data-var="1350" title="Save + attach this to the draft (replaces the raw vendor photo)">Use this in the draft</button></figure>
+          </div>`
+        : `<div class="amp-cre-empty">Generating branded card…</div>`;
+      host.innerHTML = `
+        <div class="amp-cre-hd"><b>✦ Make it ours</b> <span class="muted">— a DW-original asset (never reposts the vendor's photo)</span></div>
+        <div class="amp-cre-block">
+          <div class="amp-cre-row"><span class="amp-cre-t">1 · Branded card</span><span class="amp-cost">$0 (local)</span>
+            <button type="button" class="amp-mini" data-cre="regen-card" title="Re-composite the branded card">↻ Regenerate</button></div>
+          ${cardHtml}
+        </div>
+        <div class="amp-cre-block">
+          <div class="amp-cre-row"><span class="amp-cre-t">2 · Room setting</span><span class="amp-cost amp-cost-paid">~$0.03 / render (Gemini 2.5 flash image)</span>
+            <button type="button" class="amp-btn amp-strong" data-cre="room" title="Generate an ORIGINAL DW room mockup with this pattern on the wall (small cost)">Generate room render</button></div>
+          <div class="amp-cre-room" data-cre-room="${ampId}"></div>
+        </div>
+        <div class="amp-cre-note muted">Settlement-gated before save · nothing auto-posts · posting is Steve-gated.${st.runningTotal ? ` <b>Session render spend: $${st.runningTotal.toFixed(2)}</b>` : ''}</div>`;
+      // Kick off the $0 branded-card composite if not done yet.
+      if (!st.cards) {
+        loadPatternImage(kit.image).then(img => {
+          st.cards = composeBoth(img, kit.brand, kit.brand);
+          renderCreative(ampId);
+        }).catch(err => {
+          const h = root.querySelector(`[data-amp-creative="${ampId}"] .amp-cre-empty`);
+          if (h) h.innerHTML = `<span style="color:#a1341f">Couldn't build the branded card: ${esc(err.message)}</span>`;
+        });
+      }
+    };
+
     // Close the popover when a re-render is about to replace the DOM under it
     // (codex trap: a body-appended popover would otherwise orphan). render() calls
     // this at its top.
@@ -409,6 +573,7 @@ window.MCC_PANELS['vendors'] = {
         const action = btn.dataset.amp;
         if (action === 'from') { openFromPopover(btn); return; }
         if (action === 'unique') { renderReview(btn.dataset.ampId); return; }
+        if (action === 'ours') { renderCreative(btn.dataset.ampId); return; }
         if (action === 'x') {
           window.open(kit.xUrl, '_blank', 'noopener,noreferrer');
           return;
@@ -532,6 +697,90 @@ window.MCC_PANELS['vendors'] = {
             return;
           }
         }
+
+        // — creative "Make it ours" area interactions —
+        const creBtn = e.target.closest('[data-cre]');
+        if (creBtn) {
+          const creEl = creBtn.closest('.amp-creative');
+          if (!creEl) return;
+          const ampId = creEl.getAttribute('data-amp-creative');
+          const kit = ampKits[ampId]; if (!kit) return;
+          const st = creativeState[ampId] || (creativeState[ampId] = { runningTotal: 0 });
+          const kind = creBtn.dataset.cre;
+
+          if (kind === 'regen-card') {
+            st.cards = null;
+            renderCreative(ampId);
+            return;
+          }
+
+          if (kind === 'use') {
+            if (!st.cards) return;
+            const variant = creBtn.dataset.var === '1350' ? '1350' : '1080';
+            const label = creBtn.textContent; creBtn.disabled = true; creBtn.textContent = 'Saving…';
+            try {
+              // Attach to an existing staged draft for this post if one exists.
+              const draftId = await findDraftIdForKit(kit);
+              const res = await stageBrandedCard(kit, st.cards[variant], variant, draftId || undefined);
+              if (res && res.blocked) {
+                creBtn.textContent = '⚠ Blocked (settlement)';
+                alert('Settlement gate BLOCKED this asset — not saved.\n\n' + (res.reason || ''));
+              } else if (res && res.ok) {
+                const attachMsg = res.attached ? ' + attached to draft' : (draftId ? ' (attach failed)' : ' (no draft yet — staged asset)');
+                const rv = res.verdict && res.verdict !== 'OK' ? ` · ${res.verdict}` : '';
+                creBtn.textContent = `✓ Saved${attachMsg}${rv}`;
+              } else {
+                creBtn.textContent = '⚠ ' + ((res && res.error) || 'failed');
+              }
+            } catch (err) {
+              creBtn.textContent = '⚠ ' + err.message;
+            }
+            setTimeout(() => { creBtn.textContent = label; creBtn.disabled = false; }, 4000);
+            return;
+          }
+
+          if (kind === 'room') {
+            const roomHost = creEl.querySelector(`[data-cre-room="${ampId}"]`);
+            const label = creBtn.textContent; creBtn.disabled = true; creBtn.textContent = 'Rendering… (~30–60s)';
+            if (roomHost) roomHost.innerHTML = '<div class="amp-cre-empty">Rendering an original DW room mockup…</div>';
+            try {
+              // Route the pattern through the same-origin proxy → dataURL so the
+              // server gets a clean base64 (never a tainted / CORS-blocked read).
+              const img = await loadPatternImage(kit.image);
+              const cv = document.createElement('canvas');
+              const MAX = 1024;
+              const scale = Math.min(1, MAX / Math.max(img.width, img.height));
+              cv.width = Math.round(img.width * scale); cv.height = Math.round(img.height * scale);
+              cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height);
+              const patternBase64 = cv.toDataURL('image/jpeg', 0.9).replace(/^data:[^,]*,/, '');
+              const draftId = await findDraftIdForKit(kit);
+              const res = await (await fetch(location.origin + '/api/vendor-amplify-room', {
+                method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
+                body: JSON.stringify({ vendor: kit.brand, patternBase64, roomType: 'living_room', attachToDraftId: draftId || undefined }),
+              })).json();
+              if (res && res.cost) { st.runningTotal += res.cost; }
+              if (res && res.blocked) {
+                if (roomHost) roomHost.innerHTML = `<div class="amp-cre-empty" style="color:#a1341f">Settlement gate BLOCKED this render — not saved. ${esc(res.reason || '')}</div><div class="amp-cost amp-cost-paid">${esc(res.costLabel || '')}</div>`;
+              } else if (res && res.ok) {
+                const rv = res.verdict && res.verdict !== 'OK' ? ` · <b>${esc(res.verdict)}</b>` : '';
+                const att = res.attached ? ' + attached to draft' : (draftId ? '' : '');
+                if (roomHost) roomHost.innerHTML = `
+                  <figure class="amp-cre-fig"><img src="${esc(res.asset.url)}" alt="DW room setting">
+                    <figcaption>Room · ${esc(res.asset.variant)}${rv}${att}</figcaption></figure>
+                  <div class="amp-cost amp-cost-paid">${esc(res.costLabel || '')}${st.runningTotal ? ` · session total $${st.runningTotal.toFixed(2)}` : ''}</div>`;
+              } else {
+                if (roomHost) roomHost.innerHTML = `<div class="amp-cre-empty" style="color:#a1341f">${esc((res && res.error) || 'render failed')}</div>`;
+              }
+              // Refresh the note line's running-total.
+              const note = creEl.querySelector('.amp-cre-note');
+              if (note && st.runningTotal) note.innerHTML = note.innerHTML.replace(/(<b>Session render spend:.*?<\/b>)?$/, `<b>Session render spend: $${st.runningTotal.toFixed(2)}</b>`);
+            } catch (err) {
+              if (roomHost) roomHost.innerHTML = `<div class="amp-cre-empty" style="color:#a1341f">${esc(err.message)}</div>`;
+            }
+            creBtn.textContent = label; creBtn.disabled = false;
+            return;
+          }
+        }
       });
       // Live-filter the popover list as the user types.
       document.addEventListener('input', (e) => {
@@ -622,10 +871,10 @@ window.MCC_PANELS['vendors'] = {
     }
 
     // Inline last-3 listing styling (idempotent; id bumped so it re-injects over old grid CSS)
-    if (!document.getElementById('vend-ig-css3')) {
-      // remove the prior version so the bumped stylesheet fully supersedes it
-      const old = document.getElementById('vend-ig-css2'); if (old) old.remove();
-      const st = document.createElement('style'); st.id = 'vend-ig-css3';
+    if (!document.getElementById('vend-ig-css4')) {
+      // remove the prior versions so the bumped stylesheet fully supersedes them
+      ['vend-ig-css2', 'vend-ig-css3'].forEach(oid => { const o = document.getElementById(oid); if (o) o.remove(); });
+      const st = document.createElement('style'); st.id = 'vend-ig-css4';
       st.textContent = `
       .ig-list{display:flex;flex-direction:column;gap:4px;margin-top:8px}
       .ig-li{display:flex;gap:9px;align-items:center;padding:5px 7px;border:1px solid var(--line);border-radius:8px;background:#fbf9f4;text-decoration:none;color:inherit}
@@ -673,7 +922,25 @@ window.MCC_PANELS['vendors'] = {
       .amp-mini{font-size:11px;line-height:1;padding:3px 7px;border:1px solid var(--line,#e7e0cf);border-radius:6px;background:transparent;color:var(--muted,#8a8372);cursor:pointer}
       .amp-mini:hover{background:#f3eee2;color:inherit}
       .amp-ta{width:100%;box-sizing:border-box;font-size:11.5px;line-height:1.4;font-family:inherit;padding:6px;border:1px solid var(--line,#e7e0cf);border-radius:6px;background:#fffdf8;resize:vertical}
-      .amp-review-note{font-size:10.5px;margin-top:6px}`;
+      .amp-review-note{font-size:10.5px;margin-top:6px}
+      /* Make-it-ours creative review area */
+      .amp-btn.amp-ours{opacity:.8;border-color:#c9b98f;color:#8a6d2f}
+      .amp-btn.amp-ours:hover{background:#f4ecd6;color:#6b531f}
+      .amp-creative{margin:2px 2px 8px;border:1px solid #d8cfb8;border-radius:9px;background:#fdfbf5;padding:9px}
+      .amp-cre-hd{font-size:12.5px;margin-bottom:8px}
+      .amp-cre-block{border:1px solid var(--line,#e7e0cf);border-radius:8px;background:#fff;padding:8px;margin-bottom:8px}
+      .amp-cre-row{display:flex;align-items:center;gap:9px;flex-wrap:wrap}
+      .amp-cre-t{font-weight:600;font-size:12px}
+      .amp-cost{font-size:10.5px;color:#3f7a3f;background:#eef6ee;border:1px solid #cfe4cf;border-radius:5px;padding:2px 7px;font-variant-numeric:tabular-nums}
+      .amp-cost.amp-cost-paid{color:#8a6d2f;background:#f6efdd;border-color:#e2d3ac}
+      .amp-cre-cards{display:flex;gap:12px;margin-top:9px;flex-wrap:wrap}
+      .amp-cre-fig{margin:0;display:flex;flex-direction:column;gap:5px;align-items:flex-start;max-width:200px}
+      .amp-cre-fig img{max-width:200px;width:100%;height:auto;border:1px solid var(--line,#e7e0cf);border-radius:6px;background:#f4efe4}
+      .amp-cre-fig figcaption{font-size:10.5px;color:var(--muted,#8a8372)}
+      .amp-cre-room{margin-top:9px}
+      .amp-cre-room .amp-cre-fig,.amp-cre-room .amp-cre-fig img{max-width:320px}
+      .amp-cre-empty{font-size:11.5px;color:var(--muted,#8a8372);padding:6px 2px}
+      .amp-cre-note{font-size:10.5px;margin-top:2px}`;
       document.head.appendChild(st);
     }
 
diff --git a/server.js b/server.js
index 8b0cc75..30faf35 100644
--- a/server.js
+++ b/server.js
@@ -5,6 +5,7 @@
 const express = require('express');
 const fs = require('fs');
 const path = require('path');
+const os = require('os');
 
 // Load .env file into process.env (zero-dep; file is the source of truth so pm2
 // daemon-env drift can't override it). Existing process.env wins only if a key
@@ -179,6 +180,225 @@ app.post('/api/vendor-amplify-drafts', (req, res) => {
   res.json({ ok: true, id: draft.id, staged: targetAccounts.length });
 });
 
+// ── "Make it ours" creative image system (TK-10842) ──────────────────────────
+// Transforms a vendor's pattern image into a GENUINELY DW-ORIGINAL branded asset
+// DW can post as its own creative, so amplifying a vendor post never reposts the
+// vendor's raw copyrighted photo. Two treatments:
+//   Treatment 1 "Branded card" ($0 local) — the client composites the pattern onto
+//     a DW-branded canvas (wordmark + hairline frame + availability strip) via the
+//     browser Canvas API and POSTs the finished PNG here. Transformative brand
+//     creative, no external API. (sharp/canvas aren't installed in this app, so the
+//     composite is done client-side per the task's pure-canvas fallback.)
+//   Treatment 2 "Room setting" (small $) — server calls the EXISTING DW
+//     room-setting-generator pipeline (:8106) to produce an ORIGINAL DW room mockup.
+//
+// HARD COMPLIANCE GATES (baked in, never skipped):
+//  (1) SETTLEMENT — every asset (AI or composite) runs through the settlement
+//      post-gen-vision gate (lib/settlement-gate.js, canonical Gemini-vision port)
+//      BEFORE it is saved/shown as final. BLOCK/NEEDS_REVIEW => not staged as final;
+//      the reason is surfaced.
+//  (2) NEVER-FRONT-FACING — restricted (never-front-facing) brands get NO creative
+//      controls in the panel; this endpoint also refuses a restricted vendor name.
+//  (3) NOTHING AUTO-POSTS — assets save to public/amp-assets/ (gitignored +
+//      rsync-excluded so a deploy never clobbers remote-generated assets). Attaching
+//      to a staged draft REPLACES the raw vendor photo (draft.dwAsset). No IG/Meta
+//      publish call anywhere; engine-queue/channels-outbox/meta-pages untouched.
+//  (4) SHOW COST — Treatment 1 = "$0 local"; Treatment 2 = its real per-image rate;
+//      paid renders logged via the cost-tracker skill.
+const { settlementGateBuffer } = require('./lib/settlement-gate');
+const { generateRoom, roomAppUp, ROOM_RENDER_COST } = require('./lib/room-render');
+
+// Same never-front-facing guard the panel uses (kept in sync — extend as policy
+// dictates). A restricted vendor must produce NO creative asset here.
+const isRestrictedVendor = (name) => /\bschumacher\b/i.test(String(name || ''));
+
+// Saved-asset registry (side-file, gitignored + rsync-excluded) — lets the panel
+// list/attach assets and survives a re-render. NOT the live channels/engine store.
+const AMP_ASSETS_DIR = path.join(__dirname, 'public', 'amp-assets');
+const AMP_ASSETS_INDEX = path.join(__dirname, 'data', 'vendor-amplify-assets.json');
+const readAmpAssets = () => { try { const d = JSON.parse(fs.readFileSync(AMP_ASSETS_INDEX, 'utf8')); return Array.isArray(d) ? d : []; } catch { return []; } };
+const writeAmpAssets = (arr) => {
+  fs.mkdirSync(path.dirname(AMP_ASSETS_INDEX), { recursive: true });
+  const tmp = AMP_ASSETS_INDEX + '.tmp';
+  fs.writeFileSync(tmp, JSON.stringify(arr, null, 2));
+  fs.renameSync(tmp, AMP_ASSETS_INDEX);   // atomic swap
+};
+// Persist a produced asset buffer to public/amp-assets/ + register it. Atomic write.
+const saveAmpAsset = (buf, ext, meta) => {
+  fs.mkdirSync(AMP_ASSETS_DIR, { recursive: true });
+  const id = 'amp_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
+  const fname = `${id}.${ext}`;
+  const tmp = path.join(AMP_ASSETS_DIR, fname + '.tmp');
+  fs.writeFileSync(tmp, buf);
+  fs.renameSync(tmp, path.join(AMP_ASSETS_DIR, fname));
+  const rec = Object.assign({ id, file: fname, url: '/amp-assets/' + fname, created_at: new Date().toISOString(), bytes: buf.length }, meta || {});
+  const all = readAmpAssets(); all.push(rec); writeAmpAssets(all.slice(-2000));
+  return rec;
+};
+// Best-effort cost log to the cost-tracker skill (never blocks the response).
+const logCost = (units, note) => {
+  try {
+    const { spawn } = require('child_process');
+    const script = path.join(os.homedir(), '.claude', 'skills', 'cost-tracker', 'scripts', 'log.js');
+    if (!fs.existsSync(script)) return;
+    spawn('node', [script, '--api', 'gemini_2_0_flash', '--units', units, '--app', 'marketing-command-center', '--note', note || 'vendor-amplify make-it-ours'], { detached: true, stdio: 'ignore' }).unref();
+  } catch { /* cost log is best-effort */ }
+};
+
+// Decode a data: URL or bare base64 into { buffer, mime, ext }. Rejects non-images.
+const decodeImage = (dataUrl) => {
+  const s = String(dataUrl || '');
+  const m = s.match(/^data:(image\/(png|jpeg|jpg|webp));base64,(.+)$/);
+  const b64 = m ? m[3] : s.replace(/^data:[^,]*,/, '');
+  const mime = m ? m[1] : 'image/png';
+  const ext = /jpe?g/.test(mime) ? 'jpg' : (/webp/.test(mime) ? 'webp' : 'png');
+  const buffer = Buffer.from(b64, 'base64');
+  return { buffer, mime, ext };
+};
+
+// Attach a saved DW asset to an existing staged draft, REPLACING the raw vendor
+// photo as the image-to-post (draft.dwAsset). Returns {ok, matched}. Never posts.
+const attachAssetToDraft = (draftId, asset) => {
+  if (!draftId) return { ok: false, matched: false };
+  const all = readAmpDrafts();
+  let matched = false;
+  for (const d of all) {
+    if (d && d.id === draftId) {
+      d.dwAsset = { url: asset.url, id: asset.id, treatment: asset.treatment || null, settlement: asset.settlement || null, attached_at: new Date().toISOString() };
+      d.originalPostImage = d.originalPostImage || d.postImage || '';
+      d.postImage = asset.url;   // the DW-original asset is now the image-to-post
+      matched = true;
+      break;
+    }
+  }
+  if (matched) { try { writeAmpDrafts(all); } catch { return { ok: false, matched: true }; } }
+  return { ok: matched, matched };
+};
+
+// Treatment 1 — save a client-composited branded card. body:
+//   { vendor, treatment:'branded-card', image:<dataURL png>, variant:'1080'|'1350',
+//     attachToDraftId?:string }
+app.post('/api/vendor-amplify-assets', async (req, res) => {
+  const b = req.body || {};
+  const vendor = typeof b.vendor === 'string' ? b.vendor.slice(0, 200) : '';
+  if (isRestrictedVendor(vendor)) return res.status(403).json({ ok: false, error: 'restricted brand — no creative allowed' });
+  const treatment = (typeof b.treatment === 'string' && b.treatment) ? b.treatment.slice(0, 40) : 'branded-card';
+  let img;
+  try { img = decodeImage(b.image); } catch { return res.status(400).json({ ok: false, error: 'bad image' }); }
+  if (!img.buffer || !img.buffer.length) return res.status(400).json({ ok: false, error: 'empty image' });
+  if (img.buffer.length > 12 * 1024 * 1024) return res.status(413).json({ ok: false, error: 'image too large' });
+
+  // (1) SETTLEMENT gate — even the plain composite is vision-checked per the task.
+  let gate;
+  try { gate = await settlementGateBuffer(img.buffer, img.mime, vendor + ' branded card'); }
+  catch (e) { gate = { verdict: 'NEEDS_REVIEW', reason: 'gate error ' + String(e && e.message || e).slice(0, 100), cost: 0 }; }
+  if (gate.verdict === 'BLOCK') {
+    return res.json({ ok: false, blocked: true, verdict: gate.verdict, reason: gate.reason, cost: gate.cost, costLabel: '$0 (local)' });
+  }
+
+  // Save regardless of OK vs NEEDS_REVIEW (NEEDS_REVIEW is a human-eyes flag, not a
+  // hard block); the record carries the verdict so the UI + any downstream step can
+  // gate the actual (Steve-owned) posting decision on it.
+  const asset = saveAmpAsset(img.buffer, img.ext, {
+    vendor, treatment, variant: (b.variant === '1350' ? '1080x1350' : '1080x1080'),
+    settlement: { verdict: gate.verdict, reason: gate.reason },
+  });
+
+  let attach = { ok: false, matched: false };
+  if (b.attachToDraftId) attach = attachAssetToDraft(String(b.attachToDraftId).slice(0, 80), Object.assign({ treatment }, asset));
+
+  res.json({
+    ok: true,
+    asset: { id: asset.id, url: asset.url, variant: asset.variant, bytes: asset.bytes },
+    verdict: gate.verdict, reason: gate.reason,
+    cost: 0, costLabel: '$0 (local)',   // the composite itself is $0; the gate call is negligible
+    attached: attach.matched ? attach.ok : undefined,
+  });
+});
+
+// Treatment 2 — server-side DW-original room render via the existing pipeline. body:
+//   { vendor, patternBase64, roomType?, angle?, cameraDistance?, attachToDraftId? }
+app.post('/api/vendor-amplify-room', async (req, res) => {
+  const b = req.body || {};
+  const vendor = typeof b.vendor === 'string' ? b.vendor.slice(0, 200) : '';
+  if (isRestrictedVendor(vendor)) return res.status(403).json({ ok: false, error: 'restricted brand — no creative allowed' });
+  const patternBase64 = typeof b.patternBase64 === 'string' ? b.patternBase64 : '';
+  if (!patternBase64) return res.status(400).json({ ok: false, error: 'no pattern image' });
+
+  if (!(await roomAppUp())) {
+    return res.status(503).json({ ok: false, error: 'room renderer offline — start the Room Setting App on :8106', costLabel: `$0 (not billed)` });
+  }
+
+  // (2) generate — the room app bills ~$0.01–0.04 once success comes back.
+  const gen = await generateRoom(patternBase64, {
+    roomType: b.roomType, angle: b.angle, cameraDistance: b.cameraDistance,
+    patternWidth: b.patternWidth, patternHeight: b.patternHeight,
+  });
+  if (!gen.ok) return res.status(502).json({ ok: false, error: gen.error || 'render failed', cost: gen.cost || 0, costLabel: '$0 (not billed)' });
+  logCost('1:image', `room render (${b.roomType || 'living_room'}) for ${vendor}`);
+  const costLabel = `$${gen.cost.toFixed(2)} (Gemini 2.5 flash image room render)`;
+
+  // (1) SETTLEMENT gate on the AI-generated render — MANDATORY.
+  let gate;
+  try { gate = await settlementGateBuffer(gen.buffer, gen.mime, vendor + ' room setting'); }
+  catch (e) { gate = { verdict: 'NEEDS_REVIEW', reason: 'gate error ' + String(e && e.message || e).slice(0, 100), cost: 0 }; }
+  if (gate.verdict === 'BLOCK') {
+    // Do NOT save/show a blocked AI asset. Cost was still incurred by the render.
+    return res.json({ ok: false, blocked: true, verdict: gate.verdict, reason: gate.reason, cost: gen.cost, costLabel });
+  }
+
+  const asset = saveAmpAsset(gen.buffer, 'jpg', {
+    vendor, treatment: 'room-setting', variant: '1248x832',
+    roomType: b.roomType || 'living_room',
+    settlement: { verdict: gate.verdict, reason: gate.reason },
+  });
+  let attach = { ok: false, matched: false };
+  if (b.attachToDraftId) attach = attachAssetToDraft(String(b.attachToDraftId).slice(0, 80), Object.assign({ treatment: 'room-setting' }, asset));
+
+  res.json({
+    ok: true,
+    asset: { id: asset.id, url: asset.url, variant: asset.variant, bytes: asset.bytes, roomType: asset.roomType },
+    verdict: gate.verdict, reason: gate.reason,
+    cost: gen.cost, costLabel,
+    attached: attach.matched ? attach.ok : undefined,
+  });
+});
+
+// List saved amplify assets (newest first) — for the panel's creative review area.
+app.get('/api/vendor-amplify-assets', (_req, res) => {
+  res.json({ ok: true, assets: readAmpAssets().slice().reverse() });
+});
+
+// Same-origin image relay so the client Canvas compositor (Treatment 1) can READ
+// the vendor pattern pixels without CORS-tainting the canvas (IG/fbcdn images are
+// cross-origin and would otherwise block toDataURL). Read-only, image-only,
+// size-capped, allowlisted to the known IG/FB CDN + product-image hosts. Not a
+// general proxy — refuses anything that isn't an image from an allowed host.
+const IMG_PROXY_HOSTS = /(\.cdninstagram\.com|\.fbcdn\.net|instagram\.com|designerwallcoverings\.com|\.shopify\.com|cdn\.shopify\.com)$/i;
+app.get('/api/img-proxy', async (req, res) => {
+  const raw = String(req.query.u || '');
+  let u;
+  try { u = new URL(raw); } catch { return res.status(400).end('bad url'); }
+  if (!/^https?:$/.test(u.protocol)) return res.status(400).end('bad protocol');
+  if (!IMG_PROXY_HOSTS.test(u.hostname)) return res.status(403).end('host not allowed');
+  try {
+    const c = new AbortController();
+    const t = setTimeout(() => c.abort(), 15000);
+    const r = await fetch(u.href, { signal: c.signal, redirect: 'follow' });
+    clearTimeout(t);
+    const ct = (r.headers.get('content-type') || '').split(';')[0];
+    if (!r.ok || !/^image\//.test(ct)) return res.status(502).end('not an image');
+    const buf = Buffer.from(await r.arrayBuffer());
+    if (buf.length > 20 * 1024 * 1024) return res.status(413).end('too large');
+    res.set('Content-Type', ct);
+    res.set('Cache-Control', 'private, max-age=3600');
+    res.set('Access-Control-Allow-Origin', '*');   // same-origin fetch, but explicit for the <img crossorigin>
+    res.end(buf);
+  } catch (e) {
+    res.status(502).end('proxy error');
+  }
+});
+
 app.use('/panels', express.static(path.join(__dirname, 'public', 'panels'), { fallthrough: true }));
 app.use(express.static(path.join(__dirname, 'public')));
 app.get('/', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));

← c12730b Show last 6 posts per owned account in the #vendors Owned·DW  ·  back to Marketing Command Center  ·  Refine Owned·DW Fleet: surface dormant + repetition health s f91859a →