← back to Whatsmystyle

scripts/fashn.js

73 lines

/**
 * 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');
  // Categories per current fashn.ai API: 'tops' | 'bottoms' | 'one-pieces' | 'auto'
  const cat = ({ top: 'tops', bottom: 'bottoms', dress: 'one-pieces', outerwear: 'tops' })[category] || 'auto';
  const submit = await fetch(`${BASE}/v1/run`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${KEY}`, 'content-type': 'application/json' },
    body: JSON.stringify({
      model_name: process.env.FASHN_MODEL_NAME || 'tryon-v1.6',
      inputs: {
        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 120s — FASHN v1.6 takes ~20-60s per render
  const start = Date.now();
  while (Date.now() - start < 120000) {
    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 — ' + JSON.stringify(j.error || j));
    await new Promise(r => setTimeout(r, 3000));
  }
  throw new Error('FASHN: poll timeout');
}

module.exports = { available, tryOn, COST_CENTS };