|
@@ -0,0 +1,611 @@
|
|
|
|
|
+'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)) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ images.push(readAsDataUri(path.join(shopDir, f)));
|
|
|
|
|
+ } 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;
|
|
|
|
|
+
|
|
|
|
|
+ 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 persistJob(job) {
|
|
|
|
|
+ if (!job || !job.id) return;
|
|
|
|
|
+ persistedTasks.set(job.id, taskSnapshot(job));
|
|
|
|
|
+ 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);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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);
|
|
|
|
|
+ } else if (job.state === 'running' || job.state === 'submitted') {
|
|
|
|
|
+ if (!job.taskId) {
|
|
|
|
|
+ job.state = 'failed';
|
|
|
|
|
+ job.finishedAt = Date.now();
|
|
|
|
|
+ job.error = '服务重启时任务尚未获得供应商任务 ID,请重新提交';
|
|
|
|
|
+ persistJob(job);
|
|
|
|
|
+ } 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);
|
|
|
|
|
+ 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);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function runJob(job) {
|
|
|
|
|
+ activeCount++;
|
|
|
|
|
+ job.state = 'running';
|
|
|
|
|
+ job.startedAt = job.startedAt || Date.now();
|
|
|
|
|
+ persistJob(job);
|
|
|
|
|
+ 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.finishedAt = Date.now();
|
|
|
|
|
+ persistJob(job);
|
|
|
|
|
+ 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);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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,
|
|
|
|
|
+ buildImagesAndPrompt,
|
|
|
|
|
+ readAsDataUri,
|
|
|
|
|
+ loadCreationHistory,
|
|
|
|
|
+ appendCreationHistory,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+module.exports = { createGenerationService };
|