jassub
Version:
The Fastest JavaScript SSA/ASS Subtitle Renderer For Browsers
278 lines • 11.4 kB
JavaScript
/* eslint-disable camelcase */
import { finalizer } from 'abslink';
import { expose } from 'abslink/w3c';
import { queryRemoteFonts } from 'lfa-ponyfill';
import WASM from '../wasm/jassub-worker.js';
import { Canvas2DRenderer } from "./renderers/2d-renderer.js";
import { WebGL1Renderer } from "./renderers/webgl1-renderer.js";
import { WebGL2Renderer } from "./renderers/webgl2-renderer.js";
import { _applyKeys, _fetch, fetchtext, LIBASS_YCBCR_MAP, THREAD_COUNT, WEIGHT_MAP } from "./util.js";
export class ASSRenderer {
_offCanvas;
_wasm;
_subtitleColorSpace;
_videoColorSpace;
_malloc;
_gpurender;
debug = false;
_ready;
constructor(data, getFont) {
// remove case sensitivity
this._availableFonts = Object.fromEntries(Object.entries(data.availableFonts).map(([k, v]) => [k.trim().toLowerCase(), v]));
this.debug = data.debug;
this.queryFonts = data.queryFonts;
this._getFont = getFont;
this._defaultFont = data.defaultFont.trim().toLowerCase();
// hack, we want custom WASM URLs
const _fetch = globalThis.fetch;
globalThis.fetch = _ => _fetch(data.wasmUrl);
// TODO: abslink doesnt support transferables yet
const handleMessage = ({ data }) => {
if (data.name === 'offscreenCanvas') {
// await this._ready // needed for webGPU
this._offCanvas = data.ctrl;
this._gpurender.setCanvas(this._offCanvas);
removeEventListener('message', handleMessage);
}
};
addEventListener('message', handleMessage);
// const devicePromise = navigator.gpu?.requestAdapter({
// powerPreference: 'high-performance'
// }).then(adapter => adapter?.requestDevice())
try {
const testCanvas = new OffscreenCanvas(1, 1);
if (testCanvas.getContext('webgl2')) {
this._gpurender = new WebGL2Renderer();
}
else {
this._gpurender = testCanvas.getContext('webgl')?.getExtension('ANGLE_instanced_arrays') ? new WebGL1Renderer() : new Canvas2DRenderer();
}
}
catch {
this._gpurender = new Canvas2DRenderer();
}
// eslint-disable-next-line @typescript-eslint/unbound-method
this._ready = WASM({ __url: data.wasmUrl, __out: (log) => this._log(log) }).then(async ({ _malloc, JASSUB }) => {
this._malloc = _malloc;
this._wasm = new JASSUB(data.width, data.height, this._defaultFont);
// Firefox seems to have issues with multithreading in workers
// a worker inside a worker does not recieve messages properly
this._wasm.setThreads(THREAD_COUNT);
this._loadInitialFonts(data.fonts);
this._wasm.createTrackMem(data.subContent ?? await fetchtext(data.subUrl));
this._subtitleColorSpace = LIBASS_YCBCR_MAP[this._wasm.trackColorSpace];
if (data.libassMemoryLimit > 0 || data.libassGlyphLimit > 0) {
this._wasm.setMemoryLimits(data.libassGlyphLimit || 0, data.libassMemoryLimit || 0);
}
// const device = await devicePromise
// this._gpurender = device ? new WebGPURenderer(device) : new WebGL2Renderer()
// if (this._offCanvas) this._gpurender.setCanvas(this._offCanvas, this._offCanvas.width, this._offCanvas.height)
this._checkColorSpace();
});
}
ready() {
return this._ready;
}
createEvent(event) {
_applyKeys(event, this._wasm.getEvent(this._wasm.allocEvent()));
}
getEvents() {
const events = [];
for (let i = 0; i < this._wasm.getEventCount(); i++) {
const { Start, Duration, ReadOrder, Layer, Style, MarginL, MarginR, MarginV, Name, Text, Effect } = this._wasm.getEvent(i);
events.push({ Start, Duration, ReadOrder, Layer, Style, MarginL, MarginR, MarginV, Name, Text, Effect });
}
return events;
}
setEvent(event, index) {
_applyKeys(event, this._wasm.getEvent(index));
}
removeEvent(index) {
this._wasm.removeEvent(index);
}
createStyle(style) {
const alloc = this._wasm.getStyle(this._wasm.allocStyle());
_applyKeys(style, alloc);
return alloc;
}
getStyles() {
const styles = [];
for (let i = 0; i < this._wasm.getStyleCount(); i++) {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { Name, FontName, FontSize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding, treat_fontname_as_pattern, Blur, Justify } = this._wasm.getStyle(i);
styles.push({ Name, FontName, FontSize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding, treat_fontname_as_pattern, Blur, Justify });
}
return styles;
}
setStyle(style, index) {
_applyKeys(style, this._wasm.getStyle(index));
}
removeStyle(index) {
this._wasm.removeStyle(index);
}
styleOverride(style) {
this._wasm.styleOverride(this.createStyle(style));
}
disableStyleOverride() {
this._wasm.disableStyleOverride();
}
setTrack(content) {
this._wasm.createTrackMem(content);
this._subtitleColorSpace = LIBASS_YCBCR_MAP[this._wasm.trackColorSpace];
}
freeTrack() {
this._wasm.removeTrack();
}
async setTrackByUrl(url) {
this.setTrack(await fetchtext(url));
}
_checkColorSpace() {
if (!this._subtitleColorSpace || !this._videoColorSpace)
return;
this._gpurender.setColorMatrix(this._subtitleColorSpace, this._videoColorSpace);
}
_defaultFont;
setDefaultFont(fontName) {
this._defaultFont = fontName.trim().toLowerCase();
this._wasm.setDefaultFont(this._defaultFont);
}
async _log(log) {
console.debug(log);
const match = log.match(/JASSUB: fontselect:[^(]+: \(([^,]+), (\d{1,4}), \d\)/);
if (match && !await this._findAvailableFont(match[1].trim().toLowerCase(), WEIGHT_MAP[Math.ceil(parseInt(match[2]) / 100) - 1])) {
await this._findAvailableFont(this._defaultFont);
}
}
async addFonts(fontOrURLs) {
if (!fontOrURLs.length)
return;
const strings = [];
const uint8s = [];
for (const fontOrURL of fontOrURLs) {
if (typeof fontOrURL === 'string') {
strings.push(fontOrURL);
}
else {
uint8s.push(fontOrURL);
}
}
if (uint8s.length)
this._allocFonts(uint8s);
// this isn't batched like uint8s because software like jellyfin exists, which loads 50+ fonts over the network which takes time...
// is connection exhaustion a concern here?
return await Promise.allSettled(strings.map(url => this._asyncWrite(url)));
}
// we don't want to run _findAvailableFont before initial fonts are loaded
// because it could duplicate fonts
_loadedInitialFonts = false;
async _loadInitialFonts(fontOrURLs) {
await this.addFonts(fontOrURLs);
this._loadedInitialFonts = true;
}
_getFont;
_availableFonts = {};
_checkedFonts = new Set();
async _findAvailableFont(fontName, weight) {
if (!this._loadedInitialFonts)
return;
// Roboto Medium, null -> Roboto, Medium
// Roboto Medium, Medium -> Roboto, Medium
// Roboto, null -> Roboto, Regular
// italic is not handled I guess
for (const _weight of WEIGHT_MAP) {
// check if fontname has this weight name in it, if yes remove it
if (fontName.includes(_weight)) {
fontName = fontName.replace(_weight, '').trim();
weight ??= _weight;
break;
}
}
weight ??= 'regular';
const key = fontName + ' ' + weight;
if (this._checkedFonts.has(key))
return;
this._checkedFonts.add(key);
try {
const font = this._availableFonts[key] ?? this._availableFonts[fontName] ?? await this._queryLocalFont(fontName, weight) ?? await this._queryRemoteFont([key, fontName]);
if (font)
return await this.addFonts([font]);
}
catch (e) {
console.warn('Error querying font', fontName, weight, e);
}
}
queryFonts;
async _queryLocalFont(fontName, weight) {
if (!this.queryFonts)
return;
return await this._getFont(fontName, weight);
}
async _queryRemoteFont(postscriptNames) {
if (this.queryFonts !== 'localandremote')
return;
const fontData = await queryRemoteFonts({ postscriptNames });
if (!fontData.length)
return;
const blob = await fontData[0].blob();
return new Uint8Array(await blob.arrayBuffer());
}
async _asyncWrite(font) {
const res = await _fetch(font);
this._allocFonts([new Uint8Array(await res.arrayBuffer())]);
}
_fontId = 0;
_allocFonts(uint8s) {
// TODO: this should re-draw last frame!
for (const uint8 of uint8s) {
const ptr = this._malloc(uint8.byteLength);
self.HEAPU8RAW.set(uint8, ptr);
this._wasm.addFont('font-' + (this._fontId++), ptr, uint8.byteLength);
}
this._wasm.reloadFonts();
}
_resizeCanvas(width, height, videoWidth, videoHeight) {
this._wasm.resizeCanvas(width, height, videoWidth, videoHeight);
this._gpurender.resizeCanvas(width, height);
}
async [finalizer]() {
await this._ready;
this._wasm.quitLibrary();
this._gpurender.destroy();
// @ts-expect-error force GC
this._wasm = null;
// @ts-expect-error force GC
this._gpurender = null;
this._availableFonts = {};
}
_draw(time, repaint = false) {
if (!this._offCanvas || !this._gpurender)
return;
const result = this._wasm.rawRender(time, Number(repaint));
if (this._wasm.changed === 0 && !repaint)
return;
const bitmaps = [];
for (let image = result, i = 0; i < this._wasm.count; image = image.next, ++i) {
// @ts-expect-error internal emsc types
bitmaps.push({
bitmap: image.bitmap,
color: image.color,
dst_x: image.dst_x,
dst_y: image.dst_y,
h: image.h,
stride: image.stride,
w: image.w
});
}
this._gpurender.render(bitmaps, self.HEAPU8RAW);
}
_setColorSpace(videoColorSpace) {
if (videoColorSpace === 'RGB')
return;
this._videoColorSpace = videoColorSpace;
this._checkColorSpace();
}
}
if (self.name === 'jassub-worker') {
expose(ASSRenderer);
}
//# sourceMappingURL=worker.js.map