refactor(auto-thumbnail): [PKG-AUTO-THUMB-03] type internal extraction contracts

This commit is contained in:
Harvey Zhao committed 2026-09-13 23:39:06 +08:00
1 parent 00e81309ca
commit 6c989deccd
20 files changed
+3968 -48

No files matched your search

@@ -1,8 +1,9 @@
import type { ExtractionConfig, ExtractionJob } from './types'
import createFrameReader from './frames'
import { sheetSize } from './options'
import createVideo from './video'
export default function extract(job, config) {
export default function extract(job: ExtractionJob, config: ExtractionConfig) {
const video = createVideo(job)
if (!job.active())
return
@@ -1,9 +1,20 @@
import type { ExtractionJob } from './types'
import { cleanupAll } from './session'
export default function createFrameReader(job, video) {
interface PendingFrame {
frame: number | null
timer: ReturnType<typeof setTimeout> | null
sought: boolean
presented: boolean
retries: number
sequence: number
started: boolean
}
export default function createFrameReader(job: ExtractionJob, video: HTMLVideoElement) {
const presentedFrames = typeof video.requestVideoFrameCallback === 'function'
&& typeof video.cancelVideoFrameCallback === 'function'
let pending
let pending: PendingFrame | undefined
function clear() {
const previous = pending
@@ -13,7 +24,10 @@ export default function createFrameReader(job, video) {
if (!previous)
return
const result = cleanupAll([
() => clearTimeout(previous.timer),
() => {
if (previous.timer !== null)
clearTimeout(previous.timer)
},
() => {
if (previous.frame !== null)
video.cancelVideoFrameCallback(previous.frame)
@@ -24,11 +38,11 @@ export default function createFrameReader(job, video) {
}
job.own(clear)
return (target, draw) => {
return (target: number, draw: () => void) => {
clear()
if (!job.active())
return
const current = { frame: null, timer: null, sought: false, presented: !presentedFrames, retries: 0, sequence: 0, started: false }
const current: PendingFrame = { frame: null, timer: null, sought: false, presented: !presentedFrames, retries: 0, sequence: 0, started: false }
pending = current
const active = () => job.active() && pending === current
const finish = job.guard(() => {
@@ -1,14 +1,15 @@
import type { Cleanup, ThumbnailHost, ThumbnailOptions } from './types'
import extract from './extraction'
import { readOptions } from './options'
import createSession, { cleanupAll } from './session'
export default function artplayerPluginAutoThumbnail(option) {
return async (art) => {
const report = error => console.warn('ArtPlayer auto-thumbnail failed:', error)
export default function artplayerPluginAutoThumbnail(option: ThumbnailOptions) {
return async (art: ThumbnailHost) => {
const report = (error: unknown) => console.warn('ArtPlayer auto-thumbnail failed:', error)
const session = createSession((config) => {
art.thumbnails = config
}, report)
const subscriptions = []
const subscriptions: Cleanup[] = []
const onMetadata = () => {
const job = session.start()
if (job) {
@@ -26,7 +27,8 @@ export default function artplayerPluginAutoThumbnail(option) {
report(result.failure)
}
try {
for (const [name, callback] of [['destroy', onDestroy], ['restart', session.cancel], ['video:loadedmetadata', onMetadata]]) {
const events: [string, Cleanup][] = [['destroy', onDestroy], ['restart', session.cancel], ['video:loadedmetadata', onMetadata]]
for (const [name, callback] of events) {
if (session.closed)
break
subscriptions.push(() => art.off(name, callback))
@@ -1,4 +1,6 @@
export function readOptions(option, getFallbackUrl) {
import type { ExtractionConfig, ThumbnailOptions } from './types'
export function readOptions(option: ThumbnailOptions, getFallbackUrl: () => string): ExtractionConfig {
const config = {
url: option.url || getFallbackUrl(),
width: option.width || 160,
@@ -10,7 +12,7 @@ export function readOptions(option, getFallbackUrl) {
return config
}
export function sheetSize(config, video) {
export function sheetSize(config: ExtractionConfig, video: Pick<HTMLVideoElement, 'duration' | 'videoWidth' | 'videoHeight'>) {
if (!Number.isFinite(video.duration) || video.duration <= 0 || !Number.isFinite(video.videoWidth) || video.videoWidth <= 0 || !Number.isFinite(video.videoHeight) || video.videoHeight <= 0)
throw new TypeError('Auto-thumbnail requires finite media duration and dimensions')
const height = Math.floor(config.width * video.videoHeight / video.videoWidth)
@@ -1,5 +1,7 @@
export function cleanupAll(actions) {
let failure
import type { Cleanup, ExtractionJob, ThumbnailSheet } from './types'
export function cleanupAll(actions: Cleanup[]) {
let failure: unknown
let failed = false
for (const action of actions) {
try {
@@ -15,14 +17,14 @@ export function cleanupAll(actions) {
return { failed, failure }
}
export default function createSession(publish, report) {
export default function createSession(publish: (sheet: ThumbnailSheet) => void, report: (error: unknown) => void) {
let closed = false
let current
let lastUrl
const urls = new Set()
let current: ExtractionJob | undefined
let lastUrl: string | undefined
const urls = new Set<string>()
function release(url) {
if (urls.delete(url))
function release(url: string | undefined) {
if (url !== undefined && urls.delete(url))
URL.revokeObjectURL(url)
}
@@ -47,9 +49,9 @@ export default function createSession(publish, report) {
return
// Install the replacement before cleanup, which can reenter through host APIs.
const previous = current
const actions = []
const actions: Cleanup[] = []
let disposed = false
const job = {
const job: ExtractionJob = {
active: () => !closed && !disposed && current === job,
own(action) {
if (disposed) {
@@ -107,7 +109,7 @@ export default function createSession(publish, report) {
}
catch (error) {
if (lastUrl === url)
lastUrl = urls.has(previousUrl) ? previousUrl : undefined
lastUrl = previousUrl !== undefined && urls.has(previousUrl) ? previousUrl : undefined
const result = cleanupAll([() => release(url)])
if (result.failed)
report(result.failure)
@@ -0,0 +1,43 @@
// Internal contracts only. Public declarations retain their historical entrypoint.
export interface ThumbnailOptions {
url?: string
width?: number
number?: number
scale?: number
height?: number
}
export interface ExtractionConfig {
url: string
width: number
number: number
scale: number
}
export interface SheetConfig {
height: number
column: number
number: number
width: number
scale: number
}
export interface ThumbnailSheet extends SheetConfig { url: string }
export type Cleanup = () => void
export interface ExtractionJob {
active: () => boolean
own: (action: Cleanup) => void
dispose: Cleanup
fail: (error: unknown) => void
guard: <Args extends unknown[], Result>(callback: (...args: Args) => Result) => (...args: Args) => Result | undefined
publish: (blob: Blob | null, config: SheetConfig) => void
}
export interface ThumbnailHost {
option: { url: string }
// A host may already hold a partial user configuration; generated sheets are complete.
thumbnails: { url: string } & Partial<SheetConfig>
on: (event: string, callback: Cleanup) => unknown
off: (event: string, callback: Cleanup) => unknown
}
@@ -1,10 +1,12 @@
export default function createVideo(job) {
import type { ExtractionJob } from './types'
export default function createVideo(job: ExtractionJob) {
const video = document.createElement('video')
job.own(() => video.remove())
job.own(() => video.load())
job.own(() => video.removeAttribute('src'))
job.own(() => video.pause())
for (const property of ['onloadedmetadata', 'onloadeddata', 'onseeked', 'onerror'])
for (const property of ['onloadedmetadata', 'onloadeddata', 'onseeked', 'onerror'] as const)
job.own(() => { video[property] = null })
if (!job.active())
return video