Share indexed recency, count/weight budgets and native disposal across six caches while preserving domain invalidation and eviction policies. Keep pools, weak memos and dependency-owned resources separate.\n\nAccount for effect pixels incrementally and preserve caller ownership on rejection. Validate disposal, slot reuse, exact integer accounting and unchanged raster output.
219 lines
6.7 KiB
TypeScript
219 lines
6.7 KiB
TypeScript
import { beforeAll, describe, expect, test } from 'bun:test'
|
|
|
|
import { SceneGraph } from '@open-pencil/scene-graph'
|
|
|
|
import { initCanvasKit } from '#cli/headless'
|
|
import { SkiaRenderer } from '#core/canvas'
|
|
import {
|
|
EffectRasterCache,
|
|
type EffectRasterCacheEntry
|
|
} from '#core/canvas/renderer/effect-raster-cache'
|
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
|
|
let ck: Awaited<ReturnType<typeof initCanvasKit>>
|
|
|
|
beforeAll(async () => {
|
|
ck = await initCanvasKit()
|
|
})
|
|
|
|
function createEffectGraph(effectType: 'DROP_SHADOW' | 'BACKGROUND_BLUR') {
|
|
const graph = new SceneGraph()
|
|
const page = graph.getPages()[0]
|
|
if (!page) throw new Error('Expected default page')
|
|
const effect =
|
|
effectType === 'DROP_SHADOW'
|
|
? {
|
|
type: 'DROP_SHADOW' as const,
|
|
color: { r: 0.05, g: 0.1, b: 0.2, a: 0.7 },
|
|
offset: { x: 8, y: 10 },
|
|
radius: 12,
|
|
spread: 2,
|
|
visible: true
|
|
}
|
|
: {
|
|
type: 'BACKGROUND_BLUR' as const,
|
|
color: { r: 0, g: 0, b: 0, a: 0 },
|
|
offset: { x: 0, y: 0 },
|
|
radius: 12,
|
|
spread: 0,
|
|
visible: true
|
|
}
|
|
graph.createNode('RECTANGLE', page.id, {
|
|
x: 50,
|
|
y: 50,
|
|
width: 100,
|
|
height: 80,
|
|
fills: [
|
|
{
|
|
type: 'SOLID',
|
|
color: { r: 0.3, g: 0.6, b: 0.9, a: 1 },
|
|
opacity: 1,
|
|
visible: true
|
|
}
|
|
],
|
|
effects: [effect]
|
|
})
|
|
return { graph, pageId: page.id }
|
|
}
|
|
|
|
function renderPixels(renderer: SkiaRenderer, graph: SceneGraph): Uint8Array {
|
|
renderer.render(graph, new Set(), {}, 1, 'scene')
|
|
const image = renderer.surface.makeImageSnapshot()
|
|
const pixels = image.readPixels(0, 0, {
|
|
width: 256,
|
|
height: 192,
|
|
colorType: ck.ColorType.RGBA_8888,
|
|
alphaType: ck.AlphaType.Unpremul,
|
|
colorSpace: ck.ColorSpace.SRGB
|
|
})
|
|
image.delete()
|
|
return expectDefined(pixels, 'retained backing pixels')
|
|
}
|
|
|
|
function differingChannels(a: Uint8Array, b: Uint8Array, tolerance: number): number {
|
|
let different = 0
|
|
for (let index = 0; index < a.length; index++) {
|
|
if (Math.abs(a[index] - b[index]) > tolerance) different++
|
|
}
|
|
return different
|
|
}
|
|
|
|
describe('retained effect raster integration', () => {
|
|
test('rejected rasters remain alive through drawing and are then disposed', () => {
|
|
class RejectingCache extends EffectRasterCache {
|
|
last: EffectRasterCacheEntry | null = null
|
|
override set(key: string, entry: EffectRasterCacheEntry): boolean {
|
|
this.last = entry
|
|
return super.set(key, entry)
|
|
}
|
|
}
|
|
const { graph, pageId } = createEffectGraph('DROP_SHADOW')
|
|
const retained = new SkiaRenderer(
|
|
ck,
|
|
expectDefined(ck.MakeSurface(256, 192), 'retained surface')
|
|
)
|
|
const transient = new SkiaRenderer(
|
|
ck,
|
|
expectDefined(ck.MakeSurface(256, 192), 'transient surface')
|
|
)
|
|
const rejected = new RejectingCache(0)
|
|
transient.effectRasterCache = rejected
|
|
for (const renderer of [retained, transient]) {
|
|
renderer.viewportWidth = 256
|
|
renderer.viewportHeight = 192
|
|
renderer.pageId = pageId
|
|
renderer.pageColor = { r: 1, g: 1, b: 1, a: 1 }
|
|
renderer.zoom = 1
|
|
renderer.dpr = 1
|
|
}
|
|
try {
|
|
const pixels = renderPixels(transient, graph)
|
|
const center = (90 * 256 + 90) * 4
|
|
expect(pixels[center]).toBeLessThan(200)
|
|
expect(pixels[center + 3]).toBe(255)
|
|
expect(pixels).toEqual(renderPixels(retained, graph))
|
|
expect(rejected.size).toBe(0)
|
|
expect(rejected.last?.image.isDeleted()).toBe(true)
|
|
expect(retained.effectRasterCache.size).toBe(1)
|
|
} finally {
|
|
transient.destroy()
|
|
retained.destroy()
|
|
}
|
|
})
|
|
|
|
test('settled drop-shadow backing closely matches direct rendering', () => {
|
|
const { graph, pageId } = createEffectGraph('DROP_SHADOW')
|
|
const directSurface = expectDefined(ck.MakeSurface(256, 192), 'direct surface')
|
|
const backingSurface = expectDefined(ck.MakeSurface(256, 192), 'backing surface')
|
|
const direct = new SkiaRenderer(ck, directSurface)
|
|
const retained = new SkiaRenderer(ck, backingSurface)
|
|
for (const renderer of [direct, retained]) {
|
|
renderer.viewportWidth = 256
|
|
renderer.viewportHeight = 192
|
|
renderer.pageId = pageId
|
|
renderer.pageColor = { r: 1, g: 1, b: 1, a: 1 }
|
|
renderer.dpr = 1
|
|
renderer.zoom = 1
|
|
}
|
|
|
|
try {
|
|
direct.renderingSceneBacking = false
|
|
direct.surface.getCanvas().clear(ck.WHITE)
|
|
direct.renderSceneToCanvas(direct.surface.getCanvas(), graph, pageId)
|
|
direct.surface.flush()
|
|
const directImage = direct.surface.makeImageSnapshot()
|
|
const directPixels = expectDefined(
|
|
directImage.readPixels(0, 0, {
|
|
width: 256,
|
|
height: 192,
|
|
colorType: ck.ColorType.RGBA_8888,
|
|
alphaType: ck.AlphaType.Unpremul,
|
|
colorSpace: ck.ColorSpace.SRGB
|
|
}),
|
|
'direct pixels'
|
|
)
|
|
directImage.delete()
|
|
|
|
const retainedPixels = renderPixels(retained, graph)
|
|
expect(retained.effectRasterCache.size).toBe(1)
|
|
expect(differingChannels(directPixels, retainedPixels, 12)).toBeLessThan(
|
|
directPixels.length * 0.02
|
|
)
|
|
} finally {
|
|
direct.destroy()
|
|
retained.destroy()
|
|
}
|
|
})
|
|
|
|
test('nested shadow subtrees retain the single-picture backing path', () => {
|
|
const graph = new SceneGraph()
|
|
const page = graph.getPages()[0]
|
|
if (!page) throw new Error('Expected default page')
|
|
const frame = graph.createNode('FRAME', page.id, { width: 200, height: 160 })
|
|
graph.createNode('RECTANGLE', frame.id, {
|
|
x: 20,
|
|
y: 20,
|
|
width: 100,
|
|
height: 80,
|
|
effects: [
|
|
{
|
|
type: 'DROP_SHADOW',
|
|
color: { r: 0, g: 0, b: 0, a: 0.5 },
|
|
offset: { x: 4, y: 6 },
|
|
radius: 8,
|
|
spread: 0,
|
|
visible: true
|
|
}
|
|
]
|
|
})
|
|
const surface = expectDefined(ck.MakeSurface(256, 192), 'nested shadow surface')
|
|
const renderer = new SkiaRenderer(ck, surface)
|
|
renderer.viewportWidth = 256
|
|
renderer.viewportHeight = 192
|
|
renderer.pageId = page.id
|
|
try {
|
|
renderPixels(renderer, graph)
|
|
expect(renderer.effectRasterCache.size).toBe(0)
|
|
expect(renderer.subtreePictureCache.has(frame.id)).toBe(true)
|
|
} finally {
|
|
renderer.destroy()
|
|
}
|
|
})
|
|
|
|
test('backdrop-dependent blur remains on the picture fallback', () => {
|
|
const { graph, pageId } = createEffectGraph('BACKGROUND_BLUR')
|
|
const surface = expectDefined(ck.MakeSurface(256, 192), 'blur surface')
|
|
const renderer = new SkiaRenderer(ck, surface)
|
|
renderer.viewportWidth = 256
|
|
renderer.viewportHeight = 192
|
|
renderer.pageId = pageId
|
|
try {
|
|
renderPixels(renderer, graph)
|
|
expect(renderer.effectRasterCache.size).toBe(0)
|
|
} finally {
|
|
renderer.destroy()
|
|
}
|
|
})
|
|
})
|