mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-18 02:21:20 -08:00
refactor(mask): [PKG-MASK-03] isolate model runs and release stale work
This commit is contained in:
1 parent
6c989deccd
commit
aba125262a
23 files changed
+1835
-379
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user