← back to Whatsmystyle
scripts/openai-image.js
67 lines
/**
* 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 };