← 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 };