generation.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  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. try {
  119. images.push(readAsDataUri(path.join(shopDir, f)));
  120. } catch {
  121. // skip unreadable reference image
  122. }
  123. }
  124. const warnings = [];
  125. const overlayClauses = [];
  126. const compositeOverlays = [];
  127. if (addLogo) {
  128. const logoPath = findExistingLogo(shopDir);
  129. if (logoPath) {
  130. const posLabel = OVERLAY_POSITION_LABELS[logoPosition] || OVERLAY_POSITION_LABELS['bottom-right'];
  131. if (logoMode === 'composite') {
  132. compositeOverlays.push({ type: 'logo', path: logoPath, position: logoPosition || 'bottom-right' });
  133. overlayClauses.push(
  134. opts.sourceImageFirst
  135. ? `第一张图是菜品原图,第二张图是店铺指定LOGO。必须彻底移除菜品原图中已有的LOGO、水印、店铺名、徽章和旧品牌标识,并自然修复该区域背景;不要模仿或保留这些旧标识。画面${posLabel}保持干净,留出用于程序叠加店铺指定LOGO的区域。`
  136. : `画面的${posLabel}请保持背景简洁、不绘制任何LOGO或品牌图案(该区域将在生成后由程序直接叠加店铺LOGO图片)`
  137. );
  138. } else {
  139. try {
  140. images.push(readAsDataUri(logoPath));
  141. overlayClauses.push(
  142. opts.sourceImageFirst
  143. ? `第一张图是菜品原图,第二张图是店铺指定LOGO。店铺指定LOGO是唯一权威品牌标识;必须彻底移除菜品原图中已有的LOGO、水印、店铺名、徽章和旧品牌标识,并自然修复该区域背景。只在画面${posLabel}使用店铺指定LOGO,严格保持其图形、文字、配色和比例完整清晰,不要复制、模仿或保留原图旧标识。`
  144. : `参考图中的店铺LOGO图案(简洁品牌标识)请原样添加到画面的${posLabel}`
  145. );
  146. } catch {
  147. warnings.push('读取已生成的LOGO文件失败,本次生成未附加LOGO');
  148. }
  149. }
  150. } else {
  151. warnings.push('未找到可用LOGO,请先生成');
  152. }
  153. }
  154. if (addSticker) {
  155. const stickerPath = findExistingSticker(shopDir);
  156. if (stickerPath) {
  157. const posLabel = OVERLAY_POSITION_LABELS[stickerPosition] || OVERLAY_POSITION_LABELS['bottom-left'];
  158. if (stickerMode === 'composite') {
  159. compositeOverlays.push({ type: 'sticker', path: stickerPath, position: stickerPosition || 'bottom-left' });
  160. overlayClauses.push(`画面的${posLabel}请保持背景简洁、不绘制任何贴纸/徽章图案(该区域将在生成后由程序直接叠加店铺贴纸图片)`);
  161. } else {
  162. try {
  163. images.push(readAsDataUri(stickerPath));
  164. overlayClauses.push(`参考图中的店铺贴纸插画(徽章/贴纸风格图案)请原样叠加到画面的${posLabel}`);
  165. } catch {
  166. warnings.push('读取已生成的贴纸文件失败,本次生成未附加贴纸');
  167. }
  168. }
  169. } else {
  170. warnings.push('未找到可用贴纸,请先生成');
  171. }
  172. }
  173. let prompt = basePrompt;
  174. if (overlayClauses.length) {
  175. prompt += `\n\n${overlayClauses.join(';')},保持图案本身完整、清晰、不变形、不被裁切,不要遮挡画面主体;其余参考图仅作为风格/内容参考,不必出现在画面里。`;
  176. }
  177. return { images, prompt, warning: warnings.length ? warnings.join(';') : null, compositeOverlays };
  178. }
  179. // ---------- pure image-processing overlay compositing (no LLM call) ----------
  180. // Alternative to the AI-fusion overlay path above: pastes an already-generated logo/sticker
  181. // image directly onto the generated result via alpha blending, entirely in-process.
  182. function decodeToRGBA(buf) {
  183. if (buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
  184. const png = PNG.sync.read(buf);
  185. return { width: png.width, height: png.height, data: png.data };
  186. }
  187. const img = jpeg.decode(buf, { useTArray: true });
  188. return { width: img.width, height: img.height, data: img.data };
  189. }
  190. function resizeRGBA(src, srcW, srcH, dstW, dstH) {
  191. if (srcW === dstW && srcH === dstH) return src;
  192. const dst = Buffer.alloc(dstW * dstH * 4);
  193. for (let y = 0; y < dstH; y++) {
  194. const srcYf = Math.min(srcH - 1, Math.max(0, ((y + 0.5) * srcH) / dstH - 0.5));
  195. const y0 = Math.floor(srcYf);
  196. const y1 = Math.min(srcH - 1, y0 + 1);
  197. const wy = srcYf - y0;
  198. for (let x = 0; x < dstW; x++) {
  199. const srcXf = Math.min(srcW - 1, Math.max(0, ((x + 0.5) * srcW) / dstW - 0.5));
  200. const x0 = Math.floor(srcXf);
  201. const x1 = Math.min(srcW - 1, x0 + 1);
  202. const wx = srcXf - x0;
  203. const o = (y * dstW + x) * 4;
  204. for (let c = 0; c < 4; c++) {
  205. const p00 = src[(y0 * srcW + x0) * 4 + c];
  206. const p10 = src[(y0 * srcW + x1) * 4 + c];
  207. const p01 = src[(y1 * srcW + x0) * 4 + c];
  208. const p11 = src[(y1 * srcW + x1) * 4 + c];
  209. const top = p00 + (p10 - p00) * wx;
  210. const bottom = p01 + (p11 - p01) * wx;
  211. dst[o + c] = Math.round(top + (bottom - top) * wy);
  212. }
  213. }
  214. }
  215. return dst;
  216. }
  217. const OVERLAY_WIDTH_RATIO = { logo: 0.16, sticker: 0.22 };
  218. // Alpha-blends `overlay` (resized to a fraction of the base width, keeping its aspect ratio)
  219. // onto `base` at the given corner and returns a new decoded-RGBA object. Both inputs and the
  220. // output are the same {width, height, data} shape produced by decodeToRGBA().
  221. function compositeOverlayOntoBase(base, overlay, position, widthRatio) {
  222. const targetW = Math.max(1, Math.round(base.width * widthRatio));
  223. const targetH = Math.max(1, Math.round((overlay.height / overlay.width) * targetW));
  224. const resized = resizeRGBA(overlay.data, overlay.width, overlay.height, targetW, targetH);
  225. const margin = Math.round(base.width * 0.04);
  226. let x, y;
  227. if (position === 'top-left') { x = margin; y = margin; }
  228. else if (position === 'top-right') { x = base.width - margin - targetW; y = margin; }
  229. else if (position === 'bottom-left') { x = margin; y = base.height - margin - targetH; }
  230. else { x = base.width - margin - targetW; y = base.height - margin - targetH; } // bottom-right (default)
  231. x = Math.max(0, Math.min(x, base.width - targetW));
  232. y = Math.max(0, Math.min(y, base.height - targetH));
  233. const out = Buffer.from(base.data);
  234. for (let j = 0; j < targetH; j++) {
  235. const by = y + j;
  236. if (by < 0 || by >= base.height) continue;
  237. for (let i = 0; i < targetW; i++) {
  238. const bx = x + i;
  239. if (bx < 0 || bx >= base.width) continue;
  240. const so = (j * targetW + i) * 4;
  241. const alpha = resized[so + 3] / 255;
  242. if (alpha <= 0) continue;
  243. const bo = (by * base.width + bx) * 4;
  244. out[bo] = Math.round(resized[so] * alpha + out[bo] * (1 - alpha));
  245. out[bo + 1] = Math.round(resized[so + 1] * alpha + out[bo + 1] * (1 - alpha));
  246. out[bo + 2] = Math.round(resized[so + 2] * alpha + out[bo + 2] * (1 - alpha));
  247. }
  248. }
  249. return { width: base.width, height: base.height, data: out };
  250. }
  251. function encodeRGBAToPng(decoded) {
  252. const png = new PNG({ width: decoded.width, height: decoded.height });
  253. Buffer.from(decoded.data).copy(png.data);
  254. return PNG.sync.write(png);
  255. }
  256. // Applies a list of {type, path, position} overlays (from buildImagesAndPrompt's composite
  257. // mode) onto an already-generated result image. Returns {buf, ext} - falls back to the
  258. // original buffer/ext (with a warning appended to job.warning) if the base image can't be
  259. // decoded (e.g. a webp/gif result, which decodeToRGBA doesn't support).
  260. function applyCompositeOverlays(job, buf, ext) {
  261. if (!job.compositeOverlays || !job.compositeOverlays.length) return { buf, ext };
  262. let base;
  263. try {
  264. base = decodeToRGBA(buf);
  265. } catch (e) {
  266. job.warning = (job.warning ? job.warning + ';' : '') + '本地合成失败:无法解析生成结果图片格式,已保留未叠加的原图';
  267. return { buf, ext };
  268. }
  269. for (const ov of job.compositeOverlays) {
  270. try {
  271. const overlay = decodeToRGBA(fs.readFileSync(ov.path));
  272. base = compositeOverlayOntoBase(base, overlay, ov.position, OVERLAY_WIDTH_RATIO[ov.type] || 0.2);
  273. } catch (e) {
  274. const label = ov.type === 'logo' ? 'LOGO' : '贴纸';
  275. job.warning = (job.warning ? job.warning + ';' : '') + `本地合成${label}失败: ${e.message || e}`;
  276. }
  277. }
  278. return { buf: encodeRGBAToPng(base), ext: 'png' };
  279. }
  280. // ---------- job queue ----------
  281. const jobs = new Map();
  282. const pendingQueue = [];
  283. let activeCount = 0;
  284. const persistedTasks = new Map();
  285. let taskStoreLoaded = false;
  286. let flushTimer = null;
  287. function getTaskStoreFile() {
  288. return path.join(workspace.getWorkspaceRoot(), '.genpic', 'tasks.json');
  289. }
  290. function taskSnapshot(job) {
  291. const { images, ...safeJob } = job;
  292. return {
  293. ...safeJob,
  294. imageCount: Array.isArray(images) ? images.length : 0,
  295. updatedAt: Date.now(),
  296. };
  297. }
  298. function writeTaskStore() {
  299. flushTimer = null;
  300. const storeFile = getTaskStoreFile();
  301. fs.mkdirSync(path.dirname(storeFile), { recursive: true });
  302. const tempPath = `${storeFile}.${process.pid}.tmp`;
  303. fs.writeFileSync(tempPath, JSON.stringify({
  304. version: 1,
  305. updatedAt: new Date().toISOString(),
  306. tasks: Array.from(persistedTasks.values()),
  307. }, null, 2));
  308. fs.renameSync(tempPath, storeFile);
  309. }
  310. // 持久化策略:关键节点(新建/启动/拿到供应商任务ID/终态)立即落盘,保证重启可恢复;
  311. // 轮询期的进度/花费更新合并为 2 秒一次,避免每个任务每 5 秒全量重写 tasks.json。
  312. function persistJob(job, opts = {}) {
  313. if (!job || !job.id) return;
  314. persistedTasks.set(job.id, taskSnapshot(job));
  315. if (opts.urgent) {
  316. if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
  317. try { writeTaskStore(); } catch { /* 磁盘异常不阻断任务 */ }
  318. return;
  319. }
  320. if (!flushTimer) {
  321. flushTimer = setTimeout(() => { try { writeTaskStore(); } catch { /* ignore */ } }, 2000);
  322. flushTimer.unref();
  323. }
  324. }
  325. function flushPersisted() {
  326. if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
  327. if (!persistedTasks.size) return;
  328. try { writeTaskStore(); } catch { /* ignore */ }
  329. }
  330. function loadPersistedJobs() {
  331. if (taskStoreLoaded) return;
  332. taskStoreLoaded = true;
  333. const storeFile = getTaskStoreFile();
  334. let parsed;
  335. try {
  336. parsed = JSON.parse(fs.readFileSync(storeFile, 'utf8'));
  337. } catch {
  338. return;
  339. }
  340. if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.tasks)) return;
  341. for (const raw of parsed.tasks) {
  342. if (!raw || typeof raw.id !== 'string' || !raw.root || typeof raw.state !== 'string') continue;
  343. const job = {
  344. ...raw,
  345. images: [],
  346. compositeOverlays: Array.isArray(raw.compositeOverlays) ? raw.compositeOverlays : [],
  347. };
  348. if (!models.MODEL_IDS.has(job.model)) job.model = config.model;
  349. jobs.set(job.id, job);
  350. persistedTasks.set(job.id, taskSnapshot(job));
  351. }
  352. for (const job of jobs.values()) {
  353. if (job.state === 'queued') {
  354. job.state = 'failed';
  355. job.finishedAt = Date.now();
  356. job.error = '服务重启前任务仍在排队,请重新提交';
  357. persistJob(job, { urgent: true });
  358. } else if (job.state === 'running' || job.state === 'submitted') {
  359. if (!job.taskId) {
  360. job.state = 'failed';
  361. job.finishedAt = Date.now();
  362. job.error = '服务重启时任务尚未获得供应商任务 ID,请重新提交';
  363. persistJob(job, { urgent: true });
  364. } else {
  365. runJob(job);
  366. }
  367. }
  368. }
  369. }
  370. function createJob(opts) {
  371. const id = crypto.randomUUID();
  372. const job = {
  373. id,
  374. root: opts.root,
  375. folderName: opts.folderName,
  376. kind: opts.kind,
  377. shopName: opts.shopName,
  378. dishName: opts.dishName || null,
  379. model: models.MODEL_IDS.has(opts.model) ? opts.model : config.model,
  380. prompt: opts.prompt,
  381. size: opts.size,
  382. aspectRatio: opts.aspectRatio,
  383. images: opts.images || [],
  384. outputDir: opts.outputDir,
  385. outputBaseName: opts.outputBaseName,
  386. compositeOverlays: opts.compositeOverlays || [],
  387. state: 'queued',
  388. progress: '0%',
  389. taskId: null,
  390. cost: null,
  391. resultUrl: null,
  392. remoteUrl: null,
  393. width: null,
  394. height: null,
  395. fileSizeBytes: null,
  396. error: null,
  397. warning: opts.warning || null,
  398. createdAt: Date.now(),
  399. startedAt: null,
  400. finishedAt: null,
  401. };
  402. jobs.set(id, job);
  403. persistJob(job, { urgent: true });
  404. const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
  405. logs.pushSystemLog(`生成任务已提交:${jobTitle}(${job.model})`);
  406. if (activeCount < config.maxConcurrentJobs) {
  407. startJob(job);
  408. } else {
  409. pendingQueue.push(id);
  410. }
  411. return job;
  412. }
  413. function startJob(job) {
  414. runJob(job);
  415. }
  416. function runJob(job) {
  417. activeCount++;
  418. job.state = 'running';
  419. job.startedAt = job.startedAt || Date.now();
  420. persistJob(job, { urgent: true });
  421. processJob(job)
  422. .catch((e) => {
  423. job.state = 'failed';
  424. job.error = job.error || e.message || String(e);
  425. })
  426. .finally(() => {
  427. const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
  428. if (job.state === 'success') logs.pushSystemLog(`生成完成:${jobTitle}`, 'success');
  429. else if (job.state === 'failed') logs.pushSystemLog(`生成失败:${jobTitle},${job.error || '未知错误'}`, 'error');
  430. job.finishedAt = Date.now();
  431. persistJob(job, { urgent: true });
  432. activeCount--;
  433. const nextId = pendingQueue.shift();
  434. if (nextId) {
  435. const nextJob = jobs.get(nextId);
  436. if (nextJob) startJob(nextJob);
  437. }
  438. });
  439. }
  440. // Builds the model-specific `params` object for the /v1/media/generate request. Different
  441. // image models on this API expose different (and sometimes confusingly named) parameters -
  442. // e.g. wan2.6-image has no separate aspect_ratio field, its required "size" param IS the
  443. // aspect-ratio string (auto/1:1/16:9/...), plus an optional prompt_extend flag.
  444. function buildParamsForModel(modelId, job) {
  445. const params = {};
  446. if (modelId === 'gpt-image-2') {
  447. params.size = 'auto';
  448. params.quality = 'auto';
  449. params.resolution = job.size || '1K';
  450. params.n = 1;
  451. params.response_format = 'url';
  452. if (job.aspectRatio && job.aspectRatio !== 'auto') params.aspect_ratio = job.aspectRatio;
  453. } else if (modelId === 'gpt-image-2-guan') {
  454. params.size = 'auto';
  455. params.quality = 'auto';
  456. } else if (modelId === 'wan2.6-image') {
  457. params.size = job.aspectRatio || job.size || 'auto';
  458. params.prompt_extend = true;
  459. } else {
  460. if (job.size) params.size = models.NO_1K_SUPPORT.has(modelId) && job.size === '1K' ? '2K' : job.size;
  461. if (job.aspectRatio) params.aspect_ratio = job.aspectRatio;
  462. }
  463. let images = job.images;
  464. if (modelId === 'wan2.6-image' && images.length > 4) {
  465. job.warning = (job.warning ? job.warning + ';' : '') + 'wan2.6-image 最多支持4张参考图,已自动截取前4张';
  466. images = images.slice(0, 4);
  467. }
  468. if (images.length === 1) params.images = images[0];
  469. else if (images.length > 1) params.images = images;
  470. return params;
  471. }
  472. async function processJob(job) {
  473. const resuming = !!job.taskId;
  474. if (!resuming) {
  475. const body = {
  476. model: job.model,
  477. prompt: job.prompt,
  478. params: buildParamsForModel(job.model, job),
  479. };
  480. const submit = await apiRequest('POST', '/v1/media/generate', body);
  481. const taskId = submit.json && (submit.json.task_id || (submit.json.data && submit.json.data.task_id));
  482. if (submit.statusCode !== 200 || !submit.json || !taskId) {
  483. throw new Error('提交生成任务失败: ' + (submit.json ? JSON.stringify(submit.json) : submit.raw.slice(0, 500)));
  484. }
  485. job.taskId = taskId;
  486. persistJob(job, { urgent: true });
  487. }
  488. await sleep(resuming ? 1000 : 7000);
  489. const start = job.startedAt || Date.now();
  490. const TIMEOUT_MS = 10 * 60 * 1000;
  491. for (;;) {
  492. if (Date.now() - start > TIMEOUT_MS) {
  493. job.state = 'failed';
  494. job.error = '轮询超时(10分钟未完成)';
  495. return;
  496. }
  497. const statusPath = (job.model === 'gpt-image-2' || job.model === 'gpt-image-2-guan')
  498. ? `/v1/media/status?task_id=${encodeURIComponent(job.taskId)}`
  499. : `/v1/skills/task-status?task_id=${encodeURIComponent(job.taskId)}`;
  500. const statusResp = await apiRequest('GET', statusPath);
  501. const status = statusResp.json;
  502. if (!status) {
  503. await sleep(5000);
  504. continue;
  505. }
  506. job.progress = String(status.progress ?? job.progress);
  507. if (status.cost !== undefined) job.cost = status.cost;
  508. persistJob(job);
  509. if (status.is_final) {
  510. if (status.state === 'success' && status.result_url) {
  511. job.remoteUrl = status.result_url;
  512. const buf = await downloadBuffer(status.result_url);
  513. let finalBuf, ext;
  514. finalBuf = buf;
  515. ext = guessExtFromUrl(status.result_url);
  516. ({ buf: finalBuf, ext } = applyCompositeOverlays(job, finalBuf, ext));
  517. fs.mkdirSync(job.outputDir, { recursive: true });
  518. const savePath = path.join(job.outputDir, `${job.outputBaseName}.${ext}`);
  519. fs.writeFileSync(savePath, finalBuf);
  520. const dims = getImageSize(finalBuf) || {};
  521. job.width = dims.width || null;
  522. job.height = dims.height || null;
  523. job.fileSizeBytes = finalBuf.length;
  524. job.resultPath = savePath;
  525. job.resultUrl = '/files?path=' + encodeURIComponent(savePath);
  526. const creationHistoryDir = path.basename(job.outputDir) === '菜品图'
  527. ? path.dirname(job.outputDir)
  528. : job.outputDir;
  529. appendCreationHistory(creationHistoryDir, {
  530. id: job.id,
  531. root: job.root,
  532. folderName: job.folderName,
  533. shopName: job.shopName,
  534. kind: job.kind,
  535. name: job.dishName || job.kind,
  536. dishName: job.dishName,
  537. fileName: path.basename(savePath),
  538. relativePath: path.relative(creationHistoryDir, savePath),
  539. remoteUrl: job.remoteUrl,
  540. model: job.model,
  541. cost: job.cost,
  542. width: job.width,
  543. height: job.height,
  544. fileSizeBytes: job.fileSizeBytes,
  545. generatedAt: new Date().toISOString(),
  546. });
  547. job.state = 'success';
  548. } else {
  549. job.state = 'failed';
  550. job.error = status.error || status.status || '生成失败';
  551. job.cost = status.cost ?? job.cost;
  552. }
  553. return;
  554. }
  555. await sleep(5000);
  556. }
  557. }
  558. // ---------- 创作历史(generation-history.json) ----------
  559. function loadCreationHistory(outputDir) {
  560. const historyPath = path.join(outputDir, 'generation-history.json');
  561. if (!fs.existsSync(historyPath)) return [];
  562. try {
  563. const records = JSON.parse(fs.readFileSync(historyPath, 'utf8'));
  564. return Array.isArray(records) ? records : [];
  565. } catch {
  566. return [];
  567. }
  568. }
  569. function appendCreationHistory(outputDir, record) {
  570. fs.mkdirSync(outputDir, { recursive: true });
  571. const historyPath = path.join(outputDir, 'generation-history.json');
  572. const records = loadCreationHistory(outputDir).filter((item) => item.id !== record.id);
  573. records.push(record);
  574. const tempPath = historyPath + '.tmp';
  575. fs.writeFileSync(tempPath, JSON.stringify(records, null, 2));
  576. fs.renameSync(tempPath, historyPath);
  577. }
  578. return {
  579. createJob,
  580. getJob: (id) => jobs.get(id),
  581. listJobs: () => Array.from(jobs.values()),
  582. loadPersistedJobs,
  583. flushPersisted,
  584. buildImagesAndPrompt,
  585. readAsDataUri,
  586. loadCreationHistory,
  587. appendCreationHistory,
  588. };
  589. }
  590. module.exports = { createGenerationService };