mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-19 19:51:20 -08:00
fix(jassub): [PKG-JASSUB-07] repair vendor lifecycle and subtitle frame clock
This commit is contained in:
1 parent
5e445b1af6
commit
0f3c068aae
29 files changed
+14064
-267
No files matched your search
@@ -0,0 +1,190 @@
|
||||
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 subtitles = `[Script Info]
|
||||
ScriptType: v4.00+
|
||||
PlayResX: 640
|
||||
PlayResY: 360
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Default,Liberation Sans,36,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,20,1
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:00.00,0:00:30.00,Default,,0,0,0,,Native lifecycle subtitle
|
||||
`
|
||||
|
||||
async function setup(page) {
|
||||
await page.goto('/test/player.html?core=candidate')
|
||||
await page.addScriptTag({ content: code })
|
||||
await page.evaluate(() => {
|
||||
window.createPlayer('/assets/sample/steve-jobs.mp4')
|
||||
window.jassubLifecycle = { sent: [], terminated: 0, errors: [] }
|
||||
const NativeWorker = window.Worker
|
||||
window.Worker = class extends NativeWorker {
|
||||
constructor(...args) {
|
||||
super(...args)
|
||||
this.addEventListener('error', (event) => {
|
||||
window.jassubLifecycle.lastWorkerError = event
|
||||
})
|
||||
}
|
||||
|
||||
postMessage(message, ...rest) {
|
||||
window.jassubLifecycle.sent.push({ target: message.target, time: message.time, rate: message.rate })
|
||||
return super.postMessage(message, ...rest)
|
||||
}
|
||||
|
||||
terminate() {
|
||||
window.jassubLifecycle.terminated++
|
||||
return super.terminate()
|
||||
}
|
||||
}
|
||||
window.jassubOptions = { 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'], offscreenRender: false }
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.duration)).toBeGreaterThan(30)
|
||||
}
|
||||
|
||||
for (const onDemandRender of [true, false]) {
|
||||
test(`JASSUB actual video replacement and repeated destruction, onDemandRender=${onDemandRender}`, async ({ page }, testInfo) => {
|
||||
await setup(page)
|
||||
try {
|
||||
await page.evaluate(async ({ subtitles, onDemandRender }) => {
|
||||
const register = window.artplayerPluginJassub.default || window.artplayerPluginJassub
|
||||
window.art.plugins.add(register({ ...window.jassubOptions, subContent: subtitles, onDemandRender }))
|
||||
const instance = window.jassub = window.art.plugins.artplayerPluginJassub.instance
|
||||
instance.addEventListener('error', event => window.jassubLifecycle.errors.push(String(event.error)))
|
||||
await new Promise(resolve => instance.addEventListener('ready', resolve, { once: true }))
|
||||
const container = document.createElement('div')
|
||||
const video = document.createElement('video')
|
||||
video.width = 640
|
||||
video.height = 360
|
||||
video.muted = true
|
||||
video.preload = 'auto'
|
||||
container.append(video)
|
||||
document.body.append(container)
|
||||
const loaded = new Promise(resolve => video.addEventListener('loadeddata', resolve, { once: true }))
|
||||
video.src = '/assets/sample/steve-jobs.mp4'
|
||||
await loaded
|
||||
await video.play()
|
||||
await new Promise((resolve) => {
|
||||
const playing = () => {
|
||||
if (video.currentTime <= 0.1)
|
||||
return
|
||||
video.removeEventListener('timeupdate', playing)
|
||||
resolve()
|
||||
}
|
||||
video.addEventListener('timeupdate', playing)
|
||||
playing()
|
||||
})
|
||||
video.pause()
|
||||
instance.setVideo(video)
|
||||
window.replacementVideo = video
|
||||
window.replacementContainer = container
|
||||
const seeked = new Promise(resolve => video.addEventListener('seeked', resolve, { once: true }))
|
||||
video.currentTime = 9
|
||||
await seeked
|
||||
video.playbackRate = 1.5
|
||||
await video.play()
|
||||
}, { subtitles, onDemandRender })
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const canvas = window.jassub._canvas
|
||||
return canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data.filter((value, index) => index % 4 === 3 && value > 0).length
|
||||
})).toBeGreaterThan(100)
|
||||
const state = await page.evaluate(async () => {
|
||||
const instance = window.jassub
|
||||
const events = await new Promise((resolve, reject) => instance.getEvents((error, rows) => error ? reject(error) : resolve(rows)))
|
||||
const parentMatches = instance._canvasParent.parentNode === window.replacementContainer
|
||||
const originalContainerCount = window.art.template.$player.querySelectorAll('.JASSUB').length
|
||||
const time = window.replacementVideo.currentTime
|
||||
instance.destroy()
|
||||
instance.destroy()
|
||||
window.art.destroy(false)
|
||||
window.replacementVideo.pause()
|
||||
return { parentMatches, originalContainerCount, time, event: events[0], terminated: window.jassubLifecycle.terminated, ownedContainers: document.querySelectorAll('.JASSUB').length, replacementPreserved: window.replacementVideo.isConnected, errors: window.jassubLifecycle.errors, sent: window.jassubLifecycle.sent }
|
||||
})
|
||||
expect(state.parentMatches).toBe(true)
|
||||
expect(state.originalContainerCount).toBe(0)
|
||||
expect(state.time).toBeGreaterThanOrEqual(9)
|
||||
expect(typeof state.event.Style).toBe('number')
|
||||
expect(state.event._index).toBeUndefined()
|
||||
expect(state.terminated).toBe(1)
|
||||
expect(state.ownedContainers).toBe(0)
|
||||
expect(state.replacementPreserved).toBe(true)
|
||||
expect(state.errors).toEqual([])
|
||||
if (!onDemandRender)
|
||||
expect(state.sent.some(message => message.target === 'video' && message.rate === 1.5)).toBe(true)
|
||||
await testInfo.attach('jassub-native-lifecycle', { body: JSON.stringify({ artifact, sha256: hash(code), onDemandRender, state }), contentType: 'application/json' })
|
||||
}
|
||||
finally {
|
||||
await page.evaluate(() => {
|
||||
window.replacementVideo?.pause()
|
||||
if (!window.art.isDestroy)
|
||||
window.art.destroy()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('JASSUB actual invalid Worker URL construction releases the newly created canvas and media listeners', async ({ page }, testInfo) => {
|
||||
await setup(page)
|
||||
const state = await page.evaluate(() => {
|
||||
const video = window.art.video
|
||||
const listeners = new Map()
|
||||
const add = video.addEventListener.bind(video)
|
||||
const remove = video.removeEventListener.bind(video)
|
||||
video.addEventListener = (name, callback, ...options) => {
|
||||
if (!listeners.has(name))
|
||||
listeners.set(name, new Set())
|
||||
listeners.get(name).add(callback)
|
||||
return add(name, callback, ...options)
|
||||
}
|
||||
video.removeEventListener = (name, callback, ...options) => {
|
||||
listeners.get(name)?.delete(callback)
|
||||
return remove(name, callback, ...options)
|
||||
}
|
||||
let error
|
||||
try {
|
||||
const register = window.artplayerPluginJassub.default || window.artplayerPluginJassub
|
||||
register({ ...window.jassubOptions, workerUrl: 'http://[', onDemandRender: false })(window.art)
|
||||
}
|
||||
catch (failure) { error = { name: failure.name, message: failure.message } }
|
||||
const activeListeners = [...listeners.values()].reduce((sum, values) => sum + values.size, 0)
|
||||
window.art.destroy()
|
||||
return { error, activeListeners, ownedContainers: document.querySelectorAll('.JASSUB').length }
|
||||
})
|
||||
expect(state.error?.name).toBe('SyntaxError')
|
||||
expect(state.activeListeners).toBe(0)
|
||||
expect(state.ownedContainers).toBe(0)
|
||||
await testInfo.attach('jassub-native-construction-failure', { body: JSON.stringify({ artifact, sha256: hash(code), state }), contentType: 'application/json' })
|
||||
})
|
||||
|
||||
test('JASSUB native asynchronous CSP Worker error reaches a pending query and explicit destruction cleans up', async ({ page }, testInfo) => {
|
||||
await setup(page)
|
||||
const state = await page.evaluate(async () => {
|
||||
const meta = document.createElement('meta')
|
||||
meta.httpEquiv = 'Content-Security-Policy'
|
||||
meta.content = 'worker-src \'none\''
|
||||
document.head.append(meta)
|
||||
const register = window.artplayerPluginJassub.default || window.artplayerPluginJassub
|
||||
const { instance } = register({ ...window.jassubOptions, onDemandRender: false })(window.art)
|
||||
let calls = 0
|
||||
const outcome = await new Promise((resolve) => {
|
||||
instance.getEvents((error, rows) => {
|
||||
calls++
|
||||
resolve({ errorIsNativeEvent: error instanceof Event, trusted: error.isTrusted, sameNativeError: error === window.jassubLifecycle.lastWorkerError, type: error.type, rowsAbsent: rows === undefined })
|
||||
})
|
||||
})
|
||||
instance.destroy()
|
||||
instance.destroy()
|
||||
window.art.destroy(false)
|
||||
return { outcome, calls, terminated: window.jassubLifecycle.terminated, ownedContainers: document.querySelectorAll('.JASSUB').length }
|
||||
})
|
||||
expect(state.outcome).toEqual({ errorIsNativeEvent: true, trusted: true, sameNativeError: true, type: 'error', rowsAbsent: true })
|
||||
expect(state.calls).toBe(1)
|
||||
expect(state.terminated).toBe(1)
|
||||
expect(state.ownedContainers).toBe(0)
|
||||
await testInfo.attach('jassub-native-worker-error', { body: JSON.stringify({ artifact, sha256: hash(code), state }), contentType: 'application/json' })
|
||||
})
|
||||
@@ -41,9 +41,25 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
|
||||
resources.push({ url: response.url(), status: response.status(), contentType: response.headers()['content-type'] })
|
||||
})
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
await page.evaluate(() => {
|
||||
window.jassubFramePlatform = { before: typeof HTMLVideoElement.prototype.requestVideoFrameCallback, registered: 0, delivered: 0 }
|
||||
})
|
||||
await page.addScriptTag({ content: code })
|
||||
await page.evaluate(() => {
|
||||
window.jassubNative = { workers: [], snapshots: [], errors: [] }
|
||||
const frames = window.jassubFramePlatform
|
||||
frames.after = typeof HTMLVideoElement.prototype.requestVideoFrameCallback
|
||||
if (frames.after === 'function') {
|
||||
const request = HTMLVideoElement.prototype.requestVideoFrameCallback
|
||||
HTMLVideoElement.prototype.requestVideoFrameCallback = function (callback) {
|
||||
frames.registered++
|
||||
return request.call(this, function (...args) {
|
||||
frames.delivered++
|
||||
frames.lastMetadata = args[1]
|
||||
return callback.apply(this, args)
|
||||
})
|
||||
}
|
||||
}
|
||||
window.jassubNative = { workers: [], snapshots: [], errors: [], frames }
|
||||
const NativeWorker = window.Worker
|
||||
window.Worker = class extends NativeWorker {
|
||||
constructor(...args) {
|
||||
@@ -110,7 +126,9 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
|
||||
signature = (signature + i * pixels[i]) >>> 0
|
||||
}
|
||||
}
|
||||
const state = { phase, visible, signature, width: canvas.width, height: canvas.height, cssWidth: rect.width, cssHeight: rect.height, time: window.art.currentTime, connected: canvas.isConnected }
|
||||
const video = window.art.video
|
||||
const quality = video.getVideoPlaybackQuality?.()
|
||||
const state = { phase, visible, signature, width: canvas.width, height: canvas.height, cssWidth: rect.width, cssHeight: rect.height, time: window.art.currentTime, connected: canvas.isConnected, media: { paused: video.paused, readyState: video.readyState, totalFrames: quality?.totalVideoFrames, droppedFrames: quality?.droppedVideoFrames, decodedFrames: video.webkitDecodedFrameCount, mozPresentedFrames: video.mozPresentedFrames }, onDemand: window.jassub._onDemandRender }
|
||||
window.jassubNative.lastPixel = state
|
||||
if (phase)
|
||||
window.jassubNative.snapshots.push(state)
|
||||
|
||||
@@ -39,6 +39,7 @@ export function jassubEnvironment(implementation, { script = false, simd = false
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
child.parentNode?.removeChild(child)
|
||||
this.children.push(child)
|
||||
child.parentNode = this
|
||||
return child
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Frame clock regressions use the repository runner.
|
||||
import test from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
import { jassubCandidate } from './helpers/jassub.js'
|
||||
|
||||
const candidate = await jassubCandidate()
|
||||
const interval = 1000 / 30
|
||||
|
||||
function environment({ native = false, quality = true } = {}) {
|
||||
let now = 0
|
||||
let nextFrame = 0
|
||||
const frames = new Map()
|
||||
const cancelled = []
|
||||
class Video {
|
||||
constructor() {
|
||||
this.currentTime = 0
|
||||
this.videoWidth = 640
|
||||
this.videoHeight = 360
|
||||
this.readyState = 4
|
||||
this.paused = false
|
||||
this.seeking = false
|
||||
this.quality = { totalVideoFrames: 0, droppedVideoFrames: 0, totalFrameDelay: 0 }
|
||||
}
|
||||
}
|
||||
if (quality) {
|
||||
Video.prototype.getVideoPlaybackQuality = function () {
|
||||
return { ...this.quality }
|
||||
}
|
||||
}
|
||||
const nativeRequest = () => 123
|
||||
const nativeCancel = () => undefined
|
||||
if (native) {
|
||||
Video.prototype.requestVideoFrameCallback = nativeRequest
|
||||
Video.prototype.cancelVideoFrameCallback = nativeCancel
|
||||
}
|
||||
const originalDescriptors = Object.getOwnPropertyDescriptors(Video.prototype)
|
||||
const module = { exports: {} }
|
||||
const context = {
|
||||
HTMLVideoElement: Video,
|
||||
EventTarget,
|
||||
Map,
|
||||
module,
|
||||
exports: module.exports,
|
||||
performance: { now: () => now },
|
||||
requestAnimationFrame(callback) {
|
||||
const id = nextFrame++
|
||||
frames.set(id, callback)
|
||||
return id
|
||||
},
|
||||
cancelAnimationFrame(id) {
|
||||
cancelled.push(id)
|
||||
frames.delete(id)
|
||||
},
|
||||
}
|
||||
context.window = context
|
||||
context.self = context
|
||||
// Only platform primitives are controlled. Execute the actual package bundle;
|
||||
// no vendor method is substituted or copied into this test.
|
||||
vm.runInNewContext(candidate.code, context, { timeout: 5000 })
|
||||
assert.equal(typeof (module.exports.default || module.exports), 'function')
|
||||
return {
|
||||
Video,
|
||||
frames,
|
||||
cancelled,
|
||||
nativeRequest,
|
||||
nativeCancel,
|
||||
originalDescriptors,
|
||||
frame(time) {
|
||||
assert(time >= now, 'The controlled wall clock is monotonic')
|
||||
now = time
|
||||
for (const [id, callback] of [...frames]) {
|
||||
if (!frames.delete(id))
|
||||
continue
|
||||
callback(time)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function collect(video) {
|
||||
const calls = []
|
||||
const id = video.requestVideoFrameCallback((now, metadata) => calls.push({ now, metadata }))
|
||||
assert.equal(typeof id, 'number')
|
||||
return { id, calls }
|
||||
}
|
||||
|
||||
function hasPending(video, id) {
|
||||
const pending = video._rvfcpolyfillmap
|
||||
assert(pending, 'The actual polyfill exposes its pending request map')
|
||||
return pending instanceof Map ? pending.has(id) : Object.hasOwn(pending, id)
|
||||
}
|
||||
|
||||
test('JASSUB frame polyfill leaves native RVFC and cancellation untouched', () => {
|
||||
const env = environment({ native: true })
|
||||
assert.deepEqual(Object.getOwnPropertyDescriptors(env.Video.prototype), env.originalDescriptors)
|
||||
const video = new env.Video()
|
||||
assert.equal(video.requestVideoFrameCallback, env.nativeRequest)
|
||||
assert.equal(video.cancelVideoFrameCallback, env.nativeCancel)
|
||||
assert(!Object.hasOwn(video, '_rvfcpolyfillmap'))
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
|
||||
test('JASSUB frame polyfill preserves the unsupported quality-API boundary', () => {
|
||||
const env = environment({ quality: false })
|
||||
assert.equal(env.Video.prototype.requestVideoFrameCallback, undefined)
|
||||
assert.equal(env.Video.prototype.cancelVideoFrameCallback, undefined)
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
|
||||
test('JASSUB healthy frame counts retain metadata and bypass fallback throttling', () => {
|
||||
const env = environment()
|
||||
const video = new env.Video()
|
||||
video.quality = { totalVideoFrames: 20, droppedVideoFrames: 5, totalFrameDelay: 0.125 }
|
||||
const { calls } = collect(video)
|
||||
video.currentTime = 4
|
||||
video.quality = { totalVideoFrames: 21, droppedVideoFrames: 5, totalFrameDelay: 0.25 }
|
||||
// The new fallback conditions must not alter the original increasing-count path.
|
||||
video.readyState = 1
|
||||
video.seeking = true
|
||||
env.frame(16)
|
||||
assert.equal(calls.length, 1)
|
||||
const { now, metadata } = calls[0]
|
||||
assert.equal(now, 16)
|
||||
assert.equal(metadata.presentedFrames, 16)
|
||||
assert.equal(metadata.processingDuration, 0.125)
|
||||
assert.equal(metadata.presentationTime, 141)
|
||||
assert.equal(metadata.expectedDisplayTime, 32)
|
||||
assert.equal(metadata.mediaTime, 4.016)
|
||||
assert.equal(metadata.width, 640)
|
||||
assert.equal(metadata.height, 360)
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
|
||||
for (const counter of ['mozPresentedFrames', 'mozPaintedFrames']) {
|
||||
test(`JASSUB preserves the ${counter} increasing-count path`, () => {
|
||||
const env = environment()
|
||||
const video = new env.Video()
|
||||
video[counter] = 4
|
||||
const { calls } = collect(video)
|
||||
video[counter] = 5
|
||||
env.frame(1)
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].metadata.presentedFrames, 5)
|
||||
})
|
||||
}
|
||||
|
||||
test('JASSUB zero-count fallback waits one 30Hz interval and reports actual media time with zero frames', () => {
|
||||
const env = environment()
|
||||
const video = new env.Video()
|
||||
const { calls } = collect(video)
|
||||
video.currentTime = 0.75
|
||||
env.frame(interval - 0.001)
|
||||
assert.equal(calls.length, 0)
|
||||
env.frame(interval)
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].now, interval)
|
||||
assert.equal(calls[0].metadata.mediaTime, 0.75)
|
||||
assert.equal(calls[0].metadata.presentedFrames, 0)
|
||||
assert.equal(calls[0].metadata.width, 640)
|
||||
assert.equal(calls[0].metadata.height, 360)
|
||||
env.frame(interval * 3)
|
||||
assert.equal(calls.length, 1, 'A fulfilled request is never delivered twice')
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
|
||||
test('JASSUB zero-count fallback remains bounded on a 144Hz wall clock', () => {
|
||||
const env = environment()
|
||||
const video = new env.Video()
|
||||
const deliveries = []
|
||||
let pending
|
||||
const request = () => {
|
||||
pending = video.requestVideoFrameCallback((now, metadata) => {
|
||||
deliveries.push({ now, mediaTime: metadata.mediaTime })
|
||||
request()
|
||||
})
|
||||
}
|
||||
request()
|
||||
for (let frame = 1; frame <= 144; frame++) {
|
||||
const time = frame * 1000 / 144
|
||||
video.currentTime = time / 1000
|
||||
env.frame(time)
|
||||
}
|
||||
assert(deliveries.length > 20 && deliveries.length <= 30, `Expected bounded useful fallback, received ${deliveries.length} callbacks`)
|
||||
for (let index = 1; index < deliveries.length; index++)
|
||||
assert(deliveries[index].now - deliveries[index - 1].now >= interval - 1e-9)
|
||||
video.cancelVideoFrameCallback(pending)
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
|
||||
test('JASSUB resumed real frame counts bypass the preceding fallback interval', () => {
|
||||
const env = environment()
|
||||
const video = new env.Video()
|
||||
const fallback = collect(video)
|
||||
video.currentTime = 0.5
|
||||
env.frame(interval)
|
||||
assert.equal(fallback.calls.length, 1)
|
||||
assert.equal(fallback.calls[0].metadata.presentedFrames, 0)
|
||||
const healthy = collect(video)
|
||||
video.quality.totalVideoFrames = 1
|
||||
video.currentTime = 1
|
||||
env.frame(interval + 1)
|
||||
assert.equal(healthy.calls.length, 1, 'Actual frame recovery must not wait for the fallback cap')
|
||||
assert.equal(healthy.calls[0].metadata.presentedFrames, 1)
|
||||
assert.equal(healthy.calls[0].metadata.mediaTime, 1.001)
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
|
||||
for (const paused of [false, true]) {
|
||||
test(`JASSUB unchanged media time does not complete zero-count requests, paused=${paused}`, () => {
|
||||
const env = environment()
|
||||
const video = new env.Video()
|
||||
video.paused = paused
|
||||
video.currentTime = 5
|
||||
const { id, calls } = collect(video)
|
||||
env.frame(40)
|
||||
env.frame(1000)
|
||||
assert.equal(calls.length, 0)
|
||||
video.cancelVideoFrameCallback(id)
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const destination of [20, 3]) {
|
||||
test(`JASSUB paused seek to ${destination} waits for seeking completion then delivers the changed time`, () => {
|
||||
const env = environment()
|
||||
const video = new env.Video()
|
||||
video.paused = true
|
||||
video.currentTime = 10
|
||||
const { calls } = collect(video)
|
||||
video.seeking = true
|
||||
video.currentTime = destination
|
||||
env.frame(40)
|
||||
assert.equal(calls.length, 0)
|
||||
video.seeking = false
|
||||
env.frame(60)
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].metadata.mediaTime, destination)
|
||||
assert.equal(calls[0].metadata.presentedFrames, 0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const readyState of [0, 1]) {
|
||||
test(`JASSUB zero-count fallback waits for current frame data from readyState=${readyState}`, () => {
|
||||
const env = environment()
|
||||
const video = new env.Video()
|
||||
video.readyState = readyState
|
||||
const { calls } = collect(video)
|
||||
video.currentTime = 1
|
||||
env.frame(40)
|
||||
assert.equal(calls.length, 0)
|
||||
video.readyState = 2
|
||||
env.frame(60)
|
||||
assert.equal(calls.length, 1)
|
||||
})
|
||||
}
|
||||
|
||||
for (const [label, quality] of [
|
||||
['NaN total', { totalVideoFrames: Number.NaN, droppedVideoFrames: 0 }],
|
||||
['missing total', { droppedVideoFrames: 0 }],
|
||||
['NaN dropped', { totalVideoFrames: 0, droppedVideoFrames: Number.NaN }],
|
||||
['missing dropped', { totalVideoFrames: 0 }],
|
||||
['all frames dropped', { totalVideoFrames: 10, droppedVideoFrames: 10 }],
|
||||
]) {
|
||||
test(`JASSUB does not infer zero-count fallback from ${label}`, () => {
|
||||
const env = environment()
|
||||
const video = new env.Video()
|
||||
video.quality = quality
|
||||
const { id, calls } = collect(video)
|
||||
video.currentTime = 1
|
||||
env.frame(40)
|
||||
video.currentTime = 2
|
||||
env.frame(100)
|
||||
assert.equal(calls.length, 0)
|
||||
video.cancelVideoFrameCallback(id)
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('JASSUB same-video simultaneous requests have independent handles and cancellation', () => {
|
||||
const env = environment()
|
||||
const video = new env.Video()
|
||||
const first = collect(video)
|
||||
const second = collect(video)
|
||||
assert.notEqual(first.id, second.id)
|
||||
assert(second.id > first.id, 'Callback identifiers are monotonic even at the same performance.now')
|
||||
video.cancelVideoFrameCallback(first.id)
|
||||
video.quality.totalVideoFrames = 1
|
||||
env.frame(16)
|
||||
assert.equal(first.calls.length, 0)
|
||||
assert.equal(second.calls.length, 1)
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
|
||||
test('JASSUB simultaneous requests on different videos cannot cancel each other', () => {
|
||||
const env = environment()
|
||||
const firstVideo = new env.Video()
|
||||
const secondVideo = new env.Video()
|
||||
const first = collect(firstVideo)
|
||||
const second = collect(secondVideo)
|
||||
assert(Object.hasOwn(firstVideo, '_rvfcpolyfillmap'))
|
||||
assert(Object.hasOwn(secondVideo, '_rvfcpolyfillmap'))
|
||||
assert.notEqual(firstVideo._rvfcpolyfillmap, secondVideo._rvfcpolyfillmap)
|
||||
firstVideo.cancelVideoFrameCallback(first.id)
|
||||
firstVideo.cancelVideoFrameCallback(first.id)
|
||||
firstVideo.quality.totalVideoFrames = 1
|
||||
secondVideo.quality.totalVideoFrames = 1
|
||||
env.frame(16)
|
||||
assert.equal(first.calls.length, 0)
|
||||
assert.equal(second.calls.length, 1)
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
|
||||
test('JASSUB cancellation releases RAF id zero', () => {
|
||||
const env = environment()
|
||||
const video = new env.Video()
|
||||
const { id, calls } = collect(video)
|
||||
assert(env.frames.has(0))
|
||||
video.cancelVideoFrameCallback(id)
|
||||
assert.deepEqual(env.cancelled, [0])
|
||||
video.quality.totalVideoFrames = 1
|
||||
env.frame(40)
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
|
||||
test('JASSUB removes a pending callback before invoking it even when it throws', () => {
|
||||
const env = environment()
|
||||
const video = new env.Video()
|
||||
const expected = new Error('frame consumer failure')
|
||||
const id = video.requestVideoFrameCallback(() => {
|
||||
assert.equal(hasPending(video, id), false)
|
||||
throw expected
|
||||
})
|
||||
video.quality.totalVideoFrames = 1
|
||||
assert.throws(() => env.frame(16), error => error === expected)
|
||||
assert.equal(hasPending(video, id), false)
|
||||
assert.equal(env.frames.size, 0)
|
||||
const next = collect(video)
|
||||
video.quality.totalVideoFrames = 2
|
||||
env.frame(17)
|
||||
assert.equal(next.calls.length, 1)
|
||||
})
|
||||
|
||||
test('JASSUB callback reentry retains a newly registered request independently', () => {
|
||||
const env = environment()
|
||||
const video = new env.Video()
|
||||
let next
|
||||
const calls = []
|
||||
const first = video.requestVideoFrameCallback(() => {
|
||||
assert.equal(hasPending(video, first), false)
|
||||
calls.push('first')
|
||||
next = video.requestVideoFrameCallback(() => calls.push('next'))
|
||||
video.cancelVideoFrameCallback(first)
|
||||
})
|
||||
video.quality.totalVideoFrames = 1
|
||||
env.frame(16)
|
||||
assert.deepEqual(calls, ['first'])
|
||||
assert(hasPending(video, next))
|
||||
video.quality.totalVideoFrames = 2
|
||||
env.frame(17)
|
||||
assert.deepEqual(calls, ['first', 'next'])
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Vendor lifecycle regressions use the repository runner.
|
||||
import test from 'node:test'
|
||||
import { jassubCandidate, jassubEnvironment } from './helpers/jassub.js'
|
||||
|
||||
const candidate = await jassubCandidate()
|
||||
|
||||
function environment() {
|
||||
// Expose platform controls only; every tested method is the actual bundled vendor.
|
||||
const code = `${candidate.code}\nObject.defineProperty(module.exports.default || module.exports, '__lifecyclePlatform', { value: { Error, ErrorEvent, Video: HTMLVideoElement, setGlobal: (name, value) => { globalThis[name] = value } } });`
|
||||
const env = jassubEnvironment({ ...candidate, code })
|
||||
env.platform = env.factory.__lifecyclePlatform
|
||||
return env
|
||||
}
|
||||
|
||||
function trackListeners(target) {
|
||||
const listeners = new Map()
|
||||
const add = target.addEventListener
|
||||
const remove = target.removeEventListener
|
||||
target.addEventListener = function (type, callback, ...options) {
|
||||
if (!listeners.has(type))
|
||||
listeners.set(type, new Set())
|
||||
listeners.get(type).add(callback)
|
||||
return add?.call(this, type, callback, ...options)
|
||||
}
|
||||
target.removeEventListener = function (type, callback, ...options) {
|
||||
listeners.get(type)?.delete(callback)
|
||||
return remove?.call(this, type, callback, ...options)
|
||||
}
|
||||
return {
|
||||
size: () => [...listeners.values()].reduce((sum, entries) => sum + entries.size, 0),
|
||||
callbacks: type => [...listeners.get(type) || []],
|
||||
emit(type, event) {
|
||||
for (const callback of [...listeners.get(type) || []]) callback(event)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function frames(env) {
|
||||
const pending = new Map()
|
||||
let next = 0
|
||||
env.platform.Video.prototype.requestVideoFrameCallback = function (callback) {
|
||||
pending.set(++next, { video: this, callback })
|
||||
return next
|
||||
}
|
||||
env.platform.Video.prototype.cancelVideoFrameCallback = id => pending.delete(id)
|
||||
return {
|
||||
count: video => [...pending.values()].filter(row => row.video === video).length,
|
||||
take(video) {
|
||||
const entry = [...pending].find(([, row]) => row.video === video)
|
||||
assert(entry, 'take an actually queued native frame callback')
|
||||
pending.delete(entry[0])
|
||||
return entry[1].callback
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function queryEnvironment() {
|
||||
const env = environment()
|
||||
const timers = new Map()
|
||||
const errors = []
|
||||
let next = 0
|
||||
env.platform.setGlobal('setTimeout', (callback) => {
|
||||
timers.set(++next, callback)
|
||||
return next
|
||||
})
|
||||
env.platform.setGlobal('clearTimeout', id => timers.delete(id))
|
||||
// Capture the actual Error allocated by the timeout, without intercepting vendor methods.
|
||||
env.platform.setGlobal('Error', class extends env.platform.Error {
|
||||
constructor(...args) {
|
||||
super(...args)
|
||||
errors.push(this)
|
||||
}
|
||||
})
|
||||
const { instance } = env.factory()(env.art)
|
||||
await env.ready()
|
||||
const worker = env.workers[0]
|
||||
const listeners = trackListeners(worker)
|
||||
return {
|
||||
...env,
|
||||
instance,
|
||||
worker,
|
||||
timers,
|
||||
errors,
|
||||
requests: listeners,
|
||||
timeout() {
|
||||
const entry = timers.entries().next().value
|
||||
assert(entry, 'fire an actually registered one-shot timeout')
|
||||
timers.delete(entry[0])
|
||||
entry[1]()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('JASSUB repeated direct destroy returns synchronously and terminates once', async () => {
|
||||
const env = environment()
|
||||
const listeners = trackListeners(env.art.video)
|
||||
const { instance } = env.factory()(env.art)
|
||||
await env.ready()
|
||||
assert(listeners.size() > 0)
|
||||
assert.equal(instance.destroy(), undefined)
|
||||
assert.equal(instance.destroy(), undefined)
|
||||
env.emit('destroy')
|
||||
await env.flush()
|
||||
assert.equal(env.workers[0].terminated, 1)
|
||||
assert.equal(listeners.size(), 0)
|
||||
assert.deepEqual(env.parent.children, [env.art.video])
|
||||
})
|
||||
|
||||
test('JASSUB destroy after ready cancels queued resize and track messages and settles sendMessage', async () => {
|
||||
const env = environment()
|
||||
const { instance } = env.factory()(env.art)
|
||||
await env.ready()
|
||||
const worker = env.workers[0]
|
||||
const sent = worker.messages.length
|
||||
assert.equal(instance.resize(640, 360, 4, 5, false), undefined)
|
||||
assert.equal(instance.setTrack('[Script Info]'), undefined)
|
||||
const pending = instance.sendMessage('queued-before-destroy')
|
||||
assert.equal(instance.destroy(), undefined)
|
||||
assert.equal(await pending, undefined)
|
||||
await env.flush()
|
||||
assert.equal(worker.messages.length, sent)
|
||||
assert.equal(worker.terminated, 1)
|
||||
})
|
||||
|
||||
test('JASSUB setVideo across parents destroys the actual owned container', async () => {
|
||||
const env = environment()
|
||||
const originalListeners = trackListeners(env.art.video)
|
||||
const { instance } = env.factory()(env.art)
|
||||
await env.ready()
|
||||
const parent = new env.parent.constructor()
|
||||
const video = parent.appendChild(new env.platform.Video())
|
||||
const listeners = trackListeners(video)
|
||||
instance.setVideo(video)
|
||||
assert.equal(originalListeners.size(), 0)
|
||||
assert(listeners.size() > 0)
|
||||
assert.equal(instance.destroy(), undefined)
|
||||
assert.equal(listeners.size(), 0)
|
||||
assert.equal(env.workers[0].terminated, 1)
|
||||
assert.deepEqual(env.parent.children, [env.art.video])
|
||||
assert.deepEqual(parent.children, [video])
|
||||
})
|
||||
|
||||
test('JASSUB Worker constructor SecurityError rolls back DOM and video listeners without replacing the error', async () => {
|
||||
const env = environment()
|
||||
const listeners = trackListeners(env.art.video)
|
||||
const failure = new DOMException('Controlled Worker construction failure', 'SecurityError')
|
||||
env.platform.setGlobal('Worker', class {
|
||||
constructor() { throw failure }
|
||||
})
|
||||
assert.throws(() => env.factory()(env.art), error => error === failure)
|
||||
await env.flush()
|
||||
assert.equal(listeners.size(), 0)
|
||||
assert.deepEqual(env.parent.children, [env.art.video])
|
||||
assert.equal(env.workers.length, 0)
|
||||
assert.equal(env.listeners.get('destroy')?.length || 0, 0)
|
||||
})
|
||||
|
||||
test('JASSUB destroy before ready prevents queued initialization and application messages from posting', async () => {
|
||||
const env = environment()
|
||||
const { instance } = env.factory()(env.art)
|
||||
const worker = env.workers[0]
|
||||
const afterTermination = []
|
||||
const post = worker.postMessage
|
||||
worker.postMessage = function (message) {
|
||||
if (this.terminated)
|
||||
afterTermination.push(message.target)
|
||||
return post.call(this, message)
|
||||
}
|
||||
instance.sendMessage('queued-before-destroy')
|
||||
instance.destroy()
|
||||
await env.flush()
|
||||
// A ready event already queued by a real Worker may still be delivered after termination.
|
||||
worker.onmessage?.({ data: { target: 'ready' } })
|
||||
await env.flush()
|
||||
assert.deepEqual(afterTermination, [])
|
||||
assert.equal(worker.messages.some(message => ['init', 'queued-before-destroy'].includes(message.target)), false)
|
||||
assert.equal(worker.terminated, 1)
|
||||
})
|
||||
|
||||
test('JASSUB stale queued video frame cannot drive or duplicate the replacement video loop', async () => {
|
||||
const env = environment()
|
||||
const clock = frames(env)
|
||||
const { instance } = env.factory()(env.art)
|
||||
await env.ready()
|
||||
const oldCallback = clock.take(env.art.video)
|
||||
const video = env.createVideo()
|
||||
video.currentTime = 9
|
||||
instance.setVideo(video)
|
||||
assert.equal(clock.count(video), 1)
|
||||
oldCallback(0, { mediaTime: 123, width: 640, height: 360 })
|
||||
await env.flush()
|
||||
assert.equal(env.workers[0].messages.some(message => message.target === 'demand' && message.time === 123), false)
|
||||
assert.equal(clock.count(video), 1)
|
||||
clock.take(video)(0, { mediaTime: 9, width: 640, height: 360 })
|
||||
await env.flush()
|
||||
assert.equal(env.workers[0].messages.filter(message => message.target === 'demand').at(-1).time, 9)
|
||||
assert.equal(clock.count(video), 1)
|
||||
instance.destroy()
|
||||
assert.equal(clock.count(video), 0)
|
||||
})
|
||||
|
||||
test('JASSUB fallback ratechange sends the current numeric playbackRate', async () => {
|
||||
const env = environment()
|
||||
const { instance } = env.factory({ onDemandRender: false })(env.art)
|
||||
await env.ready()
|
||||
env.art.video.playbackRate = 1.75
|
||||
env.art.video.dispatchEvent(new Event('ratechange'))
|
||||
await env.flush()
|
||||
const message = env.workers[0].messages.at(-1)
|
||||
assert.equal(message.target, 'video')
|
||||
assert.equal(message.rate, 1.75)
|
||||
instance.destroy()
|
||||
})
|
||||
|
||||
for (const method of ['getEvents', 'getStyles']) {
|
||||
for (const mode of ['success', 'timeout', 'worker-error']) {
|
||||
for (const throws of [false, true]) {
|
||||
test(`JASSUB ${method} ${mode} cleans before its single callback${throws ? ' even when that callback throws' : ''}`, async () => {
|
||||
const env = await queryEnvironment()
|
||||
const calls = []
|
||||
const cleanupAtCallback = []
|
||||
const callbackFailure = new Error('Controlled user callback failure')
|
||||
assert.equal(env.instance[method]((...args) => {
|
||||
calls.push(args)
|
||||
cleanupAtCallback.push([env.requests.size(), env.timers.size])
|
||||
if (throws)
|
||||
throw callbackFailure
|
||||
}), undefined)
|
||||
assert.equal(env.worker.messages.at(-1).target, method)
|
||||
const staleMessage = env.requests.callbacks('message')[0]
|
||||
const staleError = env.requests.callbacks('error')[0]
|
||||
const workerError = new env.platform.ErrorEvent('error', { error: new Error('Controlled Worker error') })
|
||||
const result = [{ sample: 1 }]
|
||||
const message = { data: { target: method, [method === 'getEvents' ? 'events' : 'styles']: result } }
|
||||
const complete = () => {
|
||||
if (mode === 'success')
|
||||
env.requests.emit('message', message)
|
||||
else if (mode === 'timeout')
|
||||
env.timeout()
|
||||
else
|
||||
env.requests.emit('error', workerError)
|
||||
}
|
||||
if (throws)
|
||||
assert.throws(complete, error => error === callbackFailure)
|
||||
else
|
||||
assert.doesNotThrow(complete)
|
||||
assert.equal(calls.length, 1)
|
||||
assert.deepEqual(cleanupAtCallback, [[0, 0]])
|
||||
assert.equal(calls[0][0], mode === 'success' ? null : mode === 'timeout' ? env.errors[0] : workerError)
|
||||
assert.equal(calls[0][1], mode === 'success' ? result : undefined)
|
||||
staleMessage(message)
|
||||
staleError(workerError)
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(env.requests.size(), 0)
|
||||
assert.equal(env.timers.size, 0)
|
||||
env.instance.destroy()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const throws of [false, true]) {
|
||||
test(`JASSUB destroy settles all pending queries after releasing resources${throws ? ' despite a throwing callback' : ''}`, async () => {
|
||||
const env = await queryEnvironment()
|
||||
const calls = []
|
||||
const callbackFailure = new Error('Controlled destroy callback failure')
|
||||
for (const method of ['getEvents', 'getStyles']) {
|
||||
env.instance[method]((error, result) => {
|
||||
calls.push({ method, error, result, listeners: env.requests.size(), timers: env.timers.size })
|
||||
if (throws && method === 'getEvents')
|
||||
throw callbackFailure
|
||||
})
|
||||
}
|
||||
const lateMessages = env.requests.callbacks('message')
|
||||
const lateErrors = env.requests.callbacks('error')
|
||||
assert(env.requests.size() > 0)
|
||||
assert.equal(env.timers.size, 2)
|
||||
if (throws)
|
||||
assert.throws(() => env.instance.destroy(), error => error === callbackFailure)
|
||||
else
|
||||
assert.equal(env.instance.destroy(), undefined)
|
||||
assert.equal(calls.length, 2)
|
||||
assert.deepEqual(calls.map(call => call.method), ['getEvents', 'getStyles'])
|
||||
for (const call of calls) {
|
||||
assert(call.error instanceof env.platform.Error)
|
||||
assert.match(call.error.message, /destroy/i)
|
||||
assert.equal(call.result, undefined)
|
||||
assert.equal(call.listeners, 0)
|
||||
assert.equal(call.timers, 0)
|
||||
}
|
||||
for (const callback of lateMessages) callback({ data: { target: 'getEvents', events: [] } })
|
||||
for (const callback of lateErrors) callback(new Error('Late Worker failure'))
|
||||
assert.equal(calls.length, 2)
|
||||
assert.equal(env.worker.terminated, 1)
|
||||
assert.equal(env.requests.size(), 0)
|
||||
assert.equal(env.timers.size, 0)
|
||||
assert.deepEqual(env.parent.children, [env.art.video])
|
||||
assert.equal(env.instance.destroy(), undefined)
|
||||
assert.equal(env.worker.terminated, 1)
|
||||
})
|
||||
}
|
||||
+2
-2
@@ -59,12 +59,12 @@ for (const implementation of implementations) {
|
||||
assert.equal(result.instance.resize(640, 360, 4, 5, false), undefined)
|
||||
assert.equal(result.instance.setVideo(option.video), undefined)
|
||||
assert.equal(result.instance.setTrack('[Script Info]'), undefined)
|
||||
assert.equal(result.instance.destroy(), undefined)
|
||||
await env.flush()
|
||||
assert.equal(env.workers[0].terminated, 1)
|
||||
const resize = env.workers[0].messages.find(message => message.target === 'canvas')
|
||||
assert.equal(resize.width, 640)
|
||||
assert.equal(resize.height, 360)
|
||||
assert.equal(result.instance.destroy(), undefined)
|
||||
assert.equal(env.workers[0].terminated, 1)
|
||||
})
|
||||
test(`JASSUB ${implementation.name}: omitted options retain worker and font defaults`, async () => {
|
||||
const env = jassubEnvironment(implementation)
|
||||
|
||||
@@ -41,7 +41,7 @@ instance.addFont(new Uint8Array([0]))
|
||||
instance.setDefaultFont('fallback')
|
||||
instance.runBenchmark()
|
||||
instance.getEvents((failure, events) => {
|
||||
const caught: Error | ErrorEvent | null = failure
|
||||
const caught: Error | Event | null = failure
|
||||
const values: AssEvent[] | undefined = events
|
||||
if (events) {
|
||||
for (const event of events) {
|
||||
|
||||
Reference in new issue
Block a user