fix(jassub): [PKG-JASSUB-07] repair vendor lifecycle and subtitle frame clock

This commit is contained in:
Harvey Zhao committed 2026-09-14 02:17:24 +08:00
1 parent 5e445b1af6
commit 0f3c068aae
29 files changed
+14064 -267

No files matched your search

+235 -80
View File
@@ -7,27 +7,46 @@
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
typeof HTMLVideoElement < "u" && !("requestVideoFrameCallback" in HTMLVideoElement.prototype) && "getVideoPlaybackQuality" in HTMLVideoElement.prototype && (HTMLVideoElement.prototype._rvfcpolyfillmap = {}, HTMLVideoElement.prototype.requestVideoFrameCallback = function(c) {
const e = performance.now(), t = this.getVideoPlaybackQuality(), s = this.mozPresentedFrames || this.mozPaintedFrames || t.totalVideoFrames - t.droppedVideoFrames, r = (n, a) => {
const i = this.getVideoPlaybackQuality(), h = this.mozPresentedFrames || this.mozPaintedFrames || i.totalVideoFrames - i.droppedVideoFrames;
if (h > s) {
const d = this.mozFrameDelay || i.totalFrameDelay - t.totalFrameDelay || 0, l = a - n;
c(a, {
presentationTime: a + d * 1e3,
expectedDisplayTime: a + l,
width: this.videoWidth,
height: this.videoHeight,
mediaTime: Math.max(0, this.currentTime || 0) + l / 1e3,
presentedFrames: h,
processingDuration: d
}), delete this._rvfcpolyfillmap[e];
} else
this._rvfcpolyfillmap[e] = requestAnimationFrame((d) => r(a, d));
if (typeof HTMLVideoElement < "u" && !("requestVideoFrameCallback" in HTMLVideoElement.prototype) && "getVideoPlaybackQuality" in HTMLVideoElement.prototype) {
HTMLVideoElement.prototype.requestVideoFrameCallback = function(callback) {
if (!Object.prototype.hasOwnProperty.call(this, "_rvfcpolyfillmap"))
this._rvfcpolyfillmap = {};
const id = this._rvfcpolyfillid = (this._rvfcpolyfillid || 0) + 1;
const started = performance.now(), initial = this.getVideoPlaybackQuality(), initialTime = this.currentTime;
const initialFrames = this.mozPresentedFrames || this.mozPaintedFrames || initial.totalVideoFrames - initial.droppedVideoFrames;
const poll = (previous, now) => {
if (!(id in this._rvfcpolyfillmap)) return;
const quality = this.getVideoPlaybackQuality();
const frames = this.mozPresentedFrames || this.mozPaintedFrames || quality.totalVideoFrames - quality.droppedVideoFrames;
const advanced = frames > initialFrames;
const mediaFallback = initialFrames === 0 && frames === 0 && initial.totalVideoFrames === 0 && initial.droppedVideoFrames === 0 && quality.totalVideoFrames === 0 && quality.droppedVideoFrames === 0 && Number.isFinite(this.currentTime) && this.currentTime !== initialTime && this.readyState >= 2 && !this.seeking && now - started >= 1e3 / 30;
if (advanced || mediaFallback) {
const delay = this.mozFrameDelay || quality.totalFrameDelay - initial.totalFrameDelay || 0;
const elapsed = now - previous;
delete this._rvfcpolyfillmap[id];
callback(now, {
presentationTime: now + delay * 1e3,
expectedDisplayTime: now + elapsed,
width: this.videoWidth,
height: this.videoHeight,
mediaTime: mediaFallback && !advanced ? Math.max(0, this.currentTime) : Math.max(0, this.currentTime || 0) + elapsed / 1e3,
presentedFrames: frames,
processingDuration: delay
});
} else {
this._rvfcpolyfillmap[id] = requestAnimationFrame((time) => poll(now, time));
}
};
this._rvfcpolyfillmap[id] = requestAnimationFrame((time) => poll(started, time));
return id;
};
return this._rvfcpolyfillmap[e] = requestAnimationFrame((n) => r(e, n)), e;
}, HTMLVideoElement.prototype.cancelVideoFrameCallback = function(c) {
cancelAnimationFrame(this._rvfcpolyfillmap[c]), delete this._rvfcpolyfillmap[c];
});
HTMLVideoElement.prototype.cancelVideoFrameCallback = function(id) {
const callbacks = this._rvfcpolyfillmap;
if (!callbacks || !(id in callbacks)) return;
cancelAnimationFrame(callbacks[id]);
delete callbacks[id];
};
}
const _ = {
bt709: "BT709",
// these might not be exactly correct? oops?
@@ -82,47 +101,65 @@ const _o = class _o extends EventTarget {
* @param {Number} [options.libassGlyphLimit] libass glyph cache memory limit in MiB (approximate).
*/
constructor(e) {
if (super(), !globalThis.Worker)
throw this.destroy("Worker not supported");
if (!e)
throw this.destroy("No options provided");
this._loaded = /** @type {Promise<void>} */
new Promise((s) => {
this._init = s;
});
const t = _o._test();
if (this._onDemandRender = "requestVideoFrameCallback" in HTMLVideoElement.prototype && (e.onDemandRender ?? true), this._offscreenRender = "transferControlToOffscreen" in HTMLCanvasElement.prototype && !e.canvas && (e.offscreenRender ?? true), this.timeOffset = e.timeOffset || 0, this._video = e.video, this._videoHeight = 0, this._videoWidth = 0, this._videoColorSpace = null, this._canvas = e.canvas, this._video && !this._canvas)
this._canvasParent = document.createElement("div"), this._canvasParent.className = "JASSUB", this._canvasParent.style.position = "relative", this._canvas = this._createCanvas(), this._video.insertAdjacentElement("afterend", this._canvasParent);
else if (!this._canvas)
throw this.destroy("Don't know where to render: you should give video or canvas in options.");
if (this._bufferCanvas = document.createElement("canvas"), this._bufferCtx = this._bufferCanvas.getContext("2d"), !this._bufferCtx)
throw this.destroy("Canvas rendering not supported");
this._canvasctrl = this._offscreenRender ? this._canvas.transferControlToOffscreen() : this._canvas, this._ctx = !this._offscreenRender && this._canvasctrl.getContext("2d"), this._lastRenderTime = 0, this.debug = !!e.debug, this.prescaleFactor = e.prescaleFactor || 1, this.prescaleHeightLimit = e.prescaleHeightLimit || 1080, this.maxRenderHeight = e.maxRenderHeight || 0, this._boundResize = this.resize.bind(this), this._boundTimeUpdate = this._timeupdate.bind(this), this._boundSetRate = this.setRate.bind(this), this._boundUpdateColorSpace = this._updateColorSpace.bind(this), this._video && this.setVideo(e.video), this._onDemandRender && (this.busy = false, this._lastDemandTime = null), this._worker = new Worker(e.workerUrl || "jassub-worker.js"), this._worker.onmessage = (s) => this._onmessage(s), this._worker.onerror = (s) => this._error(s), t.then(() => {
this._worker.postMessage({
target: "init",
wasmUrl: _o._supportsSIMD && e.modernWasmUrl ? e.modernWasmUrl : e.wasmUrl ?? "jassub-worker.wasm",
legacyWasmUrl: e.legacyWasmUrl ?? "jassub-worker.wasm.js",
asyncRender: typeof createImageBitmap < "u" && (e.asyncRender ?? true),
onDemandRender: this._onDemandRender,
width: this._canvasctrl.width || 0,
height: this._canvasctrl.height || 0,
blendMode: e.blendMode || "js",
subUrl: e.subUrl,
subContent: e.subContent || null,
fonts: e.fonts || [],
availableFonts: e.availableFonts || { "liberation sans": "./default.woff2" },
fallbackFont: e.fallbackFont || "liberation sans",
debug: this.debug,
targetFps: e.targetFps || 24,
dropAllAnimations: e.dropAllAnimations,
dropAllBlur: e.dropAllBlur,
libassMemoryLimit: e.libassMemoryLimit || 0,
libassGlyphLimit: e.libassGlyphLimit || 0,
// @ts-ignore
useLocalFonts: typeof queryLocalFonts < "u" && (e.useLocalFonts ?? true),
hasBitmapBug: _o._hasBitmapBug
}), this._offscreenRender === true && this.sendMessage("offscreenCanvas", null, [this._canvasctrl]);
});
super();
this._requests = /* @__PURE__ */ new Set();
let t;
try {
if (!globalThis.Worker)
throw this.destroy("Worker not supported");
if (!e)
throw this.destroy("No options provided");
this._loaded = /** @type {Promise<void>} */
new Promise((s) => {
this._init = s;
});
t = _o._test();
if (this._onDemandRender = "requestVideoFrameCallback" in HTMLVideoElement.prototype && (e.onDemandRender ?? true), this._offscreenRender = "transferControlToOffscreen" in HTMLCanvasElement.prototype && !e.canvas && (e.offscreenRender ?? true), this.timeOffset = e.timeOffset || 0, this._video = e.video, this._videoHeight = 0, this._videoWidth = 0, this._videoColorSpace = null, this._canvas = e.canvas, this._video && !this._canvas)
this._canvasParent = document.createElement("div"), this._canvasParent.className = "JASSUB", this._canvasParent.style.position = "relative", this._canvas = this._createCanvas(), this._video.insertAdjacentElement("afterend", this._canvasParent);
else if (!this._canvas)
throw this.destroy("Don't know where to render: you should give video or canvas in options.");
if (this._bufferCanvas = document.createElement("canvas"), this._bufferCtx = this._bufferCanvas.getContext("2d"), !this._bufferCtx)
throw this.destroy("Canvas rendering not supported");
this._canvasctrl = this._offscreenRender ? this._canvas.transferControlToOffscreen() : this._canvas, this._ctx = !this._offscreenRender && this._canvasctrl.getContext("2d"), this._lastRenderTime = 0, this.debug = !!e.debug, this.prescaleFactor = e.prescaleFactor || 1, this.prescaleHeightLimit = e.prescaleHeightLimit || 1080, this.maxRenderHeight = e.maxRenderHeight || 0, this._boundResize = this.resize.bind(this), this._boundTimeUpdate = this._timeupdate.bind(this), this._boundSetRate = () => {
if (!this._destroyed) this.setRate(this._video.playbackRate);
}, this._boundUpdateColorSpace = this._updateColorSpace.bind(this), this._video && this.setVideo(e.video), this._onDemandRender && (this.busy = false, this._lastDemandTime = null), this._worker = new Worker(e.workerUrl || "jassub-worker.js"), this._worker.onmessage = (s) => this._onmessage(s), this._worker.onerror = (s) => this._error(s), t.then(() => {
if (this._destroyed) return;
this._worker.postMessage({
target: "init",
wasmUrl: _o._supportsSIMD && e.modernWasmUrl ? e.modernWasmUrl : e.wasmUrl ?? "jassub-worker.wasm",
legacyWasmUrl: e.legacyWasmUrl ?? "jassub-worker.wasm.js",
asyncRender: typeof createImageBitmap < "u" && (e.asyncRender ?? true),
onDemandRender: this._onDemandRender,
width: this._canvasctrl.width || 0,
height: this._canvasctrl.height || 0,
blendMode: e.blendMode || "js",
subUrl: e.subUrl,
subContent: e.subContent || null,
fonts: e.fonts || [],
availableFonts: e.availableFonts || { "liberation sans": "./default.woff2" },
fallbackFont: e.fallbackFont || "liberation sans",
debug: this.debug,
targetFps: e.targetFps || 24,
dropAllAnimations: e.dropAllAnimations,
dropAllBlur: e.dropAllBlur,
libassMemoryLimit: e.libassMemoryLimit || 0,
libassGlyphLimit: e.libassGlyphLimit || 0,
// @ts-ignore
useLocalFonts: typeof queryLocalFonts < "u" && (e.useLocalFonts ?? true),
hasBitmapBug: _o._hasBitmapBug
}), this._offscreenRender === true && this.sendMessage("offscreenCanvas", null, [this._canvasctrl]);
}).catch((error) => {
if (!this._destroyed) this.destroy(error);
});
} catch (error) {
t?.catch(() => {
});
try {
this.destroy();
} catch {
}
throw error;
}
}
_createCanvas() {
return this._canvas = document.createElement("canvas"), this._canvas.style.display = "block", this._canvas.style.position = "absolute", this._canvas.style.pointerEvents = "none", this._canvasParent.appendChild(this._canvas), this._canvas;
@@ -183,6 +220,7 @@ const _o = class _o extends EventTarget {
* @param {Boolean} [force=false]
*/
resize(e = 0, t = 0, s = 0, r = 0, n = this._video?.paused) {
if (this._destroyed) return;
if ((!e || !t) && this._video) {
const a = this._getVideoPosition();
let i = null;
@@ -193,7 +231,7 @@ const _o = class _o extends EventTarget {
i = this._computeCanvasSize(a.width || 0, a.height || 0);
e = i.width, t = i.height, this._canvasParent && (s = a.y - (this._canvasParent.getBoundingClientRect().top - this._video.getBoundingClientRect().top), r = a.x), this._canvas.style.width = a.width + "px", this._canvas.style.height = a.height + "px";
}
this._canvas.style.top = s + "px", this._canvas.style.left = r + "px", n && this.busy === false ? this.busy = true : n = false, this.sendMessage("canvas", { width: e, height: t, videoWidth: this._videoWidth || this._video.videoWidth, videoHeight: this._videoHeight || this._video.videoHeight, force: n });
this._canvas.style.top = s + "px", this._canvas.style.left = r + "px", n && this.busy === false ? this.busy = true : n = false, this.sendMessage("canvas", { width: e, height: t, videoWidth: this._videoWidth || this._video?.videoWidth || e, videoHeight: this._videoHeight || this._video?.videoHeight || t, force: n });
}
_getVideoPosition(e = this._video.videoWidth, t = this._video.videoHeight) {
const s = e / t, { offsetWidth: r, offsetHeight: n } = this._video, a = r / n;
@@ -213,6 +251,7 @@ const _o = class _o extends EventTarget {
return { width: e, height: t };
}
_timeupdate({ type: e }) {
if (this._destroyed) return;
const s = {
seeking: true,
waiting: true,
@@ -225,7 +264,34 @@ const _o = class _o extends EventTarget {
* @param {HTMLVideoElement} video
*/
setVideo(e) {
e instanceof HTMLVideoElement ? (this._removeListeners(), this._video = e, this._onDemandRender ? this._video.requestVideoFrameCallback(this._handleRVFC.bind(this)) : (this._playstate = e.paused, e.addEventListener("timeupdate", this._boundTimeUpdate, false), e.addEventListener("progress", this._boundTimeUpdate, false), e.addEventListener("waiting", this._boundTimeUpdate, false), e.addEventListener("seeking", this._boundTimeUpdate, false), e.addEventListener("playing", this._boundTimeUpdate, false), e.addEventListener("ratechange", this._boundSetRate, false), e.addEventListener("resize", this._boundResize, false)), "VideoFrame" in window && (e.addEventListener("loadedmetadata", this._boundUpdateColorSpace, false), e.readyState > 2 && this._updateColorSpace()), e.videoWidth > 0 && this.resize(), typeof ResizeObserver < "u" && (this._ro || (this._ro = new ResizeObserver(() => this.resize())), this._ro.observe(e))) : this._error("Video element invalid!");
if (this._destroyed) return;
if (!(e instanceof HTMLVideoElement)) {
this._error("Video element invalid!");
return;
}
this._removeListeners();
this._video = e;
this._videoWidth = this._videoHeight = 0;
this._videoColorSpace = null;
if (this._canvasParent && this._canvasParent.parentNode !== e.parentNode)
e.insertAdjacentElement("afterend", this._canvasParent);
if (this._onDemandRender) this._requestFrame();
else {
this._playstate = e.paused;
for (const name of ["timeupdate", "progress", "waiting", "seeking", "playing"])
e.addEventListener(name, this._boundTimeUpdate, false);
e.addEventListener("ratechange", this._boundSetRate, false);
e.addEventListener("resize", this._boundResize, false);
}
if ("VideoFrame" in window) {
e.addEventListener("loadedmetadata", this._boundUpdateColorSpace, false);
if (e.readyState > 2) this._updateColorSpace();
}
if (e.videoWidth > 0) this.resize();
if (typeof ResizeObserver < "u") {
this._ro || (this._ro = new ResizeObserver(() => this.resize()));
this._ro.observe(e);
}
}
runBenchmark() {
this.sendMessage("runBenchmark");
@@ -317,7 +383,7 @@ const _o = class _o extends EventTarget {
getEvents(e) {
this._fetchFromWorker({
target: "getEvents"
}, (t, { events: s }) => {
}, (t, { events: s } = {}) => {
e(t, s);
});
}
@@ -392,7 +458,7 @@ const _o = class _o extends EventTarget {
getStyles(e) {
this._fetchFromWorker({
target: "getStyles"
}, (t, { styles: s }) => {
}, (t, { styles: s } = {}) => {
e(t, s);
});
}
@@ -434,12 +500,22 @@ const _o = class _o extends EventTarget {
}
}
_unbusy() {
if (this._destroyed) return;
this._lastDemandTime ? this._demandRender(this._lastDemandTime) : this.busy = false;
}
_handleRVFC(e, { mediaTime: t, width: s, height: r }) {
if (this._destroyed)
return null;
this.busy ? this._lastDemandTime = { mediaTime: t, width: s, height: r } : (this.busy = true, this._demandRender({ mediaTime: t, width: s, height: r })), this._video.requestVideoFrameCallback(this._handleRVFC.bind(this));
this.busy ? this._lastDemandTime = { mediaTime: t, width: s, height: r } : (this.busy = true, this._demandRender({ mediaTime: t, width: s, height: r })), this._requestFrame();
}
_requestFrame() {
if (this._destroyed || !this._video || this._frameCallback != null) return;
const video = this._video, generation = this._videoGeneration;
this._frameCallback = video.requestVideoFrameCallback((time, metadata) => {
if (this._destroyed || generation !== this._videoGeneration || video !== this._video) return;
this._frameCallback = null;
this._handleRVFC(time, metadata);
});
}
_demandRender({ mediaTime: e, width: t, height: s }) {
this._lastDemandTime = null, (t !== this._videoWidth || s !== this._videoHeight) && (this._videoWidth = t, this._videoHeight = s, this.resize()), this.sendMessage("demand", { time: e + this.timeOffset });
@@ -457,12 +533,20 @@ const _o = class _o extends EventTarget {
this._canvas.remove(), this._createCanvas(), this._canvasctrl = this._canvas.transferControlToOffscreen(), this._ctx = false, this.sendMessage("offscreenCanvas", null, [this._canvasctrl]), this.resize(0, 0, 0, 0, true);
}
_updateColorSpace() {
this._video.requestVideoFrameCallback(() => {
if (this._destroyed || this._colorCallback != null) return;
const video = this._video, generation = this._videoGeneration;
this._colorCallback = video.requestVideoFrameCallback(() => {
if (this._destroyed || generation !== this._videoGeneration || video !== this._video) return;
this._colorCallback = null;
let frame;
try {
const e = new VideoFrame(this._video);
this._videoColorSpace = _[e.colorSpace.matrix], e.close(), this.sendMessage("getColorSpace");
frame = new VideoFrame(video);
this._videoColorSpace = _[frame.colorSpace.matrix];
this.sendMessage("getColorSpace");
} catch (e) {
console.warn(e);
} finally {
frame?.close();
}
});
}
@@ -496,6 +580,7 @@ const _o = class _o extends EventTarget {
return e;
}
_ready() {
if (this._destroyed) return;
this._init(), this.dispatchEvent(new CustomEvent("ready"));
}
/**
@@ -505,7 +590,10 @@ const _o = class _o extends EventTarget {
* @param {Transferable[]} [transferable] Array of transferables.
*/
async sendMessage(e, t = {}, s) {
await this._loaded, s ? this._worker.postMessage({
if (this._destroyed) return;
await this._loaded;
if (this._destroyed) return;
s ? this._worker.postMessage({
target: e,
transferable: s,
...t
@@ -515,23 +603,53 @@ const _o = class _o extends EventTarget {
});
}
_fetchFromWorker(e, t) {
if (this._destroyed) {
t(new Error("JASSUB instance destroyed"));
return;
}
const worker = this._worker;
let timer, settled = false;
const cleanup = () => {
worker.removeEventListener("message", onMessage);
worker.removeEventListener("error", onError);
clearTimeout(timer);
this._requests.delete(cancel);
};
const finish = (error, data) => {
if (settled) return;
settled = true;
cleanup();
t(error, data);
};
const onMessage = ({ data }) => {
if (data.target === e.target) finish(null, data);
};
const onError = (error) => finish(error);
const cancel = () => {
if (settled) return;
settled = true;
cleanup();
return () => t(new Error("JASSUB instance destroyed"));
};
try {
const s = e.target, r = setTimeout(() => {
a(new Error("Error: Timeout while try to fetch " + s));
}, 5e3), n = ({ data: i }) => {
i.target === s && (t(null, i), this._worker.removeEventListener("message", n), this._worker.removeEventListener("error", a), clearTimeout(r));
}, a = (i) => {
t(i), this._worker.removeEventListener("message", n), this._worker.removeEventListener("error", a), clearTimeout(r);
};
this._worker.addEventListener("message", n), this._worker.addEventListener("error", a), this._worker.postMessage(e);
this._requests.add(cancel);
worker.addEventListener("message", onMessage);
worker.addEventListener("error", onError);
timer = setTimeout(() => onError(new Error("Error: Timeout while try to fetch " + e.target)), 5e3);
worker.postMessage(e);
} catch (s) {
this._error(s);
if (settled) throw s;
finish(s);
}
}
_console({ content: e, command: t }) {
console[t].apply(console, JSON.parse(e));
}
_onmessage({ data: e }) {
if (this._destroyed) {
for (const image of e.images || []) image.image?.close?.();
return;
}
this["_" + e.target] && this["_" + e.target](e);
}
_error(e) {
@@ -539,6 +657,12 @@ const _o = class _o extends EventTarget {
return this.dispatchEvent(s), console.error(t), t;
}
_removeListeners() {
this._videoGeneration = (this._videoGeneration || 0) + 1;
for (const id of [this._frameCallback, this._colorCallback])
if (id != null) this._video?.cancelVideoFrameCallback?.(id);
this._frameCallback = this._colorCallback = null;
this._lastDemandTime = null;
if (this._onDemandRender) this.busy = false;
this._video && (this._ro && this._ro.unobserve(this._video), this._ctx && (this._ctx.filter = "none"), this._video.removeEventListener("timeupdate", this._boundTimeUpdate), this._video.removeEventListener("progress", this._boundTimeUpdate), this._video.removeEventListener("waiting", this._boundTimeUpdate), this._video.removeEventListener("seeking", this._boundTimeUpdate), this._video.removeEventListener("playing", this._boundTimeUpdate), this._video.removeEventListener("ratechange", this._boundSetRate), this._video.removeEventListener("resize", this._boundResize), this._video.removeEventListener("loadedmetadata", this._boundUpdateColorSpace));
}
/**
@@ -546,7 +670,38 @@ const _o = class _o extends EventTarget {
* @param {String|Error} [err] Error to throw when destroying.
*/
destroy(e) {
return e && (e = this._error(e)), this._video && this._canvasParent && this._video.parentNode?.removeChild(this._canvasParent), this._destroyed = true, this._removeListeners(), this.sendMessage("destroy"), this._worker?.terminate(), e;
if (this._destroyed) return e && this._error(e);
this._destroyed = true;
let failure, failed = false;
const attempt = (callback) => {
try {
callback();
} catch (error) {
if (!failed) {
failure = error;
failed = true;
}
}
};
if (e) attempt(() => {
e = this._error(e);
});
attempt(() => this._removeListeners());
attempt(() => this._ro?.disconnect());
attempt(() => this._canvasParent?.remove());
this._init?.();
const callbacks = [];
for (const cancel of [...this._requests || []]) attempt(() => {
const notify = cancel();
if (notify) callbacks.push(notify);
});
for (const notify of callbacks) attempt(notify);
if (this._worker) {
this._worker.onmessage = this._worker.onerror = null;
attempt(() => this._worker.terminate());
}
if (failed) throw failure;
return e;
}
};
// test support for WASM, ImageData, alphaBug, but only once, on init so it doesn't run when first running the page