From 097814f4da6bb1421fe17485026dc44e8a39a3ec Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 18 May 2026 04:31:51 +0300 Subject: [PATCH] chore(scripts): add visual bisect helper --- scripts/visual-bisect.ts | 323 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 scripts/visual-bisect.ts diff --git a/scripts/visual-bisect.ts b/scripts/visual-bisect.ts new file mode 100644 index 000000000..f6d941637 --- /dev/null +++ b/scripts/visual-bisect.ts @@ -0,0 +1,323 @@ +#!/usr/bin/env bun + +import { existsSync, mkdirSync } from 'node:fs' +import { basename } from 'node:path' +import { parseArgs } from 'node:util' + +import { $ } from 'bun' + +import { parseColor } from '@open-pencil/core/color' +import { headlessRenderNodes, initCanvasKit, parseFigFile } from '@open-pencil/core/io' +import { computeAllLayouts } from '@open-pencil/core/layout' +import type { SceneGraph } from '@open-pencil/core/scene-graph' + +interface DiffMetrics { + mean: number + above5: number + above10: number + above20: number + above40: number + above80: number +} + +interface BisectResult { + depth: number + indices: number[] + names: string[] + metrics: DiffMetrics + figmaPath: string + openPencilPath: string +} + +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + page: { type: 'string', short: 'p' }, + 'figma-page-id': { type: 'string' }, + output: { type: 'string', short: 'o', default: '/tmp/open-pencil-visual-bisect' }, + scale: { type: 'string', default: '1' }, + threshold: { type: 'string', default: '0.25' }, + depth: { type: 'string', default: '8' }, + 'min-size': { type: 'string', default: '1' }, + background: { type: 'string', default: '#f9f9f9' } + } +}) + +const figPath = positionals[0] +if (!figPath || !values.page || !values['figma-page-id']) { + console.error(`Usage: + bun scripts/visual-bisect.ts --page Primitives --figma-page-id 1:22 [options] + +Options: + --output DIR Output directory (default: /tmp/open-pencil-visual-bisect) + --scale N Export scale (default: 1) + --threshold PERCENT Stop splitting groups under this >40 diff percent (default: 0.25) + --depth N Max bisection depth (default: 8) + --min-size N Stop splitting groups at this child count (default: 1) + --background HEX Matte color for transparent pixels (default: #f9f9f9) + +What it does: + It hides all top-level page children except a candidate subset in both Figma + and OpenPencil, exports that subset from both renderers, diffs the images, + then recursively splits only subsets that still differ. This isolates which + top-level page children introduce visual differences without staring at the + full-page diff.`) + process.exit(1) +} + +const pageName = values.page +const figmaPageId = values['figma-page-id'] +const outputDir = values.output ?? '/tmp/open-pencil-visual-bisect' +const scale = Number(values.scale ?? '1') +const threshold = Number(values.threshold ?? '0.25') +const maxDepth = Number(values.depth ?? '8') +const minSize = Number(values['min-size'] ?? '1') +const matteColor = colorBytes(values.background ?? '#f9f9f9') + +if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }) + +const graph = await loadGraph(figPath, pageName) +const page = graph.getPages().find((candidate) => candidate.name === pageName) +if (!page) throw new Error(`Page not found in .fig: ${pageName}`) +const childIds = [...page.childIds] +const childNames = childIds.map((id) => graph.getNode(id)?.name ?? id) + +console.log(`Visual bisect: ${basename(figPath)} / ${pageName}`) +console.log(`Top-level children: ${childIds.length}`) +console.log(`Output: ${outputDir}`) + +const initialVisibility = await captureFigmaVisibility(figmaPageId) +const results: BisectResult[] = [] + +try { + await bisect( + childIds.map((_, index) => index), + 0 + ) +} finally { + await restoreFigmaVisibility(figmaPageId, initialVisibility) +} + +results.sort((a, b) => b.metrics.above40 - a.metrics.above40 || a.indices.length - b.indices.length) +await writeReport(results) + +console.log('\nTop suspects:') +for (const result of results.slice(0, 12)) { + console.log( + `${result.indices.join(',')} | >40 ${result.metrics.above40.toFixed(3)}% | ${result.names.join(' / ')}` + ) +} +console.log(`\nReport: ${outputDir}/report.md`) + +async function loadGraph(path: string, targetPageName: string): Promise { + const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()) + const parsed = await parseFigFile( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + { + populate: 'all' + } + ) + const targetPage = parsed.getPages().find((candidate) => candidate.name === targetPageName) + if (targetPage) computeAllLayouts(parsed, targetPage.id) + return parsed +} + +async function bisect(indices: number[], depth: number): Promise { + if (indices.length === 0 || depth > maxDepth) return + + const result = await compareSubset(indices, depth) + const label = `[depth ${depth}] [${indices.join(',')}] >40=${result.metrics.above40.toFixed(3)}% ${result.names.join(' / ')}` + console.log(label) + + if (result.metrics.above40 <= threshold) return + results.push(result) + + if (indices.length <= minSize) return + const mid = Math.floor(indices.length / 2) + await bisect(indices.slice(0, mid), depth + 1) + await bisect(indices.slice(mid), depth + 1) +} + +async function compareSubset(indices: number[], depth: number): Promise { + const stem = `d${depth}-${indices[0]}-${indices.at(-1)}-${indices.length}` + const figmaPath = `${outputDir}/${stem}-figma.png` + const openPencilPath = `${outputDir}/${stem}-open-pencil.png` + + await exportFigmaSubset(figmaPageId, indices, figmaPath) + await exportOpenPencilSubset(indices, openPencilPath) + + return { + depth, + indices, + names: indices.map((index) => childNames[index]), + metrics: await diffImages(figmaPath, openPencilPath), + figmaPath, + openPencilPath + } +} + +async function exportOpenPencilSubset(indices: number[], path: string): Promise { + const indexSet = new Set(indices) + const changed: Array<{ id: string; visible: boolean }> = [] + for (let index = 0; index < childIds.length; index++) { + const node = graph.getNode(childIds[index]) + if (!node) continue + changed.push({ id: node.id, visible: node.visible }) + node.visible = indexSet.has(index) + } + + try { + const data = await headlessRenderNodes(graph, page.id, page.childIds, { + scale, + format: 'PNG', + trimTransparent: true + }) + if (!data) throw new Error(`OpenPencil render produced no image for ${indices.join(',')}`) + await Bun.write(path, data) + } finally { + for (const entry of changed) { + const node = graph.getNode(entry.id) + if (node) node.visible = entry.visible + } + } +} + +async function exportFigmaSubset(pageId: string, indices: number[], path: string): Promise { + await setFigmaVisibleIndices(pageId, indices) + await $`figma-use export node ${pageId} --output ${path} --scale ${String(scale)}`.quiet() +} + +async function captureFigmaVisibility(pageId: string): Promise { + const code = ` + const page = figma.getNodeById(${JSON.stringify(pageId)}); + if (!page || page.type !== 'PAGE') throw new Error('Page not found: ${pageId}'); + return page.children.map((child) => child.visible); + ` + const out = await $`figma-use eval ${code} --json`.quiet() + return JSON.parse(out.text().trim()) +} + +async function setFigmaVisibleIndices(pageId: string, indices: number[]): Promise { + const code = ` + const visible = new Set(${JSON.stringify(indices)}); + const page = figma.getNodeById(${JSON.stringify(pageId)}); + if (!page || page.type !== 'PAGE') throw new Error('Page not found: ${pageId}'); + page.children.forEach((child, index) => { child.visible = visible.has(index); }); + ` + await $`figma-use eval ${code}`.quiet() +} + +async function restoreFigmaVisibility(pageId: string, visibility: boolean[]): Promise { + const code = ` + const visibility = ${JSON.stringify(visibility)}; + const page = figma.getNodeById(${JSON.stringify(pageId)}); + if (!page || page.type !== 'PAGE') return; + page.children.forEach((child, index) => { child.visible = visibility[index] ?? child.visible; }); + ` + await $`figma-use eval ${code}`.quiet().nothrow() +} + +async function diffImages(expectedPath: string, actualPath: string): Promise { + const ck = await initCanvasKit() + const expected = ck.MakeImageFromEncoded(await Bun.file(expectedPath).bytes()) + const actual = ck.MakeImageFromEncoded(await Bun.file(actualPath).bytes()) + if (!expected || !actual) throw new Error('Failed to decode PNGs') + + try { + const width = Math.min(expected.width(), actual.width()) + const height = Math.min(expected.height(), actual.height()) + const imageInfo = { + width, + height, + colorType: ck.ColorType.RGBA_8888, + alphaType: ck.AlphaType.Unpremul, + colorSpace: ck.ColorSpace.SRGB + } + const expectedPixels = expected.readPixels(0, 0, imageInfo) + const actualPixels = actual.readPixels(0, 0, imageInfo) + if (!expectedPixels || !actualPixels) throw new Error('Failed to read image pixels') + + let sum = 0 + let above5 = 0 + let above10 = 0 + let above20 = 0 + let above40 = 0 + let above80 = 0 + const total = width * height + const bg = matteColor + for (let pixel = 0; pixel < total; pixel++) { + const offset = pixel * 4 + const expectedRgb = compositeRgb(expectedPixels, offset, bg) + const actualRgb = compositeRgb(actualPixels, offset, bg) + const dr = Math.abs(expectedRgb[0] - actualRgb[0]) + const dg = Math.abs(expectedRgb[1] - actualRgb[1]) + const db = Math.abs(expectedRgb[2] - actualRgb[2]) + const max = Math.max(dr, dg, db) + sum += dr + dg + db + if (max > 5) above5++ + if (max > 10) above10++ + if (max > 20) above20++ + if (max > 40) above40++ + if (max > 80) above80++ + } + + return { + mean: sum / total, + above5: (above5 / total) * 100, + above10: (above10 / total) * 100, + above20: (above20 / total) * 100, + above40: (above40 / total) * 100, + above80: (above80 / total) * 100 + } + } finally { + expected.delete() + actual.delete() + } +} + +async function writeReport(items: BisectResult[]): Promise { + const lines = [ + `# Visual bisect report`, + ``, + `- File: ${figPath}`, + `- Page: ${pageName}`, + `- Figma page id: ${figmaPageId}`, + `- Scale: ${scale}`, + `- Threshold (>40): ${threshold}%`, + ``, + `| depth | indices | >40 | >20 | mean | names | images |`, + `|---:|---|---:|---:|---:|---|---|` + ] + + for (const item of items) { + lines.push( + `| ${item.depth} | ${item.indices.join(',')} | ${item.metrics.above40.toFixed(3)}% | ${item.metrics.above20.toFixed(3)}% | ${item.metrics.mean.toFixed(3)} | ${item.names.map(escapePipes).join('
')} | [figma](${item.figmaPath}) / [open-pencil](${item.openPencilPath}) |` + ) + } + + await Bun.write(`${outputDir}/report.md`, `${lines.join('\n')}\n`) +} + +function colorBytes(input: string): readonly [number, number, number] { + const color = parseColor(input) + return [color.r * 255, color.g * 255, color.b * 255] +} + +function compositeRgb( + pixels: Uint8Array, + offset: number, + bg: readonly [number, number, number] +): [number, number, number] { + const alpha = pixels[offset + 3] / 255 + if (alpha >= 1) return [pixels[offset], pixels[offset + 1], pixels[offset + 2]] + if (alpha <= 0) return [bg[0], bg[1], bg[2]] + return [ + pixels[offset] * alpha + bg[0] * (1 - alpha), + pixels[offset + 1] * alpha + bg[1] * (1 - alpha), + pixels[offset + 2] * alpha + bg[2] * (1 - alpha) + ] +} + +function escapePipes(value: string): string { + return value.replaceAll('|', '\\|') +}