* refactor(canvas): draw screen-sized chrome through one outline helper Overlays each set a zoom-divided stroke width, built a zoom-divided dash, and reset the shared paint and freed the dash by hand. withScreenStroke and inNodeSpace do that once, and slot outlines, component set borders, code focus, the entered container, path-text selection, issue highlights, and the text-edit frame use them. The drop-target highlight and the text-edit caret and selection were drawn inside the scene, which is cached and scaled while navigating; they move to the overlay pass. open-pencil/no-zoom-in-scene-drawing rejects reading the zoom in scene drawing modules, apart from choosing effect raster quality, and the Core guide states the rule. * refactor(canvas): outline a layer's bounds through one helper The drop-target highlight and the entered container drew the same screen-sized rectangle around a layer; outlineNode draws it for both. * fix(lint): stop the effect raster exemption at function boundaries A zoom read inside a callback passed to effectRasterScale is not the raster scale argument. * perf(canvas): keep the cached scene while drop targeting and editing text Both are drawn only in the overlay pass now, so they no longer need an uncached scene render each frame.
238 lines
7.6 KiB
TypeScript
238 lines
7.6 KiB
TypeScript
import { beforeAll, describe, expect, setDefaultTimeout, test } from 'bun:test'
|
|
import { readFileSync } from 'node:fs'
|
|
|
|
import {
|
|
computeAllLayouts,
|
|
initCodec,
|
|
parseFigFile,
|
|
renderNodesToImage,
|
|
SkiaRenderer,
|
|
type SceneGraph,
|
|
type SceneNode
|
|
} from '@open-pencil/core'
|
|
|
|
import { initCanvasKit } from '#cli/headless'
|
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
import { repoPath } from '#tests/helpers/paths'
|
|
import { HEAVY_TEST_TIMEOUT_MS } from '#tests/helpers/test-utils'
|
|
|
|
import { expectRgbaPixels } from './helpers'
|
|
|
|
setDefaultTimeout(HEAVY_TEST_TIMEOUT_MS)
|
|
|
|
let graph: SceneGraph
|
|
let movingNodeId: string
|
|
let ck: Awaited<ReturnType<typeof initCanvasKit>>
|
|
|
|
beforeAll(async () => {
|
|
ck = await initCanvasKit()
|
|
await initCodec()
|
|
const buf = readFileSync(repoPath('tests/fixtures/gold-preview.fig'))
|
|
graph = await parseFigFile(buf.buffer as ArrayBuffer)
|
|
computeAllLayouts(graph)
|
|
const preview = [...graph.getAllNodes()].find((node) => node.name === 'Preview Thumbnail')
|
|
const input = preview
|
|
? graph.getChildren(preview.id).find((node) => node.name === 'Input')
|
|
: undefined
|
|
if (!input) throw new Error('gold-preview Input fixture node not found')
|
|
movingNodeId = input.id
|
|
}, 60_000)
|
|
|
|
function renderPreview(renderer: SkiaRenderer, sceneVersion: number): Uint8Array {
|
|
renderer.render(graph, new Set(), {}, sceneVersion)
|
|
const image = renderer.surface.makeImageSnapshot()
|
|
const pixels = image.readPixels(0, 0, {
|
|
width: 900,
|
|
height: 700,
|
|
colorType: ck.ColorType.RGBA_8888,
|
|
alphaType: ck.AlphaType.Unpremul,
|
|
colorSpace: ck.ColorSpace.SRGB
|
|
})
|
|
image.delete()
|
|
return expectRgbaPixels(pixels, 'rendered pixels')
|
|
}
|
|
|
|
function childNamed(parent: SceneNode | undefined, name: string): SceneNode | undefined {
|
|
return parent ? graph.getChildren(parent.id).find((node) => node.name === name) : undefined
|
|
}
|
|
|
|
function fixtureInputBadge(): SceneNode {
|
|
const preview = [...graph.getAllNodes()].find((node) => node.name === 'Preview Thumbnail')
|
|
const input = childNamed(preview, 'Input')
|
|
const inputRoot = childNamed(input, '_input')
|
|
const inputFrame = childNamed(inputRoot, 'Input')
|
|
const content = childNamed(inputFrame, 'Content')
|
|
const tags = childNamed(content, 'Tags')
|
|
const badge = childNamed(tags, 'Badge')
|
|
if (!badge) throw new Error('gold-preview badge fixture node not found')
|
|
return badge
|
|
}
|
|
|
|
function countDarkPixels(pixels: Uint8Array): number {
|
|
let dark = 0
|
|
for (let i = 0; i < pixels.length; i += 4) {
|
|
if (pixels[i + 3] > 200 && pixels[i] < 80 && pixels[i + 1] < 80 && pixels[i + 2] < 80) {
|
|
dark++
|
|
}
|
|
}
|
|
return dark
|
|
}
|
|
|
|
function pixelIndex(width: number, x: number, y: number): number {
|
|
return (y * width + x) * 4
|
|
}
|
|
|
|
function maskYCenter(
|
|
_pixels: Uint8Array,
|
|
width: number,
|
|
height: number,
|
|
matches: (index: number) => boolean,
|
|
xRange = [0, width]
|
|
): number {
|
|
let minY = Infinity
|
|
let maxY = -Infinity
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = xRange[0]; x < xRange[1]; x++) {
|
|
if (!matches(pixelIndex(width, x, y))) continue
|
|
minY = Math.min(minY, y)
|
|
maxY = Math.max(maxY, y)
|
|
}
|
|
}
|
|
if (minY === Infinity) throw new Error('pixel mask had no matches')
|
|
return (minY + maxY) / 2
|
|
}
|
|
|
|
describe('render cache regressions', () => {
|
|
test('badge label is vertically centered in the pill', async () => {
|
|
const surface = expectDefined(ck.MakeSurface(120, 60), 'badge surface')
|
|
const renderer = new SkiaRenderer(ck, surface)
|
|
await renderer.loadFonts()
|
|
try {
|
|
const badge = fixtureInputBadge()
|
|
const png = renderNodesToImage(ck, renderer, graph, graph.getPages()[0].id, [badge.id], {
|
|
scale: 1,
|
|
format: 'PNG'
|
|
})
|
|
expect(png).toBeTruthy()
|
|
const image = expectDefined(
|
|
ck.MakeImageFromEncoded(expectDefined(png, 'badge png')),
|
|
'badge image'
|
|
)
|
|
const width = image.width()
|
|
const height = image.height()
|
|
const pixels = image.readPixels(0, 0, {
|
|
width,
|
|
height,
|
|
colorType: ck.ColorType.RGBA_8888,
|
|
alphaType: ck.AlphaType.Unpremul,
|
|
colorSpace: ck.ColorSpace.SRGB
|
|
})
|
|
image.delete()
|
|
|
|
const renderedPixels = expectRgbaPixels(pixels, 'badge pixels')
|
|
const contentCenter = maskYCenter(
|
|
renderedPixels,
|
|
width,
|
|
height,
|
|
(i) => renderedPixels[i + 3] > 10
|
|
)
|
|
const textCenter = maskYCenter(
|
|
renderedPixels,
|
|
width,
|
|
height,
|
|
(i) =>
|
|
renderedPixels[i + 3] > 128 &&
|
|
renderedPixels[i] < 130 &&
|
|
renderedPixels[i + 1] < 140 &&
|
|
renderedPixels[i + 2] < 160,
|
|
[0, width]
|
|
)
|
|
expect(Math.abs(textCenter - contentCenter)).toBeLessThanOrEqual(0.6)
|
|
} finally {
|
|
surface.delete()
|
|
}
|
|
})
|
|
|
|
test('scene picture redraw keeps text after moving a node', async () => {
|
|
const surface = expectDefined(ck.MakeSurface(900, 700), 'preview surface')
|
|
const renderer = new SkiaRenderer(ck, surface)
|
|
renderer.viewportWidth = 900
|
|
renderer.viewportHeight = 700
|
|
renderer.dpr = 1
|
|
await renderer.loadFonts()
|
|
renderer.panX = 0
|
|
renderer.panY = 0
|
|
renderer.zoom = 0.75
|
|
renderer.pageId = graph.getPages()[0].id
|
|
|
|
try {
|
|
const beforeDark = countDarkPixels(renderPreview(renderer, 1))
|
|
const movingNode = graph.getNode(movingNodeId)
|
|
expect(movingNode).toBeDefined()
|
|
const originalX = movingNode?.x ?? 0
|
|
graph.updateNode(movingNodeId, { x: originalX + 20 })
|
|
expect(graph.getNode(movingNodeId)?.x).toBeCloseTo(originalX + 20, 3)
|
|
const afterDark = countDarkPixels(renderPreview(renderer, 2))
|
|
|
|
expect(afterDark).toBeGreaterThan(beforeDark * 0.8)
|
|
} finally {
|
|
surface.delete()
|
|
}
|
|
})
|
|
|
|
test('scene picture cache recovers after position preview commits', async () => {
|
|
const surface = expectDefined(ck.MakeSurface(900, 700), 'preview surface')
|
|
const renderer = new SkiaRenderer(ck, surface)
|
|
renderer.viewportWidth = 900
|
|
renderer.viewportHeight = 700
|
|
renderer.dpr = 1
|
|
renderer.panX = 0
|
|
renderer.panY = 0
|
|
renderer.zoom = 0.75
|
|
renderer.pageId = graph.getPages()[0].id
|
|
|
|
try {
|
|
renderPreview(renderer, 10)
|
|
expect(renderer.profiler.stats.scenePictureMode).toBe('record')
|
|
|
|
const movingNode = expectDefined(graph.getNode(movingNodeId), 'moving node')
|
|
const originalX = movingNode.x
|
|
graph.updateNodePositionPreview(movingNodeId, originalX + 20, movingNode.y)
|
|
renderPreview(renderer, 10)
|
|
expect(renderer.profiler.stats.scenePictureMode).toBe('volatile')
|
|
expect(renderer.profiler.stats.scenePictureMissReason).toBe('position-preview')
|
|
|
|
graph.updateNode(movingNodeId, { x: originalX + 20 })
|
|
renderPreview(renderer, 11)
|
|
expect(renderer.profiler.stats.scenePictureMode).toBe('record')
|
|
expect(renderer.profiler.stats.scenePictureMissReason).toBe('position-preview-version')
|
|
|
|
renderPreview(renderer, 11)
|
|
expect(renderer.profiler.stats.scenePictureMode).toBe('hit')
|
|
} finally {
|
|
surface.delete()
|
|
}
|
|
})
|
|
|
|
test('drop targeting and text editing reuse the cached scene', () => {
|
|
const surface = expectDefined(ck.MakeSurface(900, 700), 'preview surface')
|
|
const renderer = new SkiaRenderer(ck, surface)
|
|
renderer.viewportWidth = 900
|
|
renderer.viewportHeight = 700
|
|
renderer.dpr = 1
|
|
renderer.zoom = 0.75
|
|
renderer.pageId = graph.getPages()[0].id
|
|
|
|
try {
|
|
renderPreview(renderer, 20)
|
|
for (const overlays of [{ dropTargetId: movingNodeId }, { editingTextId: movingNodeId }]) {
|
|
renderer.render(graph, new Set(), overlays, 20)
|
|
expect(renderer.profiler.stats.scenePictureMode).toBe('hit')
|
|
}
|
|
} finally {
|
|
surface.delete()
|
|
}
|
|
})
|
|
})
|