generate.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  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. function register(app, ctx) {
  117. app.post('/api/generate/brand', async ({ req, res }) => {
  118. const body = await readBody(req);
  119. return handleGenerateBrand(ctx, body, res);
  120. });
  121. app.post('/api/generate/dish', async ({ req, res }) => {
  122. const body = await readBody(req);
  123. return handleGenerateDish(ctx, body, res);
  124. });
  125. app.get('/api/job/:id', async ({ res, params }) => {
  126. const job = ctx.generation.getJob(params.id);
  127. if (!job) return sendJson(res, 404, { error: 'job not found' });
  128. sendJson(res, 200, {
  129. id: job.id,
  130. kind: job.kind,
  131. dishName: job.dishName,
  132. state: job.state,
  133. progress: job.progress,
  134. cost: job.cost,
  135. resultUrl: job.resultUrl,
  136. width: job.width,
  137. height: job.height,
  138. fileSizeBytes: job.fileSizeBytes,
  139. error: job.error,
  140. warning: job.warning,
  141. });
  142. });
  143. app.get('/api/tasks', async ({ res, query }) => {
  144. if (query.root && !ctx.workspace.isWorkspaceRoot(query.root)) {
  145. return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  146. }
  147. const root = ctx.workspace.getWorkspaceRoot();
  148. const tasks = ctx.generation.listJobs()
  149. .filter((job) => path.resolve(job.root) === root)
  150. .sort((a, b) => b.createdAt - a.createdAt)
  151. .map(publicTask);
  152. const summary = tasks.reduce((acc, task) => {
  153. acc[task.status] = (acc[task.status] || 0) + 1;
  154. return acc;
  155. }, { all: tasks.length });
  156. sendJson(res, 200, { root, summary, tasks });
  157. });
  158. app.post('/api/save-config', async ({ req, res }) => {
  159. const body = await readBody(req);
  160. const { root, folderName, shopName, prompts } = body;
  161. if (!root || !folderName) return sendJson(res, 400, { error: '参数不完整' });
  162. const shopDir = ctx.workspace.safeShopDir(root, folderName);
  163. if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
  164. const dir = path.join(shopDir, '_生成图片');
  165. fs.mkdirSync(dir, { recursive: true });
  166. const cfgPath = path.join(dir, 'prompt-config.json');
  167. let existing = {};
  168. if (fs.existsSync(cfgPath)) {
  169. try {
  170. existing = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
  171. } catch {
  172. // start fresh if corrupt
  173. }
  174. }
  175. const merged = {
  176. shopName: shopName !== undefined ? shopName : existing.shopName,
  177. prompts: { ...(existing.prompts || {}), ...(prompts || {}) },
  178. };
  179. fs.writeFileSync(cfgPath, JSON.stringify(merged, null, 2), 'utf8');
  180. sendJson(res, 200, { ok: true });
  181. });
  182. app.post('/api/save-poster-canvas', async ({ req, res }) => {
  183. const body = await readBody(req);
  184. const { root, folderName, kind, imageDataUrl } = body;
  185. const spec = POSTER_CANVAS_SPECS[kind];
  186. if (!root || !folderName || !spec || !imageDataUrl) {
  187. return sendJson(res, 400, { error: '参数不完整' });
  188. }
  189. if (typeof folderName !== 'string' || folderName === '.' || folderName === '..' || /[\\/]/.test(folderName)) {
  190. return sendJson(res, 400, { error: '店铺目录名无效' });
  191. }
  192. const dataUrlMatch = typeof imageDataUrl === 'string' && /^data:image\/png;base64,([A-Za-z0-9+/=]+)$/.exec(imageDataUrl);
  193. if (!dataUrlMatch) {
  194. return sendJson(res, 400, { error: '画板图必须为 PNG 格式' });
  195. }
  196. const base64 = dataUrlMatch[1];
  197. if (!base64 || base64.length > 10 * 1024 * 1024) {
  198. return sendJson(res, 400, { error: '画板图数据无效或过大' });
  199. }
  200. let imageBuffer;
  201. try {
  202. imageBuffer = Buffer.from(base64, 'base64');
  203. } catch {
  204. return sendJson(res, 400, { error: '画板图数据无法读取' });
  205. }
  206. const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
  207. if (!imageBuffer.length || !imageBuffer.subarray(0, 8).equals(pngSignature)) {
  208. return sendJson(res, 400, { error: '画板图不是有效的 PNG 文件' });
  209. }
  210. const dimensions = getImageSize(imageBuffer);
  211. if (!dimensions || dimensions.width !== spec.width || dimensions.height !== spec.height) {
  212. return sendJson(res, 400, { error: `画板图尺寸必须为 ${spec.width}×${spec.height}` });
  213. }
  214. const resolvedRoot = path.resolve(root);
  215. if (!ctx.workspace.isWorkspaceRoot(resolvedRoot)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  216. const shopDir = path.resolve(resolvedRoot, folderName);
  217. if (shopDir !== path.join(resolvedRoot, folderName) || !shopDir.startsWith(resolvedRoot + path.sep)) {
  218. return sendJson(res, 400, { error: '店铺目录无效' });
  219. }
  220. const dir = path.join(shopDir, '_生成图片');
  221. const savePath = path.join(dir, spec.fileName);
  222. try {
  223. fs.mkdirSync(dir, { recursive: true });
  224. fs.writeFileSync(savePath, imageBuffer);
  225. } catch (e) {
  226. return sendJson(res, 500, { error: '保存画板图失败: ' + e.message });
  227. }
  228. sendJson(res, 200, { ok: true, fileName: spec.fileName, width: dimensions.width, height: dimensions.height, fileSizeBytes: imageBuffer.length });
  229. });
  230. app.post('/api/open-folder', async ({ req, res }) => {
  231. const body = await readBody(req);
  232. if (body.root && !ctx.workspace.isWorkspaceRoot(body.root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  233. const dir = body.dir || path.join(body.root || ctx.workspace.getWorkspaceRoot(), body.folderName || '', '_生成图片');
  234. const fallbackDir = body.dir ? null : path.join(body.root || ctx.workspace.getWorkspaceRoot(), body.folderName || '');
  235. const targetDir = fs.existsSync(dir) ? dir : (fallbackDir && fs.existsSync(fallbackDir) ? fallbackDir : null);
  236. if (!targetDir || !ctx.workspace.isWorkspacePath(targetDir)) return sendJson(res, 400, { error: '目录不存在或不在输出根目录内: ' + dir });
  237. const command = process.platform === 'win32' ? 'explorer' : process.platform === 'darwin' ? 'open' : 'xdg-open';
  238. execFile(command, [targetDir], () => {});
  239. sendJson(res, 200, { ok: true, opened: targetDir });
  240. });
  241. }
  242. module.exports = { register };