From 3c0c4e530cd140f752d615b9c3bf7c6e77dc86f9 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 4 Mar 2026 10:35:44 +0300 Subject: [PATCH] Guard against double destroy and post-unmount rendering - Add destroyed flag to SkiaRenderer.destroy() to prevent double-free - Check destroyed flag in renderNow() to skip rendering after unmount (loadFonts promise could resolve after component unmounts) --- package.json | 3 +- packages/core/src/renderer.ts | 5 + scripts/visual-compare.ts | 226 ++++++++++++++++++++++++++++++++++ src/composables/use-canvas.ts | 2 +- 4 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 scripts/visual-compare.ts diff --git a/package.json b/package.json index cb021a6da..82b50b85d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "open-pencil": "bun packages/cli/src/index.ts", "docs:dev": "bun --filter @open-pencil/docs dev", "docs:build": "bun --filter @open-pencil/docs build", - "docs:preview": "bun --filter @open-pencil/docs preview" + "docs:preview": "bun --filter @open-pencil/docs preview", + "visual-compare": "bun scripts/visual-compare.ts" }, "dependencies": { "@ai-sdk/openai": "^3.0.37", diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 017c4d72f..6c307d06e 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -2902,7 +2902,12 @@ export class SkiaRenderer { return Math.round(value).toString() } + private destroyed = false + destroy(): void { + if (this.destroyed) return + this.destroyed = true + for (const img of this.imageCache.values()) img.delete() this.imageCache.clear() for (const p of this.vectorPathCache.values()) p.delete() diff --git a/scripts/visual-compare.ts b/scripts/visual-compare.ts new file mode 100644 index 000000000..0d22bc55f --- /dev/null +++ b/scripts/visual-compare.ts @@ -0,0 +1,226 @@ +#!/usr/bin/env bun +/** + * Visual comparison pipeline: Figma vs OpenPencil renderer. + * + * Copy an element in Figma, then run: + * bun scripts/visual-compare.ts [--scale 2] [--output /tmp/visual-compare] + * + * Or pass a node ID directly (skips clipboard): + * bun scripts/visual-compare.ts --node 1:23 [--scale 2] + * + * Outputs: + * figma.png — exported from real Figma + * ours.png — rendered by OpenPencil headless SkiaRenderer + * diff.png — visual diff (red = changed pixels) + */ + +import { parseArgs } from 'node:util' +import { existsSync, mkdirSync } from 'node:fs' +import { $ } from 'bun' + +import { + parseFigmaClipboard, + importClipboardNodes, + computeAllLayouts, + SceneGraph, + loadFont, + SkiaRenderer, + renderNodesToImage +} from '@open-pencil/core' +import { initCanvasKit } from '../packages/cli/src/headless' + +const { values: opts } = parseArgs({ + options: { + scale: { type: 'string', default: '2' }, + output: { type: 'string', short: 'o', default: '/tmp/visual-compare' }, + node: { type: 'string', short: 'n' } + } +}) + +const scale = Number(opts.scale) +const outputDir = opts.output! +const figmaPath = `${outputDir}/figma.png` +const oursPath = `${outputDir}/ours.png` +const diffPath = `${outputDir}/diff.png` + +if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }) + +if (opts.node) { + await runWithNodeId(opts.node) +} else { + await runWithClipboard() +} + +// --- Mode 1: Clipboard --- + +async function runWithClipboard() { + console.log('📋 Reading clipboard…') + const html = await readClipboardHtml() + if (!html) bail('No HTML on clipboard. Copy an element in Figma first.') + + const parsed = await parseFigmaClipboard(html) + if (!parsed) bail('Clipboard has no Figma data. Copy an element in Figma first.') + console.log(` ${parsed.nodes.length} node changes, ${parsed.blobs.length} blobs`) + + console.log('🖼️ Rendering with OpenPencil…') + await renderOurs(html) + + console.log('🎨 Pasting into Figma & exporting…') + await ensureFigmaConnected() + await renderFigmaViaPaste() + + await diff() +} + +// --- Mode 2: Node ID --- + +async function runWithNodeId(nodeId: string) { + await ensureFigmaConnected() + + console.log(`🎨 Exporting node ${nodeId} from Figma…`) + await $`figma-use export node ${nodeId} --output ${figmaPath} --scale ${String(scale)}`.quiet() + console.log(` → ${figmaPath}`) + + console.log('📋 Exporting clipboard data from Figma…') + // Select the node, copy, read clipboard, render with our engine + await $`figma-use eval ${`(() => { const n = figma.getNodeById('${nodeId}'); if (n) { figma.currentPage.selection = [n]; } })()`}`.quiet() + await Bun.sleep(200) + await $`osascript -e 'tell application "Figma" to activate'`.quiet() + await Bun.sleep(300) + await $`osascript -e 'tell application "System Events" to keystroke "c" using command down'`.quiet() + await Bun.sleep(1000) + + const html = await readClipboardHtml() + if (!html) bail('Failed to copy from Figma') + const parsed = await parseFigmaClipboard(html) + if (!parsed) bail('Clipboard has no Figma data after copy') + + console.log('🖼️ Rendering with OpenPencil…') + await renderOurs(html) + + await diff() +} + +// --- Rendering --- + +async function renderOurs(html: string) { + const result = await parseFigmaClipboard(html) + if (!result) throw new Error('Failed to parse clipboard') + + const graph = new SceneGraph() + const pageId = graph.getPages()[0].id + + const createdIds = importClipboardNodes(result.nodes, graph, pageId, 0, 0, result.blobs) + if (createdIds.length === 0) throw new Error('No nodes imported from clipboard') + + computeAllLayouts(graph) + + const families = new Set() + for (const node of graph.getAllNodes()) { + if (node.fontFamily) families.add(node.fontFamily) + } + for (const family of families) { + await loadFont(family) + } + + const ck = await initCanvasKit() + const surface = ck.MakeSurface(1, 1)! + const renderer = new SkiaRenderer(ck, surface) + renderer.viewportWidth = 1 + renderer.viewportHeight = 1 + renderer.dpr = 1 + + const data = renderNodesToImage(ck, renderer, graph, pageId, createdIds, { + scale, + format: 'PNG' + }) + + surface.delete() + if (!data) throw new Error('Render produced no image') + await Bun.write(oursPath, data) + console.log(` → ${oursPath}`) +} + +async function renderFigmaViaPaste() { + // Create temp page so we don't pollute the user's work + const createPage = + await $`figma-use eval ${'(() => { const p = figma.createPage(); p.name = "__visual_compare__"; figma.currentPage = p; return p.id; })()'} --json`.quiet() + const tmpPageId = JSON.parse(createPage.text().trim()) + + try { + // Activate Figma and paste + await $`osascript -e 'tell application "Figma" to activate'`.quiet() + await Bun.sleep(500) + await $`osascript -e 'tell application "System Events" to keystroke "v" using command down'`.quiet() + await Bun.sleep(2000) + + // Get pasted selection + const selJson = await $`figma-use selection get --json`.quiet() + const selection = JSON.parse(selJson.text().trim()) + if (!selection.length) throw new Error('Nothing pasted. Ensure clipboard has Figma data.') + + const nodeId = selection[0].id + + // Export from Figma + await $`figma-use export node ${nodeId} --output ${figmaPath} --scale ${String(scale)}`.quiet() + console.log(` → ${figmaPath}`) + } finally { + // Clean up: remove temp page + await $`figma-use eval ${'(() => { const ps = figma.root.children; const tmp = ps.find(p => p.name === "__visual_compare__"); if (tmp) { const other = ps.find(p => p !== tmp); if (other) figma.currentPage = other; tmp.remove(); } })()'}`.quiet().nothrow() + } +} + +// --- Diff --- + +async function diff() { + console.log('🔍 Computing diff…') + + const figmaSize = (await $`identify -format '%wx%h' ${figmaPath}`.quiet()).text().trim() + const oursSize = (await $`identify -format '%wx%h' ${oursPath}`.quiet()).text().trim() + + if (figmaSize !== oursSize) { + console.log(` ⚠ Size mismatch: Figma ${figmaSize}, Ours ${oursSize} → resizing`) + await $`magick ${oursPath} -resize ${figmaSize}! ${oursPath}`.quiet() + } + + const result = + await $`magick compare -metric AE -highlight-color red -lowlight-color 'rgba(255,255,255,0.2)' -compose src ${figmaPath} ${oursPath} ${diffPath}` + .quiet() + .nothrow() + + const diffPixels = parseInt(result.stderr.toString().trim()) || 0 + const [w, h] = figmaSize.split('x').map(Number) + const total = w * h + const pct = ((diffPixels / total) * 100).toFixed(2) + + console.log(` → ${diffPath}`) + console.log(` ${diffPixels.toLocaleString()} different pixels (${pct}% of ${total.toLocaleString()})`) + console.log(`\n✅ Done! Images in ${outputDir}/`) +} + +// --- Helpers --- + +async function readClipboardHtml(): Promise { + const proc = Bun.spawn( + [ + 'swift', + '-e', + 'import AppKit; if let h = NSPasteboard.general.string(forType: .html) { print(h) } else { exit(1) }' + ], + { stdout: 'pipe', stderr: 'pipe' } + ) + const text = await new Response(proc.stdout).text() + return (await proc.exited) === 0 ? text.trim() : null +} + +async function ensureFigmaConnected() { + const s = await $`figma-use status`.quiet().nothrow() + if (s.exitCode !== 0) { + bail('figma-use not connected. Start Figma with:\n open -a Figma --args --remote-debugging-port=9222') + } +} + +function bail(msg: string): never { + console.error(msg) + process.exit(1) +} diff --git a/src/composables/use-canvas.ts b/src/composables/use-canvas.ts index 4f0f12139..56f1a68eb 100644 --- a/src/composables/use-canvas.ts +++ b/src/composables/use-canvas.ts @@ -129,7 +129,7 @@ export function useCanvas(canvasRef: Ref, store: Edito const showRulers = !params.has('no-rulers') function renderNow() { - if (!renderer) return + if (!renderer || destroyed) return renderer.dpr = window.devicePixelRatio || 1 renderer.panX = store.state.panX renderer.panY = store.state.panY