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

@@ -2,4 +2,5 @@ node_modules
.DS_Store
yarn.lock
package-lock.json
src
src
tsconfig.json
@@ -9,15 +9,47 @@ The compatibility baseline is the actual npm 1.1.0 package and the earlier
| Module | Responsibility |
| --- | --- |
| `src/index.js` | Preserve synchronous registration, capture core template nodes before option getters, expose named start/stop closures |
| `src/config.js` | Snapshot the original option defaults without changing OR/undefined semantics |
| `src/sdk.js` | Existing TF backend selection, MediaPipe adapter configuration, mask calls and model disposal outlet |
| `src/controller.js` | One active run, initialization/inference serialization, cancellation, RAF ownership, ready/destroy subscriptions |
| `src/output.js` | Private canvas/context, unchanged binary mask colors and threshold conversion, guarded maskImage commit, canvas release |
| `src/index.ts` | Preserve synchronous registration, capture core template nodes before option getters, expose named start/stop closures |
| `src/config.ts` | Snapshot the original option defaults without changing OR/undefined semantics |
| `src/sdk.ts` | Existing TF backend selection, MediaPipe adapter configuration, mask calls and model disposal outlet |
| `src/controller.ts` | One active run, initialization/inference serialization, cancellation, RAF ownership, ready/destroy subscriptions |
| `src/output.ts` | Private canvas/context, unchanged binary mask colors and threshold conversion, guarded maskImage commit, canvas release |
| `src/types.ts` | Internal host, normalized options, exact legacy SDK arguments, canvas resources and nullable run state |
| `src/sdk-ambient.d.ts` | Declaration-only import of the SDK's official Long namespace dependency |
These modules remain JavaScript for PKG-MASK-03. Full owned-source TypeScript and
public declaration work belongs to PKG-MASK-04; this task does not claim it is
finished. Public declarations are unchanged.
The owned modules use strict TypeScript. The entry depends on config/controller;
controller depends on SDK/output; output delegates mask operations to SDK. The
shared contracts contain only types and do not add runtime dependencies. The
internal host accepts the actual core declaration in
`refactor/fixtures/implementation/danmuku-mask.ts`; it needs only core template
nodes, destroy state and ready/destroy subscription methods. Public declarations
remain a separate compatibility surface and never import these source types.
Controller fields use `declare` to retain the existing own-property initialization
order. Its limited non-null assertions describe lifecycle invariants: tick runs
only after model/output initialization, disposal waits for busy frame work, and
the public start promise is assigned before the first initialization continuation.
The cancellation resolver is assigned by the synchronous Promise executor.
Output byte indexing relies on native ImageData's complete RGBA pixels. The
backend error's message assertion preserves the old property read, including
its behavior for non-Error rejections; it does not normalize thrown values.
The SDK config uses the official MediaPipe config intersected with the exact
legacy extra keys. `satisfies` checks those fields, and the subsequent assertion
prevents the SDK union's excess-property check from deleting compatibility data.
No SDK option interpretation changes. TF 4.22.0 `dist/hash_util.d.ts:2-3` references
`Long` without an import. The local declaration-only bridge loads its existing
official `@types/long` 4.0.2 UMD namespace. This package imports the TF declarations
directly; it therefore needs the bridge under the root's explicit `types: []`.
Keep `skipLibCheck: false`, do not rewrite Long or add a runtime import for it.
The package tsconfig and all source modules stay outside the packed artifact.
Public factory declarations retain the actual npm 1.0.0/1.1.0 shape. The exact
`typesVersions` legacy mapping fixes old Node-resolution consumers without
changing the root factory. See `types/README.md` for private types, namespace
limits and the semantic online-editor generator. Strict positive/negative
factory replacement and return-type cases run in the baseline suite; installed
consumers separately check packed contents and historical diagnostics.
Mask captures the core's video and `.art-danmuku` layer. The core template
provides that layer before plugins register; Danmuku does not create it.
@@ -104,15 +136,19 @@ make a disposal test appear stronger than its real API.
From the repository root with pinned Node and Yarn:
```sh
node node_modules/typescript/bin/tsc -p packages/artplayer-plugin-danmuku-mask/tsconfig.json
node --test test/danmuku-mask-lifecycle.test.js
yarn test:danmuku-mask
yarn test:danmuku-mask-types-package
yarn build:ts artplayer-plugin-danmuku-mask
node --test test/danmuku-mask-failures.test.js refactor/scripts/danmuku-mask-contract.test.mjs
node node_modules/eslint/bin/eslint.js packages/artplayer-plugin-danmuku-mask/src test/helpers/danmuku-mask-candidate.js test/danmuku-mask-lifecycle.test.js
yarn build artplayer-plugin-danmuku-mask
yarn dev artplayer-plugin-danmuku-mask
```
The candidate helper bundles current owned modules and substitutes the SDK
The candidate helper requires exactly one JS/TS source entry, bundles current
owned modules and substitutes the SDK
imports with controlled implementations. Its fake RAF/video/canvas test
cancellation and output ownership; they do not prove real model quality, canvas
pixels, CSS alignment, CORS, browser scheduling, GPU memory or WASM cleanup.
@@ -129,5 +165,5 @@ by its original player. Native shared TensorFlow backend state remains unverifie
Native acceptance still needs actual local model loading, new/old cores, real
Danmuku composition, pause/seek/source/layout changes, multiple players, failure
recovery and post-destroy resources. The default unversioned solutionPath can
load assets independently of Yarn's SDK resolution. PKG-MASK-04/05/06 remain
separate source/type, real combination and distribution/release stages.
load assets independently of Yarn's SDK resolution. PKG-MASK-05/06 retain the
real combination and distribution/release gates after source/type migration.
@@ -41,5 +41,12 @@
"@tensorflow/tfjs-backend-webgl": "^4.21.0",
"@tensorflow/tfjs-converter": "^4.21.0",
"@tensorflow/tfjs-core": "^4.21.0"
},
"typesVersions": {
"*": {
"legacy": [
"types/artplayer-plugin-danmuku-mask.d.ts"
]
}
}
}
@@ -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>
}
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "allowJs": false },
"include": ["src/**/*.ts", "../../refactor/fixtures/implementation/danmuku-mask.ts"]
}
@@ -0,0 +1,39 @@
# Public type maintenance
`artplayer-plugin-danmuku-mask.d.ts` is the authored public declaration for both
the root and `/legacy` entrypoints. Both actual npm 1.0.0 and 1.1.0 shipped this
same factory shape. Keep its optional argument, synchronous registration,
literal result name, `start(): Promise<void>` and `stop(): void`; ordinary
replacement functions and `Parameters`/`ReturnType` extraction are supported.
The declaration already describes these runtime return values correctly. This
migration does not add a second `/runtime` entrypoint or expose SDK model types.
Internal run, canvas and SDK types live under `src/`; they are not the consumer
contract and are excluded from the package archive.
The manifest's exact `typesVersions` mapping makes `/legacy` resolve with older
Node module resolution, including the supported TypeScript 4.3.5 compiler. The
published packages lacked this mapping; their root declaration remains unchanged.
Modern resolution uses the existing `exports` declaration targets.
The package retains its historical NodeNext ESM declaration namespace behavior.
Do not infer a runtime `factory.default` property from that type-only namespace:
the latest CommonJS runtime exports a function without that property. The npm
1.0.0 CommonJS wrapper had a default export object, while 1.1.0 already exports
the callable directly. These are recorded historical differences.
Numeric option types do not redefine runtime defaults. Zero-valued options still
follow the original OR defaults; the installed SDK uses `modelType: general`
and ignores several additional forwarded options. See `../ARCHITECTURE.md` before
changing their effects or claiming a specific inference backend.
Run `node --test refactor/scripts/danmuku-mask-types.test.mjs` for strict factory,
argument/result and negative consumer cases. After a normal package build,
`yarn test:danmuku-mask-types-package` checks actual tarballs installed outside the
workspace, old/current compilers, NodeNext/Bundler resolution and the historical
namespace diagnostics. Those checks do not run a model or prove GPU cleanup.
`yarn build:ts artplayer-plugin-danmuku-mask` generates the standalone online
editor declaration with the semantic generator. This avoids the former mixture
of default and export-assignment syntax. The private Option/Result interfaces
remain private; extract them through `Parameters` and `ReturnType` when needed.