import { createHash } from 'node:crypto' import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import sharp from 'sharp' const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)) const workspace = path.resolve(scriptDirectory, '..', '..') const manifestPath = path.join(workspace, 'design-pipeline', 'manifests', 'a01.json') const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) const generatedDirectory = path.join(workspace, 'design-pipeline', 'generated', 'a01') const resolveWorkspacePath = (relativePath) => { const absolutePath = path.resolve(workspace, relativePath) const relative = path.relative(workspace, absolutePath) if (relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error(`路径越出工作区:${relativePath}`) } return absolutePath } const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex') const writeVersionedFile = async (outputPath, buffer) => { await mkdir(path.dirname(outputPath), { recursive: true }) try { const current = await readFile(outputPath) if (sha256(current) !== sha256(buffer)) { throw new Error(`拒绝覆盖内容不同的版本化输出:${path.relative(workspace, outputPath)}`) } return } catch (error) { if (error.code !== 'ENOENT') throw error } await writeFile(outputPath, buffer) } const runtimeAssets = new Map() const reportAssets = [] for (const asset of manifest.assets) { const sourcePath = resolveWorkspacePath(asset.source) const outputPath = resolveWorkspacePath(asset.output) await stat(sourcePath) const pipeline = sharp(sourcePath).resize(asset.width, asset.height, { fit: 'fill' }) if (!asset.alpha) pipeline.flatten({ background: '#f4eadc' }) const buffer = await pipeline.png({ compressionLevel: 9, adaptiveFiltering: true, palette: false }).toBuffer() await writeVersionedFile(outputPath, buffer) runtimeAssets.set(asset.id, buffer) const metadata = await sharp(buffer).metadata() reportAssets.push({ id: asset.id, output: asset.output, width: metadata.width, height: metadata.height, bytes: buffer.length, sha256: sha256(buffer) }) } const loadShared = async (key) => readFile(resolveWorkspacePath(manifest.shared[key])) const shared = { brandSeal: await loadShared('brandSeal'), lock: await loadShared('lock'), wechat: await loadShared('wechat'), agreementUnchecked: await loadShared('agreementUnchecked') } const svgLayer = (mode) => Buffer.from(` 登录家谱 密码登录 验证码登录 手机号 ${mode === 'password' ? '密码' : '验证码'} ${mode === 'password' ? '忘记密码' : '获取验证码'} 登录 其他登录方式 微信登录 还没有账号? 注册账号 我已阅读并同意《用户协议》与《隐私政策》 `) const sized = async (input, width, height) => sharp(input).resize(width, height, { fit: 'fill' }).png().toBuffer() const buildBasePreview = async (mode) => { const stateIcon = mode === 'password' ? shared.lock : runtimeAssets.get('smsThreeDots') const rightIcon = mode === 'password' ? runtimeAssets.get('eyeClosedPupil') : null const layers = [ { input: await sized(runtimeAssets.get('header'), 412, 170), left: 0, top: 0 }, { input: await sized(runtimeAssets.get('scroll'), 392, 772), left: 10, top: 143 }, { input: await sized(shared.brandSeal, 72, 86), left: 170, top: 35 }, { input: await sized(runtimeAssets.get('titleOrnament'), 126, 44), left: 143, top: 235 }, { input: await sized(runtimeAssets.get('divider'), 180, 30), left: 116, top: 330 }, { input: await sized(runtimeAssets.get('phone'), 40, 40), left: 68, top: 420 }, { input: await sized(stateIcon, 40, 40), left: 68, top: 522 }, { input: await sized(runtimeAssets.get('primaryButton'), 292, 65), left: 60, top: 608 }, { input: await sized(runtimeAssets.get('secondaryButton'), 292, 60), left: 60, top: 718 }, { input: await sized(shared.wechat, 36, 36), left: 126, top: 730 }, { input: await sized(shared.agreementUnchecked, 24, 24), left: 62, top: 831 }, { input: svgLayer(mode), left: 0, top: 0 } ] if (rightIcon) layers.splice(7, 0, { input: await sized(rightIcon, 42, 35), left: 312, top: 525 }) return sharp({ create: { width: 412, height: 915, channels: 4, background: '#f4eadc' } }) .composite(layers) .png({ compressionLevel: 9, adaptiveFiltering: true }) .toBuffer() } await mkdir(generatedDirectory, { recursive: true }) const passwordPreview = await buildBasePreview('password') const smsPreview = await buildBasePreview('sms') const writeGenerated = async (relativePath, buffer) => { const outputPath = resolveWorkspacePath(relativePath) await mkdir(path.dirname(outputPath), { recursive: true }) await writeFile(outputPath, buffer) } await writeGenerated('design-pipeline/generated/a01/A01-password-412x915.png', passwordPreview) await writeGenerated('design-pipeline/generated/a01/A01-sms-412x915.png', smsPreview) const contact = await sharp({ create: { width: 824, height: 915, channels: 4, background: '#ffffff' } }) .composite([{ input: passwordPreview, left: 0, top: 0 }, { input: smsPreview, left: 412, top: 0 }]) .png({ compressionLevel: 9, adaptiveFiltering: true }) .toBuffer() await writeGenerated('design-pipeline/generated/a01/A01-password-sms-contact-824x915.png', contact) for (const preview of manifest.previews.filter((item) => item.width !== 412 && item.width !== 824)) { const compact = await sharp(passwordPreview) .resize(preview.width, preview.height, { fit: 'fill' }) .png({ compressionLevel: 9, adaptiveFiltering: true }) .toBuffer() await writeGenerated(preview.output, compact) } const buildReport = { schemaVersion: 1, manifest: path.relative(workspace, manifestPath).replaceAll('\\', '/'), assets: reportAssets, generatedAt: new Date().toISOString() } await writeGenerated('design-pipeline/generated/a01/build-report.json', Buffer.from(`${JSON.stringify(buildReport, null, 2)}\n`)) console.log('A01-CODE-PIPELINE BUILD PASS')