jassub
Version:
The Fastest JavaScript SSA/ASS Subtitle Renderer For Browsers
250 lines • 10.5 kB
JavaScript
import 'rvfc-polyfill';
import { proxy, releaseProxy, transfer } from 'abslink';
import { wrap } from 'abslink/w3c';
import { Debug } from "./debug.js";
export 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?
};
export default class JASSUB {
timeOffset;
prescaleFactor;
prescaleHeightLimit;
maxRenderHeight;
debug;
renderer;
ready;
busy = false;
_video;
_videoWidth = 0;
_videoHeight = 0;
_videoColorSpace = null;
_canvas;
_ro = new ResizeObserver(async () => {
await this.ready;
this.resize();
});
_destroyed = false;
_lastDemandTime;
_skipped = false;
_worker;
constructor(opts) {
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._canvas.className = 'JASSUB';
this._canvas.style.position = 'absolute';
this._canvas.style.pointerEvents = 'none';
this._video.insertAdjacentElement('afterend', this._canvas);
}
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(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 = 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)), transfer(ctrl, [ctrl])).then((renderer) => {
this.renderer = renderer;
});
if (this._video) {
this.setVideo(this._video);
}
else {
this._ro.observe(this._canvas);
}
}
static _supportsSIMD;
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, renderWidth = 0, renderHeight = 0) {
const videoWidth = this._video?.videoWidth ?? this._videoWidth;
const videoHeight = this._video?.videoHeight ?? this._videoHeight;
const videoSize = this._getElementBoundingBox(this._video ?? this._canvas, videoWidth, videoHeight);
if (!renderWidth || !renderHeight) {
// || 1 for divide by zero safety
const widthScale = (this._videoWidth / videoWidth) || 1;
const heightScale = (this._videoHeight / videoHeight) || 1;
const { width, height } = this._computeRenderSize(videoSize.width * widthScale, videoSize.height * heightScale);
renderWidth = Math.round(width);
renderHeight = Math.round(height);
}
if (this._video) {
this._canvas.style.width = Math.round(videoSize.width) + 'px';
this._canvas.style.height = Math.round(videoSize.height) + 'px';
this._canvas.style.top = videoSize.y + 'px';
this._canvas.style.left = videoSize.x + 'px';
}
await this.renderer._resizeCanvas(renderWidth, renderHeight, this._videoWidth || renderWidth, this._videoHeight || renderHeight);
if (this._lastDemandTime)
await this._demandRender(forceRepaint);
}
_getElementBoundingBox(el, videoWidth, videoHeight) {
const { clientWidth, clientHeight, offsetLeft, offsetTop } = el;
const videoRatio = videoWidth / videoHeight;
const elementRatio = clientWidth / clientHeight;
if (elementRatio > videoRatio) {
videoHeight = clientHeight;
videoWidth = clientHeight * videoRatio;
}
else {
videoHeight = clientWidth / videoRatio;
videoWidth = clientWidth;
}
return { x: offsetLeft + (clientWidth - videoWidth) / 2, y: offsetTop + (clientHeight - videoHeight) / 2, width: videoWidth, height: videoHeight };
}
_computeRenderSize(width = 0, height = 0) {
if (height <= 0 || width <= 0)
return { width: 0, height: 0 };
const scalefactor = this.prescaleFactor <= 0 ? 1.0 : this.prescaleFactor;
const ratio = self.devicePixelRatio || 1;
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(target) {
this._removeListeners();
this._video = target;
this._ro.observe(target);
if (typeof VideoFrame !== 'undefined') {
target.addEventListener('loadedmetadata', this._boundUpdateColorSpace);
this._updateColorSpace({ target });
}
await this.ready;
this._video.requestVideoFrameCallback((now, data) => this._handleRVFC(data));
}
async _getLocalFont(font, weight = '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' });
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) {
if (this._destroyed)
return;
this.manualRender(data);
this._video.requestVideoFrameCallback((now, data) => this._handleRVFC(data));
}
manualRender(data) {
this._lastDemandTime = data;
return this._demandRender();
}
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();
}
_boundUpdateColorSpace = this._updateColorSpace.bind(this);
_updateColorSpace({ target }) {
this._video.requestVideoFrameCallback(async () => {
if (this._destroyed || this._video !== target)
return;
try {
const frame = new VideoFrame(this._video);
frame.close();
await this.ready;
await this.renderer._setColorSpace(webYCbCrMap[frame.colorSpace.matrix]);
}
catch (e) {
// sources can be tainted
console.warn(e);
}
});
}
_removeListeners() {
this._ro.disconnect();
this._video?.removeEventListener('loadedmetadata', this._boundUpdateColorSpace);
}
async destroy() {
if (this._destroyed)
return;
this._destroyed = true;
this._canvas.remove();
this._removeListeners();
await this.ready;
await this.renderer?.[releaseProxy]();
this._worker.terminate();
}
}
//# sourceMappingURL=jassub.js.map