fix(auto-thumbnail): [PKG-AUTO-THUMB-07] release terminal canvas storage

This commit is contained in:
Harvey Zhao committed 2026-09-14 03:48:00 +08:00
1 parent e93d3cbb67
commit 7be628a983
19 files changed
+4544 -15

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Repository canvas resource ownership regressions.
import test from 'node:test'
import { autoThumbnailCandidate, autoThumbnailEnvironment } from './helpers/auto-thumbnail.js'
const implementation = await autoThumbnailCandidate()
async function setup(number = 2) {
const env = autoThumbnailEnvironment(implementation)
await env.factory({ width: 80, number })(env.art)
env.art.emit('video:loadedmetadata')
return env
}
for (const event of ['destroy', 'restart']) {
test(`Auto-thumbnail clears a retained canvas when ${event} cancels an encoding`, async () => {
const env = await setup()
env.metadata()
env.seeked()
const canvas = env.canvases[0]
assert.equal(canvas.width * canvas.height, 36000)
env.art.emit(event)
assert.deepEqual([canvas.width, canvas.height], [0, 0])
env.finish()
assert.equal(env.updates.length, 0)
assert.equal(env.urls.size, 0)
assert.equal(env.attached.size, 0)
if (event === 'restart') {
env.art.emit('video:loadedmetadata')
env.metadata()
assert.deepEqual([env.canvases[1].width, env.canvases[1].height], [800, 45])
assert.deepEqual([canvas.width, canvas.height], [0, 0])
}
env.art.emit('destroy')
})
}
test('Auto-thumbnail clears completed canvas storage while retaining the encoded preview', async () => {
const env = await setup(1)
env.metadata()
env.seeked()
const blob = { type: 'image/jpeg', content: 'encoded image owns its bytes' }
env.finish(0, blob)
assert.deepEqual([env.canvases[0].width, env.canvases[0].height], [0, 0])
assert.equal(env.urls.get(env.art.thumbnails.url), blob)
assert.equal(env.attached.size, 0)
env.art.emit('destroy')
assert.equal(env.urls.size, 0)
})
test('Auto-thumbnail clears allocated dimensions if context creation fails', async () => {
const env = await setup()
env.controls.nullContext = true
env.metadata()
assert.deepEqual([env.canvases[0].width, env.canvases[0].height], [0, 0])
assert.equal(env.warnings.length, 1)
assert.equal(env.attached.size, 0)
env.art.emit('destroy')
})
test('Auto-thumbnail still clears height and decoder if the width reset throws', async () => {
const env = await setup()
env.metadata()
const canvas = env.canvases[0]
const failure = new Error('Width reset failed')
Object.defineProperty(canvas, 'width', {
get: () => 800,
set() {
throw failure
},
})
env.art.emit('destroy')
assert.equal(canvas.height, 0)
assert.equal(env.attached.size, 0)
assert.equal(env.videos[0].src, undefined)
assert.equal(env.timers.size, 0)
assert.equal(env.warnings.length, 1)
assert.equal(env.warnings[0][1], failure)
})
test('Auto-thumbnail does not allocate height after a width setter closes the job', async () => {
const env = await setup()
const createElement = env.box.document.createElement
env.box.document.createElement = function (name) {
const element = createElement.call(this, name)
if (name === 'canvas') {
let width = 0
Object.defineProperty(element, 'width', {
get: () => width,
set(value) {
width = value
if (value > 0)
env.art.emit('destroy')
},
})
}
return element
}
env.metadata()
assert.deepEqual([env.canvases[0].width, env.canvases[0].height], [0, 0])
assert.equal(env.attached.size, 0)
assert.equal(env.blobs.length, 0)
})
+2 -1
View File
@@ -269,6 +269,7 @@ test('Auto-thumbnail candidate retains coercible numeric strings and fractional
for (const option of [{ width: '80', number: '2', scale: '0.5' }, { width: 20.5, number: 2.5, scale: 1 }]) {
const env = await setup(option)
env.metadata()
assert.equal(env.canvases[0].width, Math.trunc(Number(option.width) * 10))
for (let index = 0; index < Math.ceil(Number(option.number)); index++) {
env.seeked()
env.finish(index)
@@ -277,7 +278,7 @@ test('Auto-thumbnail candidate retains coercible numeric strings and fractional
assert.equal(env.updates[0].width, option.width)
assert.equal(env.updates[0].number, option.number)
assert.equal(env.updates[0].scale, option.scale)
assert.equal(env.canvases[0].width, Math.trunc(Number(option.width) * 10))
assert.deepEqual([env.canvases[0].width, env.canvases[0].height], [0, 0])
assertClosed(env)
env.art.emit('destroy')
assert.equal(env.urls.size, 0)
+15 -3
View File
@@ -9,13 +9,15 @@ for (const scenario of ['destroy', 'restart', 'complete', 'frame-destroy', 'fram
await page.setContent('<!doctype html><div></div>')
await page.addScriptTag({ content: implementation.code })
await page.evaluate(async (scenario) => {
const probe = window.probe = { videos: [], updates: [], urls: new Set(), blobs: [], frames: [], frameSupport: false, listeners: new Map(), hold: scenario !== 'complete' }
const probe = window.probe = { videos: [], canvases: [], updates: [], urls: new Set(), blobs: [], frames: [], frameSupport: false, listeners: new Map(), hold: scenario !== 'complete' }
const createElement = document.createElement.bind(document)
const createUrl = URL.createObjectURL.bind(URL)
const revokeUrl = URL.revokeObjectURL.bind(URL)
const toBlob = HTMLCanvasElement.prototype.toBlob
document.createElement = function (name, ...args) {
const element = createElement(name, ...args)
if (name === 'canvas')
probe.canvases.push(element)
if (name === 'video') {
probe.videos.push(element)
const request = element.requestVideoFrameCallback?.bind(element)
@@ -74,7 +76,7 @@ for (const scenario of ['destroy', 'restart', 'complete', 'frame-destroy', 'fram
}, scenario)
const holdFrame = scenario.startsWith('frame-') && await page.evaluate(() => window.probe.frameSupport)
await expect.poll(() => page.evaluate(holdFrame => holdFrame ? window.probe.frames.length : window.probe.blobs.length, holdFrame)).toBe(scenario === 'complete' ? 2 : 1)
const state = await page.evaluate((scenario) => {
const state = await page.evaluate(async (scenario) => {
const probe = window.probe
const before = { urls: probe.urls.size, updates: probe.updates.length }
if (scenario !== 'complete') {
@@ -91,18 +93,28 @@ for (const scenario of ['destroy', 'restart', 'complete', 'frame-destroy', 'fram
const after = { urls: probe.urls.size, updates: probe.updates.length }
const decoder = probe.videos[0]
const video = { connected: decoder.isConnected, src: decoder.getAttribute('src'), readyState: decoder.readyState, paused: decoder.paused, handlers: ['onloadedmetadata', 'onloadeddata', 'onseeked', 'onerror'].map(key => decoder[key] === null) }
const canvasDimensions = probe.canvases.map(canvas => [canvas.width, canvas.height])
let publishedImage = null
if (scenario === 'complete') {
const image = new Image()
image.src = probe.updates.at(-1).url
await image.decode()
publishedImage = { width: image.naturalWidth, height: image.naturalHeight }
}
probe.art.emit('destroy')
probe.art.emit('destroy')
const final = { urls: probe.urls.size, listeners: [...probe.listeners.values()].reduce((sum, set) => sum + set.size, 0), updates: probe.updates.length }
const blobs = probe.blobs.map(({ blob }) => ({ type: blob.type, bytes: blob.size }))
probe.restore()
return { before, after, video, final, blobs, heldFrames: probe.frames.length, frameSupport: probe.frameSupport, result: probe.result }
return { before, after, video, canvasDimensions, publishedImage, final, blobs, heldFrames: probe.frames.length, frameSupport: probe.frameSupport, result: probe.result }
}, scenario)
await testInfo.attach('auto-thumbnail-candidate-native-lifecycle', { contentType: 'application/json', body: JSON.stringify({ scenario, sha256: hash(implementation.code), scope: 'Actual HTTP decoding/seek/JPEG and held native presentation callback lifecycle with a stub player host. Frame scenarios use a held Blob fallback only when native frame callbacks are absent. Pixel acceptance is separate; AUTO-THUMB-PIXEL-01 remains open.', ...state }) })
expect(state.result).toEqual({ name: 'artplayerPluginAutoThumbnail' })
expect(state.before).toEqual(scenario === 'complete' ? { urls: 1, updates: 2 } : { urls: 0, updates: 0 })
expect(state.after).toEqual(state.before)
expect(state.video).toEqual({ connected: false, src: null, readyState: 0, paused: true, handlers: [true, true, true, true] })
expect(state.canvasDimensions).toEqual([[0, 0]])
expect(state.publishedImage).toEqual(scenario === 'complete' ? { width: 800, height: 45 } : null)
expect(state.final).toEqual({ urls: 0, listeners: 0, updates: scenario === 'complete' ? 2 : 0 })
expect(state.blobs.every(blob => blob.type === 'image/jpeg' && blob.bytes > 0)).toBe(true)
expect(state.heldFrames).toBe(holdFrame ? 1 : 0)