mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-28 18:41:20 -08:00
feat: add danmuku plugin documentation and i18n setup guide - Created a new documentation file for the danmuku plugin with code examples. - Added a comprehensive guide for language settings (i18n) including default languages, importing languages, adding new languages, and modifying existing languages. - Included code snippets for initializing the Artplayer with different language settings. Add internationalization support for multiple languages in Artplayer - Introduced Arabic (ar), Czech (cs), Spanish (es), Persian (fa), French (fr), Indonesian (id), Polish (pl), Russian (ru), Turkish (tr), Vietnamese (vi), and Traditional Chinese (zh-tw) translations. - Created corresponding JavaScript and ES module files for each language containing translated strings for player controls and settings. - Added a new documentation file for language settings in the project. feat: add initial implementation of start option page with interactive code execution buttons feat: add danmuku plugin documentation and i18n setup guide - Created a new documentation file for the danmuku plugin, providing code examples for usage. - Added a comprehensive guide for language settings, including default languages, importing languages, adding new languages, and modifying existing languages. - Included code snippets for initializing the Artplayer with different language settings and importing language files. refactor: update danmuku worker implementation and remove unused i18n files - Changed the way the Danmuku class creates a Web Worker by using a worker loader instead of creating a blob. - Removed Arabic, Czech, Spanish, Persian, French, Indonesian, Polish, Russian, Turkish, Vietnamese, and Traditional Chinese i18n files as they are no longer needed. - Updated the Vite build configuration to disable banners for inline workers and added a plugin to remove worker banners from generated code. feat: add danmuku plugin and i18n setup documentation - Created a new documentation file for the danmuku plugin, providing code examples for usage. - Added a new documentation file for language settings (i18n), detailing how to set default languages, import languages, and add new languages. - Included code snippets for initializing the Artplayer with different language settings and examples for modifying existing languages. feat: add danmuku plugin and i18n setup documentation - Created a new documentation file for the danmuku plugin with code examples. - Added a new documentation file for language settings (i18n) including import instructions and examples for setting default and custom languages. - Updated the i18n documentation to include details on adding and modifying languages. feat: add danmuku plugin and i18n setup documentation - Created a new JavaScript file for the danmuku plugin documentation with multiple code execution buttons. - Added a new JavaScript file for i18n (internationalization) setup documentation, including instructions for language settings, importing languages, and modifying language options. - Included examples for setting default languages and adding new languages in the i18n documentation. Refactor code structure for improved readability and maintainability
633 lines
16 KiB
JavaScript
633 lines
16 KiB
JavaScript
export default class Upscaler {
|
|
static DEFAULT_TIMEOUTS = { IMAGE: 300000, VIDEO: 3600000, METADATA: 10000 }
|
|
static DEFAULT_DELAYS = { INIT: 500, NETWORK: 300 }
|
|
|
|
static isSupported() {
|
|
try {
|
|
const hasWorker = typeof Worker !== 'undefined'
|
|
const hasOffscreen
|
|
= typeof OffscreenCanvas !== 'undefined'
|
|
|| (typeof document !== 'undefined'
|
|
&& !!document.createElement('canvas').transferControlToOffscreen)
|
|
const hasBlob = typeof Blob !== 'undefined'
|
|
return hasWorker && hasOffscreen && hasBlob
|
|
}
|
|
catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
static isVideoSupported() {
|
|
try {
|
|
return (
|
|
typeof VideoEncoder !== 'undefined'
|
|
&& typeof VideoDecoder !== 'undefined'
|
|
)
|
|
}
|
|
catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
constructor(options = {}) {
|
|
const weightsBaseUrl = options.weightsBaseUrl || '/weights'
|
|
|
|
this.networks = options.networks || {
|
|
small: {
|
|
name: 'anime4k/cnn-2x-l',
|
|
weightsUrl: `${weightsBaseUrl}/cnn-8.json`,
|
|
},
|
|
medium: {
|
|
name: 'anime4k/cnn-2x-16',
|
|
weightsUrl: `${weightsBaseUrl}/cnn-16.json`,
|
|
},
|
|
large: {
|
|
name: 'anime4k/cnn-2x-28',
|
|
weightsUrl: `${weightsBaseUrl}/cnn-28.json`,
|
|
},
|
|
}
|
|
|
|
this.weightsBaseUrl = weightsBaseUrl
|
|
this.networkSize = options.networkSize || 'medium'
|
|
this.workerUrl = options.workerUrl || '/worker/main.js'
|
|
|
|
this.timeouts = {
|
|
...Upscaler.DEFAULT_TIMEOUTS,
|
|
...(options.timeouts || {}),
|
|
}
|
|
this.delays = { ...Upscaler.DEFAULT_DELAYS, ...(options.delays || {}) }
|
|
|
|
this.imageScale
|
|
= typeof options.imageScale === 'number' && options.imageScale > 0
|
|
? options.imageScale
|
|
: 2
|
|
this.videoScale
|
|
= typeof options.videoScale === 'number' && options.videoScale > 0
|
|
? options.videoScale
|
|
: 2
|
|
|
|
this.weightsCache = new Map()
|
|
this.workerInstance = null
|
|
this.messageHandlers = {}
|
|
this.progressCallback = null
|
|
this.processingType = null
|
|
|
|
this.realtimeLoopId = null
|
|
this.realtimeState = null
|
|
|
|
this.init()
|
|
}
|
|
|
|
init({ prewarm = true } = {}) {
|
|
if (!Upscaler.isSupported()) {
|
|
throw new Error('Upscaler is not supported in this environment')
|
|
}
|
|
if (prewarm) {
|
|
this.getWorker().postMessage({ cmd: 'isSupported' })
|
|
}
|
|
return this
|
|
}
|
|
|
|
getWorker() {
|
|
if (!this.workerInstance) {
|
|
this.workerInstance = new Worker(this.workerUrl)
|
|
this.workerInstance.onmessage = event =>
|
|
this.handleWorkerMessage(event)
|
|
}
|
|
return this.workerInstance
|
|
}
|
|
|
|
static extractProgressValue(data) {
|
|
const value = data.data ?? data.progress ?? data.value ?? data.percentage
|
|
return typeof value === 'number'
|
|
? Math.min(100, Math.max(0, Math.round(value)))
|
|
: null
|
|
}
|
|
|
|
handleBlobResponse(data) {
|
|
const blobType
|
|
= this.processingType === 'video' ? 'videoBlob' : 'imageBlob'
|
|
const handler = this.messageHandlers[blobType]
|
|
if (data.data instanceof Blob && handler) {
|
|
handler({ [blobType]: data.data })
|
|
}
|
|
}
|
|
|
|
requestBlob() {
|
|
const cmd
|
|
= this.processingType === 'video' ? 'getVideoBlob' : 'getImageBlob'
|
|
this.getWorker().postMessage({ cmd })
|
|
}
|
|
|
|
handleWorkerMessage(event) {
|
|
const { data } = event
|
|
if (!data.cmd)
|
|
return
|
|
|
|
const { cmd } = data
|
|
|
|
if (cmd === 'progress') {
|
|
const progress = Upscaler.extractProgressValue(data)
|
|
if (progress !== null && this.progressCallback) {
|
|
this.progressCallback(progress)
|
|
}
|
|
if (this.messageHandlers.progress)
|
|
this.messageHandlers.progress(data)
|
|
}
|
|
else if (cmd === 'finished') {
|
|
if (data.data instanceof Blob) {
|
|
this.handleBlobResponse(data)
|
|
}
|
|
else {
|
|
this.requestBlob()
|
|
}
|
|
}
|
|
else if (this.messageHandlers[cmd]) {
|
|
this.messageHandlers[cmd](data)
|
|
}
|
|
}
|
|
|
|
async loadWeights(networkSize) {
|
|
this.validateNetworkSize(networkSize)
|
|
if (this.weightsCache.has(networkSize)) {
|
|
return this.weightsCache.get(networkSize)
|
|
}
|
|
const network = this.networks[networkSize]
|
|
const response = await fetch(network.weightsUrl)
|
|
if (!response.ok)
|
|
throw new Error(`Failed to fetch weights: ${response.statusText}`)
|
|
const weights = await response.json()
|
|
this.weightsCache.set(networkSize, weights)
|
|
return weights
|
|
}
|
|
|
|
static loadImageMetadata(arrayBuffer, mimeType) {
|
|
return new Promise((resolve, reject) => {
|
|
const blob = new Blob([arrayBuffer], { type: mimeType })
|
|
const url = URL.createObjectURL(blob)
|
|
const img = new Image()
|
|
|
|
img.onload = () => {
|
|
URL.revokeObjectURL(url)
|
|
resolve({ width: img.width, height: img.height })
|
|
}
|
|
|
|
img.onerror = () => {
|
|
URL.revokeObjectURL(url)
|
|
reject(new Error('Failed to load image'))
|
|
}
|
|
|
|
img.src = url
|
|
})
|
|
}
|
|
|
|
static loadVideoMetadata(file, timeoutMs) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = URL.createObjectURL(file)
|
|
const video = document.createElement('video')
|
|
video.src = url
|
|
|
|
const timeout = setTimeout(() => reject(new Error('Timeout')), timeoutMs)
|
|
|
|
video.onloadedmetadata = () => {
|
|
clearTimeout(timeout)
|
|
URL.revokeObjectURL(url)
|
|
resolve({
|
|
width: video.videoWidth,
|
|
height: video.videoHeight,
|
|
duration: video.duration,
|
|
})
|
|
}
|
|
|
|
video.onerror = () => {
|
|
clearTimeout(timeout)
|
|
URL.revokeObjectURL(url)
|
|
reject(new Error('Failed to load video'))
|
|
}
|
|
})
|
|
}
|
|
|
|
createOffscreenCanvas(width, height, scale = 2) {
|
|
const factor = typeof scale === 'number' && scale > 0 ? scale : 1
|
|
const canvas = document.createElement('canvas')
|
|
canvas.width = width * factor
|
|
canvas.height = height * factor
|
|
if (typeof canvas.transferControlToOffscreen !== 'function') {
|
|
throw new TypeError('OffscreenCanvas is not supported in this environment')
|
|
}
|
|
return canvas.transferControlToOffscreen()
|
|
}
|
|
|
|
createBlobPromise(blobType, timeout) {
|
|
return new Promise((resolve, reject) => {
|
|
const timeoutId = setTimeout(() => {
|
|
if (this.messageHandlers[blobType]) {
|
|
delete this.messageHandlers[blobType]
|
|
reject(new Error(`${blobType} processing timeout`))
|
|
}
|
|
}, timeout)
|
|
|
|
this.messageHandlers[blobType] = (msg) => {
|
|
delete this.messageHandlers[blobType]
|
|
clearTimeout(timeoutId)
|
|
const blob = msg[blobType] || msg.data
|
|
if (blob instanceof Blob) {
|
|
resolve(blob)
|
|
}
|
|
else {
|
|
reject(new Error(`Invalid ${blobType} format`))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
validateNetworkSize(size) {
|
|
if (!this.networks[size]) {
|
|
throw new Error(
|
|
`Invalid networkSize: use one of ${
|
|
Object.keys(this.networks).join(', ')}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
async upscaleImage(imageFile, networkSize, scaleOverride) {
|
|
if (!imageFile)
|
|
throw new Error('Image file is required')
|
|
const size = networkSize || this.networkSize
|
|
this.validateNetworkSize(size)
|
|
|
|
this.processingType = 'image'
|
|
const arrayBuffer = await imageFile.arrayBuffer()
|
|
const mimeType = imageFile.type || 'image/jpeg'
|
|
const { width, height } = await Upscaler.loadImageMetadata(
|
|
arrayBuffer,
|
|
mimeType,
|
|
)
|
|
|
|
const weights = await this.loadWeights(size)
|
|
const network = this.networks[size]
|
|
const scale
|
|
= typeof scaleOverride === 'number' && scaleOverride > 0
|
|
? scaleOverride
|
|
: this.imageScale
|
|
const [canvasUp, canvasOrig] = [
|
|
this.createOffscreenCanvas(width, height, scale),
|
|
this.createOffscreenCanvas(width, height, 1),
|
|
]
|
|
|
|
const blobPromise = this.createBlobPromise(
|
|
'imageBlob',
|
|
this.timeouts.IMAGE,
|
|
)
|
|
|
|
this.getWorker().postMessage(
|
|
{
|
|
cmd: 'init',
|
|
data: {
|
|
imageArrayBuffer: arrayBuffer,
|
|
imageMimeType: mimeType,
|
|
upscaled: canvasUp,
|
|
original: canvasOrig,
|
|
resolution: { width, height },
|
|
network_name: network.name,
|
|
weights,
|
|
},
|
|
},
|
|
[canvasUp, canvasOrig],
|
|
)
|
|
|
|
setTimeout(() => {
|
|
this.getWorker().postMessage({
|
|
cmd: 'network',
|
|
data: {
|
|
name: network.name,
|
|
imageArrayBuffer: arrayBuffer,
|
|
imageMimeType: mimeType,
|
|
weights,
|
|
},
|
|
})
|
|
|
|
setTimeout(() => {
|
|
this.getWorker().postMessage({
|
|
cmd: 'getImageBlob',
|
|
data: { imageArrayBuffer: arrayBuffer, imageMimeType: mimeType },
|
|
})
|
|
}, this.delays.NETWORK)
|
|
}, this.delays.INIT)
|
|
|
|
return blobPromise
|
|
}
|
|
|
|
async upscaleVideo(videoFile, networkSize, scaleOverride) {
|
|
if (!videoFile)
|
|
throw new Error('Video file is required')
|
|
const size = networkSize || this.networkSize
|
|
this.validateNetworkSize(size)
|
|
|
|
if (!Upscaler.isVideoSupported()) {
|
|
throw new Error('WebCodecs API not supported. Requires Chrome 94+')
|
|
}
|
|
|
|
this.processingType = 'video'
|
|
const { width, height, duration } = await Upscaler.loadVideoMetadata(
|
|
videoFile,
|
|
this.timeouts.METADATA,
|
|
)
|
|
|
|
const weights = await this.loadWeights(size)
|
|
const network = this.networks[size]
|
|
const scale
|
|
= typeof scaleOverride === 'number' && scaleOverride > 0
|
|
? scaleOverride
|
|
: this.videoScale
|
|
const [canvasOut, canvasIn] = [
|
|
this.createOffscreenCanvas(width, height, scale),
|
|
this.createOffscreenCanvas(width, height, 1),
|
|
]
|
|
|
|
const blobPromise = this.createBlobPromise(
|
|
'videoBlob',
|
|
this.timeouts.VIDEO,
|
|
)
|
|
|
|
this.getWorker().postMessage(
|
|
{
|
|
cmd: 'process',
|
|
file: videoFile,
|
|
fileSize: videoFile.size,
|
|
duration,
|
|
adjustedResolution: {
|
|
adjustedInputWidth: width,
|
|
adjustedInputHeight: height,
|
|
adjustedOutputWidth: width * scale,
|
|
adjustedOutputHeight: height * scale,
|
|
},
|
|
upscaled: canvasOut,
|
|
original: canvasIn,
|
|
weights,
|
|
network_name: network.name,
|
|
skipDemuxProgress: true,
|
|
},
|
|
[canvasOut, canvasIn],
|
|
)
|
|
|
|
return blobPromise
|
|
}
|
|
|
|
async startRealtimeUpscale(
|
|
videoElement,
|
|
canvasElement,
|
|
networkSize,
|
|
scaleOverride,
|
|
) {
|
|
if (!videoElement || !canvasElement) {
|
|
throw new Error('videoElement and canvasElement are required')
|
|
}
|
|
|
|
const size = networkSize || this.networkSize
|
|
this.validateNetworkSize(size)
|
|
|
|
const network = this.networks[size]
|
|
const weights = await this.loadWeights(size)
|
|
|
|
const scale
|
|
= typeof scaleOverride === 'number' && scaleOverride > 0
|
|
? scaleOverride
|
|
: this.videoScale
|
|
|
|
const ensureMetadata = () => {
|
|
return new Promise((resolve, reject) => {
|
|
if (videoElement.readyState >= 1) {
|
|
resolve()
|
|
return
|
|
}
|
|
let onLoaded, onError
|
|
const cleanup = () => {
|
|
videoElement.removeEventListener('loadedmetadata', onLoaded)
|
|
videoElement.removeEventListener('error', onError)
|
|
}
|
|
onLoaded = () => {
|
|
cleanup()
|
|
resolve()
|
|
}
|
|
onError = () => {
|
|
cleanup()
|
|
reject(new Error('Failed to load video metadata'))
|
|
}
|
|
videoElement.addEventListener('loadedmetadata', onLoaded)
|
|
videoElement.addEventListener('error', onError)
|
|
})
|
|
}
|
|
|
|
await ensureMetadata()
|
|
|
|
const width = videoElement.videoWidth
|
|
const height = videoElement.videoHeight
|
|
if (!width || !height) {
|
|
throw new Error('Invalid video dimensions')
|
|
}
|
|
|
|
const firstInit = !canvasElement._upscalerTransferred
|
|
|
|
let offscreen = null
|
|
if (firstInit) {
|
|
canvasElement.width = width * scale
|
|
canvasElement.height = height * scale
|
|
|
|
if (typeof canvasElement.transferControlToOffscreen !== 'function') {
|
|
throw new TypeError('OffscreenCanvas is not supported in this environment')
|
|
}
|
|
|
|
offscreen = canvasElement.transferControlToOffscreen()
|
|
canvasElement._upscalerTransferred = true
|
|
}
|
|
|
|
const captureCanvas = document.createElement('canvas')
|
|
captureCanvas.width = width
|
|
captureCanvas.height = height
|
|
const captureCtx = captureCanvas.getContext('2d')
|
|
|
|
if (this.realtimeLoopId) {
|
|
cancelAnimationFrame(this.realtimeLoopId)
|
|
this.realtimeLoopId = null
|
|
}
|
|
|
|
const initPayload = {
|
|
cmd: 'realtimeInit',
|
|
data: {
|
|
upscaled: firstInit ? offscreen : null,
|
|
resolution: {
|
|
width,
|
|
height,
|
|
scale,
|
|
outputWidth: width * scale,
|
|
outputHeight: height * scale,
|
|
},
|
|
network_name: network.name,
|
|
weights,
|
|
},
|
|
}
|
|
|
|
if (firstInit && offscreen) {
|
|
this.getWorker().postMessage(initPayload, [offscreen])
|
|
}
|
|
else {
|
|
this.getWorker().postMessage(initPayload)
|
|
}
|
|
|
|
this.realtimeState = {
|
|
running: true,
|
|
video: videoElement,
|
|
canvas: canvasElement,
|
|
scale,
|
|
busy: false,
|
|
frameIndex: 0,
|
|
captureCanvas,
|
|
captureCtx,
|
|
}
|
|
|
|
const loop = async () => {
|
|
if (!this.realtimeState || !this.realtimeState.running)
|
|
return
|
|
const state = this.realtimeState
|
|
const v = state.video
|
|
|
|
const haveCurrentData = v.HAVE_CURRENT_DATA || 2
|
|
|
|
if (
|
|
!v.paused
|
|
&& !v.ended
|
|
&& !state.busy
|
|
&& v.readyState >= haveCurrentData
|
|
&& v.videoWidth > 0
|
|
&& v.videoHeight > 0
|
|
) {
|
|
state.busy = true
|
|
try {
|
|
const captureCanvas = state.captureCanvas
|
|
const captureCtx = state.captureCtx
|
|
if (!captureCanvas || !captureCtx) {
|
|
throw new Error('Missing capture canvas')
|
|
}
|
|
captureCtx.drawImage(v, 0, 0, captureCanvas.width, captureCanvas.height)
|
|
const frame = await createImageBitmap(captureCanvas)
|
|
state.frameIndex += 1
|
|
|
|
this.getWorker().postMessage(
|
|
{
|
|
cmd: 'realtimeFrame',
|
|
frame,
|
|
},
|
|
[frame],
|
|
)
|
|
}
|
|
catch (e) {
|
|
console.warn('realtimeFrame error', e)
|
|
}
|
|
finally {
|
|
if (this.realtimeState) {
|
|
this.realtimeState.busy = false
|
|
}
|
|
}
|
|
}
|
|
|
|
this.realtimeLoopId = requestAnimationFrame(loop)
|
|
}
|
|
|
|
this.realtimeLoopId = requestAnimationFrame(loop)
|
|
}
|
|
|
|
stopRealtimeUpscale() {
|
|
if (this.realtimeLoopId) {
|
|
cancelAnimationFrame(this.realtimeLoopId)
|
|
this.realtimeLoopId = null
|
|
}
|
|
if (this.realtimeState) {
|
|
this.realtimeState.running = false
|
|
this.realtimeState = null
|
|
}
|
|
}
|
|
|
|
async downloadUpscaled(file, networkSize, filename = null) {
|
|
const isVideo = file.type.startsWith('video')
|
|
const size = networkSize || this.networkSize
|
|
const blob = isVideo
|
|
? await this.upscaleVideo(file, size)
|
|
: await this.upscaleImage(file, size)
|
|
const url = URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download
|
|
= filename || `upscaled_${Date.now()}.${isVideo ? 'mp4' : 'png'}`
|
|
link.click()
|
|
URL.revokeObjectURL(url)
|
|
return blob
|
|
}
|
|
|
|
getSupportedNetworks() {
|
|
return Object.keys(this.networks)
|
|
}
|
|
|
|
static async getGPUCapability() {
|
|
try {
|
|
if (navigator.gpu) {
|
|
const adapter = await navigator.gpu.requestAdapter()
|
|
if (adapter) {
|
|
const buffer = adapter.limits.maxStorageBufferBindingSize / 512
|
|
const texture = 8192 * 8192
|
|
return Math.min(buffer, texture)
|
|
}
|
|
}
|
|
const canvas = document.createElement('canvas')
|
|
const gl = canvas.getContext('webgl2')
|
|
if (gl)
|
|
return gl.getParameter(gl.MAX_TEXTURE_SIZE) ** 2
|
|
return 8388608
|
|
}
|
|
catch {
|
|
return 8388608
|
|
}
|
|
}
|
|
|
|
static getBackendType() {
|
|
try {
|
|
if (navigator.gpu)
|
|
return 'webgpu'
|
|
if (document.createElement('canvas').getContext('webgl2'))
|
|
return 'webgl'
|
|
return 'unknown'
|
|
}
|
|
catch {
|
|
return 'unknown'
|
|
}
|
|
}
|
|
|
|
getAppState() {
|
|
return {
|
|
backend: Upscaler.getBackendType(),
|
|
isProcessing: false,
|
|
progress: 0,
|
|
width: 0,
|
|
height: 0,
|
|
}
|
|
}
|
|
|
|
onProgress(callback) {
|
|
if (typeof callback === 'function') {
|
|
this.progressCallback = callback
|
|
}
|
|
}
|
|
|
|
dispose() {
|
|
this.stopRealtimeUpscale()
|
|
if (this.workerInstance) {
|
|
this.workerInstance.terminate()
|
|
this.workerInstance = null
|
|
}
|
|
this.messageHandlers = {}
|
|
this.progressCallback = null
|
|
this.processingType = null
|
|
this.weightsCache.clear()
|
|
}
|
|
}
|