55 lines
3.2 KiB
TypeScript

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<string, RenderAsset>, 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<string>((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` }
}