Files
ArtPlayer/packages/artplayer-tool-thumbnail/src/extraction.ts
T

246 lines
7.0 KiB
TypeScript

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: ArtplayerToolThumbnail, job: ExtractionJob) {
const { video } = job
const { width, number, begin, end } = tool.option
const height = (video.videoHeight / video.videoWidth) * width
const guard = (condition: unknown, message: string) => {
try {
tool.errorHandle(condition, message)
}
catch (error) {
job.reported = error
job.wasReported = true
throw error
}
}
tool.option.height = height
tool.option.begin = clamp(begin, 0, video.duration)
tool.option.end = clamp(end || video.duration, begin, video.duration)
guard(tool.option.end > tool.option.begin, 'End time must be greater than the start time')
guard(Number.isFinite(video.duration), 'Video duration must be finite')
tool.duration = tool.option.end - tool.option.begin
tool.density = number / tool.duration
guard(tool.file && video, 'Please select the video file first')
guard(!tool.processing, 'There is currently a task in progress, please wait a moment...')
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')!
tool.emit('canvas', canvas)
return { width, height, number, points, canvas, context }
}
function createJob(tool: ArtplayerToolThumbnail, state: LifecycleState) {
let epoch = state.epoch
const video = tool.video
let resolve!: () => void
let reject!: (error: unknown) => void
let timer: ReturnType<typeof setTimeout> | undefined
let settled = false
let running = false
let job: ExtractionJob
let frameCleanup = () => {}
const listeners: Cleanup[] = []
const promise = new Promise<void>((yes, no) => {
resolve = yes
reject = no
})
// Consumers still receive the rejecting promise; ignored cancellation is owned.
promise.catch(() => {})
const live = () => !settled && !state.closed && state.epoch === epoch && state.job === job
function finish() {
if (settled)
return false
settled = true
if (state.job === job) {
state.job = null
tool.processing = false
}
cleanupAll([() => clearTimeout(timer), frameCleanup, ...listeners.splice(0)])
return true
}
function fail(error: unknown, report = true) {
if (settled)
return
try {
finish()
}
catch (cleanupError) {
error ||= cleanupError
}
if (report) {
try {
tool.emit('error', (error as { message?: unknown } | null | undefined)?.message)
}
catch (listenerError) {
error = listenerError
}
}
reject(error)
}
function complete() {
if (!live())
return
try {
finish()
tool.emit('done')
resolve()
}
catch (error) {
let failure = error
try {
tool.emit('error', (error as { message?: unknown } | null | undefined)?.message)
}
catch (listenerError) {
failure = listenerError
}
reject(failure)
}
}
function extract(plan: ReturnType<typeof prepare>) {
if (!live())
return
running = true
tool.processing = true
let index = 0
const next = () => {
if (!live())
return
if (index === plan.points.length) {
complete()
return
}
const point = plan.points[index]!
let drawing = false
let encoded = false
const previous = video.oncanplay
const draw = () => {
if (!live() || drawing || video.seeking || (video.readyState !== undefined && video.readyState < 2))
return
drawing = true
try {
frameCleanup()
plan.context.drawImage(video, point.x, point.y, plan.width, plan.height)
if (!live())
return
plan.canvas.toBlob((blob) => {
if (!live() || encoded)
return
encoded = true
try {
if (!blob)
throw new Error('Unable to create thumbnail image')
const url = replaceThumbnail(tool, blob, live)
if (!url)
return
tool.emit('update', url, (index + 1) / plan.number)
index++
Promise.resolve().then(next)
}
catch (error) {
fail(error)
}
})
}
catch (error) {
fail(error)
}
}
frameCleanup = () => {
if (video.oncanplay === draw)
video.oncanplay = previous
video.removeEventListener('seeked', draw)
}
try {
video.oncanplay = draw
video.addEventListener('seeked', draw)
video.currentTime = point.time
if (video.readyState >= 2 && !video.seeking)
Promise.resolve().then(draw)
}
catch (error) {
fail(error)
}
}
Promise.resolve().then(next)
}
function ready(synchronous = false) {
if (!live() || running)
return
clearTimeout(timer)
if (!state.loading && video.duration) {
running = true
try {
const plan = prepare(tool, job)
extract(plan)
}
catch (error) {
fail(error, !synchronous && !(job.wasReported && error === job.reported))
if (synchronous)
throw error
}
}
else {
timer = setTimeout(() => ready(), 1000)
if (!live())
clearTimeout(timer)
}
}
job = {
video,
promise,
ready,
fail,
cancel(reason) { fail(cancellation(reason), false) },
adopt(sourceEpoch) {
if (settled || running || video !== tool.video)
return false
epoch = sourceEpoch
return true
},
}
state.job = job
try {
for (const [name, callback] of [
['error', () => {
if (live())
fail(new Error(`Unable to load video: media error ${video.error?.code || 0}`))
}],
['loadedmetadata', () => ready()],
['durationchange', () => ready()],
] as const) {
listeners.push(() => video.removeEventListener(name, callback))
video.addEventListener(name, callback)
if (!live()) {
cleanupAll([() => video.removeEventListener(name, callback), ...listeners.splice(0)])
return promise
}
}
if (video.error)
fail(new Error(`Unable to load video: media error ${video.error.code}`))
else
ready(true)
}
catch (error) {
fail(error, false)
throw error
}
return promise
}
export function startExtraction(tool: ArtplayerToolThumbnail): Promise<void> {
const state = stateFor(tool)
if (state.closed) {
const promise = Promise.reject(cancellation('destroyed'))
promise.catch(() => {})
return promise
}
tool.errorHandle(!state.job, 'There is currently a task in progress, please wait a moment...')
return createJob(tool, state)
}