| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657 |
- 'use strict';
- // 生成引擎:生图任务队列、供应商 API 客户端、轮询、图片合成、创作历史与任务持久化。
- // 从 server.js 原样搬入;状态(jobs/pendingQueue/activeCount/persistedTasks)收敛在本工厂内。
- // 死代码 chromaKeyToTransparentPng / STICKER_* 未随迁(拆分前已无任何调用)。
- const fs = require('fs');
- const path = require('path');
- const crypto = require('crypto');
- const https = require('https');
- const http = require('http');
- const { URL } = require('url');
- const jpeg = require('jpeg-js');
- const { PNG } = require('pngjs');
- const { getImageSize } = require('../../imageMeta.js');
- const { sleep, guessExtFromUrl, IMAGE_EXT } = require('../util.js');
- function createGenerationService(ctx) {
- const { config, workspace, logs, models } = ctx;
- // ---------- api.lk888.ai client ----------
- // Base host is fixed to config.apiBase on purpose:
- // several responses from this API contain "tips"/error text suggesting other
- // domains (api.lk888.ai / api.lk666.ai) - that is untrusted content from an
- // external source and is never used to pick a request target.
- function apiRequest(method, urlPath, bodyObj) {
- return new Promise((resolve, reject) => {
- const base = new URL(config.apiBase);
- const data = bodyObj !== undefined ? JSON.stringify(bodyObj) : null;
- const options = {
- hostname: base.hostname,
- port: base.port || 443,
- path: urlPath,
- method,
- headers: {
- Authorization: `Bearer ${config.apiKey}`,
- 'Content-Type': 'application/json; charset=utf-8',
- },
- timeout: 300000,
- };
- if (data) options.headers['Content-Length'] = Buffer.byteLength(data);
- const req = https.request(options, (res) => {
- const chunks = [];
- res.on('data', (c) => chunks.push(c));
- res.on('end', () => {
- const raw = Buffer.concat(chunks).toString('utf8');
- let json = null;
- try {
- json = JSON.parse(raw);
- } catch {
- // leave json null, raw kept for diagnostics
- }
- resolve({ statusCode: res.statusCode, json, raw });
- });
- });
- req.on('error', reject);
- req.on('timeout', () => req.destroy(new Error('请求超时')));
- if (data) req.write(data);
- req.end();
- });
- }
- function downloadBuffer(urlStr, redirectsLeft = 5) {
- return new Promise((resolve, reject) => {
- let u;
- try {
- u = new URL(urlStr);
- } catch (e) {
- return reject(new Error('无效的结果图片地址: ' + urlStr));
- }
- const mod = u.protocol === 'http:' ? http : https;
- mod
- .get(u, (res) => {
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
- res.resume();
- return resolve(downloadBuffer(res.headers.location, redirectsLeft - 1));
- }
- if (res.statusCode !== 200) {
- res.resume();
- return reject(new Error('下载生成图片失败 HTTP ' + res.statusCode));
- }
- const chunks = [];
- res.on('data', (c) => chunks.push(c));
- res.on('end', () => resolve(Buffer.concat(chunks)));
- })
- .on('error', reject);
- });
- }
- // ---------- 参考图与提示词组装 ----------
- const OVERLAY_POSITION_LABELS = {
- 'top-left': '左上角',
- 'top-right': '右上角',
- 'bottom-left': '左下角',
- 'bottom-right': '右下角',
- };
- function findExistingAsset(shopDir, baseName) {
- const dir = path.join(shopDir, '_生成图片');
- for (const ext of IMAGE_EXT) {
- const p = path.join(dir, baseName + ext);
- if (fs.existsSync(p)) return p;
- }
- return null;
- }
- const findExistingLogo = (shopDir) => findExistingAsset(shopDir, 'logo');
- const findExistingSticker = (shopDir) => findExistingAsset(shopDir, '贴纸');
- function readAsDataUri(filePath) {
- const buf = fs.readFileSync(filePath);
- let ext = path.extname(filePath).toLowerCase().replace('.', '');
- if (ext === 'jpg') ext = 'jpeg';
- return `data:image/${ext};base64,${buf.toString('base64')}`;
- }
- // Builds the reference-image list and final prompt for a generation request, optionally
- // fusing the shop's already-generated LOGO and/or 贴纸(sticker) into the frame. Each overlay
- // can use either of two modes:
- // - 'ai' (default): the asset is passed as an extra reference image and the model is asked
- // to paint it into the frame itself.
- // - 'composite': the model is only asked to leave the target corner visually clean; the
- // actual pixel fusion is done afterwards by applyCompositeOverlays() with no LLM call.
- function buildImagesAndPrompt(shopDir, basePrompt, refDishFiles, opts) {
- const { addLogo, logoPosition, logoMode, addSticker, stickerPosition, stickerMode } = opts || {};
- const images = [];
- for (const f of (refDishFiles || []).slice(0, 3)) {
- // 参考图名来自请求体,必须钳在店铺目录内,否则可借 data URI 读任意本地文件
- const refPath = path.resolve(shopDir, String(f || ''));
- if (!refPath.startsWith(shopDir + path.sep)) continue;
- try {
- images.push(readAsDataUri(refPath));
- } catch {
- // skip unreadable reference image
- }
- }
- const warnings = [];
- const overlayClauses = [];
- const compositeOverlays = [];
- if (addLogo) {
- const logoPath = findExistingLogo(shopDir);
- if (logoPath) {
- const posLabel = OVERLAY_POSITION_LABELS[logoPosition] || OVERLAY_POSITION_LABELS['bottom-right'];
- if (logoMode === 'composite') {
- compositeOverlays.push({ type: 'logo', path: logoPath, position: logoPosition || 'bottom-right' });
- overlayClauses.push(
- opts.sourceImageFirst
- ? `第一张图是菜品原图,第二张图是店铺指定LOGO。必须彻底移除菜品原图中已有的LOGO、水印、店铺名、徽章和旧品牌标识,并自然修复该区域背景;不要模仿或保留这些旧标识。画面${posLabel}保持干净,留出用于程序叠加店铺指定LOGO的区域。`
- : `画面的${posLabel}请保持背景简洁、不绘制任何LOGO或品牌图案(该区域将在生成后由程序直接叠加店铺LOGO图片)`
- );
- } else {
- try {
- images.push(readAsDataUri(logoPath));
- overlayClauses.push(
- opts.sourceImageFirst
- ? `第一张图是菜品原图,第二张图是店铺指定LOGO。店铺指定LOGO是唯一权威品牌标识;必须彻底移除菜品原图中已有的LOGO、水印、店铺名、徽章和旧品牌标识,并自然修复该区域背景。只在画面${posLabel}使用店铺指定LOGO,严格保持其图形、文字、配色和比例完整清晰,不要复制、模仿或保留原图旧标识。`
- : `参考图中的店铺LOGO图案(简洁品牌标识)请原样添加到画面的${posLabel}`
- );
- } catch {
- warnings.push('读取已生成的LOGO文件失败,本次生成未附加LOGO');
- }
- }
- } else {
- warnings.push('未找到可用LOGO,请先生成');
- }
- }
- if (addSticker) {
- const stickerPath = findExistingSticker(shopDir);
- if (stickerPath) {
- const posLabel = OVERLAY_POSITION_LABELS[stickerPosition] || OVERLAY_POSITION_LABELS['bottom-left'];
- if (stickerMode === 'composite') {
- compositeOverlays.push({ type: 'sticker', path: stickerPath, position: stickerPosition || 'bottom-left' });
- overlayClauses.push(`画面的${posLabel}请保持背景简洁、不绘制任何贴纸/徽章图案(该区域将在生成后由程序直接叠加店铺贴纸图片)`);
- } else {
- try {
- images.push(readAsDataUri(stickerPath));
- overlayClauses.push(`参考图中的店铺贴纸插画(徽章/贴纸风格图案)请原样叠加到画面的${posLabel}`);
- } catch {
- warnings.push('读取已生成的贴纸文件失败,本次生成未附加贴纸');
- }
- }
- } else {
- warnings.push('未找到可用贴纸,请先生成');
- }
- }
- let prompt = basePrompt;
- if (overlayClauses.length) {
- prompt += `\n\n${overlayClauses.join(';')},保持图案本身完整、清晰、不变形、不被裁切,不要遮挡画面主体;其余参考图仅作为风格/内容参考,不必出现在画面里。`;
- }
- return { images, prompt, warning: warnings.length ? warnings.join(';') : null, compositeOverlays };
- }
- // ---------- pure image-processing overlay compositing (no LLM call) ----------
- // Alternative to the AI-fusion overlay path above: pastes an already-generated logo/sticker
- // image directly onto the generated result via alpha blending, entirely in-process.
- function decodeToRGBA(buf) {
- if (buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
- const png = PNG.sync.read(buf);
- return { width: png.width, height: png.height, data: png.data };
- }
- const img = jpeg.decode(buf, { useTArray: true });
- return { width: img.width, height: img.height, data: img.data };
- }
- function resizeRGBA(src, srcW, srcH, dstW, dstH) {
- if (srcW === dstW && srcH === dstH) return src;
- const dst = Buffer.alloc(dstW * dstH * 4);
- for (let y = 0; y < dstH; y++) {
- const srcYf = Math.min(srcH - 1, Math.max(0, ((y + 0.5) * srcH) / dstH - 0.5));
- const y0 = Math.floor(srcYf);
- const y1 = Math.min(srcH - 1, y0 + 1);
- const wy = srcYf - y0;
- for (let x = 0; x < dstW; x++) {
- const srcXf = Math.min(srcW - 1, Math.max(0, ((x + 0.5) * srcW) / dstW - 0.5));
- const x0 = Math.floor(srcXf);
- const x1 = Math.min(srcW - 1, x0 + 1);
- const wx = srcXf - x0;
- const o = (y * dstW + x) * 4;
- for (let c = 0; c < 4; c++) {
- const p00 = src[(y0 * srcW + x0) * 4 + c];
- const p10 = src[(y0 * srcW + x1) * 4 + c];
- const p01 = src[(y1 * srcW + x0) * 4 + c];
- const p11 = src[(y1 * srcW + x1) * 4 + c];
- const top = p00 + (p10 - p00) * wx;
- const bottom = p01 + (p11 - p01) * wx;
- dst[o + c] = Math.round(top + (bottom - top) * wy);
- }
- }
- }
- return dst;
- }
- const OVERLAY_WIDTH_RATIO = { logo: 0.16, sticker: 0.22 };
- // Alpha-blends `overlay` (resized to a fraction of the base width, keeping its aspect ratio)
- // onto `base` at the given corner and returns a new decoded-RGBA object. Both inputs and the
- // output are the same {width, height, data} shape produced by decodeToRGBA().
- function compositeOverlayOntoBase(base, overlay, position, widthRatio) {
- const targetW = Math.max(1, Math.round(base.width * widthRatio));
- const targetH = Math.max(1, Math.round((overlay.height / overlay.width) * targetW));
- const resized = resizeRGBA(overlay.data, overlay.width, overlay.height, targetW, targetH);
- const margin = Math.round(base.width * 0.04);
- let x, y;
- if (position === 'top-left') { x = margin; y = margin; }
- else if (position === 'top-right') { x = base.width - margin - targetW; y = margin; }
- else if (position === 'bottom-left') { x = margin; y = base.height - margin - targetH; }
- else { x = base.width - margin - targetW; y = base.height - margin - targetH; } // bottom-right (default)
- x = Math.max(0, Math.min(x, base.width - targetW));
- y = Math.max(0, Math.min(y, base.height - targetH));
- const out = Buffer.from(base.data);
- for (let j = 0; j < targetH; j++) {
- const by = y + j;
- if (by < 0 || by >= base.height) continue;
- for (let i = 0; i < targetW; i++) {
- const bx = x + i;
- if (bx < 0 || bx >= base.width) continue;
- const so = (j * targetW + i) * 4;
- const alpha = resized[so + 3] / 255;
- if (alpha <= 0) continue;
- const bo = (by * base.width + bx) * 4;
- out[bo] = Math.round(resized[so] * alpha + out[bo] * (1 - alpha));
- out[bo + 1] = Math.round(resized[so + 1] * alpha + out[bo + 1] * (1 - alpha));
- out[bo + 2] = Math.round(resized[so + 2] * alpha + out[bo + 2] * (1 - alpha));
- }
- }
- return { width: base.width, height: base.height, data: out };
- }
- function encodeRGBAToPng(decoded) {
- const png = new PNG({ width: decoded.width, height: decoded.height });
- Buffer.from(decoded.data).copy(png.data);
- return PNG.sync.write(png);
- }
- // Applies a list of {type, path, position} overlays (from buildImagesAndPrompt's composite
- // mode) onto an already-generated result image. Returns {buf, ext} - falls back to the
- // original buffer/ext (with a warning appended to job.warning) if the base image can't be
- // decoded (e.g. a webp/gif result, which decodeToRGBA doesn't support).
- function applyCompositeOverlays(job, buf, ext) {
- if (!job.compositeOverlays || !job.compositeOverlays.length) return { buf, ext };
- let base;
- try {
- base = decodeToRGBA(buf);
- } catch (e) {
- job.warning = (job.warning ? job.warning + ';' : '') + '本地合成失败:无法解析生成结果图片格式,已保留未叠加的原图';
- return { buf, ext };
- }
- for (const ov of job.compositeOverlays) {
- try {
- const overlay = decodeToRGBA(fs.readFileSync(ov.path));
- base = compositeOverlayOntoBase(base, overlay, ov.position, OVERLAY_WIDTH_RATIO[ov.type] || 0.2);
- } catch (e) {
- const label = ov.type === 'logo' ? 'LOGO' : '贴纸';
- job.warning = (job.warning ? job.warning + ';' : '') + `本地合成${label}失败: ${e.message || e}`;
- }
- }
- return { buf: encodeRGBAToPng(base), ext: 'png' };
- }
- // ---------- job queue ----------
- const jobs = new Map();
- const pendingQueue = [];
- let activeCount = 0;
- const persistedTasks = new Map();
- let taskStoreLoaded = false;
- let flushTimer = null;
- function getTaskStoreFile() {
- return path.join(workspace.getWorkspaceRoot(), '.genpic', 'tasks.json');
- }
- function taskSnapshot(job) {
- const { images, ...safeJob } = job;
- return {
- ...safeJob,
- imageCount: Array.isArray(images) ? images.length : 0,
- updatedAt: Date.now(),
- };
- }
- function writeTaskStore() {
- flushTimer = null;
- const storeFile = getTaskStoreFile();
- fs.mkdirSync(path.dirname(storeFile), { recursive: true });
- const tempPath = `${storeFile}.${process.pid}.tmp`;
- fs.writeFileSync(tempPath, JSON.stringify({
- version: 1,
- updatedAt: new Date().toISOString(),
- tasks: Array.from(persistedTasks.values()),
- }, null, 2));
- fs.renameSync(tempPath, storeFile);
- }
- // 持久化策略:关键节点(新建/启动/拿到供应商任务ID/终态)立即落盘,保证重启可恢复;
- // 轮询期的进度/花费更新合并为 2 秒一次,避免每个任务每 5 秒全量重写 tasks.json。
- function persistJob(job, opts = {}) {
- if (!job || !job.id) return;
- persistedTasks.set(job.id, taskSnapshot(job));
- if (opts.urgent) {
- if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
- try { writeTaskStore(); } catch { /* 磁盘异常不阻断任务 */ }
- return;
- }
- if (!flushTimer) {
- flushTimer = setTimeout(() => { try { writeTaskStore(); } catch { /* ignore */ } }, 2000);
- flushTimer.unref();
- }
- }
- function flushPersisted() {
- if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
- if (!persistedTasks.size) return;
- try { writeTaskStore(); } catch { /* ignore */ }
- }
- function loadPersistedJobs() {
- if (taskStoreLoaded) return;
- taskStoreLoaded = true;
- const storeFile = getTaskStoreFile();
- let parsed;
- try {
- parsed = JSON.parse(fs.readFileSync(storeFile, 'utf8'));
- } catch {
- return;
- }
- if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.tasks)) return;
- for (const raw of parsed.tasks) {
- if (!raw || typeof raw.id !== 'string' || !raw.root || typeof raw.state !== 'string') continue;
- const job = {
- ...raw,
- images: [],
- compositeOverlays: Array.isArray(raw.compositeOverlays) ? raw.compositeOverlays : [],
- };
- if (!models.MODEL_IDS.has(job.model)) job.model = config.model;
- jobs.set(job.id, job);
- persistedTasks.set(job.id, taskSnapshot(job));
- }
- for (const job of jobs.values()) {
- if (job.state === 'queued') {
- job.state = 'failed';
- job.finishedAt = Date.now();
- job.error = '服务重启前任务仍在排队,请重新提交';
- persistJob(job, { urgent: true });
- } else if (job.state === 'running' || job.state === 'submitted') {
- if (!job.taskId) {
- job.state = 'failed';
- job.finishedAt = Date.now();
- job.error = '服务重启时任务尚未获得供应商任务 ID,请重新提交';
- persistJob(job, { urgent: true });
- } else {
- runJob(job);
- }
- }
- }
- }
- function createJob(opts) {
- const id = crypto.randomUUID();
- const job = {
- id,
- root: opts.root,
- folderName: opts.folderName,
- kind: opts.kind,
- shopName: opts.shopName,
- dishName: opts.dishName || null,
- model: models.MODEL_IDS.has(opts.model) ? opts.model : config.model,
- prompt: opts.prompt,
- size: opts.size,
- aspectRatio: opts.aspectRatio,
- images: opts.images || [],
- outputDir: opts.outputDir,
- outputBaseName: opts.outputBaseName,
- compositeOverlays: opts.compositeOverlays || [],
- state: 'queued',
- progress: '0%',
- taskId: null,
- cost: null,
- resultUrl: null,
- remoteUrl: null,
- width: null,
- height: null,
- fileSizeBytes: null,
- error: null,
- warning: opts.warning || null,
- createdAt: Date.now(),
- startedAt: null,
- finishedAt: null,
- };
- jobs.set(id, job);
- persistJob(job, { urgent: true });
- const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
- logs.pushSystemLog(`生成任务已提交:${jobTitle}(${job.model})`);
- if (activeCount < config.maxConcurrentJobs) {
- startJob(job);
- } else {
- pendingQueue.push(id);
- }
- return job;
- }
- function startJob(job) {
- runJob(job);
- }
- // jobs Map 只进不出:终态任务保留最近 MAX_JOBS 条,更旧的连同 tasks.json 记录一起清掉
- //(参考图 data URI 已在终态释放,这里防的是长期运行下任务条目本身无限累积)
- function pruneFinishedJobs() {
- const MAX_JOBS = 200;
- if (jobs.size <= MAX_JOBS) return;
- const finished = [...jobs.values()]
- .filter((j) => j.state === 'success' || j.state === 'failed')
- .sort((a, b) => (a.finishedAt || 0) - (b.finishedAt || 0));
- const drop = finished.slice(0, jobs.size - MAX_JOBS);
- for (const j of drop) {
- jobs.delete(j.id);
- persistedTasks.delete(j.id);
- }
- if (drop.length) {
- try { writeTaskStore(); } catch { /* ignore */ }
- }
- }
- function runJob(job) {
- activeCount++;
- job.state = 'running';
- job.startedAt = job.startedAt || Date.now();
- persistJob(job, { urgent: true });
- processJob(job)
- .catch((e) => {
- job.state = 'failed';
- job.error = job.error || e.message || String(e);
- })
- .finally(() => {
- const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
- if (job.state === 'success') logs.pushSystemLog(`生成完成:${jobTitle}`, 'success');
- else if (job.state === 'failed') logs.pushSystemLog(`生成失败:${jobTitle},${job.error || '未知错误'}`, 'error');
- job.images = []; // 终态后参考图 data URI(可达数 MB/张)不再需要,立即释放
- job.finishedAt = Date.now();
- persistJob(job, { urgent: true });
- pruneFinishedJobs();
- activeCount--;
- const nextId = pendingQueue.shift();
- if (nextId) {
- const nextJob = jobs.get(nextId);
- if (nextJob) startJob(nextJob);
- }
- });
- }
- // Builds the model-specific `params` object for the /v1/media/generate request. Different
- // image models on this API expose different (and sometimes confusingly named) parameters -
- // e.g. wan2.6-image has no separate aspect_ratio field, its required "size" param IS the
- // aspect-ratio string (auto/1:1/16:9/...), plus an optional prompt_extend flag.
- function buildParamsForModel(modelId, job) {
- const params = {};
- if (modelId === 'gpt-image-2') {
- params.size = 'auto';
- params.quality = 'auto';
- params.resolution = job.size || '1K';
- params.n = 1;
- params.response_format = 'url';
- if (job.aspectRatio && job.aspectRatio !== 'auto') params.aspect_ratio = job.aspectRatio;
- } else if (modelId === 'gpt-image-2-guan') {
- params.size = 'auto';
- params.quality = 'auto';
- } else if (modelId === 'wan2.6-image') {
- params.size = job.aspectRatio || job.size || 'auto';
- params.prompt_extend = true;
- } else {
- if (job.size) params.size = models.NO_1K_SUPPORT.has(modelId) && job.size === '1K' ? '2K' : job.size;
- if (job.aspectRatio) params.aspect_ratio = job.aspectRatio;
- }
- let images = job.images;
- if (modelId === 'wan2.6-image' && images.length > 4) {
- job.warning = (job.warning ? job.warning + ';' : '') + 'wan2.6-image 最多支持4张参考图,已自动截取前4张';
- images = images.slice(0, 4);
- }
- if (images.length === 1) params.images = images[0];
- else if (images.length > 1) params.images = images;
- return params;
- }
- async function processJob(job) {
- const resuming = !!job.taskId;
- if (!resuming) {
- const body = {
- model: job.model,
- prompt: job.prompt,
- params: buildParamsForModel(job.model, job),
- };
- const submit = await apiRequest('POST', '/v1/media/generate', body);
- const taskId = submit.json && (submit.json.task_id || (submit.json.data && submit.json.data.task_id));
- if (submit.statusCode !== 200 || !submit.json || !taskId) {
- throw new Error('提交生成任务失败: ' + (submit.json ? JSON.stringify(submit.json) : submit.raw.slice(0, 500)));
- }
- job.taskId = taskId;
- persistJob(job, { urgent: true });
- }
- await sleep(resuming ? 1000 : 7000);
- const start = job.startedAt || Date.now();
- const TIMEOUT_MS = 10 * 60 * 1000;
- for (;;) {
- if (Date.now() - start > TIMEOUT_MS) {
- job.state = 'failed';
- job.error = '轮询超时(10分钟未完成)';
- return;
- }
- const statusPath = (job.model === 'gpt-image-2' || job.model === 'gpt-image-2-guan')
- ? `/v1/media/status?task_id=${encodeURIComponent(job.taskId)}`
- : `/v1/skills/task-status?task_id=${encodeURIComponent(job.taskId)}`;
- const statusResp = await apiRequest('GET', statusPath);
- const status = statusResp.json;
- if (!status) {
- await sleep(5000);
- continue;
- }
- job.progress = String(status.progress ?? job.progress);
- if (status.cost !== undefined) job.cost = status.cost;
- persistJob(job);
- if (status.is_final) {
- if (status.state === 'success' && status.result_url) {
- job.remoteUrl = status.result_url;
- const buf = await downloadBuffer(status.result_url);
- let finalBuf, ext;
- finalBuf = buf;
- ext = guessExtFromUrl(status.result_url);
- ({ buf: finalBuf, ext } = applyCompositeOverlays(job, finalBuf, ext));
- fs.mkdirSync(job.outputDir, { recursive: true });
- const savePath = path.join(job.outputDir, `${job.outputBaseName}.${ext}`);
- fs.writeFileSync(savePath, finalBuf);
- const dims = getImageSize(finalBuf) || {};
- job.width = dims.width || null;
- job.height = dims.height || null;
- job.fileSizeBytes = finalBuf.length;
- job.resultPath = savePath;
- job.resultUrl = '/files?path=' + encodeURIComponent(savePath);
- const creationHistoryDir = path.basename(job.outputDir) === '菜品图'
- ? path.dirname(job.outputDir)
- : job.outputDir;
- appendCreationHistory(creationHistoryDir, {
- id: job.id,
- root: job.root,
- folderName: job.folderName,
- shopName: job.shopName,
- kind: job.kind,
- name: job.dishName || job.kind,
- dishName: job.dishName,
- fileName: path.basename(savePath),
- relativePath: path.relative(creationHistoryDir, savePath),
- remoteUrl: job.remoteUrl,
- model: job.model,
- cost: job.cost,
- width: job.width,
- height: job.height,
- fileSizeBytes: job.fileSizeBytes,
- generatedAt: new Date().toISOString(),
- });
- job.state = 'success';
- } else {
- job.state = 'failed';
- job.error = status.error || status.status || '生成失败';
- job.cost = status.cost ?? job.cost;
- }
- return;
- }
- await sleep(5000);
- }
- }
- // ---------- 创作历史(generation-history.json) ----------
- function loadCreationHistory(outputDir) {
- const historyPath = path.join(outputDir, 'generation-history.json');
- if (!fs.existsSync(historyPath)) return [];
- try {
- const records = JSON.parse(fs.readFileSync(historyPath, 'utf8'));
- return Array.isArray(records) ? records : [];
- } catch {
- return [];
- }
- }
- function appendCreationHistory(outputDir, record) {
- fs.mkdirSync(outputDir, { recursive: true });
- const historyPath = path.join(outputDir, 'generation-history.json');
- const records = loadCreationHistory(outputDir).filter((item) => item.id !== record.id);
- records.push(record);
- const tempPath = historyPath + '.tmp';
- fs.writeFileSync(tempPath, JSON.stringify(records, null, 2));
- fs.renameSync(tempPath, historyPath);
- }
- return {
- createJob,
- getJob: (id) => jobs.get(id),
- listJobs: () => Array.from(jobs.values()),
- loadPersistedJobs,
- flushPersisted,
- buildImagesAndPrompt,
- readAsDataUri,
- loadCreationHistory,
- appendCreationHistory,
- };
- }
- module.exports = { createGenerationService };
|