201 lines
7.6 KiB
JavaScript
201 lines
7.6 KiB
JavaScript
import crypto from 'node:crypto';
|
|
|
|
const SUPPORTED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
|
const ACTIVE_STATUSES = new Set(['queued', 'submitted', 'running']);
|
|
const DEFAULT_MAX_RESULT_BYTES = 20 * 1024 * 1024;
|
|
|
|
function positiveInteger(value, fallback) {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
}
|
|
|
|
function normalizeBaseUrl(value) {
|
|
const url = new URL(String(value ?? '').trim());
|
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
throw new Error('image_make base URL is invalid');
|
|
}
|
|
url.pathname = url.pathname.replace(/\/+$/, '');
|
|
url.search = '';
|
|
url.hash = '';
|
|
return url.toString().replace(/\/$/, '');
|
|
}
|
|
|
|
function imageMagicMatches(buffer, mimeType) {
|
|
if (mimeType === 'image/png') {
|
|
return buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'));
|
|
}
|
|
if (mimeType === 'image/jpeg') {
|
|
return buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
|
|
}
|
|
if (mimeType === 'image/webp') {
|
|
return buffer.length >= 12
|
|
&& buffer.subarray(0, 4).toString('ascii') === 'RIFF'
|
|
&& buffer.subarray(8, 12).toString('ascii') === 'WEBP';
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function safeJson(response) {
|
|
try {
|
|
return await response.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function createClientError(code, message, details = {}) {
|
|
return Object.assign(new Error(message), { code, ...details });
|
|
}
|
|
|
|
export function createImageMakeClient({
|
|
baseUrl,
|
|
token,
|
|
fetchImpl = globalThis.fetch,
|
|
requestTimeoutMs = 15_000,
|
|
generationTimeoutMs = 10 * 60_000,
|
|
pollIntervalMs = 1_000,
|
|
maxResultBytes = DEFAULT_MAX_RESULT_BYTES,
|
|
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
} = {}) {
|
|
if (typeof fetchImpl !== 'function') throw new Error('image_make fetch implementation is required');
|
|
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
|
const bearerToken = String(token ?? '').trim();
|
|
if (!bearerToken) throw new Error('image_make token is required');
|
|
const perRequestTimeout = positiveInteger(requestTimeoutMs, 15_000);
|
|
const overallTimeout = positiveInteger(generationTimeoutMs, 10 * 60_000);
|
|
const pollingDelay = positiveInteger(pollIntervalMs, 1_000);
|
|
const resultLimit = positiveInteger(maxResultBytes, DEFAULT_MAX_RESULT_BYTES);
|
|
|
|
async function request(pathname, init = {}, expectedStatuses = [200]) {
|
|
const response = await fetchImpl(new URL(pathname, `${normalizedBaseUrl}/`), {
|
|
...init,
|
|
headers: {
|
|
accept: 'application/json',
|
|
authorization: `Bearer ${bearerToken}`,
|
|
...(init.body ? { 'content-type': 'application/json' } : {}),
|
|
...(init.headers ?? {}),
|
|
},
|
|
signal: AbortSignal.timeout(perRequestTimeout),
|
|
});
|
|
if (!expectedStatuses.includes(response.status)) {
|
|
const body = await safeJson(response);
|
|
throw createClientError(
|
|
'IMAGE_MAKE_HTTP_ERROR',
|
|
body?.error_message_safe ?? body?.message ?? `image_make request failed (${response.status})`,
|
|
{ status: response.status },
|
|
);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
async function generateImage({
|
|
prompt,
|
|
negativePrompt = '',
|
|
presetId,
|
|
presetVersion = 1,
|
|
width,
|
|
height,
|
|
consumerRef,
|
|
idempotencyKey = `imgreq_${crypto.randomUUID()}`,
|
|
} = {}) {
|
|
const normalizedPrompt = String(prompt ?? '').trim();
|
|
if (!normalizedPrompt) throw createClientError('INVALID_PROMPT', '图片描述不能为空');
|
|
if (!presetId) throw createClientError('INVALID_PRESET', '图片 preset 不能为空');
|
|
|
|
const createResponse = await request('/v1/generation-jobs', {
|
|
method: 'POST',
|
|
headers: { 'idempotency-key': idempotencyKey },
|
|
body: JSON.stringify({
|
|
idempotency_key: idempotencyKey,
|
|
mode: 'text_to_image',
|
|
prompt: normalizedPrompt,
|
|
negative_prompt: String(negativePrompt ?? '').trim(),
|
|
preset: { id: presetId, version: presetVersion },
|
|
...(width && height ? { width, height } : {}),
|
|
count: 1,
|
|
output_format: 'webp',
|
|
consumer_ref: String(consumerRef ?? '').trim() || undefined,
|
|
}),
|
|
}, [200, 201]);
|
|
const created = await safeJson(createResponse);
|
|
const jobId = String(created?.job_id ?? '').trim();
|
|
if (!jobId) throw createClientError('IMAGE_MAKE_INVALID_RESPONSE', 'image_make 未返回任务 ID');
|
|
|
|
const deadline = Date.now() + overallTimeout;
|
|
let job = created;
|
|
if (!ACTIVE_STATUSES.has(String(job?.status ?? '')) && !job?.result && created?.status_url) {
|
|
job = await safeJson(await request(created.status_url));
|
|
}
|
|
while (ACTIVE_STATUSES.has(String(job?.status ?? ''))) {
|
|
if (Date.now() >= deadline) {
|
|
throw createClientError('IMAGE_MAKE_TIMEOUT', '图片生成超时', { jobId });
|
|
}
|
|
await sleep(Math.min(pollingDelay, Math.max(1, deadline - Date.now())));
|
|
job = await safeJson(await request(`/v1/generation-jobs/${encodeURIComponent(jobId)}`));
|
|
}
|
|
if (job?.status !== 'succeeded' || !job?.result?.download_url) {
|
|
throw createClientError(
|
|
'IMAGE_MAKE_JOB_FAILED',
|
|
job?.error_message_safe ?? '图片生成失败',
|
|
{ jobId, jobStatus: job?.status ?? 'unknown' },
|
|
);
|
|
}
|
|
|
|
const download = await request(job.result.download_url, {
|
|
headers: { accept: 'image/png,image/jpeg,image/webp' },
|
|
});
|
|
const mimeType = String(download.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
|
if (!SUPPORTED_IMAGE_TYPES.has(mimeType)) {
|
|
throw createClientError('IMAGE_MAKE_INVALID_RESULT', 'image_make 返回了不支持的图片格式', { jobId });
|
|
}
|
|
const declaredSize = Number(download.headers.get('content-length') ?? 0);
|
|
if (declaredSize > resultLimit) {
|
|
throw createClientError('IMAGE_MAKE_RESULT_TOO_LARGE', 'image_make 图片超过大小限制', { jobId });
|
|
}
|
|
const buffer = Buffer.from(await download.arrayBuffer());
|
|
if (!buffer.length || buffer.length > resultLimit || !imageMagicMatches(buffer, mimeType)) {
|
|
throw createClientError('IMAGE_MAKE_INVALID_RESULT', 'image_make 图片内容校验失败', { jobId });
|
|
}
|
|
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
const expectedHashes = [job.result.sha256, download.headers.get('x-artifact-sha256')]
|
|
.map((value) => String(value ?? '').trim().toLowerCase())
|
|
.filter(Boolean);
|
|
if (expectedHashes.some((value) => value !== sha256)) {
|
|
throw createClientError('IMAGE_MAKE_CHECKSUM_MISMATCH', 'image_make 图片摘要校验失败', { jobId });
|
|
}
|
|
return {
|
|
jobId,
|
|
buffer,
|
|
mimeType,
|
|
sha256,
|
|
width: Number(job.result.width ?? 0) || null,
|
|
height: Number(job.result.height ?? 0) || null,
|
|
};
|
|
}
|
|
|
|
async function acknowledge(jobId, consumerAssetRef) {
|
|
await request(`/v1/generation-jobs/${encodeURIComponent(jobId)}/acknowledge`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ consumer_asset_ref: consumerAssetRef }),
|
|
});
|
|
return { ok: true };
|
|
}
|
|
|
|
return { baseUrl: normalizedBaseUrl, generateImage, acknowledge };
|
|
}
|
|
|
|
export function createImageMakeClientFromEnv(env = process.env, options = {}) {
|
|
const baseUrl = String(env.IMAGE_MAKE_BASE_URL ?? '').trim();
|
|
const token = String(env.IMAGE_MAKE_TOKEN ?? '').trim();
|
|
if (!baseUrl || !token) return null;
|
|
return createImageMakeClient({
|
|
baseUrl,
|
|
token,
|
|
requestTimeoutMs: env.IMAGE_MAKE_REQUEST_TIMEOUT_MS,
|
|
generationTimeoutMs: env.IMAGE_MAKE_GENERATION_TIMEOUT_MS,
|
|
pollIntervalMs: env.IMAGE_MAKE_POLL_INTERVAL_MS,
|
|
maxResultBytes: env.IMAGE_MAKE_MAX_RESULT_BYTES,
|
|
...options,
|
|
});
|
|
}
|