fix(jassub): [PKG-JASSUB-10] release bitmap batches on rendering errors

This commit is contained in:
Harvey Zhao committed 2026-09-14 15:28:52 +08:00
1 parent 04060aa8b6
commit 7b0e1d156b
22 files changed
+5795 -22

No files matched your search

+7
View File
@@ -216,6 +216,13 @@ asserts it remains connected after destroy(false). The native report records the
artifact hash and both mode choices. This does not change the default published
baseline or establish ESM/offscreen/device acceptance.
`jassub-render-failure.spec.js` uses an actual Worker/WASM bitmap and native copies.
It intentionally closes one bitmap to provoke native Canvas InvalidStateError,
checks release of the entire batch, then pauses and waits for pending demand before
public resize(force) verifies a visible subtitle again. Select actual main/legacy
with ARTPLAYER_JASSUB_ARTIFACT. This main-thread fault injection does not reproduce
spontaneous Worker corruption or resolve the separate Firefox offscreen stall.
JASSUB Firefox diagnostics retain the canonical failing cases. Set
`ARTPLAYER_JASSUB_OFFSCREEN=default` to exercise native capability selection.
`ARTPLAYER_JASSUB_ASYNC_RENDER=false` explicitly selects synchronous rendering;
+108
View File
@@ -0,0 +1,108 @@
import fs from 'node:fs'
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { expect, test } from './fixtures.js'
const artifact = process.env.ARTPLAYER_JASSUB_ARTIFACT || 'packages/artplayer-plugin-jassub/dist/artplayer-plugin-jassub.js'
const code = fs.readFileSync(artifact, 'utf8')
const subtitle = `[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 360
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Liberation Sans,40,&H0000FF00,&H00000000,&H00000000,0,0,1,0,0,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.00,0:02:00.00,Default,,0,0,0,,Resource recovery subtitle
`
test('JASSUB closes the entire bitmap batch after a native draw failure and renders the next frame', async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=candidate')
await page.addScriptTag({ content: code })
await page.evaluate(() => window.createPlayer('/assets/sample/steve-jobs.mp4'))
await page.locator('#play').click()
try {
await page.evaluate((subtitle) => {
const register = window.artplayerPluginJassub.default || window.artplayerPluginJassub
window.art.plugins.add(register({
offscreenRender: false,
asyncRender: true,
workerUrl: '/assets/jassub/jassub-worker.js',
wasmUrl: '/assets/jassub/jassub-worker.wasm',
modernWasmUrl: '/assets/jassub/jassub-worker-modern.wasm',
availableFonts: { 'liberation sans': '/assets/jassub/default.woff2' },
fonts: ['/assets/jassub/default.woff2'],
subContent: subtitle,
}))
const { instance } = window.art.plugins.artplayerPluginJassub
const probe = window.bitmapFailureProbe = { instance, held: [], holding: true, errors: [] }
instance.addEventListener('error', event => probe.errors.push(String(event.error)))
probe.dispatch = instance._worker.onmessage
instance._worker.onmessage = (event) => {
if (probe.holding && event.data.target === 'render' && event.data.images.some(item => item.image instanceof ImageBitmap))
probe.held.push(event)
else
probe.dispatch(event)
}
}, subtitle)
await expect.poll(() => page.evaluate(() => window.bitmapFailureProbe.held.length)).toBeGreaterThan(0)
const evidence = await page.evaluate(async () => {
const probe = window.bitmapFailureProbe
window.art.video.pause()
const event = probe.held.shift()
const first = event.data.images.find(item => item.image instanceof ImageBitmap)
const copies = await Promise.all([createImageBitmap(first.image), createImageBitmap(first.image)])
for (const item of event.data.images) {
if (item !== first)
item.image?.close?.()
}
event.data.images = [first, ...copies.map(image => ({ image, x: 0, y: 0 }))]
const before = event.data.images.map(item => item.image.width)
first.image.close()
let failure
try {
probe.dispatch(event)
}
catch (error) { failure = { name: error.name, message: error.message } }
const after = event.data.images.map(item => item.image.width)
// Clean the failing historical candidate too, after recording its resource state.
for (const item of event.data.images)
item.image.close()
for (const held of probe.held.splice(0)) {
for (const item of held.data.images)
item.image?.close?.()
}
probe.holding = false
return { before, after, failure, asyncRender: event.data.asyncRender }
})
await testInfo.attach('bitmap-failure', { contentType: 'application/json', body: JSON.stringify({ artifact, sha256: hash(code), evidence, limitation: 'Actual JASSUB Worker/WASM bitmap, plus two native bitmap copies. Deliberately closing the first bitmap injects a native draw error; this does not claim spontaneous Worker corruption or Firefox offscreen recovery.' }) })
expect(evidence.before.every(width => width > 0)).toBe(true)
expect(evidence.asyncRender).toBe(true)
expect(evidence.failure?.name).toBe('InvalidStateError')
expect(evidence.after).toEqual([0, 0, 0])
// A busy renderer intentionally ignores resize(force); let its pending demand finish first.
await expect.poll(() => page.evaluate(() => window.bitmapFailureProbe.instance.busy)).toBe(false)
await page.evaluate(() => window.bitmapFailureProbe.instance.resize(0, 0, 0, 0, true))
await expect.poll(() => page.evaluate(() => {
const { instance } = window.bitmapFailureProbe
const pixels = instance._ctx.getImageData(0, 0, instance._canvas.width, instance._canvas.height).data
let green = 0
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i + 1] > 150 && pixels[i] < 100 && pixels[i + 2] < 100 && pixels[i + 3] > 100)
green++
}
return green
})).toBeGreaterThan(100)
expect(await page.evaluate(() => window.bitmapFailureProbe.errors)).toEqual([])
}
finally {
await page.evaluate(() => {
for (const event of window.bitmapFailureProbe?.held || []) {
for (const item of event.data.images)
item.image?.close?.()
}
window.art?.destroy(false)
})
}
})
+51
View File
@@ -144,3 +144,54 @@ for (const method of ['setTrack', 'setTrackByUrl']) {
assert.equal(env.workers[0].terminated, 1)
})
}
for (const phase of ['clear', 'first-image', 'second-image', 'resize']) {
test(`JASSUB releases every received bitmap when rendering fails during ${phase}`, async () => {
const env = environment(false)
const { instance } = env.factory({ offscreenRender: false })(env.art)
await env.ready()
const failure = new Error(`render ${phase}`)
const closed = [0, 0, 0]
const images = closed.map((_, index) => ({
image: { close() { closed[index]++ } },
x: index,
y: 0,
}))
let draws = 0
instance._ctx.drawImage = () => {
draws++
if (draws === (phase === 'first-image' ? 1 : phase === 'second-image' ? 2 : 0))
throw failure
}
if (phase === 'clear')
instance._ctx.clearRect = () => { throw failure }
if (phase === 'resize') {
Object.defineProperty(instance._canvasctrl, 'width', {
configurable: true,
get() { return 300 },
set() { throw failure },
})
}
try {
assert.throws(() => env.workers[0].onmessage({ data: { target: 'render', images, asyncRender: true, width: 640, height: 360 } }), error => error === failure)
assert.deepEqual(closed, [1, 1, 1])
assert.equal(draws, phase === 'first-image' ? 1 : phase === 'second-image' ? 2 : 0)
}
finally {
instance.destroy()
}
})
}
test('JASSUB synchronous pixel buffers are drawn without bitmap disposal', async () => {
const env = environment(false)
const { instance } = env.factory({ offscreenRender: false })(env.art)
await env.ready()
const image = new Uint8ClampedArray([0, 255, 0, 255]).buffer
image.close = () => assert.fail('Synchronous buffers are not owned ImageBitmaps')
const draws = []
instance._ctx.drawImage = (...args) => draws.push(args)
env.workers[0].onmessage({ data: { target: 'render', images: [{ image, x: 2, y: 3, w: 1, h: 1 }], asyncRender: false, width: 640, height: 360 } })
assert.deepEqual(draws, [[instance._bufferCanvas, 2, 3]])
instance.destroy()
})