mirror of
https://github.com/hanzoai/render.git
synced 2026-10-30 15:04:50 +00:00
A Node 24 process that claims render jobs from Studio's render lane (/v1/studio/render), renders one output, asks the lane where it goes, PUTs it there, and completes the job with the measured result. Five ops: render.html (exact PNG/JPEG/WebP or one-page PDF), render.document (PDF, DOCX, Markdown), render.slides (PPTX with notes, or a 1920x1080 PDF per slide), render.composition (@hanzo/frames), and video.edit (ffmpeg: trim, fit, join, libass captions, overlays, mixed audio). Every page runs in a fresh browser context behind request interception and a per-job egress proxy, which frames' Chrome and Node go through too; ffmpeg opens only local files through a demuxer whitelist. The image is node:24-bookworm-slim with Debian's chromium-headless-shell (amd64 and arm64), ffmpeg and fonts, built for both platforms by hanzoai/ci.
87 lines
3.7 KiB
TypeScript
87 lines
3.7 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { after, before, test } from 'node:test'
|
|
import { inflateRawSync } from 'node:zlib'
|
|
import { close } from '../src/browser.ts'
|
|
import { DOCX, zipNames } from '../src/measure.ts'
|
|
import { boxes, fake, perform, png } from './fake.ts'
|
|
import type { Fake } from './fake.ts'
|
|
|
|
let f: Fake
|
|
before(async () => {
|
|
f = await fake()
|
|
f.files.set('lib_chart.png', { body: png(320, 180, [200, 60, 40]), type: 'image/png' })
|
|
})
|
|
after(async () => { await close(); await f.close() })
|
|
|
|
const IR = {
|
|
blocks: [
|
|
{ type: 'heading', level: 1, text: 'Quarterly *review*' },
|
|
{ type: 'paragraph', text: 'Revenue grew **18%** while costs held *flat*. Not italic: 2 * 3 * 4.' },
|
|
{ type: 'list', items: ['First', 'Second with **bold**'] },
|
|
{ type: 'list', ordered: true, items: ['One', 'Two', 'Three'] },
|
|
{ type: 'quote', text: 'Ship it finished or do not ship it.' },
|
|
{ type: 'image', asset: 'lib_chart', caption: 'Figure 1. <script>alert(1)</script>' },
|
|
{ type: 'break' },
|
|
{ type: 'heading', level: 2, text: 'Appendix' },
|
|
{ type: 'table', header: true, rows: [['Region', 'Q1', 'Q2'], ['EMEA', '10', '12'], ['APAC', '7']] },
|
|
{ type: 'code', text: 'const a = 1\nconsole.log(`${a}`)' },
|
|
],
|
|
}
|
|
|
|
const input = (format: string) => ({
|
|
op: 'render.document', with: { ir: IR, title: 'Review', format, tokens: { '--primary': '#2255cc', '--font-sans': 'Inter, sans-serif' } },
|
|
inputs: { lib_chart: f.input('lib_chart.png') },
|
|
})
|
|
|
|
// One entry of a ZIP, inflated.
|
|
function entry(zip: Buffer, name: string): string {
|
|
let p = 0
|
|
while (zip.readUInt32LE(p) === 0x04034b50) {
|
|
const method = zip.readUInt16LE(p + 8), size = zip.readUInt32LE(p + 18), n = zip.readUInt16LE(p + 26), x = zip.readUInt16LE(p + 28)
|
|
const start = p + 30 + n + x
|
|
if (zip.toString('utf8', p + 30, p + 30 + n) === name) {
|
|
const data = zip.subarray(start, start + size)
|
|
return (method === 8 ? inflateRawSync(data) : data).toString('utf8')
|
|
}
|
|
p = start + size
|
|
}
|
|
throw new Error(`${name} not in zip`)
|
|
}
|
|
|
|
test('render.document pdf breaks pages and numbers them', async () => {
|
|
const { result, body } = await perform(f, input('pdf'))
|
|
assert.equal(result.mime, 'application/pdf')
|
|
assert.ok(result.pages! >= 2, `pages ${result.pages}`)
|
|
const [box] = boxes(body)
|
|
assert.ok(Math.abs(box[2] - 595.3) < 1.5 && Math.abs(box[3] - 841.9) < 1.5, `A4 MediaBox ${box}`)
|
|
assert.ok(f.hits.some((h) => h.path === '/files/lib_chart.png'), 'the image input was fetched')
|
|
})
|
|
|
|
test('render.document docx is a Word document with its image', async () => {
|
|
const { result, body } = await perform(f, input('docx'))
|
|
assert.equal(result.mime, DOCX)
|
|
const names = zipNames(body)
|
|
assert.ok(names.includes('word/document.xml'), names.join(','))
|
|
assert.ok(names.some((n) => /^word\/media\/.+\.png$/.test(n)), 'image embedded')
|
|
const xml = entry(body, 'word/document.xml')
|
|
assert.match(xml, /<w:b\/>.*?18%/s)
|
|
assert.match(xml, /w:br w:type="page"/)
|
|
assert.match(xml, /<script>/)
|
|
assert.ok(names.includes('word/footer1.xml'), 'page-number footer')
|
|
})
|
|
|
|
test('render.document md keeps the IR text', async () => {
|
|
const { result, body } = await perform(f, input('md'))
|
|
assert.equal(result.mime, 'text/markdown')
|
|
const md = body.toString()
|
|
assert.match(md, /^# Quarterly \*review\*$/m)
|
|
assert.match(md, /^1\. One$/m)
|
|
assert.match(md, /^\| Region \| Q1 \| Q2 \|$/m)
|
|
assert.match(md, /!\[Figure 1\. <script>alert\(1\)<\/script>\]\(asset:\/\/lib_chart\)/)
|
|
assert.match(md, /^```\nconst a = 1/m)
|
|
})
|
|
|
|
test('render.document refuses an unknown block', async () => {
|
|
await assert.rejects(perform(f, { op: 'render.document', with: { ir: { blocks: [{ type: 'video' }] } } }), /block 0 has unknown type video/)
|
|
})
|