mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-14 00:06:17 -08:00
240 lines
9.1 KiB
JavaScript
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
|
|
}
|