← back to Whatsmystyle

scripts/dopple.js

116 lines

/**
 * Dopple provider — dopple.ai digital-twin + virtual-try-on.
 *
 * Why first-priority: Dopple is purpose-built for the photoreal
 * person+garment composite. It maintains a persistent "twin" per user
 * (so we don't re-segment on every render) and the try-on outputs are
 * higher fidelity than general image-edit models for clothing.
 *
 * Env:
 *   DOPPLE_API_KEY   — required
 *   DOPPLE_API_BASE  — defaults to https://api.dopple.ai
 *
 * Endpoints used (current Dopple API):
 *   POST  /v1/twins                        create twin from N photos → returns twin_id
 *   GET   /v1/twins/{id}                   poll twin readiness
 *   POST  /v1/tryon                        body: { twin_id, garment_url, options }
 *   POST  /v1/scenes/edit                  body: { source_image, twin_id, garment_url, scene_hint }
 *                                          → edits an existing photo, replacing the wearer's outfit
 *
 * If the API surface changes we fail gracefully and the worker falls
 * through to the next provider.
 */
const fs = require('fs');
const fetch = require('node-fetch');
const FormData = require('form-data');

const BASE = process.env.DOPPLE_API_BASE || 'https://api.dopple.ai';
const KEY  = process.env.DOPPLE_API_KEY;

function hdrs(extra = {}) {
  return { Authorization: `Bearer ${KEY}`, ...extra };
}

async function createTwin(photoPaths) {
  if (!KEY) throw new Error('DOPPLE_API_KEY not set');
  const fd = new FormData();
  photoPaths.slice(0, 6).forEach((p, i) => fd.append('photos', fs.createReadStream(p), `p${i}.jpg`));
  const r = await fetch(`${BASE}/v1/twins`, { method: 'POST', headers: hdrs(fd.getHeaders()), body: fd });
  if (!r.ok) throw new Error(`Dopple createTwin ${r.status}: ${await r.text()}`);
  const j = await r.json();
  return j.twin_id || j.id;
}

async function pollTwin(twinId, { timeoutMs = 120000, intervalMs = 3000 } = {}) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const r = await fetch(`${BASE}/v1/twins/${twinId}`, { headers: hdrs() });
    if (r.ok) {
      const j = await r.json();
      if (j.status === 'ready' || j.ready) return j;
      if (j.status === 'failed') throw new Error('Dopple twin failed: ' + (j.error || 'unknown'));
    }
    await new Promise(r => setTimeout(r, intervalMs));
  }
  throw new Error('Dopple twin poll timeout');
}

async function tryOn({ twinId, garmentUrl, garmentBuffer, options = {} }) {
  if (!KEY) throw new Error('DOPPLE_API_KEY not set');
  let body;
  let headers = hdrs({ 'content-type': 'application/json' });
  if (garmentBuffer) {
    const fd = new FormData();
    fd.append('twin_id', twinId);
    fd.append('garment', garmentBuffer, { filename: 'garment.jpg' });
    Object.entries(options).forEach(([k, v]) => fd.append(k, String(v)));
    body = fd;
    headers = hdrs(fd.getHeaders());
  } else {
    body = JSON.stringify({ twin_id: twinId, garment_url: garmentUrl, ...options });
  }
  const r = await fetch(`${BASE}/v1/tryon`, { method: 'POST', headers, body });
  if (!r.ok) throw new Error(`Dopple tryon ${r.status}: ${await r.text()}`);
  // API returns either a JSON {result_url} or a binary image
  const ct = r.headers.get('content-type') || '';
  if (ct.startsWith('image/')) return { buffer: Buffer.from(await r.arrayBuffer()) };
  const j = await r.json();
  if (j.result_url) {
    const r2 = await fetch(j.result_url);
    return { buffer: Buffer.from(await r2.arrayBuffer()), meta: j };
  }
  if (j.result_b64) return { buffer: Buffer.from(j.result_b64, 'base64'), meta: j };
  throw new Error('Dopple tryon: no image in response');
}

/**
 * Scene edit — replace the outfit of the wearer in an existing photograph,
 * preserving everyone else + the venue. Used for "the dress I wish I'd worn
 * to my sister's wedding" workflow.
 */
async function editScene({ twinId, sourceImagePath, garmentUrl, garmentBuffer, occasion }) {
  if (!KEY) throw new Error('DOPPLE_API_KEY not set');
  const fd = new FormData();
  fd.append('source_image', fs.createReadStream(sourceImagePath), 'source.jpg');
  fd.append('twin_id', twinId);
  if (garmentBuffer) fd.append('garment', garmentBuffer, 'garment.jpg');
  if (garmentUrl) fd.append('garment_url', garmentUrl);
  if (occasion) fd.append('scene_hint', occasion);
  fd.append('preserve_identity', '1');
  fd.append('preserve_others', '1');
  fd.append('preserve_background', '1');
  const r = await fetch(`${BASE}/v1/scenes/edit`, { method: 'POST', headers: hdrs(fd.getHeaders()), body: fd });
  if (!r.ok) throw new Error(`Dopple editScene ${r.status}: ${await r.text()}`);
  const ct = r.headers.get('content-type') || '';
  if (ct.startsWith('image/')) return { buffer: Buffer.from(await r.arrayBuffer()) };
  const j = await r.json();
  if (j.result_url) {
    const r2 = await fetch(j.result_url);
    return { buffer: Buffer.from(await r2.arrayBuffer()), meta: j };
  }
  if (j.result_b64) return { buffer: Buffer.from(j.result_b64, 'base64'), meta: j };
  throw new Error('Dopple editScene: no image');
}

module.exports = { createTwin, pollTwin, tryOn, editScene, available: () => !!KEY };