Files
ArtPlayer/test/helpers/danmuku-mask.js

240 lines
9.1 KiB
JavaScript

import assert from 'node:assert/strict'
import vm from 'node:vm'
import { transform } from 'esbuild'
import ts from 'typescript'
import { verifyDanmukuMaskContract } from '../../refactor/scripts/danmuku-mask-contract.mjs'
import { hash, readMember } from '../../refactor/scripts/releases.mjs'
const sourceFile = 'packages/artplayer-plugin-danmuku-mask/src/index.js'
let historical
export function danmukuMaskHistorical() {
historical ||= loadHistorical()
return historical
}
function evaluate(code, additions = {}) {
const module = { exports: {} }
const context = {
module,
exports: module.exports,
console: { log() {}, warn() {}, error() {} },
fetch() { throw new Error('Model network access is forbidden in historical probes') },
...additions,
}
context.global = context
context.self = context
vm.runInNewContext(code, context, { timeout: 5000 })
return module.exports.default || module.exports
}
function instrument(code, factory, label) {
const text = factory.toString()
const start = code.indexOf(text)
assert(start >= 0 && !code.includes(text, start + 1), 'Exported factory must map uniquely to the evaluated bytes')
const prefix = 'const factory = ('
const parsed = ts.createSourceFile('factory.js', `${prefix}${text});`, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
assert.equal(parsed.parseDiagnostics.length, 0)
const replacements = []
const counts = { setBackend: 0, createSegmenter: 0, toBinaryMask: 0, drawMask: 0 }
const configs = new Set()
function findConfigs(node) {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isObjectLiteralExpression(node.initializer)) {
const properties = node.initializer.properties.map(property => property.name?.getText(parsed))
if (properties.includes('runtime') && properties.includes('modelType'))
configs.add(node.name.text)
}
ts.forEachChild(node, findConfigs)
}
findConfigs(parsed)
function visit(node) {
if (ts.isCallExpression(node)) {
const args = node.arguments
const callee = node.expression.getText(parsed)
let target
if (args.length === 1 && ts.isStringLiteral(args[0]) && ['webgl', 'cpu'].includes(args[0].text))
target = 'setBackend'
else if (callee === 'createSegmenter' || callee.endsWith('.createSegmenter') || callee.includes('is not a supported model name.') || (args.length === 2 && ts.isIdentifier(args[1]) && configs.has(args[1].text)))
target = 'createSegmenter'
else if (callee === 'toBinaryMask' || callee.endsWith('.toBinaryMask') || callee.includes('mask.toImageData') || (args.length === 5 && args[3].getText(parsed).endsWith('.drawContour') && args[4].getText(parsed).endsWith('.foregroundThreshold')))
target = 'toBinaryMask'
else if (args.length === 5 && args[3].getText(parsed).endsWith('.opacity') && args[4].getText(parsed).endsWith('.maskBlurAmount'))
target = 'drawMask'
if (target) {
counts[target]++
replacements.push({ start: start + node.expression.getStart(parsed) - prefix.length, end: start + node.expression.end - prefix.length, target, original: callee })
// Inline SDK bodies are replaced as one expression, never traversed as owned lifecycle code.
return
}
}
ts.forEachChild(node, visit)
}
visit(parsed)
assert.deepEqual(counts, { setBackend: 2, createSegmenter: 1, toBinaryMask: 1, drawMask: 1 }, label)
let result = code
for (const replacement of [...replacements].sort((a, b) => b.start - a.start))
result = `${result.slice(0, replacement.start)}globalThis.__maskProbe.${replacement.target}${result.slice(replacement.end)}`
return { code: result, counts, replacements: replacements.map(({ start, end, target, original }) => ({ start, end, target, originalSha256: hash(original) })) }
}
async function loadHistorical() {
const contract = await verifyDanmukuMaskContract()
const implementations = []
const source = contract.sources.get(sourceFile)
const sourceCode = (await transform(source, { format: 'cjs', target: 'es2020' })).code
implementations.push({ name: 'frozen-source', code: sourceCode, inputSha256: hash(source), transformedSha256: hash(sourceCode), source: true, replacements: [] })
for (const release of [contract.baseline.release, ...contract.baseline.previous]) {
const archive = contract.archives.get(release.version)
for (const entry of ['main', 'legacy', 'module']) {
const member = `package/${release.manifest[entry].replace(/^\.\//u, '')}`
const input = readMember(archive, member).toString()
const code = entry === 'module' ? (await transform(input, { format: 'cjs', target: 'es2020' })).code : input
const modified = instrument(code, evaluate(code), `${release.version}/${entry}`)
implementations.push({ name: `published-${release.version}-${entry}`, archiveSha256: release.sha256, member, inputSha256: hash(input), transformedSha256: hash(code), probeSha256: hash(modified.code), ...modified })
}
}
return implementations
}
export const flushMask = () => new Promise(resolve => setImmediate(resolve))
function deferred() {
let resolve
let reject
const promise = new Promise((accept, fail) => {
resolve = accept
reject = fail
})
return { promise, resolve, reject }
}
export function danmukuMaskEnvironment(implementation, settings = {}) {
const env = {
frames: new Map(),
listeners: new Map(),
logs: [],
backends: [],
created: [],
inferences: [],
masks: [],
draws: [],
initGates: [],
segmentGates: [],
writes: [],
cancelled: [],
settings,
}
let nextFrame = settings.firstFrame ?? 1
const style = { maskMode: 'luminance', maskImage: 'url(prior-mask)' }
Object.defineProperty(style, 'maskImage', {
configurable: true,
enumerable: true,
get: () => env.writes.at(-1) ?? 'url(prior-mask)',
set: value => env.writes.push(value),
})
const context = {
getImageData() {
if (settings.readError)
throw settings.readError
return { data: new Uint8ClampedArray([255, 255, 255, 255]) }
},
putImageData() {},
}
env.canvas = {
width: 0,
height: 0,
getContext: () => settings.noContext ? null : context,
toDataURL: () => 'data:image/png;base64,historical-mask',
}
env.video = settings.noVideo ? undefined : { paused: settings.paused ?? false, ended: false, videoWidth: 2, videoHeight: 1 }
env.style = style
env.art = {
isDestroy: false,
template: { $video: env.video, $danmuku: settings.noDanmuku ? undefined : { style } },
on(name, callback) {
if (!env.listeners.has(name))
env.listeners.set(name, new Set())
env.listeners.get(name).add(callback)
},
off(name, callback) { env.listeners.get(name)?.delete(callback) },
}
const probe = {
SupportedModels: { MediaPipeSelfieSegmentation: 'selfie' },
async setBackend(name) {
env.backends.push(name)
if (settings.backendError?.[name])
throw settings.backendError[name]
return settings.backendResult?.[name] ?? true
},
async createSegmenter(model, config) {
const instance = {
model,
config,
disposed: 0,
dispose() { instance.disposed++ },
async segmentPeople(video) {
env.inferences.push({ instance, video })
if (settings.segmentError)
throw settings.segmentError
if (settings.pendingSegment) {
const gate = deferred()
env.segmentGates.push(gate)
return gate.promise
}
return settings.empty ? [] : [{}]
},
}
env.created.push(instance)
if (settings.initError)
throw settings.initError
if (settings.pendingInit) {
const gate = deferred()
env.initGates.push(gate)
await gate.promise
}
return instance
},
async toBinaryMask(...args) {
env.masks.push(args)
return {}
},
async drawMask(...args) { env.draws.push(args) },
}
const factory = evaluate(implementation.code, {
__maskProbe: probe,
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
return env.canvas
} },
requestAnimationFrame(callback) {
const id = nextFrame++
env.frames.set(id, callback)
return id
},
cancelAnimationFrame(id) {
env.cancelled.push(id)
env.frames.delete(id)
},
})
env.result = factory(settings.option)(env.art)
env.emit = (name) => {
for (const callback of [...(env.listeners.get(name) || [])]) 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) await callback()
await flushMask()
}
return env
}