import { chromium } from '@playwright/test' import { mkdir, writeFile } from 'node:fs/promises' // Offline fixtures for photo guidance and silent explanation playback. const browser = await chromium.launch({ channel: 'chrome', headless: true }) const page = await browser.newPage() await page.goto('about:blank') await mkdir('src/assets', { recursive: true }) try { for (const answer of [false, true]) { const png = await page.evaluate( ({ answer }) => { const c = document.createElement('canvas') c.width = 720 c.height = 960 const x = c.getContext('2d') x.fillStyle = '#fff' x.fillRect(0, 0, 720, 960) x.strokeStyle = '#50425e' x.lineWidth = 2 x.strokeRect(24, 24, 672, 912) x.fillStyle = '#49315e' x.textAlign = 'center' x.font = 'bold 32px sans-serif' x.fillText(answer ? '知了数学 · 答题卡' : '知了数学 · 数学练习卷', 360, 80) x.font = '22px sans-serif' x.fillText('二次函数专项练习', 360, 128) x.textAlign = 'left' x.fillStyle = '#5e5667' x.font = '18px sans-serif' x.fillText('姓名:李一 班级:初二 日期:2026/10/26', 54, 180) if (answer) { for (let r = 0; r < 15; r++) for (let col = 0; col < 15; col++) if ((r * 7 + col * 3) % 5 < 3) x.fillRect(588 + col * 4, 200 + r * 4, 4, 4) x.font = '16px sans-serif' x.fillText('编号:261026000030001', 54, 220) } const rows = answer ? [ ['1. 选择题(请清晰填涂)', '1. [ A ] [ B ] [ ● ] [ D ]', '2. [ ● ] [ B ] [ C ] [ D ]'], ['2. 填空题', '交点坐标:(1, 0) 和 (3, 0)', '请保留完整作答区域'], ['3. 解答题(保留步骤)', '解:设 y = a(x − 1)² + 4', '代入 (0, 3),得 a = −1', '所以 y = −(x − 1)² + 4'] ] : [ [ '1. 函数图像平移(选择题)', '将 y = −(x − 2)² + 3 平移至 y = −(x + 1)² − 2。', 'A. 右移3,上移5 B. 左移3,上移5', 'C. 左移3,下移5 D. 右移3,下移5' ], [ '2. 二次函数与坐标轴(填空题)', '求 y = x² − 4x + 3 与 x 轴的交点坐标。', '答:________________________________' ], [ '3. 抛物线解析式(解答题)', '已知顶点 (1, 4),且过点 (0, 3)。', '求抛物线的解析式及递减区间。', '请写出解题步骤。' ] ] rows.forEach((lines, n) => { const top = 280 + n * 195 x.strokeStyle = '#ded6e7' x.strokeRect(48, top - 28, 624, 175) lines.forEach((line, i) => { x.font = i === 0 ? 'bold 21px sans-serif' : '18px sans-serif' x.fillStyle = answer && i > 0 ? '#33598a' : '#493e53' x.fillText(line, 64, top + i * 37) }) }) x.font = '17px sans-serif' x.fillStyle = '#95859f' x.fillText('■ 保留四周边缘及完整题目', 52, 914) x.fillText('第 1 页 · 页面示例', 490, 914) return c.toDataURL('image/png').split(',')[1] }, { answer } ) await writeFile(`src/assets/sample-${answer ? 'answer' : 'paper'}.png`, Buffer.from(png, 'base64')) } const clips = [ [ ['函数图像平移', '顶点 (2, 3) → (−1, −2)'], ['横坐标减少 3', '向左平移 3 个单位'], ['纵坐标减少 5', '向下平移 5 个单位'], ['参考答案:C', '向左平移3个单位,向下平移5个单位'] ], [ ['二次函数交点问题', 'y = x² − 4x + 3'], ['令 y = 0', 'x² − 4x + 3 = 0'], ['因式分解', '(x − 1)(x − 3) = 0'], ['参考答案', '(1, 0) 和 (3, 0)'] ], [ ['已知顶点 (1, 4)', '设 y = a(x − 1)² + 4'], ['代入 (0, 3)', '3 = a + 4,所以 a = −1'], ['得到解析式', 'y = −(x − 1)² + 4'], ['开口向下,对称轴 x = 1', '当 x > 1 时,函数递减'] ], [ ['一元二次方程', 'x² − 5x + 6 = 0'], ['因式分解', '(x − 2)(x − 3) = 0'], ['分别求解', 'x − 2 = 0 或 x − 3 = 0'], ['参考答案:A', 'x₁ = 2,x₂ = 3'] ] ] for (let i = 0; i < clips.length; i++) { const data = await page.evaluate(async (steps) => { const mime = ['video/mp4;codecs=avc1.42001E', 'video/mp4'].find((v) => MediaRecorder.isTypeSupported(v)) if (!mime) throw new Error('Chrome does not support MP4 recording') const canvas = document.createElement('canvas') canvas.width = 800 canvas.height = 500 const x = canvas.getContext('2d') const stream = canvas.captureStream(10) const recorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 200000 }) const chunks = [] const start = performance.now() const draw = () => { const seconds = (performance.now() - start) / 1000 const step = steps[Math.min(3, Math.floor(seconds / 2))] x.fillStyle = '#271a3b' x.fillRect(0, 0, 800, 500) x.fillStyle = '#bba1e5' x.font = '22px sans-serif' x.fillText('知了数学 · 题目讲解示范', 42, 62) x.fillStyle = '#fff' x.font = 'bold 30px sans-serif' x.fillText(step[0], 42, 170) x.font = '30px sans-serif' x.fillText(step[1], 42, 270) x.fillStyle = '#bdacce' x.font = '20px sans-serif' x.fillText(`第 ${Math.min(4, Math.floor(seconds / 2) + 1)} 步 / 共 4 步`, 42, 390) x.fillStyle = '#a479ea' x.fillRect(42, 440, Math.min(716, (seconds / 8) * 716), 6) } draw() const done = new Promise((resolve) => { recorder.onstop = () => resolve(new Blob(chunks, { type: mime })) recorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data) } }) recorder.start() const interval = setInterval(draw, 100) await new Promise((resolve) => setTimeout(resolve, 8100)) recorder.stop() clearInterval(interval) const blob = await done stream.getTracks().forEach((t) => t.stop()) return await new Promise((resolve) => { const reader = new FileReader() reader.onload = () => resolve(String(reader.result).split(',')[1]) reader.readAsDataURL(blob) }) }, clips[i]) await writeFile(`src/assets/explanation-${i}.mp4`, Buffer.from(data, 'base64')) } } finally { await browser.close() }