generate.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. 'use strict';
  2. // 视觉生成路由:品牌资产/菜品图生成提交、任务查询、任务中心、提示词配置、画板保存、打开目录
  3. const fs = require('fs');
  4. const path = require('path');
  5. const { execFile } = require('child_process');
  6. const { sendJson, readBody, sanitizeFileName } = require('../util.js');
  7. const { getImageSize } = require('../../imageMeta.js');
  8. const POSTER_CANVAS_SPECS = {
  9. banner: { fileName: '店内海报_1138x292.png', width: 1138, height: 292 },
  10. signage: { fileName: '招牌头图_750x288.png', width: 750, height: 288 },
  11. };
  12. async function handleGenerateBrand(ctx, body, res) {
  13. const { root, folderName, shopName, kind, prompt, size, aspectRatio, refDishFiles, addLogo, logoPosition, logoMode, model } = body;
  14. const outNameMap = { logo: 'logo', banner: 'banner', signage: '招牌头图', sticker: '贴纸' };
  15. if (!root || !folderName || !kind || !outNameMap[kind] || !prompt) {
  16. return sendJson(res, 400, { error: '参数不完整' });
  17. }
  18. const shopDir = ctx.workspace.safeShopDir(root, folderName);
  19. if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
  20. // logo/sticker are themselves brand-mark assets, adding the shop logo onto them is not applicable
  21. const shouldAddLogo = !!addLogo && kind !== 'logo';
  22. const { images, prompt: finalPrompt, warning, compositeOverlays } = ctx.generation.buildImagesAndPrompt(shopDir, prompt, refDishFiles, {
  23. addLogo: shouldAddLogo,
  24. logoPosition,
  25. logoMode,
  26. });
  27. const job = ctx.generation.createJob({
  28. kind,
  29. root,
  30. folderName,
  31. shopName,
  32. model,
  33. prompt: finalPrompt,
  34. size: size || '1K',
  35. aspectRatio: aspectRatio || undefined,
  36. images,
  37. outputDir: path.join(shopDir, '_生成图片'),
  38. outputBaseName: outNameMap[kind],
  39. warning,
  40. compositeOverlays,
  41. });
  42. sendJson(res, 200, { jobId: job.id });
  43. }
  44. async function handleGenerateDish(ctx, body, res) {
  45. const { root, folderName, shopName, dishFile, dishName, prompt, size, addLogo, logoPosition, logoMode, addSticker, stickerPosition, stickerMode, model } = body;
  46. if (!root || !folderName || !dishFile || !prompt) {
  47. return sendJson(res, 400, { error: '参数不完整' });
  48. }
  49. const shopDir = ctx.workspace.safeShopDir(root, folderName);
  50. if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
  51. const dishPath = path.resolve(shopDir, dishFile);
  52. if (!ctx.workspace.isWorkspacePath(dishPath) || !dishPath.startsWith(shopDir + path.sep)) {
  53. return sendJson(res, 403, { error: '菜品文件无效' });
  54. }
  55. let dishImage;
  56. try {
  57. dishImage = ctx.generation.readAsDataUri(dishPath);
  58. } catch (e) {
  59. return sendJson(res, 400, { error: '读取菜品原图失败: ' + e.message });
  60. }
  61. const { images: overlayImages, prompt: finalPrompt, warning, compositeOverlays } = ctx.generation.buildImagesAndPrompt(shopDir, prompt, [], {
  62. addLogo: !!addLogo,
  63. logoPosition,
  64. logoMode,
  65. addSticker: !!addSticker,
  66. stickerPosition,
  67. stickerMode,
  68. sourceImageFirst: true,
  69. });
  70. const job = ctx.generation.createJob({
  71. kind: 'dish',
  72. root,
  73. folderName,
  74. shopName,
  75. dishName,
  76. model,
  77. prompt: finalPrompt,
  78. size: size || '1K',
  79. aspectRatio: '1:1',
  80. images: [dishImage, ...overlayImages],
  81. outputDir: path.join(shopDir, '_生成图片', '菜品图'),
  82. outputBaseName: sanitizeFileName(dishName),
  83. warning,
  84. compositeOverlays,
  85. });
  86. sendJson(res, 200, { jobId: job.id });
  87. }
  88. function publicTask(job) {
  89. return {
  90. id: job.id,
  91. type: 'generation',
  92. status: job.state,
  93. root: job.root,
  94. folderName: job.folderName,
  95. shopName: job.shopName,
  96. dishName: job.dishName,
  97. name: job.dishName || job.kind,
  98. kind: job.kind,
  99. model: job.model,
  100. providerTaskId: job.taskId,
  101. progress: job.progress,
  102. cost: job.cost,
  103. resultUrl: job.resultUrl,
  104. remoteUrl: job.remoteUrl,
  105. width: job.width,
  106. height: job.height,
  107. fileSizeBytes: job.fileSizeBytes,
  108. error: job.error,
  109. warning: job.warning,
  110. createdAt: job.createdAt,
  111. startedAt: job.startedAt,
  112. finishedAt: job.finishedAt,
  113. updatedAt: job.updatedAt || job.createdAt,
  114. };
  115. }
  116. // 采集任务映射为任务中心条目(与生成任务并列展示)
  117. function crawlTaskFromState(cs) {
  118. if (!cs || cs.status === 'idle') return null;
  119. const shops = Array.isArray(cs.shops) ? cs.shops : [];
  120. const done = shops.filter((s) => s.state === 'done' || s.state === 'skipped_completed').length;
  121. const failedShops = shops.filter((s) => s.state === 'failed').length;
  122. const statusMap = { running: 'running', done: 'success', stopped: 'failed', interrupted: 'failed', error: 'failed' };
  123. const labelMap = { running: '采集中', done: '采集完成', stopped: '采集已停止', interrupted: '采集被中断', error: '采集出错' };
  124. const pct = shops.length ? Math.round((done / shops.length) * 100) : 0;
  125. return {
  126. id: 'crawl-current',
  127. type: 'crawl',
  128. status: statusMap[cs.status] || 'running',
  129. name: `门店采集 · ${labelMap[cs.status] || cs.status}`,
  130. kind: 'crawl',
  131. shopName: '',
  132. model: '',
  133. progress: pct + '%',
  134. detail: `${done}/${shops.length} 家门店完成${failedShops ? ` · ${failedShops} 家失败` : ''}`,
  135. warning: cs.status === 'interrupted' ? '服务重启导致中断,未完成的门店可重新勾选采集' : '',
  136. createdAt: cs.startedAt,
  137. startedAt: cs.startedAt,
  138. finishedAt: cs.finishedAt,
  139. updatedAt: cs.finishedAt || cs.startedAt || Date.now(),
  140. };
  141. }
  142. function register(app, ctx) {
  143. app.post('/api/generate/brand', async ({ req, res }) => {
  144. const body = await readBody(req);
  145. return handleGenerateBrand(ctx, body, res);
  146. });
  147. app.post('/api/generate/dish', async ({ req, res }) => {
  148. const body = await readBody(req);
  149. return handleGenerateDish(ctx, body, res);
  150. });
  151. app.get('/api/job/:id', async ({ res, params }) => {
  152. const job = ctx.generation.getJob(params.id);
  153. if (!job) return sendJson(res, 404, { error: 'job not found' });
  154. sendJson(res, 200, {
  155. id: job.id,
  156. kind: job.kind,
  157. dishName: job.dishName,
  158. state: job.state,
  159. progress: job.progress,
  160. cost: job.cost,
  161. resultUrl: job.resultUrl,
  162. width: job.width,
  163. height: job.height,
  164. fileSizeBytes: job.fileSizeBytes,
  165. error: job.error,
  166. warning: job.warning,
  167. });
  168. });
  169. app.get('/api/tasks', async ({ res, query }) => {
  170. if (query.root && !ctx.workspace.isWorkspaceRoot(query.root)) {
  171. return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  172. }
  173. const root = ctx.workspace.getWorkspaceRoot();
  174. const tasks = ctx.generation.listJobs()
  175. .filter((job) => path.resolve(job.root) === root)
  176. .sort((a, b) => b.createdAt - a.createdAt)
  177. .map(publicTask);
  178. const crawlTask = crawlTaskFromState(ctx.crawl.getState());
  179. if (crawlTask) tasks.unshift(crawlTask);
  180. const summary = tasks.reduce((acc, task) => {
  181. acc[task.status] = (acc[task.status] || 0) + 1;
  182. return acc;
  183. }, { all: tasks.length });
  184. sendJson(res, 200, { root, summary, tasks });
  185. });
  186. app.post('/api/save-config', async ({ req, res }) => {
  187. const body = await readBody(req);
  188. const { root, folderName, shopName, prompts } = body;
  189. if (!root || !folderName) return sendJson(res, 400, { error: '参数不完整' });
  190. const shopDir = ctx.workspace.safeShopDir(root, folderName);
  191. if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
  192. const dir = path.join(shopDir, '_生成图片');
  193. fs.mkdirSync(dir, { recursive: true });
  194. const cfgPath = path.join(dir, 'prompt-config.json');
  195. let existing = {};
  196. if (fs.existsSync(cfgPath)) {
  197. try {
  198. existing = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
  199. } catch {
  200. // start fresh if corrupt
  201. }
  202. }
  203. const merged = {
  204. shopName: shopName !== undefined ? shopName : existing.shopName,
  205. prompts: { ...(existing.prompts || {}), ...(prompts || {}) },
  206. };
  207. fs.writeFileSync(cfgPath, JSON.stringify(merged, null, 2), 'utf8');
  208. sendJson(res, 200, { ok: true });
  209. });
  210. app.post('/api/save-poster-canvas', async ({ req, res }) => {
  211. const body = await readBody(req);
  212. const { root, folderName, kind, imageDataUrl } = body;
  213. const spec = POSTER_CANVAS_SPECS[kind];
  214. if (!root || !folderName || !spec || !imageDataUrl) {
  215. return sendJson(res, 400, { error: '参数不完整' });
  216. }
  217. if (typeof folderName !== 'string' || folderName === '.' || folderName === '..' || /[\\/]/.test(folderName)) {
  218. return sendJson(res, 400, { error: '店铺目录名无效' });
  219. }
  220. const dataUrlMatch = typeof imageDataUrl === 'string' && /^data:image\/png;base64,([A-Za-z0-9+/=]+)$/.exec(imageDataUrl);
  221. if (!dataUrlMatch) {
  222. return sendJson(res, 400, { error: '画板图必须为 PNG 格式' });
  223. }
  224. const base64 = dataUrlMatch[1];
  225. if (!base64 || base64.length > 10 * 1024 * 1024) {
  226. return sendJson(res, 400, { error: '画板图数据无效或过大' });
  227. }
  228. let imageBuffer;
  229. try {
  230. imageBuffer = Buffer.from(base64, 'base64');
  231. } catch {
  232. return sendJson(res, 400, { error: '画板图数据无法读取' });
  233. }
  234. const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
  235. if (!imageBuffer.length || !imageBuffer.subarray(0, 8).equals(pngSignature)) {
  236. return sendJson(res, 400, { error: '画板图不是有效的 PNG 文件' });
  237. }
  238. const dimensions = getImageSize(imageBuffer);
  239. if (!dimensions || dimensions.width !== spec.width || dimensions.height !== spec.height) {
  240. return sendJson(res, 400, { error: `画板图尺寸必须为 ${spec.width}×${spec.height}` });
  241. }
  242. const resolvedRoot = path.resolve(root);
  243. if (!ctx.workspace.isWorkspaceRoot(resolvedRoot)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  244. const shopDir = path.resolve(resolvedRoot, folderName);
  245. if (shopDir !== path.join(resolvedRoot, folderName) || !shopDir.startsWith(resolvedRoot + path.sep)) {
  246. return sendJson(res, 400, { error: '店铺目录无效' });
  247. }
  248. const dir = path.join(shopDir, '_生成图片');
  249. const savePath = path.join(dir, spec.fileName);
  250. try {
  251. fs.mkdirSync(dir, { recursive: true });
  252. fs.writeFileSync(savePath, imageBuffer);
  253. } catch (e) {
  254. return sendJson(res, 500, { error: '保存画板图失败: ' + e.message });
  255. }
  256. sendJson(res, 200, { ok: true, fileName: spec.fileName, width: dimensions.width, height: dimensions.height, fileSizeBytes: imageBuffer.length });
  257. });
  258. app.post('/api/open-folder', async ({ req, res }) => {
  259. const body = await readBody(req);
  260. if (body.root && !ctx.workspace.isWorkspaceRoot(body.root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  261. const dir = body.dir || path.join(body.root || ctx.workspace.getWorkspaceRoot(), body.folderName || '', '_生成图片');
  262. const fallbackDir = body.dir ? null : path.join(body.root || ctx.workspace.getWorkspaceRoot(), body.folderName || '');
  263. const targetDir = fs.existsSync(dir) ? dir : (fallbackDir && fs.existsSync(fallbackDir) ? fallbackDir : null);
  264. if (!targetDir || !ctx.workspace.isWorkspacePath(targetDir)) return sendJson(res, 400, { error: '目录不存在或不在输出根目录内: ' + dir });
  265. const command = process.platform === 'win32' ? 'explorer' : process.platform === 'darwin' ? 'open' : 'xdg-open';
  266. execFile(command, [targetDir], () => {});
  267. sendJson(res, 200, { ok: true, opened: targetDir });
  268. });
  269. }
  270. module.exports = { register };