mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-18 02:21:20 -08:00
test(jassub): [PKG-JASSUB-09] isolate synchronous native bitmap stalls
This commit is contained in:
1 parent
319dccb605
commit
eba8c29fa7
10 files changed
+8865
-16
No files matched your search
@@ -385,6 +385,18 @@ The report records these distinctions and never infers a JASSUB fix from a contr
|
||||
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).
|
||||
|
||||
`ARTPLAYER_JASSUB_CONTROL_SINGLE_FLIGHT=true` submits at most one unacknowledged
|
||||
draw, without pausing readback. It records skipped video callbacks and the maximum
|
||||
outstanding count; this opt-in control also rejects individual canvas reads taking
|
||||
7 seconds or more, even if expect.poll eventually receives the expected pixels.
|
||||
Worker messages distinguish entry/return of createImageBitmap from Promise
|
||||
settlement, and include IDs plus pending-task timer observations. Main-thread
|
||||
samples separately time canvas copying and pixel reading. A native script-free
|
||||
host has exhibited a roughly ten-second stall despite a green eventual-pixel
|
||||
result; a single outstanding task does not eliminate the Firefox issue. These
|
||||
timings identify API boundaries, not a native stack or the underlying cause.
|
||||
See [the native-call record](../../refactor/changes/2026-09-15-PKG-JASSUB-09-native-call.md).
|
||||
|
||||
`ARTPLAYER_JASSUB_SCREENSHOT=true` adds a separate observation path to the native
|
||||
JASSUB spec. `jassub-display.js` decodes a clipped composited page PNG using the
|
||||
pinned root-only pngjs development dependency; it never copies the transferred
|
||||
|
||||
@@ -3,6 +3,7 @@ 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'
|
||||
const singleFlight = process.env.ARTPLAYER_JASSUB_CONTROL_SINGLE_FLIGHT === 'true'
|
||||
|
||||
for (const host of ['candidate', 'published', 'native']) {
|
||||
test(`Native canvas and video control through seek/layout without JASSUB: ${host}`, async ({ page }, testInfo) => {
|
||||
@@ -16,7 +17,7 @@ for (const host of ['candidate', 'published', 'native']) {
|
||||
else {
|
||||
await page.goto(`/test/player.html?core=${host}`)
|
||||
}
|
||||
await page.evaluate(async ({ host, bitmap, idleReadback }) => {
|
||||
await page.evaluate(async ({ host, bitmap, idleReadback, singleFlight }) => {
|
||||
let video
|
||||
if (host === 'native') {
|
||||
video = document.createElement('video')
|
||||
@@ -33,7 +34,7 @@ for (const host of ['candidate', 'published', 'native']) {
|
||||
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 probe = window.nativeCanvasProbe = { video, canvas, received: 0, frames: 0, sent: 0, bitmap, idleReadback, singleFlight, skipped: 0, maxOutstanding: 0, reading: false, stages: [], samples: [], errors: [], pixel: null, offscreen: 'transferControlToOffscreen' in canvas, closed: false }
|
||||
const draw = (ctx, canvas, message) => {
|
||||
if (canvas.width !== message.width)
|
||||
canvas.width = message.width
|
||||
@@ -44,20 +45,27 @@ for (const host of ['candidate', 'published', 'native']) {
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
if (probe.offscreen) {
|
||||
const source = `let canvas, ctx; const draw = ${draw.toString()}; onmessage = async ({data}) => {
|
||||
const source = `let canvas, ctx; const pending = new Set(); const draw = ${draw.toString()};
|
||||
setInterval(() => { if (pending.size) postMessage({stage: 'pending', ids: [...pending], at: performance.now()}); }, 250);
|
||||
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()});
|
||||
const imageData = new ImageData(pixels, data.width, data.height);
|
||||
pending.add(data.id);
|
||||
postMessage({stage: 'bitmap-start', id: data.id, time: data.time, at: performance.now()});
|
||||
const promise = createImageBitmap(imageData);
|
||||
postMessage({stage: 'bitmap-returned', id: data.id, time: data.time, at: performance.now()});
|
||||
const image = await promise;
|
||||
pending.delete(data.id);
|
||||
postMessage({stage: 'bitmap-ready', id: data.id, 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()});
|
||||
postMessage({stage: 'drawn', id: data.id, time: data.time, at: performance.now()});
|
||||
};`
|
||||
probe.workerUrl = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }))
|
||||
probe.worker = new Worker(probe.workerUrl)
|
||||
@@ -84,34 +92,44 @@ for (const host of ['candidate', 'published', 'native']) {
|
||||
if (probe.closed)
|
||||
return
|
||||
probe.frames++
|
||||
if (!probe.reading) {
|
||||
if (!probe.reading && (!singleFlight || probe.sent === probe.received)) {
|
||||
const rect = video.getBoundingClientRect()
|
||||
probe.post({ width: Math.round(rect.width), height: Math.round(rect.height), time: video.currentTime })
|
||||
probe.sent++
|
||||
probe.maxOutstanding = Math.max(probe.maxOutstanding, probe.sent - probe.received)
|
||||
probe.post({ id: probe.sent, width: Math.round(rect.width), height: Math.round(rect.height), time: video.currentTime })
|
||||
}
|
||||
else if (!probe.reading) {
|
||||
probe.skipped++
|
||||
}
|
||||
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.state = () => ({ pixel: probe.pixel, width: canvas.width, height: canvas.height, time: video.currentTime, frames: probe.frames, sent: probe.sent, received: probe.received, skipped: probe.skipped, maxOutstanding: probe.maxOutstanding, quality: video.getVideoPlaybackQuality?.().totalVideoFrames })
|
||||
probe.sample = async () => {
|
||||
if (idleReadback) {
|
||||
probe.reading = true
|
||||
if (probe.received < probe.sent)
|
||||
await new Promise(resolve => probe.drain = resolve)
|
||||
}
|
||||
const sample = { start: performance.now(), sent: probe.sent, received: probe.received }
|
||||
readback.width = canvas.width
|
||||
readback.height = canvas.height
|
||||
const ctx = readback.getContext('2d')
|
||||
sample.copyStart = performance.now()
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
sample.drawn = performance.now()
|
||||
probe.pixel = [...ctx.getImageData(0, 0, 1, 1).data]
|
||||
sample.read = performance.now()
|
||||
sample.pixel = probe.pixel
|
||||
probe.samples.push(sample)
|
||||
probe.reading = false
|
||||
return probe.state()
|
||||
}
|
||||
await video.play()
|
||||
render()
|
||||
}, { host, bitmap, idleReadback })
|
||||
}, { host, bitmap, idleReadback, singleFlight })
|
||||
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())
|
||||
@@ -124,6 +142,8 @@ for (const host of ['candidate', 'published', 'native']) {
|
||||
await video.play()
|
||||
})
|
||||
await expect.poll(async () => (await page.evaluate(() => window.nativeCanvasProbe.sample())).pixel).toEqual([0, 0, 255, 255])
|
||||
if (singleFlight)
|
||||
expect(await page.evaluate(() => window.nativeCanvasProbe.maxOutstanding)).toBe(1)
|
||||
await page.evaluate((host) => {
|
||||
if (host === 'native') {
|
||||
Object.assign(window.nativeCanvasProbe.video.style, { position: 'fixed', inset: '0', width: '100vw', height: '100vh' })
|
||||
@@ -135,11 +155,16 @@ for (const host of ['candidate', 'published', 'native']) {
|
||||
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])
|
||||
if (singleFlight) {
|
||||
const samples = await page.evaluate(() => window.nativeCanvasProbe.samples)
|
||||
// A successful predicate after a blocked browser call can outlive expect.poll's deadline.
|
||||
expect(Math.max(...samples.map(sample => sample.read - sample.start)), 'Native canvas readback must not hide a stall behind eventual pixels').toBeLessThan(7000)
|
||||
}
|
||||
}
|
||||
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() }
|
||||
const state = { offscreen: probe.offscreen, bitmap: probe.bitmap, idleReadback: probe.idleReadback, singleFlight: probe.singleFlight, stages: probe.stages, samples: probe.samples, errors: probe.errors, coreLoaded: typeof window.Artplayer !== 'undefined', ...probe.state() }
|
||||
probe.closed = true
|
||||
if (probe.video.cancelVideoFrameCallback)
|
||||
probe.video.cancelVideoFrameCallback(probe.handle)
|
||||
|
||||
Reference in new issue
Block a user