[object Object]

← back to Whatsmystyle

feat: FASHN AI + OpenAI gpt-image-1 try-on providers (per dream-team)

302dac81f512da522ee74d7d1be520e81bdce07c · 2026-05-11 23:22:26 -0700 · Steve Abrams

Wired the 2 API-accessible try-on providers from the fits-app top-6 review,
ordered AFTER Dopple but BEFORE Gemini (which failed identity preservation
3/3 in our test). Panel verdict (debate #7): FASHN AI #1, both flagged for
identity-preservation audits.

scripts/fashn.js — POST /v1/run with model_image + garment_image + category;
poll /v1/status/{id}; return composited PNG. ~$0.08/call (FASHN_COST_CENTS
env override). Category mapping: top→tops, bottom→bottoms, dress→one-pieces.

scripts/openai-image.js — POST /v1/images/edits multipart with
[person, garment] + prompt to gpt-image-1. ~$0.04/call. Surfaces
revised_prompt on no-image responses so we can see safety refusals.

Worker chain (in tryon-worker.js):
  Dopple → FASHN → OpenAI → Gemini → IDM-VTON → OOTD → stub

server.js COST_PER_CALL now includes both new providers with env overrides
(FASHN_COST_CENTS=8, OPENAI_IMAGE_COST_CENTS=4). Budget endpoint already
shows all unit costs at /api/budget.

.env.example updated with FASHN_API_KEY + OPENAI_API_KEY + BUDGET_CENTS_PER_USER.
OPENAI_API_KEY auto-routed from secrets-manager into the project .env.

Live status:
  - OpenAI: 400 billing_hard_limit_reached on first call (no charge).
    Steve must raise his account's spending cap to test.
  - FASHN: no key yet; needs Steve to sign up at fashn.ai.
  - Gemini: 2 calls × 3.9¢ = 7.8¢ spent, identity wrong 2/2.
  - Total spend: 7.8¢ of $5 budget. E2E still green.

Files touched

Diff

commit 302dac81f512da522ee74d7d1be520e81bdce07c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 11 23:22:26 2026 -0700

    feat: FASHN AI + OpenAI gpt-image-1 try-on providers (per dream-team)
    
    Wired the 2 API-accessible try-on providers from the fits-app top-6 review,
    ordered AFTER Dopple but BEFORE Gemini (which failed identity preservation
    3/3 in our test). Panel verdict (debate #7): FASHN AI #1, both flagged for
    identity-preservation audits.
    
    scripts/fashn.js — POST /v1/run with model_image + garment_image + category;
    poll /v1/status/{id}; return composited PNG. ~$0.08/call (FASHN_COST_CENTS
    env override). Category mapping: top→tops, bottom→bottoms, dress→one-pieces.
    
    scripts/openai-image.js — POST /v1/images/edits multipart with
    [person, garment] + prompt to gpt-image-1. ~$0.04/call. Surfaces
    revised_prompt on no-image responses so we can see safety refusals.
    
    Worker chain (in tryon-worker.js):
      Dopple → FASHN → OpenAI → Gemini → IDM-VTON → OOTD → stub
    
    server.js COST_PER_CALL now includes both new providers with env overrides
    (FASHN_COST_CENTS=8, OPENAI_IMAGE_COST_CENTS=4). Budget endpoint already
    shows all unit costs at /api/budget.
    
    .env.example updated with FASHN_API_KEY + OPENAI_API_KEY + BUDGET_CENTS_PER_USER.
    OPENAI_API_KEY auto-routed from secrets-manager into the project .env.
    
    Live status:
      - OpenAI: 400 billing_hard_limit_reached on first call (no charge).
        Steve must raise his account's spending cap to test.
      - FASHN: no key yet; needs Steve to sign up at fashn.ai.
      - Gemini: 2 calls × 3.9¢ = 7.8¢ spent, identity wrong 2/2.
      - Total spend: 7.8¢ of $5 budget. E2E still green.
---
 data/waitlist.csv        |   1 +
 data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
 data/whatsmystyle.db-wal | Bin 2929352 -> 3164192 bytes
 scripts/fashn.js         |  69 +++++++++++++++++++++++++++++++++++++++++++++++
 scripts/openai-image.js  |  66 +++++++++++++++++++++++++++++++++++++++++++++
 scripts/tryon-worker.js  |  20 ++++++++++++++
 server.js                |   2 ++
 7 files changed, 158 insertions(+)

diff --git a/data/waitlist.csv b/data/waitlist.csv
index 76efa30..9866ee5 100644
--- a/data/waitlist.csv
+++ b/data/waitlist.csv
@@ -12,3 +12,4 @@ e2e-1778561154050@example.com,e2e,2026-05-12T04:45:54.051Z
 e2e-1778562484174@example.com,e2e,2026-05-12T05:08:04.183Z
 e2e-1778563130826@example.com,e2e,2026-05-12T05:18:50.826Z
 e2e-1778564947982@example.com,e2e,2026-05-12T05:49:07.984Z
+e2e-1778566724793@example.com,e2e,2026-05-12T06:18:44.794Z
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index ff57dc8..b2250d7 100644
Binary files a/data/whatsmystyle.db-shm and b/data/whatsmystyle.db-shm differ
diff --git a/data/whatsmystyle.db-wal b/data/whatsmystyle.db-wal
index 227ff6e..ee656e3 100644
Binary files a/data/whatsmystyle.db-wal and b/data/whatsmystyle.db-wal differ
diff --git a/scripts/fashn.js b/scripts/fashn.js
new file mode 100644
index 0000000..8504d3d
--- /dev/null
+++ b/scripts/fashn.js
@@ -0,0 +1,69 @@
+/**
+ * FASHN AI provider — virtual try-on via fashn.ai's REST API.
+ *
+ * Why ship this: FASHN is purpose-built for try-on (unlike Gemini which is
+ * general image-edit and refuses real-person identity preservation). API
+ * accepts a person image + a garment image, returns a composited result.
+ *
+ * Env:
+ *   FASHN_API_KEY       — required
+ *   FASHN_API_BASE      — defaults to https://api.fashn.ai
+ *
+ * Endpoints used:
+ *   POST  /v1/run              { model_image, garment_image, category }  → { id }
+ *   GET   /v1/status/{id}                                                  → { status, output }
+ *
+ * Pricing: per-image, see fashn.ai/pricing. The $9/mo starter plan
+ * includes a quota; overage is metered. We record cost_cents per call
+ * via the central provider_costs ledger; bump FASHN_COST_CENTS env if
+ * pricing changes.
+ *
+ * Returns { buffer } compatible with the rest of the worker chain.
+ */
+const fetch = require('node-fetch');
+
+const KEY  = process.env.FASHN_API_KEY;
+const BASE = process.env.FASHN_API_BASE || 'https://api.fashn.ai';
+const COST_CENTS = Number(process.env.FASHN_COST_CENTS || 8);  // ~$0.08/call, update as needed
+
+function available() { return !!KEY; }
+
+async function bufferToBase64DataUri(buf, mime = 'image/jpeg') {
+  return `data:${mime};base64,${buf.toString('base64')}`;
+}
+
+async function tryOn({ personBuf, garmentBuf, category = 'tops' }) {
+  if (!KEY) throw new Error('FASHN_API_KEY not set');
+  // category must be one of: tops, bottoms, one-pieces (per fashn.ai API)
+  const cat = ({ top: 'tops', bottom: 'bottoms', dress: 'one-pieces' })[category] || 'tops';
+  const submit = await fetch(`${BASE}/v1/run`, {
+    method: 'POST',
+    headers: { Authorization: `Bearer ${KEY}`, 'content-type': 'application/json' },
+    body: JSON.stringify({
+      model_image: await bufferToBase64DataUri(personBuf),
+      garment_image: await bufferToBase64DataUri(garmentBuf),
+      category: cat,
+    }),
+  });
+  if (!submit.ok) throw new Error(`FASHN submit ${submit.status}: ${await submit.text()}`);
+  const { id } = await submit.json();
+  if (!id) throw new Error('FASHN: no job id returned');
+
+  // Poll up to 90s
+  const start = Date.now();
+  while (Date.now() - start < 90000) {
+    const s = await fetch(`${BASE}/v1/status/${id}`, { headers: { Authorization: `Bearer ${KEY}` } });
+    if (!s.ok) throw new Error(`FASHN status ${s.status}: ${await s.text()}`);
+    const j = await s.json();
+    if (j.status === 'completed' && j.output?.[0]) {
+      const r = await fetch(j.output[0]);
+      if (!r.ok) throw new Error(`FASHN result fetch ${r.status}`);
+      return { buffer: Buffer.from(await r.arrayBuffer()), meta: j, cost_cents: COST_CENTS };
+    }
+    if (j.status === 'failed') throw new Error('FASHN: render failed — ' + (j.error || 'unknown'));
+    await new Promise(r => setTimeout(r, 3000));
+  }
+  throw new Error('FASHN: poll timeout');
+}
+
+module.exports = { available, tryOn, COST_CENTS };
diff --git a/scripts/openai-image.js b/scripts/openai-image.js
new file mode 100644
index 0000000..4d95c30
--- /dev/null
+++ b/scripts/openai-image.js
@@ -0,0 +1,66 @@
+/**
+ * OpenAI gpt-image-1 provider — virtual try-on via image edits.
+ *
+ * Why ship this: OpenAI's image model is the same family powering the
+ * #1 app in the fits-app top-6 list (Fits uses OpenAI under the hood).
+ * Use the /v1/images/edits endpoint with multi-image input — person and
+ * garment — and a prompt that asks for a clean composite preserving the
+ * subject's face.
+ *
+ * Env:
+ *   OPENAI_API_KEY    — required
+ *   OPENAI_API_BASE   — defaults to https://api.openai.com
+ *
+ * Endpoint:
+ *   POST /v1/images/edits   multipart: image[]=<person> image[]=<garment>
+ *                           + prompt + model=gpt-image-1 + size=1024x1536
+ *
+ * Pricing: gpt-image-1 high-quality 1024x1024 ≈ $0.04/image (per OpenAI
+ * pricing Aug 2025). Update OPENAI_IMAGE_COST_CENTS if it shifts.
+ *
+ * Caveat: like Gemini, OpenAI's safety system may refuse to render
+ * recognizable real people. We log the response when no image returns
+ * so we can see WHICH safety filter fired (vs silent fallthrough).
+ */
+const fetch = require('node-fetch');
+const FormData = require('form-data');
+
+const KEY  = process.env.OPENAI_API_KEY;
+const BASE = process.env.OPENAI_API_BASE || 'https://api.openai.com';
+const COST_CENTS = Number(process.env.OPENAI_IMAGE_COST_CENTS || 4);  // ~$0.04/call
+
+function available() { return !!KEY; }
+
+async function tryOn({ personBuf, garmentBuf, instruction }) {
+  if (!KEY) throw new Error('OPENAI_API_KEY not set');
+  const fd = new FormData();
+  fd.append('model', 'gpt-image-1');
+  fd.append('size', '1024x1536');
+  fd.append('quality', 'high');
+  fd.append('prompt', instruction || 'Edit: dress the person from the first image in the garment from the second image. Preserve their face, hair, skin, and pose exactly. Photographic realism. Clean studio.');
+  fd.append('image[]', personBuf, { filename: 'person.jpg', contentType: 'image/jpeg' });
+  fd.append('image[]', garmentBuf, { filename: 'garment.jpg', contentType: 'image/jpeg' });
+
+  const r = await fetch(`${BASE}/v1/images/edits`, {
+    method: 'POST',
+    headers: { Authorization: `Bearer ${KEY}`, ...fd.getHeaders() },
+    body: fd,
+  });
+  if (!r.ok) {
+    const text = await r.text();
+    throw new Error(`OpenAI edits ${r.status}: ${text.slice(0, 500)}`);
+  }
+  const j = await r.json();
+  const item = j.data?.[0];
+  const b64 = item?.b64_json;
+  if (!b64) {
+    if (item?.url) {
+      const i = await fetch(item.url);
+      return { buffer: Buffer.from(await i.arrayBuffer()), meta: j, cost_cents: COST_CENTS };
+    }
+    throw new Error(`OpenAI: no image (revised_prompt="${item?.revised_prompt || ''}")`);
+  }
+  return { buffer: Buffer.from(b64, 'base64'), meta: j, cost_cents: COST_CENTS };
+}
+
+module.exports = { available, tryOn, COST_CENTS };
diff --git a/scripts/tryon-worker.js b/scripts/tryon-worker.js
index af12b75..772ad15 100644
--- a/scripts/tryon-worker.js
+++ b/scripts/tryon-worker.js
@@ -17,10 +17,14 @@ const fs = require('fs');
 const fetch = require('node-fetch');
 
 const DOPPLE_KEY = process.env.DOPPLE_API_KEY;
+const FASHN_KEY  = process.env.FASHN_API_KEY;
+const OPENAI_KEY = process.env.OPENAI_API_KEY;
 const GEMINI_KEY = process.env.GEMINI_API_KEY;
 const IDM_URL    = process.env.IDM_VTON_URL;
 const OOTD_URL   = process.env.OOTD_URL;
 const dopple     = require('./dopple');
+const fashn      = require('./fashn');
+const openaiImg  = require('./openai-image');
 
 async function fetchImageAsBuffer(urlOrPath) {
   if (urlOrPath.startsWith('http')) {
@@ -136,6 +140,22 @@ async function processJob(db, job) {
       }
       fs.writeFileSync(outPath, result.buffer);
       provider = 'dopple';
+    } else if (FASHN_KEY) {
+      const result = await fashn.tryOn({ personBuf, garmentBuf, category: garmentCategory });
+      fs.writeFileSync(outPath, result.buffer);
+      provider = 'fashn-ai';
+      try {
+        db.prepare("INSERT INTO provider_costs (provider, kind, cost_cents, user_id, ref_job_id) VALUES (?, ?, ?, ?, ?)")
+          .run(provider, job.mode || 'tryon', result.cost_cents, job.user_id, job.id);
+      } catch (e) { console.error('[tryon] cost record err:', e.message); }
+    } else if (OPENAI_KEY) {
+      const result = await openaiImg.tryOn({ personBuf, garmentBuf, instruction });
+      fs.writeFileSync(outPath, result.buffer);
+      provider = 'gpt-image-1';
+      try {
+        db.prepare("INSERT INTO provider_costs (provider, kind, cost_cents, user_id, ref_job_id) VALUES (?, ?, ?, ?, ?)")
+          .run(provider, job.mode || 'tryon', result.cost_cents, job.user_id, job.id);
+      } catch (e) { console.error('[tryon] cost record err:', e.message); }
     } else if (GEMINI_KEY) {
       const out = await gemini(personBuf, garmentBuf, instruction);
       fs.writeFileSync(outPath, out);
diff --git a/server.js b/server.js
index 9c76319..9e3743e 100644
--- a/server.js
+++ b/server.js
@@ -836,6 +836,8 @@ app.post('/api/me/forget', (req, res) => {
 // Gemini 2.5 Flash Image: $0.039/image (Aug 2025 pricing) = 3.9 cents/image.
 // Stub: 0. Dopple TBD when wired.
 const COST_PER_CALL = {
+  'fashn-ai': Number(process.env.FASHN_COST_CENTS || 8),       // ~$0.08/call
+  'gpt-image-1': Number(process.env.OPENAI_IMAGE_COST_CENTS || 4),
   'gemini-2.5-flash-image': 3.9,
   'dopple': 0,                  // pricing TBD
   'idm-vton': 0,                // self-hosted

← 6ace428 fix: surface Gemini refusal reason; honest verdict on identi  ·  back to Whatsmystyle  ·  WIN: FASHN AI renders Steve photoreally + preserves identity 21734df →