UNPKG

jassub

Version:

The Fastest JavaScript SSA/ASS Subtitle Renderer For Browsers

344 lines (291 loc) 11.5 kB
import 'rvfc-polyfill' import { proxy, releaseProxy } from 'abslink' import { wrap } from 'abslink/w3c' import { Debug } from './debug.ts' import type { WeightValue } from './worker/util.ts' import type { ASSRenderer } from './worker/worker' import type { Remote } from 'abslink' import type { queryRemoteFonts } from 'lfa-ponyfill' declare const self: typeof globalThis & { queryLocalFonts: (opts?: { postscriptNames?: string[] }) => ReturnType<typeof queryRemoteFonts> } const webYCbCrMap = { rgb: 'RGB', bt709: 'BT709', // these might not be exactly correct? oops? bt470bg: 'BT601', // alias BT.601 PAL... whats the difference? smpte170m: 'BT601'// alias BT.601 NTSC... whats the difference? } as const export type JASSUBOptions = { timeOffset?: number debug?: boolean prescaleFactor?: number prescaleHeightLimit?: number maxRenderHeight?: number workerUrl?: string wasmUrl?: string modernWasmUrl?: string fonts?: Array<string | Uint8Array> availableFonts?: Record<string, Uint8Array | string> defaultFont?: string queryFonts?: 'local' | 'localandremote' | false libassMemoryLimit?: number libassGlyphLimit?: number } & ({ video: HTMLVideoElement canvas?: HTMLCanvasElement } | { video?: HTMLVideoElement canvas: HTMLCanvasElement }) & ({ subUrl: string subContent?: string } | { subUrl?: string subContent: string }) export default class JASSUB { timeOffset prescaleFactor prescaleHeightLimit maxRenderHeight debug renderer!: Remote<ASSRenderer> ready busy = false _video _videoWidth = 0 _videoHeight = 0 _videoColorSpace: string | null = null _canvas _canvasParent _ctrl = new AbortController() _ro = new ResizeObserver(async () => { await this.ready this.resize() }) _destroyed = false _lastDemandTime!: VideoFrameCallbackMetadata _skipped = false _worker constructor (opts: JASSUBOptions) { if (!globalThis.Worker) throw new Error('Worker not supported') if (!opts) throw new Error('No options provided') if (!opts.video && !opts.canvas) throw new Error('You should give video or canvas in options.') JASSUB._test() this.timeOffset = opts.timeOffset ?? 0 this._video = opts.video this._canvas = opts.canvas ?? document.createElement('canvas') if (this._video && !opts.canvas) { this._canvasParent = document.createElement('div') this._canvasParent.className = 'JASSUB' this._canvasParent.style.position = 'relative' this._canvas.style.display = 'block' this._canvas.style.position = 'absolute' this._canvas.style.pointerEvents = 'none' this._canvasParent.appendChild(this._canvas) this._video.insertAdjacentElement('afterend', this._canvasParent) } const ctrl = this._canvas.transferControlToOffscreen() this.debug = opts.debug ? new Debug() : null this.prescaleFactor = opts.prescaleFactor ?? 1.0 this.prescaleHeightLimit = opts.prescaleHeightLimit ?? 1080 this.maxRenderHeight = opts.maxRenderHeight ?? 0 // 0 - no limit. // yes this is awful, but bundlers check for new Worker(new URL()) patterns, so can't use new Worker(workerUrl ?? new URL(...)) ... bruh this._worker = opts.workerUrl ? new Worker(opts.workerUrl, { name: 'jassub-worker', type: 'module' }) : new Worker(new URL('./worker/worker.js', import.meta.url), { name: 'jassub-worker', type: 'module' }) const Renderer = wrap<typeof ASSRenderer>(this._worker) const modern = opts.modernWasmUrl ?? new URL('./wasm/jassub-worker-modern.wasm', import.meta.url).href const normal = opts.wasmUrl ?? new URL('./wasm/jassub-worker.wasm', import.meta.url).href const availableFonts = opts.availableFonts ?? {} if (!availableFonts['liberation sans'] && !opts.defaultFont) { availableFonts['liberation sans'] = new URL('./default.woff2', import.meta.url).href } this.ready = (async () => { this.renderer = await new Renderer({ wasmUrl: JASSUB._supportsSIMD ? modern : normal, width: ctrl.width, height: ctrl.height, subUrl: opts.subUrl, subContent: opts.subContent ?? null, fonts: opts.fonts ?? [], availableFonts, defaultFont: opts.defaultFont ?? 'liberation sans', debug: !!opts.debug, libassMemoryLimit: opts.libassMemoryLimit ?? 0, libassGlyphLimit: opts.libassGlyphLimit ?? 0, queryFonts: opts.queryFonts ?? 'local' }, proxy(font => this._getLocalFont(font))) as unknown as Remote<ASSRenderer> await this.renderer.ready() })() if (this._video) this.setVideo(this._video) this._worker.postMessage({ name: 'offscreenCanvas', ctrl }, [ctrl]) } static _supportsSIMD?: boolean static _test () { if (JASSUB._supportsSIMD != null) return try { JASSUB._supportsSIMD = WebAssembly.validate(Uint8Array.of(0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123, 3, 2, 1, 0, 10, 10, 1, 8, 0, 65, 0, 253, 15, 253, 98, 11)) } catch (e) { JASSUB._supportsSIMD = false } const module = new WebAssembly.Module(Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00)) if (!(module instanceof WebAssembly.Module) || !(new WebAssembly.Instance(module) instanceof WebAssembly.Instance)) throw new Error('WASM not supported') } async resize (forceRepaint = !!this._video?.paused, width = 0, height = 0, top = 0, left = 0) { if ((!width || !height) && this._video) { const videoSize = this._getVideoPosition() let renderSize = null // support anamorphic video if (this._videoWidth) { const widthRatio = this._video.videoWidth / this._videoWidth const heightRatio = this._video.videoHeight / this._videoHeight renderSize = this._computeCanvasSize((videoSize.width || 0) / widthRatio, (videoSize.height || 0) / heightRatio) } else { renderSize = this._computeCanvasSize(videoSize.width || 0, videoSize.height || 0) } width = renderSize.width height = renderSize.height if (this._canvasParent) { top = videoSize.y - (this._canvasParent.getBoundingClientRect().top - this._video.getBoundingClientRect().top) left = videoSize.x } this._canvas.style.width = videoSize.width + 'px' this._canvas.style.height = videoSize.height + 'px' } if (this._canvasParent) { this._canvas.style.top = top + 'px' this._canvas.style.left = left + 'px' } await this.renderer._resizeCanvas( width, height, (this._videoWidth || this._video?.videoWidth) ?? width, (this._videoHeight || this._video?.videoHeight) ?? height ) if (this._lastDemandTime) await this._demandRender(forceRepaint) } _getVideoPosition (width = this._video!.videoWidth, height = this._video!.videoHeight) { const videoRatio = width / height const { offsetWidth, offsetHeight } = this._video! const elementRatio = offsetWidth / offsetHeight width = offsetWidth height = offsetHeight if (elementRatio > videoRatio) { width = Math.floor(offsetHeight * videoRatio) } else { height = Math.floor(offsetWidth / videoRatio) } const x = (offsetWidth - width) / 2 const y = (offsetHeight - height) / 2 return { width, height, x, y } } _computeCanvasSize (width = 0, height = 0) { const scalefactor = this.prescaleFactor <= 0 ? 1.0 : this.prescaleFactor const ratio = self.devicePixelRatio || 1 if (height <= 0 || width <= 0) { width = 0 height = 0 } else { const sgn = scalefactor < 1 ? -1 : 1 let newH = height * ratio if (sgn * newH * scalefactor <= sgn * this.prescaleHeightLimit) { newH *= scalefactor } else if (sgn * newH < sgn * this.prescaleHeightLimit) { newH = this.prescaleHeightLimit } if (this.maxRenderHeight > 0 && newH > this.maxRenderHeight) newH = this.maxRenderHeight width *= newH / height height = newH } return { width, height } } async setVideo (video: HTMLVideoElement) { if (video instanceof HTMLVideoElement) { this._removeListeners() this._video = video await this.ready this._video.requestVideoFrameCallback((now, data) => this._handleRVFC(data)) // everything else is unreliable for this, loadedmetadata and loadeddata included. if ('VideoFrame' in globalThis) { video.addEventListener('loadedmetadata', () => this._updateColorSpace(), this._ctrl) if (video.readyState > 2) this._updateColorSpace() } if (video.videoWidth > 0) this.resize() this._ro.observe(video) } else { throw new Error('Video element invalid!') } } async _getLocalFont (font: string, weight: WeightValue = 'regular') { // electron by default has all permissions enabled, and it doesn't have perm query // if this happens, just send it if (navigator.permissions?.query) { const { state } = await navigator.permissions.query({ name: 'local-fonts' as PermissionName }) if (state !== 'granted') return } for (const data of await self.queryLocalFonts()) { const family = data.family.toLowerCase() const style = data.style.toLowerCase() if (family === font && style === weight) { const blob = await data.blob() return new Uint8Array(await blob.arrayBuffer()) } } } _handleRVFC (data: VideoFrameCallbackMetadata) { if (this._destroyed) return this._lastDemandTime = data this._demandRender() this._video!.requestVideoFrameCallback((now, data) => this._handleRVFC(data)) } async _demandRender (repaint = false) { const { mediaTime, width, height } = this._lastDemandTime if (width !== this._videoWidth || height !== this._videoHeight) { this._videoWidth = width this._videoHeight = height return await this.resize(repaint) } if (this.busy) { this._skipped = true this.debug?._drop() return } this.busy = true this._skipped = false this.debug?._startFrame() await this.renderer._draw(mediaTime + this.timeOffset, repaint) this.debug?._endFrame(this._lastDemandTime) this.busy = false if (this._skipped) await this._demandRender() } async _updateColorSpace () { await this.ready this._video!.requestVideoFrameCallback(async () => { try { const frame = new VideoFrame(this._video!) frame.close() await this.renderer._setColorSpace(webYCbCrMap[frame.colorSpace.matrix!]) } catch (e) { // sources can be tainted console.warn(e) } }) } _removeListeners () { if (this._video) { if (this._ro) this._ro.unobserve(this._video) this._ctrl.abort() this._ctrl = new AbortController() } } async destroy () { if (this._destroyed) return this._destroyed = true if (this._video && this._canvasParent) this._video.parentNode?.removeChild(this._canvasParent) this._removeListeners() await this.renderer?.[releaseProxy]() this._worker.terminate() } }