Files
memind/image-make-client.mjs
john 4b15610aa8
Memind CI / Test, build, and release guards (pull_request) Successful in 2m47s
feat(image): complete reviewed generation delivery
2026-07-20 20:04:53 +08:00

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', '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,
});
}