feat: integrate image_make with MindSpace assets
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
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;
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user