import { spawn } from 'child_process' import path from 'path' import AdmZip from 'adm-zip' import type { DetailDocument, DetailExportFormat } from './types' type RenderAsset = { buffer: Buffer; mimeType: string } type RenderFile = { filename: string; mimeType: string; base64: string } const RENDER_TIMEOUT_MS = 180_000 const MAX_IO_BYTES = 192 * 1024 * 1024 /** Assets are already read through the project/owner checked private storage boundary. */ export async function renderDetailExport(document: DetailDocument, assets: Record, format: DetailExportFormat): Promise<{ buffer: Buffer; mimeType: string; filename: string }> { const input = JSON.stringify({ document, format, assets: Object.fromEntries(Object.entries(assets).map(([id, asset]) => [id, { mimeType: asset.mimeType, base64: asset.buffer.toString('base64') }])) }) if (Buffer.byteLength(input) > MAX_IO_BYTES) throw new Error('导出图片总量超过上限') const executable = process.env.DETAIL_RENDERER_NODE || process.execPath const entry = process.env.DETAIL_RENDERER_ENTRY || path.resolve(process.cwd(), 'workers/detail-renderer/cli.mjs') const output = await new Promise((resolve, reject) => { const child = spawn(executable, ['--max-old-space-size=768', entry], { cwd: process.cwd(), windowsHide: true, shell: false, env: { ...process.env, DETAIL_FONT_DIR: process.env.DETAIL_FONT_DIR || path.resolve(process.cwd(), 'public/detail-generator/fonts') }, stdio: ['pipe', 'pipe', 'pipe'] }) const chunks: Buffer[] = [] let bytes = 0 let stderr = '' let settled = false const finish = (error?: Error, result?: string) => { if (settled) return settled = true clearTimeout(timeout) if (error) reject(error) else resolve(result || '') } const timeout = setTimeout(() => { child.kill('SIGKILL'); finish(new Error('详情页导出超时,请减少屏数或素材尺寸')) }, RENDER_TIMEOUT_MS) child.on('error', error => finish(error)) child.stdin.on('error', () => undefined) // spawn/exit handling below reports the actual failure. child.stdout.on('data', (chunk: Buffer) => { bytes += chunk.length if (bytes > MAX_IO_BYTES) { child.kill('SIGKILL'); finish(new Error('导出结果超过上限')); return } chunks.push(chunk) }) child.stderr.on('data', (chunk: Buffer) => { if (stderr.length < 2048) stderr += chunk.toString('utf8') }) child.on('close', code => { if (code !== 0) { finish(new Error(stderr.trim() || '详情页渲染进程失败,请检查独立渲染依赖')); return } finish(undefined, Buffer.concat(chunks).toString('utf8')) }) child.stdin.end(input) }) const parsed = JSON.parse(output) as { files: RenderFile[] } if (!Array.isArray(parsed.files) || !parsed.files.length) throw new Error('导出未产生文件') if (parsed.files.length === 1) { const file = parsed.files[0] return { buffer: Buffer.from(file.base64, 'base64'), mimeType: file.mimeType, filename: file.filename } } const zip = new AdmZip() for (const file of parsed.files) zip.addFile(file.filename, Buffer.from(file.base64, 'base64')) return { buffer: zip.toBuffer(), mimeType: 'application/zip', filename: `detail-screens-${format}.zip` } }