From c5f6a0f7166c34a330a70bc150897c18c7e49547 Mon Sep 17 00:00:00 2001 From: ThaUnknown <6506529+ThaUnknown@users.noreply.github.com> Date: Mon, 21 Feb 2022 21:44:29 +0100 Subject: [PATCH 1/6] rewrite: v2 --- src/polyfill.js | 122 ++-- src/post-worker.js | 1189 +++++++++++++++++--------------------- src/pre-worker.js | 245 ++++---- src/subtitles-octopus.js | 1051 +++++++++++++-------------------- 4 files changed, 1125 insertions(+), 1482 deletions(-) diff --git a/src/polyfill.js b/src/polyfill.js index 105090ff..86fa3c76 100644 --- a/src/polyfill.js +++ b/src/polyfill.js @@ -1,94 +1,80 @@ +/* eslint no-extend-native: 0 */ if (!String.prototype.startsWith) { - String.prototype.startsWith = function (search, pos) { - if (pos === undefined) { - pos = 0; - } - return this.substring(pos, search.length) === search; - }; + String.prototype.startsWith = function (search, pos) { + if (pos === undefined) { + pos = 0 + } + return this.substring(pos, search.length) === search + } } if (!String.prototype.endsWith) { - String.prototype.endsWith = function (search, this_len) { - if (this_len === undefined || this_len > this.length) { - this_len = this.length; - } - return this.substring(this_len - search.length, this_len) === search; - }; + String.prototype.endsWith = function (search, len) { + if (len === undefined || len > this.length) { + len = this.length + } + return this.substring(len - search.length, len) === search + } } if (!String.prototype.includes) { - String.prototype.includes = function (search, pos) { - return this.indexOf(search, pos) !== -1; - }; + String.prototype.includes = function (search, pos) { + return this.indexOf(search, pos) !== -1 + } } if (!ArrayBuffer.isView) { - var typedArrays = [ - Int8Array, - Uint8Array, - Uint8ClampedArray, - Int16Array, - Uint16Array, - Int32Array, - Uint32Array, - Float32Array, - Float64Array - ]; + const typedArrays = [ + Int8Array, + Uint8Array, + Uint8ClampedArray, + Int16Array, + Uint16Array, + Int32Array, + Uint32Array, + Float32Array, + Float64Array + ] - ArrayBuffer.isView = function (obj) { - return obj && obj.constructor && typedArrays.indexOf(obj.constructor) !== -1; - }; + ArrayBuffer.isView = function (obj) { + return obj && obj.constructor && typedArrays.indexOf(obj.constructor) !== -1 + } } if (!Int8Array.prototype.slice) { - Object.defineProperty(Int8Array.prototype, 'slice', { - value: function (begin, end) - { - return new Int8Array(this.subarray(begin, end)); - } - }); + Object.defineProperty(Int8Array.prototype, 'slice', { + value: function (begin, end) { + return new Int8Array(this.subarray(begin, end)) + } + }) } if (!Uint8Array.prototype.slice) { - Object.defineProperty(Uint8Array.prototype, 'slice', { - value: function (begin, end) - { - return new Uint8Array(this.subarray(begin, end)); - } - }); + Object.defineProperty(Uint8Array.prototype, 'slice', { + value: function (begin, end) { + return new Uint8Array(this.subarray(begin, end)) + } + }) } if (!Int16Array.from) { - // Doesn't work for String - Int16Array.from = function (source) { - var arr = new Int16Array(source.length); - arr.set(source, 0); - return arr; - }; + // Doesn't work for String + Int16Array.from = function (source) { + const arr = new Int16Array(source.length) + arr.set(source, 0) + return arr + } } if (!Int32Array.from) { - // Doesn't work for String - Int32Array.from = function (source) { - var arr = new Int32Array(source.length); - arr.set(source, 0); - return arr; - }; + // Doesn't work for String + Int32Array.from = function (source) { + const arr = new Int32Array(source.length) + arr.set(source, 0) + return arr + } } -// performance.now() polyfill -if ("performance" in self === false) { - self.performance = {}; -} Date.now = (Date.now || function () { - return new Date().getTime(); -}); -if ("now" in self.performance === false) { - var nowOffset = Date.now(); - if (performance.timing && performance.timing.navigationStart) { - nowOffset = performance.timing.navigationStart - } - self.performance.now = function now() { - return Date.now() - nowOffset; - } -} + return new Date().getTime() +}) diff --git a/src/post-worker.js b/src/post-worker.js index 6c01f39a..2ce17ab1 100644 --- a/src/post-worker.js +++ b/src/post-worker.js @@ -1,760 +1,637 @@ -Module['FS'] = FS; - -self.delay = 0; // approximate delay (time of render + postMessage + drawImage), for example 1/60 or 0 -self.lastCurrentTime = 0; -self.rate = 1; -self.rafId = null; -self.nextIsRaf = false; -self.lastCurrentTimeReceivedAt = Date.now(); -self.targetFps = 24; -self.libassMemoryLimit = 0; // in MiB - -self.width = 0; -self.height = 0; - -self.fontMap_ = {}; -self.fontId = 0; +/* global Module, HEAPU8, FS, SDL */ +/* eslint-env browser, worker */ +/* eslint node/no-callback-literal: 0 */ +Module.FS = FS + +self.delay = 0 // approximate delay (time of render + postMessage + drawImage), for example 1/60 or 0 +self.lastCurrentTime = 0 +self.rate = 1 +self.rafId = null +self.nextIsRaf = false +self.lastCurrentTimeReceivedAt = Date.now() +self.targetFps = 24 +self.libassMemoryLimit = 0 // in MiB + +self.width = 0 +self.height = 0 + +self.fontMap_ = {} +self.fontId = 0 /** * Make the font accessible by libass by writing it to the virtual FS. * @param {!string} font the font name. */ -self.writeFontToFS = function(font) { - font = font.trim().toLowerCase(); +self.writeFontToFS = function (font) { + font = font.trim().toLowerCase() - if (font.startsWith("@")) { - font = font.substr(1); - } + if (font.startsWith('@')) { + font = font.substring(1) + } - if (self.fontMap_.hasOwnProperty(font)) return; + if (self.fontMap_[font]) return - self.fontMap_[font] = true; + self.fontMap_[font] = true - if (!self.availableFonts.hasOwnProperty(font)) return; - var content = readBinary(self.availableFonts[font]); + if (!self.availableFonts[font]) return + const content = readBinary(self.availableFonts[font]) - Module["FS"].writeFile('/fonts/font' + (self.fontId++) + '-' + self.availableFonts[font].split('/').pop(), content, { - encoding: 'binary' - }); -}; + Module.FS.writeFile('/fonts/font' + (self.fontId++) + '-' + self.availableFonts[font].split('/').pop(), content, { + encoding: 'binary' + }) +} /** * Write all font's mentioned in the .ass file to the virtual FS. * @param {!string} content the file content. */ -self.writeAvailableFontsToFS = function(content) { - if (!self.availableFonts) return; - - var sections = parseAss(content); +self.writeAvailableFontsToFS = function (content) { + if (!self.availableFonts) return - for (var i = 0; i < sections.length; i++) { - for (var j = 0; j < sections[i].body.length; j++) { - if (sections[i].body[j].key === 'Style') { - self.writeFontToFS(sections[i].body[j].value['Fontname']); - } - } - } + const sections = parseAss(content) - var regex = /\\fn([^\\}]*?)[\\}]/g; - var matches; - while (matches = regex.exec(self.subContent)) { - self.writeFontToFS(matches[1]); + for (let i = 0; i < sections.length; i++) { + for (let j = 0; j < sections[i].body.length; j++) { + if (sections[i].body[j].key === 'Style') { + self.writeFontToFS(sections[i].body[j].value.Fontname) + } } -}; + } -self.getRenderMethod = function () { - switch (self.renderMode) { - case 'lossy': - return self.lossyRender; - case 'js-blend': - return self.render; - default: - console.error('Unrecognised renderMode, falling back to default!'); - self.renderMode = 'wasm-blend'; - // fallthrough - case 'wasm-blend': - return self.blendRender; - } + const regex = /\\fn([^\\}]*?)[\\}]/g + let matches + while (matches = regex.exec(self.subContent)) { + self.writeFontToFS(matches[1]) + } } - /** * Set the subtitle track. * @param {!string} content the content of the subtitle file. */ -self.setTrack = function (content) { - // Make sure that the fonts are loaded - self.writeAvailableFontsToFS(content); +self.setTrack = function ({ content }) { + // Make sure that the fonts are loaded + self.writeAvailableFontsToFS(content) - // Write the subtitle file to the virtual FS. - Module["FS"].writeFile("/sub.ass", content); + // Write the subtitle file to the virtual FS. + Module.FS.writeFile('/sub.ass', content) - // Tell libass to render the new track - self.octObj.createTrack("/sub.ass"); - self.ass_track = self.octObj.track; - self.getRenderMethod()(); -}; + // Tell libass to render the new track + self.octObj.createTrack('/sub.ass') + self.ass_track = self.octObj.track + self.renderLoop() +} /** * Remove subtitle track. */ self.freeTrack = function () { - self.octObj.removeTrack(); - self.getRenderMethod()(); -}; + self.octObj.removeTrack() + self.renderLoop() +} /** * Set the subtitle track. * @param {!string} url the URL of the subtitle file. */ -self.setTrackByUrl = function (url) { - var content = ""; - if (isBrotliFile(url)) { - content = Module["BrotliDecode"](readBinary(url)) - } else { - content = read_(url); - } - self.setTrack(content); -}; +self.setTrackByUrl = function ({ url }) { + let content = '' + if (isBrotliFile(url)) { + content = Module.BrotliDecode(readBinary(url)) + } else { + content = read_(url) + } + self.setTrack({ content }) +} -self.resize = function (width, height) { - self.width = width; - self.height = height; - self.octObj.resizeCanvas(width, height); -}; +self.resize = (width, height) => { + self.width = width + self.height = height + if (self.offscreenCanvas) { + self.offscreenCanvas.width = width + self.offscreenCanvas.height = height + } + self.octObj.resizeCanvas(width, height) +} self.getCurrentTime = function () { - var diff = (Date.now() - self.lastCurrentTimeReceivedAt) / 1000; - if (self._isPaused) { - return self.lastCurrentTime; + const diff = (Date.now() - self.lastCurrentTimeReceivedAt) / 1000 + if (self._isPaused) { + return self.lastCurrentTime + } else { + if (diff > 5) { + console.error('Didn\'t received currentTime > 5 seconds. Assuming video was paused.') + self.setIsPaused(true) } - else { - if (diff > 5) { - console.error('Didn\'t received currentTime > 5 seconds. Assuming video was paused.'); - self.setIsPaused(true); - } - return self.lastCurrentTime + (diff * self.rate); - } -}; + return self.lastCurrentTime + (diff * self.rate) + } +} self.setCurrentTime = function (currentTime) { - self.lastCurrentTime = currentTime; - self.lastCurrentTimeReceivedAt = Date.now(); - if (!self.rafId) { - if (self.nextIsRaf) { - self.rafId = self.requestAnimationFrame(self.getRenderMethod()); - } - else { - self.getRenderMethod()(); + self.lastCurrentTime = currentTime + self.lastCurrentTimeReceivedAt = Date.now() + if (!self.rafId) { + if (self.nextIsRaf) { + self.rafId = self.requestAnimationFrame(self.renderLoop) + } else { + self.renderLoop() - // Give onmessage chance to receive all queued messages - setTimeout(function () { - self.nextIsRaf = false; - }, 20); - } + // Give onmessage chance to receive all queued messages + setTimeout(function () { + self.nextIsRaf = false + }, 20) } -}; + } +} -self._isPaused = true; +self._isPaused = true self.getIsPaused = function () { - return self._isPaused; -}; + return self._isPaused +} self.setIsPaused = function (isPaused) { - if (isPaused != self._isPaused) { - self._isPaused = isPaused; - if (isPaused) { - if (self.rafId) { - clearTimeout(self.rafId); - self.rafId = null; - } - } - else { - self.lastCurrentTimeReceivedAt = Date.now(); - self.rafId = self.requestAnimationFrame(self.getRenderMethod()); - } - } -}; - -self.render = function (force) { - self.rafId = 0; - self.renderPending = false; - var startTime = performance.now(); - var renderResult = self.octObj.renderImage(self.getCurrentTime() + self.delay, self.changed); - var changed = Module.getValue(self.changed, 'i32'); - if (changed != 0 || force) { - var result = self.buildResult(renderResult); - var spentTime = performance.now() - startTime; - postMessage({ - target: 'canvas', - op: 'renderCanvas', - time: Date.now(), - spentTime: spentTime, - canvases: result[0] - }, result[1]); - } - - if (!self._isPaused) { - self.rafId = self.requestAnimationFrame(self.render); - } -}; - -self.blendRender = function (force) { - self.rafId = 0; - self.renderPending = false; - var startTime = performance.now(); - - var renderResult = self.octObj.renderBlend(self.getCurrentTime() + self.delay, force); - if (renderResult.changed != 0 || force) { - var canvases = []; - var buffers = []; - - if (renderResult.image) { - // make a copy, as we should free the memory so subsequent calls can utilize it - var result = new Uint8Array(HEAPU8.subarray(renderResult.image, renderResult.image + renderResult.dest_width * renderResult.dest_height * 4)); - - canvases = [{w: renderResult.dest_width, h: renderResult.dest_height, x: renderResult.dest_x, y: renderResult.dest_y, buffer: result.buffer}]; - buffers = [result.buffer]; - } - - postMessage({ - target: 'canvas', - op: 'renderCanvas', - time: Date.now(), - spentTime: performance.now() - startTime, - blendTime: renderResult.blend_time, - canvases: canvases - }, buffers); + if (isPaused !== self._isPaused) { + self._isPaused = isPaused + if (isPaused) { + if (self.rafId) { + clearTimeout(self.rafId) + self.rafId = null + } + } else { + self.lastCurrentTimeReceivedAt = Date.now() + self.rafId = self.requestAnimationFrame(self.renderLoop) } + } +} - if (!self._isPaused) { - self.rafId = self.requestAnimationFrame(self.blendRender); - } -}; +self.renderImageData = (time, force) => { + if (self.blendMode === 'wasm') { + const result = self.octObj.renderBlend(time, force) + return { w: result.dest_width, h: result.dest_height, x: result.dest_x, y: result.dest_y, changed: result.changed, image: result.image } + } else { + const result = self.octObj.renderImage(time, self.changed) + result.changed = Module.getValue(self.changed, 'i32') + return result + } +} -self.lossyRender = function (force) { - self.rafId = 0; - self.renderPending = false; - var startTime = performance.now(); - var renderResult = self.octObj.renderImage(self.getCurrentTime() + self.delay, self.changed); - var changed = Module.getValue(self.changed, "i32"); - if (changed != 0 || force) { - var result = self.buildResult(renderResult); - var newTime = performance.now(); - var libassTime = newTime - startTime; - var promises = []; - for (var i = 0; i < result[0].length; i++) { - var image = result[0][i]; - var imageBuffer = new Uint8ClampedArray(image.buffer); - var imageData = new ImageData(imageBuffer, image.w, image.h); - promises[i] = createImageBitmap(imageData, 0, 0, image.w, image.h); - } - Promise.all(promises).then(function (imgs) { - var decodeTime = performance.now() - newTime; - var bitmaps = []; - for (var i = 0; i < imgs.length; i++) { - var image = result[0][i]; - bitmaps[i] = { x: image.x, y: image.y, bitmap: imgs[i] }; - } - postMessage({ - target: "canvas", - op: "renderFastCanvas", - time: Date.now(), - libassTime: libassTime, - decodeTime: decodeTime, - bitmaps: bitmaps - }, imgs); - }); +self.processRender = (result, callback) => { + const images = [] + let buffers = [] + if (self.blendMode === 'wasm') { + if (result.image) { + result.buffer = HEAPU8.buffer.slice(result.image, result.image + result.w * result.h * 4) + images.push(result) + buffers.push(result.buffer) } - if (!self._isPaused) { - self.rafId = self.requestAnimationFrame(self.lossyRender); + } else { + let res = result + while (res.ptr !== 0) { + const decode = self.decodeResultBitmap(res) + if (decode) { + images.push(decode) + buffers.push(decode.buffer) + } + res = res.next } -}; - -self.buildResult = function (ptr) { - var items = []; - var transferable = []; - var item; - - while (ptr.ptr != 0) { - item = self.buildResultItem(ptr); - if (item !== null) { - items.push(item); - transferable.push(item.buffer); - } - ptr = ptr.next; + } + // use callback to not rely on async/await + if (self.asyncRender) { + const promises = [] + for (const image of images) { + if (image.buffer) promises.push(createImageBitmap(new ImageData(new Uint8ClampedArray(image.buffer), image.w, image.h), 0, 0, image.w, image.h)) } - - return [items, transferable]; + Promise.all(promises).then(bitmaps => { + for (let i = 0; i < images.length; i++) { + images[i].buffer = bitmaps[i] + } + buffers = bitmaps + callback({ images, buffers }) + }) + } else { + callback({ images, buffers }) + } } -self.buildResultItem = function (ptr) { - var bitmap = ptr.bitmap, - stride = ptr.stride, - w = ptr.w, - h = ptr.h, - color = ptr.color; - - if (w == 0 || h == 0) { - return null; +self.decodeResultBitmap = ({ bitmap, stride, w, h, color, dst_x, dst_y }) => { + if (w === 0 || h === 0) return null + const a = (255 - (color & 255)) / 255 + if (a === 0) return null + const c = ((color << 8) & 0xff0000) | ((color >> 8) & 0xff00) | ((color >> 24) & 0xff) + const data = new Uint32Array(w * h) // operate on a single position at once, instead of 4 positions + for (let y = h + 1, pos = bitmap, res = 0; --y; pos += stride) { + for (let z = 0; z < w; ++z, ++res) { + const k = HEAPU8[pos + z] + if (k !== 0) data[res] = ((a * k) << 24) | c } + } + return { w, h, x: dst_x, y: dst_y, buffer: data.buffer } +} - var r = (color >> 24) & 0xFF, - g = (color >> 16) & 0xFF, - b = (color >> 8) & 0xFF, - a = 255 - (color & 0xFF); +self.render = (time, force) => { + self.busy = true + const result = self.renderImageData(time, force) + if (result.changed !== 0 || force) { + self.processRender(result, self.paintImages) + } else { + self.busy = false + } +} - var result = new Uint8ClampedArray(4 * w * h); +self.demand = data => { + self.lastCurrentTime = data.time + if (!self.busy) self.render(data.time, true) +} - var bitmapPosition = 0; - var resultPosition = 0; +self.renderLoop = (force) => { + self.rafId = 0 + self.renderPending = false + self.render(self.getCurrentTime() + self.delay, force) + if (!self._isPaused) { + self.rafId = self.requestAnimationFrame(self.renderLoop) + } +} - for (var y = 0; y < h; ++y) { - for (var x = 0; x < w; ++x) { - var k = Module.HEAPU8[bitmap + bitmapPosition + x] * a / 255; - result[resultPosition] = r; - result[resultPosition + 1] = g; - result[resultPosition + 2] = b; - result[resultPosition + 3] = k; - resultPosition += 4; +self.paintImages = ({ images, buffers }) => { + if (self.offscreenCanvasCtx) { + self.offscreenCanvasCtx.clearRect(0, 0, self.offscreenCanvas.width, self.offscreenCanvas.height) + for (const image of images) { + if (image.buffer) { + if (self.asyncRender) { + self.offscreenCanvasCtx.drawImage(image.buffer, image.x, image.y) + } else { + self.bufferCanvas.width = image.w + self.bufferCanvas.height = image.h + self.bufferCtx.putImageData(new ImageData(new Uint8ClampedArray(image.buffer), image.w, image.h), 0, 0) + self.offscreenCanvasCtx.drawImage(self.bufferCanvas, image.x, image.y) } - bitmapPosition += stride; + } } - - x = ptr.dst_x; - y = ptr.dst_y; - - return {w: w, h: h, x: x, y: y, buffer: result.buffer}; -}; - -if (typeof SDL !== 'undefined') { - SDL.defaults.copyOnLock = false; - SDL.defaults.discardOnLock = false; - SDL.defaults.opaqueFrontBuffer = false; + } else { + postMessage({ + target: 'render', + async: self.asyncRender, + images + }, buffers) + } + self.busy = false } -function FPSTracker(text) { - var last = 0; - var mean = 0; - var counter = 0; - this.tick = function () { - var now = Date.now(); - if (last > 0) { - var diff = now - last; - mean = 0.99 * mean + 0.01 * diff; - if (counter++ === 60) { - counter = 0; - dump(text + ' fps: ' + (1000 / mean).toFixed(2) + '\n'); - } - } - last = now; - } +if (typeof SDL !== 'undefined') { + SDL.defaults.copyOnLock = false + SDL.defaults.discardOnLock = false + SDL.defaults.opaqueFrontBuffer = false } /** * Parse the content of an .ass file. * @param {!string} content the content of the file */ -function parseAss(content) { - var m, format, lastPart, parts, key, value, tmp, i, j, body; - var sections = []; - var lines = content.split(/[\r\n]+/g); - for (i = 0; i < lines.length; i++) { - m = lines[i].match(/^\[(.*)\]$/); - if (m) { - format = null; - sections.push({ - name: m[1], - body: [] - }); - } else { - if (/^\s*$/.test(lines[i])) continue; - if (sections.length === 0) continue; - body = sections[sections.length - 1].body; - if (lines[i][0] === ';') { - body.push({ - type: 'comment', - value: lines[i].substring(1) - }); - } else { - parts = lines[i].split(":"); - key = parts[0]; - value = parts.slice(1).join(':').trim(); - if (format || key === 'Format') { - value = value.split(','); - if (format && value.length > format.length) { - lastPart = value.slice(format.length - 1).join(','); - value = value.slice(0, format.length - 1); - value.push(lastPart); - } - value = value.map(function(s) { - return s.trim(); - }); - if (format) { - tmp = {}; - for (j = 0; j < value.length; j++) { - tmp[format[j]] = value[j]; - } - value = tmp; - } - } - if (key === 'Format') { - format = value; - } - body.push({ - key: key, - value: value - }); +function parseAss (content) { + let m, format, lastPart, parts, key, value, tmp, i, j, body + const sections = [] + const lines = content.split(/[\r\n]+/g) + for (i = 0; i < lines.length; i++) { + m = lines[i].match(/^\[(.*)\]$/) + if (m) { + format = null + sections.push({ + name: m[1], + body: [] + }) + } else { + if (/^\s*$/.test(lines[i])) continue + if (sections.length === 0) continue + body = sections[sections.length - 1].body + if (lines[i][0] === ';') { + body.push({ + type: 'comment', + value: lines[i].substring(1) + }) + } else { + parts = lines[i].split(':') + key = parts[0] + value = parts.slice(1).join(':').trim() + if (format || key === 'Format') { + value = value.split(',') + if (format && value.length > format.length) { + lastPart = value.slice(format.length - 1).join(',') + value = value.slice(0, format.length - 1) + value.push(lastPart) + } + value = value.map(function (s) { + return s.trim() + }) + if (format) { + tmp = {} + for (j = 0; j < value.length; j++) { + tmp[format[j]] = value[j] } + value = tmp + } + } + if (key === 'Format') { + format = value } + body.push({ + key: key, + value: value + }) + } } + } - return sections; + return sections }; self.requestAnimationFrame = (function () { - // similar to Browser.requestAnimationFrame - var nextRAF = 0; - return function (func) { - // try to keep target fps (30fps) between calls to here - var now = Date.now(); - if (nextRAF === 0) { - nextRAF = now + 1000 / self.targetFps; - } else { - while (now + 2 >= nextRAF) { // fudge a little, to avoid timer jitter causing us to do lots of delay:0 - nextRAF += 1000 / self.targetFps; - } - } - var delay = Math.max(nextRAF - now, 0); - return setTimeout(func, delay); - //return setTimeout(func, 1); - }; -})(); - -var screen = { - width: 0, - height: 0 -}; + // similar to Browser.requestAnimationFrame + let nextRAF = 0 + return function (func) { + // try to keep target fps (30fps) between calls to here + const now = Date.now() + if (nextRAF === 0) { + nextRAF = now + 1000 / self.targetFps + } else { + while (now + 2 >= nextRAF) { // fudge a little, to avoid timer jitter causing us to do lots of delay:0 + nextRAF += 1000 / self.targetFps + } + } + const delay = Math.max(nextRAF - now, 0) + return setTimeout(func, delay) + // return setTimeout(func, 1); + } +})() + +const screen = { + width: 0, + height: 0 +} -Module.print = function Module_print(x) { - //dump('OUT: ' + x + '\n'); - postMessage({target: 'stdout', content: x}); -}; -Module.printErr = function Module_printErr(x) { - //dump('ERR: ' + x + '\n'); - postMessage({target: 'stderr', content: x}); -}; +Module.print = function Module_print (x) { + // dump('OUT: ' + x + '\n'); + postMessage({ target: 'stdout', content: x }) +} +Module.printErr = function Module_printErr (x) { + // dump('ERR: ' + x + '\n'); + postMessage({ target: 'stderr', content: x }) +} // Frame throttling -var frameId = 0; -var clientFrameId = 0; -var commandBuffer = []; +let frameId = 0 +let commandBuffer = [] -var postMainLoop = Module['postMainLoop']; -Module['postMainLoop'] = function () { - if (postMainLoop) postMainLoop(); - // frame complete, send a frame id - postMessage({target: 'tick', id: frameId++}); - commandBuffer = []; -}; +const postMainLoop = Module.postMainLoop +Module.postMainLoop = function () { + if (postMainLoop) postMainLoop() + // frame complete, send a frame id + postMessage({ target: 'tick', id: frameId++ }) + commandBuffer = [] +} // Wait to start running until we receive some info from the client -//addRunDependency('gl-prefetch'); -addRunDependency('worker-init'); +// addRunDependency('gl-prefetch'); +addRunDependency('worker-init') // buffer messages until the program starts to run -var messageBuffer = null; -var messageResenderTimeout = null; - -function messageResender() { - if (calledMain) { - assert(messageBuffer && messageBuffer.length > 0); - messageResenderTimeout = null; - messageBuffer.forEach(function (message) { - onmessage(message); - }); - messageBuffer = null; - } else { - messageResenderTimeout = setTimeout(messageResender, 50); - } +let messageBuffer = null +let messageResenderTimeout = null + +function messageResender () { + if (calledMain) { + assert(messageBuffer && messageBuffer.length > 0) + messageResenderTimeout = null + messageBuffer.forEach(function (message) { + onmessage(message) + }) + messageBuffer = null + } else { + messageResenderTimeout = setTimeout(messageResender, 50) + } } -function _applyKeys(input, output) { - var vargs = Object.keys(input); +function _applyKeys (input, output) { + const vargs = Object.keys(input) - for (var i = 0; i < vargs.length; i++) { - output[vargs[i]] = input[vargs[i]]; - } + for (let i = 0; i < vargs.length; i++) { + output[vargs[i]] = input[vargs[i]] + } } -function onMessageFromMainEmscriptenThread(message) { - if (!calledMain && !message.data.preMain) { - if (!messageBuffer) { - messageBuffer = []; - messageResenderTimeout = setTimeout(messageResender, 50); - } - messageBuffer.push(message); - return; +self.init = data => { + screen.width = self.width = data.width + screen.height = self.height = data.height + self.subUrl = data.subUrl + self.subContent = data.subContent + self.fontFiles = data.fonts + self.blendMode = data.blendMode + self.asyncRender = data.asyncRender + // Force fallback if engine does not support 'lossy' mode. + // We only use createImageBitmap in the worker and historic WebKit versions supported + // the API in the normal but not the worker scope, so we can't check this earlier. + if (self.asyncRender && typeof createImageBitmap === 'undefined') { + self.asyncRender = false + console.error("'createImageBitmap' needed for 'asyncRender' unsupported!") + } + + self.availableFonts = data.availableFonts + self.debug = data.debug + if (!hasNativeConsole && self.debug) { + console = makeCustomConsole() + console.log('overridden console') + } + if (Module.canvas) { + Module.canvas.width_ = data.width + Module.canvas.height_ = data.height + if (data.boundingClientRect) { + Module.canvas.boundingClientRect = data.boundingClientRect } - if (calledMain && messageResenderTimeout) { - clearTimeout(messageResenderTimeout); - messageResender(); - } - //console.log('worker got ' + JSON.stringify(message.data).substr(0, 150) + '\n'); - switch (message.data.target) { - case 'window': { - self.fireEvent(message.data.event); - break; - } - case 'canvas': { - if (message.data.event) { - Module.canvas.fireEvent(message.data.event); - } else if (message.data.width) { - if (Module.canvas && message.data.boundingClientRect) { - Module.canvas.boundingClientRect = message.data.boundingClientRect; - } - self.resize(message.data.width, message.data.height); - self.getRenderMethod()(); - } else throw 'ey?'; - break; - } - case 'video': { - if (message.data.currentTime !== undefined) { - self.setCurrentTime(message.data.currentTime); - } - if (message.data.isPaused !== undefined) { - self.setIsPaused(message.data.isPaused); - } - if (message.data.rate) { - self.rate = message.data.rate; - } - break; - } - case 'tock': { - clientFrameId = message.data.id; - break; - } - case 'worker-init': { - //Module.canvas = document.createElement('canvas'); - screen.width = self.width = message.data.width; - screen.height = self.height = message.data.height; - self.subUrl = message.data.subUrl; - self.subContent = message.data.subContent; - self.fontFiles = message.data.fonts; - self.renderMode = message.data.renderMode; - // Force fallback if engine does not support 'lossy' mode. - // We only use createImageBitmap in the worker and historic WebKit versions supported - // the API in the normal but not the worker scope, so we can't check this earlier. - if (self.renderMode == 'lossy' && typeof createImageBitmap === 'undefined') { - self.renderMode = 'wasm-blend'; - console.error("'createImageBitmap' needed for 'lossy' unsupported. Falling back to default!"); - } + } + self.targetFps = data.targetFps || self.targetFps + self.libassMemoryLimit = data.libassMemoryLimit || self.libassMemoryLimit + self.libassGlyphLimit = data.libassGlyphLimit || 0 + removeRunDependency('worker-init') + postMessage({ + target: 'ready' + }) +} - self.availableFonts = message.data.availableFonts; - self.debug = message.data.debug; - if (!hasNativeConsole && self.debug) { - console = makeCustomConsole(); - console.log("overridden console"); - } - if (Module.canvas) { - Module.canvas.width_ = message.data.width; - Module.canvas.height_ = message.data.height; - if (message.data.boundingClientRect) { - Module.canvas.boundingClientRect = message.data.boundingClientRect; - } - } - self.targetFps = message.data.targetFps || self.targetFps; - self.libassMemoryLimit = message.data.libassMemoryLimit || self.libassMemoryLimit; - self.libassGlyphLimit = message.data.libassGlyphLimit || 0; - removeRunDependency('worker-init'); - postMessage({ - target: "ready", - }); - break; - } - case 'destroy': - self.octObj.quitLibrary(); - break; - case 'free-track': - self.freeTrack(); - break; - case 'set-track': - self.setTrack(message.data.content); - break; - case 'set-track-by-url': - self.setTrackByUrl(message.data.url); - break; - case 'create-event': - var event = message.data.event; - var i = self.octObj.allocEvent(); - var evnt_ptr = self.octObj.track.get_events(i); - _applyKeys(event, evnt_ptr); - break; - case 'get-events': - var events = []; - for (var i = 0; i < self.octObj.getEventCount(); i++) { - var evnt_ptr = self.octObj.track.get_events(i); - var event = { - _index: i, - Start: evnt_ptr.get_Start(), - Duration: evnt_ptr.get_Duration(), - ReadOrder: evnt_ptr.get_ReadOrder(), - Layer: evnt_ptr.get_Layer(), - Style: evnt_ptr.get_Style(), - Name: evnt_ptr.get_Name(), - MarginL: evnt_ptr.get_MarginL(), - MarginR: evnt_ptr.get_MarginR(), - MarginV: evnt_ptr.get_MarginV(), - Effect: evnt_ptr.get_Effect(), - Text: evnt_ptr.get_Text() - }; - - events.push(event); - } - postMessage({ - target: "get-events", - time: Date.now(), - events: events - }); - break; - case 'set-event': - var event = message.data.event; - var i = message.data.index; - var evnt_ptr = self.octObj.track.get_events(i); - _applyKeys(event, evnt_ptr); - break; - case 'remove-event': - var i = message.data.index; - self.octObj.removeEvent(i); - break; - case 'create-style': - var style = message.data.style; - var i = self.octObj.allocStyle(); - var styl_ptr = self.octObj.track.get_styles(i); - _applyKeys(style, styl_ptr); - break; - case 'get-styles': - var styles = []; - for (var i = 0; i < self.octObj.getStyleCount(); i++) { - var styl_ptr = self.octObj.track.get_styles(i); - var style = { - _index: i, - Name: styl_ptr.get_Name(), - FontName: styl_ptr.get_FontName(), - FontSize: styl_ptr.get_FontSize(), - PrimaryColour: styl_ptr.get_PrimaryColour(), - SecondaryColour: styl_ptr.get_SecondaryColour(), - OutlineColour: styl_ptr.get_OutlineColour(), - BackColour: styl_ptr.get_BackColour(), - Bold: styl_ptr.get_Bold(), - Italic: styl_ptr.get_Italic(), - Underline: styl_ptr.get_Underline(), - StrikeOut: styl_ptr.get_StrikeOut(), - ScaleX: styl_ptr.get_ScaleX(), - ScaleY: styl_ptr.get_ScaleY(), - Spacing: styl_ptr.get_Spacing(), - Angle: styl_ptr.get_Angle(), - BorderStyle: styl_ptr.get_BorderStyle(), - Outline: styl_ptr.get_Outline(), - Shadow: styl_ptr.get_Shadow(), - Alignment: styl_ptr.get_Alignment(), - MarginL: styl_ptr.get_MarginL(), - MarginR: styl_ptr.get_MarginR(), - MarginV: styl_ptr.get_MarginV(), - Encoding: styl_ptr.get_Encoding(), - treat_fontname_as_pattern: styl_ptr.get_treat_fontname_as_pattern(), - Blur: styl_ptr.get_Blur(), - Justify: styl_ptr.get_Justify() - }; - styles.push(style); - } - postMessage({ - target: "get-styles", - time: Date.now(), - styles: styles - }); - break; - case 'set-style': - var style = message.data.style; - var i = message.data.index; - var styl_ptr = self.octObj.track.get_styles(i); - _applyKeys(style, styl_ptr); - break; - case 'remove-style': - var i = message.data.index; - self.octObj.removeStyle(i); - break; - case 'runBenchmark': { - self.runBenchmark(); - break; - } - case 'custom': { - if (Module['onCustomMessage']) { - Module['onCustomMessage'](message); - } else { - throw 'Custom message received but worker Module.onCustomMessage not implemented.'; - } - break; +self.canvas = data => { + if (data.width == null) throw new Error('Invalid canvas size specified') + if (Module.canvas && data.boundingClientRect) { + Module.canvas.boundingClientRect = data.boundingClientRect + } + self.resize(data.width, data.height) + self.renderLoop() +} + +self.video = data => { + if (data.currentTime != null) self.setCurrentTime(data.currentTime) + if (data.isPaused != null) self.setIsPaused(data.isPaused) + self.rate = data.rate || self.rate +} + +onmessage = message => { + if (!calledMain && !message.data.preMain) { + if (!messageBuffer) { + messageBuffer = [] + messageResenderTimeout = setTimeout(messageResender, 50) + } + messageBuffer.push(message) + return + } + if (calledMain && messageResenderTimeout) { + clearTimeout(messageResenderTimeout) + messageResender() + } + if (self[message.data.target]) { + self[message.data.target](message.data) + } else { + const { data } = message + switch (data.target) { + case 'offscreenCanvas': + self.offscreenCanvas = data.transferable[0] + self.offscreenCanvasCtx = self.offscreenCanvas.getContext('2d') + self.bufferCanvas = new OffscreenCanvas(self.height, self.width) + self.bufferCtx = self.bufferCanvas.getContext('2d') + break + case 'destroy': + self.octObj.quitLibrary() + break + case 'createEvent': + _applyKeys(data.event, self.octObj.track.get_events(self.octObj.allocEvent())) + break + case 'getEvents': { + const events = [] + for (let i = 0; i < self.octObj.getEventCount(); i++) { + const evntPtr = self.octObj.track.get_events(i) + events.push({ + Start: evntPtr.get_Start(), + Duration: evntPtr.get_Duration(), + ReadOrder: evntPtr.get_ReadOrder(), + Layer: evntPtr.get_Layer(), + Style: evntPtr.get_Style(), + Name: evntPtr.get_Name(), + MarginL: evntPtr.get_MarginL(), + MarginR: evntPtr.get_MarginR(), + MarginV: evntPtr.get_MarginV(), + Effect: evntPtr.get_Effect(), + Text: evntPtr.get_Text() + }) } - case 'setimmediate': { - if (Module['setImmediates']) Module['setImmediates'].shift()(); - break; + postMessage({ + target: 'getEvents', + events: events + }) + } + break + case 'setEvent': + _applyKeys(data.event, self.octObj.track.get_events(data.index)) + break + case 'removeEvent': + self.octObj.removeEvent(data.index) + break + case 'createStyle': + _applyKeys(data.style, self.octObj.track.get_styles(self.octObj.allocStyle())) + break + case 'getStyles': { + const styles = [] + for (let i = 0; i < self.octObj.getStyleCount(); i++) { + const stylPtr = self.octObj.track.get_styles(i) + styles.push({ + Name: stylPtr.get_Name(), + FontName: stylPtr.get_FontName(), + FontSize: stylPtr.get_FontSize(), + PrimaryColour: stylPtr.get_PrimaryColour(), + SecondaryColour: stylPtr.get_SecondaryColour(), + OutlineColour: stylPtr.get_OutlineColour(), + BackColour: stylPtr.get_BackColour(), + Bold: stylPtr.get_Bold(), + Italic: stylPtr.get_Italic(), + Underline: stylPtr.get_Underline(), + StrikeOut: stylPtr.get_StrikeOut(), + ScaleX: stylPtr.get_ScaleX(), + ScaleY: stylPtr.get_ScaleY(), + Spacing: stylPtr.get_Spacing(), + Angle: stylPtr.get_Angle(), + BorderStyle: stylPtr.get_BorderStyle(), + Outline: stylPtr.get_Outline(), + Shadow: stylPtr.get_Shadow(), + Alignment: stylPtr.get_Alignment(), + MarginL: stylPtr.get_MarginL(), + MarginR: stylPtr.get_MarginR(), + MarginV: stylPtr.get_MarginV(), + Encoding: stylPtr.get_Encoding(), + treat_fontname_as_pattern: stylPtr.get_treat_fontname_as_pattern(), + Blur: stylPtr.get_Blur(), + Justify: stylPtr.get_Justify() + }) } - default: - throw 'wha? ' + message.data.target; + postMessage({ + target: 'getStyles', + time: Date.now(), + styles: styles + }) + } + break + case 'setStyle': + _applyKeys(data.style, self.octObj.track.get_styles(data.index)) + break + case 'removeStyle': + self.octObj.removeStyle(data.index) + break + case 'setimmediate': { + if (Module.setImmediates) Module.setImmediates.shift()() + break + } + default: + throw new Error('Unknown event target ' + message.data.target) } -}; - -onmessage = onMessageFromMainEmscriptenThread; - -function postCustomMessage(data) { - postMessage({target: 'custom', userData: data}); + } } self.runBenchmark = function (seconds, pos, async) { - var totalTime = 0; - var i = 0; - pos = pos || 0; - seconds = seconds || 60; - var count = seconds * self.targetFps; - var start = performance.now(); - var longestFrame = 0; - var run = function () { - var t0 = performance.now(); - - pos += 1 / self.targetFps; - self.setCurrentTime(pos); - - var t1 = performance.now(); - var diff = t1 - t0; - totalTime += diff; - if (diff > longestFrame) { - longestFrame = diff; - } + let totalTime = 0 + let i = 0 + pos = pos || 0 + seconds = seconds || 60 + const count = seconds * self.targetFps + const start = Date.now() + let longestFrame = 0 + const run = function () { + const t0 = Date.now() + + pos += 1 / self.targetFps + self.setCurrentTime(pos) + + const t1 = Date.now() + const diff = t1 - t0 + totalTime += diff + if (diff > longestFrame) { + longestFrame = diff + } - if (i < count) { - i++; + if (i < count) { + i++ - if (async) { - self.requestAnimationFrame(run); - return false; - } - else { - return true; - } - } - else { - console.log("Performance fps: " + Math.round(1000 / (totalTime / count)) + ""); - console.log("Real fps: " + Math.round(1000 / ((t1 - start) / count)) + ""); - console.log("Total time: " + totalTime); - console.log("Longest frame: " + Math.ceil(longestFrame) + "ms (" + Math.floor(1000 / longestFrame) + " fps)"); + if (async) { + self.requestAnimationFrame(run) + return false + } else { + return true + } + } else { + console.log('Performance fps: ' + Math.round(1000 / (totalTime / count)) + '') + console.log('Real fps: ' + Math.round(1000 / ((t1 - start) / count)) + '') + console.log('Total time: ' + totalTime) + console.log('Longest frame: ' + Math.ceil(longestFrame) + 'ms (' + Math.floor(1000 / longestFrame) + ' fps)') - return false; - } - }; + return false + } + } - while (true) { - if (!run()) { - break; - } + while (true) { + if (!run()) { + break } -}; + } +} diff --git a/src/pre-worker.js b/src/pre-worker.js index 09f86b76..3a25df24 100644 --- a/src/pre-worker.js +++ b/src/pre-worker.js @@ -1,35 +1,38 @@ -var hasNativeConsole = typeof console !== "undefined"; +/* global Module */ +/* eslint-env browser, worker */ +const hasNativeConsole = typeof console !== 'undefined' // implement console methods if they're missing -function makeCustomConsole() { - var console = (function () { - function postConsoleMessage(prefix, args) { - postMessage({ - target: "console-" + prefix, - content: JSON.stringify(Array.prototype.slice.call(args)), - }) - } +function makeCustomConsole () { + const console = (function () { + function postConsoleMessage (command, args) { + postMessage({ + target: 'console', + command, + content: JSON.stringify(Array.prototype.slice.call(args)) + }) + } - return { - log: function() { - postConsoleMessage("log", arguments); - }, - debug: function() { - postConsoleMessage("debug", arguments); - }, - info: function() { - postConsoleMessage("info", arguments); - }, - warn: function() { - postConsoleMessage("warn", arguments); - }, - error: function() { - postConsoleMessage("error", arguments); - } - } - })(); + return { + log: function () { + postConsoleMessage('log', arguments) + }, + debug: function () { + postConsoleMessage('debug', arguments) + }, + info: function () { + postConsoleMessage('info', arguments) + }, + warn: function () { + postConsoleMessage('warn', arguments) + }, + error: function () { + postConsoleMessage('error', arguments) + } + } + })() - return console; + return console } /** @@ -37,114 +40,112 @@ function makeCustomConsole() { * @param {!string} url the URL of the subtitle file. * @returns {boolean} Brotli compression found or not. */ -function isBrotliFile(url) { - // Search for parameters - var len = url.indexOf("?"); +function isBrotliFile (url) { + // Search for parameters + let len = url.indexOf('?') - if (len === -1) { - len = url.length; - } + if (len === -1) { + len = url.length + } - return url.endsWith(".br", len); + return url.endsWith('.br', len) } -Module = Module || {}; - -Module["preRun"] = Module["preRun"] || []; +Module = Module || {} -Module["preRun"].push(function () { - var i; +Module.preRun = Module.preRun || [] - Module["FS_createPath"]("/", "fonts", true, true); - Module["FS_createPath"]("/", "fontconfig", true, true); +Module.preRun.push(function () { + Module.FS_createPath('/', 'fonts', true, true) + Module.FS_createPath('/', 'fontconfig', true, true) - if (!self.subContent) { - // We can use sync xhr cause we're inside Web Worker - if (isBrotliFile(self.subUrl)) { - self.subContent = Module["BrotliDecode"](readBinary(self.subUrl)) - } else { - self.subContent = read_(self.subUrl); - } + if (!self.subContent) { + // We can use sync xhr cause we're inside Web Worker + if (isBrotliFile(self.subUrl)) { + self.subContent = Module.BrotliDecode(readBinary(self.subUrl)) + } else { + self.subContent = read_(self.subUrl) } - - if (self.availableFonts && self.availableFonts.length !== 0) { - var sections = parseAss(self.subContent); - for (var i = 0; i < sections.length; i++) { - for (var j = 0; j < sections[i].body.length; j++) { - if (sections[i].body[j].key === 'Style') { - self.writeFontToFS(sections[i].body[j].value['Fontname']); - } - } - } - - var regex = /\\fn([^\\}]*?)[\\}]/g; - var matches; - while (matches = regex.exec(self.subContent)) { - self.writeFontToFS(matches[1]); - } + } + + if (self.availableFonts && self.availableFonts.length !== 0) { + const sections = parseAss(self.subContent) + for (let i = 0; i < sections.length; i++) { + for (let j = 0; j < sections[i].body.length; j++) { + if (sections[i].body[j].key === 'Style') { + self.writeFontToFS(sections[i].body[j].value.Fontname) } - - if (self.subContent) { - Module["FS"].writeFile("/sub.ass", self.subContent); + } } - self.subContent = null; - - //Module["FS"].mount(Module["FS"].filesystems.IDBFS, {}, '/fonts'); - var fontFiles = self.fontFiles || []; - for (i = 0; i < fontFiles.length; i++) { - Module["FS_createPreloadedFile"]("/fonts", 'font' + i + '-' + fontFiles[i].split('/').pop(), fontFiles[i], true, true); + const regex = /\\fn([^\\}]*?)[\\}]/g + let matches + while (matches = regex.exec(self.subContent)) { + self.writeFontToFS(matches[1]) } -}); - -Module['onRuntimeInitialized'] = function () { - self.octObj = new Module.SubtitleOctopus(); - - self.changed = Module._malloc(4); - self.blendTime = Module._malloc(8); - self.blendX = Module._malloc(4); - self.blendY = Module._malloc(4); - self.blendW = Module._malloc(4); - self.blendH = Module._malloc(4); - - self.octObj.initLibrary(screen.width, screen.height); - self.octObj.createTrack("/sub.ass"); - self.ass_track = self.octObj.track; - self.ass_library = self.octObj.ass_library; - self.ass_renderer = self.octObj.ass_renderer; - - if (self.libassMemoryLimit > 0 || self.libassGlyphLimit > 0) { - self.octObj.setMemoryLimits(self.libassGlyphLimit, self.libassMemoryLimit); - } -}; + } + + if (self.subContent) { + Module.FS.writeFile('/sub.ass', self.subContent) + } + + self.subContent = null + + // Module["FS"].mount(Module["FS"].filesystems.IDBFS, {}, '/fonts'); + const fontFiles = self.fontFiles || [] + for (let i = 0; i < fontFiles.length; i++) { + Module.FS_createPreloadedFile('/fonts', 'font' + i + '-' + fontFiles[i].split('/').pop(), fontFiles[i], true, true) + } +}) + +Module.onRuntimeInitialized = function () { + self.octObj = new Module.SubtitleOctopus() + + self.changed = Module._malloc(4) + self.blendTime = Module._malloc(8) + self.blendX = Module._malloc(4) + self.blendY = Module._malloc(4) + self.blendW = Module._malloc(4) + self.blendH = Module._malloc(4) + + self.octObj.initLibrary(screen.width, screen.height) + self.octObj.createTrack('/sub.ass') + self.ass_track = self.octObj.track + self.ass_library = self.octObj.ass_library + self.ass_renderer = self.octObj.ass_renderer + + if (self.libassMemoryLimit > 0 || self.libassGlyphLimit > 0) { + self.octObj.setMemoryLimits(self.libassGlyphLimit, self.libassMemoryLimit) + } +} -Module["print"] = function (text) { - if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' '); - console.log(text); -}; -Module["printErr"] = function (text) { - if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' '); - console.error(text); -}; +Module.print = function (text) { + if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ') + console.log(text) +} +Module.printErr = function (text) { + if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ') + console.error(text) +} // Modified from https://github.com/kripken/emscripten/blob/6dc4ac5f9e4d8484e273e4dcc554f809738cedd6/src/proxyWorker.js if (!hasNativeConsole) { - // we can't call Module.printErr because that might be circular - var console = { - log: function (x) { - if (typeof dump === 'function') dump('log: ' + x + '\n'); - }, - debug: function (x) { - if (typeof dump === 'function') dump('debug: ' + x + '\n'); - }, - info: function (x) { - if (typeof dump === 'function') dump('info: ' + x + '\n'); - }, - warn: function (x) { - if (typeof dump === 'function') dump('warn: ' + x + '\n'); - }, - error: function (x) { - if (typeof dump === 'function') dump('error: ' + x + '\n'); - }, - }; + // we can't call Module.printErr because that might be circular + console = { + log: function (x) { + if (typeof dump === 'function') dump('log: ' + x + '\n') + }, + debug: function (x) { + if (typeof dump === 'function') dump('debug: ' + x + '\n') + }, + info: function (x) { + if (typeof dump === 'function') dump('info: ' + x + '\n') + }, + warn: function (x) { + if (typeof dump === 'function') dump('warn: ' + x + '\n') + }, + error: function (x) { + if (typeof dump === 'function') dump('error: ' + x + '\n') + } + } } diff --git a/src/subtitles-octopus.js b/src/subtitles-octopus.js index a1d9ca4c..cee8230a 100644 --- a/src/subtitles-octopus.js +++ b/src/subtitles-octopus.js @@ -1,661 +1,440 @@ -var SubtitlesOctopus = function (options) { - var supportsWebAssembly = false; +/* eslint-env browser */ + +let supportsWebAssembly = false +try { + if (typeof WebAssembly === 'object' && + typeof WebAssembly.instantiate === 'function') { + const module = new WebAssembly.Module(Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00)) + if (module instanceof WebAssembly.Module) { supportsWebAssembly = (new WebAssembly.Instance(module) instanceof WebAssembly.Instance) } + } +} catch (e) {} + +// test ImageData constructor +; (() => { + if (typeof ImageData.prototype.constructor === 'function') { try { - if (typeof WebAssembly === "object" - && typeof WebAssembly.instantiate === "function") { - const module = new WebAssembly.Module(Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00)); - if (module instanceof WebAssembly.Module) - supportsWebAssembly = (new WebAssembly.Instance(module) instanceof WebAssembly.Instance); - } + // try actually calling ImageData, as on some browsers it's reported + // as existing but calling it errors out as "TypeError: Illegal constructor" + return new ImageData(new Uint8ClampedArray([0, 0, 0, 0]), 1, 1) } catch (e) { + console.log('detected that ImageData is not constructable despite browser saying so') } - console.log("WebAssembly support detected: " + (supportsWebAssembly ? "yes" : "no")); - - var self = this; - self.canvas = options.canvas; // HTML canvas element (optional if video specified) - self.renderMode = options.renderMode || (options.lossyRender ? 'lossy' : 'wasm-blend'); - self.libassMemoryLimit = options.libassMemoryLimit || 0; - self.libassGlyphLimit = options.libassGlyphLimit || 0; - self.targetFps = options.targetFps || 24; - self.prescaleFactor = options.prescaleFactor || 1.0; - self.prescaleHeightLimit = options.prescaleHeightLimit || 1080; - self.maxRenderHeight = options.maxRenderHeight || 0; // 0 - no limit - self.isOurCanvas = false; // (internal) we created canvas and manage it - self.video = options.video; // HTML video element (optional if canvas specified) - self.canvasParent = null; // (internal) HTML canvas parent element - self.fonts = options.fonts || []; // Array with links to fonts used in sub (optional) - self.availableFonts = options.availableFonts || []; // Object with all available fonts (optional). Key is font name in lower case, value is link: {"arial": "/font1.ttf"} - self.onReadyEvent = options.onReady; // Function called when SubtitlesOctopus is ready (optional) - if (supportsWebAssembly) { - self.workerUrl = options.workerUrl || 'subtitles-octopus-worker.js'; // Link to WebAssembly worker - } else { - self.workerUrl = options.legacyWorkerUrl || 'subtitles-octopus-worker-legacy.js'; // Link to legacy worker + } + + const ctx = document.createElement('canvas').getContext('2d') + + window.ImageData = (data, width, height) => { + const imageData = ctx.createImageData(width, height) + if (data) imageData.data.set(data) + return imageData + } +})() + +export default class SubtitlesOctopus extends EventTarget { + constructor (options = {}) { + super() + if (!window.Worker) { + this.destroy('Worker not supported') + } + // blending mode, 'js' for hardware acceleration [if browser supports it], 'wasm' for devices/browsers that don't benetit from hardware acceleration + const _blendMode = options.blendMode || 'wasm' + // drop frames when under heavy load, good for website performance + const _asyncRender = typeof createImageBitmap !== 'undefined' && (options.asyncRender ?? true) + // render in worker, rather than on main thread + const _offscreenRender = typeof OffscreenCanvas !== 'undefined' && (options.offscreenRender ?? true) + // render subs as video player renders video frames, rather than "predicting" the time with events + this._onDemandRender = 'requestVideoFrameCallback' in HTMLVideoElement.prototype && (options.onDemandRender ?? true) + + this.timeOffset = options.timeOffset || 0 + this._video = options.video // HTML video element (optional if canvas specified) + this._canvasParent = null // HTML canvas parent element + if (this._video) { + this._canvasParent = document.createElement('div') + this._canvasParent.className = 'subtitles-octopus' + this._canvasParent.style.position = 'relative' + + if (this._video.nextSibling) { + this._video.parentNode.insertBefore(this._canvasParent, this._video.nextSibling) + } else { + this._video.parentNode.appendChild(this._canvasParent) + } + } else if (!this._canvas) { + this.destroy('Don\'t know where to render: you should give video or canvas in options.') } - self.subUrl = options.subUrl; // Link to sub file (optional if subContent specified) - self.subContent = options.subContent || null; // Sub content (optional if subUrl specified) - self.onErrorEvent = options.onError; // Function called in case of critical error meaning sub wouldn't be shown and you should use alternative method (for instance it occurs if browser doesn't support web workers). - self.debug = options.debug || false; // When debug enabled, some performance info printed in console. - self.lastRenderTime = 0; // (internal) Last time we got some frame from worker - self.pixelRatio = window.devicePixelRatio || 1; // (internal) Device pixel ratio (for high dpi devices) - - self.timeOffset = options.timeOffset || 0; // Time offset would be applied to currentTime from video (option) - - self.hasAlphaBug = false; - - (function() { - if (typeof ImageData.prototype.constructor === 'function') { - try { - // try actually calling ImageData, as on some browsers it's reported - // as existing but calling it errors out as "TypeError: Illegal constructor" - new window.ImageData(new Uint8ClampedArray([0, 0, 0, 0]), 1, 1); - return; - } catch (e) { - console.log("detected that ImageData is not constructable despite browser saying so"); - } - } - - var canvas = document.createElement('canvas'); - var ctx = canvas.getContext('2d'); - window.ImageData = function () { - var i = 0; - if (arguments[0] instanceof Uint8ClampedArray) { - var data = arguments[i++]; - } - var width = arguments[i++]; - var height = arguments[i]; + this._canvas = options.canvas || document.createElement('canvas') // HTML canvas element (optional if video specified) + this._canvas.style.display = 'block' + this._canvas.style.position = 'absolute' + this._canvas.style.pointerEvents = 'none' + this._canvasParent.appendChild(this._canvas) + + this._bufferCanvas = document.createElement('canvas') + this._bufferCtx = this._bufferCanvas.getContext('2d') + + this._canvasctrl = _offscreenRender ? this._canvas.transferControlToOffscreen() : this._canvas + this._ctx = !_offscreenRender && this._canvasctrl.getContext('2d') + + this._lastRenderTime = 0 // Last time we got some frame from worker + this.debug = !!options.debug // When debug enabled, some performance info printed in console. + + this.prescaleFactor = options.prescaleFactor || 1.0 + this.prescaleHeightLimit = options.prescaleHeightLimit || 1080 + this.maxRenderHeight = options.maxRenderHeight || 0 // 0 - no limit + + this._worker = new Worker(options.workerUrl || 'subtitles-octopus-worker.js') + this._worker.onmessage = e => this._onmessage(e) + this._worker.onerror = e => this._error(e) + + // test alpha bug + const canvas2 = document.createElement('canvas') + const ctx2 = canvas2.getContext('2d') + + // test for alpha bug, where e.g. WebKit can render a transparent pixel + // (with alpha == 0) as non-black which then leads to visual artifacts + this._bufferCanvas.width = 1 + this._bufferCanvas.height = 1 + this._bufferCtx.clearRect(0, 0, 1, 1) + ctx2.clearRect(0, 0, 1, 1) + const prePut = ctx2.getImageData(0, 0, 1, 1).data + this._bufferCtx.putImageData(new ImageData(new Uint8ClampedArray([0, 255, 0, 0]), 1, 1), 0, 0) + ctx2.drawImage(this._bufferCanvas, 0, 0) + const postPut = ctx2.getImageData(0, 0, 1, 1).data + this.hasAlphaBug = prePut[1] !== postPut[1] + if (this.hasAlphaBug) console.log('Detected a browser having issue with transparent pixels, applying workaround') + + this._worker.postMessage({ + target: 'init', + asyncRender: _asyncRender, + width: this._canvas.width, + height: this._canvas.height, + URL: document.URL, + currentScript: supportsWebAssembly ? options.workerUrl || 'subtitles-octopus-worker.js' : options.legacyWorkerUrl || 'subtitles-octopus-worker-legacy.js', // Link to WebAssembly worker + preMain: true, + blendMode: _blendMode, + subUrl: options.subUrl, // Link to sub file (optional if subContent specified) + subContent: options.subContent || null, // Sub content (optional if subUrl specified) + fonts: options.fonts || [], // Array with links to fonts used in sub (optional) + availableFonts: options.availableFonts || [], // Object with all available fonts (optional). Key is font name in lower case, value is link: {"arial": "/font1.ttf"} + debug: this.debug, + targetFps: options.targetFps, + libassMemoryLimit: options.libassMemoryLimit || 0, // set libass bitmap cache memory limit in MiB (approximate) + libassGlyphLimit: options.libassGlyphLimit || 0, // set libass glyph cache memory limit in MiB (approximate), + hasAlphaBug: this.hasAlphaBug + }) + if (_offscreenRender === true) this.sendMessage('offscreenCanvas', null, [this._canvasctrl]) + this.setVideo(options.video) + if (this._onDemandRender) this._video.requestVideoFrameCallback(this._demandRender.bind(this)) + } + + resize (width = 0, height = 0, top = 0, left = 0) { + let videoSize = null + if ((!width || !height) && this._video) { + videoSize = this._getVideoPosition() + const newsize = this._computeCanvasSize(videoSize.width || 0 * (window.devicePixelRatio || 1), videoSize.height || 0 * (window.devicePixelRatio || 1)) + width = newsize.width + height = newsize.height + top = videoSize.y - (this._canvasParent.getBoundingClientRect().top - this._video.getBoundingClientRect().top) + left = videoSize.x + } - var imageData = ctx.createImageData(width, height); - if (data) imageData.data.set(data); - return imageData; + if (this._canvas.style.top !== top || this._canvas.style.left !== left) { + if (videoSize != null) { + this._canvas.style.top = top + 'px' + this._canvas.style.left = left + 'px' + this._canvas.style.width = videoSize.width + 'px' + this._canvas.style.height = videoSize.height + 'px' + } + if (!(this._canvasctrl.width === width && this._canvasctrl.height === height)) { + // only re-paint if dimensions actually changed + // dont spam re-paints like crazy when re-sizing with animations, but still update instantly without them + if (this._resizeTimeoutBuffer) { + clearTimeout(this._resizeTimeoutBuffer) + this._resizeTimeoutBuffer = setTimeout(() => { + this._resizeTimeoutBuffer = undefined + this._canvasctrl.width = width + this._canvasctrl.height = height + this.sendMessage('canvas', { width, height }) + }, 50) + } else { + this._canvasctrl.width = width + this._canvasctrl.height = height + this.sendMessage('canvas', { width, height }) + this._resizeTimeoutBuffer = setTimeout(() => { + this._resizeTimeoutBuffer = undefined + }, 50) } - })(); + } + } + } + + _getVideoPosition () { + const videoRatio = this._video.videoWidth / this._video.videoHeight + const { offsetWidth, offsetHeight } = this._video + const elementRatio = offsetWidth / offsetHeight + let width = offsetWidth + let height = offsetHeight + if (elementRatio > videoRatio) { + width = Math.floor(offsetHeight * videoRatio) + } else { + height = Math.floor(offsetWidth / videoRatio) + } - self.workerError = function (error) { - console.error('Worker error: ', error); - if (self.onErrorEvent) { - self.onErrorEvent(error); - } - if (!self.debug) { - self.dispose(); - throw new Error('Worker error: ' + error); - } - }; + const x = (offsetWidth - width) / 2 + const y = (offsetHeight - height) / 2 - // Not tested for repeated usage yet - self.init = function () { - if (!window.Worker) { - self.workerError('worker not supported'); - return; - } - // Worker - if (!self.worker) { - self.worker = new Worker(self.workerUrl); - self.worker.addEventListener('message', self.onWorkerMessage); - self.worker.addEventListener('error', self.workerError); - } - self.workerActive = false; - self.createCanvas(); - self.setVideo(options.video); - self.setSubUrl(options.subUrl); - self.worker.postMessage({ - target: 'worker-init', - width: self.canvas.width, - height: self.canvas.height, - URL: document.URL, - currentScript: self.workerUrl, - preMain: true, - renderMode: self.renderMode, - subUrl: self.subUrl, - subContent: self.subContent, - fonts: self.fonts, - availableFonts: self.availableFonts, - debug: self.debug, - targetFps: self.targetFps, - libassMemoryLimit: self.libassMemoryLimit, - libassGlyphLimit: self.libassGlyphLimit - }); - }; - - self.createCanvas = function () { - if (!self.canvas) { - if (self.video) { - self.isOurCanvas = true; - self.canvas = document.createElement('canvas'); - self.canvas.className = 'libassjs-canvas'; - self.canvas.style.display = 'none'; - - self.canvasParent = document.createElement('div'); - self.canvasParent.className = 'libassjs-canvas-parent'; - self.canvasParent.appendChild(self.canvas); - - if (self.video.nextSibling) { - self.video.parentNode.insertBefore(self.canvasParent, self.video.nextSibling); - } - else { - self.video.parentNode.appendChild(self.canvasParent); - } - } - else { - if (!self.canvas) { - self.workerError('Don\'t know where to render: you should give video or canvas in options.'); - } - } - } - self.ctx = self.canvas.getContext('2d'); - self.bufferCanvas = document.createElement('canvas'); - self.bufferCanvasCtx = self.bufferCanvas.getContext('2d'); - - // test for alpha bug, where e.g. WebKit can render a transparent pixel - // (with alpha == 0) as non-black which then leads to visual artifacts - self.bufferCanvas.width = 1; - self.bufferCanvas.height = 1; - var testBuf = new Uint8ClampedArray([0, 255, 0, 0]); - var testImage = new ImageData(testBuf, 1, 1); - self.bufferCanvasCtx.clearRect(0, 0, 1, 1); - self.ctx.clearRect(0, 0, 1, 1); - var prePut = self.ctx.getImageData(0, 0, 1, 1).data; - self.bufferCanvasCtx.putImageData(testImage, 0, 0); - self.ctx.drawImage(self.bufferCanvas, 0, 0); - var postPut = self.ctx.getImageData(0, 0, 1, 1).data; - self.hasAlphaBug = prePut[1] != postPut[1]; - if (self.hasAlphaBug) { - console.log("Detected a browser having issue with transparent pixels, applying workaround"); - } - }; - - self.setVideo = function (video) { - self.video = video; - if (self.video) { - var timeupdate = function () { - self.setCurrentTime(video.currentTime + self.timeOffset); - } - self.video.addEventListener("timeupdate", timeupdate, false); - self.video.addEventListener("playing", function () { - self.setIsPaused(false, video.currentTime + self.timeOffset); - }, false); - self.video.addEventListener("pause", function () { - self.setIsPaused(true, video.currentTime + self.timeOffset); - }, false); - self.video.addEventListener("seeking", function () { - self.video.removeEventListener("timeupdate", timeupdate); - }, false); - self.video.addEventListener("seeked", function () { - self.video.addEventListener("timeupdate", timeupdate, false); - self.setCurrentTime(video.currentTime + self.timeOffset); - }, false); - self.video.addEventListener("ratechange", function () { - self.setRate(video.playbackRate); - }, false); - self.video.addEventListener("waiting", function () { - self.setIsPaused(true, video.currentTime + self.timeOffset); - }, false); - - document.addEventListener("fullscreenchange", self.resizeWithTimeout, false); - document.addEventListener("mozfullscreenchange", self.resizeWithTimeout, false); - document.addEventListener("webkitfullscreenchange", self.resizeWithTimeout, false); - document.addEventListener("msfullscreenchange", self.resizeWithTimeout, false); - window.addEventListener("resize", self.resizeWithTimeout, false); - - // Support Element Resize Observer - if (typeof ResizeObserver !== "undefined") { - self.ro = new ResizeObserver(self.resizeWithTimeout); - self.ro.observe(self.video); - } - - if (self.video.videoWidth > 0) { - self.resize(); - } - else { - self.video.addEventListener("loadedmetadata", function listener(e) { - e.target.removeEventListener(e.type, listener); - self.resize(); - }, false); - } - } - }; - - self.getVideoPosition = function () { - var videoRatio = self.video.videoWidth / self.video.videoHeight; - var width = self.video.offsetWidth, height = self.video.offsetHeight; - var elementRatio = width / height; - var realWidth = width, realHeight = height; - if (elementRatio > videoRatio) realWidth = Math.floor(height * videoRatio); - else realHeight = Math.floor(width / videoRatio); - - var x = (width - realWidth) / 2; - var y = (height - realHeight) / 2; - - return { - width: realWidth, - height: realHeight, - x: x, - y: y - }; - }; - - self.setSubUrl = function (subUrl) { - self.subUrl = subUrl; - }; - - self.renderFrameData = null; - function renderFrames() { - var data = self.renderFramesData; - var beforeDrawTime = performance.now(); - self.ctx.clearRect(0, 0, self.canvas.width, self.canvas.height); - for (var i = 0; i < data.canvases.length; i++) { - var image = data.canvases[i]; - self.bufferCanvas.width = image.w; - self.bufferCanvas.height = image.h; - var imageBuffer = new Uint8ClampedArray(image.buffer); - if (self.hasAlphaBug) { - for (var j = 3; j < imageBuffer.length; j = j + 4) { - imageBuffer[j] = (imageBuffer[j] >= 1) ? imageBuffer[j] : 1; - } - } - var imageData = new ImageData(imageBuffer, image.w, image.h); - self.bufferCanvasCtx.putImageData(imageData, 0, 0); - self.ctx.drawImage(self.bufferCanvas, image.x, image.y); - } - if (self.debug) { - var drawTime = Math.round(performance.now() - beforeDrawTime); - var blendTime = data.blendTime; - if (typeof blendTime !== 'undefined') { - console.log('render: ' + Math.round(data.spentTime - blendTime) + ' ms, blend: ' + Math.round(blendTime) + ' ms, draw: ' + drawTime + ' ms; TOTAL=' + Math.round(data.spentTime + drawTime) + ' ms'); - } else { - console.log(Math.round(data.spentTime) + ' ms (+ ' + drawTime + ' ms draw)'); - } - self.renderStart = performance.now(); - } - } + return { width, height, x, y } + } - /** - * Lossy Render Mode - * - */ - function renderFastFrames() { - var data = self.renderFramesData; - var beforeDrawTime = performance.now(); - self.ctx.clearRect(0, 0, self.canvas.width, self.canvas.height); - for (var i = 0; i < data.bitmaps.length; i++) { - var image = data.bitmaps[i]; - self.ctx.drawImage(image.bitmap, image.x, image.y); - } - if (self.debug) { - var drawTime = Math.round(performance.now() - beforeDrawTime); - console.log(data.bitmaps.length + ' bitmaps, libass: ' + Math.round(data.libassTime) + 'ms, decode: ' + Math.round(data.decodeTime) + 'ms, draw: ' + drawTime + 'ms'); - self.renderStart = performance.now(); - } - } + _computeCanvasSize (width = 0, height = 0) { + const scalefactor = this.prescaleFactor <= 0 ? 1.0 : this.prescaleFactor - self.workerActive = false; - self.frameId = 0; - self.onWorkerMessage = function (event) { - //dump('\nclient got ' + JSON.stringify(event.data).substr(0, 150) + '\n'); - if (!self.workerActive) { - self.workerActive = true; - if (self.onReadyEvent) { - self.onReadyEvent(); - } - } - var data = event.data; - switch (data.target) { - case 'stdout': { - console.log(data.content); - break; - } - case 'console-log': { - console.log.apply(console, JSON.parse(data.content)); - break; - } - case 'console-debug': { - console.debug.apply(console, JSON.parse(data.content)); - break; - } - case 'console-info': { - console.info.apply(console, JSON.parse(data.content)); - break; - } - case 'console-warn': { - console.warn.apply(console, JSON.parse(data.content)); - break; - } - case 'console-error': { - console.error.apply(console, JSON.parse(data.content)); - break; - } - case 'stderr': { - console.error(data.content); - break; - } - case 'window': { - window[data.method](); - break; - } - case 'canvas': { - switch (data.op) { - case 'getContext': { - self.ctx = self.canvas.getContext(data.type, data.attributes); - break; - } - case 'resize': { - self.resize(data.width, data.height); - break; - } - case 'renderCanvas': { - if (self.lastRenderTime < data.time) { - self.lastRenderTime = data.time; - self.renderFramesData = data; - window.requestAnimationFrame(renderFrames); - } - break; - } - case 'renderFastCanvas': { - if (self.lastRenderTime < data.time) { - self.lastRenderTime = data.time; - self.renderFramesData = data; - window.requestAnimationFrame(renderFastFrames); - } - break; - } - case 'setObjectProperty': { - self.canvas[data.object][data.property] = data.value; - break; - } - default: - throw 'eh?'; - } - break; - } - case 'tick': { - self.frameId = data.id; - self.worker.postMessage({ - target: 'tock', - id: self.frameId - }); - break; - } - case 'custom': { - if (self['onCustomMessage']) { - self['onCustomMessage'](event); - } else { - throw 'Custom message received but client onCustomMessage not implemented.'; - } - break; - } - case 'setimmediate': { - self.worker.postMessage({ - target: 'setimmediate' - }); - break; - } - case 'get-events': { - break; - } - case 'get-styles': { - break; - } - case 'ready': { - break; - } - default: - throw 'what? ' + data.target; - } - }; + if (height <= 0 || width <= 0) { + width = 0 + height = 0 + } else { + const sgn = scalefactor < 1 ? -1 : 1 + let newH = height + 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 + } - function _computeCanvasSize(width, height) { - var scalefactor = self.prescaleFactor <= 0 ? 1.0 : self.prescaleFactor; + return { width, height } + } - if (height <= 0 || width <= 0) { - width = 0; - height = 0; + _timeupdate ({ type }) { + const eventmap = { + seeking: true, + waiting: true, + playing: false + } + const playing = eventmap[type] + if (playing != null) this._playstate = playing + this.setCurrentTime(this._video.paused || this._playstate, this._video.currentTime + this.timeOffset) + } + + setVideo (video) { + if (video instanceof HTMLVideoElement) { + this._removeListeners() + this._video = video + if (this._onDemandRender !== true) { + this._playstate = video.paused + + video.addEventListener('timeupdate', e => this._timeupdate(e), false) + video.addEventListener('progress', e => this._timeupdate(e), false) + video.addEventListener('waiting', e => this._timeupdate(e), false) + video.addEventListener('seeking', e => this._timeupdate(e), false) + video.addEventListener('playing', e => this._timeupdate(e), false) + video.addEventListener('ratechange', e => this.setRate(e), false) + } + if (video.videoWidth > 0) { + this.resize() + } else { + video.addEventListener('loadedmetadata', () => this.resize(0, 0, 0, 0), false) + } + // Support Element Resize Observer + if (typeof ResizeObserver !== 'undefined') { + if (!this._ro) this._ro = new ResizeObserver(() => this.resize(0, 0, 0, 0)) + this._ro.observe(video) + } + } else { + this._error('Video element invalid!') + } + } + + runBenchmark () { + this.sendMessage('runBenchmark') + } + + setTrackByUrl (url) { + this.sendMessage('setTrackByUrl', { url }) + } + + setTrack (content) { + this.sendMessage('setTrack', { content }) + } + + freeTrack () { + this.sendMessage('freeTrack') + } + + setIsPaused (isPaused) { + this.sendMessage('video', { isPaused }) + } + + setRate (rate) { + this.sendMessage('video', { rate }) + } + + setCurrentTime (isPaused, currentTime, rate) { + this.sendMessage('video', { isPaused, currentTime, rate }) + } + + createEvent (event) { + this.sendMessage('createEvent', { event }) + } + + setEvent (event, index) { + this.sendMessage('setEvent', { event, index }) + } + + removeEvent (index) { + this.sendMessage('removeEvent', { index }) + } + + getEvents (onError, onSuccess) { + this._fetchFromWorker({ + target: 'getEvents' + }, onError, ({ events }) => { + onSuccess(events) + }) + } + + createStyle (style) { + this.sendMessage('createStyle', { style }) + } + + setStyle (event, index) { + this.sendMessage('setStyle', { event, index }) + } + + removeStyle (index) { + this.sendMessage('removeStyle', { index }) + } + + getStyles (onError, onSuccess) { + this._fetchFromWorker({ + target: 'getStyles' + }, onError, ({ styles }) => { + onSuccess(styles) + }) + } + + _demandRender (now, metadata) { + if (this._destroyed) return null + this.sendMessage('demand', { time: metadata.mediaTime + this.timeOffset }) + this._video.requestVideoFrameCallback(this._demandRender.bind(this)) + } + + _render (data) { + this._ctx.clearRect(0, 0, this._canvasctrl.width, this._canvasctrl.height) + for (const image of data.images) { + if (image.buffer) { + if (data.async) { + this._ctx.drawImage(image.buffer, image.x, image.y) } else { - var sgn = scalefactor < 1 ? -1 : 1; - var newH = height; - if (sgn * newH * scalefactor <= sgn * self.prescaleHeightLimit) - newH *= scalefactor; - else if (sgn * newH < sgn * self.prescaleHeightLimit) - newH = self.prescaleHeightLimit; - - if (self.maxRenderHeight > 0 && newH > self.maxRenderHeight) - newH = self.maxRenderHeight; - - width *= newH / height; - height = newH; + this._bufferCanvas.width = image.w + this._bufferCanvas.height = image.h + this._bufferCtx.putImageData(new ImageData(this._fixAlpha(new Uint8ClampedArray(image.buffer)), image.w, image.h), 0, 0) + this._ctx.drawImage(this._bufferCanvas, image.x, image.y) } + } + } + } - return {'width': width, 'height': height}; + _fixAlpha (uint8) { + if (this.hasAlphaBug) { + for (let j = 3; j < uint8.length; j += 4) { + uint8[j] = uint8[j] > 1 ? uint8[j] : 1 + } } + return uint8 + } + + _ready () { + this.dispatchEvent(new CustomEvent('ready')) + } + + sendMessage (target, data = {}, transferable) { + if (transferable) { + this._worker.postMessage({ + target, + transferable, + ...data + }, [...transferable]) + } else { + this._worker.postMessage({ + target, + ...data + }) + } + } - self.resize = function (width, height, top, left) { - var videoSize = null; - top = top || 0; - left = left || 0; - if ((!width || !height) && self.video) { - videoSize = self.getVideoPosition(); - var newSize = _computeCanvasSize(videoSize.width * self.pixelRatio, videoSize.height * self.pixelRatio); - width = newSize.width; - height = newSize.height; - var offset = self.canvasParent.getBoundingClientRect().top - self.video.getBoundingClientRect().top; - top = videoSize.y - offset; - left = videoSize.x; - } - if (!width || !height) { - if (!self.video) { - console.error('width or height is 0. You should specify width & height for resize.'); - } - return; + _fetchFromWorker (workerOptions, onError, onSuccess) { + try { + const target = workerOptions.target + + const timeout = setTimeout(() => { + reject(new Error('Error: Timeout while try to fetch ' + target)) + }, 5000) + + const resolve = ({ data }) => { + if (data.target === target) { + onSuccess(data) + this._worker.removeEventListener('message', resolve) + this._worker.removeEventListener('error', reject) + clearTimeout(timeout) } + } + const reject = function (event) { + onError(event) + this._worker.removeEventListener('message', resolve) + this._worker.removeEventListener('error', reject) + clearTimeout(timeout) + } - if ( - self.canvas.width != width || - self.canvas.height != height || - self.canvas.style.top != top || - self.canvas.style.left != left - ) { - self.canvas.width = width; - self.canvas.height = height; - - if (videoSize != null) { - self.canvasParent.style.position = 'relative'; - self.canvas.style.display = 'block'; - self.canvas.style.position = 'absolute'; - self.canvas.style.width = videoSize.width + 'px'; - self.canvas.style.height = videoSize.height + 'px'; - self.canvas.style.top = top + 'px'; - self.canvas.style.left = left + 'px'; - self.canvas.style.pointerEvents = 'none'; - } - - self.worker.postMessage({ - target: 'canvas', - width: self.canvas.width, - height: self.canvas.height - }); - } - }; - - self.resizeWithTimeout = function () { - self.resize(); - setTimeout(self.resize, 100); - }; - - self.runBenchmark = function () { - self.worker.postMessage({ - target: 'runBenchmark' - }); - }; - - self.customMessage = function (data, options) { - options = options || {}; - self.worker.postMessage({ - target: 'custom', - userData: data, - preMain: options.preMain - }); - }; - - self.setCurrentTime = function (currentTime) { - self.worker.postMessage({ - target: 'video', - currentTime: currentTime - }); - }; - - self.setTrackByUrl = function (url) { - self.worker.postMessage({ - target: 'set-track-by-url', - url: url - }); - }; - - self.setTrack = function (content) { - self.worker.postMessage({ - target: 'set-track', - content: content - }); - }; - - self.freeTrack = function (content) { - self.worker.postMessage({ - target: 'free-track' - }); - }; - - - self.render = self.setCurrentTime; - - self.setIsPaused = function (isPaused, currentTime) { - self.worker.postMessage({ - target: 'video', - isPaused: isPaused, - currentTime: currentTime - }); - }; - - self.setRate = function (rate) { - self.worker.postMessage({ - target: 'video', - rate: rate - }); - }; - - self.dispose = function () { - self.worker.postMessage({ - target: 'destroy' - }); - - self.worker.terminate(); - self.workerActive = false; - // Remove the canvas element to remove residual subtitles rendered on player - if (self.video) { - self.video.parentNode.removeChild(self.canvasParent); - } - }; - - self.fetchFromWorker = function (workerOptions, onSuccess, onError) { - try { - var target = workerOptions['target'] - - var timeout = setTimeout(function() { - reject(Error('Error: Timeout while try to fetch ' + target)) - }, 5000) - - var resolve = function (event) { - if (event.data.target == target) { - onSuccess(event.data) - self.worker.removeEventListener('message', resolve) - self.worker.removeEventListener('error', reject) - clearTimeout(timeout) - } - } - - var reject = function (event) { - onError(event) - self.worker.removeEventListener('message', resolve) - self.worker.removeEventListener('error', reject) - clearTimeout(timeout) - } - - self.worker.addEventListener('message', resolve) - self.worker.addEventListener('error', reject) - - self.worker.postMessage(workerOptions) - } catch (error) { - onError(error) - } - } + this._worker.addEventListener('message', resolve) + this._worker.addEventListener('error', reject) - self.createEvent = function (event) { - self.worker.postMessage({ - target: 'create-event', - event: event - }); - }; - - self.getEvents = function (onSuccess, onError) { - self.fetchFromWorker({ - target: 'get-events' - }, function(data) { - onSuccess(data.events) - }, onError); - }; - - self.setEvent = function (event, index) { - self.worker.postMessage({ - target: 'set-event', - event: event, - index: index - }); - }; - - self.removeEvent = function (index) { - self.worker.postMessage({ - target: 'remove-event', - index: index - }); - }; - - self.createStyle = function (style) { - self.worker.postMessage({ - target: 'create-style', - style: style - }); - }; - - self.getStyles = function (onSuccess, onError) { - self.fetchFromWorker({ - target: 'get-styles' - }, function(data) { - onSuccess(data.styles) - }, onError); - }; - - self.setStyle = function (style, index) { - self.worker.postMessage({ - target: 'set-style', - style: style, - index: index - }); - }; - - self.removeStyle = function (index) { - self.worker.postMessage({ - target: 'remove-style', - index: index - }); - }; - - self.init(); -}; - -if (typeof SubtitlesOctopusOnLoad == 'function') { - SubtitlesOctopusOnLoad(); + this._worker.postMessage(workerOptions) + } catch (error) { + this._error(error) + } + } + + _console ({ content, command }) { + console[command].apply(console, JSON.parse(content)) + } + + _onmessage ({ data }) { + if (this['_' + data.target]) this['_' + data.target](data) + } + + _error (err) { + this.dispatchEvent(new CustomEvent('error', { detail: err })) + throw new Error(err) + } + + _removeListeners () { + if (this._video) { + if (this._ro) this._ro.unobserve(this._video) + this._video.removeEventListener('timeupdate', this._timeupdate) + this._video.removeEventListener('progress', this._timeupdate) + this._video.removeEventListener('waiting', this._timeupdate) + this._video.removeEventListener('seeking', this._timeupdate) + this._video.removeEventListener('playing', this._timeupdate) + this._video.removeEventListener('ratechange', this.setRate) + } + } + + destroy (err) { + if (err) this._error(err) + if (this._video) this._video.parentNode.removeChild(this._canvasParent) + this._destroyed = true + this._removeListeners() + this.sendMessage('destroy') + this._worker.terminate() + } } -if (typeof exports !== 'undefined') { - if (typeof module !== 'undefined' && module.exports) { - exports = module.exports = SubtitlesOctopus - } +if (typeof exports !== 'undefined' && typeof module !== 'undefined' && module.exports) { + exports = module.exports = SubtitlesOctopus } From c7f57b9a6a0026a32f49f6a2ee1ae6bec401a31b Mon Sep 17 00:00:00 2001 From: ThaUnknown <6506529+ThaUnknown@users.noreply.github.com> Date: Tue, 22 Feb 2022 01:06:52 +0100 Subject: [PATCH 2/6] jsdoc --- src/subtitles-octopus.js | 203 +++++++++++++++++++++++++++++++++------ 1 file changed, 173 insertions(+), 30 deletions(-) diff --git a/src/subtitles-octopus.js b/src/subtitles-octopus.js index cee8230a..eb645148 100644 --- a/src/subtitles-octopus.js +++ b/src/subtitles-octopus.js @@ -29,25 +29,46 @@ try { return imageData } })() - +/** + * New SubtitlesOctopus instance. + * @class + */ export default class SubtitlesOctopus extends EventTarget { + /** + * @param {Object} options Settings object. + * @param {HTMLVideoElement} options.video Video to use as target for event listeners. Optional if canvas is specified instead. + * @param {HTMLCanvasElement} [options.canvas=HTMLCanvasElement] Canvas to use for manual handling. Not required if video is specified. + * @param {'js'|'wasm'} [options.blendMode='wasm'] Which color blending mode to use. WASM will perform better on lower end devices, JS can perform better if the device and browser supports hardware acceleration. + * @param {Boolean} [options.asyncRender=true] Whether or not to use async rendering, which can skip rendering frames when resources aren't available. + * @param {Boolean} [options.offscreenRender=true] Whether or not to render things fully on the worker, greatly reduces CPU usage. + * @param {Boolean} [options.onDemandRender=true] Whether or not to render subtitles as the video player decodes frames, rather than predicting which frame the player is on using events. + * @param {Number} [options.targetFps=24] Target FPS to render subtitles at. + * @param {Number} [options.timeOffset=0] Subtitle time offset in seconds. + * @param {Boolean} [options.debug=false] Whether or not to print debug information. + * @param {Number} [options.prescaleFactor=1.0] Scale down (< 1.0) the subtitles canvas to improve performance at the expense of quality, or scale it up (> 1.0). + * @param {Number} [options.prescaleHeightLimit=1080] The height in pixels beyond which the subtitles canvas won't be prescaled. + * @param {Number} [options.prescaleHeightLimit=0] The maximum rendering height in pixels of the subtitles canvas. Beyond this subtitles will be upscaled by the browser. + * @param {String} [options.workerUrl='subtitles-octopus-worker.js'] The URL of the worker. + * @param {String} [options.subUrl=options.subContent] The URL of the subtitle file to play. + * @param {String} [options.subContent=options.subUrl] The content of the subtitle file to play. + * @param {String[]} [options.fonts] An array of links to the fonts used in the subtitle. + * @param {Object} [options.availableFonts] Object with all available fonts - Key is font name in lower case, value is link: { arial: '/font1.ttf' }. + * @param {Number} [options.libassMemoryLimit] libass bitmap cache memory limit in MiB (approximate). + * @param {Number} [options.libassGlyphLimit] libass glyph cache memory limit in MiB (approximate). + */ constructor (options = {}) { super() if (!window.Worker) { this.destroy('Worker not supported') } - // blending mode, 'js' for hardware acceleration [if browser supports it], 'wasm' for devices/browsers that don't benetit from hardware acceleration const _blendMode = options.blendMode || 'wasm' - // drop frames when under heavy load, good for website performance const _asyncRender = typeof createImageBitmap !== 'undefined' && (options.asyncRender ?? true) - // render in worker, rather than on main thread const _offscreenRender = typeof OffscreenCanvas !== 'undefined' && (options.offscreenRender ?? true) - // render subs as video player renders video frames, rather than "predicting" the time with events this._onDemandRender = 'requestVideoFrameCallback' in HTMLVideoElement.prototype && (options.onDemandRender ?? true) this.timeOffset = options.timeOffset || 0 - this._video = options.video // HTML video element (optional if canvas specified) - this._canvasParent = null // HTML canvas parent element + this._video = options.video + this._canvasParent = null if (this._video) { this._canvasParent = document.createElement('div') this._canvasParent.className = 'subtitles-octopus' @@ -62,7 +83,7 @@ export default class SubtitlesOctopus extends EventTarget { this.destroy('Don\'t know where to render: you should give video or canvas in options.') } - this._canvas = options.canvas || document.createElement('canvas') // HTML canvas element (optional if video specified) + this._canvas = options.canvas || document.createElement('canvas') this._canvas.style.display = 'block' this._canvas.style.position = 'absolute' this._canvas.style.pointerEvents = 'none' @@ -74,23 +95,22 @@ export default class SubtitlesOctopus extends EventTarget { this._canvasctrl = _offscreenRender ? this._canvas.transferControlToOffscreen() : this._canvas this._ctx = !_offscreenRender && this._canvasctrl.getContext('2d') - this._lastRenderTime = 0 // Last time we got some frame from worker - this.debug = !!options.debug // When debug enabled, some performance info printed in console. + this._lastRenderTime = 0 + this.debug = !!options.debug this.prescaleFactor = options.prescaleFactor || 1.0 this.prescaleHeightLimit = options.prescaleHeightLimit || 1080 - this.maxRenderHeight = options.maxRenderHeight || 0 // 0 - no limit + this.maxRenderHeight = options.maxRenderHeight || 0 // 0 - no limit. this._worker = new Worker(options.workerUrl || 'subtitles-octopus-worker.js') this._worker.onmessage = e => this._onmessage(e) this._worker.onerror = e => this._error(e) - // test alpha bug const canvas2 = document.createElement('canvas') const ctx2 = canvas2.getContext('2d') - // test for alpha bug, where e.g. WebKit can render a transparent pixel - // (with alpha == 0) as non-black which then leads to visual artifacts + // Test for alpha bug, where e.g. WebKit can render a transparent pixel + // (with alpha == 0) as non-black which then leads to visual artifacts. this._bufferCanvas.width = 1 this._bufferCanvas.height = 1 this._bufferCtx.clearRect(0, 0, 1, 1) @@ -111,14 +131,14 @@ export default class SubtitlesOctopus extends EventTarget { currentScript: supportsWebAssembly ? options.workerUrl || 'subtitles-octopus-worker.js' : options.legacyWorkerUrl || 'subtitles-octopus-worker-legacy.js', // Link to WebAssembly worker preMain: true, blendMode: _blendMode, - subUrl: options.subUrl, // Link to sub file (optional if subContent specified) - subContent: options.subContent || null, // Sub content (optional if subUrl specified) - fonts: options.fonts || [], // Array with links to fonts used in sub (optional) - availableFonts: options.availableFonts || [], // Object with all available fonts (optional). Key is font name in lower case, value is link: {"arial": "/font1.ttf"} + subUrl: options.subUrl, + subContent: options.subContent || null, + fonts: options.fonts || [], + availableFonts: options.availableFonts || [], debug: this.debug, targetFps: options.targetFps, - libassMemoryLimit: options.libassMemoryLimit || 0, // set libass bitmap cache memory limit in MiB (approximate) - libassGlyphLimit: options.libassGlyphLimit || 0, // set libass glyph cache memory limit in MiB (approximate), + libassMemoryLimit: options.libassMemoryLimit || 0, + libassGlyphLimit: options.libassGlyphLimit || 0, hasAlphaBug: this.hasAlphaBug }) if (_offscreenRender === true) this.sendMessage('offscreenCanvas', null, [this._canvasctrl]) @@ -126,6 +146,13 @@ export default class SubtitlesOctopus extends EventTarget { if (this._onDemandRender) this._video.requestVideoFrameCallback(this._demandRender.bind(this)) } + /** + * Resize the canvas to given parameters. Auto-generated if values are ommited. + * @param {Number} [width=0] + * @param {Number} [height=0] + * @param {Number} [top=0] + * @param {Number} [left=0] + */ resize (width = 0, height = 0, top = 0, left = 0) { let videoSize = null if ((!width || !height) && this._video) { @@ -220,6 +247,10 @@ export default class SubtitlesOctopus extends EventTarget { this.setCurrentTime(this._video.paused || this._playstate, this._video.currentTime + this.timeOffset) } + /** + * Change the video to use as target for event listeners. + * @param {HTMLVideoElement} video + */ setVideo (video) { if (video instanceof HTMLVideoElement) { this._removeListeners() @@ -253,10 +284,18 @@ export default class SubtitlesOctopus extends EventTarget { this.sendMessage('runBenchmark') } + /** + * Overwrites the current subtitle content. + * @param {String} url URL to load subtitles from. + */ setTrackByUrl (url) { this.sendMessage('setTrackByUrl', { url }) } + /** + * Overwrites the current subtitle content. + * @param {String} content Content of the ASS file. + */ setTrack (content) { this.sendMessage('setTrack', { content }) } @@ -265,55 +304,149 @@ export default class SubtitlesOctopus extends EventTarget { this.sendMessage('freeTrack') } + /** + * Sets the playback state of the media. + * @param {Boolean} isPaused Pause/Play subtitle playback. + */ setIsPaused (isPaused) { this.sendMessage('video', { isPaused }) } + /** + * Sets the playback rate of the media [speed multiplier]. + * @param {Number} rate Playback rate. + */ setRate (rate) { this.sendMessage('video', { rate }) } + /** + * Sets the current time, playback state and rate of the subtitles. + * @param {Boolean} [isPaused] Pause/Play subtitle playback. + * @param {Number} [currentTime] Time in seconds. + * @param {Number} [rate] Playback rate. + */ setCurrentTime (isPaused, currentTime, rate) { this.sendMessage('video', { isPaused, currentTime, rate }) } + /** + * @typedef {Object} ASS_Event + * @property {Number} Start Start Time of the Event, in 0:00:00:00 format ie. Hrs:Mins:Secs:hundredths. This is the time elapsed during script playback at which the text will appear onscreen. Note that there is a single digit for the hours! + * @property {Number} Duration End Time of the Event, in 0:00:00:00 format ie. Hrs:Mins:Secs:hundredths. This is the time elapsed during script playback at which the text will disappear offscreen. Note that there is a single digit for the hours! + * @property {String} Style Style name. If it is "Default", then your own *Default style will be subtituted. + * @property {String} Name Character name. This is the name of the character who speaks the dialogue. It is for information only, to make the script is easier to follow when editing/timing. + * @property {Number} MarginL 4-figure Left Margin override. The values are in pixels. All zeroes means the default margins defined by the style are used. + * @property {Number} MarginR 4-figure Right Margin override. The values are in pixels. All zeroes means the default margins defined by the style are used. + * @property {Number} MarginV 4-figure Bottom Margin override. The values are in pixels. All zeroes means the default margins defined by the style are used. + * @property {String} Effect Transition Effect. This is either empty, or contains information for one of the three transition effects implemented in SSA v4.x + * @property {String} Text Subtitle Text. This is the actual text which will be displayed as a subtitle onscreen. Everything after the 9th comma is treated as the subtitle text, so it can include commas. + * @property {Number} ReadOrder Number in order of which to read this event. + * @property {Number} Layer Z-index overlap in which to render this event. + * @property {Number} _index (Internal) index of the event. + */ + + /** + * Create a new ASS event directly. + * @param {ASS_Event} event + */ createEvent (event) { this.sendMessage('createEvent', { event }) } + /** + * Overwrite the data of the event with the specified index. + * @param {ASS_Event} event + * @param {Number} index + */ setEvent (event, index) { this.sendMessage('setEvent', { event, index }) } + /** + * Remove the event with the specified index. + * @param {Number} index + */ removeEvent (index) { this.sendMessage('removeEvent', { index }) } - getEvents (onError, onSuccess) { + /** + * Get all ASS events. + * @param {function(Error|null, ASS_Event)} callback Function to callback when worker returns the events. + */ + getEvents (callback) { this._fetchFromWorker({ target: 'getEvents' - }, onError, ({ events }) => { - onSuccess(events) + }, (err, { events }) => { + callback(err, events) }) } + /** + * @typedef {Object} ASS_Style + * @property {String} Name The name of the Style. Case sensitive. Cannot include commas. + * @property {String} FontName The fontname as used by Windows. Case-sensitive. + * @property {Number} FontSize Font size. + * @property {Number} PrimaryColour A long integer BGR (blue-green-red) value. ie. the byte order in the hexadecimal equivelent of this number is BBGGRR + * @property {Number} SecondaryColour A long integer BGR (blue-green-red) value. ie. the byte order in the hexadecimal equivelent of this number is BBGGRR + * @property {Number} OutlineColour A long integer BGR (blue-green-red) value. ie. the byte order in the hexadecimal equivelent of this number is BBGGRR + * @property {Number} BackColour This is the colour of the subtitle outline or shadow, if these are used. A long integer BGR (blue-green-red) value. ie. the byte order in the hexadecimal equivelent of this number is BBGGRR. + * @property {Number} Bold This defines whether text is bold (true) or not (false). -1 is True, 0 is False. This is independant of the Italic attribute - you can have have text which is both bold and italic. + * @property {Number} Italic Italic. This defines whether text is italic (true) or not (false). -1 is True, 0 is False. This is independant of the bold attribute - you can have have text which is both bold and italic. + * @property {Number} Underline -1 or 0 + * @property {Number} StrikeOut -1 or 0 + * @property {Number} ScaleX Modifies the width of the font. [percent] + * @property {Number} ScaleY Modifies the height of the font. [percent] + * @property {Number} Spacing Extra space between characters. [pixels] + * @property {Number} Angle The origin of the rotation is defined by the alignment. Can be a floating point number. [degrees] + * @property {Number} BorderStyle 1=Outline + drop shadow, 3=Opaque box + * @property {Number} Outline If BorderStyle is 1, then this specifies the width of the outline around the text, in pixels. Values may be 0, 1, 2, 3 or 4. + * @property {Number} Shadow If BorderStyle is 1, then this specifies the depth of the drop shadow behind the text, in pixels. Values may be 0, 1, 2, 3 or 4. Drop shadow is always used in addition to an outline - SSA will force an outline of 1 pixel if no outline width is given. + * @property {Number} Alignment This sets how text is "justified" within the Left/Right onscreen margins, and also the vertical placing. Values may be 1=Left, 2=Centered, 3=Right. Add 4 to the value for a "Toptitle". Add 8 to the value for a "Midtitle". eg. 5 = left-justified toptitle + * @property {Number} MarginL This defines the Left Margin in pixels. It is the distance from the left-hand edge of the screen.The three onscreen margins (MarginL, MarginR, MarginV) define areas in which the subtitle text will be displayed. + * @property {Number} MarginR This defines the Right Margin in pixels. It is the distance from the right-hand edge of the screen. The three onscreen margins (MarginL, MarginR, MarginV) define areas in which the subtitle text will be displayed. + * @property {Number} MarginV This defines the vertical Left Margin in pixels. For a subtitle, it is the distance from the bottom of the screen. For a toptitle, it is the distance from the top of the screen. For a midtitle, the value is ignored - the text will be vertically centred. + * @property {Number} Encoding This specifies the font character set or encoding and on multi-lingual Windows installations it provides access to characters used in multiple than one languages. It is usually 0 (zero) for English (Western, ANSI) Windows. + * @property {Number} treat_fontname_as_pattern + * @property {Number} Blur + * @property {Number} Justify + */ + + /** + * Create a new ASS style directly. + * @param {ASS_Style} event + */ createStyle (style) { this.sendMessage('createStyle', { style }) } + /** + * Overwrite the data of the style with the specified index. + * @param {ASS_Style} event + * @param {Number} index + */ setStyle (event, index) { this.sendMessage('setStyle', { event, index }) } + /** + * Remove the style with the specified index. + * @param {Number} index + */ removeStyle (index) { this.sendMessage('removeStyle', { index }) } - getStyles (onError, onSuccess) { + /** + * Get all ASS styles. + * @param {function(Error|null, ASS_Style)} callback Function to callback when worker returns the styles. + */ + getStyles (callback) { this._fetchFromWorker({ target: 'getStyles' - }, onError, ({ styles }) => { - onSuccess(styles) + }, (err, { styles }) => { + callback(err, styles) }) } @@ -352,6 +485,12 @@ export default class SubtitlesOctopus extends EventTarget { this.dispatchEvent(new CustomEvent('ready')) } + /** + * Send data and execute function in the worker. + * @param {String} target Target function. + * @param {Object} [data] Data for function. + * @param {Transferable[]} [transferable] Array of transferables. + */ sendMessage (target, data = {}, transferable) { if (transferable) { this._worker.postMessage({ @@ -367,7 +506,7 @@ export default class SubtitlesOctopus extends EventTarget { } } - _fetchFromWorker (workerOptions, onError, onSuccess) { + _fetchFromWorker (workerOptions, callback) { try { const target = workerOptions.target @@ -377,15 +516,15 @@ export default class SubtitlesOctopus extends EventTarget { const resolve = ({ data }) => { if (data.target === target) { - onSuccess(data) + callback(null, data) this._worker.removeEventListener('message', resolve) this._worker.removeEventListener('error', reject) clearTimeout(timeout) } } - const reject = function (event) { - onError(event) + const reject = event => { + callback(event) this._worker.removeEventListener('message', resolve) this._worker.removeEventListener('error', reject) clearTimeout(timeout) @@ -425,6 +564,10 @@ export default class SubtitlesOctopus extends EventTarget { } } + /** + * Destroy the object, worker, listeners and all data. + * @param {String} [err] Error to throw when destroying. + */ destroy (err) { if (err) this._error(err) if (this._video) this._video.parentNode.removeChild(this._canvasParent) From a5a68985a604a7e04853bbe8022821756abd4795 Mon Sep 17 00:00:00 2001 From: ThaUnknown <6506529+ThaUnknown@users.noreply.github.com> Date: Fri, 25 Feb 2022 03:46:45 +0100 Subject: [PATCH 3/6] close buffers after draw --- src/post-worker.js | 7 ++++--- src/subtitles-octopus.js | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/post-worker.js b/src/post-worker.js index 2ce17ab1..6f392eec 100644 --- a/src/post-worker.js +++ b/src/post-worker.js @@ -256,6 +256,7 @@ self.paintImages = ({ images, buffers }) => { if (image.buffer) { if (self.asyncRender) { self.offscreenCanvasCtx.drawImage(image.buffer, image.x, image.y) + image.buffer.close() } else { self.bufferCanvas.width = image.w self.bufferCanvas.height = image.h @@ -483,10 +484,10 @@ onmessage = message => { clearTimeout(messageResenderTimeout) messageResender() } - if (self[message.data.target]) { - self[message.data.target](message.data) + const data = message.data + if (self[data.target]) { + self[data.target](data) } else { - const { data } = message switch (data.target) { case 'offscreenCanvas': self.offscreenCanvas = data.transferable[0] diff --git a/src/subtitles-octopus.js b/src/subtitles-octopus.js index eb645148..eeaac31b 100644 --- a/src/subtitles-octopus.js +++ b/src/subtitles-octopus.js @@ -462,6 +462,7 @@ export default class SubtitlesOctopus extends EventTarget { if (image.buffer) { if (data.async) { this._ctx.drawImage(image.buffer, image.x, image.y) + image.buffer.close() } else { this._bufferCanvas.width = image.w this._bufferCanvas.height = image.h From 6e7eb0d33d2b6484e039be160bae5a59c689fce3 Mon Sep 17 00:00:00 2001 From: ThaUnknown <6506529+ThaUnknown@users.noreply.github.com> Date: Tue, 1 Mar 2022 21:52:36 +0100 Subject: [PATCH 4/6] remove const from screen, remove switch statement for onmessage --- src/post-worker.js | 206 +++++++++++++++++++++++---------------------- 1 file changed, 106 insertions(+), 100 deletions(-) diff --git a/src/post-worker.js b/src/post-worker.js index 6f392eec..2194eb12 100644 --- a/src/post-worker.js +++ b/src/post-worker.js @@ -361,7 +361,8 @@ self.requestAnimationFrame = (function () { } })() -const screen = { +// eslint-disable-next-line +let screen = { width: 0, height: 0 } @@ -471,6 +472,109 @@ self.video = data => { self.rate = data.rate || self.rate } +self.offscreenCanvas = data => { + self.offscreenCanvas = data.transferable[0] + self.offscreenCanvasCtx = self.offscreenCanvas.getContext('2d') + self.bufferCanvas = new OffscreenCanvas(self.height, self.width) + self.bufferCtx = self.bufferCanvas.getContext('2d') +} + +self.destroy = () => { + self.octObj.quitLibrary() +} + +self.createEvent = data => { + _applyKeys(data.event, self.octObj.track.get_events(self.octObj.allocEvent())) +} + +self.getEvents = () => { + const events = [] + for (let i = 0; i < self.octObj.getEventCount(); i++) { + const evntPtr = self.octObj.track.get_events(i) + events.push({ + Start: evntPtr.get_Start(), + Duration: evntPtr.get_Duration(), + ReadOrder: evntPtr.get_ReadOrder(), + Layer: evntPtr.get_Layer(), + Style: evntPtr.get_Style(), + Name: evntPtr.get_Name(), + MarginL: evntPtr.get_MarginL(), + MarginR: evntPtr.get_MarginR(), + MarginV: evntPtr.get_MarginV(), + Effect: evntPtr.get_Effect(), + Text: evntPtr.get_Text() + }) + } + postMessage({ + target: 'getEvents', + events: events + }) +} + +self.setEvent = data => { + _applyKeys(data.event, self.octObj.track.get_events(data.index)) +} + +self.removeEvent = data => { + self.octObj.removeEvent(data.index) +} + +self.createStyle = data => { + _applyKeys(data.style, self.octObj.track.get_styles(self.octObj.allocStyle())) +} + +self.getStyles = () => { + const styles = [] + for (let i = 0; i < self.octObj.getStyleCount(); i++) { + const stylPtr = self.octObj.track.get_styles(i) + styles.push({ + Name: stylPtr.get_Name(), + FontName: stylPtr.get_FontName(), + FontSize: stylPtr.get_FontSize(), + PrimaryColour: stylPtr.get_PrimaryColour(), + SecondaryColour: stylPtr.get_SecondaryColour(), + OutlineColour: stylPtr.get_OutlineColour(), + BackColour: stylPtr.get_BackColour(), + Bold: stylPtr.get_Bold(), + Italic: stylPtr.get_Italic(), + Underline: stylPtr.get_Underline(), + StrikeOut: stylPtr.get_StrikeOut(), + ScaleX: stylPtr.get_ScaleX(), + ScaleY: stylPtr.get_ScaleY(), + Spacing: stylPtr.get_Spacing(), + Angle: stylPtr.get_Angle(), + BorderStyle: stylPtr.get_BorderStyle(), + Outline: stylPtr.get_Outline(), + Shadow: stylPtr.get_Shadow(), + Alignment: stylPtr.get_Alignment(), + MarginL: stylPtr.get_MarginL(), + MarginR: stylPtr.get_MarginR(), + MarginV: stylPtr.get_MarginV(), + Encoding: stylPtr.get_Encoding(), + treat_fontname_as_pattern: stylPtr.get_treat_fontname_as_pattern(), + Blur: stylPtr.get_Blur(), + Justify: stylPtr.get_Justify() + }) + } + postMessage({ + target: 'getStyles', + time: Date.now(), + styles: styles + }) +} + +self.setStyle = data => { + _applyKeys(data.style, self.octObj.track.get_styles(data.index)) +} + +self.removeStyle = data => { + self.octObj.removeStyle(data.index) +} + +self.setimmediate = () => { + if (Module.setImmediates) Module.setImmediates.shift()() +} + onmessage = message => { if (!calledMain && !message.data.preMain) { if (!messageBuffer) { @@ -488,105 +592,7 @@ onmessage = message => { if (self[data.target]) { self[data.target](data) } else { - switch (data.target) { - case 'offscreenCanvas': - self.offscreenCanvas = data.transferable[0] - self.offscreenCanvasCtx = self.offscreenCanvas.getContext('2d') - self.bufferCanvas = new OffscreenCanvas(self.height, self.width) - self.bufferCtx = self.bufferCanvas.getContext('2d') - break - case 'destroy': - self.octObj.quitLibrary() - break - case 'createEvent': - _applyKeys(data.event, self.octObj.track.get_events(self.octObj.allocEvent())) - break - case 'getEvents': { - const events = [] - for (let i = 0; i < self.octObj.getEventCount(); i++) { - const evntPtr = self.octObj.track.get_events(i) - events.push({ - Start: evntPtr.get_Start(), - Duration: evntPtr.get_Duration(), - ReadOrder: evntPtr.get_ReadOrder(), - Layer: evntPtr.get_Layer(), - Style: evntPtr.get_Style(), - Name: evntPtr.get_Name(), - MarginL: evntPtr.get_MarginL(), - MarginR: evntPtr.get_MarginR(), - MarginV: evntPtr.get_MarginV(), - Effect: evntPtr.get_Effect(), - Text: evntPtr.get_Text() - }) - } - postMessage({ - target: 'getEvents', - events: events - }) - } - break - case 'setEvent': - _applyKeys(data.event, self.octObj.track.get_events(data.index)) - break - case 'removeEvent': - self.octObj.removeEvent(data.index) - break - case 'createStyle': - _applyKeys(data.style, self.octObj.track.get_styles(self.octObj.allocStyle())) - break - case 'getStyles': { - const styles = [] - for (let i = 0; i < self.octObj.getStyleCount(); i++) { - const stylPtr = self.octObj.track.get_styles(i) - styles.push({ - Name: stylPtr.get_Name(), - FontName: stylPtr.get_FontName(), - FontSize: stylPtr.get_FontSize(), - PrimaryColour: stylPtr.get_PrimaryColour(), - SecondaryColour: stylPtr.get_SecondaryColour(), - OutlineColour: stylPtr.get_OutlineColour(), - BackColour: stylPtr.get_BackColour(), - Bold: stylPtr.get_Bold(), - Italic: stylPtr.get_Italic(), - Underline: stylPtr.get_Underline(), - StrikeOut: stylPtr.get_StrikeOut(), - ScaleX: stylPtr.get_ScaleX(), - ScaleY: stylPtr.get_ScaleY(), - Spacing: stylPtr.get_Spacing(), - Angle: stylPtr.get_Angle(), - BorderStyle: stylPtr.get_BorderStyle(), - Outline: stylPtr.get_Outline(), - Shadow: stylPtr.get_Shadow(), - Alignment: stylPtr.get_Alignment(), - MarginL: stylPtr.get_MarginL(), - MarginR: stylPtr.get_MarginR(), - MarginV: stylPtr.get_MarginV(), - Encoding: stylPtr.get_Encoding(), - treat_fontname_as_pattern: stylPtr.get_treat_fontname_as_pattern(), - Blur: stylPtr.get_Blur(), - Justify: stylPtr.get_Justify() - }) - } - postMessage({ - target: 'getStyles', - time: Date.now(), - styles: styles - }) - } - break - case 'setStyle': - _applyKeys(data.style, self.octObj.track.get_styles(data.index)) - break - case 'removeStyle': - self.octObj.removeStyle(data.index) - break - case 'setimmediate': { - if (Module.setImmediates) Module.setImmediates.shift()() - break - } - default: - throw new Error('Unknown event target ' + message.data.target) - } + throw new Error('Unknown event target ' + message.data.target) } } From 13b96a51f2aae617255d1a2018e47b4af303752c Mon Sep 17 00:00:00 2001 From: ThaUnknown <6506529+ThaUnknown@users.noreply.github.com> Date: Sat, 5 Mar 2022 01:40:37 +0100 Subject: [PATCH 5/6] add requestVideoFrameCallback polyfill --- src/subtitles-octopus.js | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/subtitles-octopus.js b/src/subtitles-octopus.js index eeaac31b..ecff8c2c 100644 --- a/src/subtitles-octopus.js +++ b/src/subtitles-octopus.js @@ -582,3 +582,40 @@ export default class SubtitlesOctopus extends EventTarget { if (typeof exports !== 'undefined' && typeof module !== 'undefined' && module.exports) { exports = module.exports = SubtitlesOctopus } + +if (!('requestVideoFrameCallback' in HTMLVideoElement.prototype) && 'getVideoPlaybackQuality' in HTMLVideoElement.prototype) { + HTMLVideoElement.prototype._rvfcpolyfillmap = {} + HTMLVideoElement.prototype.requestVideoFrameCallback = function (callback) { + const quality = this.getVideoPlaybackQuality() + const baseline = this.mozPaintedFrames || quality.totalVideoFrames - quality.droppedVideoFrames + + const check = () => { + const newquality = this.getVideoPlaybackQuality() + const current = this.mozPaintedFrames || newquality.totalVideoFrames - newquality.droppedVideoFrames + if (current > baseline) { + const now = performance.now() + callback(now, { + presentationTime: now, + expectedDisplayTime: now + (this.mozFrameDelay / 1000 || 0), + width: this.videoWidth, + height: this.videoHeight, + mediaTime: this.currentTime, + presentedFrames: current, + processingDuration: this.mozFrameDelay || (newquality.totalFrameDelay - quality.totalFrameDelay) || 0 + }) + delete this._rvfcpolyfillmap[handle] + } else { + this._rvfcpolyfillmap[handle] = requestAnimationFrame(check) + } + } + + const handle = Date.now() + this._rvfcpolyfillmap[handle] = requestAnimationFrame(check) + return handle + } + + HTMLVideoElement.prototype.cancelVideoFrameCallback = function (handle) { + cancelAnimationFrame(this._rvfcpolyfillmap[handle]) + delete this._rvfcpolyfillmap[handle] + } +} From d721da873a9f441289c2d79596025264f71a7713 Mon Sep 17 00:00:00 2001 From: ThaUnknown <6506529+ThaUnknown@users.noreply.github.com> Date: Wed, 27 Apr 2022 17:33:34 +0200 Subject: [PATCH 6/6] improve error logging, implement performance logging --- src/post-worker.js | 37 ++++++++++++++++++++++++++++++------- src/subtitles-octopus.js | 17 ++++++++++++----- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/post-worker.js b/src/post-worker.js index 2194eb12..a3d7b087 100644 --- a/src/post-worker.js +++ b/src/post-worker.js @@ -162,19 +162,33 @@ self.setIsPaused = function (isPaused) { } self.renderImageData = (time, force) => { + const renderStartTime = Date.now() if (self.blendMode === 'wasm') { const result = self.octObj.renderBlend(time, force) - return { w: result.dest_width, h: result.dest_height, x: result.dest_x, y: result.dest_y, changed: result.changed, image: result.image } + return { + w: result.dest_width, + h: result.dest_height, + x: result.dest_x, + y: result.dest_y, + changed: result.changed, + image: result.image, + times: { + renderTime: Date.now() - renderStartTime - result.blend_time, + blendTime: result.blend_time + } + } } else { const result = self.octObj.renderImage(time, self.changed) result.changed = Module.getValue(self.changed, 'i32') + result.times = { renderTime: Date.now() - renderStartTime } return result } } -self.processRender = (result, callback) => { +self.processRender = (result) => { const images = [] let buffers = [] + const decodeStartTime = Date.now() if (self.blendMode === 'wasm') { if (result.image) { result.buffer = HEAPU8.buffer.slice(result.image, result.image + result.w * result.h * 4) @@ -203,10 +217,10 @@ self.processRender = (result, callback) => { images[i].buffer = bitmaps[i] } buffers = bitmaps - callback({ images, buffers }) + self.paintImages({ images, buffers, times: result.times, decodeStartTime }) }) } else { - callback({ images, buffers }) + self.paintImages({ images, buffers, times: result.times, decodeStartTime }) } } @@ -229,7 +243,7 @@ self.render = (time, force) => { self.busy = true const result = self.renderImageData(time, force) if (result.changed !== 0 || force) { - self.processRender(result, self.paintImages) + self.processRender(result) } else { self.busy = false } @@ -249,8 +263,10 @@ self.renderLoop = (force) => { } } -self.paintImages = ({ images, buffers }) => { +self.paintImages = ({ images, buffers, decodeStartTime, times }) => { + times.decodeTime = Date.now() - decodeStartTime if (self.offscreenCanvasCtx) { + const drawStartTime = Date.now() self.offscreenCanvasCtx.clearRect(0, 0, self.offscreenCanvas.width, self.offscreenCanvas.height) for (const image of images) { if (image.buffer) { @@ -265,11 +281,18 @@ self.paintImages = ({ images, buffers }) => { } } } + if (self.debug) { + times.drawTime = Date.now() - drawStartTime + let total = 0 + for (const key in times) total += times[key] + console.log('Bitmaps: ' + images.length + ' Total: ' + Math.round(total) + 'ms', times) + } } else { postMessage({ target: 'render', async: self.asyncRender, - images + images, + times }, buffers) } self.busy = false diff --git a/src/subtitles-octopus.js b/src/subtitles-octopus.js index ecff8c2c..4a7eb2e6 100644 --- a/src/subtitles-octopus.js +++ b/src/subtitles-octopus.js @@ -456,11 +456,12 @@ export default class SubtitlesOctopus extends EventTarget { this._video.requestVideoFrameCallback(this._demandRender.bind(this)) } - _render (data) { + _render ({ images, async, times }) { + const drawStartTime = Date.now() this._ctx.clearRect(0, 0, this._canvasctrl.width, this._canvasctrl.height) - for (const image of data.images) { + for (const image of images) { if (image.buffer) { - if (data.async) { + if (async) { this._ctx.drawImage(image.buffer, image.x, image.y) image.buffer.close() } else { @@ -471,6 +472,12 @@ export default class SubtitlesOctopus extends EventTarget { } } } + if (this.debug) { + times.drawTime = Date.now() - drawStartTime + let total = 0 + for (const key in times) total += times[key] + console.log('Bitmaps: ' + images.length + ' Total: ' + Math.round(total) + 'ms', times) + } } _fixAlpha (uint8) { @@ -549,8 +556,8 @@ export default class SubtitlesOctopus extends EventTarget { } _error (err) { - this.dispatchEvent(new CustomEvent('error', { detail: err })) - throw new Error(err) + if (!(err instanceof ErrorEvent)) this.dispatchEvent(new ErrorEvent('error', { message: err instanceof Error ? err.cause : err })) + throw err instanceof Error ? err : new Error(err instanceof ErrorEvent ? err.message : 'error', { cause: err }) } _removeListeners () {