refactor(thumbnail): [PKG-TOOL-THUMB-04] type runtime modules and retain emitter attribution

This commit is contained in:
Harvey Zhao committed 2026-09-13 04:45:01 +08:00
1 parent efd9f42fb9
commit 15485b6420
37 files changed
+8446 -152

No files matched your search

@@ -1,44 +0,0 @@
export default class Emitter {
on(name, fn, ctx) {
const e = this.e || (this.e = {});
(e[name] || (e[name] = [])).push({ fn, ctx })
return this
}
once(name, fn, ctx) {
const self = this
function listener(...args) {
self.off(name, listener)
fn.apply(ctx, args)
}
listener._ = fn
return this.on(name, listener, ctx)
}
emit(name, ...data) {
const evtArr = ((this.e || (this.e = {}))[name] || []).slice()
for (let i = 0; i < evtArr.length; i += 1) {
evtArr[i].fn.apply(evtArr[i].ctx, data)
}
return this
}
off(name, callback) {
const e = this.e || (this.e = {})
const evts = e[name]
const liveEvents = []
if (evts && callback) {
for (let i = 0, len = evts.length; i < len; i += 1) {
if (evts[i].fn !== callback && evts[i].fn._ !== callback)
liveEvents.push(evts[i])
}
}
if (liveEvents.length) {
e[name] = liveEvents
}
else {
delete e[name]
}
return this
}
}
@@ -0,0 +1,58 @@
// Locally adapted from tiny-emitter; upstream reference 2.1.0. See THIRD_PARTY_NOTICES.
type EventMap<Events> = { [Name in keyof Events]: readonly unknown[] }
type Listener<Args extends unknown[]> = ((...args: Args) => unknown) & { _?: (...args: Args) => unknown }
interface Registration<Args extends unknown[]> {
fn: Listener<Args>
ctx: unknown
}
type Registry<Events extends EventMap<Events>> = { [Name in keyof Events]?: Registration<[...Events[Name]]>[] }
export default class Emitter<Events extends EventMap<Events> = Record<PropertyKey, unknown[]>> {
declare e?: Registry<Events>
on<Name extends keyof Events, Context>(name: Name, fn: (this: Context, ...args: [...Events[Name]]) => unknown, ctx?: Context): this {
const e: Registry<Events> = this.e || (this.e = {});
(e[name] || (e[name] = [])).push({ fn, ctx })
return this
}
once<Name extends keyof Events, Context>(name: Name, fn: (this: Context, ...args: [...Events[Name]]) => unknown, ctx?: Context): this {
// eslint-disable-next-line ts/no-this-alias -- Preserve the owning emitter independently of callback ctx.
const self = this
const callback: Listener<[...Events[Name]]> = fn
function listener(...args: [...Events[Name]]) {
self.off(name, listener)
callback.apply(ctx, args)
}
listener._ = fn
return this.on(name, listener, ctx)
}
emit<Name extends keyof Events>(name: Name, ...data: [...Events[Name]]): this {
const e: Registry<Events> = this.e || (this.e = {})
const evtArr = (e[name] || []).slice()
for (let i = 0; i < evtArr.length; i += 1) {
evtArr[i]!.fn.apply(evtArr[i]!.ctx, data)
}
return this
}
off<Name extends keyof Events>(name: Name, callback?: Listener<[...Events[Name]]>): this {
const e: Registry<Events> = this.e || (this.e = {})
const evts = e[name]
const liveEvents: Registration<[...Events[Name]]>[] = []
if (evts && callback) {
for (let i = 0, len = evts.length; i < len; i += 1) {
if (evts[i]!.fn !== callback && evts[i]!.fn._ !== callback)
liveEvents.push(evts[i]!)
}
}
if (liveEvents.length) {
e[name] = liveEvents
}
else {
delete e[name]
}
return this
}
}
@@ -1,12 +1,14 @@
import type ArtplayerToolThumbnail from './index'
import type { Cleanup, ExtractionJob, LifecycleState } from './types'
import { cancellation, cleanupAll, stateFor } from './lifecycle'
import { replaceThumbnail } from './source'
import { clamp } from './utils'
function prepare(tool, job) {
function prepare(tool: ArtplayerToolThumbnail, job: ExtractionJob) {
const { video } = job
const { width, number, begin, end } = tool.option
const height = (video.videoHeight / video.videoWidth) * width
const guard = (condition, message) => {
const guard = (condition: unknown, message: string) => {
try {
tool.errorHandle(condition, message)
}
@@ -28,23 +30,23 @@ function prepare(tool, job) {
guard(tool.density <= 1, `The preview density cannot be greater than 1, but got ${tool.density}`)
const points = tool.creatScreenshotDate()
const canvas = tool.creatCanvas()
const context = canvas.getContext('2d')
const context = canvas.getContext('2d')!
tool.emit('canvas', canvas)
return { width, height, number, points, canvas, context }
}
function createJob(tool, state) {
function createJob(tool: ArtplayerToolThumbnail, state: LifecycleState) {
let epoch = state.epoch
const video = tool.video
let resolve
let reject
let timer
let resolve!: () => void
let reject!: (error: unknown) => void
let timer: ReturnType<typeof setTimeout> | undefined
let settled = false
let running = false
let job
let job: ExtractionJob
let frameCleanup = () => {}
const listeners = []
const promise = new Promise((yes, no) => {
const listeners: Cleanup[] = []
const promise = new Promise<void>((yes, no) => {
resolve = yes
reject = no
})
@@ -62,7 +64,7 @@ function createJob(tool, state) {
cleanupAll([() => clearTimeout(timer), frameCleanup, ...listeners.splice(0)])
return true
}
function fail(error, report = true) {
function fail(error: unknown, report = true) {
if (settled)
return
try {
@@ -73,7 +75,7 @@ function createJob(tool, state) {
}
if (report) {
try {
tool.emit('error', error?.message)
tool.emit('error', (error as { message?: string } | null | undefined)?.message)
}
catch (listenerError) {
error = listenerError
@@ -92,7 +94,7 @@ function createJob(tool, state) {
catch (error) {
let failure = error
try {
tool.emit('error', error?.message)
tool.emit('error', (error as { message?: string } | null | undefined)?.message)
}
catch (listenerError) {
failure = listenerError
@@ -100,7 +102,7 @@ function createJob(tool, state) {
reject(failure)
}
}
function extract(plan) {
function extract(plan: ReturnType<typeof prepare>) {
if (!live())
return
running = true
@@ -113,7 +115,7 @@ function createJob(tool, state) {
complete()
return
}
const point = plan.points[index]
const point = plan.points[index]!
let drawing = false
let encoded = false
const previous = video.oncanplay
@@ -211,7 +213,7 @@ function createJob(tool, state) {
}],
['loadedmetadata', () => ready()],
['durationchange', () => ready()],
]) {
] as const) {
listeners.push(() => video.removeEventListener(name, callback))
video.addEventListener(name, callback)
if (!live()) {
@@ -231,7 +233,7 @@ function createJob(tool, state) {
return promise
}
export function startExtraction(tool) {
export function startExtraction(tool: ArtplayerToolThumbnail): Promise<void> {
const state = stateFor(tool)
if (state.closed) {
const promise = Promise.reject(cancellation('destroyed'))
@@ -1,3 +1,4 @@
import type { InputOptions, RuntimeOptions, ThumbnailEvents } from './types'
import Emitter from './emitter'
import { startExtraction } from './extraction'
import { connectInput, releaseInput, setupInput } from './input'
@@ -5,11 +6,21 @@ import { cleanupAll, closeState, releaseMedia, stateFor } from './lifecycle'
import { createSheet, downloadSheet, screenshotPoints } from './sheet'
import { loadSource } from './source'
export default class ArtplayerToolThumbnail extends Emitter {
constructor(option = {}) {
export default class ArtplayerToolThumbnail extends Emitter<ThumbnailEvents> {
declare processing: boolean
declare option: RuntimeOptions
declare video: HTMLVideoElement
declare duration: number
declare density: number | undefined
declare file: File | undefined
declare videoUrl: string | undefined
declare thumbnailUrl: string | undefined
constructor(option: InputOptions = {}) {
super()
this.processing = false
this.option = {}
// setup validates the temporary empty options before construction can succeed.
this.option = {} as RuntimeOptions
try {
this.setup(Object.assign({}, ArtplayerToolThumbnail.DEFAULTS, option))
this.video = ArtplayerToolThumbnail.creatVideo()
@@ -44,19 +55,19 @@ export default class ArtplayerToolThumbnail extends Emitter {
}
}
static ondragover(event) {
static ondragover(event: DragEvent) {
event.preventDefault()
}
ondrop(event) {
ondrop(event: DragEvent) {
if (stateFor(this).closed)
return
event.preventDefault()
const file = event.dataTransfer.files[0]
const file = event.dataTransfer!.files[0]
this.loadVideo(file)
}
setup(option = {}) {
setup(option: InputOptions = {}) {
if (!stateFor(this).closed)
setupInput(this, option)
return this
@@ -80,15 +91,15 @@ export default class ArtplayerToolThumbnail extends Emitter {
return video
}
inputChange(event) {
inputChange(event: Event) {
if (stateFor(this).closed)
return
const file = this.option.fileInput.files[0]
const file = this.option.fileInput.files![0]
this.loadVideo(file)
event.target.value = ''
;(event.target as HTMLInputElement).value = ''
}
loadVideo(file) {
loadVideo(file?: File | null) {
loadSource(this, file)
}
@@ -112,12 +123,12 @@ export default class ArtplayerToolThumbnail extends Emitter {
'Download does not seem to be ready, please create preview first',
)
this.errorHandle(!this.processing, 'There is currently a task in progress, please wait a moment...')
const name = downloadSheet(this.file, this.thumbnailUrl)
const name = downloadSheet(this.file!, this.thumbnailUrl!)
this.emit('download', name)
return this
}
errorHandle(condition, msg) {
errorHandle(condition: unknown, msg: string) {
if (!condition) {
this.emit('error', msg)
throw new Error(msg)
@@ -1,10 +1,21 @@
import type ArtplayerToolThumbnail from './index'
import type { InputOptions } from './types'
import { cancellation, stateFor } from './lifecycle'
import { clamp } from './utils'
const inputs = new WeakMap()
type Registration = readonly [name: string, callback: EventListener]
interface InputRecord {
input: HTMLInputElement
wrapper: HTMLElement | null
position: string
listeners: Registration[]
callbacks: Registration[] | null
}
function release(record) {
let failure
const inputs = new WeakMap<ArtplayerToolThumbnail, InputRecord>()
function release(record: InputRecord) {
let failure: unknown
for (const [name, callback] of record.listeners.splice(0)) {
try {
record.input.removeEventListener(name, callback)
@@ -28,7 +39,7 @@ function release(record) {
throw failure
}
function connect(record, callbacks) {
function connect(record: InputRecord, callbacks: Registration[]) {
try {
for (const [name, callback] of callbacks) {
record.listeners.push([name, callback])
@@ -44,12 +55,12 @@ function connect(record, callbacks) {
}
}
export function setupInput(tool, patch) {
export function setupInput(tool: ArtplayerToolThumbnail, patch: InputOptions) {
const state = stateFor(tool)
const epoch = ++state.inputEpoch
const current = () => !state.closed && state.inputEpoch === epoch
const option = Object.assign({}, tool.option, patch)
const target = option.fileInput
const target = option.fileInput!
tool.errorHandle(target instanceof Element, 'The \'fileInput\' is not a Element')
for (const name of ['number', 'width', 'column', 'begin', 'end'])
tool.errorHandle(typeof option[name] === 'number', `The '${name}' is not a number`)
@@ -65,9 +76,10 @@ export function setupInput(tool, patch) {
tool.option = option
return option
}
const record = { input: target, wrapper: null, position: '', listeners: [], callbacks: previous?.callbacks || null }
// A wrapper is replaced by its owned file input before the record is committed.
const record: InputRecord = { input: target as HTMLInputElement, wrapper: null, position: '', listeners: [], callbacks: previous?.callbacks || null }
try {
if (!(target.tagName === 'INPUT' && target.type === 'file')) {
if (!(target.tagName === 'INPUT' && (target as HTMLInputElement).type === 'file')) {
record.wrapper = target
record.position = target.style.position
record.input = document.createElement('input')
@@ -98,13 +110,14 @@ export function setupInput(tool, patch) {
return option
}
export function connectInput(tool, dragover) {
const record = inputs.get(tool)
record.callbacks = [['change', tool.inputChange], ['dragover', dragover], ['drop', tool.ondrop]]
export function connectInput(tool: ArtplayerToolThumbnail, dragover: (event: DragEvent) => void) {
const record = inputs.get(tool)!
// These callbacks are registered only for their matching DOM event names.
record.callbacks = [['change', tool.inputChange], ['dragover', dragover as EventListener], ['drop', tool.ondrop as EventListener]]
connect(record, record.callbacks)
}
export function releaseInput(tool) {
export function releaseInput(tool: ArtplayerToolThumbnail) {
const record = inputs.get(tool)
if (!record)
return
@@ -1,6 +1,9 @@
const states = new WeakMap()
import type ArtplayerToolThumbnail from './index'
import type { Cleanup, LifecycleState } from './types'
export function stateFor(tool) {
const states = new WeakMap<ArtplayerToolThumbnail, LifecycleState>()
export function stateFor(tool: ArtplayerToolThumbnail): LifecycleState {
if (!states.has(tool)) {
states.set(tool, {
closed: false,
@@ -16,24 +19,24 @@ export function stateFor(tool) {
loading: false,
})
}
return states.get(tool)
return states.get(tool)!
}
export function cancellation(reason) {
export function cancellation(reason: string) {
const error = new Error(`Thumbnail task cancelled: ${reason}`)
error.name = 'AbortError'
return error
}
export function revoke(urls, url) {
export function revoke(urls: Set<string>, url: string | undefined) {
if (!url)
return
urls.delete(url)
URL.revokeObjectURL(url)
}
export function cleanupAll(callbacks) {
let failure
export function cleanupAll(callbacks: Cleanup[]) {
let failure: unknown
for (const callback of callbacks) {
try {
callback()
@@ -46,7 +49,7 @@ export function cleanupAll(callbacks) {
throw failure
}
export function closeState(tool, reason = 'destroyed') {
export function closeState(tool: ArtplayerToolThumbnail, reason = 'destroyed') {
const state = stateFor(tool)
if (state.closed)
return false
@@ -56,11 +59,11 @@ export function closeState(tool, reason = 'destroyed') {
return true
}
export function releaseMedia(tool) {
export function releaseMedia(tool: ArtplayerToolThumbnail) {
const state = stateFor(tool)
const sources = new Set([...state.sourceUrls, tool.videoUrl])
const thumbnails = new Set([...state.thumbnailUrls, tool.thumbnailUrl])
const media = []
const media: Cleanup[] = []
for (const video of new Set([state.video, tool.video])) {
if (video) {
media.push(
@@ -1,11 +1,12 @@
import type { ScreenshotPoint, SheetOptions } from './types'
import { getFileName } from './utils'
export function screenshotPoints(option, duration) {
export function screenshotPoints(option: SheetOptions, duration: number): ScreenshotPoint[] {
const { number, width, height, column, begin } = option
const timeGap = duration / number
const timePoints = [begin + timeGap]
while (timePoints.length < number) {
const last = timePoints[timePoints.length - 1]
const last = timePoints[timePoints.length - 1]!
timePoints.push(last + timeGap)
}
return timePoints.map((item, index) => ({
@@ -15,10 +16,10 @@ export function screenshotPoints(option, duration) {
}))
}
export function createSheet(option) {
export function createSheet(option: SheetOptions) {
const { number, width, height, column } = option
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
const context = canvas.getContext('2d')!
canvas.width = width * column
canvas.height = Math.ceil(number / column) * height + 30
context.fillStyle = 'black'
@@ -33,7 +34,7 @@ export function createSheet(option) {
return canvas
}
export function downloadSheet(file, url) {
export function downloadSheet(file: File, url: string) {
const link = document.createElement('a')
const name = `${getFileName(file.name)}.png`
link.download = name
@@ -1,6 +1,8 @@
import type ArtplayerToolThumbnail from './index'
import type { Cleanup } from './types'
import { cleanupAll, revoke, stateFor } from './lifecycle'
export function loadSource(tool, file) {
export function loadSource(tool: ArtplayerToolThumbnail, file?: File | null) {
const state = stateFor(tool)
if (!file || state.closed)
return
@@ -23,7 +25,7 @@ export function loadSource(tool, file) {
state.loading = true
const current = () => !state.closed && state.epoch === epoch
const previousListeners = state.sourceListeners
const listeners = []
const listeners: Cleanup[] = []
state.sourceListeners = listeners
let pendingCleanup = () => {}
let reported = false
@@ -50,7 +52,7 @@ export function loadSource(tool, file) {
revoke(state.sourceUrls, url)
return
}
for (const [name, callback] of [['error', error], ['loadedmetadata', ready]]) {
for (const [name, callback] of [['error', error], ['loadedmetadata', ready]] as const) {
pendingCleanup = () => video.removeEventListener(name, callback)
listeners.push(pendingCleanup)
video.addEventListener(name, callback)
@@ -94,7 +96,7 @@ export function loadSource(tool, file) {
}
}
export function replaceThumbnail(tool, blob, live) {
export function replaceThumbnail(tool: ArtplayerToolThumbnail, blob: Blob, live: () => boolean) {
const state = stateFor(tool)
const url = URL.createObjectURL(blob)
state.thumbnailUrls.add(url)
@@ -0,0 +1,62 @@
export interface SheetOptions {
number: number
width: number
height: number
column: number
begin: number
end: number
}
export interface InputOptions extends Partial<SheetOptions> {
fileInput?: Element
[name: string]: unknown
}
export interface RuntimeOptions extends SheetOptions {
fileInput: HTMLInputElement
[name: string]: unknown
}
export interface ScreenshotPoint {
time: number
x: number
y: number
}
export interface ThumbnailEvents extends Record<PropertyKey, unknown[]> {
file: [file: File]
video: [video: HTMLVideoElement]
canvas: [canvas: HTMLCanvasElement]
update: [url: string, progress: number]
done: []
download: [name: string]
error: [message: string | undefined]
destroy: []
}
export type Cleanup = () => void
export interface ExtractionJob {
video: HTMLVideoElement
promise: Promise<void>
ready: (synchronous?: boolean) => void
fail: (error: unknown, report?: boolean) => void
cancel: (reason: string) => void
adopt: (epoch: number) => boolean
reported?: unknown
wasReported?: boolean
}
export interface LifecycleState {
closed: boolean
epoch: number
inputEpoch: number
job: ExtractionJob | null
video: HTMLVideoElement | null
sourceUrl: string | null
thumbnailUrl: string | null
sourceUrls: Set<string>
thumbnailUrls: Set<string>
sourceListeners: Cleanup[]
loading: boolean
}
@@ -1,17 +0,0 @@
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
export function runPromisesInSeries(ps) {
return ps.reduce((p, next) => p.then(next), Promise.resolve())
}
export function getFileName(name) {
const nameArray = name.split('.')
nameArray.pop()
return nameArray.join('.')
}
export function clamp(num, a, b) {
return Math.max(Math.min(num, Math.max(a, b)), Math.min(a, b))
}
@@ -0,0 +1,9 @@
export function getFileName(name: string) {
const nameArray = name.split('.')
nameArray.pop()
return nameArray.join('.')
}
export function clamp(num: number, a: number, b: number) {
return Math.max(Math.min(num, Math.max(a, b)), Math.min(a, b))
}