refactor(mask): [PKG-MASK-04] migrate strict types and preserve public consumers

This commit is contained in:
Harvey Zhao committed 2026-09-14 00:05:14 +08:00
1 parent aba125262a
commit aca4bc1cd2
30 files changed
+1848 -72

No files matched your search

@@ -1,4 +1,6 @@
export default function maskConfig(option) {
import type { MaskConfig, MaskOptions } from './types'
export default function maskConfig(option: MaskOptions): MaskConfig {
return {
solutionPath: option.solutionPath || 'https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation',
modelSelection: option.modelSelection || 1,
@@ -1,8 +1,18 @@
import type { MaskConfig, MaskHost, MaskNodes, MaskRun } from './types'
import { createOutput, releaseOutput, renderMask } from './output'
import { loadSegmenter, releaseSegmenter } from './sdk'
export default class MaskController {
constructor(art, config, { $video, $danmuku }) {
declare art: MaskHost
declare config: MaskConfig
declare video: HTMLVideoElement
declare layer: HTMLElement
declare closed: boolean
declare run: MaskRun | null
declare tail: Promise<void>
declare ready: () => void
constructor(art: MaskHost, config: MaskConfig, { $video, $danmuku }: MaskNodes) {
this.art = art
this.config = config
this.video = $video
@@ -38,11 +48,11 @@ export default class MaskController {
}
}
active(run) {
active(run: MaskRun): boolean {
return run === this.run && run.running && !this.closed && !this.art.isDestroy
}
release(run) {
release(run: MaskRun): Promise<void> {
if (run.releasing)
return run.releasing
const segmenter = run.segmenter
@@ -63,7 +73,7 @@ export default class MaskController {
return run.releasing
}
schedule(run) {
schedule(run: MaskRun): void {
if (!this.active(run) || run.frame !== null || run.busy)
return
run.frame = requestAnimationFrame(() => {
@@ -72,7 +82,7 @@ export default class MaskController {
})
}
tick(run) {
tick(run: MaskRun): void {
if (!this.active(run) || run.busy)
return
const video = this.video
@@ -86,7 +96,8 @@ export default class MaskController {
// 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))
// Only initialized runs reach tick; release waits for this busy work to settle.
await renderMask(run.output!, video, this.layer, run.segmenter!, this.config, () => this.active(run))
}
catch (error) {
if (this.active(run))
@@ -103,14 +114,16 @@ export default class MaskController {
this.tail = work.catch(error => console.warn('Failed to release danmuku mask resources:', error))
}
async start() {
async start(): Promise<void> {
if (this.closed || this.art.isDestroy)
return
// The start promise is assigned before initialization resumes after its first await.
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 }
return this.run.started!
// Promise executors install their resolver synchronously.
let cancel!: () => void
const cancelled = new Promise<void>(resolve => cancel = resolve)
const run: MaskRun = { 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 () => {
@@ -149,7 +162,7 @@ export default class MaskController {
return run.started
}
halt() {
halt(): void {
const run = this.run
if (run) {
run.running = false
@@ -163,13 +176,13 @@ export default class MaskController {
}
}
stop() {
stop(): void {
this.halt()
if (this.layer?.style)
this.layer.style.maskImage = 'none'
}
destroy() {
destroy(): void {
if (this.closed)
return
this.closed = true
@@ -1,8 +1,9 @@
import type { MaskHost, MaskOptions } from './types'
import maskConfig from './config'
import MaskController from './controller'
export default function artplayerPluginDanmukuMask(option = {}) {
return (art) => {
export default function artplayerPluginDanmukuMask(option: MaskOptions = {}) {
return (art: MaskHost) => {
const { template: { $video, $danmuku } } = art
const controller = new MaskController(art, maskConfig(option), { $video, $danmuku })
async function startSegmentation() {
@@ -1,15 +1,18 @@
import type { BodySegmenter } from '@tensorflow-models/body-segmentation'
import type { Active, MaskConfig, MaskOutput } from './types'
import { drawMask, toBinaryMask } from './sdk'
export function makeWhiteTransparent(imageData) {
export function makeWhiteTransparent(imageData: ImageData): ImageData {
const data = imageData.data
// Native ImageData stores complete four-byte RGBA pixels.
for (let i = 0; i < data.length; i += 4) {
if (data[i] > 250 && data[i + 1] > 250 && data[i + 2] > 250)
if (data[i]! > 250 && data[i + 1]! > 250 && data[i + 2]! > 250)
data[i + 3] = 0
}
return imageData
}
export function createOutput(layer) {
export function createOutput(layer: HTMLElement): MaskOutput {
const canvas = document.createElement('canvas')
try {
const ctx = canvas.getContext('2d')
@@ -31,7 +34,7 @@ export function createOutput(layer) {
}
}
export async function renderMask(output, video, layer, segmenter, config, active) {
export async function renderMask(output: MaskOutput, video: HTMLVideoElement, layer: HTMLElement, segmenter: BodySegmenter, config: MaskConfig, active: Active): Promise<void> {
const { canvas, ctx } = output
canvas.width = video.videoWidth
canvas.height = video.videoHeight
@@ -57,7 +60,7 @@ export async function renderMask(output, video, layer, segmenter, config, active
layer.style.maskImage = `url(${url})`
}
export function releaseOutput(output) {
export function releaseOutput(output: MaskOutput | null): void {
if (output) {
output.canvas.width = 0
output.canvas.height = 0
@@ -0,0 +1,3 @@
// TF 4.22.0 hash_util.d.ts uses global Long without importing its own dependency.
// Load the official UMD namespace for this internal check; emit no runtime import.
import 'long'
@@ -1,21 +1,25 @@
import type { BodySegmenter } from '@tensorflow-models/body-segmentation'
import type { Active, MaskConfig, SegmenterConfig } from './types'
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) {
export async function loadSegmenter(config: MaskConfig, active: Active): Promise<BodySegmenter | null> {
try {
await tf.setBackend('webgl')
}
catch (error) {
if (!active())
return null
console.warn('WebGL backend not available, falling back to CPU', error.message)
// Keep the historical message property read, including non-Error rejection behavior.
console.warn('WebGL backend not available, falling back to CPU', (error as { message: unknown }).message)
await tf.setBackend('cpu')
}
if (!active())
return null
try {
// Validate the full legacy object without dropping keys absent from the SDK config union.
return await bodySegmentation.createSegmenter(bodySegmentation.SupportedModels.MediaPipeSelfieSegmentation, {
runtime: 'mediapipe',
modelType: 'general',
@@ -25,7 +29,7 @@ export async function loadSegmenter(config, active) {
minDetectionConfidence: config.minDetectionConfidence,
minTrackingConfidence: config.minTrackingConfidence,
selfieMode: config.selfieMode,
})
} satisfies SegmenterConfig as SegmenterConfig)
}
catch (error) {
if (active())
@@ -34,7 +38,7 @@ export async function loadSegmenter(config, active) {
}
}
export async function releaseSegmenter(segmenter) {
export async function releaseSegmenter(segmenter: BodySegmenter): Promise<void> {
try {
await segmenter.dispose()
}
@@ -43,5 +47,5 @@ export async function releaseSegmenter(segmenter) {
}
}
export const toBinaryMask = (...args) => bodySegmentation.toBinaryMask(...args)
export const drawMask = (...args) => bodySegmentation.drawMask(...args)
export const toBinaryMask = (...args: Parameters<typeof bodySegmentation.toBinaryMask>) => bodySegmentation.toBinaryMask(...args)
export const drawMask = (...args: Parameters<typeof bodySegmentation.drawMask>) => bodySegmentation.drawMask(...args)
@@ -0,0 +1,50 @@
import type { BodySegmenter, MediaPipeSelfieSegmentationMediaPipeModelConfig } from '@tensorflow-models/body-segmentation'
// Owned implementation contracts; the historical public entrypoint is maintained separately.
export interface MaskOptions {
solutionPath?: string
modelSelection?: number
smoothSegmentation?: boolean
minDetectionConfidence?: number
minTrackingConfidence?: number
selfieMode?: boolean
drawContour?: boolean
foregroundThreshold?: number
opacity?: number
maskBlurAmount?: number
}
export type MaskConfig = Required<MaskOptions>
export type Active = () => boolean
// These legacy extra keys are passed through even where the current adapter ignores them.
export type SegmenterConfig = MediaPipeSelfieSegmentationMediaPipeModelConfig & Pick<MaskConfig, 'modelSelection' | 'smoothSegmentation' | 'minDetectionConfidence' | 'minTrackingConfidence' | 'selfieMode'>
export interface MaskNodes {
$video: HTMLVideoElement
$danmuku: HTMLElement
}
export interface MaskHost {
isDestroy: boolean
template: MaskNodes
on: (event: 'ready' | 'destroy', callback: () => void) => unknown
off: (event: 'ready' | 'destroy', callback: () => void) => unknown
}
export interface MaskOutput {
canvas: HTMLCanvasElement
ctx: CanvasRenderingContext2D
}
export interface MaskRun {
running: boolean
initializing: boolean
busy: boolean
frame: number | null
segmenter: BodySegmenter | null
output: MaskOutput | null
cancel: () => void
started: Promise<void> | null
releasing?: Promise<void>
}