import { readFile, writeFile } from 'node:fs/promises';
import { basename, extname } from 'node:path';
import { randomUUID } from 'node:crypto';
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, idempotencyKey } = {}) {
const isForm = body instanceof FormData;
const response = await fetch(`${base}${path}`, {
method,
headers: {
Authorization: `Bearer ${key}`,
...(!body || isForm ? {} : { 'Content-Type': 'application/json' }),
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
},
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 = { message: text }; }
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${data.code || ''} ${data.message || data.error || 'Request failed'} (request ${data.request_id || 'unknown'})`);
}
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 URL. Contact support.`);
return job;
}
if (['failed', 'policy_blocked'].includes(job.status)) {
throw new Error(`Job ${id}: ${job.error?.message || job.error?.code || job.status}`);
}
await sleep(5_000);
}
throw new Error(`Still processing. Resume: 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 {
let saved;
let receiptFile;
if (process.argv[2] === '--retry') {
receiptFile = process.argv[3];
if (!receiptFile) throw new Error('Usage: node generate.mjs --retry REQUEST_FILE');
saved = JSON.parse(await readFile(receiptFile, 'utf8'));
// This example is intentionally conservative: finalized receipts may
// expire after 24 hours. Resolve older or uncertain requests with support.
if (Date.now() - saved.createdAt > 23 * 60 * 60_000) {
throw new Error('This request is too old for automatic replay. Check Studio or contact support.');
}
} 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', 'outfit');
const upload = await request('/v1/images', { method: 'POST', body: form });
if (upload.status !== 'completed') throw new Error(`Upload ${upload.id} is not ready.`);
saved = {
idempotencyKey: randomUUID(),
createdAt: Date.now(),
body: {
model_text: 'An adult fashion model',
outfit_image_ids: [upload.id],
prompt: 'Full-length front view in a white studio, soft shadows.',
aspect_ratio: '3:4',
},
};
receiptFile = `bitstudio-request-${saved.idempotencyKey}.json`;
// Persist before the paid POST. This file contains references, never the API key.
await writeFile(receiptFile, JSON.stringify(saved, null, 2), { flag: 'wx', mode: 0o600 });
}
console.log(`If submission is interrupted: node generate.mjs --retry ${receiptFile}`);
const { jobs } = await request('/v1/images/generate', {
method: 'POST', body: saved.body, idempotencyKey: saved.idempotencyKey,
});
if (!Array.isArray(jobs) || jobs.length !== 1 || !jobs[0].id) {
throw new Error(`Unexpected response. Retain ${receiptFile} and contact support.`);
}
id = jobs[0].id;
console.log(`Job accepted: ${id}\nResume polling: node generate.mjs --resume ${id}`);
}
const result = await waitForJob(id);
console.log(`Result ID: ${result.result.id}\nResult: ${result.result.url}`);