refactor(mask): [PKG-MASK-03] isolate model runs and release stale work

This commit is contained in:
Harvey Zhao committed 2026-09-13 23:46:15 +08:00
1 parent 6c989deccd
commit aba125262a
23 files changed
+1835 -379

No files matched your search

+5
View File
@@ -2,6 +2,11 @@
Use the pinned Node/Yarn toolchain from `../refactor/toolchain-setup.md`.
`yarn test:danmuku-mask` runs candidate run cancellation/resource ownership plus
frozen historical defects and public contracts. Candidate tests also run in
`test:unit`; their controlled SDK, RAF and canvas hosts do not prove native model
inference, GPU disposal or browser mask geometry. See the Mask architecture map.
`yarn test:mediabunny` runs historical lifecycle observations, candidate load cancellation,
and real SDK input parsing/track contracts; all are in `test:unit`. Candidate failures
can be reproduced against the frozen main with `ARTPLAYER_MB_BASELINE=1`. This does not
+409
View File
@@ -0,0 +1,409 @@
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)
})
+144
View File
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict'
import vm from 'node:vm'
import { build } from 'esbuild'
export const flush = () => new Promise(resolve => setImmediate(resolve))
export async function maskCandidate() {
const result = await build({ entryPoints: ['packages/artplayer-plugin-danmuku-mask/src/index.js'], bundle: true, write: false, format: 'cjs', platform: 'browser', external: ['@tensorflow-models/body-segmentation', '@tensorflow/tfjs-core', '@tensorflow/tfjs-backend-webgl', '@tensorflow/tfjs-backend-cpu'] })
return result.outputFiles[0].text
}
export function environment(code, settings = {}) {
const env = { settings, frames: new Map(), listeners: new Map(), logs: [], writes: [], backends: [], created: [], canvases: [], inferences: [], masks: [], draws: [], gates: {}, cancelled: [], inflight: 0, maximum: 0 }
let nextFrame = settings.firstFrame ?? 1
async function phase(name, value) {
if (settings.reject?.[name])
throw settings.reject[name]
if (settings.hold?.includes(name)) {
let resolve
let reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
;(env.gates[name] ||= []).push({ resolve, reject })
await promise
}
return value
}
const style = { maskMode: 'luminance' }
Object.defineProperty(style, 'maskImage', { get: () => env.writes.at(-1) ?? 'url(prior-mask)', set: value => env.writes.push(value) })
env.video = { paused: settings.paused ?? false, ended: false, videoWidth: 2, videoHeight: 1 }
env.style = style
const probe = {
SupportedModels: { MediaPipeSelfieSegmentation: 'selfie' },
async setBackend(name) {
env.backends.push(name)
return phase(name, settings.backendResult?.[name] ?? true)
},
async createSegmenter(model, config) {
const segmenter = {
model,
config,
disposed: 0,
disposedWhileBusy: false,
async dispose() {
this.disposed++
this.disposedWhileBusy = env.inflight > 0
await phase('dispose')
},
async segmentPeople(video) {
env.inferences.push({ segmenter, video })
env.inflight++
env.maximum = Math.max(env.maximum, env.inflight)
settings.onSegment?.(env)
try {
return await phase('segment', settings.empty ? [] : [{}])
}
finally { env.inflight-- }
},
}
env.created.push(segmenter)
return phase('init', segmenter)
},
async toBinaryMask(...args) {
env.masks.push(args)
return phase('mask', {})
},
async drawMask(...args) {
env.draws.push(args)
await phase('draw')
},
}
const module = { exports: {} }
const context = {
module,
exports: module.exports,
require(name) {
assert(['@tensorflow-models/body-segmentation', '@tensorflow/tfjs-core', '@tensorflow/tfjs-backend-webgl', '@tensorflow/tfjs-backend-cpu'].includes(name))
return probe
},
console: { log() {}, warn: (...args) => env.logs.push(['warn', ...args]), error: (...args) => env.logs.push(['error', ...args]) },
document: { createElement(tag) {
assert.equal(tag, 'canvas')
if (settings.canvasError)
throw settings.canvasError
const canvas = {
width: 300,
height: 150,
pixels: null,
getContext: () => settings.noContext
? null
: {
getImageData() {
if (settings.readError)
throw settings.readError
return { data: new Uint8ClampedArray([255, 255, 255, 255, 250, 255, 255, 255]) }
},
putImageData(image) { canvas.pixels = [...image.data] },
},
toDataURL: () => 'data:image/png;base64,candidate-mask',
}
env.canvases.push(canvas)
return canvas
} },
requestAnimationFrame(callback) {
const id = nextFrame++
env.frames.set(id, callback)
return id
},
cancelAnimationFrame(id) {
env.cancelled.push(id)
env.frames.delete(id)
},
}
vm.runInNewContext(code, context)
const factory = module.exports.default
env.factory = factory
env.art = {
isDestroy: settings.destroyed ?? false,
template: { $video: settings.noVideo ? null : env.video, $danmuku: settings.noLayer ? null : { style } },
on(name, callback) {
if (!env.listeners.has(name))
env.listeners.set(name, new Set())
env.listeners.get(name).add(callback)
if (settings.subscriptionError === name)
throw settings.subscriptionFailure
},
off(name, callback) { env.listeners.get(name)?.delete(callback) },
}
settings.onEnvironment?.(env)
env.result = factory(settings.option)(env.art)
env.emit = name => [...(env.listeners.get(name) || [])].forEach(callback => callback())
env.destroy = () => {
env.art.isDestroy = true
env.emit('destroy')
}
env.frame = async () => {
const frames = [...env.frames.values()]
env.frames.clear()
for (const callback of frames) callback()
await flush()
}
return env
}