diff --git a/src/agents/productionAgent/tools.ts b/src/agents/productionAgent/tools.ts index d50679d..7d47d13 100644 --- a/src/agents/productionAgent/tools.ts +++ b/src/agents/productionAgent/tools.ts @@ -284,6 +284,7 @@ export default (resTool: ResTool, toolsNames?: string[]) => { lines: item.lines, state: "未生成", scriptId: resTool.data.scriptId, + createTime: Date.now(), }); if (item.associateAssetsIds.length) { await u.db("o_assets2Storyboard").insert(item.associateAssetsIds.map((i) => ({ storyboardId: insertedId, assetId: i }))); diff --git a/src/routes/production/storyboard/downPreviewImage.ts b/src/routes/production/storyboard/downPreviewImage.ts new file mode 100644 index 0000000..fc35b74 --- /dev/null +++ b/src/routes/production/storyboard/downPreviewImage.ts @@ -0,0 +1,116 @@ +import express from "express"; +import u from "@/utils"; +import { z } from "zod"; +import sharp from "sharp"; +import { validateFields } from "@/middleware/middleware"; +const router = express.Router(); + +export default router.post( + "/", + validateFields({ + storyboardIds: z.array(z.number()), + }), + async (req, res) => { + const { storyboardIds } = req.body; + const storyboardImage = await u.db("o_storyboard").whereIn("id", storyboardIds).select("id", "filePath"); + + // 按 storyboardIds 顺序构建 filePath 映射 + const filePathMap: Record = {}; + storyboardImage.forEach((i) => { + filePathMap[i.id!] = i.filePath || ""; + }); + const orderedFilePaths = storyboardIds.map((id: number) => filePathMap[id]); + + // 读取所有图片 buffer 并获取元数据 + // sharp 底层 libvips 在 composite 时会将所有 input 解码为内存像素再合成,无需预转格式 + const loaded = await Promise.all( + orderedFilePaths.map(async (filePath: string) => { + if (!filePath) return null; + const buffer = await u.oss.getFile(filePath); + const metadata = await sharp(buffer).metadata(); + return { buffer, width: metadata.width || 0, height: metadata.height || 0 }; + }), + ); + + // 过滤掉无效图片 + const validImages = loaded.filter((img): img is NonNullable => img !== null && img.width > 0 && img.height > 0); + if (validImages.length === 0) { + res.status(204).end(); + return; + } + + // 计算网格布局 + const cols = Math.min(5, validImages.length); + const rows = Math.ceil(validImages.length / cols); + + const colWidths: number[] = Array(cols).fill(0); + const rowHeights: number[] = Array(rows).fill(0); + validImages.forEach((img, idx) => { + const c = idx % cols; + const r = Math.floor(idx / cols); + colWidths[c] = Math.max(colWidths[c], img.width); + rowHeights[r] = Math.max(rowHeights[r], img.height); + }); + + const canvasWidth = colWidths.reduce((a, b) => a + b, 0); + const canvasHeight = rowHeights.reduce((a, b) => a + b, 0); + + // 为每张图片生成带标号的合成层 + const compositeInputs: sharp.OverlayOptions[] = []; + + for (let i = 0; i < validImages.length; i++) { + const img = validImages[i]; + const c = i % cols; + const r = Math.floor(i / cols); + const x = colWidths.slice(0, c).reduce((a, b) => a + b, 0); + const y = rowHeights.slice(0, r).reduce((a, b) => a + b, 0); + + // 添加图片层 + compositeInputs.push({ + input: img.buffer, + left: x, + top: y, + }); + + // 生成标号标签 SVG + const label = `S${String(i + 1).padStart(2, "0")}`; + const fontSize = Math.max(14, Math.min(img.width, img.height) * 0.06); + const padding = Math.round(fontSize * 0.4); + const textWidth = Math.round(label.length * fontSize * 0.65); + const bgW = textWidth + padding * 2; + const bgH = Math.round(fontSize) + padding * 2; + + const labelSvg = Buffer.from( + ` + + ${label} + `, + ); + + compositeInputs.push({ + input: labelSvg, + left: x + 4, + top: y + 4, + }); + } + + // 使用 sharp 创建画布并合成,输出 PNG 无损格式避免压缩损耗 + const resultBuffer = await sharp({ + create: { + width: canvasWidth, + height: canvasHeight, + channels: 4, + background: { r: 255, g: 255, b: 255, alpha: 1 }, + }, + }) + .composite(compositeInputs) + .png({ compressionLevel: 3 }) + .toBuffer(); + + // 以文件下载形式返回 + res.setHeader("Content-Type", "image/png"); + res.setHeader("Content-Disposition", "attachment; filename=storyboard-preview.png"); + res.setHeader("Content-Length", resultBuffer.length); + res.status(200).send(resultBuffer); + }, +); diff --git a/src/routes/production/storyboard/getStoryboardData.ts b/src/routes/production/storyboard/getStoryboardData.ts new file mode 100644 index 0000000..6a90c68 --- /dev/null +++ b/src/routes/production/storyboard/getStoryboardData.ts @@ -0,0 +1,58 @@ +import express from "express"; +import u from "@/utils"; +import { z } from "zod"; +import { success } from "@/lib/responseFormat"; +import { validateFields } from "@/middleware/middleware"; +const router = express.Router(); + +export default router.post( + "/", + validateFields({ + scriptId: z.number(), + page: z.number(), + limit: z.number(), + name: z.string().optional().nullable(), + }), + async (req, res) => { + const { scriptId, page, limit, name } = req.body; + const offset = (page - 1) * limit; + + const storyboardData = await u + .db("o_storyboard") + .where({ scriptId }) + .modify((qb) => { + if (name) { + qb.andWhere("title", "like", `%${name}%`); + } + }) + .offset(offset) + .limit(limit); + const data = await Promise.all( + storyboardData.map(async (i: any) => { + return { + id: i.id, + title: i.title, + prompt: i.prompt, + description: i.description, + camera: i.camera, + lines: i.lines, + sound: i.sound, + state: i.state, + src: i.filePath ? await u.oss.getFileUrl(i.filePath!) : "", + }; + }), + ); + const totalQuery = (await u + .db("o_storyboard") + .where({ scriptId }) + .modify((qb) => { + if (name) { + qb.andWhere("title", "like", `%${name}%`); + } + }) + .count("* as total") + .first()) as any; + + res.status(200).send(success({ data: data, total: totalQuery?.total })); + }, +); diff --git a/src/routes/production/storyboard/previewImage.ts b/src/routes/production/storyboard/previewImage.ts index 84e0611..e373f81 100644 --- a/src/routes/production/storyboard/previewImage.ts +++ b/src/routes/production/storyboard/previewImage.ts @@ -38,13 +38,28 @@ export default router.post( return res.status(200).send(success(null)); } + // 将每张图片缩放到合理尺寸,单张最大宽度 512px + const maxThumbWidth = 512; + const resizedImages = await Promise.all( + validImages.map(async (img) => { + if (img.width <= maxThumbWidth) { + return img; + } + const scale = maxThumbWidth / img.width; + const newWidth = maxThumbWidth; + const newHeight = Math.round(img.height * scale); + const buffer = await sharp(img.buffer).resize(newWidth, newHeight).toBuffer(); + return { buffer, width: newWidth, height: newHeight }; + }), + ); + // 计算网格布局 - const cols = Math.min(5, validImages.length); - const rows = Math.ceil(validImages.length / cols); + const cols = Math.min(5, resizedImages.length); + const rows = Math.ceil(resizedImages.length / cols); const colWidths: number[] = Array(cols).fill(0); const rowHeights: number[] = Array(rows).fill(0); - validImages.forEach((img, idx) => { + resizedImages.forEach((img, idx) => { const c = idx % cols; const r = Math.floor(idx / cols); colWidths[c] = Math.max(colWidths[c], img.width); @@ -57,8 +72,8 @@ export default router.post( // 为每张图片生成带标号的合成层 const compositeInputs: sharp.OverlayOptions[] = []; - for (let i = 0; i < validImages.length; i++) { - const img = validImages[i]; + for (let i = 0; i < resizedImages.length; i++) { + const img = resizedImages[i]; const c = i % cols; const r = Math.floor(i / cols); const x = colWidths.slice(0, c).reduce((a, b) => a + b, 0); @@ -104,11 +119,11 @@ export default router.post( }, }) .composite(compositeInputs) - .png() + .jpeg({ quality: 80 }) .toBuffer(); const base64 = resultBuffer.toString("base64"); - const dataUrl = `data:image/png;base64,${base64}`; + const dataUrl = `data:image/jpeg;base64,${base64}`; return res.status(200).send(success(dataUrl)); },