mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
410 lines
15 KiB
JavaScript
410 lines
15 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
// eslint-disable-next-line test/no-import-node-test -- Candidate lifecycle probes share the historical Node runner.
|
|
import test from 'node:test'
|
|
import { environment, flush, maskCandidate } from './helpers/danmuku-mask-candidate.js'
|
|
|
|
const code = await maskCandidate()
|
|
const create = settings => environment(code, settings)
|
|
|
|
test('Mask candidate instances sharing SDK and RAF retain independent models, layers and cancellation', async () => {
|
|
const env = create({ hold: ['segment'] })
|
|
const listeners = new Map()
|
|
const style = { maskImage: 'url(second-prior)' }
|
|
const secondArt = {
|
|
isDestroy: false,
|
|
template: { $video: { ...env.video }, $danmuku: { style } },
|
|
on(name, callback) { listeners.set(name, callback) },
|
|
off(name, callback) {
|
|
if (listeners.get(name) === callback)
|
|
listeners.delete(name)
|
|
},
|
|
}
|
|
const second = env.factory()(secondArt)
|
|
await Promise.all([env.result.start(), second.start()])
|
|
await flush()
|
|
assert.equal(env.created.length, 2)
|
|
assert.equal(env.inferences.length, 2)
|
|
assert.notEqual(env.inferences[0].video, env.inferences[1].video)
|
|
env.result.stop()
|
|
env.gates.segment[0].resolve()
|
|
await flush()
|
|
assert.equal(env.created[0].disposed, 1)
|
|
assert.equal(env.created[1].disposed, 0)
|
|
assert.equal(style.maskImage, 'url(second-prior)')
|
|
env.gates.segment[1].resolve()
|
|
await flush()
|
|
assert.equal(env.style.maskImage, 'none')
|
|
assert.equal(style.maskImage, 'url(data:image/png;base64,candidate-mask)')
|
|
assert.equal(env.frames.size, 1)
|
|
env.destroy()
|
|
await flush()
|
|
assert.equal(env.frames.size, 1, 'Destroying the first player must preserve the other RAF')
|
|
secondArt.isDestroy = true
|
|
listeners.get('destroy')()
|
|
await flush()
|
|
assert.equal(env.frames.size, 0)
|
|
assert.equal(listeners.size, 0)
|
|
assert.equal(style.maskImage, 'none')
|
|
assert.equal(env.created[1].disposed, 1)
|
|
})
|
|
|
|
async function ended(env) {
|
|
env.destroy()
|
|
await flush()
|
|
assert.equal(env.frames.size, 0)
|
|
assert.equal([...env.listeners.values()].reduce((sum, listeners) => sum + listeners.size, 0), 0)
|
|
}
|
|
|
|
test('Mask candidate retains synchronous registrar, optional options, exact public methods and Promise<void> start', async () => {
|
|
const env = create()
|
|
assert.deepEqual(Object.keys(env.result), ['name', 'start', 'stop'])
|
|
assert.equal(env.result.name, 'artplayerPluginDanmukuMask')
|
|
assert.equal(env.result.start.name, 'startSegmentation')
|
|
assert.equal(env.result.stop.name, 'stopSegmentation')
|
|
assert.equal(await env.result.start(), undefined)
|
|
await flush()
|
|
assert.equal(env.created.length, 1)
|
|
assert.equal(env.inferences.length, 1)
|
|
assert.equal(env.frames.size, 1)
|
|
assert.equal(env.style.maskImage, 'url(data:image/png;base64,candidate-mask)')
|
|
assert.deepEqual(env.canvases[0].pixels, [255, 255, 255, 0, 250, 255, 255, 255])
|
|
assert.equal(env.result.stop(), undefined)
|
|
await ended(env)
|
|
assert.equal(env.created[0].disposed, 1)
|
|
})
|
|
|
|
test('Mask candidate preserves OR defaults, false smooth setting, snapshot and exact SDK interpretation', async () => {
|
|
const option = { modelSelection: 0, smoothSegmentation: false, opacity: 0, maskBlurAmount: 0, foregroundThreshold: 0, solutionPath: '/models' }
|
|
const env = create({ option })
|
|
option.solutionPath = '/changed'
|
|
await env.result.start()
|
|
await flush()
|
|
assert.deepEqual(env.backends, ['webgl'])
|
|
assert.deepEqual(JSON.parse(JSON.stringify(env.created[0].config)), { runtime: 'mediapipe', modelType: 'general', solutionPath: '/models', modelSelection: 1, smoothSegmentation: false, minDetectionConfidence: 0.5, minTrackingConfidence: 0.5, selfieMode: false })
|
|
assert.equal(env.masks[0][4], 0.5)
|
|
assert.equal(env.draws[0][3], 1)
|
|
assert.equal(env.draws[0][4], 3)
|
|
await ended(env)
|
|
})
|
|
|
|
for (const action of ['stop', 'destroy']) {
|
|
for (const phase of ['webgl', 'init', 'segment', 'mask', 'draw']) {
|
|
test(`Mask candidate ${action} during ${phase} prevents late output and RAF revival`, async () => {
|
|
const env = create({ hold: [phase] })
|
|
const starting = env.result.start()
|
|
await flush()
|
|
assert.equal(env.gates[phase].length, 1)
|
|
if (action === 'stop')
|
|
env.result.stop()
|
|
else env.destroy()
|
|
assert.equal(await starting, undefined, 'Cancellation settles the public start before SDK completion')
|
|
assert.equal(env.style.maskImage, 'none')
|
|
for (const model of env.created)
|
|
assert.equal(model.disposed, 0, 'Do not dispose a model while its initialization or frame work is pending')
|
|
assert.equal(env.frames.size, 0)
|
|
env.gates[phase][0].resolve()
|
|
await flush()
|
|
assert.equal(env.frames.size, 0)
|
|
assert(env.writes.every(value => value === 'none'))
|
|
assert.equal(env.created.length, phase === 'webgl' ? 0 : 1)
|
|
for (const model of env.created) {
|
|
assert.equal(model.disposed, 1)
|
|
assert.equal(model.disposedWhileBusy, false)
|
|
}
|
|
for (const canvas of env.canvases) assert.deepEqual([canvas.width, canvas.height], [0, 0])
|
|
await ended(env)
|
|
})
|
|
}
|
|
}
|
|
|
|
test('Mask candidate concurrent and repeated starts keep one initialization and one inference chain', async () => {
|
|
const env = create({ hold: ['init'] })
|
|
const first = env.result.start()
|
|
const second = env.result.start()
|
|
await flush()
|
|
assert.equal(env.created.length, 1)
|
|
env.gates.init[0].resolve()
|
|
await Promise.all([first, second])
|
|
await flush()
|
|
await env.result.start()
|
|
assert.equal(env.created.length, 1)
|
|
assert.equal(env.inferences.length, 1)
|
|
assert.equal(env.frames.size, 1)
|
|
await env.frame()
|
|
assert.equal(env.inferences.length, 2)
|
|
assert.equal(env.maximum, 1)
|
|
await ended(env)
|
|
})
|
|
|
|
test('Mask candidate restart waits for old inference and model disposal without blocking stop settlement', async () => {
|
|
const env = create({ hold: ['segment', 'dispose'] })
|
|
await env.result.start()
|
|
await flush()
|
|
env.result.stop()
|
|
const next = env.result.start()
|
|
await flush()
|
|
assert.equal(env.created.length, 1)
|
|
env.gates.segment[0].resolve()
|
|
await flush()
|
|
assert.equal(env.gates.dispose.length, 1)
|
|
assert.equal(env.created.length, 1)
|
|
env.settings.hold = []
|
|
env.gates.dispose[0].resolve()
|
|
await next
|
|
await flush()
|
|
assert.equal(env.created.length, 2)
|
|
assert.equal(env.maximum, 1)
|
|
assert.equal(env.created[0].disposed, 1)
|
|
assert.equal(env.created[0].disposedWhileBusy, false)
|
|
assert.equal(env.frames.size, 1)
|
|
await ended(env)
|
|
})
|
|
|
|
test('Mask candidate repeated stops cannot bypass a pending disposal when restarting', async () => {
|
|
const env = create({ paused: true, hold: ['dispose'] })
|
|
await env.result.start()
|
|
env.result.stop()
|
|
env.result.stop()
|
|
const next = env.result.start()
|
|
await flush()
|
|
assert.equal(env.created.length, 1)
|
|
assert.equal(env.gates.dispose.length, 1)
|
|
assert.deepEqual([env.canvases[0].width, env.canvases[0].height], [0, 0], 'A pending model disposal must not retain an idle private canvas bitmap')
|
|
env.settings.hold = []
|
|
env.gates.dispose[0].resolve()
|
|
await next
|
|
assert.equal(env.created.length, 2)
|
|
await ended(env)
|
|
})
|
|
|
|
test('Mask candidate synchronous SDK reentry does not overlap old and new runs', async () => {
|
|
let restarted
|
|
const env = create({ hold: ['segment'], onSegment(env) {
|
|
env.settings.onSegment = null
|
|
env.result.stop()
|
|
restarted = env.result.start()
|
|
} })
|
|
await env.result.start()
|
|
await flush()
|
|
assert.equal(env.created.length, 1)
|
|
env.settings.hold = []
|
|
env.gates.segment[0].resolve()
|
|
await restarted
|
|
await flush()
|
|
assert.equal(env.maximum, 1)
|
|
assert.equal(env.created.length, 2)
|
|
await ended(env)
|
|
})
|
|
|
|
test('Mask candidate cancels RAF id zero, detaches exact listeners and ignores start after destroy', async () => {
|
|
const env = create({ firstFrame: 0, paused: true })
|
|
await env.result.start()
|
|
assert.deepEqual([...env.frames.keys()], [0])
|
|
await ended(env)
|
|
assert.deepEqual(env.cancelled, [0])
|
|
env.emit('ready')
|
|
await env.result.start()
|
|
assert.equal(env.created.length, 1)
|
|
assert.equal(env.frames.size, 0)
|
|
})
|
|
|
|
test('Mask candidate no readable dimensions waits without invoking inference', async () => {
|
|
const env = create()
|
|
env.video.videoWidth = 0
|
|
await env.result.start()
|
|
await flush()
|
|
assert.equal(env.inferences.length, 0)
|
|
assert.equal(env.frames.size, 1)
|
|
env.video.videoWidth = 2
|
|
await env.frame()
|
|
assert.equal(env.inferences.length, 1)
|
|
await ended(env)
|
|
})
|
|
|
|
test('Mask candidate backend false keeps old interpretation while rejection tries CPU', async () => {
|
|
const fulfilled = create({ backendResult: { webgl: false } })
|
|
await fulfilled.result.start()
|
|
assert.deepEqual(fulfilled.backends, ['webgl'])
|
|
await ended(fulfilled)
|
|
const rejected = create({ reject: { webgl: new Error('no WebGL') }, backendResult: { cpu: false } })
|
|
await rejected.result.start()
|
|
assert.deepEqual(rejected.backends, ['webgl', 'cpu'])
|
|
assert.equal(rejected.created.length, 1)
|
|
await ended(rejected)
|
|
})
|
|
|
|
test('Mask candidate rejects explicit backend failure and observes automatic ready failure', async () => {
|
|
const error = new Error('no CPU')
|
|
const env = create({ reject: { webgl: new Error('no WebGL'), cpu: error } })
|
|
await assert.rejects(env.result.start(), failure => failure === error)
|
|
env.emit('ready')
|
|
await flush()
|
|
assert(env.logs.some(entry => entry[1] === 'Failed to start danmuku mask:' && entry[2] === error))
|
|
assert.equal(env.frames.size, 0)
|
|
await ended(env)
|
|
})
|
|
|
|
test('Mask candidate model rejection retains resolved start and logged error without idle retry loop', async () => {
|
|
const error = new Error('bad model')
|
|
const env = create({ reject: { init: error } })
|
|
assert.equal(await env.result.start(), undefined)
|
|
assert.deepEqual(env.logs, [['error', 'Error initializing segmenter:', error]])
|
|
assert.equal(env.frames.size, 0)
|
|
env.settings.reject = {}
|
|
await env.result.start()
|
|
await flush()
|
|
assert.equal(env.frames.size, 1)
|
|
await ended(env)
|
|
})
|
|
|
|
for (const setting of ['noLayer', 'noVideo', 'noContext', 'canvasError']) {
|
|
test(`Mask candidate ${setting} initialization fails without retaining resources`, async () => {
|
|
const env = create({ [setting]: setting === 'canvasError' ? new Error('canvas allocation') : true })
|
|
await assert.rejects(env.result.start())
|
|
await flush()
|
|
assert.equal(env.frames.size, 0)
|
|
for (const model of env.created) assert.equal(model.disposed, 1)
|
|
for (const canvas of env.canvases) assert.deepEqual([canvas.width, canvas.height], [0, 0])
|
|
assert.doesNotThrow(() => env.result.stop())
|
|
await ended(env)
|
|
})
|
|
}
|
|
|
|
test('Mask candidate inference and canvas-read errors keep the old active retry outlet', async () => {
|
|
for (const settings of [{ reject: { segment: new Error('inference') } }, { readError: new Error('taint') }]) {
|
|
const env = create(settings)
|
|
await env.result.start()
|
|
await flush()
|
|
assert.equal(env.logs[0][1], 'Error in segmentBody:')
|
|
assert.equal(env.style.maskImage, 'url(prior-mask)')
|
|
await env.frame()
|
|
assert.equal(env.logs.length, 2)
|
|
assert.equal(env.frames.size, 1)
|
|
await ended(env)
|
|
}
|
|
})
|
|
|
|
test('Mask candidate late SDK rejection after cancellation remains observed and silent', async () => {
|
|
const env = create({ hold: ['segment'] })
|
|
await env.result.start()
|
|
await flush()
|
|
env.destroy()
|
|
env.gates.segment[0].reject(new Error('late inference'))
|
|
await flush()
|
|
assert.equal(env.logs.length, 0)
|
|
assert.equal(env.frames.size, 0)
|
|
assert.equal(env.created[0].disposed, 1)
|
|
})
|
|
|
|
test('Mask candidate already-destroyed registration does not install listeners or allocate resources', async () => {
|
|
const env = create({ destroyed: true })
|
|
await env.result.start()
|
|
assert.equal(env.listeners.size, 0)
|
|
assert.equal(env.created.length, 0)
|
|
})
|
|
|
|
test('Mask candidate subscription rollback preserves original failure and prior host styles', () => {
|
|
for (const event of ['ready', 'destroy']) {
|
|
let env
|
|
const failure = new Error('subscription failed')
|
|
assert.throws(() => create({
|
|
subscriptionError: event,
|
|
subscriptionFailure: failure,
|
|
onEnvironment(value) { env = value },
|
|
}), error => error === failure)
|
|
assert.equal([...env.listeners.values()].reduce((sum, callbacks) => sum + callbacks.size, 0), 0)
|
|
assert.equal(env.style.maskImage, 'url(prior-mask)')
|
|
assert.equal(env.created.length, 0)
|
|
}
|
|
})
|
|
|
|
test('Mask candidate immediate stop skips backend/model startup and public start settles', async () => {
|
|
const env = create()
|
|
const pending = env.result.start()
|
|
env.result.stop()
|
|
await pending
|
|
await flush()
|
|
assert.deepEqual(env.backends, [])
|
|
assert.equal(env.created.length, 0)
|
|
await ended(env)
|
|
})
|
|
|
|
test('Mask candidate cancelled backend/model rejection is observed without retry or late log', async () => {
|
|
for (const phase of ['webgl', 'init']) {
|
|
const env = create({ hold: [phase] })
|
|
const pending = env.result.start()
|
|
await flush()
|
|
env.result.stop()
|
|
await pending
|
|
env.gates[phase][0].reject(new Error('late SDK rejection'))
|
|
await flush()
|
|
assert.equal(env.logs.length, 0)
|
|
assert.equal(env.frames.size, 0)
|
|
assert.deepEqual(env.backends, ['webgl'])
|
|
await ended(env)
|
|
}
|
|
})
|
|
|
|
test('Mask candidate disposal rejection is observed, clears canvas and allows independent restart', async () => {
|
|
const failure = new Error('dispose failed')
|
|
const env = create({ reject: { dispose: failure } })
|
|
await env.result.start()
|
|
await flush()
|
|
env.result.stop()
|
|
await flush()
|
|
assert.deepEqual(env.logs, [['warn', 'Failed to dispose danmuku mask segmenter:', failure]])
|
|
assert.deepEqual([env.canvases[0].width, env.canvases[0].height], [0, 0])
|
|
env.settings.reject = {}
|
|
await env.result.start()
|
|
assert.equal(env.created.length, 2)
|
|
await ended(env)
|
|
})
|
|
|
|
test('Mask candidate empty segmentation retains existing mask and a single active loop', async () => {
|
|
const env = create({ empty: true })
|
|
await env.result.start()
|
|
await flush()
|
|
assert.equal(env.style.maskImage, 'url(prior-mask)')
|
|
assert.equal(env.masks.length, 0)
|
|
assert.equal(env.draws.length, 0)
|
|
assert.equal(env.frames.size, 1)
|
|
await env.frame()
|
|
assert.equal(env.maximum, 1)
|
|
assert.equal(env.frames.size, 1)
|
|
await ended(env)
|
|
})
|
|
|
|
test('Mask candidate cancelled inference cannot overwrite a subsequent external mask write', async () => {
|
|
const env = create({ hold: ['segment'] })
|
|
await env.result.start()
|
|
await flush()
|
|
env.result.stop()
|
|
env.style.maskImage = 'url(user-mask)'
|
|
env.gates.segment[0].resolve()
|
|
await flush()
|
|
assert.equal(env.style.maskImage, 'url(user-mask)')
|
|
assert.equal(env.frames.size, 0)
|
|
assert.equal(env.created[0].disposed, 1)
|
|
await ended(env)
|
|
})
|
|
|
|
test('Mask candidate retains template capture before evaluating option getters', async () => {
|
|
let captured
|
|
const option = {
|
|
get solutionPath() {
|
|
captured.art.template.$video = { paused: true, ended: false, videoWidth: 0, videoHeight: 0 }
|
|
captured.art.template.$danmuku = { style: {} }
|
|
return '/models'
|
|
},
|
|
}
|
|
const env = create({
|
|
option,
|
|
onEnvironment(value) { captured = value },
|
|
})
|
|
await env.result.start()
|
|
await flush()
|
|
assert.equal(env.inferences[0].video, env.video)
|
|
assert.equal(env.style.maskImage, 'url(data:image/png;base64,candidate-mask)')
|
|
await ended(env)
|
|
})
|