refactor(jassub): [PKG-JASSUB-04] migrate owned TypeScript and preserve public types

This commit is contained in:
Harvey Zhao committed 2026-09-14 01:41:31 +08:00
1 parent 38e7b37878
commit 5e445b1af6
33 files changed
+3636 -61

No files matched your search

+37 -34
View File
@@ -1,38 +1,41 @@
export interface JassubOption {
workerUrl: string
wasmUrl: string
modernWasmUrl: string
subUrl?: string
subContent?: string
timeOffset?: number
debug?: boolean
prescaleFactor?: number
prescaleHeightLimit?: number
maxRenderHeight?: number
fonts?: string[] | Uint8Array[]
availableFonts?: Record<string, Uint8Array | string>
fallbackFont?: string
useLocalFonts?: boolean
libassMemoryLimit?: number
libassGlyphLimit?: number
[key: string]: any
// Generated from the package public declaration by yarn build:ts. Do not edit.
/* eslint-disable ts/no-redeclare -- Callable and public type namespace intentionally merge. */
declare namespace artplayerPluginJassubDefinitions {
export interface JassubOption {
workerUrl: string
wasmUrl: string
modernWasmUrl: string
subUrl?: string
subContent?: string
timeOffset?: number
debug?: boolean
prescaleFactor?: number
prescaleHeightLimit?: number
maxRenderHeight?: number
fonts?: string[] | Uint8Array[]
availableFonts?: Record<string, Uint8Array | string>
fallbackFont?: string
useLocalFonts?: boolean
libassMemoryLimit?: number
libassGlyphLimit?: number
[key: string]: any
}
export interface JassubInstance {
resize: (force?: boolean, width?: number, height?: number, top?: number, left?: number) => Promise<void>
setVideo: (video: HTMLVideoElement) => Promise<void>
destroy: () => Promise<void>
[key: string]: any
}
export interface Result {
name: 'artplayerPluginJassub'
instance: JassubInstance
}
export const artplayerPluginJassub: (option: JassubOption) => (art: Artplayer) => Result
}
export interface JassubInstance {
resize: (force?: boolean, width?: number, height?: number, top?: number, left?: number) => Promise<void>
setVideo: (video: HTMLVideoElement) => Promise<void>
destroy: () => Promise<void>
[key: string]: any
declare const artplayerPluginJassub: typeof artplayerPluginJassubDefinitions.artplayerPluginJassub
declare namespace artplayerPluginJassub {
export type JassubOption = artplayerPluginJassubDefinitions.JassubOption
export type JassubInstance = artplayerPluginJassubDefinitions.JassubInstance
}
interface Result {
name: 'artplayerPluginJassub'
instance: JassubInstance
}
declare const artplayerPluginJassub: (option: JassubOption) => (art: Artplayer) => Result
export default artplayerPluginJassub
export = artplayerPluginJassub
export as namespace artplayerPluginJassub;
+4 -2
View File
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js test/asr-distribution.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/danmuku-setting.test.js test/danmuku-heatmap.test.js test/danmuku-renderer.test.js test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js test/danmuku-mask-failures.test.js test/danmuku-mask-lifecycle.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/jassub-registration.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js",
"test:unit": "node --test test/danmuku-setting.test.js test/danmuku-heatmap.test.js test/danmuku-renderer.test.js test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js test/danmuku-mask-failures.test.js test/danmuku-mask-lifecycle.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/jassub-registration.test.js test/jassub-runtime.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -90,7 +90,9 @@
"test:vtt-thumbnail-types-package": "node refactor/scripts/vtt-thumbnail-package-types.mjs",
"test:multiple-subtitles": "node --test test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js refactor/scripts/multiple-subtitles-contract.test.mjs refactor/scripts/multiple-subtitles-runtime-types.test.mjs refactor/scripts/multiple-subtitles-types.test.mjs",
"test:multiple-subtitles-types-package": "node refactor/scripts/multiple-subtitles-package-types.mjs",
"test:jassub": "node --test test/jassub.test.js test/jassub-registration.test.js refactor/scripts/jassub-contract.test.mjs refactor/scripts/jassub-provenance.test.mjs refactor/scripts/jassub-failures.test.mjs",
"test:jassub": "node --test test/jassub.test.js test/jassub-registration.test.js test/jassub-runtime.test.js refactor/scripts/jassub-contract.test.mjs refactor/scripts/jassub-provenance.test.mjs refactor/scripts/jassub-failures.test.mjs refactor/scripts/jassub-types.test.mjs",
"test:jassub-types": "node --test refactor/scripts/jassub-types.test.mjs",
"test:jassub-types-package": "node refactor/scripts/jassub-package-types.mjs",
"check:ci": "node refactor/scripts/ci-workflow.mjs",
"test:ci": "node --test test/ci-summary.test.js refactor/scripts/ci-workflow.test.mjs refactor/scripts/impact.test.mjs test/package-runtime.test.js",
"test:package:runtime": "node scripts/package-runtime.mjs --canonical",
+2 -1
View File
@@ -2,4 +2,5 @@ node_modules
.DS_Store
yarn.lock
package-lock.json
src
src
tsconfig.json
@@ -1,7 +1,7 @@
# JASSUB maintenance
`src/index.js` preserves the lazy factory and delegates registration to
`src/registration.js`. The registration module constructs the actual vendor object
`src/index.ts` preserves the lazy factory and delegates registration to
`src/registration.ts`. The registration module constructs the actual vendor object
with `{ video: art.video, ...option }`, styles only a vendor-created canvas parent,
binds host cleanup and synchronously returns `{ name: 'artplayerPluginJassub', instance }`.
Options remain live until registration; a supplied video wins. This small split keeps
@@ -27,11 +27,27 @@ messages. Keep this third-party file separate from the adapter's TypeScript migr
files. The local demo explicitly hosts its resources under `docs/assets/jassub/` and selects
fonts in `docs/assets/example/jassub.js`. Do not move or rename those URLs as an internal cleanup.
`types/artplayer-plugin-jassub.d.ts` currently requires three resource URLs and describes
resize/setVideo/destroy as Promise-returning. Actual historical methods are synchronous, and
resize is width/height/top/left/force. Keep the existing declarations until the dedicated public
type compatibility work has assessed extraction, callbacks and replacement consumers.
Both option and instance expose extension indexes; they are existing compatibility boundaries.
`types/artplayer-plugin-jassub.d.ts` preserves the actual npm 1.0.0/1.1.0 declarations,
including three required resource URLs, Promise-returning resize/setVideo/destroy and
the historical force-first resize signature. Both option and instance retain their existing
extension indexes. Do not correct this root file by narrowing fields or adding overloads:
complete factory assignment and Parameters/ReturnType are compatibility boundaries.
`types/runtime-api.d.ts` separately describes the actual optional options, synchronous
registration and methods, width/height/top/left/force resize, EventTarget events and
Worker query/mutation data. `types/runtime.d.ts`, `.d.cts` and `.d.mts` expose it through
the optional `/runtime` entry; it resolves to the same main/ESM JavaScript as the root.
There is no second implementation and no new factory.default property. Root/legacy
types remain unchanged; exact typesVersions mappings support old Node module resolution.
See [type migration notes](types/README.md) before changing either surface.
`src/jassub.es.d.ts` is a private bridge for the frozen vendor JS, not a public export.
It adds only the adapter's `_destroyed` read and `_canvasParent.style.zIndex` write to the
accurate instance. The minimal style contract permits the historical numeric 20, which
the native DOM setter converts to a string. The owned TS modules depend on RuntimeOption
and RuntimeResult, while JassubHost needs only video/on/off. The implementation fixture
checks actual Artplayer assignability. No broad any index, allowJs or ts-nocheck hides
owned code; the isolated third-party JS remains an explicit provenance/07 exception.
The wrapper matches upstream jassub 1.8.8 apart from formatting and the ESLint header;
worker JS and default font match that archive byte-for-byte. Local WASM instead matches
@@ -48,6 +64,10 @@ Use the pinned Node/Yarn toolchain:
```sh
yarn test:jassub
yarn test:jassub-types
yarn test:jassub-types-package
yarn typecheck
yarn build:ts artplayer-plugin-jassub
node --test test/jassub-registration.test.js
node refactor/scripts/jassub-provenance.test.mjs --network
yarn build artplayer-plugin-jassub
@@ -11,6 +11,10 @@ jassub plugin for ArtPlayer
See [maintenance notes](ARCHITECTURE.md) for adapter/vendor boundaries, resource paths,
historical declaration differences and the current validation scope.
Existing imports keep their historical types. The optional
[`artplayer-plugin-jassub/runtime` entry](types/README.md) provides accurate options,
synchronous instance methods and subtitle query types using the same implementation.
## License
MIT © Harvey Zhao
+21 -1
View File
@@ -27,11 +27,31 @@
"types": "./types/artplayer-plugin-jassub.d.ts",
"import": "./dist/artplayer-plugin-jassub.legacy.js",
"require": "./dist/artplayer-plugin-jassub.legacy.js"
},
"./runtime": {
"import": {
"types": "./types/runtime.d.mts",
"default": "./dist/artplayer-plugin-jassub.mjs"
},
"require": {
"types": "./types/runtime.d.cts",
"default": "./dist/artplayer-plugin-jassub.js"
}
}
},
"main": "dist/artplayer-plugin-jassub.js",
"module": "./dist/artplayer-plugin-jassub.mjs",
"types": "types/artplayer-plugin-jassub.d.ts",
"legacy": "dist/artplayer-plugin-jassub.legacy.js",
"browserslist": "last 1 Chrome version"
"browserslist": "last 1 Chrome version",
"typesVersions": {
"*": {
"legacy": [
"types/artplayer-plugin-jassub.d.ts"
],
"runtime": [
"types/runtime.d.ts"
]
}
}
}
@@ -1,5 +0,0 @@
import { registerJassub } from './registration.js'
export default function artplayerPluginJassub(option) {
return art => registerJassub(art, option)
}
@@ -0,0 +1,7 @@
import type { RuntimeOption } from '../types/runtime-api.js'
import type { JassubHost } from './registration.js'
import { registerJassub } from './registration.js'
export default function artplayerPluginJassub(option?: RuntimeOption) {
return (art: JassubHost) => registerJassub(art, option)
}
+12
View File
@@ -0,0 +1,12 @@
import type { RuntimeInstance, RuntimeOption } from '../types/runtime-api.js'
interface VendorInstance extends RuntimeInstance {
_destroyed?: boolean
// Minimal write contract: the DOM setter coerces the historical numeric 20.
_canvasParent?: { style: { zIndex: string | number } }
}
declare const JASSUB: {
new (option: RuntimeOption): VendorInstance
}
export default JASSUB
@@ -1,6 +1,13 @@
import type { RuntimeOption, RuntimeResult } from '../types/runtime-api.js'
import JASSUB from './jassub.es.js'
export function registerJassub(art, option) {
export interface JassubHost {
video: HTMLVideoElement
on: (name: 'destroy', callback: () => void) => unknown
off: (name: 'destroy', callback: () => void) => unknown
}
export function registerJassub(art: JassubHost, option?: RuntimeOption): RuntimeResult {
const instance = new JASSUB({ video: art.video, ...option })
let disposed = false
const dispose = () => {
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "allowJs": false },
"include": ["src/**/*.ts", "../../refactor/fixtures/implementation/jassub.ts"]
}
@@ -0,0 +1,67 @@
# JASSUB type compatibility
The root and `/legacy` declarations retain the complete npm 1.0.0 and 1.1.0 type
shape, including known mistakes. Existing TypeScript consumers do not need to
change imports as part of this migration. Both releases used the same declarations.
The generated online-editor global also preserves this historical surface.
For accurate types, explicitly opt into the additive entry:
```ts
import jassub from 'artplayer-plugin-jassub/runtime'
const register = jassub({
workerUrl: '/assets/jassub-worker.js',
wasmUrl: '/assets/jassub-worker.wasm',
modernWasmUrl: '/assets/jassub-worker-modern.wasm',
subUrl: '/subtitles.ass',
})
// Add register to the existing Artplayer plugins option.
// When called with an Artplayer, it synchronously returns { name, instance }.
```
`/runtime` uses the same JavaScript file as the root entry. It does not change
defaults, wrap instances, download resources differently or introduce a second
implementation. URLs are optional because the vendor has defaults; this does not
mean its default relative URLs will exist on your site. Continue hosting compatible
worker, WASM and font files and supplying the paths your deployment needs.
| Area | Historical root types | Accurate `/runtime` types and existing JavaScript |
| --- | --- | --- |
| Factory options | Required, with three required URLs | Optional options; optional resource URLs |
| Instance resize | force, width, height, top, left | width, height, top, left, force |
| resize/setVideo/destroy | Promise<void> | Synchronous; no-argument calls return void |
| sendMessage | Unchecked extension | Promise<void>, resolves after posting, not after Worker completion |
| Events/styles | Unchecked extension | Typed callbacks and partial mutation objects |
| ASS event Style | Unchecked extension | Numeric libass style index |
| ASS Start/Duration | Unchecked extension | Milliseconds; setCurrentTime uses seconds |
| Unknown fields/methods | Existing any indexes | Named supported fields; unknown members rejected |
Switching to `/runtime` intentionally changes type inference. For example,
`instance.resize(640, 360, 0, 0, true)` describes the actual call order.
`await instance.destroy()` remains legal JavaScript, but `.then()` on that result
never worked at runtime. Resource teardown completion is not represented by a Promise.
The callback error channel is `Error | ErrorEvent | null`; an error may omit the data.
The currently frozen vendor has a request timeout/error bug that throws before user
callbacks run. PKG-JASSUB-07 tracks the fix; typing that channel does not fix or guarantee
callback delivery. Query outputs do not contain the upstream npm declaration's `_index`.
The accurate instance extends EventTarget and preserves its normal listener APIs.
`destroy(error)` returns the original Error, or converts a nonempty string to Error;
an empty string is returned unchanged. Repeated direct destruction and construction/
video clock failures remain separate vendor lifecycle work, documented in ARCHITECTURE.md.
NodeNext ESM retains the root declaration's historical CommonJS namespace behavior.
If existing code uses the root namespace's `.default` to satisfy that declaration,
keep that form or opt into `/runtime`, whose conditional `.d.mts` is directly callable.
CommonJS accurate types use `import jassub = require('artplayer-plugin-jassub/runtime')`.
Do not invent `jassub.default`: the current JavaScript factory has no self alias.
Older TypeScript resolves `/legacy` and `/runtime` through exact typesVersions mappings.
Existing direct declaration paths are retained; no wildcard remaps unrelated paths.
Maintain `runtime-api.d.ts` against the frozen wrapper and worker behavior, not a new
upstream version. Keep CJS/ESM entry aliases synchronized. Run `yarn test:jassub-types`,
`yarn test:jassub-types-package`, `yarn test:jassub`, and `yarn typecheck` after changes.
The package runner installs actual historical archives and the candidate outside the
workspace, checks strict positive/negative consumers, and verifies artifact identity.
This is type/distribution evidence, not browser playback or final release acceptance.
+155
View File
@@ -0,0 +1,155 @@
// Core exposes a default constructor in Node10/ESM and export= in NodeNext CJS.
// Extract the same instance without requiring esModuleInterop in consumers.
type CoreModule = typeof import('artplayer')
type Artplayer = CoreModule extends { default: { prototype: infer Instance } }
? Instance
: CoreModule extends { prototype: infer Instance } ? Instance : never
export type FontSource = string | Uint8Array
/** Options are read when the registrar runs; the host supplies video by default. */
export interface RuntimeOption {
video?: HTMLVideoElement | null
canvas?: HTMLCanvasElement
blendMode?: 'js' | 'wasm'
asyncRender?: boolean
offscreenRender?: boolean
onDemandRender?: boolean
targetFps?: number
timeOffset?: number
debug?: boolean
prescaleFactor?: number
prescaleHeightLimit?: number
maxRenderHeight?: number
dropAllAnimations?: boolean
dropAllBlur?: boolean
workerUrl?: string
wasmUrl?: string
legacyWasmUrl?: string
modernWasmUrl?: string
subUrl?: string
subContent?: string
fonts?: FontSource[]
availableFonts?: Record<string, FontSource>
fallbackFont?: string
useLocalFonts?: boolean
libassMemoryLimit?: number
libassGlyphLimit?: number
}
/** Fields actually serialized by the frozen worker's getEvents response. */
export interface AssEvent {
/** Milliseconds; distinct from setCurrentTime's seconds. */
Start: number
Duration: number
/** Numeric libass style index, not the ASS text format's style name. */
Style: number
Name: string
MarginL: number
MarginR: number
MarginV: number
Effect: string
Text: string
ReadOrder: number
Layer: number
}
/** Fields actually serialized by the frozen worker's getStyles response. */
export interface AssStyle {
Name: string
FontName: string
FontSize: number
PrimaryColour: number
SecondaryColour: number
OutlineColour: number
BackColour: number
Bold: number
Italic: number
Underline: number
StrikeOut: number
ScaleX: number
ScaleY: number
Spacing: number
Angle: number
BorderStyle: number
Outline: number
Shadow: number
Alignment: number
MarginL: number
MarginR: number
MarginV: number
Encoding: number
treat_fontname_as_pattern: number
Blur: number
Justify: number
}
/** Worker mutation handlers assign supplied keys; unspecified fields are not sent. */
export type AssEventInput = Partial<AssEvent>
export type AssStyleInput = Partial<AssStyle>
/** Timeout errors and native Worker error events are separate channels. */
export type WorkerRequestError = Error | ErrorEvent
/**
* Success supplies null plus an array. Error payloads are absent.
* The frozen vendor currently throws before notifying this callback on request
* errors/timeouts; PKG-JASSUB-07 owns that defect. These types do not guarantee
* callback delivery or claim that the error path has been repaired.
*/
export type EventsCallback = (error: WorkerRequestError | null, events?: AssEvent[]) => void
export type StylesCallback = (error: WorkerRequestError | null, styles?: AssStyle[]) => void
export interface RuntimeEventMap {
ready: CustomEvent<null>
error: ErrorEvent
}
/** The actual vendor EventTarget instance, rather than an adapter copy. */
export interface RuntimeInstance extends EventTarget {
timeOffset: number
debug: boolean
prescaleFactor: number
prescaleHeightLimit: number
maxRenderHeight: number
/** Present when the selected rendering path uses demand/busy state. */
busy?: boolean
/** These two fields were explicitly exposed by npm jassub 1.8.8. */
_canvas: HTMLCanvasElement
_ctx: CanvasRenderingContext2D | false | null
resize: (width?: number, height?: number, top?: number, left?: number, force?: boolean) => void
setVideo: (video: HTMLVideoElement) => void
runBenchmark: () => void
setTrackByUrl: (url: string) => void
setTrack: (content: string) => void
freeTrack: () => void
setIsPaused: (isPaused: boolean) => void
setRate: (rate: number) => void
/** Current time is measured in seconds. */
setCurrentTime: (isPaused?: boolean, currentTime?: number, rate?: number) => void
createEvent: (event: AssEventInput) => void
setEvent: (event: AssEventInput, index: number) => void
removeEvent: (index: number) => void
getEvents: (callback: EventsCallback) => void
createStyle: (style: AssStyleInput) => void
setStyle: (style: AssStyleInput, index: number) => void
removeStyle: (index: number) => void
getStyles: (callback: StylesCallback) => void
styleOverride: (style: AssStyleInput) => void
disableStyleOverride: () => void
setDefaultFont: (font: string) => void
addFont: (font: FontSource) => void
/** Resolves after posting the message; it is not a Worker-operation acknowledgement. */
sendMessage: (target: string, data?: Record<string, unknown> | null, transferable?: Transferable[]) => Promise<void>
destroy: (() => void) & ((error: Error) => Error) & ((error: string) => Error | '') & ((error: Error | string | undefined) => Error | '' | undefined)
addEventListener: (<Name extends keyof RuntimeEventMap>(type: Name, listener: (this: RuntimeInstance, event: RuntimeEventMap[Name]) => void, options?: boolean | AddEventListenerOptions) => void) & ((type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) => void)
removeEventListener: (<Name extends keyof RuntimeEventMap>(type: Name, listener: (this: RuntimeInstance, event: RuntimeEventMap[Name]) => void, options?: boolean | EventListenerOptions) => void) & ((type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions) => void)
}
export interface RuntimeResult {
name: 'artplayerPluginJassub'
instance: RuntimeInstance
}
export type RuntimeFactory = (option?: RuntimeOption) => (art: Artplayer) => RuntimeResult
@@ -0,0 +1,20 @@
import type { RuntimeFactory } from './runtime-api.js'
declare const artplayerPluginJassub: RuntimeFactory
// eslint-disable-next-line ts/no-redeclare -- Ambient merge exposes types on the CommonJS factory.
declare namespace artplayerPluginJassub {
type FontSource = import('./runtime-api.js').FontSource
type RuntimeOption = import('./runtime-api.js').RuntimeOption
type AssEvent = import('./runtime-api.js').AssEvent
type AssStyle = import('./runtime-api.js').AssStyle
type AssEventInput = import('./runtime-api.js').AssEventInput
type AssStyleInput = import('./runtime-api.js').AssStyleInput
type WorkerRequestError = import('./runtime-api.js').WorkerRequestError
type EventsCallback = import('./runtime-api.js').EventsCallback
type StylesCallback = import('./runtime-api.js').StylesCallback
type RuntimeEventMap = import('./runtime-api.js').RuntimeEventMap
type RuntimeInstance = import('./runtime-api.js').RuntimeInstance
type RuntimeResult = import('./runtime-api.js').RuntimeResult
type RuntimeFactory = import('./runtime-api.js').RuntimeFactory
}
export = artplayerPluginJassub
@@ -0,0 +1,5 @@
import type { RuntimeFactory } from './runtime-api.js'
declare const artplayerPluginJassub: RuntimeFactory
export default artplayerPluginJassub
export type { AssEvent, AssEventInput, AssStyle, AssStyleInput, EventsCallback, FontSource, RuntimeEventMap, RuntimeFactory, RuntimeInstance, RuntimeOption, RuntimeResult, StylesCallback, WorkerRequestError } from './runtime-api.js'
+20
View File
@@ -0,0 +1,20 @@
import type { RuntimeFactory } from './runtime-api.js'
declare const artplayerPluginJassub: RuntimeFactory
// eslint-disable-next-line ts/no-redeclare -- Ambient merge exposes types on the CommonJS factory.
declare namespace artplayerPluginJassub {
type FontSource = import('./runtime-api.js').FontSource
type RuntimeOption = import('./runtime-api.js').RuntimeOption
type AssEvent = import('./runtime-api.js').AssEvent
type AssStyle = import('./runtime-api.js').AssStyle
type AssEventInput = import('./runtime-api.js').AssEventInput
type AssStyleInput = import('./runtime-api.js').AssStyleInput
type WorkerRequestError = import('./runtime-api.js').WorkerRequestError
type EventsCallback = import('./runtime-api.js').EventsCallback
type StylesCallback = import('./runtime-api.js').StylesCallback
type RuntimeEventMap = import('./runtime-api.js').RuntimeEventMap
type RuntimeInstance = import('./runtime-api.js').RuntimeInstance
type RuntimeResult = import('./runtime-api.js').RuntimeResult
type RuntimeFactory = import('./runtime-api.js').RuntimeFactory
}
export = artplayerPluginJassub
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,67 @@
# PKG-JASSUB-04 自有 TS 与兼容类型
## 结构与运行时边界
自有 index/registration 迁为严格 TS,职责延续 03 的工厂与资源注册分离。
JassubHost 只需要 video/on/off,真实 Artplayer fixture 验证结构兼容;内部返回
RuntimeResult,不使用已知不准确的旧根声明来约束实现。私有 jassub.es.d.ts
桥接 vendor,只补 adapter 使用的 _destroyed 与最小样式写入契约,保留数值 20
经 DOM setter 转字符串的历史行为。allowJs=false,不用 any/ts-nocheck 隐藏自有实现。
vendor JS 是明确的第三方例外,本次完全不改字节和算法。Worker/WASM/font 来源
和 07 修复责任继续保留。两个自有模块经 esbuild 去类型后与 38e7b378 的 JS 完全
相同;正常构建 main/legacy/ESM 与该提交一致(仅归一化 LF),docs 副本逐字节一致。
因此不以这次类型迁移声称新增浏览器播放验收,03 的原生验证范围和限制仍适用。
## API-09/11 兼容策略
实际 npm 1.0.0/1.1.0 根声明相同,本次逐字保留(忽略换行):资源 URL 必填、
force-first resize、三个 Promise 返回值和两个扩展索引都没有收窄。工厂完整
双向赋值、Parameters/ReturnType 和原有非法调用的错误位置继续验证。
新增可选 /runtime 与根入口复用同一 JavaScript,准确声明可选 options、同步
registration/resize/setVideo/destroy、width/height/top/left/force 顺序,以及
EventTarget、字幕/样式/字体方法。sendMessage 实际是 Promise<void>,完成仅代表
发出消息;不声称 Worker 已处理。ASS Style 是 libass 数值索引,Start/Duration
是毫秒,查询结果没有上游 npm 类型误写的 _index。旧入口消费者不需要迁移,
选择准确入口时的推导差异和调用示例写在包内 types/README.md。
精确 typesVersions 修复旧 Node10 下 /legacy 找不到声明;实际两旧包保留原 2307
证据,候选仅允许消除该 import 行的错误。NodeNext ESM 旧根 namespace 行为及
直接调用失败保持一致;/runtime 使用 conditional .d.mts/.d.cts 与旧编译器 .d.ts。
不添加不存在的 factory.default。在线编辑器旧生成方式同时包含 default/export=
而报 2309,两个编译器已复现;接入已有语义生成器并验证生成文件及正反例。
## 类型审查发现的真实缺陷
JASSUB-QUERY-01:getEvents/getStyles 的 timeout 和 Worker-error 路径缺少第二个
参数,公开 wrapper 先解构 undefined 抛 TypeError,用户回调为零次,两个请求
监听器保留;Worker-error 路径还保留定时器。冻结实现的四个失败测试保留此行为。
该问题纳入 PKG-JASSUB-07 的独立 vendor 修复,不在 04 中混改,不标为已修复。
准确回调类型允许 Error/ErrorEvent 与缺失数组,不能代表错误回调已经可靠送达。
## 验证、命令与限制
完整证据与最终安装报告摘要见 [类型验证](../baselines/jassub-types-validation.json)。
验证包括:严格 TS 自有项目及全仓流程,实际旧声明正反例、生成编辑器,源码和
main/legacy 注册回归,完整 vendor Worker JS/本地 WASM 的字幕查询及部分字段更新。
WASM 测试控制消息传输与 fetch,不声称浏览器 Worker、绘制或完整字体加载验收。
仓库外实际安装两个历史 npm 归档与 Yarn pack 候选,逐文件核对 hash 和非链接,
离线安装后强制 frozen 重装并检查锁文件。TS 5.9.3 Node10/NodeNext CJS/ESM/Bundler
及 TS 4.3.5 Node10 共 15 组;旧入口七项非法调用、准确入口十四项非法调用逐条
验证。CJS/ESM 根/legacy 工厂和 root/runtime 同一对象从实际安装包执行,不启动
vendor 或模拟一次浏览器播放。历史 raw 错误与正确 namespace 消费均单独记录。
复跑:yarn test:jassub、yarn test:jassub-types、yarn test:jassub-types-package、
yarn typecheck、yarn build:ts artplayer-plugin-jassub、yarn build artplayer-plugin-jassub。
新增脚本复用既有依赖,无新增依赖或锁变更;类型测试进入既有 baseline glob,
运行测试进入 test:unit/test:jassub。隔离安装脚本是可复跑的分包验证,尚不声称
全部生态安装矩阵已经接入远程 CI 或运行通过。
## 剩余任务与回退
07 继续 vendor 生命周期、时钟和查询错误清理;05 继续新旧核心/浏览器/真实设备
组合;06 继续完整分发和许可通知,VENDOR-04/05 保留。版本仍由各包独立 major
任务统一落地。本任务没有发布、推送或减少最终三轮复盘要求。
回退本任务并正常重建即可恢复旧 JS 入口/类型生成选择;03 修复仍是独立提交。
@@ -0,0 +1,8 @@
import type Artplayer from 'artplayer'
import type { RuntimeFactory, RuntimeResult } from '../../../packages/artplayer-plugin-jassub/types/runtime-api.js'
import factory from '../../../packages/artplayer-plugin-jassub/src'
declare const art: Artplayer
const registration: RuntimeResult = factory()(art)
const publicFactory: RuntimeFactory = factory
export { publicFactory, registration }
+4 -3
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 232 项,范围 22 个包及工作区/示例。
状态:todo 64 / doing 14 / blocked 0 / done 154 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 63 / doing 14 / blocked 0 / done 155 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -232,10 +232,10 @@
| PKG-JASSUB-01 | artplayer-plugin-jassub<br>核对包契约与历史用法 | BASE-05 | 选项透传、result.instance、vendor 来源、worker/WASM/font 路径 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结;接续 VENDOR-04/05,核对 wrapper/worker/WASM/font 的独立来源与通知,保持资源路径和选项透传 | H | done |
| PKG-JASSUB-02 | artplayer-plugin-jassub<br>建立特有行为与错误测试 | PKG-JASSUB-01, ENG-03, ENG-05 | ASS 字体、时钟/seek/倍率/resize、加载失败和销毁 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
| PKG-JASSUB-03 | artplayer-plugin-jassub<br>整理内部职责与资源 | PKG-JASSUB-02, CORE-15, CORE-16 | 仅整理自有 adapter/销毁;保留第三方文件及来源 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | done |
| PKG-JASSUB-04 | artplayer-plugin-jassub<br>迁移自有源码和公开类型 | PKG-JASSUB-03, ENG-04, ENG-06, CORE-07 | JASSUB option/instance 的兼容类型包装,vendor JS 例外记录 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo |
| PKG-JASSUB-04 | artplayer-plugin-jassub<br>迁移自有源码和公开类型 | PKG-JASSUB-03, ENG-04, ENG-06, CORE-07 | JASSUB option/instance 的兼容类型包装,vendor JS 例外记录 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | done |
| PKG-JASSUB-05 | artplayer-plugin-jassub<br>验证新旧核心和组合 | PKG-JASSUB-04, CORE-22, PKG-JASSUB-07 | 真实 worker/WASM 字幕渲染与全屏、旧核心测试 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
| PKG-JASSUB-06 | artplayer-plugin-jassub<br>验证分发并同步文档 | PKG-JASSUB-05, ENG-07 | jassub.js、外部资源路径、许可和离线失败记录;接续 VENDOR-04/05 完整组件/字体通知、LGPL 源码分发核对及六份未明确字体的分发处置 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录;来源身份已由01冻结,完整通知和未明确字体的发布处理必须闭环,不能以来源相同替代许可结论 | H | todo |
| PKG-JASSUB-07 | artplayer-plugin-jassub<br>修复 vendor 生命周期与字幕时钟 | PKG-JASSUB-03, PKG-JASSUB-02 | 独立记录 vendor 适配差异;幂等销毁、跨容器 setVideo、构造回滚、逐帧归属和倍率/能力边界 | 保持公开真实 instance、同步方法与资源路径;保留原 vendor 来源指纹并记录每项实际补丁;候选修复正例和真实字幕/失败/时钟验证通过,未证实设备不得冒充通过 | H | todo |
| PKG-JASSUB-07 | artplayer-plugin-jassub<br>修复 vendor 生命周期与字幕时钟 | PKG-JASSUB-03, PKG-JASSUB-02 | 独立记录 vendor 适配差异;幂等销毁、跨容器 setVideo、构造回滚、逐帧归属和倍率/能力边界;查询 timeout/Worker error 的缺失数据回调及每请求监听器/定时器清理(JASSUB-QUERY-01) | 保持公开真实 instance、同步方法与资源路径;保留原 vendor 来源指纹并记录每项实际补丁;候选修复正例和真实字幕/失败/时钟验证通过,未证实设备不得冒充通过 | H | todo |
## 5 包迁移:artplayer-plugin-danmuku-mask
@@ -535,6 +535,7 @@
- PKG-JASSUB-01: [记录](baselines/jassub-release.json) [记录](baselines/jassub-vendor.json) [记录](baselines/jassub-font-metadata.json) [记录](baselines/jassub-contract.md) [记录](baselines/jassub-contract-validation.json) [记录](changes/2026-09-13-PKG-JASSUB-01-baseline.md) [记录](baselines/jassub-provenance.json) [记录](baselines/jassub-provenance-validation.json) [记录](changes/2026-09-14-PKG-JASSUB-01-provenance.md)
- PKG-JASSUB-02: [记录](changes/2026-09-14-PKG-JASSUB-02-failure-baseline.md) [记录](baselines/jassub-failures-validation.json)
- PKG-JASSUB-03: [记录](changes/2026-09-14-PKG-JASSUB-03-registration.md) [记录](baselines/jassub-registration-validation.json)
- PKG-JASSUB-04: [记录](changes/2026-09-14-PKG-JASSUB-04-types.md) [记录](baselines/jassub-types-validation.json)
- PKG-MASK-01: [记录](baselines/danmuku-mask-release.json) [记录](baselines/danmuku-mask-registry.json) [记录](baselines/danmuku-mask-contract.md) [记录](baselines/danmuku-mask-contract-validation.json) [记录](changes/2026-09-13-PKG-MASK-01-contract.md)
- PKG-MASK-02: [记录](changes/2026-09-13-PKG-MASK-02-failures.md) [记录](baselines/danmuku-mask-failures-validation.json)
- PKG-MASK-03: [记录](changes/2026-09-13-PKG-MASK-03-lifecycle.md) [记录](baselines/danmuku-mask-lifecycle-validation.json)
+13
View File
@@ -1,5 +1,18 @@
# 进度与证据
## PKG-JASSUB-04 自有 TS 与准确类型入口完成
两个自有模块已迁为严格 TS,编译后的 JS 和三种正常构建产物保持上一任务行为。
实际 npm 两版旧根声明完整保留,新增 /runtime 描述真实同步方法/可选参数,
修复旧 Node10 legacy 解析与编辑器导出冲突;无 interop 的 CJS 类型引用也通过。
联合 139 项、源码/main/legacy 各 15 项、406 生产 TS 全仓检查通过;实际两旧包及
候选的仓库外安装 15 编译器组合通过,新增真实 WASM 查询/修改测试。
[变更记录](changes/2026-09-14-PKG-JASSUB-04-types.md)和
[安装与类型证据](baselines/jassub-types-validation.json)保留中间失败及最终候选。
新增 JASSUB-QUERY-01 有冻结错误复现,交由下一项 07 修复;没有宣称新浏览器
播放或发布验收。下一步继续 vendor 生命周期/时钟/查询清理,05/06 仍未完成。
## PKG-JASSUB-03 自有注册/清理改造完成
入口与 registration 分离;自定义 canvas、直接实例销毁后的宿主清理、重入与
+1
View File
@@ -248,3 +248,4 @@
| JASSUB-VIDEO-FRAME-01 | open / 已复现 | Old video frame callbacks can drive replacement subtitle time and duplicate chains | PKG-JASSUB-02, PKG-JASSUB-07 |
| JASSUB-RATE-01 | open / 已复现 | Fallback ratechange sends an Event instead of numeric playbackRate | PKG-JASSUB-02, PKG-JASSUB-07 |
| JASSUB-NATIVE-FRAME-01 | open / 已复现 | Windows WebKit default frame-clock path does not produce subtitle demands in actual playback | PKG-JASSUB-02, PKG-JASSUB-07, PKG-JASSUB-05 |
| JASSUB-QUERY-01 | open / 已复现 | Subtitle query errors throw before calling the consumer and leave request listeners attached | PKG-JASSUB-07, PKG-JASSUB-05 |
+18 -2
View File
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"task": "BASE-07",
"sourceCommit": "e01f39ff",
"updatedAt": "2026-09-13",
"updatedAt": "2026-09-14",
"note": "Open means closure evidence is outstanding; source-observed and unverified are not reproduced runtime bugs. A source issue already mitigated in the workspace still requires candidate regression evidence. No accepted-with-scope item is implied by baseline task completion.",
"items": [
{
@@ -779,7 +779,7 @@
"PKG-JASSUB-05"
],
"evidence": [
"packages/artplayer-plugin-jassub/src/index.js",
"packages/artplayer-plugin-jassub/src/index.ts",
"docs/assets/example/jassub.js"
],
"compatibleResolution": "Preserve options pass-through, result.instance and worker/wasm/font URLs. Wrapper, worker and WASM must be a verified compatible set.",
@@ -5535,6 +5535,22 @@
],
"compatibleResolution": "Investigate native frame delivery and establish a compatible capability response; explicit event-clock success is diagnostic only.",
"closureCriteria": "Default native behavior is verified or an explicit reviewed environment limitation is recorded; no silent global fallback or substitution of diagnostic mode for default acceptance."
},
{
"id": "JASSUB-QUERY-01",
"title": "Subtitle query errors throw before calling the consumer and leave request listeners attached",
"confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-JASSUB-07",
"PKG-JASSUB-05"
],
"evidence": [
"refactor/changes/2026-09-14-PKG-JASSUB-04-types.md",
"test/jassub-runtime.test.js"
],
"compatibleResolution": "Handle absent worker error payloads, release per-request listeners and timers before invoking user callbacks, and preserve successful synchronous query registration and callback data.",
"closureCriteria": "Actual vendor timeout and Worker-error paths invoke the consumer exactly once with the original error, leave no request listeners/timers, and successful real-WASM events/styles remain unchanged. Browser error/recovery acceptance remains PKG-JASSUB-05."
}
]
}
+30
View File
@@ -0,0 +1,30 @@
import fs from 'node:fs'
export const jassubInvalidStatements = [
'jassub()',
'jassub(undefined)',
'jassub({ workerUrl: "/worker.js", wasmUrl: "/worker.wasm" })',
'jassub({ workerUrl: 1, wasmUrl: "/worker.wasm", modernWasmUrl: "/modern.wasm" })',
'instance.resize(640)',
'const synchronousDestroy: void = instance.destroy()',
'const wrongName: "other" = result.name',
]
export function jassubConsumerSource(mode, raw = false) {
let source = fs.readFileSync(new URL('../../test/types/jassub-public.ts', import.meta.url), 'utf8')
if (mode === 'nodenext-esm' && !raw) {
source = source
.replace('import jassub from \'artplayer-plugin-jassub\'', 'import jassubModule from \'artplayer-plugin-jassub\'\nconst jassub = jassubModule.default')
.replace('import legacy from \'artplayer-plugin-jassub/legacy\'', 'import legacyModule from \'artplayer-plugin-jassub/legacy\'\nconst legacy = legacyModule.default')
}
return source
}
export function jassubRuntimeSource(mode) {
const source = fs.readFileSync(new URL('../../test/types/jassub-runtime.ts', import.meta.url), 'utf8')
return mode.endsWith('-no-interop')
? source
.replace('import runtime from \'artplayer-plugin-jassub/runtime\'', 'import runtime = require(\'artplayer-plugin-jassub/runtime\')')
.replace('import type Artplayer from \'artplayer\'', 'type Core = typeof import(\'artplayer\'); type Artplayer = Core extends { default: { prototype: infer I } } ? I : Core extends { prototype: infer I } ? I : never')
: source
}
+204
View File
@@ -0,0 +1,204 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import ts from 'typescript'
import compat from 'typescript-compat'
import { checkFiles, packedFiles } from '../../scripts/package-check.mjs'
import { consumerDirectory, removeConsumer, run, workspace, writeJson } from '../../scripts/package-consumer.mjs'
import { jassubConsumerSource, jassubInvalidStatements, jassubRuntimeSource } from './jassub-consumer.mjs'
import { verifyJassubContract } from './jassub-contract.mjs'
import { hash, readMember } from './releases.mjs'
const name = 'artplayer-plugin-jassub'
const declaration = `package/types/${name}.d.ts`
const shape = diagnostics => diagnostics.map(({ code, file, line }) => ({ code, file, line }))
let evidenceOutput
function installedBytes(consumer, packages) {
for (const pkg of packages) {
const root = path.join(consumer, 'node_modules', pkg.name)
assert.equal(fs.realpathSync(root), root, 'Installed package must not be a workspace link')
assert(!fs.lstatSync(root).isSymbolicLink(), 'Installed package root is a link')
for (const [member, expected] of Object.entries(pkg.files)) {
const file = path.join(root, member.slice(8))
assert.equal(fs.realpathSync(file), file, `Installed member resolves through a link: ${member}`)
assert(fs.lstatSync(file).isFile(), `Installed member is not a regular file: ${member}`)
assert.equal(hash(fs.readFileSync(file)), expected, `Installed bytes differ: ${member}`)
}
}
}
function runtimeProbe(candidate, old) {
return `const assert = require('node:assert/strict');
global.self = global;
const names = ${JSON.stringify([name, `${name}/legacy`, ...(candidate ? [`${name}/runtime`] : [])])};
const old = ${old};
const commonjs = names.map(name => require(name));
for (const value of commonjs) {
assert.equal(typeof value, old ? 'object' : 'function');
assert.equal(typeof value.default, old ? 'function' : 'undefined');
const factory = old ? value.default : value;
assert.equal(typeof factory(), 'function');
assert.equal(typeof factory({workerUrl: '/worker.js', wasmUrl: '/wasm.wasm', modernWasmUrl: '/modern.wasm'}), 'function');
}
${candidate ? 'assert.equal(commonjs[2], commonjs[0]); assert.equal(require.resolve(names[2]), require.resolve(names[0]));' : ''}
Promise.all(names.map(name => import(name))).then(modules => {
assert.equal(typeof modules[0].default, 'function');
assert.equal(typeof modules[0].default(), 'function');
assert.equal(modules[1].default, commonjs[1]);
const legacy = old ? modules[1].default.default : modules[1].default;
assert.equal(typeof legacy(), 'function');
${candidate ? 'assert.equal(modules[2].default, modules[0].default); assert.equal(typeof modules[2].default(), \'function\');' : ''}
console.log('Installed CJS/ESM root/legacy lazy factories and conditional runtime identity passed. Registrars were not invoked; no vendor initialization.');
}).catch(error => { console.error(error); process.exitCode = 1; });
`
}
async function main() {
assert.equal(process.version.slice(1), fs.readFileSync(path.join(workspace, '.node-version'), 'utf8').trim(), 'Use the repository Node version')
assert.equal(process.env.npm_config_user_agent?.split(' ')[0], 'yarn/1.22.22', 'Use yarn test:jassub-types-package')
const yarn = process.env.npm_execpath
assert(yarn && fs.existsSync(yarn))
const output = fs.mkdtempSync(path.join(workspace, 'refactor/.cache/jassub-package-types-'))
evidenceOutput = output
console.log(`JASSUB isolated installed consumer evidence: ${output}`)
const fixtureHashes = () => Object.fromEntries(['refactor/scripts/jassub-consumer.mjs', 'test/types/jassub-public.ts', 'test/types/jassub-runtime.ts'].map(file => [file, hash(fs.readFileSync(path.join(workspace, file)))]))
const fixtures = fixtureHashes()
const contract = await verifyJassubContract()
const releases = [...contract.baseline.previous, contract.baseline.release]
assert.deepEqual(releases.map(release => release.version), ['1.0.0', '1.1.0'])
const packages = []
for (const packageName of ['artplayer', name]) {
const archive = path.join(output, `${packageName}.tgz`)
fs.writeFileSync(path.join(output, `${packageName}-pack.log`), run([yarn, 'pack', '--filename', archive], path.join(workspace, 'packages', packageName)))
const manifest = JSON.parse(readMember(archive, 'package/package.json'))
const files = packedFiles(archive)
checkFiles(manifest, files, packageName === name ? Object.keys(contract.baseline.release.files) : [])
packages.push({ name: packageName, archive, manifest, sha256: hash(fs.readFileSync(archive)), files: Object.fromEntries(files.map(member => [member, hash(readMember(archive, member))])) })
}
const [core, candidate] = packages
const rootDeclarations = releases.map((release) => {
const current = readMember(candidate.archive, declaration)
const previous = readMember(contract.archives.get(release.version), declaration)
const normalize = bytes => bytes.toString('utf8').replaceAll('\r\n', '\n')
assert.equal(normalize(current), normalize(previous), `Preserve npm ${release.version} root declaration; only checkout CRLF may differ`)
return { version: release.version, candidateSha256: hash(current), publishedSha256: hash(previous), bytesEqual: current.equals(previous), normalizedLFSha256: hash(normalize(current)) }
})
const exports = candidate.manifest.exports
assert.equal(exports['./runtime'].import.types, './types/runtime.d.mts')
assert.equal(exports['./runtime'].require.types, './types/runtime.d.cts')
assert.equal(exports['./runtime'].import.default, exports['.'].import)
assert.equal(exports['./runtime'].require.default, exports['.'].require)
assert.deepEqual(candidate.manifest.typesVersions['*'].runtime, ['types/runtime.d.ts'])
assert.deepEqual(candidate.manifest.typesVersions['*'].legacy, [`types/${name}.d.ts`])
const plugins = [
...releases.map(release => ({ ...release, archive: contract.archives.get(release.version), label: `published-${release.version}` })),
{ ...candidate, label: 'candidate' },
]
const matrix = []
const historical = new Map()
for (const plugin of plugins) {
const consumer = consumerDirectory()
try {
writeJson(path.join(consumer, 'package.json'), { name: 'jassub-isolated-consumer', private: true, dependencies: Object.fromEntries([core, plugin].map(pkg => [pkg.name, `file:${pkg.archive.replaceAll('\\', '/')}`])) })
fs.copyFileSync(path.join(workspace, 'yarn.lock'), path.join(consumer, 'yarn.lock'))
fs.writeFileSync(path.join(output, `${plugin.label}-install.log`), run([yarn, 'install', '--offline', '--ignore-scripts', '--non-interactive'], consumer))
installedBytes(consumer, [core, plugin])
const lock = fs.readFileSync(path.join(consumer, 'yarn.lock'))
fs.writeFileSync(path.join(output, `${plugin.label}-frozen-install.log`), run([yarn, 'install', '--offline', '--frozen-lockfile', '--force', '--ignore-scripts', '--non-interactive'], consumer))
assert.deepEqual(fs.readFileSync(path.join(consumer, 'yarn.lock')), lock, 'Frozen reinstall changed consumer lock')
installedBytes(consumer, [core, plugin])
fs.writeFileSync(path.join(output, `${plugin.label}-yarn.lock`), lock)
const runtimeFile = path.join(consumer, 'exports.cjs')
fs.writeFileSync(runtimeFile, runtimeProbe(plugin.label === 'candidate', plugin.version === '1.0.0'))
fs.writeFileSync(path.join(output, `${plugin.label}-runtime.log`), run([runtimeFile], consumer))
for (const [compiler, mode] of [[ts, 'node10-commonjs'], [ts, 'nodenext-cjs'], [ts, 'nodenext-esm'], [ts, 'bundler-esm'], [compat, 'node10-commonjs']]) {
const next = mode.startsWith('nodenext')
const filename = path.join(consumer, `consumer.${next ? mode.endsWith('-cjs') ? 'cts' : 'mts' : 'ts'}`)
const options = { strict: true, noEmit: true, skipLibCheck: false, types: [], esModuleInterop: true, target: compiler.ScriptTarget.ES2020, lib: ['lib.es2020.d.ts', 'lib.dom.d.ts'], module: next ? compiler.ModuleKind.NodeNext : mode === 'bundler-esm' ? compiler.ModuleKind.ESNext : compiler.ModuleKind.CommonJS, moduleResolution: next ? compiler.ModuleResolutionKind.NodeNext : mode === 'bundler-esm' ? compiler.ModuleResolutionKind.Bundler : compiler.ModuleResolutionKind.NodeJs }
let declarations = []
const compile = (source, esModuleInterop = options.esModuleInterop) => {
fs.writeFileSync(filename, source)
const program = compiler.createProgram([filename], { ...options, esModuleInterop })
for (const file of program.getSourceFiles()) {
const actual = fs.realpathSync(file.fileName)
assert(actual.startsWith(fs.realpathSync(consumer) + path.sep) || (program.isSourceFileDefaultLibrary(file) && path.dirname(actual) === fs.realpathSync(path.dirname(compiler.sys.getExecutingFilePath()))), `Type escaped consumer: ${actual}`)
}
declarations = program.getSourceFiles().filter(file => !program.isSourceFileDefaultLibrary(file)).map(file => path.relative(consumer, file.fileName).replaceAll('\\', '/')).sort()
return compiler.getPreEmitDiagnostics(program).map(diagnostic => ({ code: diagnostic.code, file: diagnostic.file ? path.relative(consumer, diagnostic.file.fileName).replaceAll('\\', '/') : null, line: diagnostic.file && diagnostic.start !== undefined ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start).line + 1 : null, message: compiler.flattenDiagnosticMessageText(diagnostic.messageText, '\n').replaceAll(consumer.replaceAll('\\', '/'), '<consumer>').replaceAll(consumer, '<consumer>') }))
}
const rawSource = jassubConsumerSource(mode, true)
const raw = compile(rawSource)
const key = `${compiler.version}:${mode}`
const entry = { plugin: plugin.label, compiler: compiler.version, mode, raw, diagnostics: null, invalid: null, runtime: null }
matrix.push(entry)
writeJson(path.join(output, 'matrix-progress.json'), matrix)
const legacyLines = rawSource.split('\n').flatMap((line, index) => /from ['"]artplayer-plugin-jassub\/legacy['"]/.test(line) ? [index + 1] : [])
assert.equal(legacyLines.length, 1, 'The raw consumer must contain one identifiable legacy import')
const legacyFailure = { code: 2307, file: path.basename(filename), line: legacyLines[0] }
if (plugin.label === 'published-1.0.0') {
if (mode === 'node10-commonjs')
assert.deepEqual(shape(raw), [legacyFailure], 'Preserve the exact old Node10 legacy resolution error')
if (mode === 'nodenext-esm')
assert(raw.length > 0, 'Keep raw historical NodeNext namespace failures visible')
historical.set(key, { raw: shape(raw) })
}
else {
const expected = historical.get(key).raw
assert.deepEqual(shape(raw), plugin.label === 'candidate' && mode === 'node10-commonjs' ? expected.filter(item => item.code !== legacyFailure.code || item.file !== legacyFailure.file || item.line !== legacyFailure.line) : expected, 'Only the exact recorded old Node10 legacy resolution failure may be corrected')
}
let source = jassubConsumerSource(mode)
if (plugin.label !== 'candidate' && mode === 'node10-commonjs')
source = source.replace(/(['"])artplayer-plugin-jassub\/legacy\1/g, `'artplayer-plugin-jassub/types/artplayer-plugin-jassub'`)
entry.diagnostics = compile(source)
assert.deepEqual(entry.diagnostics, [], `${plugin.label} ${compiler.version} ${mode}: explicit historical namespace consumer`)
entry.invalid = compile(`${source}\n${jassubInvalidStatements.join('\n')}\n`)
assert.equal(jassubInvalidStatements.length, 7, 'Retain all seven historical negative consumers')
assert.equal(entry.invalid.length, jassubInvalidStatements.length)
assert.deepEqual(entry.invalid.map(item => item.line), jassubInvalidStatements.map((_, index) => source.split('\n').length + 1 + index), 'Every old-entry negative statement must fail at its exact line')
if (plugin.label === 'published-1.0.0')
historical.get(key).invalid = shape(entry.invalid)
else assert.deepEqual(shape(entry.invalid), historical.get(key).invalid, 'Both published packages and candidate preserve exact invalid diagnostic codes and lines')
if (plugin.label === 'candidate') {
const runtimeSource = jassubRuntimeSource(mode)
const diagnostics = compile(runtimeSource)
const runtimeDeclarations = declarations
assert.deepEqual(diagnostics, [], `Accurate runtime consumer: ${compiler.version} ${mode}`)
const lines = runtimeSource.split('\n')
const negative = []
for (let index = 0; index < lines.length; index++) {
if (!/^[ \t]*\/\/ @ts-expect-error/.test(lines[index]))
continue
assert(lines[index + 1]?.trim() && !lines[index + 1].trim().startsWith('//'), 'Each runtime directive must precede one negative statement')
const invalid = compile(lines.map((line, offset) => offset === index ? '' : line).join('\n'))
assert(invalid.length > 0 && invalid.every(item => item.file === path.basename(filename) && item.line === index + 2), `Runtime negative statement ${index + 2} must independently fail on its own line`)
negative.push({ line: index + 2, diagnostics: invalid })
}
assert.equal(negative.length, 14, 'Retain all fourteen runtime negative consumers')
entry.runtime = { diagnostics, negative, declarations: runtimeDeclarations, sourceSha256: hash(runtimeSource) }
if ((compiler === ts && mode === 'nodenext-cjs') || (compiler === compat && mode === 'node10-commonjs')) {
const commonjsSource = jassubRuntimeSource(`${mode}-no-interop`)
assert(commonjsSource.includes('import runtime = require(\'artplayer-plugin-jassub/runtime\')'), 'Exercise the promised CommonJS import assignment')
const commonjsDiagnostics = compile(commonjsSource, false)
entry.runtime.commonjs = { esModuleInterop: false, diagnostics: commonjsDiagnostics, declarations, sourceSha256: hash(commonjsSource) }
writeJson(path.join(output, 'matrix-progress.json'), matrix)
assert.deepEqual(commonjsDiagnostics, [], `Runtime import=require without interop: ${compiler.version} ${mode}`)
}
}
writeJson(path.join(output, 'matrix-progress.json'), matrix)
}
}
finally { removeConsumer(consumer) }
}
assert.deepEqual(fixtureHashes(), fixtures, 'Consumer fixtures changed during the installed matrix; rerun with final inputs')
writeJson(path.join(output, 'report.json'), { task: 'PKG-JASSUB-04', node: process.version, fixtures, rootDeclarations, packages, published: plugins.slice(0, 2).map(plugin => ({ label: plugin.label, archive: plugin.archive, sha256: hash(fs.readFileSync(plugin.archive)) })), matrix, scope: 'Actual npm 1.0.0/1.1.0 and Yarn-packed candidate/core installed outside workspace; offline installation and forced frozen reinstall with unchanged lock, strictly verified installed member hashes and non-link checks; root declaration text unchanged after CRLF normalization with both raw hashes recorded, exact old diagnostics, conditional accurate runtime declarations and Node lazy factory/legacy identity. Registrars are never invoked in Node; this is not browser Worker/WASM/font rendering or publication acceptance.' })
console.log(`JASSUB isolated installed consumers passed ${matrix.length} compiler modes: ${output}`)
}
main().catch((error) => {
if (evidenceOutput)
writeJson(path.join(evidenceOutput, 'failure.json'), { message: error.message, stack: error.stack })
console.error(error)
process.exitCode = 1
})
+74
View File
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
// eslint-disable-next-line test/no-import-node-test -- Repository baseline runner.
import test from 'node:test'
import { ESLint } from 'eslint'
import ts from 'typescript'
import compat from 'typescript-compat'
import { checkPluginEditorDeclaration, generatePluginEditorDeclaration } from '../../scripts/plugin-editor-types.mjs'
import { checkConsumer } from '../../scripts/typecheck.mjs'
import { jassubConsumerSource, jassubInvalidStatements, jassubRuntimeSource } from './jassub-consumer.mjs'
import { verifyJassubContract } from './jassub-contract.mjs'
import { readMember } from './releases.mjs'
const modes = [[ts, 'node10-commonjs'], [ts, 'nodenext-cjs'], [ts, 'nodenext-esm'], [ts, 'bundler-esm'], [compat, 'node10-commonjs']]
test('JASSUB keeps both actual published root declarations without changing historical extraction or replacement types', async () => {
const contract = await verifyJassubContract()
const candidate = fs.readFileSync('packages/artplayer-plugin-jassub/types/artplayer-plugin-jassub.d.ts', 'utf8').replaceAll('\r\n', '\n')
for (const archive of contract.archives.values())
assert.equal(candidate, readMember(archive, 'package/types/artplayer-plugin-jassub.d.ts').toString().replaceAll('\r\n', '\n'))
for (const [compiler, mode] of modes) {
const source = jassubConsumerSource(mode)
assert.deepEqual(checkConsumer(compiler, mode, source), [], `${compiler.version} ${mode}`)
const errors = checkConsumer(compiler, mode, `${source}\n${jassubInvalidStatements.join('\n')}\n`)
assert.deepEqual(errors.map(item => item.line), jassubInvalidStatements.map((_, index) => source.split('\n').length + 1 + index))
}
})
test('JASSUB runtime entry checks optional options, sync instances, worker messages, callbacks and invalid consumers', () => {
for (const [compiler, mode] of modes) {
const source = jassubRuntimeSource(mode)
assert.deepEqual(checkConsumer(compiler, mode, source), [], `${compiler.version} ${mode}`)
const invalid = checkConsumer(compiler, mode, source.replaceAll(/\/\/ @ts-expect-error[^\n]*\n/g, ''))
const expected = []
let line = 1
for (const text of source.split('\n')) {
if (text.startsWith('// @ts-expect-error'))
expected.push(line)
else line++
}
assert.equal(expected.length, 14)
assert.deepEqual(invalid.map(item => item.line), expected, `${compiler.version} ${mode}: each negative statement must fail independently`)
}
assert.deepEqual(checkConsumer(ts, 'nodenext-cjs', `import root = require('artplayer-plugin-jassub'); import legacy = require('artplayer-plugin-jassub/legacy'); import runtime = require('artplayer-plugin-jassub/runtime');
const option: root.JassubOption = {workerUrl: '/worker.js', wasmUrl: '/worker.wasm', modernWasmUrl: '/modern.wasm'};
root.default(option); legacy.default(option); runtime(); const value: runtime.RuntimeOption = {}; runtime(value);`), [])
})
test('JASSUB generated editor declaration preserves the old global API and is valid in both compilers', async () => {
const source = fs.readFileSync('packages/artplayer-plugin-jassub/types/artplayer-plugin-jassub.d.ts', 'utf8')
const generated = generatePluginEditorDeclaration(source, 'artplayerPluginJassub')
const file = 'docs/assets/ts/artplayer-plugin-jassub.d.ts'
const [formatted] = await new ESLint({ fix: true, fixTypes: ['layout'] }).lintText(generated, { filePath: file })
assert.equal(formatted.errorCount, 0)
assert.equal(fs.readFileSync(file, 'utf8').replaceAll('\r\n', '\n'), formatted.output || generated)
const core = fs.readFileSync('docs/assets/ts/artplayer.d.ts', 'utf8')
const consumer = `declare const art: Artplayer;
const option: artplayerPluginJassub.JassubOption = {workerUrl: '/worker.js', wasmUrl: '/worker.wasm', modernWasmUrl: '/modern.wasm'};
const result = artplayerPluginJassub(option)(art);
const promise: Promise<void> = result.instance.resize(true, 640, 360);
const replacement: typeof artplayerPluginJassub = (_option) => (_art) => result;
// @ts-expect-error Keep the historical required options argument.
artplayerPluginJassub();
// @ts-expect-error Keep the historical required resource URLs.
artplayerPluginJassub({});
// @ts-expect-error Global editor uses the historical force-first resize signature.
result.instance.resize(640);`
for (const compiler of [ts, compat]) {
assert.deepEqual(checkPluginEditorDeclaration(generated, core, consumer, compiler), [])
assert.equal(checkPluginEditorDeclaration(generated, core, consumer.replaceAll(/\/\/ @ts-expect-error[^\n]*\n/g, ''), compiler).length, 3)
const oldEditor = `${source.replace(/^import.*$/gim, '')}\nexport = artplayerPluginJassub;\nexport as namespace artplayerPluginJassub;\n`
assert(checkPluginEditorDeclaration(oldEditor, core, '', compiler).some(item => item.code === 2309), 'Reproduce the former conflicting export assignment before claiming the editor fix')
}
})
+6 -3
View File
@@ -2477,11 +2477,14 @@
"ENG-06",
"CORE-07"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "JASSUB option/instance 的兼容类型包装,vendor JS 例外记录",
"acceptance": "严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容",
"evidence": []
"evidence": [
"changes/2026-09-14-PKG-JASSUB-04-types.md",
"baselines/jassub-types-validation.json"
]
},
{
"id": "PKG-JASSUB-05",
@@ -2531,7 +2534,7 @@
],
"status": "todo",
"risk": "H",
"deliverable": "独立记录 vendor 适配差异;幂等销毁、跨容器 setVideo、构造回滚、逐帧归属和倍率/能力边界",
"deliverable": "独立记录 vendor 适配差异;幂等销毁、跨容器 setVideo、构造回滚、逐帧归属和倍率/能力边界;查询 timeout/Worker error 的缺失数据回调及每请求监听器/定时器清理(JASSUB-QUERY-01)",
"acceptance": "保持公开真实 instance、同步方法与资源路径;保留原 vendor 来源指纹并记录每项实际补丁;候选修复正例和真实字幕/失败/时钟验证通过,未证实设备不得冒充通过",
"evidence": []
},
+1 -1
View File
@@ -1258,7 +1258,7 @@
"PKG-JASSUB-05"
],
"sourceFiles": [
"packages/artplayer-plugin-jassub/src/index.js",
"packages/artplayer-plugin-jassub/src/index.ts",
"docs/assets/example/jassub.js"
],
"contract": "Preserve options pass-through, result.instance and worker/wasm/font URLs. Wrapper, worker and WASM must be a verified compatible set.",
+5
View File
@@ -35,3 +35,8 @@ Danmuku的PKG-DANMUKU-06保留实际npm 5.3.0根声明的完整类型形状与
points、静态icons和回调receiver;根/legacy/runtime复用原分发实现,不添加不存在
的factory.default。旧入口用户不需要因本次类型迁移修改代码。切换/runtime是可选
的准确类型入口,历史声明与运行时的区别见包README;这里没有新增旧类型破坏授权。
JASSUB 的 PKG-JASSUB-04 保留实际 npm 1.0.0/1.1.0 相同的根声明(忽略换行),
不改变必填资源 URL、旧 resize 参数顺序、Promise 返回声明和扩展索引。新增
/runtime 描述真实可选参数、同步方法及 Worker 数据,并复用同一运行产物。
这项实施没有新增旧类型破坏,不声称用户另行批准过 JASSUB 专属类型冲突。
+1 -1
View File
@@ -53,7 +53,7 @@ console.log(`✨ Built ${artplayerTSoutput}`);
const type = pluginsTS[index]
const { name, file } = parsePluginInfo(type)
const source = String(fs.readFileSync(type))
const semanticPlugin = ['artplayerPluginDanmukuMask', 'artplayerPluginDanmuku', 'artplayerPluginChromecast', 'artplayerPluginAsr', 'artplayerPluginMultipleSubtitles', 'artplayerPluginVttThumbnail', 'artplayerPluginHlsControl', 'artplayerPluginAudioTrack', 'artplayerPluginDashControl', 'artplayerPluginAds', 'artplayerPluginAmbilight', 'artplayerProxyCanvas', 'artplayerProxyMediabunny', 'artplayerPluginDocumentPip', 'ArtplayerToolIframe', 'ArtplayerToolThumbnail'].includes(name)
const semanticPlugin = ['artplayerPluginJassub', 'artplayerPluginDanmukuMask', 'artplayerPluginDanmuku', 'artplayerPluginChromecast', 'artplayerPluginAsr', 'artplayerPluginMultipleSubtitles', 'artplayerPluginVttThumbnail', 'artplayerPluginHlsControl', 'artplayerPluginAudioTrack', 'artplayerPluginDashControl', 'artplayerPluginAds', 'artplayerPluginAmbilight', 'artplayerProxyCanvas', 'artplayerProxyMediabunny', 'artplayerPluginDocumentPip', 'ArtplayerToolIframe', 'ArtplayerToolThumbnail'].includes(name)
const localTypes = name === 'artplayerProxyMediabunny' ? { './media': fs.readFileSync(path.join(path.dirname(type), 'media.d.ts'), 'utf8') } : {}
const code = semanticPlugin
? generatePluginEditorDeclaration(source, name, localTypes)
+236
View File
@@ -0,0 +1,236 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
// eslint-disable-next-line test/no-import-node-test -- Accurate runtime contracts use the repository runner.
import test from 'node:test'
import { isPromise } from 'node:util/types'
import vm from 'node:vm'
import { jassubCandidate, jassubEnvironment, jassubHistorical } from './helpers/jassub.js'
const candidate = await jassubCandidate()
const frozen = (await jassubHistorical()).find(item => item.name === 'frozen-workspace-source')
function environment(implementation) {
const code = `${implementation.code}\nObject.defineProperty(module.exports.default || module.exports, '__runtimePlatform', { value: { ErrorEvent, setGlobal: (name, value) => { globalThis[name] = value } } });`
const env = jassubEnvironment({ ...implementation, code })
env.platform = env.factory.__runtimePlatform
return env
}
function requestListeners(worker) {
const listeners = new Map()
worker.addEventListener = (type, callback) => {
if (!listeners.has(type))
listeners.set(type, new Set())
listeners.get(type).add(callback)
}
worker.removeEventListener = (type, callback) => listeners.get(type)?.delete(callback)
return {
size: () => [...listeners.values()].reduce((sum, entries) => sum + entries.size, 0),
emit(type, event) {
for (const callback of [...listeners.get(type) || []]) callback(event)
},
}
}
test('JASSUB accurate runtime resize uses width/height/top/left/force and lifecycle methods return synchronously', async () => {
const env = environment(candidate)
const { instance } = env.factory()(env.art)
await env.ready()
assert.equal(instance.resize(320, 180, 4, 7, false), undefined)
assert.equal(instance._canvas.style.top, '4px')
assert.equal(instance._canvas.style.left, '7px')
await env.flush()
const resize = env.workers[0].messages.at(-1)
assert.equal(resize.target, 'canvas')
assert.equal(resize.width, 320)
assert.equal(resize.height, 180)
assert.equal(resize.force, false)
const video = env.createVideo()
assert.equal(instance.setVideo(video), undefined)
assert.equal(instance._video, video)
assert.equal(instance.destroy(), undefined)
assert.equal(env.workers[0].terminated, 1)
assert.equal(env.parent.children.some(child => child.className === 'JASSUB'), false)
})
test('JASSUB accurate sendMessage returns a Promise waiting for ready and resolving after the actual post', async () => {
const env = environment(candidate)
const { instance } = env.factory()(env.art)
const payload = { value: 42 }
const pending = instance.sendMessage('runtime-probe', payload)
assert(isPromise(pending))
let settled = false
pending.then(() => {
settled = true
})
await env.flush()
assert.equal(settled, false)
assert.deepEqual(env.workers[0].messages.map(message => message.target), ['init'])
await env.ready()
assert.equal(await pending, undefined)
assert.equal(settled, true)
assert.equal(env.workers[0].messages.at(-1).target, 'runtime-probe')
assert.equal(env.workers[0].messages.at(-1).value, 42)
instance.destroy()
})
test('JASSUB accurate font options preserve mixed sources and font methods return synchronously', async () => {
const env = environment(candidate)
const bytes = new Uint8Array([1, 2, 3])
const fonts = ['fixture.woff2', bytes]
const availableFonts = { fixture: bytes, fallback: 'fallback.woff2' }
const { instance } = env.factory({ fonts, availableFonts })(env.art)
await env.ready()
const init = env.workers[0].messages[0]
assert.equal(init.fonts, fonts)
assert.equal(init.fonts[1], bytes)
assert.equal(init.availableFonts, availableFonts)
assert.equal(instance.addFont(bytes), undefined)
assert.equal(instance.setDefaultFont('fixture'), undefined)
await env.flush()
const added = env.workers[0].messages.find(message => message.target === 'addFont')
assert.equal(added.font, bytes)
assert.equal(env.workers[0].messages.at(-1).target, 'defaultFont')
assert.equal(env.workers[0].messages.at(-1).font, 'fixture')
instance.destroy()
})
const subtitles = `[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 360
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Runtime fixture`
test('JASSUB real Worker JS/WASM queries return numeric style indices and full event/style data through controlled transport', { timeout: 10000 }, async (context) => {
const env = environment(candidate)
env.platform.setGlobal('setTimeout', setTimeout)
env.platform.setGlobal('clearTimeout', clearTimeout)
const { instance } = env.factory({ subContent: subtitles, availableFonts: {}, fallbackFont: 'fixture', wasmUrl: '/runtime-fixture.wasm', onDemandRender: false, offscreenRender: false, asyncRender: false })(env.art)
const worker = env.workers[0]
const listeners = requestListeners(worker)
const wasm = fs.readFileSync('packages/artplayer-plugin-jassub/worker/jassub-worker.wasm')
const source = fs.readFileSync('packages/artplayer-plugin-jassub/worker/jassub-worker.js', 'utf8')
const requests = []
const messages = []
let initialized = false
let stopped = false
const workerContext = {
WebAssembly,
TextDecoder,
TextEncoder,
console,
onmessage: null,
async fetch(url) {
assert.equal(url, '/runtime-fixture.wasm')
requests.push(url)
return new Response(wasm, { headers: { 'Content-Type': 'application/wasm' } })
},
postMessage(message) {
const data = structuredClone(message)
messages.push(data)
if (data.target === 'ready')
initialized = true
queueMicrotask(() => {
if (stopped)
return
worker.onmessage({ data })
listeners.emit('message', { data })
})
},
}
workerContext.self = workerContext
vm.createContext(workerContext)
// Execute complete production Worker JS and actual WASM. Only transport and fetch are controlled.
vm.runInContext(source, workerContext, { timeout: 5000 })
const post = worker.postMessage
worker.postMessage = function (message) {
post.call(this, message)
if (!stopped)
workerContext.onmessage({ data: structuredClone(message) })
}
const terminate = worker.terminate
worker.terminate = function () {
if (initialized && !stopped)
workerContext.onmessage({ data: { target: 'destroy' } })
stopped = true
terminate.call(this)
}
context.after(() => instance.destroy())
let readyEvents = 0
instance.addEventListener('ready', (event) => {
readyEvents++
assert.equal(event.detail, null)
assert.equal(event.target, instance)
})
assert(instance instanceof EventTarget)
await new Promise(resolve => instance.addEventListener('ready', resolve, { once: true }))
assert.equal(readyEvents, 1)
assert.deepEqual(requests, ['/runtime-fixture.wasm'])
const query = method => new Promise((resolve, reject) => {
assert.equal(instance[method]((error, rows) => {
if (error !== null) {
reject(error ?? new Error('Successful queries must pass null as their error argument'))
return
}
resolve(rows)
}), undefined)
})
const events = await query('getEvents')
const styles = await query('getStyles')
assert.equal(events.length, 1)
assert.equal(events[0].Start, 1000)
assert.equal(events[0].Duration, 2000)
assert.equal(events[0].Text, 'Runtime fixture')
assert.equal(typeof events[0].Style, 'number')
assert.equal(Object.hasOwn(events[0], '_index'), false)
assert.equal(styles[events[0].Style].FontName, 'Arial')
assert.equal(styles[events[0].Style].FontSize, 24)
assert.equal(typeof styles[events[0].Style].PrimaryColour, 'number')
assert.equal(listeners.size(), 0)
assert.deepEqual(messages.find(message => message.target === 'getEvents').events, events)
assert.deepEqual(messages.find(message => message.target === 'getStyles').styles, styles)
assert.equal(instance.setEvent({ Text: 'Updated through Worker', Style: events[0].Style }, 0), undefined)
await env.flush()
assert.equal((await query('getEvents'))[0].Text, 'Updated through Worker')
assert.equal(instance.setStyle({ FontSize: 32 }, events[0].Style), undefined)
await env.flush()
assert.equal((await query('getStyles'))[events[0].Style].FontSize, 32)
})
for (const method of ['getEvents', 'getStyles']) {
for (const mode of ['timeout', 'worker-error']) {
test(`JASSUB frozen PKG-JASSUB-07 defect: ${method} ${mode} throws before the public error callback`, async () => {
const env = environment(frozen)
const timers = new Map()
let next = 0
env.platform.setGlobal('setTimeout', (callback) => {
timers.set(++next, callback)
return next
})
env.platform.setGlobal('clearTimeout', id => timers.delete(id))
const { instance } = env.factory()(env.art)
await env.ready()
const listeners = requestListeners(env.workers[0])
let calls = 0
instance[method](() => calls++)
assert.equal(listeners.size(), 2)
const trigger = mode === 'timeout'
? () => {
const [id, callback] = [...timers][0]
timers.delete(id)
callback()
}
: () => listeners.emit('error', new env.platform.ErrorEvent('error', { error: new Error('controlled Worker error') }))
assert.throws(trigger, { name: 'TypeError', message: /Cannot destructure property/ })
assert.equal(calls, 0)
assert.equal(listeners.size(), 2)
assert.equal(timers.size, mode === 'timeout' ? 0 : 1)
instance.destroy()
})
}
}
+28
View File
@@ -0,0 +1,28 @@
import type Artplayer from 'artplayer'
import type { JassubInstance, JassubOption } from 'artplayer-plugin-jassub'
import jassub from 'artplayer-plugin-jassub'
import legacy from 'artplayer-plugin-jassub/legacy'
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false
type Assert<T extends true> = T
interface OldResult { name: 'artplayerPluginJassub', instance: JassubInstance }
type OldFactory = (option: JassubOption) => (art: Artplayer) => OldResult
type FactoryCheck = Assert<Equal<typeof jassub, OldFactory>>
type ArgumentCheck = Assert<Equal<Parameters<typeof jassub>, [option: JassubOption]>>
type ReturnCheck = Assert<Equal<ReturnType<ReturnType<typeof jassub>>, OldResult>>
type ResizeCheck = Assert<Equal<Parameters<JassubInstance['resize']>, [force?: boolean, width?: number, height?: number, top?: number, left?: number]>>
declare const art: Artplayer
declare const instance: JassubInstance
const option: JassubOption = { workerUrl: '/worker.js', wasmUrl: '/worker.wasm', modernWasmUrl: '/modern.wasm', futureOption: true }
const replacement: typeof jassub = (_option: JassubOption) => (_art: Artplayer) => ({ name: 'artplayerPluginJassub', instance })
const reverse: OldFactory = jassub
const legacyReplacement: typeof legacy = replacement
const result: OldResult = jassub(option)(art)
legacy(option)(art)
const resize: Promise<void> = result.instance.resize(true, 640, 360, 0, 0)
const video: Promise<void> = result.instance.setVideo(art.video)
const destroy: Promise<void> = result.instance.destroy()
const extension: string = result.instance.futureMethod(option.futureOption)
export type { ArgumentCheck, FactoryCheck, ResizeCheck, ReturnCheck }
export { destroy, extension, legacyReplacement, replacement, resize, reverse, video }
+101
View File
@@ -0,0 +1,101 @@
import type Artplayer from 'artplayer'
import type { AssEvent, AssStyle, RuntimeFactory, RuntimeInstance, RuntimeOption, RuntimeResult } from 'artplayer-plugin-jassub/runtime'
import runtime from 'artplayer-plugin-jassub/runtime'
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false
type Assert<T extends true> = T
export type HostCheck = Assert<Equal<Parameters<ReturnType<RuntimeFactory>>, [art: Artplayer]>>
declare const art: Artplayer
declare const canvas: HTMLCanvasElement
const option: RuntimeOption = { canvas, fonts: ['/font.woff2', new Uint8Array([0])], availableFonts: { fallback: new Uint8Array([0]) }, onDemandRender: false }
const factory: RuntimeFactory = runtime
const result: RuntimeResult = runtime(option)(art)
const replacement: typeof runtime = (_option?: RuntimeOption) => (_art: Artplayer) => result
const defaults: RuntimeResult = runtime()(art)
runtime(undefined)(art)
runtime({ video: null, canvas })(art)
const instance: RuntimeInstance = result.instance
const target: EventTarget = instance
const resize: void = instance.resize(640, 360, 0, 0, true)
const video: void = instance.setVideo(art.video)
const destroyed: void = instance.destroy()
const error: Error = instance.destroy(new Error('test'))
const errorOrEmpty: Error | '' = instance.destroy('failure')
const sent: Promise<void> = instance.sendMessage('custom', { payload: new Uint8Array([1]) }, [])
instance.setCurrentTime(false, 10, 2)
instance.setTrackByUrl('/sub.ass')
instance.setTrack('[Script Info]')
instance.freeTrack()
instance.setIsPaused(true)
instance.setRate(2)
instance.createEvent({ Start: 1000, Duration: 2000, Style: 0, Text: 'Subtitle' })
instance.setEvent({ Text: 'Replacement' }, 0)
instance.removeEvent(0)
instance.createStyle({ Name: 'Default', FontSize: 24 })
instance.setStyle({ Bold: 1 }, 0)
instance.removeStyle(0)
instance.styleOverride({ FontSize: 30 })
instance.disableStyleOverride()
instance.addFont(new Uint8Array([0]))
instance.setDefaultFont('fallback')
instance.runBenchmark()
instance.getEvents((failure, events) => {
const caught: Error | ErrorEvent | null = failure
const values: AssEvent[] | undefined = events
if (events) {
for (const event of events) {
const style: number = event.Style
const start: number = event.Start
void [style, start]
}
}
void [caught, values]
})
instance.getStyles((_failure, styles) => {
const values: AssStyle[] | undefined = styles
void values
})
instance.addEventListener('ready', function (event) {
function acceptsInstance(_instance: RuntimeInstance) {}
acceptsInstance(this)
const detail: null = event.detail
void detail
})
instance.addEventListener('error', (event) => {
const message: string = event.message
void message
})
instance.addEventListener('custom', { handleEvent(event) {
const type: string = event.type
void type
} })
// @ts-expect-error Runtime URL fields are optional strings, not numbers.
runtime({ workerUrl: 42 })
// @ts-expect-error Accurate entry does not silently accept unknown options.
runtime({ typoOption: true })
// @ts-expect-error Resize starts with width; force is the fifth parameter.
instance.resize(true)
// @ts-expect-error Actual destroy is synchronous.
const asyncDestroy: Promise<void> = instance.destroy()
// @ts-expect-error Actual setVideo is synchronous.
const asyncVideo: Promise<void> = instance.setVideo(art.video)
// @ts-expect-error Actual resize is synchronous.
const asyncResize: Promise<void> = instance.resize()
// @ts-expect-error Registration is synchronous.
runtime()(art).then(() => {})
// @ts-expect-error sendMessage waits for initialization and returns a Promise.
const syncMessage: void = instance.sendMessage('custom')
// @ts-expect-error Style is the numeric libass index.
instance.createEvent({ Style: 'Default' })
// @ts-expect-error Worker responses do not contain _index.
const index: number = ({} as AssEvent)._index
// @ts-expect-error Callback errors may omit the event list.
instance.getEvents((_error, events) => { const required: AssEvent[] = events; void required }) // eslint-disable-line style/max-statements-per-line -- Keep this rejected callback on one diagnostic line.
// @ts-expect-error Font data must be a URL or bytes.
instance.addFont(1)
// @ts-expect-error Unknown instance methods are not hidden by an any index.
instance.nonexistentMethod()
// @ts-expect-error Only two blend modes are supported.
runtime({ blendMode: 'gpu' })
void [asyncDestroy, asyncVideo, asyncResize, defaults, destroyed, error, errorOrEmpty, factory, index, replacement, resize, sent, syncMessage, target, video]