自动生成封面

This commit is contained in:
feie9454 2025-09-13 13:35:06 +08:00
parent c082a30d62
commit c2d5948301
4 changed files with 52 additions and 3 deletions

View File

@ -13,6 +13,7 @@
"morgan": "^1.10.1", "morgan": "^1.10.1",
"pino": "^9.9.5", "pino": "^9.9.5",
"pino-pretty": "^13.1.1", "pino-pretty": "^13.1.1",
"playwright": "^1.55.0",
"prisma": "^6.16.1", "prisma": "^6.16.1",
"zod": "^4.1.8", "zod": "^4.1.8",
}, },
@ -173,6 +174,8 @@
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fsevents": ["fsevents@2.3.2", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@ -259,6 +262,10 @@
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"playwright": ["playwright@1.55.0", "https://registry.npmmirror.com/playwright/-/playwright-1.55.0.tgz", { "dependencies": { "playwright-core": "1.55.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA=="],
"playwright-core": ["playwright-core@1.55.0", "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.55.0.tgz", { "bin": { "playwright-core": "cli.js" } }, "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg=="],
"prisma": ["prisma@6.16.1", "", { "dependencies": { "@prisma/config": "6.16.1", "@prisma/engines": "6.16.1" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-MFkMU0eaDDKAT4R/By2IA9oQmwLTxokqv2wegAErr9Rf+oIe7W2sYpE/Uxq0H2DliIR7vnV63PkC1bEwUtl98w=="], "prisma": ["prisma@6.16.1", "", { "dependencies": { "@prisma/config": "6.16.1", "@prisma/engines": "6.16.1" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-MFkMU0eaDDKAT4R/By2IA9oQmwLTxokqv2wegAErr9Rf+oIe7W2sYpE/Uxq0H2DliIR7vnV63PkC1bEwUtl98w=="],
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],

View File

@ -23,6 +23,7 @@
"morgan": "^1.10.1", "morgan": "^1.10.1",
"pino": "^9.9.5", "pino": "^9.9.5",
"pino-pretty": "^13.1.1", "pino-pretty": "^13.1.1",
"playwright": "^1.55.0",
"prisma": "^6.16.1", "prisma": "^6.16.1",
"zod": "^4.1.8" "zod": "^4.1.8"
}, },

View File

@ -4,6 +4,7 @@ import helmet from 'helmet';
import morgan from 'morgan'; import morgan from 'morgan';
import { router as apiRouter } from './routes'; import { router as apiRouter } from './routes';
import { errorHandler } from './middlewares/errorHandler'; import { errorHandler } from './middlewares/errorHandler';
import path from 'path';
export function createApp() { export function createApp() {
const app = express(); const app = express();
@ -16,6 +17,19 @@ export function createApp() {
app.get('/health', (_req: express.Request, res: express.Response) => res.json({ ok: true })); app.get('/health', (_req: express.Request, res: express.Response) => res.json({ ok: true }));
app.use('/api', apiRouter); app.use('/api', apiRouter);
// 静态资源目录(相对于项目根目录)
const distDir = path.resolve(process.cwd(), 'dist');
// 提供 dist 下的静态文件
app.use(express.static(distDir));
// 对于非 /api 前缀且未命中的路由,回退返回 dist/index.html适用于 SPA
app.get(/^\/(?!api)(.*)/, (_req, res, next) => {
res.sendFile(path.join(distDir, 'index.html'), (err) => {
if (err) next(err);
});
});
app.use(errorHandler); app.use(errorHandler);
return app; return app;
} }

View File

@ -3,6 +3,33 @@ import { prisma } from '../lib/prisma';
import { basicAuth } from '../middlewares/basicAuth'; import { basicAuth } from '../middlewares/basicAuth';
import { CreateModelSchema, UpdateModelSchema, ListQuerySchema } from '../lib/validators'; import { CreateModelSchema, UpdateModelSchema, ListQuerySchema } from '../lib/validators';
import { decodeBase64ToBuffer } from '../lib/base64'; import { decodeBase64ToBuffer } from '../lib/base64';
import { chromium } from 'playwright';
import { readFile } from 'fs/promises';
import path from 'path';
const dirname = new URL('.', import.meta.url).pathname;
async function generatePreviewImage(circuitModel: any) {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setViewportSize({ width: 1024, height: 1024 });
// Load the lab page
await page.goto(`http://localhost:${Number(process.env.PORT ?? 3000)}/lab?preview=true`, { waitUntil: 'load' });
await page.waitForFunction(() => {
// @ts-ignore
return window.loadCircuitFromJSONText !== undefined;
});
const jsonText = JSON.stringify(circuitModel);
// Inject the model data and render
await page.evaluate((jsonText) => {
// @ts-ignore
window.loadCircuitFromJSONText(jsonText);
}, jsonText);
const image = await page.screenshot({ type: 'png' });
await browser.close();
return image;
}
export const router = Router(); export const router = Router();
@ -10,14 +37,14 @@ export const router = Router();
router.post('/', basicAuth, async (req: Request, res: Response) => { router.post('/', basicAuth, async (req: Request, res: Response) => {
const parsed = CreateModelSchema.safeParse(req.body); const parsed = CreateModelSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { title, desc, model, previewBase64, previewMime } = parsed.data; const { title, desc, model } = parsed.data;
const created = await prisma.circuitModel.create({ const created = await prisma.circuitModel.create({
data: { data: {
title, title,
desc, desc,
model, model,
preview: decodeBase64ToBuffer(previewBase64), preview: await generatePreviewImage(model),
previewMime: previewBase64 ? (previewMime ?? 'image/png') : null, previewMime: 'image/png',
authorId: (req as any).user.id, authorId: (req as any).user.id,
}, },
}); });