mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-27 17:51:20 -08:00
refactor(auto-thumbnail): [PKG-AUTO-THUMB-03] own extraction jobs and encoded URLs
This commit is contained in:
1 parent
9cd6659ab4
commit
84721f19ed
21 files changed
+2641
-154
No files matched your search
@@ -0,0 +1,66 @@
|
||||
import { sheetSize } from './options'
|
||||
|
||||
export default function extract(job, config) {
|
||||
const video = document.createElement('video')
|
||||
job.own(() => video.load())
|
||||
job.own(() => video.removeAttribute('src'))
|
||||
job.own(() => video.pause())
|
||||
for (const property of ['onloadedmetadata', 'onseeked', 'onerror'])
|
||||
job.own(() => { video[property] = null })
|
||||
if (!job.active())
|
||||
return
|
||||
video.crossOrigin = 'anonymous'
|
||||
video.onerror = job.guard(() => {
|
||||
throw video.error || new Error('Auto-thumbnail media failed to load')
|
||||
})
|
||||
video.onloadedmetadata = job.guard(() => {
|
||||
video.onloadedmetadata = null
|
||||
const duration = video.duration
|
||||
const { height, canvasWidth, canvasHeight } = sheetSize(config, {
|
||||
duration,
|
||||
videoHeight: video.videoHeight,
|
||||
videoWidth: video.videoWidth,
|
||||
})
|
||||
if (!job.active())
|
||||
return
|
||||
const canvas = document.createElement('canvas')
|
||||
if (!job.active())
|
||||
return
|
||||
canvas.width = canvasWidth
|
||||
canvas.height = canvasHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx)
|
||||
throw new Error('Auto-thumbnail canvas context is unavailable')
|
||||
if (!job.active())
|
||||
return
|
||||
let index = 0
|
||||
const seek = job.guard(() => {
|
||||
if (index >= config.number) {
|
||||
job.dispose()
|
||||
return
|
||||
}
|
||||
video.onseeked = job.guard(() => {
|
||||
video.onseeked = null
|
||||
ctx.drawImage(video, (index % 10) * config.width, Math.floor(index / 10) * height, config.width, height)
|
||||
if (!job.active())
|
||||
return
|
||||
let delivered = false
|
||||
canvas.toBlob(job.guard((blob) => {
|
||||
if (delivered)
|
||||
return
|
||||
delivered = true
|
||||
job.publish(blob, { height, column: 10, number: config.number, width: config.width, scale: config.scale })
|
||||
if (job.active()) {
|
||||
index += 1
|
||||
seek()
|
||||
}
|
||||
}), 'image/jpeg')
|
||||
})
|
||||
if (job.active())
|
||||
video.currentTime = duration * index / config.number
|
||||
})
|
||||
seek()
|
||||
})
|
||||
if (job.active())
|
||||
video.src = config.url
|
||||
}
|
||||
@@ -1,64 +1,44 @@
|
||||
function create({ url, width, number }, callback) {
|
||||
const video = document.createElement('video')
|
||||
video.crossOrigin = 'anonymous'
|
||||
video.src = url
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
const duration = video.duration
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const height = Math.floor((width * video.videoHeight) / video.videoWidth)
|
||||
|
||||
canvas.width = width * 10
|
||||
canvas.height = height * Math.ceil(number / 10)
|
||||
|
||||
let blobUrl = null
|
||||
|
||||
function seekAndDraw(index) {
|
||||
canvas.toBlob((blob) => {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
callback({
|
||||
url: blobUrl,
|
||||
height,
|
||||
})
|
||||
}, 'image/jpeg')
|
||||
|
||||
if (index >= number)
|
||||
return
|
||||
video.currentTime = (duration * index) / number
|
||||
|
||||
video.onseeked = () => {
|
||||
ctx.drawImage(video, (index % 10) * width, Math.floor(index / 10) * height, width, height)
|
||||
seekAndDraw(index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
seekAndDraw(0)
|
||||
}
|
||||
}
|
||||
import extract from './extraction'
|
||||
import { readOptions } from './options'
|
||||
import createSession, { cleanupAll } from './session'
|
||||
|
||||
export default function artplayerPluginAutoThumbnail(option) {
|
||||
return async (art) => {
|
||||
art.on('video:loadedmetadata', () => {
|
||||
const url = option.url || art.option.url
|
||||
const width = option.width || 160
|
||||
const number = option.number || 100
|
||||
const scale = option.scale || 1
|
||||
create({ url, width, number }, (config) => {
|
||||
art.thumbnails = {
|
||||
...config,
|
||||
column: 10,
|
||||
number,
|
||||
width,
|
||||
scale,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
name: 'artplayerPluginAutoThumbnail',
|
||||
const report = error => console.warn('ArtPlayer auto-thumbnail failed:', error)
|
||||
const session = createSession((config) => {
|
||||
art.thumbnails = config
|
||||
}, report)
|
||||
const subscriptions = []
|
||||
const onMetadata = () => {
|
||||
const job = session.start()
|
||||
if (job) {
|
||||
job.guard(() => {
|
||||
const config = readOptions(option, () => art.option.url)
|
||||
if (job.active())
|
||||
extract(job, config)
|
||||
})()
|
||||
}
|
||||
}
|
||||
const onDestroy = () => {
|
||||
const actions = subscriptions.splice(0)
|
||||
const result = cleanupAll([session.destroy, ...actions])
|
||||
if (result.failed)
|
||||
report(result.failure)
|
||||
}
|
||||
try {
|
||||
for (const [name, callback] of [['destroy', onDestroy], ['restart', session.cancel], ['video:loadedmetadata', onMetadata]]) {
|
||||
if (session.closed)
|
||||
break
|
||||
subscriptions.push(() => art.off(name, callback))
|
||||
art.on(name, callback)
|
||||
if (session.closed)
|
||||
art.off(name, callback)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
onDestroy()
|
||||
throw error
|
||||
}
|
||||
return { name: 'artplayerPluginAutoThumbnail' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function readOptions(option, getFallbackUrl) {
|
||||
const config = {
|
||||
url: option.url || getFallbackUrl(),
|
||||
width: option.width || 160,
|
||||
number: option.number || 100,
|
||||
scale: option.scale || 1,
|
||||
}
|
||||
if (!Number.isFinite(Number(config.width)) || config.width <= 0 || !Number.isFinite(Number(config.number)) || config.number <= 0)
|
||||
throw new TypeError('Auto-thumbnail width and number must be finite positive numbers')
|
||||
return config
|
||||
}
|
||||
|
||||
export function sheetSize(config, video) {
|
||||
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)
|
||||
const canvasWidth = Math.trunc(config.width * 10)
|
||||
const canvasHeight = Math.trunc(height * Math.ceil(config.number / 10))
|
||||
if (![height, canvasWidth, canvasHeight].every(value => Number.isSafeInteger(value) && value > 0 && value <= 0xFFFFFFFF))
|
||||
throw new RangeError('Auto-thumbnail canvas dimensions are invalid')
|
||||
return { height, canvasWidth, canvasHeight }
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
export function cleanupAll(actions) {
|
||||
let failure
|
||||
let failed = false
|
||||
for (const action of actions) {
|
||||
try {
|
||||
action()
|
||||
}
|
||||
catch (error) {
|
||||
if (!failed) {
|
||||
failed = true
|
||||
failure = error
|
||||
}
|
||||
}
|
||||
}
|
||||
return { failed, failure }
|
||||
}
|
||||
|
||||
export default function createSession(publish, report) {
|
||||
let closed = false
|
||||
let current
|
||||
let lastUrl
|
||||
const urls = new Set()
|
||||
|
||||
function release(url) {
|
||||
if (urls.delete(url))
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
const previous = current
|
||||
current = undefined
|
||||
previous?.dispose()
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (closed)
|
||||
return
|
||||
closed = true
|
||||
const result = cleanupAll([cancel, ...[...urls].map(url => () => release(url))])
|
||||
lastUrl = undefined
|
||||
if (result.failed)
|
||||
report(result.failure)
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (closed)
|
||||
return
|
||||
// Install the replacement before cleanup, which can reenter through host APIs.
|
||||
const previous = current
|
||||
const actions = []
|
||||
let disposed = false
|
||||
const job = {
|
||||
active: () => !closed && !disposed && current === job,
|
||||
own(action) {
|
||||
if (disposed) {
|
||||
const result = cleanupAll([action])
|
||||
if (result.failed)
|
||||
report(result.failure)
|
||||
}
|
||||
else {
|
||||
actions.push(action)
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
if (disposed)
|
||||
return
|
||||
disposed = true
|
||||
if (current === job)
|
||||
current = undefined
|
||||
const result = cleanupAll(actions.splice(0).reverse())
|
||||
if (result.failed)
|
||||
report(result.failure)
|
||||
},
|
||||
fail(error) {
|
||||
if (!job.active())
|
||||
return
|
||||
job.dispose()
|
||||
report(error)
|
||||
},
|
||||
guard(callback) {
|
||||
return (...args) => {
|
||||
if (!job.active())
|
||||
return
|
||||
try {
|
||||
return callback(...args)
|
||||
}
|
||||
catch (error) {
|
||||
job.fail(error)
|
||||
}
|
||||
}
|
||||
},
|
||||
publish(blob, config) {
|
||||
if (!job.active())
|
||||
return
|
||||
if (!blob)
|
||||
throw new Error('Auto-thumbnail encoding returned no Blob')
|
||||
const url = URL.createObjectURL(blob)
|
||||
urls.add(url)
|
||||
if (!job.active()) {
|
||||
release(url)
|
||||
return
|
||||
}
|
||||
const previousUrl = lastUrl
|
||||
lastUrl = url
|
||||
try {
|
||||
publish({ url, ...config })
|
||||
}
|
||||
catch (error) {
|
||||
if (lastUrl === url)
|
||||
lastUrl = urls.has(previousUrl) ? previousUrl : undefined
|
||||
const result = cleanupAll([() => release(url)])
|
||||
if (result.failed)
|
||||
report(result.failure)
|
||||
throw error
|
||||
}
|
||||
release(previousUrl)
|
||||
},
|
||||
}
|
||||
current = job
|
||||
previous?.dispose()
|
||||
return job
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
cancel,
|
||||
destroy,
|
||||
get closed() { return closed },
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user