← 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
M .deploy.confM .gitignoreA lib/room-render.jsA lib/settlement-gate.jsA public/amp-assets/.gitkeepM public/panels/vendors.jsM server.js
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 →