import { readFile } from 'node:fs/promises';
import { basename, extname } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
const key = process.env.BITSTUDIO_API_KEY;
const base = (process.env.BITSTUDIO_API_BASE_URL || 'https://api.bitstudio.ai').replace(/\/$/, '');
if (!key) throw new Error('Set BITSTUDIO_API_KEY on your server.');
async function request(path, { method = 'GET', body } = {}) {
const isForm = body instanceof FormData;
const response = await fetch(`${base}${path}`, {
method,
headers: {
Authorization: `Bearer ${key}`,
...(!body || isForm ? {} : { 'Content-Type': 'application/json' }),
},
body: body ? (isForm ? body : JSON.stringify(body)) : undefined,
signal: AbortSignal.timeout(60_000),
});
const text = await response.text();
let data;
try { data = JSON.parse(text); } catch { data = { error: text }; }
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${data.user_message || data.message || data.error || 'Request failed'}`);
}
return data;
}
async function waitForJob(id) {
const deadline = Date.now() + 5 * 60_000;
while (Date.now() < deadline) {
const job = await request(`/v1/jobs/${encodeURIComponent(id)}`);
if (job.status === 'completed') {
if (!job.result?.url) throw new Error(`Job ${id} completed without a result URL. Contact support.`);
return job;
}
if (['failed', 'policy_blocked'].includes(job.status)) {
throw new Error(`Job ${id}: ${job.error?.code || job.status}`);
}
await sleep(5_000);
}
throw new Error(`Still processing. Resume polling with: node generate.mjs --resume ${id}`);
}
let id;
if (process.argv[2] === '--resume') {
id = process.argv[3];
if (!id) throw new Error('Usage: node generate.mjs --resume JOB_ID');
} else {
const filename = process.argv[2];
if (!filename) throw new Error('Usage: node generate.mjs ./shirt.jpg');
const types = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' };
const type = types[extname(filename).toLowerCase()];
if (!type) throw new Error('Choose a JPEG, PNG, or WebP product photo.');
const file = await readFile(filename);
if (file.length > 15 * 1024 * 1024) throw new Error('Image must be no larger than 15 MiB.');
const form = new FormData();
form.append('file', new Blob([file], { type }), basename(filename));
form.append('type', 'virtual-try-on-outfit');
const upload = await request('/images', { method: 'POST', body: form });
console.log(`Uploaded image: ${upload.id}`);
// Submit once. Do not automatically retry a timed-out generation POST.
const { jobs } = await request('/v1/jobs', {
method: 'POST',
body: {
operation: 'generate',
input: {
model_version: 'nano-banana-2',
generate_mode: 'presets',
model_text: 'An adult fashion model',
outfit_image_ids: [upload.id],
prompt: 'Full-length front view in a white studio, soft shadows.',
resolution: 'standard',
aspect_ratio: '3:4',
num_images: 1,
},
},
});
if (!Array.isArray(jobs) || jobs.length !== 1 || !jobs[0].id) {
throw new Error('Unexpected response. Check Studio before submitting another generation.');
}
id = jobs[0].id;
console.log(`Job accepted: ${id}`);
}
const result = await waitForJob(id);
console.log(`Result: ${result.result.url}`);