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', 'validating']); 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 = 15 * 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, 15 * 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, }); }