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