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