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