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)
This commit is contained in:
parent
4e966c4976
commit
3c0c4e530c
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
226
scripts/visual-compare.ts
Normal file
226
scripts/visual-compare.ts
Normal file
|
|
@ -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<string>()
|
||||
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<string | null> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -129,7 +129,7 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue