test(jassub): [PKG-JASSUB-09] isolate Firefox bitmap readback stalls

This commit is contained in:
Harvey Zhao committed 2026-09-14 03:13:55 +08:00
1 parent 7f185f698d
commit 7ef605fba7
9 files changed
+5843 -13

No files matched your search

+20
View File
@@ -185,3 +185,23 @@ cleanup. ARTPLAYER_JASSUB_CUSTOM_CANVAS=true supplies a caller-owned canvas and
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 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;
`ARTPLAYER_JASSUB_READBACK_FRAME=true` waits one animation frame before readback.
Neither diagnostic changes the default candidate or constitutes a runtime fix.
`playwright.jassub-diagnostic.config.js` requests Firefox software WebRender for
comparison only; it is not the canonical browser acceptance configuration.
`jassub-platform.spec.js` isolates native video and a minimal canvas Worker under
candidate/published cores and a script-free native host. All hosts resize to the
viewport. Default drawing uses fillRect; `ARTPLAYER_JASSUB_CONTROL_BITMAP=true`
selects actual createImageBitmap/drawImage/close and records worker stage times.
`ARTPLAYER_JASSUB_CONTROL_IDLE_READBACK=true` pauses submissions while waiting for
all draw acknowledgements before copying the display canvas. This is a deliberate
scheduling diagnostic, not unchanged production timing. Hosts without canvas
transfer use main-thread fillRect and do not verify the Worker/ImageBitmap path.
The report records these distinctions and never infers a JASSUB fix from a control
pass. Preserve each raw report/results directory before the next browser command.
See [the isolation record](../../refactor/changes/2026-09-14-PKG-JASSUB-09-firefox-diagnostics.md).
+23 -11
View File
@@ -12,6 +12,15 @@ const customCanvas = process.env.ARTPLAYER_JASSUB_CUSTOM_CANVAS === 'true'
const code = artifact ? fs.readFileSync(artifact, 'utf8') : readMember(archive, member).toString()
const onDemandRender = process.env.ARTPLAYER_JASSUB_ON_DEMAND !== 'false'
const defaultOffscreen = process.env.ARTPLAYER_JASSUB_OFFSCREEN === 'default'
const readbackFrame = process.env.ARTPLAYER_JASSUB_READBACK_FRAME === 'true'
const synchronousRender = process.env.ARTPLAYER_JASSUB_ASYNC_RENDER === 'false'
async function pixels(page, phase) {
return page.evaluate(async ({ phase, readbackFrame }) => {
if (readbackFrame)
await new Promise(requestAnimationFrame)
return window.jassubPixels(phase)
}, { phase, readbackFrame })
}
const subtitles = `[Script Info]
ScriptType: v4.00+
PlayResX: 640
@@ -71,7 +80,7 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
}
postMessage(message, ...rest) {
this.observation.sent.push({ target: message.target, time: message.time, width: message.width, height: message.height })
this.observation.sent.push({ target: message.target, time: message.time, width: message.width, height: message.height, asyncRender: message.asyncRender })
return super.postMessage(message, ...rest)
}
@@ -87,7 +96,7 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.locator('#pause').click()
await page.evaluate(async ({ subContent, onDemandRender, customCanvas, defaultOffscreen }) => {
await page.evaluate(async ({ subContent, onDemandRender, customCanvas, defaultOffscreen, synchronousRender }) => {
const register = window.artplayerPluginJassub.default || window.artplayerPluginJassub
let canvas
if (customCanvas) {
@@ -106,6 +115,7 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
availableFonts: { 'liberation sans': '/assets/jassub/default.woff2' },
fonts: ['/assets/jassub/default.woff2'],
...(defaultOffscreen ? {} : { offscreenRender: false }),
...(synchronousRender ? { asyncRender: false } : {}),
onDemandRender,
...(canvas ? { canvas } : {}),
}))
@@ -147,12 +157,12 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
window.jassubNative.snapshots.push(state)
return state
}
}, { subContent: subtitles, onDemandRender, customCanvas, defaultOffscreen })
}, { subContent: subtitles, onDemandRender, customCanvas, defaultOffscreen, synchronousRender })
const offscreen = await page.evaluate(() => window.jassubNative.offscreen)
expect(offscreen.selected).toBe(defaultOffscreen && !customCanvas && offscreen.available)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.jassubPixels().visible)).toBeGreaterThan(100)
const before = await page.evaluate(() => window.jassubPixels('before-seek'))
await expect.poll(async () => (await pixels(page)).visible).toBeGreaterThan(100)
const before = await pixels(page, 'before-seek')
expect(before.connected).toBe(true)
await page.locator('#pause').click()
await page.evaluate(async () => {
@@ -161,17 +171,17 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
await seeked
})
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.jassubPixels().signature)).not.toBe(before.signature)
await expect.poll(() => page.evaluate(() => window.jassubPixels().visible)).toBeGreaterThan(100)
const after = await page.evaluate(() => window.jassubPixels('after-seek'))
await expect.poll(async () => (await pixels(page)).signature).not.toBe(before.signature)
await expect.poll(async () => (await pixels(page)).visible).toBeGreaterThan(100)
const after = await pixels(page, 'after-seek')
expect(after.time).toBeGreaterThanOrEqual(10)
expect(after.signature).not.toBe(before.signature)
await page.evaluate(() => {
window.art.fullscreenWeb = true
})
await expect.poll(() => page.evaluate(() => window.jassubPixels().cssWidth)).toBeGreaterThan(before.cssWidth)
await expect.poll(() => page.evaluate(() => window.jassubPixels().visible)).toBeGreaterThan(100)
await page.evaluate(() => window.jassubPixels('fullscreen'))
await expect.poll(async () => (await pixels(page)).cssWidth).toBeGreaterThan(before.cssWidth)
await expect.poll(async () => (await pixels(page)).visible).toBeGreaterThan(100)
await pixels(page, 'fullscreen')
await testInfo.attach('actual-jassub-page', { body: await page.screenshot(), contentType: 'image/png' })
if (artifact)
await page.evaluate(() => window.jassub.destroy())
@@ -187,6 +197,8 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
}
finally {
const state = await page.evaluate(() => window.jassubNative).catch(error => ({ unavailable: error.message }))
state.readbackFrame = readbackFrame
state.synchronousRender = synchronousRender
await testInfo.attach('actual-jassub-evidence', { body: JSON.stringify({ core, onDemandRender, customCanvas, defaultOffscreen, source: artifact ? { artifact, sha256: hash(code) } : { version: release.version, integrity: release.integrity, member, sha256: hash(code) }, subtitleSha256: hash(subtitles), resources, external, state, limitation: `Actual worker, WASM and local font; offscreen option ${defaultOffscreen ? 'omitted, actual capability/selection recorded' : 'explicitly false'}. Canvas pixels are read from a separate canvas copying the native display bitmap. No full failure recovery, sustained GPU/memory or physical-device acceptance; candidate adds direct-then-host destruction.` }, null, 2), contentType: 'application/json' })
if (!page.isClosed()) {
await page.evaluate(() => {
+162
View File
@@ -0,0 +1,162 @@
import process from 'node:process'
import { expect, test } from './fixtures.js'
const bitmap = process.env.ARTPLAYER_JASSUB_CONTROL_BITMAP === 'true'
const idleReadback = process.env.ARTPLAYER_JASSUB_CONTROL_IDLE_READBACK === 'true'
for (const host of ['candidate', 'published', 'native']) {
test(`Native canvas and video control through seek/layout without JASSUB: ${host}`, async ({ page }, testInfo) => {
if (host === 'native') {
await page.route('**/test/native-canvas.html', route => route.fulfill({
contentType: 'text/html',
body: '<!doctype html><html><head><title>Native canvas control</title></head><body></body></html>',
}))
await page.goto('/test/native-canvas.html')
}
else {
await page.goto(`/test/player.html?core=${host}`)
}
await page.evaluate(async ({ host, bitmap, idleReadback }) => {
let video
if (host === 'native') {
video = document.createElement('video')
video.src = '/assets/sample/steve-jobs.mp4'
video.muted = true
video.style.width = '640px'
video.style.height = '360px'
document.body.append(video)
}
else {
window.createPlayer('/assets/sample/steve-jobs.mp4')
video = window.art.video
}
const canvas = document.createElement('canvas')
document.body.append(canvas)
const readback = document.createElement('canvas')
const probe = window.nativeCanvasProbe = { video, canvas, received: 0, frames: 0, sent: 0, bitmap, idleReadback, reading: false, stages: [], errors: [], pixel: null, offscreen: 'transferControlToOffscreen' in canvas, closed: false }
const draw = (ctx, canvas, message) => {
if (canvas.width !== message.width)
canvas.width = message.width
if (canvas.height !== message.height)
canvas.height = message.height
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = message.time < 5 ? 'red' : 'blue'
ctx.fillRect(0, 0, canvas.width, canvas.height)
}
if (probe.offscreen) {
const source = `let canvas, ctx; const draw = ${draw.toString()}; onmessage = async ({data}) => {
if (data.canvas) { canvas = data.canvas; ctx = canvas.getContext('2d'); return; }
if (${bitmap}) {
const pixels = new Uint8ClampedArray(data.width * data.height * 4);
for (let i = 0; i < pixels.length; i += 4) { pixels[i + (data.time < 5 ? 0 : 2)] = 255; pixels[i + 3] = 255; }
postMessage({stage: 'bitmap-start', time: data.time, at: performance.now()});
const image = await createImageBitmap(new ImageData(pixels, data.width, data.height));
postMessage({stage: 'bitmap-ready', time: data.time, at: performance.now()});
if (canvas.width !== data.width) canvas.width = data.width;
if (canvas.height !== data.height) canvas.height = data.height;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0); image.close();
} else { draw(ctx, canvas, data); }
postMessage({stage: 'drawn', time: data.time, at: performance.now()});
};`
probe.workerUrl = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }))
probe.worker = new Worker(probe.workerUrl)
const transferred = canvas.transferControlToOffscreen()
probe.worker.postMessage({ canvas: transferred }, [transferred])
probe.worker.onmessage = ({ data }) => {
probe.stages.push(data)
if (data.stage === 'drawn')
probe.received++
if (probe.received === probe.sent)
probe.drain?.()
}
probe.worker.onerror = event => probe.errors.push(event.message)
probe.post = message => probe.worker.postMessage(message)
}
else {
const ctx = canvas.getContext('2d')
probe.post = (message) => {
draw(ctx, canvas, message)
probe.received++
}
}
const render = () => {
if (probe.closed)
return
probe.frames++
if (!probe.reading) {
const rect = video.getBoundingClientRect()
probe.post({ width: Math.round(rect.width), height: Math.round(rect.height), time: video.currentTime })
probe.sent++
}
if (video.requestVideoFrameCallback)
probe.handle = video.requestVideoFrameCallback(render)
else
probe.handle = requestAnimationFrame(render)
}
probe.state = () => ({ pixel: probe.pixel, width: canvas.width, height: canvas.height, time: video.currentTime, frames: probe.frames, sent: probe.sent, received: probe.received, quality: video.getVideoPlaybackQuality?.().totalVideoFrames })
probe.sample = async () => {
if (idleReadback) {
probe.reading = true
if (probe.received < probe.sent)
await new Promise(resolve => probe.drain = resolve)
}
readback.width = canvas.width
readback.height = canvas.height
const ctx = readback.getContext('2d')
ctx.drawImage(canvas, 0, 0)
probe.pixel = [...ctx.getImageData(0, 0, 1, 1).data]
probe.reading = false
return probe.state()
}
await video.play()
render()
}, { host, bitmap, idleReadback })
try {
await expect.poll(async () => (await page.evaluate(() => window.nativeCanvasProbe.sample())).pixel).toEqual([255, 0, 0, 255])
const before = await page.evaluate(() => window.nativeCanvasProbe.sample())
await page.evaluate(async () => {
const video = window.nativeCanvasProbe.video
video.pause()
const seeked = new Promise(resolve => video.addEventListener('seeked', resolve, { once: true }))
video.currentTime = 10
await seeked
await video.play()
})
await expect.poll(async () => (await page.evaluate(() => window.nativeCanvasProbe.sample())).pixel).toEqual([0, 0, 255, 255])
await page.evaluate((host) => {
if (host === 'native') {
Object.assign(window.nativeCanvasProbe.video.style, { position: 'fixed', inset: '0', width: '100vw', height: '100vh' })
}
else {
window.art.fullscreenWeb = true
}
}, host)
await expect.poll(async () => (await page.evaluate(() => window.nativeCanvasProbe.sample())).width).toBeGreaterThan(before.width)
await expect.poll(async () => (await page.evaluate(() => window.nativeCanvasProbe.sample())).time).toBeGreaterThan(10.2)
await expect.poll(async () => (await page.evaluate(() => window.nativeCanvasProbe.sample())).pixel).toEqual([0, 0, 255, 255])
}
finally {
const evidence = await page.evaluate(() => {
const probe = window.nativeCanvasProbe
const state = { offscreen: probe.offscreen, bitmap: probe.bitmap, idleReadback: probe.idleReadback, stages: probe.stages, errors: probe.errors, coreLoaded: typeof window.Artplayer !== 'undefined', ...probe.state() }
probe.closed = true
if (probe.video.cancelVideoFrameCallback)
probe.video.cancelVideoFrameCallback(probe.handle)
else
cancelAnimationFrame(probe.handle)
probe.video.pause()
probe.worker?.terminate()
if (probe.workerUrl)
URL.revokeObjectURL(probe.workerUrl)
probe.canvas.remove()
if (window.art)
window.art.destroy(false)
else
probe.video.remove()
return state
})
await testInfo.attach('native-canvas-control', { body: JSON.stringify({ host, evidence, limitation: 'Native video/frame callbacks and a minimal Worker painting opaque colors. Bitmap mode uses createImageBitmap/drawImage/close; otherwise fillRect. JASSUB wrapper, WASM and fonts are absent. The native host loads no ArtPlayer scripts. Capability fallback without transfer is main-thread fillRect, not offscreen or ImageBitmap verification.' }), contentType: 'application/json' })
}
})
}