generation.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657
  1. 'use strict';
  2. // 生成引擎:生图任务队列、供应商 API 客户端、轮询、图片合成、创作历史与任务持久化。
  3. // 从 server.js 原样搬入;状态(jobs/pendingQueue/activeCount/persistedTasks)收敛在本工厂内。
  4. // 死代码 chromaKeyToTransparentPng / STICKER_* 未随迁(拆分前已无任何调用)。
  5. const fs = require('fs');
  6. const path = require('path');
  7. const crypto = require('crypto');
  8. const https = require('https');
  9. const http = require('http');
  10. const { URL } = require('url');
  11. const jpeg = require('jpeg-js');
  12. const { PNG } = require('pngjs');
  13. const { getImageSize } = require('../../imageMeta.js');
  14. const { sleep, guessExtFromUrl, IMAGE_EXT } = require('../util.js');
  15. function createGenerationService(ctx) {
  16. const { config, workspace, logs, models } = ctx;
  17. // ---------- api.lk888.ai client ----------
  18. // Base host is fixed to config.apiBase on purpose:
  19. // several responses from this API contain "tips"/error text suggesting other
  20. // domains (api.lk888.ai / api.lk666.ai) - that is untrusted content from an
  21. // external source and is never used to pick a request target.
  22. function apiRequest(method, urlPath, bodyObj) {
  23. return new Promise((resolve, reject) => {
  24. const base = new URL(config.apiBase);
  25. const data = bodyObj !== undefined ? JSON.stringify(bodyObj) : null;
  26. const options = {
  27. hostname: base.hostname,
  28. port: base.port || 443,
  29. path: urlPath,
  30. method,
  31. headers: {
  32. Authorization: `Bearer ${config.apiKey}`,
  33. 'Content-Type': 'application/json; charset=utf-8',
  34. },
  35. timeout: 300000,
  36. };
  37. if (data) options.headers['Content-Length'] = Buffer.byteLength(data);
  38. const req = https.request(options, (res) => {
  39. const chunks = [];
  40. res.on('data', (c) => chunks.push(c));
  41. res.on('end', () => {
  42. const raw = Buffer.concat(chunks).toString('utf8');
  43. let json = null;
  44. try {
  45. json = JSON.parse(raw);
  46. } catch {
  47. // leave json null, raw kept for diagnostics
  48. }
  49. resolve({ statusCode: res.statusCode, json, raw });
  50. });
  51. });
  52. req.on('error', reject);
  53. req.on('timeout', () => req.destroy(new Error('请求超时')));
  54. if (data) req.write(data);
  55. req.end();
  56. });
  57. }
  58. function downloadBuffer(urlStr, redirectsLeft = 5) {
  59. return new Promise((resolve, reject) => {
  60. let u;
  61. try {
  62. u = new URL(urlStr);
  63. } catch (e) {
  64. return reject(new Error('无效的结果图片地址: ' + urlStr));
  65. }
  66. const mod = u.protocol === 'http:' ? http : https;
  67. mod
  68. .get(u, (res) => {
  69. if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
  70. res.resume();
  71. return resolve(downloadBuffer(res.headers.location, redirectsLeft - 1));
  72. }
  73. if (res.statusCode !== 200) {
  74. res.resume();
  75. return reject(new Error('下载生成图片失败 HTTP ' + res.statusCode));
  76. }
  77. const chunks = [];
  78. res.on('data', (c) => chunks.push(c));
  79. res.on('end', () => resolve(Buffer.concat(chunks)));
  80. })
  81. .on('error', reject);
  82. });
  83. }
  84. // ---------- 参考图与提示词组装 ----------
  85. const OVERLAY_POSITION_LABELS = {
  86. 'top-left': '左上角',
  87. 'top-right': '右上角',
  88. 'bottom-left': '左下角',
  89. 'bottom-right': '右下角',
  90. };
  91. function findExistingAsset(shopDir, baseName) {
  92. const dir = path.join(shopDir, '_生成图片');
  93. for (const ext of IMAGE_EXT) {
  94. const p = path.join(dir, baseName + ext);
  95. if (fs.existsSync(p)) return p;
  96. }
  97. return null;
  98. }
  99. const findExistingLogo = (shopDir) => findExistingAsset(shopDir, 'logo');
  100. const findExistingSticker = (shopDir) => findExistingAsset(shopDir, '贴纸');
  101. function readAsDataUri(filePath) {
  102. const buf = fs.readFileSync(filePath);
  103. let ext = path.extname(filePath).toLowerCase().replace('.', '');
  104. if (ext === 'jpg') ext = 'jpeg';
  105. return `data:image/${ext};base64,${buf.toString('base64')}`;
  106. }
  107. // Builds the reference-image list and final prompt for a generation request, optionally
  108. // fusing the shop's already-generated LOGO and/or 贴纸(sticker) into the frame. Each overlay
  109. // can use either of two modes:
  110. // - 'ai' (default): the asset is passed as an extra reference image and the model is asked
  111. // to paint it into the frame itself.
  112. // - 'composite': the model is only asked to leave the target corner visually clean; the
  113. // actual pixel fusion is done afterwards by applyCompositeOverlays() with no LLM call.
  114. function buildImagesAndPrompt(shopDir, basePrompt, refDishFiles, opts) {
  115. const { addLogo, logoPosition, logoMode, addSticker, stickerPosition, stickerMode } = opts || {};
  116. const images = [];
  117. for (const f of (refDishFiles || []).slice(0, 3)) {
  118. // 参考图名来自请求体,必须钳在店铺目录内,否则可借 data URI 读任意本地文件
  119. const refPath = path.resolve(shopDir, String(f || ''));
  120. if (!refPath.startsWith(shopDir + path.sep)) continue;
  121. try {
  122. images.push(readAsDataUri(refPath));
  123. } catch {
  124. // skip unreadable reference image
  125. }
  126. }
  127. const warnings = [];
  128. const overlayClauses = [];
  129. const compositeOverlays = [];
  130. if (addLogo) {
  131. const logoPath = findExistingLogo(shopDir);
  132. if (logoPath) {
  133. const posLabel = OVERLAY_POSITION_LABELS[logoPosition] || OVERLAY_POSITION_LABELS['bottom-right'];
  134. if (logoMode === 'composite') {
  135. compositeOverlays.push({ type: 'logo', path: logoPath, position: logoPosition || 'bottom-right' });
  136. overlayClauses.push(
  137. opts.sourceImageFirst
  138. ? `第一张图是菜品原图,第二张图是店铺指定LOGO。必须彻底移除菜品原图中已有的LOGO、水印、店铺名、徽章和旧品牌标识,并自然修复该区域背景;不要模仿或保留这些旧标识。画面${posLabel}保持干净,留出用于程序叠加店铺指定LOGO的区域。`
  139. : `画面的${posLabel}请保持背景简洁、不绘制任何LOGO或品牌图案(该区域将在生成后由程序直接叠加店铺LOGO图片)`
  140. );
  141. } else {
  142. try {
  143. images.push(readAsDataUri(logoPath));
  144. overlayClauses.push(
  145. opts.sourceImageFirst
  146. ? `第一张图是菜品原图,第二张图是店铺指定LOGO。店铺指定LOGO是唯一权威品牌标识;必须彻底移除菜品原图中已有的LOGO、水印、店铺名、徽章和旧品牌标识,并自然修复该区域背景。只在画面${posLabel}使用店铺指定LOGO,严格保持其图形、文字、配色和比例完整清晰,不要复制、模仿或保留原图旧标识。`
  147. : `参考图中的店铺LOGO图案(简洁品牌标识)请原样添加到画面的${posLabel}`
  148. );
  149. } catch {
  150. warnings.push('读取已生成的LOGO文件失败,本次生成未附加LOGO');
  151. }
  152. }
  153. } else {
  154. warnings.push('未找到可用LOGO,请先生成');
  155. }
  156. }
  157. if (addSticker) {
  158. const stickerPath = findExistingSticker(shopDir);
  159. if (stickerPath) {
  160. const posLabel = OVERLAY_POSITION_LABELS[stickerPosition] || OVERLAY_POSITION_LABELS['bottom-left'];
  161. if (stickerMode === 'composite') {
  162. compositeOverlays.push({ type: 'sticker', path: stickerPath, position: stickerPosition || 'bottom-left' });
  163. overlayClauses.push(`画面的${posLabel}请保持背景简洁、不绘制任何贴纸/徽章图案(该区域将在生成后由程序直接叠加店铺贴纸图片)`);
  164. } else {
  165. try {
  166. images.push(readAsDataUri(stickerPath));
  167. overlayClauses.push(`参考图中的店铺贴纸插画(徽章/贴纸风格图案)请原样叠加到画面的${posLabel}`);
  168. } catch {
  169. warnings.push('读取已生成的贴纸文件失败,本次生成未附加贴纸');
  170. }
  171. }
  172. } else {
  173. warnings.push('未找到可用贴纸,请先生成');
  174. }
  175. }
  176. let prompt = basePrompt;
  177. if (overlayClauses.length) {
  178. prompt += `\n\n${overlayClauses.join(';')},保持图案本身完整、清晰、不变形、不被裁切,不要遮挡画面主体;其余参考图仅作为风格/内容参考,不必出现在画面里。`;
  179. }
  180. return { images, prompt, warning: warnings.length ? warnings.join(';') : null, compositeOverlays };
  181. }
  182. // ---------- pure image-processing overlay compositing (no LLM call) ----------
  183. // Alternative to the AI-fusion overlay path above: pastes an already-generated logo/sticker
  184. // image directly onto the generated result via alpha blending, entirely in-process.
  185. function decodeToRGBA(buf) {
  186. if (buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
  187. const png = PNG.sync.read(buf);
  188. return { width: png.width, height: png.height, data: png.data };
  189. }
  190. const img = jpeg.decode(buf, { useTArray: true });
  191. return { width: img.width, height: img.height, data: img.data };
  192. }
  193. function resizeRGBA(src, srcW, srcH, dstW, dstH) {
  194. if (srcW === dstW && srcH === dstH) return src;
  195. const dst = Buffer.alloc(dstW * dstH * 4);
  196. for (let y = 0; y < dstH; y++) {
  197. const srcYf = Math.min(srcH - 1, Math.max(0, ((y + 0.5) * srcH) / dstH - 0.5));
  198. const y0 = Math.floor(srcYf);
  199. const y1 = Math.min(srcH - 1, y0 + 1);
  200. const wy = srcYf - y0;
  201. for (let x = 0; x < dstW; x++) {
  202. const srcXf = Math.min(srcW - 1, Math.max(0, ((x + 0.5) * srcW) / dstW - 0.5));
  203. const x0 = Math.floor(srcXf);
  204. const x1 = Math.min(srcW - 1, x0 + 1);
  205. const wx = srcXf - x0;
  206. const o = (y * dstW + x) * 4;
  207. for (let c = 0; c < 4; c++) {
  208. const p00 = src[(y0 * srcW + x0) * 4 + c];
  209. const p10 = src[(y0 * srcW + x1) * 4 + c];
  210. const p01 = src[(y1 * srcW + x0) * 4 + c];
  211. const p11 = src[(y1 * srcW + x1) * 4 + c];
  212. const top = p00 + (p10 - p00) * wx;
  213. const bottom = p01 + (p11 - p01) * wx;
  214. dst[o + c] = Math.round(top + (bottom - top) * wy);
  215. }
  216. }
  217. }
  218. return dst;
  219. }
  220. const OVERLAY_WIDTH_RATIO = { logo: 0.16, sticker: 0.22 };
  221. // Alpha-blends `overlay` (resized to a fraction of the base width, keeping its aspect ratio)
  222. // onto `base` at the given corner and returns a new decoded-RGBA object. Both inputs and the
  223. // output are the same {width, height, data} shape produced by decodeToRGBA().
  224. function compositeOverlayOntoBase(base, overlay, position, widthRatio) {
  225. const targetW = Math.max(1, Math.round(base.width * widthRatio));
  226. const targetH = Math.max(1, Math.round((overlay.height / overlay.width) * targetW));
  227. const resized = resizeRGBA(overlay.data, overlay.width, overlay.height, targetW, targetH);
  228. const margin = Math.round(base.width * 0.04);
  229. let x, y;
  230. if (position === 'top-left') { x = margin; y = margin; }
  231. else if (position === 'top-right') { x = base.width - margin - targetW; y = margin; }
  232. else if (position === 'bottom-left') { x = margin; y = base.height - margin - targetH; }
  233. else { x = base.width - margin - targetW; y = base.height - margin - targetH; } // bottom-right (default)
  234. x = Math.max(0, Math.min(x, base.width - targetW));
  235. y = Math.max(0, Math.min(y, base.height - targetH));
  236. const out = Buffer.from(base.data);
  237. for (let j = 0; j < targetH; j++) {
  238. const by = y + j;
  239. if (by < 0 || by >= base.height) continue;
  240. for (let i = 0; i < targetW; i++) {
  241. const bx = x + i;
  242. if (bx < 0 || bx >= base.width) continue;
  243. const so = (j * targetW + i) * 4;
  244. const alpha = resized[so + 3] / 255;
  245. if (alpha <= 0) continue;
  246. const bo = (by * base.width + bx) * 4;
  247. out[bo] = Math.round(resized[so] * alpha + out[bo] * (1 - alpha));
  248. out[bo + 1] = Math.round(resized[so + 1] * alpha + out[bo + 1] * (1 - alpha));
  249. out[bo + 2] = Math.round(resized[so + 2] * alpha + out[bo + 2] * (1 - alpha));
  250. }
  251. }
  252. return { width: base.width, height: base.height, data: out };
  253. }
  254. function encodeRGBAToPng(decoded) {
  255. const png = new PNG({ width: decoded.width, height: decoded.height });
  256. Buffer.from(decoded.data).copy(png.data);
  257. return PNG.sync.write(png);
  258. }
  259. // Applies a list of {type, path, position} overlays (from buildImagesAndPrompt's composite
  260. // mode) onto an already-generated result image. Returns {buf, ext} - falls back to the
  261. // original buffer/ext (with a warning appended to job.warning) if the base image can't be
  262. // decoded (e.g. a webp/gif result, which decodeToRGBA doesn't support).
  263. function applyCompositeOverlays(job, buf, ext) {
  264. if (!job.compositeOverlays || !job.compositeOverlays.length) return { buf, ext };
  265. let base;
  266. try {
  267. base = decodeToRGBA(buf);
  268. } catch (e) {
  269. job.warning = (job.warning ? job.warning + ';' : '') + '本地合成失败:无法解析生成结果图片格式,已保留未叠加的原图';
  270. return { buf, ext };
  271. }
  272. for (const ov of job.compositeOverlays) {
  273. try {
  274. const overlay = decodeToRGBA(fs.readFileSync(ov.path));
  275. base = compositeOverlayOntoBase(base, overlay, ov.position, OVERLAY_WIDTH_RATIO[ov.type] || 0.2);
  276. } catch (e) {
  277. const label = ov.type === 'logo' ? 'LOGO' : '贴纸';
  278. job.warning = (job.warning ? job.warning + ';' : '') + `本地合成${label}失败: ${e.message || e}`;
  279. }
  280. }
  281. return { buf: encodeRGBAToPng(base), ext: 'png' };
  282. }
  283. // ---------- job queue ----------
  284. const jobs = new Map();
  285. const pendingQueue = [];
  286. let activeCount = 0;
  287. const persistedTasks = new Map();
  288. let taskStoreLoaded = false;
  289. let flushTimer = null;
  290. function getTaskStoreFile() {
  291. return path.join(workspace.getWorkspaceRoot(), '.genpic', 'tasks.json');
  292. }
  293. function taskSnapshot(job) {
  294. const { images, ...safeJob } = job;
  295. return {
  296. ...safeJob,
  297. imageCount: Array.isArray(images) ? images.length : 0,
  298. updatedAt: Date.now(),
  299. };
  300. }
  301. function writeTaskStore() {
  302. flushTimer = null;
  303. const storeFile = getTaskStoreFile();
  304. fs.mkdirSync(path.dirname(storeFile), { recursive: true });
  305. const tempPath = `${storeFile}.${process.pid}.tmp`;
  306. fs.writeFileSync(tempPath, JSON.stringify({
  307. version: 1,
  308. updatedAt: new Date().toISOString(),
  309. tasks: Array.from(persistedTasks.values()),
  310. }, null, 2));
  311. fs.renameSync(tempPath, storeFile);
  312. }
  313. // 持久化策略:关键节点(新建/启动/拿到供应商任务ID/终态)立即落盘,保证重启可恢复;
  314. // 轮询期的进度/花费更新合并为 2 秒一次,避免每个任务每 5 秒全量重写 tasks.json。
  315. function persistJob(job, opts = {}) {
  316. if (!job || !job.id) return;
  317. persistedTasks.set(job.id, taskSnapshot(job));
  318. if (opts.urgent) {
  319. if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
  320. try { writeTaskStore(); } catch { /* 磁盘异常不阻断任务 */ }
  321. return;
  322. }
  323. if (!flushTimer) {
  324. flushTimer = setTimeout(() => { try { writeTaskStore(); } catch { /* ignore */ } }, 2000);
  325. flushTimer.unref();
  326. }
  327. }
  328. function flushPersisted() {
  329. if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
  330. if (!persistedTasks.size) return;
  331. try { writeTaskStore(); } catch { /* ignore */ }
  332. }
  333. function loadPersistedJobs() {
  334. if (taskStoreLoaded) return;
  335. taskStoreLoaded = true;
  336. const storeFile = getTaskStoreFile();
  337. let parsed;
  338. try {
  339. parsed = JSON.parse(fs.readFileSync(storeFile, 'utf8'));
  340. } catch {
  341. return;
  342. }
  343. if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.tasks)) return;
  344. for (const raw of parsed.tasks) {
  345. if (!raw || typeof raw.id !== 'string' || !raw.root || typeof raw.state !== 'string') continue;
  346. const job = {
  347. ...raw,
  348. images: [],
  349. compositeOverlays: Array.isArray(raw.compositeOverlays) ? raw.compositeOverlays : [],
  350. };
  351. if (!models.MODEL_IDS.has(job.model)) job.model = config.model;
  352. jobs.set(job.id, job);
  353. persistedTasks.set(job.id, taskSnapshot(job));
  354. }
  355. for (const job of jobs.values()) {
  356. if (job.state === 'queued') {
  357. job.state = 'failed';
  358. job.finishedAt = Date.now();
  359. job.error = '服务重启前任务仍在排队,请重新提交';
  360. persistJob(job, { urgent: true });
  361. } else if (job.state === 'running' || job.state === 'submitted') {
  362. if (!job.taskId) {
  363. job.state = 'failed';
  364. job.finishedAt = Date.now();
  365. job.error = '服务重启时任务尚未获得供应商任务 ID,请重新提交';
  366. persistJob(job, { urgent: true });
  367. } else {
  368. runJob(job);
  369. }
  370. }
  371. }
  372. }
  373. function createJob(opts) {
  374. const id = crypto.randomUUID();
  375. const job = {
  376. id,
  377. root: opts.root,
  378. folderName: opts.folderName,
  379. kind: opts.kind,
  380. shopName: opts.shopName,
  381. dishName: opts.dishName || null,
  382. model: models.MODEL_IDS.has(opts.model) ? opts.model : config.model,
  383. prompt: opts.prompt,
  384. size: opts.size,
  385. aspectRatio: opts.aspectRatio,
  386. images: opts.images || [],
  387. outputDir: opts.outputDir,
  388. outputBaseName: opts.outputBaseName,
  389. compositeOverlays: opts.compositeOverlays || [],
  390. state: 'queued',
  391. progress: '0%',
  392. taskId: null,
  393. cost: null,
  394. resultUrl: null,
  395. remoteUrl: null,
  396. width: null,
  397. height: null,
  398. fileSizeBytes: null,
  399. error: null,
  400. warning: opts.warning || null,
  401. createdAt: Date.now(),
  402. startedAt: null,
  403. finishedAt: null,
  404. };
  405. jobs.set(id, job);
  406. persistJob(job, { urgent: true });
  407. const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
  408. logs.pushSystemLog(`生成任务已提交:${jobTitle}(${job.model})`);
  409. if (activeCount < config.maxConcurrentJobs) {
  410. startJob(job);
  411. } else {
  412. pendingQueue.push(id);
  413. }
  414. return job;
  415. }
  416. function startJob(job) {
  417. runJob(job);
  418. }
  419. // jobs Map 只进不出:终态任务保留最近 MAX_JOBS 条,更旧的连同 tasks.json 记录一起清掉
  420. //(参考图 data URI 已在终态释放,这里防的是长期运行下任务条目本身无限累积)
  421. function pruneFinishedJobs() {
  422. const MAX_JOBS = 200;
  423. if (jobs.size <= MAX_JOBS) return;
  424. const finished = [...jobs.values()]
  425. .filter((j) => j.state === 'success' || j.state === 'failed')
  426. .sort((a, b) => (a.finishedAt || 0) - (b.finishedAt || 0));
  427. const drop = finished.slice(0, jobs.size - MAX_JOBS);
  428. for (const j of drop) {
  429. jobs.delete(j.id);
  430. persistedTasks.delete(j.id);
  431. }
  432. if (drop.length) {
  433. try { writeTaskStore(); } catch { /* ignore */ }
  434. }
  435. }
  436. function runJob(job) {
  437. activeCount++;
  438. job.state = 'running';
  439. job.startedAt = job.startedAt || Date.now();
  440. persistJob(job, { urgent: true });
  441. processJob(job)
  442. .catch((e) => {
  443. job.state = 'failed';
  444. job.error = job.error || e.message || String(e);
  445. })
  446. .finally(() => {
  447. const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
  448. if (job.state === 'success') logs.pushSystemLog(`生成完成:${jobTitle}`, 'success');
  449. else if (job.state === 'failed') logs.pushSystemLog(`生成失败:${jobTitle},${job.error || '未知错误'}`, 'error');
  450. job.images = []; // 终态后参考图 data URI(可达数 MB/张)不再需要,立即释放
  451. job.finishedAt = Date.now();
  452. persistJob(job, { urgent: true });
  453. pruneFinishedJobs();
  454. activeCount--;
  455. const nextId = pendingQueue.shift();
  456. if (nextId) {
  457. const nextJob = jobs.get(nextId);
  458. if (nextJob) startJob(nextJob);
  459. }
  460. });
  461. }
  462. // Builds the model-specific `params` object for the /v1/media/generate request. Different
  463. // image models on this API expose different (and sometimes confusingly named) parameters -
  464. // e.g. wan2.6-image has no separate aspect_ratio field, its required "size" param IS the
  465. // aspect-ratio string (auto/1:1/16:9/...), plus an optional prompt_extend flag.
  466. function buildParamsForModel(modelId, job) {
  467. const params = {};
  468. if (modelId === 'gpt-image-2') {
  469. params.size = 'auto';
  470. params.quality = 'auto';
  471. params.resolution = job.size || '1K';
  472. params.n = 1;
  473. params.response_format = 'url';
  474. if (job.aspectRatio && job.aspectRatio !== 'auto') params.aspect_ratio = job.aspectRatio;
  475. } else if (modelId === 'gpt-image-2-guan') {
  476. params.size = 'auto';
  477. params.quality = 'auto';
  478. } else if (modelId === 'wan2.6-image') {
  479. params.size = job.aspectRatio || job.size || 'auto';
  480. params.prompt_extend = true;
  481. } else {
  482. if (job.size) params.size = models.NO_1K_SUPPORT.has(modelId) && job.size === '1K' ? '2K' : job.size;
  483. if (job.aspectRatio) params.aspect_ratio = job.aspectRatio;
  484. }
  485. let images = job.images;
  486. if (modelId === 'wan2.6-image' && images.length > 4) {
  487. job.warning = (job.warning ? job.warning + ';' : '') + 'wan2.6-image 最多支持4张参考图,已自动截取前4张';
  488. images = images.slice(0, 4);
  489. }
  490. if (images.length === 1) params.images = images[0];
  491. else if (images.length > 1) params.images = images;
  492. return params;
  493. }
  494. async function processJob(job) {
  495. const resuming = !!job.taskId;
  496. if (!resuming) {
  497. const body = {
  498. model: job.model,
  499. prompt: job.prompt,
  500. params: buildParamsForModel(job.model, job),
  501. };
  502. const submit = await apiRequest('POST', '/v1/media/generate', body);
  503. const taskId = submit.json && (submit.json.task_id || (submit.json.data && submit.json.data.task_id));
  504. if (submit.statusCode !== 200 || !submit.json || !taskId) {
  505. throw new Error('提交生成任务失败: ' + (submit.json ? JSON.stringify(submit.json) : submit.raw.slice(0, 500)));
  506. }
  507. job.taskId = taskId;
  508. persistJob(job, { urgent: true });
  509. }
  510. await sleep(resuming ? 1000 : 7000);
  511. const start = job.startedAt || Date.now();
  512. const TIMEOUT_MS = 10 * 60 * 1000;
  513. for (;;) {
  514. if (Date.now() - start > TIMEOUT_MS) {
  515. job.state = 'failed';
  516. job.error = '轮询超时(10分钟未完成)';
  517. return;
  518. }
  519. const statusPath = (job.model === 'gpt-image-2' || job.model === 'gpt-image-2-guan')
  520. ? `/v1/media/status?task_id=${encodeURIComponent(job.taskId)}`
  521. : `/v1/skills/task-status?task_id=${encodeURIComponent(job.taskId)}`;
  522. const statusResp = await apiRequest('GET', statusPath);
  523. const status = statusResp.json;
  524. if (!status) {
  525. await sleep(5000);
  526. continue;
  527. }
  528. job.progress = String(status.progress ?? job.progress);
  529. if (status.cost !== undefined) job.cost = status.cost;
  530. persistJob(job);
  531. if (status.is_final) {
  532. if (status.state === 'success' && status.result_url) {
  533. job.remoteUrl = status.result_url;
  534. const buf = await downloadBuffer(status.result_url);
  535. let finalBuf, ext;
  536. finalBuf = buf;
  537. ext = guessExtFromUrl(status.result_url);
  538. ({ buf: finalBuf, ext } = applyCompositeOverlays(job, finalBuf, ext));
  539. fs.mkdirSync(job.outputDir, { recursive: true });
  540. const savePath = path.join(job.outputDir, `${job.outputBaseName}.${ext}`);
  541. fs.writeFileSync(savePath, finalBuf);
  542. const dims = getImageSize(finalBuf) || {};
  543. job.width = dims.width || null;
  544. job.height = dims.height || null;
  545. job.fileSizeBytes = finalBuf.length;
  546. job.resultPath = savePath;
  547. job.resultUrl = '/files?path=' + encodeURIComponent(savePath);
  548. const creationHistoryDir = path.basename(job.outputDir) === '菜品图'
  549. ? path.dirname(job.outputDir)
  550. : job.outputDir;
  551. appendCreationHistory(creationHistoryDir, {
  552. id: job.id,
  553. root: job.root,
  554. folderName: job.folderName,
  555. shopName: job.shopName,
  556. kind: job.kind,
  557. name: job.dishName || job.kind,
  558. dishName: job.dishName,
  559. fileName: path.basename(savePath),
  560. relativePath: path.relative(creationHistoryDir, savePath),
  561. remoteUrl: job.remoteUrl,
  562. model: job.model,
  563. cost: job.cost,
  564. width: job.width,
  565. height: job.height,
  566. fileSizeBytes: job.fileSizeBytes,
  567. generatedAt: new Date().toISOString(),
  568. });
  569. job.state = 'success';
  570. } else {
  571. job.state = 'failed';
  572. job.error = status.error || status.status || '生成失败';
  573. job.cost = status.cost ?? job.cost;
  574. }
  575. return;
  576. }
  577. await sleep(5000);
  578. }
  579. }
  580. // ---------- 创作历史(generation-history.json) ----------
  581. function loadCreationHistory(outputDir) {
  582. const historyPath = path.join(outputDir, 'generation-history.json');
  583. if (!fs.existsSync(historyPath)) return [];
  584. try {
  585. const records = JSON.parse(fs.readFileSync(historyPath, 'utf8'));
  586. return Array.isArray(records) ? records : [];
  587. } catch {
  588. return [];
  589. }
  590. }
  591. function appendCreationHistory(outputDir, record) {
  592. fs.mkdirSync(outputDir, { recursive: true });
  593. const historyPath = path.join(outputDir, 'generation-history.json');
  594. const records = loadCreationHistory(outputDir).filter((item) => item.id !== record.id);
  595. records.push(record);
  596. const tempPath = historyPath + '.tmp';
  597. fs.writeFileSync(tempPath, JSON.stringify(records, null, 2));
  598. fs.renameSync(tempPath, historyPath);
  599. }
  600. return {
  601. createJob,
  602. getJob: (id) => jobs.get(id),
  603. listJobs: () => Array.from(jobs.values()),
  604. loadPersistedJobs,
  605. flushPersisted,
  606. buildImagesAndPrompt,
  607. readAsDataUri,
  608. loadCreationHistory,
  609. appendCreationHistory,
  610. };
  611. }
  612. module.exports = { createGenerationService };