mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-29 19:41:25 -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
@@ -0,0 +1,14 @@
|
||||
export default function maskConfig(option) {
|
||||
return {
|
||||
solutionPath: option.solutionPath || 'https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation',
|
||||
modelSelection: option.modelSelection || 1,
|
||||
smoothSegmentation: option.smoothSegmentation !== undefined ? option.smoothSegmentation : true,
|
||||
minDetectionConfidence: option.minDetectionConfidence || 0.5,
|
||||
minTrackingConfidence: option.minTrackingConfidence || 0.5,
|
||||
selfieMode: option.selfieMode || false,
|
||||
drawContour: option.drawContour || false,
|
||||
foregroundThreshold: option.foregroundThreshold || 0.5,
|
||||
opacity: option.opacity || 1,
|
||||
maskBlurAmount: option.maskBlurAmount || 3,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { createOutput, releaseOutput, renderMask } from './output'
|
||||
import { loadSegmenter, releaseSegmenter } from './sdk'
|
||||
|
||||
export default class MaskController {
|
||||
constructor(art, config, { $video, $danmuku }) {
|
||||
this.art = art
|
||||
this.config = config
|
||||
this.video = $video
|
||||
this.layer = $danmuku
|
||||
this.closed = art.isDestroy
|
||||
this.run = null
|
||||
this.tail = Promise.resolve()
|
||||
this.start = this.start.bind(this)
|
||||
this.stop = this.stop.bind(this)
|
||||
this.destroy = this.destroy.bind(this)
|
||||
this.ready = () => {
|
||||
this.start().catch(error => console.error('Failed to start danmuku mask:', error))
|
||||
}
|
||||
if (!this.closed) {
|
||||
try {
|
||||
art.on('destroy', this.destroy)
|
||||
if (!this.closed)
|
||||
art.on('ready', this.ready)
|
||||
}
|
||||
catch (error) {
|
||||
this.closed = true
|
||||
this.halt()
|
||||
try {
|
||||
art.off('ready', this.ready)
|
||||
}
|
||||
catch {}
|
||||
try {
|
||||
art.off('destroy', this.destroy)
|
||||
}
|
||||
catch {}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
active(run) {
|
||||
return run === this.run && run.running && !this.closed && !this.art.isDestroy
|
||||
}
|
||||
|
||||
release(run) {
|
||||
if (run.releasing)
|
||||
return run.releasing
|
||||
const segmenter = run.segmenter
|
||||
run.segmenter = null
|
||||
const output = run.output
|
||||
run.output = null
|
||||
run.releasing = (async () => {
|
||||
await Promise.resolve()
|
||||
try {
|
||||
// Frame work has settled; an SDK close must not retain this independent bitmap.
|
||||
releaseOutput(output)
|
||||
}
|
||||
finally {
|
||||
if (segmenter)
|
||||
await releaseSegmenter(segmenter)
|
||||
}
|
||||
})()
|
||||
return run.releasing
|
||||
}
|
||||
|
||||
schedule(run) {
|
||||
if (!this.active(run) || run.frame !== null || run.busy)
|
||||
return
|
||||
run.frame = requestAnimationFrame(() => {
|
||||
run.frame = null
|
||||
this.tick(run)
|
||||
})
|
||||
}
|
||||
|
||||
tick(run) {
|
||||
if (!this.active(run) || run.busy)
|
||||
return
|
||||
const video = this.video
|
||||
if (video.paused || video.ended || !(video.videoWidth > 0 && video.videoHeight > 0)) {
|
||||
this.schedule(run)
|
||||
return
|
||||
}
|
||||
run.busy = true
|
||||
const work = (async () => {
|
||||
try {
|
||||
// Reserve the work slot before SDK code can synchronously reenter start/stop.
|
||||
await Promise.resolve()
|
||||
if (this.active(run))
|
||||
await renderMask(run.output, video, this.layer, run.segmenter, this.config, () => this.active(run))
|
||||
}
|
||||
catch (error) {
|
||||
if (this.active(run))
|
||||
console.error('Error in segmentBody:', error)
|
||||
}
|
||||
finally {
|
||||
run.busy = false
|
||||
if (!this.active(run))
|
||||
await this.release(run)
|
||||
else this.schedule(run)
|
||||
}
|
||||
})()
|
||||
// A restart waits for uncancellable SDK work and disposal; it never overlaps it.
|
||||
this.tail = work.catch(error => console.warn('Failed to release danmuku mask resources:', error))
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.closed || this.art.isDestroy)
|
||||
return
|
||||
if (this.run?.running)
|
||||
return this.run.started
|
||||
let cancel
|
||||
const cancelled = new Promise(resolve => cancel = resolve)
|
||||
const run = { running: true, initializing: true, busy: false, frame: null, segmenter: null, output: null, cancel, started: null }
|
||||
const previous = this.tail
|
||||
this.run = run
|
||||
const initialize = (async () => {
|
||||
try {
|
||||
await previous
|
||||
if (!this.active(run))
|
||||
return
|
||||
if (!this.video || !this.layer?.style)
|
||||
throw new Error('Danmuku mask requires core video and danmuku template nodes')
|
||||
run.segmenter = await loadSegmenter(this.config, () => this.active(run))
|
||||
if (!this.active(run))
|
||||
return
|
||||
if (!run.segmenter) {
|
||||
run.running = false
|
||||
return
|
||||
}
|
||||
run.output = createOutput(this.layer)
|
||||
}
|
||||
catch (error) {
|
||||
if (this.active(run)) {
|
||||
run.running = false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
finally {
|
||||
run.initializing = false
|
||||
if (!this.active(run))
|
||||
await this.release(run)
|
||||
}
|
||||
if (this.active(run))
|
||||
this.tick(run)
|
||||
})()
|
||||
this.tail = initialize.catch(() => {})
|
||||
// stop settles public start promptly; eventual SDK results remain observed by initialize.
|
||||
run.started = Promise.race([initialize, cancelled])
|
||||
return run.started
|
||||
}
|
||||
|
||||
halt() {
|
||||
const run = this.run
|
||||
if (run) {
|
||||
run.running = false
|
||||
run.cancel()
|
||||
if (run.frame !== null) {
|
||||
cancelAnimationFrame(run.frame)
|
||||
run.frame = null
|
||||
}
|
||||
if (!run.initializing && !run.busy)
|
||||
this.tail = this.release(run).catch(error => console.warn('Failed to release danmuku mask resources:', error))
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.halt()
|
||||
if (this.layer?.style)
|
||||
this.layer.style.maskImage = 'none'
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.closed)
|
||||
return
|
||||
this.closed = true
|
||||
try {
|
||||
this.stop()
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
this.art.off('ready', this.ready)
|
||||
}
|
||||
finally {
|
||||
this.art.off('destroy', this.destroy)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,155 +1,16 @@
|
||||
import * as bodySegmentation from '@tensorflow-models/body-segmentation'
|
||||
import * as tf from '@tensorflow/tfjs-core'
|
||||
import '@tensorflow/tfjs-backend-webgl'
|
||||
import '@tensorflow/tfjs-backend-cpu'
|
||||
import maskConfig from './config'
|
||||
import MaskController from './controller'
|
||||
|
||||
export default function artplayerPluginDanmukuMask(option = {}) {
|
||||
return (art) => {
|
||||
const {
|
||||
template: { $video, $danmuku },
|
||||
} = art
|
||||
|
||||
let segmenter = null
|
||||
let canvas = null
|
||||
let ctx = null
|
||||
let animationFrameId = null
|
||||
let isInitialized = false
|
||||
|
||||
const config = {
|
||||
solutionPath: option.solutionPath || 'https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation',
|
||||
modelSelection: option.modelSelection || 1,
|
||||
smoothSegmentation: option.smoothSegmentation !== undefined ? option.smoothSegmentation : true,
|
||||
minDetectionConfidence: option.minDetectionConfidence || 0.5,
|
||||
minTrackingConfidence: option.minTrackingConfidence || 0.5,
|
||||
selfieMode: option.selfieMode || false,
|
||||
drawContour: option.drawContour || false,
|
||||
foregroundThreshold: option.foregroundThreshold || 0.5,
|
||||
opacity: option.opacity || 1,
|
||||
maskBlurAmount: option.maskBlurAmount || 3,
|
||||
}
|
||||
|
||||
async function initTensorFlow() {
|
||||
try {
|
||||
await tf.setBackend('webgl')
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('WebGL backend not available, falling back to CPU', error.message)
|
||||
await tf.setBackend('cpu')
|
||||
}
|
||||
}
|
||||
|
||||
async function initSegmenter() {
|
||||
await initTensorFlow()
|
||||
const model = bodySegmentation.SupportedModels.MediaPipeSelfieSegmentation
|
||||
|
||||
const segmenterConfig = {
|
||||
runtime: 'mediapipe',
|
||||
modelType: 'general',
|
||||
solutionPath: config.solutionPath,
|
||||
modelSelection: config.modelSelection,
|
||||
smoothSegmentation: config.smoothSegmentation,
|
||||
minDetectionConfidence: config.minDetectionConfidence,
|
||||
minTrackingConfidence: config.minTrackingConfidence,
|
||||
selfieMode: config.selfieMode,
|
||||
}
|
||||
|
||||
try {
|
||||
segmenter = await bodySegmentation.createSegmenter(model, segmenterConfig)
|
||||
isInitialized = true
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error initializing segmenter:', error)
|
||||
isInitialized = false
|
||||
}
|
||||
}
|
||||
|
||||
function setupDanmukuStyle() {
|
||||
Object.assign($danmuku.style, {
|
||||
maskMode: 'alpha',
|
||||
maskSize: 'contain',
|
||||
maskRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
})
|
||||
}
|
||||
|
||||
function createCanvas() {
|
||||
canvas = document.createElement('canvas')
|
||||
ctx = canvas.getContext('2d')
|
||||
}
|
||||
|
||||
function makeWhiteTransparent(imageData) {
|
||||
const data = imageData.data
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i] > 250 && data[i + 1] > 250 && data[i + 2] > 250) {
|
||||
data[i + 3] = 0
|
||||
}
|
||||
}
|
||||
return imageData
|
||||
}
|
||||
|
||||
async function segmentBody() {
|
||||
if (!isInitialized || $video.paused || $video.ended) {
|
||||
animationFrameId = requestAnimationFrame(segmentBody)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
canvas.width = $video.videoWidth
|
||||
canvas.height = $video.videoHeight
|
||||
|
||||
const segmentation = await segmenter.segmentPeople($video)
|
||||
|
||||
if (!segmentation || segmentation.length === 0) {
|
||||
animationFrameId = requestAnimationFrame(segmentBody)
|
||||
return
|
||||
}
|
||||
|
||||
const foregroundColor = { r: 255, g: 255, b: 255, a: 255 }
|
||||
const backgroundColor = { r: 0, g: 0, b: 0, a: 255 }
|
||||
const mask = await bodySegmentation.toBinaryMask(
|
||||
segmentation,
|
||||
foregroundColor,
|
||||
backgroundColor,
|
||||
config.drawContour,
|
||||
config.foregroundThreshold,
|
||||
)
|
||||
|
||||
await bodySegmentation.drawMask(canvas, $video, mask, config.opacity, config.maskBlurAmount)
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
ctx.putImageData(makeWhiteTransparent(imageData), 0, 0)
|
||||
$danmuku.style.maskImage = `url(${canvas.toDataURL()})`
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error in segmentBody:', error)
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(segmentBody)
|
||||
}
|
||||
|
||||
const { template: { $video, $danmuku } } = art
|
||||
const controller = new MaskController(art, maskConfig(option), { $video, $danmuku })
|
||||
async function startSegmentation() {
|
||||
if (!isInitialized) {
|
||||
await initSegmenter()
|
||||
}
|
||||
if (!canvas) {
|
||||
createCanvas()
|
||||
}
|
||||
setupDanmukuStyle()
|
||||
segmentBody()
|
||||
await controller.start()
|
||||
}
|
||||
|
||||
function stopSegmentation() {
|
||||
$danmuku.style.maskImage = 'none'
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
controller.stop()
|
||||
}
|
||||
|
||||
art.on('ready', startSegmentation)
|
||||
art.on('destroy', stopSegmentation)
|
||||
|
||||
return {
|
||||
name: 'artplayerPluginDanmukuMask',
|
||||
start: startSegmentation,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { drawMask, toBinaryMask } from './sdk'
|
||||
|
||||
export function makeWhiteTransparent(imageData) {
|
||||
const data = imageData.data
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i] > 250 && data[i + 1] > 250 && data[i + 2] > 250)
|
||||
data[i + 3] = 0
|
||||
}
|
||||
return imageData
|
||||
}
|
||||
|
||||
export function createOutput(layer) {
|
||||
const canvas = document.createElement('canvas')
|
||||
try {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx)
|
||||
throw new Error('Danmuku mask requires a 2D canvas context')
|
||||
Object.assign(layer.style, {
|
||||
maskMode: 'alpha',
|
||||
maskSize: 'contain',
|
||||
maskRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
})
|
||||
return { canvas, ctx }
|
||||
}
|
||||
catch (error) {
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderMask(output, video, layer, segmenter, config, active) {
|
||||
const { canvas, ctx } = output
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
const segmentation = await segmenter.segmentPeople(video)
|
||||
if (!active() || !segmentation || !segmentation.length)
|
||||
return
|
||||
const mask = await toBinaryMask(
|
||||
segmentation,
|
||||
{ r: 255, g: 255, b: 255, a: 255 },
|
||||
{ r: 0, g: 0, b: 0, a: 255 },
|
||||
config.drawContour,
|
||||
config.foregroundThreshold,
|
||||
)
|
||||
if (!active())
|
||||
return
|
||||
await drawMask(canvas, video, mask, config.opacity, config.maskBlurAmount)
|
||||
if (!active())
|
||||
return
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
ctx.putImageData(makeWhiteTransparent(imageData), 0, 0)
|
||||
const url = canvas.toDataURL()
|
||||
if (active())
|
||||
layer.style.maskImage = `url(${url})`
|
||||
}
|
||||
|
||||
export function releaseOutput(output) {
|
||||
if (output) {
|
||||
output.canvas.width = 0
|
||||
output.canvas.height = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as bodySegmentation from '@tensorflow-models/body-segmentation'
|
||||
import * as tf from '@tensorflow/tfjs-core'
|
||||
import '@tensorflow/tfjs-backend-webgl'
|
||||
import '@tensorflow/tfjs-backend-cpu'
|
||||
|
||||
export async function loadSegmenter(config, active) {
|
||||
try {
|
||||
await tf.setBackend('webgl')
|
||||
}
|
||||
catch (error) {
|
||||
if (!active())
|
||||
return null
|
||||
console.warn('WebGL backend not available, falling back to CPU', error.message)
|
||||
await tf.setBackend('cpu')
|
||||
}
|
||||
if (!active())
|
||||
return null
|
||||
try {
|
||||
return await bodySegmentation.createSegmenter(bodySegmentation.SupportedModels.MediaPipeSelfieSegmentation, {
|
||||
runtime: 'mediapipe',
|
||||
modelType: 'general',
|
||||
solutionPath: config.solutionPath,
|
||||
modelSelection: config.modelSelection,
|
||||
smoothSegmentation: config.smoothSegmentation,
|
||||
minDetectionConfidence: config.minDetectionConfidence,
|
||||
minTrackingConfidence: config.minTrackingConfidence,
|
||||
selfieMode: config.selfieMode,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
if (active())
|
||||
console.error('Error initializing segmenter:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseSegmenter(segmenter) {
|
||||
try {
|
||||
await segmenter.dispose()
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Failed to dispose danmuku mask segmenter:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export const toBinaryMask = (...args) => bodySegmentation.toBinaryMask(...args)
|
||||
export const drawMask = (...args) => bodySegmentation.drawMask(...args)
|
||||
Reference in new issue
Block a user