diff --git a/build/all.py b/build/all.py index 99a4f058a..7efc55391 100755 --- a/build/all.py +++ b/build/all.py @@ -231,6 +231,16 @@ def main(args): for mode in modes: tasks = [] + worker_args = [ + '--worker', '--name', 'transmuxer-worker', + '+@transmuxer-worker', '--langout', 'ECMASCRIPT5', '--mode', mode, + ] + if parsed_args.force: + worker_args += ['--force'] + tasks.append(( + [sys.executable, os.path.join(base, 'build', 'build.py')] + worker_args, + os.environ.copy())) + for lang_out, suffix in language_variants: for build_args in builds: args = list(build_args) @@ -242,6 +252,7 @@ def main(args): # Add language and mode flags. args += ['--langout', lang_out] args += ['--mode', mode] + args += ['--skip-worker'] # Prepare environment and command for a separate process. env = os.environ.copy() diff --git a/build/build.py b/build/build.py index 7d3ccd1a6..00f718c9f 100755 --- a/build/build.py +++ b/build/build.py @@ -192,6 +192,13 @@ class Build(object): return True return False + def has_transmuxer_proxy(self): + """Returns True if the transmuxer proxy is in the build.""" + for path in self.include: + if path.endswith('transmuxer_proxy.js'): + return True + return False + def generate_localizations(self, locales, force): localizations = compiler.GenerateLocalizations(locales) localizations.generate(force) @@ -265,7 +272,56 @@ class Build(object): return True - def build_library(self, name, langout, locales, force, is_debug, skip_ts): + def build_worker_bundle(self, langout, force, is_debug): + """Compiles the transmuxer worker bundle. + + Returns True on success; False on failure. + """ + worker_build = Build() + if not worker_build.parse_build(['+@transmuxer-worker'], os.getcwd()): + return False + worker_build.add_closure() + if not worker_build.add_core(): + return False + + build_name = 'shaka-player.transmuxer-worker' + if is_debug: + build_name += '.debug' + closure = compiler.ClosureCompiler(worker_build.include, build_name) + closure.add_wrapper = False + closure.add_source_map = False + + closure_opts = common_closure_opts + common_closure_defines + closure_opts += ['--language_out', langout] + if is_debug: + closure_opts += debug_closure_opts + debug_closure_defines + else: + closure_opts += release_closure_opts + release_closure_defines + + closure_opts += [ + '--dependency_mode=PRUNE', + '--entry_point=goog:shaka.transmuxer.TransmuxerWorker', + # Each transmuxer plugin registers itself at load time (side effect), + # so we must list them as entry points too. + '--entry_point=goog:shaka.transmuxer.AacTransmuxer', + '--entry_point=goog:shaka.transmuxer.Ac3Transmuxer', + '--entry_point=goog:shaka.transmuxer.Ec3Transmuxer', + '--entry_point=goog:shaka.transmuxer.Mp3Transmuxer', + '--entry_point=goog:shaka.transmuxer.MpegTsTransmuxer', + '--entry_point=goog:shaka.transmuxer.TsTransmuxer', + ] + + # Suppress type errors caused by dependency pruning; the main build + # already validates all types. + closure_opts += [ + '--jscomp_off=checkTypes', + '--jscomp_off=unknownDefines', + ] + + return closure.compile(closure_opts, force) + + def build_library(self, name, langout, locales, force, is_debug, skip_ts, + build_worker): """Builds Shaka Player using the files in |self.include|. Args: @@ -275,6 +331,7 @@ class Build(object): force: True to rebuild, False to ignore if no changes are detected. is_debug: True to compile for debugging, false for release. skip_ts: True to skip generation of TypeScript definitions. + build_worker: True to build the standalone transmuxer worker if needed. Returns: True on success; False on failure. @@ -289,6 +346,11 @@ class Build(object): if not self.has_cast(): self.include.add(os.path.abspath('conditional/dummy_cast_proxy.js')) + if build_worker and self.has_transmuxer_proxy(): + logging.info('Compiling transmuxer worker bundle...') + if not self.build_worker_bundle(langout, force, is_debug): + return False + if is_debug: name += '.debug' @@ -385,6 +447,16 @@ def main(args): help='Skips generation of TypeScript definition files (.d.ts).', action='store_true') + parser.add_argument( + '--worker', + help='Build only the standalone transmuxer worker script.', + action='store_true') + + parser.add_argument( + '--skip-worker', + help='Do not build the standalone transmuxer worker alongside the library.', + action='store_true') + parsed_args, commands = parser.parse_known_args(args) # Make the dist/ folder, ignore errors. @@ -501,8 +573,11 @@ def main(args): is_debug = parsed_args.mode == 'debug' skip_ts = parsed_args.skip_ts - if not custom_build.build_library(name, langout, locales, force, is_debug, - skip_ts): + if parsed_args.worker: + if not custom_build.build_worker_bundle(langout, force, is_debug): + return 1 + elif not custom_build.build_library(name, langout, locales, force, is_debug, + skip_ts, not parsed_args.skip_worker): return 1 # Persist (merge) the updated state under lock so we don't clobber parallel updates. diff --git a/build/check.py b/build/check.py index 0d93a3342..9634f12aa 100755 --- a/build/check.py +++ b/build/check.py @@ -17,7 +17,7 @@ """This is used to validate that the library is correct. This checks: - * All files in lib/ appear when compiling +@complete + * All files in lib/ appear in +@complete or another standalone build type * Runs a compiler pass over the test code to check for type errors * Run the linter to check for style violations. """ @@ -48,7 +48,11 @@ def complete_build_files(): # Normally we don't need to include @core, but because we look at the build # object directly, we need to include it here. When using main(), it will # call addCore which will ensure core is included. - if not complete.parse_build(['+@complete', '+@core'], os.getcwd()): + # + # Standalone build types are included here so their entry points are covered + # by the "all files are in a build" invariant without bloating +@complete. + if not complete.parse_build( + ['+@complete', '+@core', '+@transmuxer-worker'], os.getcwd()): logging.error('Error parsing complete build') return False return complete.include @@ -120,7 +124,7 @@ def check_html_lint(args): @_Check('complete') def check_complete(_): - """Checks whether the 'complete' build references every file. + """Checks whether the build type definitions reference every file. This is used by the build script to ensure that every file is included in at least one build type. diff --git a/build/types/core b/build/types/core index 23a728059..e1e6cb225 100644 --- a/build/types/core +++ b/build/types/core @@ -79,6 +79,7 @@ +../../lib/text/web_vtt_generator.js +../../lib/transmuxer/transmuxer_engine.js ++../../lib/transmuxer/transmuxer_proxy.js +../../lib/transmuxer/transmuxer_utils.js +../../lib/util/abortable_operation.js diff --git a/build/types/transmuxer-worker b/build/types/transmuxer-worker new file mode 100644 index 000000000..a1e2a7202 --- /dev/null +++ b/build/types/transmuxer-worker @@ -0,0 +1,10 @@ +# Transmuxer Worker bundle. +# This build type produces a standalone script to be loaded in a Web Worker. +# It includes all transmuxer plugins and the worker entry point. + ++../../lib/device/apple_browser.js ++../../lib/device/default_browser.js ++@devices ++../../lib/transmuxer/loc_transmuxer.js ++../../lib/transmuxer/transmuxer_worker.js ++@transmuxer diff --git a/demo/config.js b/demo/config.js index d4ffd73dc..ef70cde7d 100644 --- a/demo/config.js +++ b/demo/config.js @@ -806,6 +806,18 @@ shakaDemo.Config = class { 'mediaSource.useSourceElements') .addBoolInput_('Expect updateEnd when duration is truncated', 'mediaSource.durationReductionEmitsUpdateEnd'); + + const transmuxWorkerToggleOnChange = (input) => { + const url = input.checked ? + shakaDemoMain.getTransmuxerWorkerUrl() : ''; + shakaDemoMain.configure('mediaSource.transmuxWorkerUrl', url); + shakaDemoMain.remakeHash(); + }; + this.addCustomBoolInput_( + 'Use a worker for transmuxing', transmuxWorkerToggleOnChange); + if (shakaDemoMain.getCurrentConfigValue('mediaSource.transmuxWorkerUrl')) { + this.latestInput_.input().checked = true; + } } /** diff --git a/demo/main.js b/demo/main.js index ae05d2354..1d6a419e3 100644 --- a/demo/main.js +++ b/demo/main.js @@ -429,6 +429,13 @@ shakaDemo.Main = class { this.player_.configure( 'manifest.dash.clockSyncUri', 'https://time.akamai.com/?ms&iso'); + // The library does not auto-detect the transmuxer worker URL — the demo + // is responsible for telling Shaka where to load it from. The path + // depends on which build the demo loaded (compiled, debug, uncompiled). + this.player_.configure( + 'mediaSource.transmuxWorkerUrl', + this.getTransmuxerWorkerUrl()); + // Get default config. this.defaultConfig_ = this.player_.getConfiguration(); this.desiredConfig_ = this.player_.getConfiguration(); @@ -1228,6 +1235,29 @@ shakaDemo.Main = class { return params; } + /** + * Picks the worker bundle that matches the build the demo loaded. + * The demo always serves the worker from `../dist/` (compiled) or from + * the repo root (`../transmuxer_worker.uncompiled.js`). + * @return {string} + */ + getTransmuxerWorkerUrl() { + const params = this.getParams_(); + let buildType = 'uncompiled'; + if (params.has('build')) { + buildType = params.get('build'); + } else if (params.has('compiled')) { + buildType = 'compiled'; + } + if (buildType === 'uncompiled') { + return '../transmuxer_worker.uncompiled.js'; + } + if (buildType === 'debug_compiled') { + return '../dist/shaka-player.transmuxer-worker.debug.js'; + } + return '../dist/shaka-player.transmuxer-worker.js'; + } + /** * Recovers the value from the given config field, from an arbitrary config * object. diff --git a/docs/tutorials/index.json b/docs/tutorials/index.json index c6080f3f8..aa0c9fc1e 100644 --- a/docs/tutorials/index.json +++ b/docs/tutorials/index.json @@ -9,6 +9,7 @@ { "license-wrapping": { "title": "License Wrapping" } }, { "moq": { "title": "MoQ" } }, { "preload": { "title": "Preloading" } }, + { "transmuxing-in-worker": { "title": "Transmuxing in Worker" } }, { "ui": { "title": "UI Library" } }, { "ui-customization": { "title": "Configuring the UI" } }, { "text-displayer": { "title": "Configuring text displayer" } }, diff --git a/docs/tutorials/transmuxing-in-worker.md b/docs/tutorials/transmuxing-in-worker.md new file mode 100644 index 000000000..bb92ec648 --- /dev/null +++ b/docs/tutorials/transmuxing-in-worker.md @@ -0,0 +1,205 @@ +# Transmuxing in a Web Worker + +#### Overview + +When playing HLS streams with MPEG-TS segments, Shaka Player must transmux +(convert) each segment from MPEG-TS to fMP4 before feeding it to +`MediaSource`. By default this work happens synchronously on the main thread, +which can cause frame drops or audio glitches on slower devices. + +Shaka Player can offload this work to a dedicated **Web Worker**, freeing the +main thread for rendering and UI. The worker is shared across all active +streams (audio and video), so only one worker thread is ever created per page. + +#### Quick Summary + +To enable worker-based transmuxing you must do **two** things: + +1. Make the compiled worker script reachable over HTTP from your page. +2. Tell Shaka where to find it via `mediaSource.transmuxWorkerUrl`. + +Shaka does **not** auto-detect the worker URL. The library cannot reliably +know where its assets live at runtime (script tags, bundler output, CDNs, +ES modules, hashed filenames all differ). The integrating application owns +how Shaka is loaded, so the application also owns the worker URL. + +If `transmuxWorkerUrl` is not set, transmux falls back to the main thread. +No error is thrown — the feature simply stays dormant. + +#### Configuration Key + +```js +player.configure({ + mediaSource: { + // URL of the worker script. Empty by default. Required for the worker + // to run; empty string keeps transmuxing on the main thread. + transmuxWorkerUrl: '', + }, +}); +``` + +When `transmuxWorkerUrl` is a non-empty string, Shaka creates the worker on +the first transmux call. Empty value (or device-level opt-out — see below) +falls back to main-thread transmuxing. + +#### The Worker File + +Compiled builds emit the worker as a standalone bundle next to the main +library bundle: + +| Build type | Worker filename | +| ---------- | --------------------------------------- | +| Release | `shaka-player.transmuxer-worker.js` | +| Debug | `shaka-player.transmuxer-worker.debug.js` | + +Both files live in `dist/` after running `python3 build/all.py`, and ship +inside the npm package under `node_modules/shaka-player/dist/`. + +You must serve the worker file from a URL the browser can reach. The exact +path depends on how you deploy Shaka — see the patterns below. + +#### Deployment Patterns + +##### Plain ` + +``` + +##### Webpack 5 / Vite / Rollup (modern bundlers) + +Use `new URL(..., import.meta.url)`. The bundler resolves the npm path, +copies the worker into the build output, and rewrites the URL at build +time: + +```js +const workerUrl = new URL( + 'shaka-player/dist/shaka-player.transmuxer-worker.js', + import.meta.url, +).toString(); + +player.configure('mediaSource.transmuxWorkerUrl', workerUrl); +``` + +##### Webpack 4 + +Webpack 4 has no `import.meta.url` support. Use `file-loader` or +`asset/resource`: + +```js +import workerUrl from + 'shaka-player/dist/shaka-player.transmuxer-worker.js?url'; + +player.configure('mediaSource.transmuxWorkerUrl', workerUrl); +``` + +##### Create React App / static `public/` folder + +Copy `node_modules/shaka-player/dist/shaka-player.transmuxer-worker.js` +into `public/` (or your equivalent static asset folder) and reference it +by absolute path: + +```js +player.configure( + 'mediaSource.transmuxWorkerUrl', + '/shaka-player.transmuxer-worker.js'); +``` + +A small build script that copies the file on `postinstall` keeps the +worker version in sync with the installed package. + +#### Same-Origin and CORS Requirements + +`new Worker(url)` requires the worker script to be either: + +- same origin as the host page, or +- served with CORS headers that allow the host origin + (`Access-Control-Allow-Origin`, plus `Cross-Origin-Resource-Policy` when + the page itself runs cross-origin). + +Self-hosted same-origin deployments need no extra headers. Cross-origin +deployments must serve the worker with proper CORS configuration, or the +browser will reject the `new Worker(url)` call and Shaka will fall back to +main-thread transmuxing. + +#### Uncompiled / Development Mode + +In uncompiled mode (running Shaka directly from source for development) +the worker is bootstrapped from `transmuxer_worker.uncompiled.js` at the +repository root. Set the URL the same way: + +```js +player.configure( + 'mediaSource.transmuxWorkerUrl', + '/path/to/shaka-player/transmuxer_worker.uncompiled.js'); +``` + +Run `python3 build/gendeps.py` first so the bootstrap script can locate +the Closure dependency graph. + +#### Disabling the Worker + +To force main-thread transmuxing — for debugging or for environments +where Web Workers are unreliable — leave `transmuxWorkerUrl` empty, or +clear it at runtime: + +```js +player.configure('mediaSource.transmuxWorkerUrl', ''); +``` + +Certain TV platforms (Tizen, WebOS, Hisense) also opt out of worker +transmuxing internally via the device layer regardless of this setting, +because Worker support is often limited or unstable on those devices. + +#### Fallback Chain + +Shaka silently falls back to main-thread transmuxing in any of these +cases: + +1. `transmuxWorkerUrl` is empty. +2. The device platform reports no Worker support (e.g. older Tizen/WebOS). +3. `new Worker(url)` throws — CSP block, network error, MIME mismatch. +4. The first `postMessage` to the worker fails. +5. The worker does not respond within 30 seconds for a given segment. + +The fallback is transparent: playback continues without error. A warning +is logged to the console when a fallback is taken. + +#### Troubleshooting + +**Worker URL returns 404.** Open DevTools → Network, filter by `worker`, +and compare the requested URL against your deployed asset paths. Most +often the worker file was not copied alongside the main bundle. + +**CSP blocks the worker.** Add the worker's origin to the relevant +Content-Security-Policy directives: + +``` +worker-src 'self'; +script-src 'self'; +``` + +If the worker is served from a different origin, list that origin in +both directives (and `connect-src` if your app also fetches it +directly). + +**Cross-origin Worker rejected.** Ensure the worker response includes +`Access-Control-Allow-Origin: ` and, when the page is +itself cross-origin-isolated, `Cross-Origin-Resource-Policy: +cross-origin`. + +**Need to isolate a transmux bug.** Disable the worker as shown above so +that any transmux failure surfaces on the main thread with a full stack +trace. + +For general configuration see {@tutorial config}, and for all +`mediaSource` options see {@link shaka.extern.MediaSourceConfiguration}. diff --git a/externs/shaka/player.js b/externs/shaka/player.js index c8c26b67a..caea394ca 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -2357,7 +2357,8 @@ shaka.extern.NetworkingConfiguration; * modifyCueCallback: shaka.extern.TextParser.ModifyCueCallback, * dispatchAllEmsgBoxes: boolean, * useSourceElements: boolean, - * durationReductionEmitsUpdateEnd: boolean + * durationReductionEmitsUpdateEnd: boolean, + * transmuxWorkerUrl: string * }} * * @description @@ -2425,6 +2426,19 @@ shaka.extern.NetworkingConfiguration; * smaller than existing value. *
* Defaults to true. + * @property {string} transmuxWorkerUrl + * URL of the standalone transmuxer worker script. When set to a non-empty + * string, transmuxing (e.g., MPEG-TS to MP4) is offloaded to a Web Worker + * loaded from this URL, freeing the main thread. When empty, transmuxing + * runs on the main thread. + *
+ * The library does not auto-detect this URL; the integrating application + * is responsible for serving the worker script (e.g., + * shaka-player.transmuxer-worker.js from dist/) + * and providing the URL here. Falls back to main-thread transmuxing if the + * worker fails to load or the device does not support Workers. + *
+ * Defaults to '' (worker disabled). * @exportDoc */ shaka.extern.MediaSourceConfiguration; diff --git a/karma.conf.js b/karma.conf.js index 0903ca7a1..98fee2462 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -309,6 +309,9 @@ module.exports = (config) => { {pattern: 'test/test/assets/lcevc-sei-ts/*', included: false}, {pattern: 'test/test/assets/lcevc-dual-track/*', included: false}, {pattern: 'dist/shaka-player.experimental.js', included: false}, + {pattern: 'dist/shaka-player.transmuxer-worker.js', included: false}, + // eslint-disable-next-line @stylistic/max-len + {pattern: 'dist/shaka-player.transmuxer-worker.debug.js', included: false}, {pattern: 'dist/locales.js', included: false}, {pattern: 'demo/**/*.js', included: false}, {pattern: 'demo/locales/en.json', included: false}, diff --git a/lib/debug/log.js b/lib/debug/log.js index bac380df5..1063ae24f 100644 --- a/lib/debug/log.js +++ b/lib/debug/log.js @@ -134,7 +134,9 @@ shaka.log.MAX_LOG_LEVEL = 3; shaka.log.oneTimeWarningIssued_ = new Set(); -if (window.console) { +// Use `self` instead of `window` so this code works in both the main thread +// and Web Workers (where `window` is undefined). +if (self.console) { /** @private {!Map} */ shaka.log.logMap_ = new Map() .set(shaka.log.Level.ERROR, (...args) => console.error(...args)) diff --git a/lib/device/abstract_device.js b/lib/device/abstract_device.js index 3f87aa3f2..f6f55f416 100644 --- a/lib/device/abstract_device.js +++ b/lib/device/abstract_device.js @@ -382,6 +382,13 @@ shaka.device.AbstractDevice = class { return null; } + /** + * @override + */ + supportsWorkerTransmux() { + return typeof Worker !== 'undefined'; + } + /** * @override */ diff --git a/lib/device/hisense.js b/lib/device/hisense.js index 9aad01de4..42276d604 100644 --- a/lib/device/hisense.js +++ b/lib/device/hisense.js @@ -91,6 +91,13 @@ shaka.device.Hisense = class extends shaka.device.AbstractDevice { return config; } + /** + * @override + */ + supportsWorkerTransmux() { + return false; + } + /** * @return {boolean} * @private diff --git a/lib/device/i_device.js b/lib/device/i_device.js index e26c7bfd9..ef99303df 100644 --- a/lib/device/i_device.js +++ b/lib/device/i_device.js @@ -262,6 +262,15 @@ shaka.device.IDevice = class { * @return {?RemotePlayback} */ getRemote(video) {} + + /** + * Returns true if the platform reliably supports running transmuxing + * operations in a Web Worker. Devices with known Worker instability or + * broken Worker implementations should return false here. + * + * @return {boolean} + */ + supportsWorkerTransmux() {} }; /** diff --git a/lib/device/tizen.js b/lib/device/tizen.js index 299693669..73189fcfc 100644 --- a/lib/device/tizen.js +++ b/lib/device/tizen.js @@ -206,6 +206,18 @@ shaka.device.Tizen = class extends shaka.device.AbstractDevice { return this.getVersion() === 3; } + /** + * @override + */ + supportsWorkerTransmux() { + // Web Workers are unreliable on Tizen 2. + const version = this.getVersion(); + if (version !== null && version < 3) { + return false; + } + return super.supportsWorkerTransmux(); + } + /** * @return {boolean} * @private diff --git a/lib/device/webos.js b/lib/device/webos.js index 478e2ef61..c222bfde3 100644 --- a/lib/device/webos.js +++ b/lib/device/webos.js @@ -214,6 +214,18 @@ shaka.device.WebOS = class extends shaka.device.AbstractDevice { version >= 6 : super.supportsCbcsWithoutEncryptionSchemeSupport(); } + /** + * @override + */ + supportsWorkerTransmux() { + // Web Workers are unreliable on WebOS 3 and earlier. + const version = this.getVersion(); + if (version !== null && version < 4) { + return false; + } + return super.supportsWorkerTransmux(); + } + /** * @return {boolean} * @private diff --git a/lib/media/media_source_capabilities.js b/lib/media/media_source_capabilities.js index d52e158d2..182e15507 100644 --- a/lib/media/media_source_capabilities.js +++ b/lib/media/media_source_capabilities.js @@ -21,7 +21,9 @@ shaka.media.Capabilities = class { static isTypeSupported(type) { const supportMap = shaka.media.Capabilities.MediaSourceTypeSupportMap; return supportMap.getOrInsertComputed(type, () => { - const mediaSource = window.ManagedMediaSource || window.MediaSource; + // Use self instead of window so this works in Worker contexts + // (where window is not defined) as well as on the main thread. + const mediaSource = self.ManagedMediaSource || self.MediaSource; return mediaSource?.isTypeSupported(type) ?? false; }); } @@ -34,7 +36,8 @@ shaka.media.Capabilities = class { * @return {boolean} */ static isInfiniteLiveStreamDurationSupported() { - const mediaSource = window.ManagedMediaSource || window.MediaSource; + // Use self instead of window so this works in Worker contexts. + const mediaSource = self.ManagedMediaSource || self.MediaSource; // eslint-disable-next-line no-restricted-syntax if (mediaSource && mediaSource.prototype) { // eslint-disable-next-line no-restricted-syntax diff --git a/lib/media/media_source_engine.js b/lib/media/media_source_engine.js index 8792ecc7a..f5f5d36f1 100644 --- a/lib/media/media_source_engine.js +++ b/lib/media/media_source_engine.js @@ -18,6 +18,7 @@ goog.require('shaka.media.SegmentReference'); goog.require('shaka.media.TimeRangesUtils'); goog.require('shaka.text.TextEngine'); goog.require('shaka.transmuxer.TransmuxerEngine'); +goog.require('shaka.transmuxer.TransmuxerProxy'); goog.require('shaka.util.ArrayUtils'); goog.require('shaka.util.BufferUtils'); goog.require('shaka.util.Destroyer'); @@ -33,7 +34,6 @@ goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Mp4BoxParsers'); goog.require('shaka.util.Mp4Parser'); goog.require('shaka.util.NumberUtils'); -goog.require('shaka.util.StreamUtils'); goog.require('shaka.util.TimeUtils'); goog.require('shaka.util.TsParser'); goog.require('shaka.lcevc.Dec'); @@ -388,7 +388,7 @@ shaka.media.MediaSourceEngine = class { support[type] = true; } else if (device.supportsMediaSource()) { const baseMimeType = MimeUtils.getBasicType(type); - const codecs = shaka.util.StreamUtils.getCorrectAudioCodecs( + const codecs = MimeUtils.getCorrectAudioCodecs( MimeUtils.getCodecs(type), baseMimeType); const newType = MimeUtils.getFullType(baseMimeType, codecs); support[type] = shaka.media.Capabilities.isTypeSupported(newType) || @@ -574,13 +574,13 @@ shaka.media.MediaSourceEngine = class { const ContentType = shaka.util.ManifestParserUtils.ContentType; if (contentType == ContentType.AUDIO && codecs) { - codecs = shaka.util.StreamUtils.getCorrectAudioCodecs( + codecs = shaka.util.MimeUtils.getCorrectAudioCodecs( codecs, stream.mimeType); } if (contentType == ContentType.VIDEO && codecs && stream.mimeType == 'video/mp4') { - codecs = shaka.util.StreamUtils.getCorrectVideoCodecs(codecs); + codecs = shaka.util.MimeUtils.getCorrectVideoCodecs(codecs); } let mimeType = shaka.util.MimeUtils.getFullType( @@ -611,7 +611,8 @@ shaka.media.MediaSourceEngine = class { const transmuxerPlugin = shaka.transmuxer.TransmuxerEngine .findTransmuxer(mimeTypeWithAllCodecs); if (transmuxerPlugin) { - const transmuxer = transmuxerPlugin(); + const transmuxer = new shaka.transmuxer.TransmuxerProxy( + transmuxerPlugin(), this.config_.transmuxWorkerUrl); this.transmuxers_.set(contentType, transmuxer); mimeType = transmuxer.convertCodecs(contentType, mimeTypeWithAllCodecs); @@ -2505,7 +2506,8 @@ shaka.media.MediaSourceEngine = class { const transmuxerPlugin = TransmuxerEngine.findTransmuxer(newMimeTypeWithAllCodecs); if (transmuxerPlugin) { - transmuxer = transmuxerPlugin(); + transmuxer = new shaka.transmuxer.TransmuxerProxy( + transmuxerPlugin(), this.config_.transmuxWorkerUrl); if (audioCodec && videoCodec) { transmuxerMuxed = true; } diff --git a/lib/transmuxer/loc_transmuxer.js b/lib/transmuxer/loc_transmuxer.js index 69ab3f3b2..6e90dc99a 100644 --- a/lib/transmuxer/loc_transmuxer.js +++ b/lib/transmuxer/loc_transmuxer.js @@ -17,7 +17,6 @@ goog.require('shaka.util.BufferUtils'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Mp4Generator'); -goog.require('shaka.util.StreamUtils'); goog.requireType('shaka.media.SegmentReference'); @@ -146,12 +145,12 @@ shaka.transmuxer.LocTransmuxer = class { convertCodecs(contentType, mimeType) { if (this.isLocContainer_(mimeType)) { const ContentType = shaka.util.ManifestParserUtils.ContentType; - const StreamUtils = shaka.util.StreamUtils; - const codecs = shaka.util.MimeUtils.getCodecs(mimeType).split(',') + const MimeUtils = shaka.util.MimeUtils; + const codecs = MimeUtils.getCodecs(mimeType).split(',') .map((codecs) => { - return StreamUtils.getCorrectAudioCodecs(codecs, 'audio/mp4'); + return MimeUtils.getCorrectAudioCodecs(codecs, 'audio/mp4'); }) - .map(StreamUtils.getCorrectVideoCodecs).join(','); + .map(MimeUtils.getCorrectVideoCodecs).join(','); if (contentType == ContentType.AUDIO) { return `audio/mp4; codecs="${codecs}"`; } diff --git a/lib/transmuxer/transmuxer_engine.js b/lib/transmuxer/transmuxer_engine.js index ad928d896..661106142 100644 --- a/lib/transmuxer/transmuxer_engine.js +++ b/lib/transmuxer/transmuxer_engine.js @@ -58,24 +58,45 @@ shaka.transmuxer.TransmuxerEngine = class { * @export */ static findTransmuxer(mimeType, contentType) { + const TransmuxerEngine = shaka.transmuxer.TransmuxerEngine; + for (const priority of TransmuxerEngine.PLUGIN_PRIORITY_ORDER) { + const plugin = TransmuxerEngine.findTransmuxerPlugin(mimeType, priority); + if (!plugin) { + continue; + } + const transmuxer = plugin(); + const isSupported = transmuxer.isSupported(mimeType, contentType); + transmuxer.destroy(); + if (isSupported) { + return plugin; + } + } + return null; + } + + /** + * Finds a plugin registered for |mimeType| without instantiating it or + * calling isSupported(). Used by the worker where MediaSource may not be + * available to validate support. + * + * When |priority| is null (default), returns the highest-priority plugin. + * When |priority| is specified, returns the plugin at that exact priority or + * null if none is registered at that level. + * + * @param {string} mimeType + * @param {?number=} priority + * @return {?shaka.extern.TransmuxerPlugin} + */ + static findTransmuxerPlugin(mimeType, priority = null) { const TransmuxerEngine = shaka.transmuxer.TransmuxerEngine; const normalizedMimetype = TransmuxerEngine.normalizeMimeType_(mimeType); - const priorities = [ - TransmuxerEngine.PluginPriority.APPLICATION, - TransmuxerEngine.PluginPriority.PREFERRED, - TransmuxerEngine.PluginPriority.PREFERRED_SECONDARY, - TransmuxerEngine.PluginPriority.FALLBACK, - ]; - for (const priority of priorities) { - const key = normalizedMimetype + '-' + priority; + const priorities = priority !== null ? + [priority] : TransmuxerEngine.PLUGIN_PRIORITY_ORDER; + for (const p of priorities) { + const key = normalizedMimetype + '-' + p; const object = TransmuxerEngine.transmuxerMap_.get(key); if (object) { - const transmuxer = object.plugin(); - const isSupported = transmuxer.isSupported(mimeType, contentType); - transmuxer.destroy(); - if (isSupported) { - return object.plugin; - } + return object.plugin; } } return null; @@ -159,3 +180,17 @@ shaka.transmuxer.TransmuxerEngine.PluginPriority = { 'APPLICATION': 4, }; + +/** + * Priorities in descending order (highest first), used when searching for + * a matching transmuxer plugin. + * + * @const {!Array} + */ +shaka.transmuxer.TransmuxerEngine.PLUGIN_PRIORITY_ORDER = [ + shaka.transmuxer.TransmuxerEngine.PluginPriority.APPLICATION, + shaka.transmuxer.TransmuxerEngine.PluginPriority.PREFERRED, + shaka.transmuxer.TransmuxerEngine.PluginPriority.PREFERRED_SECONDARY, + shaka.transmuxer.TransmuxerEngine.PluginPriority.FALLBACK, +]; + diff --git a/lib/transmuxer/transmuxer_proxy.js b/lib/transmuxer/transmuxer_proxy.js new file mode 100644 index 000000000..fe1f9b78b --- /dev/null +++ b/lib/transmuxer/transmuxer_proxy.js @@ -0,0 +1,416 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.transmuxer.TransmuxerProxy'); + +goog.require('shaka.device.DeviceFactory'); +goog.require('shaka.log'); +goog.require('shaka.util.BufferUtils'); +goog.require('shaka.util.Error'); +goog.require('shaka.util.Timer'); +goog.require('shaka.util.Uint8ArrayUtils'); + + +/** + * @summary A proxy transmuxer that delegates transmux() calls to a Web Worker. + * + * Synchronous methods (isSupported, convertCodecs, getOriginalMimeType) are + * handled on the main thread by the inner transmuxer. Only the heavy + * transmux() work is offloaded to the worker. + * + * The worker URL must be supplied by the integrating application via the + * `mediaSource.transmuxWorkerUrl` config option. The library does not attempt + * to discover it. If the URL is empty or the worker cannot be created, the + * proxy falls back to main-thread transmuxing. + * + * @implements {shaka.extern.Transmuxer} + * @export + */ +shaka.transmuxer.TransmuxerProxy = class { + /** + * @param {!shaka.extern.Transmuxer} innerTransmuxer + * The real transmuxer to use for sync methods and as fallback. + * @param {string=} workerUrl + * URL of the standalone transmuxer worker script. When empty, the proxy + * uses main-thread transmuxing. + */ + constructor(innerTransmuxer, workerUrl = '') { + /** @private {!shaka.extern.Transmuxer} */ + this.innerTransmuxer_ = innerTransmuxer; + + /** @private {string} */ + this.workerUrl_ = workerUrl; + + /** @private {boolean} */ + this.workerFailed_ = false; + + /** @private {number} */ + this.nextReqId_ = 0; + + /** + * Maps request IDs to pending promise resolvers and their timeout timers. + * @private {!Map} + */ + this.pendingRequests_ = new Map(); + + /** @private {number} */ + this.id_ = shaka.transmuxer.TransmuxerProxy.nextId_++; + + /** @private {boolean} */ + this.workerReady_ = false; + + /** @private {boolean} */ + this.attachedToWorker_ = false; + } + + /** + * @override + * @export + */ + destroy() { + // Reject all pending requests. + for (const pending of this.pendingRequests_.values()) { + pending.timer.stop(); + pending.reject(new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MEDIA, + shaka.util.Error.Code.TRANSMUXING_FAILED, + 'Worker transmuxer destroyed')); + } + this.pendingRequests_.clear(); + + if (this.attachedToWorker_) { + const TransmuxerProxy = shaka.transmuxer.TransmuxerProxy; + if (TransmuxerProxy.sharedWorker_) { + TransmuxerProxy.sharedWorker_.postMessage( + {'cmd': 'destroy', 'id': this.id_}); + } + TransmuxerProxy.activeInstances_.delete(this.id_); + this.attachedToWorker_ = false; + + // Terminate the shared worker when no instances remain. + if (TransmuxerProxy.activeInstances_.size === 0 && + TransmuxerProxy.sharedWorker_) { + TransmuxerProxy.sharedWorker_.terminate(); + TransmuxerProxy.sharedWorker_ = null; + } + } + + this.innerTransmuxer_.destroy(); + } + + /** + * @param {string} mimeType + * @param {string=} contentType + * @return {boolean} + * @override + * @export + */ + isSupported(mimeType, contentType) { + return this.innerTransmuxer_.isSupported(mimeType, contentType); + } + + /** + * @param {string} contentType + * @param {string} mimeType + * @return {string} + * @override + * @export + */ + convertCodecs(contentType, mimeType) { + return this.innerTransmuxer_.convertCodecs(contentType, mimeType); + } + + /** + * @return {string} + * @override + * @export + */ + getOriginalMimeType() { + return this.innerTransmuxer_.getOriginalMimeType(); + } + + /** + * @override + * @export + */ + async transmux(data, stream, reference, duration, contentType) { + // If worker creation previously failed, fall back to main thread. + if (this.workerFailed_) { + return this.innerTransmuxer_.transmux( + data, stream, reference, duration, contentType); + } + + // Lazy-init: attach to the shared worker on first transmux call. + if (!this.attachedToWorker_) { + const TransmuxerProxy = shaka.transmuxer.TransmuxerProxy; + const worker = TransmuxerProxy.getOrCreateWorker_(this.workerUrl_); + if (!worker) { + this.workerFailed_ = true; + return this.innerTransmuxer_.transmux( + data, stream, reference, duration, contentType); + } + TransmuxerProxy.activeInstances_.set(this.id_, this); + this.attachedToWorker_ = true; + } + + const worker = shaka.transmuxer.TransmuxerProxy.sharedWorker_; + if (!worker) { + this.workerFailed_ = true; + return this.innerTransmuxer_.transmux( + data, stream, reference, duration, contentType); + } + + // Send init on first use so the worker creates the right transmuxer. + if (!this.workerReady_) { + const mimeType = this.innerTransmuxer_.getOriginalMimeType(); + worker.postMessage({ + 'cmd': 'init', + 'id': this.id_, + 'mimeType': mimeType, + }); + this.workerReady_ = true; + } + + const reqId = this.nextReqId_++; + + // Extract only the properties transmuxers actually read/write. + const streamProps = { + 'id': stream.id, + 'codecs': stream.codecs, + 'channelsCount': stream.channelsCount, + 'audioSamplingRate': stream.audioSamplingRate, + 'height': stream.height, + 'width': stream.width, + 'language': stream.language, + }; + + const refProps = reference ? { + 'discontinuitySequence': reference.discontinuitySequence, + 'startTime': reference.startTime, + 'endTime': reference.endTime, + 'uris': reference.getUris(), + } : null; + + // Copy the buffer before transferring so the original `data` stays valid. + // This is necessary because MediaSourceEngine may call transmux() twice + // with the same data (split muxed content: once for audio, once for video). + const buffer = shaka.util.BufferUtils.toArrayBuffer( + shaka.util.Uint8ArrayUtils.concat(data)); + + const {promise, resolve, reject} = Promise.withResolvers(); + const timer = new shaka.util.Timer(() => { + if (this.pendingRequests_.has(reqId)) { + this.pendingRequests_.delete(reqId); + this.workerFailed_ = true; + reject(new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MEDIA, + shaka.util.Error.Code.TRANSMUXING_FAILED, + 'Worker transmux timed out')); + } + }); + timer.tickAfter(shaka.transmuxer.TransmuxerProxy.TIMEOUT_MS_ / 1000); + this.pendingRequests_.set(reqId, { + resolve, + reject, + timer, + }); + + try { + // Transfer the copied buffer to the worker for zero-copy delivery. + // The original data remains valid for any subsequent callers. + worker.postMessage({ + 'cmd': 'transmux', + 'id': this.id_, + 'reqId': reqId, + 'data': buffer, + 'streamProps': streamProps, + 'refProps': refProps, + 'duration': duration, + 'contentType': contentType, + }, [buffer]); + } catch (e) { + timer.stop(); + this.pendingRequests_.delete(reqId); + shaka.log.warning( + 'Failed to post message to worker, falling back to main thread', e); + const transmuxerProxy = shaka.transmuxer.TransmuxerProxy; + transmuxerProxy.terminateWorker_('Worker postMessage failed'); + return this.innerTransmuxer_.transmux( + data, stream, reference, duration, contentType); + } + + const response = await promise; + + // Apply stream mutations back to the real stream object. + const mutations = response['streamMutations']; + if (mutations && Object.keys(mutations).length > 0) { + for (const key of Object.keys(mutations)) { + stream[key] = mutations[key]; + } + } + + // Reconstruct the output. + const output = response['output']; + const BufferUtils = shaka.util.BufferUtils; + if (output['type'] === 'raw') { + return BufferUtils.toUint8( + /** @type {!ArrayBuffer} */(output['data'])); + } else { + return { + data: BufferUtils.toUint8( + /** @type {!ArrayBuffer} */(output['data'])), + init: output['init'] ? BufferUtils.toUint8( + /** @type {!ArrayBuffer} */(output['init'])) : null, + }; + } + } + + /** + * Handles messages from the shared worker for this instance. + * @param {!Object} msg + * @private + */ + onWorkerMessage_(msg) { + const cmd = msg['cmd']; + + if (cmd === 'transmuxed' || cmd === 'error') { + const reqId = msg['reqId']; + const pending = this.pendingRequests_.get(reqId); + if (!pending) { + return; + } + pending.timer.stop(); + this.pendingRequests_.delete(reqId); + + if (cmd === 'error') { + const errorObj = msg['error']; + pending.reject(new shaka.util.Error( + errorObj['severity'], + errorObj['category'], + errorObj['code'], + ...errorObj['data'])); + } else { + pending.resolve(msg); + } + } + } +}; + + +/** @private {number} */ +shaka.transmuxer.TransmuxerProxy.nextId_ = 0; + + +/** + * Timeout in milliseconds for a worker transmux response. If the worker does + * not respond within this time, the request is rejected and future calls fall + * back to the main thread. + * @private @const {number} + */ +shaka.transmuxer.TransmuxerProxy.TIMEOUT_MS_ = 30000; + + +/** + * Shared Worker instance used by all TransmuxerProxy instances. + * @private {?Worker} + */ +shaka.transmuxer.TransmuxerProxy.sharedWorker_ = null; + + +/** + * Map of active instances keyed by ID, for routing worker messages. + * @private {!Map} + */ +shaka.transmuxer.TransmuxerProxy.activeInstances_ = new Map(); + + +/** + * Gets or creates the shared worker. Returns null if the worker cannot be + * created (unsupported device, missing script URL, or creation error). + * @param {string} workerUrlOverride + * @return {?Worker} + * @private + */ +shaka.transmuxer.TransmuxerProxy.getOrCreateWorker_ = (workerUrlOverride) => { + const TransmuxerProxy = shaka.transmuxer.TransmuxerProxy; + if (TransmuxerProxy.sharedWorker_) { + return TransmuxerProxy.sharedWorker_; + } + + const device = shaka.device.DeviceFactory.getDevice(); + if (!device.supportsWorkerTransmux()) { + shaka.log.info( + 'Device does not support worker transmuxing; ' + + 'falling back to main-thread transmuxing'); + return null; + } + + if (!workerUrlOverride) { + shaka.log.warning( + 'Transmuxer worker URL is not configured ' + + '(mediaSource.transmuxWorkerUrl); ' + + 'falling back to main-thread transmuxing'); + return null; + } + + try { + const worker = new Worker(workerUrlOverride); + + worker.addEventListener('message', (event) => { + const msg = /** @type {!MessageEvent} */(event).data; + const cmd = msg['cmd']; + if (cmd === 'transmuxed' || cmd === 'error') { + // Route directly to the instance that owns this request. + const instance = TransmuxerProxy.activeInstances_.get(msg['id']); + if (instance) { + instance.onWorkerMessage_(msg); + } + } + }); + + worker.addEventListener('error', (event) => { + shaka.log.warning('Transmuxer worker error:', event); + TransmuxerProxy.terminateWorker_('Worker error'); + }); + + TransmuxerProxy.sharedWorker_ = worker; + return worker; + } catch (e) { + shaka.log.warning( + 'Failed to create transmuxer worker, falling back to main thread', e); + return null; + } +}; + + +/** + * Marks all active instances as failed, rejects their pending requests, and + * shuts down the shared worker. + * @param {string} message Error message for rejected promises. + * @private + */ +shaka.transmuxer.TransmuxerProxy.terminateWorker_ = (message) => { + const TransmuxerProxy = shaka.transmuxer.TransmuxerProxy; + for (const instance of TransmuxerProxy.activeInstances_.values()) { + instance.workerFailed_ = true; + for (const pending of instance.pendingRequests_.values()) { + pending.timer.stop(); + pending.reject(new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MEDIA, + shaka.util.Error.Code.TRANSMUXING_FAILED, + message)); + } + instance.pendingRequests_.clear(); + } + TransmuxerProxy.sharedWorker_ = null; + TransmuxerProxy.activeInstances_.clear(); +}; + + diff --git a/lib/transmuxer/transmuxer_worker.js b/lib/transmuxer/transmuxer_worker.js new file mode 100644 index 000000000..ea1cc8e3a --- /dev/null +++ b/lib/transmuxer/transmuxer_worker.js @@ -0,0 +1,267 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.transmuxer.TransmuxerWorker'); + +goog.require('shaka.log'); +goog.require('shaka.transmuxer.TransmuxerEngine'); +goog.require('shaka.util.BufferUtils'); +goog.require('shaka.util.Error'); + + +/** + * @summary Web Worker entry point for offloading transmux operations. + * + * This class manages transmuxer instances inside a Web Worker and + * communicates with the main thread via postMessage. + * + * Message protocol (main -> worker): + * {cmd: 'init', id: number, mimeType: string} + * {cmd: 'transmux', id: number, reqId: number, data: ArrayBuffer, + * streamProps: Object, refProps: Object, duration: number, + * contentType: string} + * {cmd: 'destroy', id: number} + * + * Response protocol (worker -> main): + * {cmd: 'transmuxed', id: number, reqId: number, output: Object, + * streamMutations: Object} + * {cmd: 'error', id: number, reqId: number, error: Object} + * {cmd: 'destroyed', id: number} + * + * @export + */ +shaka.transmuxer.TransmuxerWorker = class { + constructor() { + /** + * @private {!Map} + */ + this.transmuxers_ = new Map(); + } + + /** + * Starts listening for messages. Call this from the worker global scope. + * @export + */ + start() { + self.addEventListener('message', (event) => { + this.onMessage_(/** @type {!MessageEvent} */(event)); + }); + } + + /** + * Handles incoming messages from the main thread. + * @param {!MessageEvent} event + * @private + */ + onMessage_(event) { + const msg = event.data; + switch (msg['cmd']) { + case 'init': + this.onInit_(msg); + break; + case 'transmux': + this.onTransmux_(msg); + break; + case 'destroy': + this.onDestroy_(msg); + break; + } + } + + /** + * Creates a transmuxer instance. + * @param {!Object} msg + * @private + */ + onInit_(msg) { + const id = msg['id']; + const mimeType = msg['mimeType']; + + // Look up the transmuxer plugin directly without calling isSupported(). + // The main thread already validated support; re-checking here would fail + // because MediaSource in Workers may not report the same type support as + // the main thread. + const plugin = + shaka.transmuxer.TransmuxerEngine.findTransmuxerPlugin(mimeType); + + if (plugin) { + this.transmuxers_.set(id, plugin()); + } else { + // Only log here; the subsequent onTransmux_ call will post an error + // with the correct reqId so the proxy can route it to the caller. + shaka.log.warning('TransmuxerWorker: no plugin found for', mimeType); + } + } + + /** + * Runs a transmux operation and posts back the result. + * @param {!Object} msg + * @private + */ + async onTransmux_(msg) { + const id = msg['id']; + const reqId = msg['reqId']; + + const transmuxer = this.transmuxers_.get(id); + if (!transmuxer) { + self.postMessage({ + 'cmd': 'error', + 'id': id, + 'reqId': reqId, + 'error': { + 'severity': shaka.util.Error.Severity.CRITICAL, + 'category': shaka.util.Error.Category.MEDIA, + 'code': shaka.util.Error.Code.TRANSMUXING_FAILED, + 'data': ['No transmuxer initialized for id ' + id], + }, + }); + return; + } + + const streamProps = msg['streamProps']; + const refProps = msg['refProps']; + + const stream = { + 'id': streamProps['id'], + 'codecs': streamProps['codecs'], + 'channelsCount': streamProps['channelsCount'], + 'audioSamplingRate': streamProps['audioSamplingRate'], + 'height': streamProps['height'], + 'width': streamProps['width'], + 'language': streamProps['language'], + }; + + const reference = refProps ? { + 'discontinuitySequence': refProps['discontinuitySequence'], + 'startTime': refProps['startTime'], + 'endTime': refProps['endTime'], + 'getUris': () => refProps['uris'], + } : null; + + const data = shaka.util.BufferUtils.toUint8( + /** @type {!ArrayBuffer} */(msg['data'])); + const duration = msg['duration']; + const contentType = msg['contentType']; + + try { + const result = await transmuxer.transmux( + data, + /** @type {shaka.extern.Stream} */(stream), + /** @type {?} */(reference), + duration, + contentType); + + // Compute mutations: which stream properties changed. + const streamMutations = {}; + const mutatedKeys = [ + 'audioSamplingRate', 'channelsCount', 'height', 'width', + ]; + for (const key of mutatedKeys) { + if (stream[key] !== streamProps[key]) { + streamMutations[key] = stream[key]; + } + } + + // Convert typed array views to ArrayBuffer before posting. Only + // ArrayBuffer (not views) can be transferred zero-copy via postMessage. + const BufferUtils = shaka.util.BufferUtils; + + if (ArrayBuffer.isView(result)) { + const buf = BufferUtils.toArrayBuffer( + /** @type {!Uint8Array} */(result)); + self.postMessage({ + 'cmd': 'transmuxed', + 'id': id, + 'reqId': reqId, + 'output': {'type': 'raw', 'data': buf}, + 'streamMutations': streamMutations, + }, [buf]); + } else { + const output = /** @type {!shaka.extern.TransmuxerOutput} */(result); + const dataBuf = BufferUtils.toArrayBuffer(output.data); + const initBuf = output.init ? + BufferUtils.toArrayBuffer(output.init) : null; + const transfers = [dataBuf]; + const response = { + 'cmd': 'transmuxed', + 'id': id, + 'reqId': reqId, + 'output': { + 'type': 'segments', + 'data': dataBuf, + 'init': initBuf, + }, + 'streamMutations': streamMutations, + }; + if (initBuf) { + transfers.push(initBuf); + } + self.postMessage(response, transfers); + } + } catch (e) { + self.postMessage({ + 'cmd': 'error', + 'id': id, + 'reqId': reqId, + 'error': shaka.transmuxer.TransmuxerWorker.errorToObject_(e), + }); + } + } + + /** + * Converts a caught error into a plain serializable object for postMessage. + * @param {*} e + * @return {!Object} + * @private + */ + static errorToObject_(e) { + if (e instanceof shaka.util.Error) { + return { + 'severity': e.severity, + 'category': e.category, + 'code': e.code, + 'data': e.data, + }; + } + return { + 'severity': shaka.util.Error.Severity.CRITICAL, + 'category': shaka.util.Error.Category.MEDIA, + 'code': shaka.util.Error.Code.TRANSMUXING_FAILED, + 'data': [e instanceof Error ? e.message : 'Unknown error'], + }; + } + + /** + * Destroys a transmuxer instance. + * @param {!Object} msg + * @private + */ + onDestroy_(msg) { + const id = msg['id']; + const transmuxer = this.transmuxers_.get(id); + if (transmuxer) { + transmuxer.destroy(); + this.transmuxers_.delete(id); + } + self.postMessage({'cmd': 'destroyed', 'id': id}); + } +}; + + +/** + * Boots the worker if running in a Worker global scope. + * This is called at load time so the worker is ready immediately. + */ +shaka.transmuxer.TransmuxerWorker.boot = () => { + if (typeof DedicatedWorkerGlobalScope !== 'undefined' && + self instanceof DedicatedWorkerGlobalScope) { + const worker = new shaka.transmuxer.TransmuxerWorker(); + worker.start(); + } +}; + +// Auto-boot when loaded in a worker context. +shaka.transmuxer.TransmuxerWorker.boot(); diff --git a/lib/transmuxer/ts_transmuxer.js b/lib/transmuxer/ts_transmuxer.js index 7b022b48b..92998ef75 100644 --- a/lib/transmuxer/ts_transmuxer.js +++ b/lib/transmuxer/ts_transmuxer.js @@ -23,7 +23,6 @@ goog.require('shaka.util.Id3Utils'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Mp4Generator'); -goog.require('shaka.util.StreamUtils'); goog.require('shaka.util.TsParser'); goog.require('shaka.util.Uint8ArrayUtils'); @@ -148,17 +147,17 @@ shaka.transmuxer.TsTransmuxer = class { convertCodecs(contentType, mimeType) { if (this.isTsContainer_(mimeType)) { const ContentType = shaka.util.ManifestParserUtils.ContentType; - const StreamUtils = shaka.util.StreamUtils; + const MimeUtils = shaka.util.MimeUtils; // The replace it's necessary because Firefox(the only browser that // supports MP3 in MP4) only support the MP3 codec with the mp3 string. // MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.34"') -> false // MediaSource.isTypeSupported('audio/mp4; codecs="mp3"') -> true - const codecs = shaka.util.MimeUtils.getCodecs(mimeType) + const codecs = MimeUtils.getCodecs(mimeType) .replace('mp4a.40.34', 'mp3').split(',') .map((codecs) => { - return StreamUtils.getCorrectAudioCodecs(codecs, 'audio/mp4'); + return MimeUtils.getCorrectAudioCodecs(codecs, 'audio/mp4'); }) - .map(StreamUtils.getCorrectVideoCodecs).join(','); + .map(MimeUtils.getCorrectVideoCodecs).join(','); if (contentType == ContentType.AUDIO) { return `audio/mp4; codecs="${codecs}"`; } diff --git a/lib/util/mime_utils.js b/lib/util/mime_utils.js index 8c53e0610..f705f4d68 100644 --- a/lib/util/mime_utils.js +++ b/lib/util/mime_utils.js @@ -6,6 +6,8 @@ goog.provide('shaka.util.MimeUtils'); +goog.require('shaka.device.DeviceFactory'); +goog.require('shaka.device.IDevice'); goog.require('shaka.transmuxer.TransmuxerEngine'); goog.require('shaka.util.ManifestParserUtils'); @@ -275,6 +277,90 @@ shaka.util.MimeUtils = class { mimeType === 'video/vnd.mpeg.dash.mpd'; } + /** + * Generates the correct audio codec for MediaDecodingConfiguration and + * for MediaSource.isTypeSupported. + * @param {string} codecs + * @param {string} mimeType + * @return {string} + */ + static getCorrectAudioCodecs(codecs, mimeType) { + // According to RFC 6381 section 3.3, 'fLaC' is actually the correct + // codec string. We still need to map it to 'flac', as some browsers + // currently don't support 'fLaC', while 'flac' is supported by most + // major browsers. + // See https://bugs.chromium.org/p/chromium/issues/detail?id=1422728 + const device = shaka.device.DeviceFactory.getDevice(); + const webkit = shaka.device.IDevice.BrowserEngine.WEBKIT; + const lowerCaseCodecs = codecs.toLowerCase(); + if (lowerCaseCodecs == 'flac') { + if (device.getBrowserEngine() != webkit) { + return 'flac'; + } else { + return 'fLaC'; + } + } + + // The same is true for 'Opus'. + if (lowerCaseCodecs === 'opus') { + if (device.getBrowserEngine() != webkit) { + return 'opus'; + } else { + if (shaka.util.MimeUtils.getContainerType(mimeType) == 'mp4') { + return 'Opus'; + } else { + return 'opus'; + } + } + } + + if (lowerCaseCodecs == 'ac-3' && device.requiresEC3InitSegments()) { + return 'ec-3'; + } + + return codecs; + } + + /** + * Generates the correct video codec for MediaDecodingConfiguration and + * for MediaSource.isTypeSupported. + * @param {string} codec + * @return {string} + */ + static getCorrectVideoCodecs(codec) { + if (codec.includes('avc1')) { + // Convert avc1 codec string from RFC-4281 to RFC-6381 for + // MediaSource.isTypeSupported + // Example, convert avc1.66.30 to avc1.42001e (0x42 == 66 and 0x1e == 30) + const avcData = codec.split('.'); + if (avcData.length == 3) { + let result = avcData.shift() + '.'; + result += parseInt(avcData.shift(), 10).toString(16); + result += + ('000' + parseInt(avcData.shift(), 10).toString(16)).slice(-4); + return result; + } + } else if (codec == 'vp9') { + // MediaCapabilities supports 'vp09...' codecs, but not 'vp9'. Translate + // vp9 codec strings into 'vp09...', to allow such content to play with + // mediaCapabilities enabled. + // This means profile 0, level 4.1, 8-bit color. This supports 1080p @ + // 60Hz. See https://en.wikipedia.org/wiki/VP9#Levels + // + // If we don't have more detailed codec info, assume this profile and + // level because it's high enough to likely accommodate the parameters we + // do have, such as width and height. If an implementation is checking + // the profile and level very strictly, we want older VP9 content to + // still work to some degree. But we don't want to set a level so high + // that it is rejected by a hardware decoder that can't handle the + // maximum requirements of the level. + // + // This became an issue specifically on Firefox on M1 Macs. + return 'vp09.00.41.08'; + } + return codec; + } + /** * Get the base and profile of a codec string. Where [0] will be the codec * base and [1] will be the profile. diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js index b9169b08f..ce7091a08 100644 --- a/lib/util/player_configuration.js +++ b/lib/util/player_configuration.js @@ -386,6 +386,7 @@ shaka.util.PlayerConfiguration = class { dispatchAllEmsgBoxes: false, useSourceElements: true, durationReductionEmitsUpdateEnd: true, + transmuxWorkerUrl: '', }; const ads = { diff --git a/lib/util/stream_utils.js b/lib/util/stream_utils.js index 395726725..108510a6e 100644 --- a/lib/util/stream_utils.js +++ b/lib/util/stream_utils.js @@ -778,7 +778,6 @@ shaka.util.StreamUtils = class { const ContentType = shaka.util.ManifestParserUtils.ContentType; const ManifestParserUtils = shaka.util.ManifestParserUtils; const MimeUtils = shaka.util.MimeUtils; - const StreamUtils = shaka.util.StreamUtils; const videoConfigs = []; const audioConfigs = []; @@ -798,7 +797,7 @@ shaka.util.StreamUtils = class { let audioCodecs = ManifestParserUtils.guessCodecs( ContentType.AUDIO, allCodecs); - audioCodecs = StreamUtils.getCorrectAudioCodecs( + audioCodecs = MimeUtils.getCorrectAudioCodecs( audioCodecs, baseMimeType); const audioFullType = MimeUtils.getFullOrConvertedType( @@ -813,7 +812,7 @@ shaka.util.StreamUtils = class { }); } - videoCodecs = StreamUtils.getCorrectVideoCodecs(videoCodecs); + videoCodecs = MimeUtils.getCorrectVideoCodecs(videoCodecs); const fullType = MimeUtils.getFullOrConvertedType( MimeUtils.getBasicType(fullMimeType), videoCodecs, ContentType.VIDEO); @@ -855,7 +854,7 @@ shaka.util.StreamUtils = class { if (audio) { for (const fullMimeType of audio.fullMimeTypes) { const baseMimeType = MimeUtils.getBasicType(fullMimeType); - const codecs = StreamUtils.getCorrectAudioCodecs( + const codecs = MimeUtils.getCorrectAudioCodecs( MimeUtils.getCodecs(fullMimeType), baseMimeType); const fullType = MimeUtils.getFullOrConvertedType( baseMimeType, codecs, ContentType.AUDIO); @@ -1041,91 +1040,6 @@ shaka.util.StreamUtils = class { } - /** - * Generates the correct audio codec for MediaDecodingConfiguration and - * for MediaSource.isTypeSupported. - * @param {string} codecs - * @param {string} mimeType - * @return {string} - */ - static getCorrectAudioCodecs(codecs, mimeType) { - // According to RFC 6381 section 3.3, 'fLaC' is actually the correct - // codec string. We still need to map it to 'flac', as some browsers - // currently don't support 'fLaC', while 'flac' is supported by most - // major browsers. - // See https://bugs.chromium.org/p/chromium/issues/detail?id=1422728 - const device = shaka.device.DeviceFactory.getDevice(); - const webkit = shaka.device.IDevice.BrowserEngine.WEBKIT; - if (codecs.toLowerCase() == 'flac') { - if (device.getBrowserEngine() != webkit) { - return 'flac'; - } else { - return 'fLaC'; - } - } - - // The same is true for 'Opus'. - if (codecs.toLowerCase() === 'opus') { - if (device.getBrowserEngine() != webkit) { - return 'opus'; - } else { - if (shaka.util.MimeUtils.getContainerType(mimeType) == 'mp4') { - return 'Opus'; - } else { - return 'opus'; - } - } - } - - if (codecs.toLowerCase() == 'ac-3' && device.requiresEC3InitSegments()) { - return 'ec-3'; - } - - return codecs; - } - - - /** - * Generates the correct video codec for MediaDecodingConfiguration and - * for MediaSource.isTypeSupported. - * @param {string} codec - * @return {string} - */ - static getCorrectVideoCodecs(codec) { - if (codec.includes('avc1')) { - // Convert avc1 codec string from RFC-4281 to RFC-6381 for - // MediaSource.isTypeSupported - // Example, convert avc1.66.30 to avc1.42001e (0x42 == 66 and 0x1e == 30) - const avcData = codec.split('.'); - if (avcData.length == 3) { - let result = avcData.shift() + '.'; - result += parseInt(avcData.shift(), 10).toString(16); - result += - ('000' + parseInt(avcData.shift(), 10).toString(16)).slice(-4); - return result; - } - } else if (codec == 'vp9') { - // MediaCapabilities supports 'vp09...' codecs, but not 'vp9'. Translate - // vp9 codec strings into 'vp09...', to allow such content to play with - // mediaCapabilities enabled. - // This means profile 0, level 4.1, 8-bit color. This supports 1080p @ - // 60Hz. See https://en.wikipedia.org/wiki/VP9#Levels - // - // If we don't have more detailed codec info, assume this profile and - // level because it's high enough to likely accommodate the parameters we - // do have, such as width and height. If an implementation is checking - // the profile and level very strictly, we want older VP9 content to - // still work to some degree. But we don't want to set a level so high - // that it is rejected by a hardware decoder that can't handle the - // maximum requirements of the level. - // - // This became an issue specifically on Firefox on M1 Macs. - return 'vp09.00.41.08'; - } - return codec; - } - - /** * Alters the given Manifest to filter out any streams incompatible with the * current variant. diff --git a/lib/util/string_utils.js b/lib/util/string_utils.js index 984d8b96e..628a3047a 100644 --- a/lib/util/string_utils.js +++ b/lib/util/string_utils.js @@ -38,7 +38,7 @@ shaka.util.StringUtils = class { uint8 = uint8.subarray(3); } - if (window.TextDecoder && !shaka.device.DeviceFactory.getDevice() + if (self.TextDecoder && !shaka.device.DeviceFactory.getDevice() .shouldAvoidUseTextDecoderEncoder()) { // Use the TextDecoder interface to decode the text. This has the // advantage compared to the previously-standard decodeUriComponent that @@ -209,7 +209,7 @@ shaka.util.StringUtils = class { * @export */ static toUTF8(str) { - if (window.TextEncoder && !shaka.device.DeviceFactory.getDevice() + if (self.TextEncoder && !shaka.device.DeviceFactory.getDevice() .shouldAvoidUseTextDecoderEncoder()) { const utf8Encoder = new TextEncoder(); return shaka.util.BufferUtils.toArrayBuffer(utf8Encoder.encode(str)); diff --git a/lib/util/uint8array_utils.js b/lib/util/uint8array_utils.js index 66c5576da..b5e0eefbf 100644 --- a/lib/util/uint8array_utils.js +++ b/lib/util/uint8array_utils.js @@ -65,7 +65,7 @@ shaka.util.Uint8ArrayUtils = class { if (!('fromBase64' in Uint8Array)) { // atob creates a "raw string" where each character is interpreted as a // byte. - const bytes = window.atob(str.replace(/-/g, '+').replace(/_/g, '/')); + const bytes = self.atob(str.replace(/-/g, '+').replace(/_/g, '/')); const result = new Uint8Array(bytes.length); for (let i = 0; i < bytes.length; ++i) { result[i] = bytes.charCodeAt(i); @@ -91,7 +91,7 @@ shaka.util.Uint8ArrayUtils = class { const size = str.length / 2; const arr = new Uint8Array(size); for (let i = 0; i < size; i++) { - arr[i] = window.parseInt(str.substr(i * 2, 2), 16); + arr[i] = parseInt(str.substr(i * 2, 2), 16); } return arr; } diff --git a/test/media/media_source_engine_integration.js b/test/media/media_source_engine_integration.js index 789bb2c65..a31e09b5d 100644 --- a/test/media/media_source_engine_integration.js +++ b/test/media/media_source_engine_integration.js @@ -179,6 +179,10 @@ describe('MediaSourceEngine', () => { onEvent = jasmine.createSpy('onEvent'); onManifestUpdate = jasmine.createSpy('onManifestUpdate'); const config = shaka.util.PlayerConfiguration.createDefault().mediaSource; + // Prevent worker creation: uncompiled worker requires Closure Library + // which is not served by Karma. + spyOn(shaka.transmuxer.TransmuxerProxy, 'getOrCreateWorker_') + .and.returnValue(null); mediaSourceEngine = new shaka.media.MediaSourceEngine( video, diff --git a/test/media/media_source_engine_unit.js b/test/media/media_source_engine_unit.js index 73c9de4cc..7aba0e589 100644 --- a/test/media/media_source_engine_unit.js +++ b/test/media/media_source_engine_unit.js @@ -55,11 +55,9 @@ describe('MediaSourceEngine', () => { const originalIsSupported = shaka.transmuxer.TransmuxerEngine.isSupported; - // Jasmine Spies don't handle toHaveBeenCalledWith well with objects, so use - // some numbers instead. - const buffer = /** @type {!ArrayBuffer} */ (/** @type {?} */ (1)); - const buffer2 = /** @type {!ArrayBuffer} */ (/** @type {?} */ (2)); - const buffer3 = /** @type {!ArrayBuffer} */ (/** @type {?} */ (3)); + const buffer = new Uint8Array([0x01]); + const buffer2 = new Uint8Array([0x02]); + const buffer3 = new Uint8Array([0x03]); const makeFakeStream = (mimeType) => { const segmentIndex = { @@ -252,6 +250,10 @@ describe('MediaSourceEngine', () => { mockClosedCaptionParser = new shaka.test.FakeClosedCaptionParser(); mockTextDisplayer = new shaka.test.FakeTextDisplayer(); const config = shaka.util.PlayerConfiguration.createDefault().mediaSource; + // FakeTransmuxer is not in the worker bundle; prevent worker creation so + // transmux calls fall back to the main-thread inner transmuxer. + spyOn(shaka.transmuxer.TransmuxerProxy, 'getOrCreateWorker_') + .and.returnValue(null); mediaSourceEngine = new shaka.media.MediaSourceEngine( video, @@ -452,6 +454,31 @@ describe('MediaSourceEngine', () => { expect(mockMediaSource.addSourceBuffer).not.toHaveBeenCalled(); expect(shaka.text.TextEngine).toHaveBeenCalled(); }); + + it('always wraps transmuxer in TransmuxerProxy', async () => { + const proxySpy = spyOn(shaka.transmuxer, 'TransmuxerProxy') + .and.callThrough(); + + const initObject = new Map(); + initObject.set(ContentType.VIDEO, fakeTransportStream); + await mediaSourceEngine.init(initObject, false); + expect(proxySpy).toHaveBeenCalled(); + }); + + it('passes transmuxWorkerUrl to TransmuxerProxy', async () => { + const proxySpy = spyOn(shaka.transmuxer, 'TransmuxerProxy') + .and.callThrough(); + const config = + shaka.util.PlayerConfiguration.createDefault().mediaSource; + config.transmuxWorkerUrl = 'https://example.com/worker.js'; + mediaSourceEngine.configure(config); + + const initObject = new Map(); + initObject.set(ContentType.VIDEO, fakeTransportStream); + await mediaSourceEngine.init(initObject, false); + expect(proxySpy).toHaveBeenCalledOnceWith( + mockTransmuxer, 'https://example.com/worker.js'); + }); }); describe('bufferStart and bufferEnd', () => { @@ -1269,7 +1296,7 @@ describe('MediaSourceEngine', () => { await expectAsync(p1).toBeRejected(); expect(mockMediaSource.endOfStream).toHaveBeenCalled(); await Util.shortDelay(); - expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(1); + expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); audioSourceBuffer.updateend(); }); }); diff --git a/test/test/util/util.js b/test/test/util/util.js index 4d2e20076..705546224 100644 --- a/test/test/util/util.js +++ b/test/test/util/util.js @@ -379,7 +379,6 @@ shaka.test.Util = class { static async isTypeSupported(mimetype, width, height) { const MimeUtils = shaka.util.MimeUtils; const ContentType = shaka.util.ManifestParserUtils.ContentType; - const StreamUtils = shaka.util.StreamUtils; /** @type {!MediaDecodingConfiguration} */ const mediaDecodingConfig = { @@ -387,7 +386,7 @@ shaka.test.Util = class { }; if (mimetype.startsWith('audio')) { const baseMimeType = MimeUtils.getBasicType(mimetype); - const codecs = StreamUtils.getCorrectAudioCodecs( + const codecs = MimeUtils.getCorrectAudioCodecs( MimeUtils.getCodecs(mimetype), baseMimeType); // AudioConfiguration mediaDecodingConfig.audio = { @@ -395,7 +394,7 @@ shaka.test.Util = class { baseMimeType, codecs, ContentType.AUDIO), }; } else { - const codecs = StreamUtils.getCorrectVideoCodecs( + const codecs = MimeUtils.getCorrectVideoCodecs( MimeUtils.getCodecs(mimetype)); const baseMimeType = MimeUtils.getBasicType(mimetype); if (codecs.startsWith('hvc1.') && deviceDetected.disableHEVCSupport()) { diff --git a/test/transmuxer/transmuxer_proxy_unit.js b/test/transmuxer/transmuxer_proxy_unit.js new file mode 100644 index 000000000..f443a712b --- /dev/null +++ b/test/transmuxer/transmuxer_proxy_unit.js @@ -0,0 +1,580 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +describe('TransmuxerProxy', () => { + const TransmuxerProxy = shaka.transmuxer.TransmuxerProxy; + + let mockInner; + + /** @type {!shaka.transmuxer.TransmuxerProxy} */ + let transmuxer; + + /** @type {string} */ + const FAKE_WORKER_URL = 'http://fake/transmuxer_worker.js'; + + /** @type {!shaka.extern.Stream} */ + let fakeStream; + + /** @type {!Uint8Array} */ + let fakeData; + + beforeEach(() => { + mockInner = jasmine.createSpyObj('innerTransmuxer', [ + 'destroy', + 'isSupported', + 'convertCodecs', + 'getOriginalMimeType', + 'transmux', + ]); + mockInner.getOriginalMimeType.and.returnValue('video/mp2t'); + mockInner.isSupported.and.returnValue(true); + mockInner.convertCodecs.and.returnValue('video/mp4; codecs="avc1.42E01E"'); + mockInner.transmux.and.returnValue( + Promise.resolve(new Uint8Array([1, 2, 3]))); + + fakeStream = /** @type {!shaka.extern.Stream} */({ + id: 1, + codecs: 'avc1.42E01E', + channelsCount: null, + audioSamplingRate: null, + height: null, + width: null, + language: 'en', + }); + + fakeData = new Uint8Array([0xAB, 0xCD, 0xEF]); + transmuxer = new TransmuxerProxy(mockInner, FAKE_WORKER_URL); + }); + + afterEach(() => { + transmuxer.destroy(); + resetClassState(); + }); + + describe('sync methods delegate to inner transmuxer', () => { + it('isSupported delegates', () => { + expect(transmuxer.isSupported('video/mp2t', 'video')).toBe(true); + expect(mockInner.isSupported).toHaveBeenCalledWith('video/mp2t', 'video'); + }); + + it('convertCodecs delegates', () => { + const result = transmuxer.convertCodecs('video', 'video/mp2t'); + expect(result).toBe('video/mp4; codecs="avc1.42E01E"'); + expect(mockInner.convertCodecs) + .toHaveBeenCalledWith('video', 'video/mp2t'); + }); + + it('getOriginalMimeType delegates', () => { + expect(transmuxer.getOriginalMimeType()).toBe('video/mp2t'); + expect(mockInner.getOriginalMimeType).toHaveBeenCalled(); + }); + }); + + describe('destroy', () => { + it('calls destroy on inner transmuxer', () => { + transmuxer.destroy(); + expect(mockInner.destroy).toHaveBeenCalled(); + }); + }); + + describe('fallback to main thread', () => { + it('falls back when workerUrl is empty', async () => { + transmuxer.destroy(); + transmuxer = new TransmuxerProxy(mockInner, ''); + + const result = await transmuxer.transmux( + fakeData, fakeStream, null, 10, 'video'); + expect(mockInner.transmux).toHaveBeenCalled(); + expect(result).toEqual(jasmine.any(Uint8Array)); + }); + + it('falls back when device does not support worker transmux', async () => { + const device = shaka.device.DeviceFactory.getDevice(); + spyOn(device, 'supportsWorkerTransmux').and.returnValue(false); + + const result = await transmuxer.transmux( + fakeData, fakeStream, null, 10, 'video'); + expect(mockInner.transmux).toHaveBeenCalled(); + expect(result).toEqual(jasmine.any(Uint8Array)); + }); + + it('continues falling back after first failure', async () => { + transmuxer.destroy(); + transmuxer = new TransmuxerProxy(mockInner, ''); + + await transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + await transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + expect(mockInner.transmux).toHaveBeenCalledTimes(2); + }); + }); + + describe('worker path', () => { + /** @type {?Function} */ + let capturedMessageHandler; + + /** @type {?Function} */ + let capturedErrorHandler; + + let mockWorker; + + /** @type {?Function} */ + let originalWorkerClass; + + /** @type {string} */ + let capturedWorkerUrl; + + beforeEach(() => { + capturedMessageHandler = null; + capturedErrorHandler = null; + capturedWorkerUrl = ''; + + mockWorker = { + postMessage: jasmine.createSpy('postMessage'), + terminate: jasmine.createSpy('terminate'), + addEventListener: jasmine.createSpy('addEventListener') + .and.callFake((type, handler) => { + if (type === 'message') { + capturedMessageHandler = handler; + } else if (type === 'error') { + capturedErrorHandler = handler; + } + }), + }; + + // Replace the Worker constructor so getOrCreateWorker_() returns our + // mock. We do this by replacing the global and restoring in afterEach. + originalWorkerClass = window['Worker']; + window['Worker'] = /** @type {?} */(class { + /** + * @param {string} url + */ + constructor(url) { + capturedWorkerUrl = url; + return /** @type {?} */(mockWorker); + } + }); + }); + + afterEach(() => { + window['Worker'] = originalWorkerClass; + }); + + /** + * Simulates the worker sending a 'transmuxed' response. + * @param {number} id The instance id to route to. + * @param {number} reqId + * @param {!ArrayBuffer} outputBuffer + * @param {Object=} streamMutations + */ + const simulateTransmuxed = (id, reqId, outputBuffer, streamMutations) => { + const msg = { + 'cmd': 'transmuxed', + 'id': id, + 'reqId': reqId, + 'output': {'type': 'raw', 'data': outputBuffer}, + 'streamMutations': streamMutations || null, + }; + capturedMessageHandler(/** @type {!MessageEvent} */({data: msg})); + }; + + it('sends init message on first transmux', async () => { + const id = getInstanceId(transmuxer); + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + simulateTransmuxed(id, 0, new ArrayBuffer(3)); + await p; + + expect(capturedWorkerUrl).toBe(FAKE_WORKER_URL); + expect(mockWorker.postMessage).toHaveBeenCalledWith( + jasmine.objectContaining({'cmd': 'init', 'mimeType': 'video/mp2t'})); + }); + + it('uses the configured worker URL', async () => { + transmuxer.destroy(); + transmuxer = new TransmuxerProxy( + mockInner, 'https://example.com/worker.js'); + + const id = getInstanceId(transmuxer); + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + simulateTransmuxed(id, 0, new ArrayBuffer(3)); + await p; + + expect(capturedWorkerUrl).toBe('https://example.com/worker.js'); + }); + + it('sends init message only once for multiple calls', async () => { + const id = getInstanceId(transmuxer); + const p1 = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + simulateTransmuxed(id, 0, new ArrayBuffer(3)); + await p1; + + const p2 = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + simulateTransmuxed(id, 1, new ArrayBuffer(3)); + await p2; + + const initCalls = mockWorker.postMessage.calls.all() + .filter((c) => c.args[0]['cmd'] === 'init'); + expect(initCalls.length).toBe(1); + }); + + it('sends transmux message with correct props', async () => { + const id = getInstanceId(transmuxer); + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + simulateTransmuxed(id, 0, new ArrayBuffer(3)); + await p; + + const call = mockWorker.postMessage.calls.all() + .find((c) => c.args[0]['cmd'] === 'transmux'); + expect(call).toBeTruthy(); + expect(call.args[0]['duration']).toBe(10); + expect(call.args[0]['contentType']).toBe('video'); + expect(call.args[0]['streamProps']['id']).toBe(1); + expect(call.args[0]['streamProps']['codecs']).toBe('avc1.42E01E'); + }); + + it('returns Uint8Array for type=raw response', async () => { + const id = getInstanceId(transmuxer); + const outputData = new Uint8Array([0x01, 0x02, 0x03]); + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + simulateTransmuxed(id, 0, + shaka.util.BufferUtils.toArrayBuffer(outputData)); + const result = await p; + + expect(result).toBeInstanceOf(Uint8Array); + expect(/** @type {!Uint8Array} */(result).length).toBe(3); + }); + + it('returns {data, init} for type=muxed response', async () => { + const id = getInstanceId(transmuxer); + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + capturedMessageHandler({data: { + 'cmd': 'transmuxed', + 'id': id, + 'reqId': 0, + 'output': { + 'type': 'segments', + 'data': new ArrayBuffer(4), + 'init': new ArrayBuffer(2), + }, + 'streamMutations': null, + }}); + const result = await p; + + expect(result).toEqual(jasmine.objectContaining({ + data: jasmine.any(Uint8Array), + init: jasmine.any(Uint8Array), + })); + }); + + it('returns null init when absent in muxed response', async () => { + const id = getInstanceId(transmuxer); + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + capturedMessageHandler({data: { + 'cmd': 'transmuxed', + 'id': id, + 'reqId': 0, + 'output': { + 'type': 'segments', 'data': new ArrayBuffer(4), 'init': null, + }, + 'streamMutations': null, + }}); + const result = await p; + + expect(/** @type {{data: ?, init: ?}} */(result).init).toBeNull(); + }); + + it('applies stream mutations from worker response', async () => { + const id = getInstanceId(transmuxer); + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'audio'); + simulateTransmuxed(id, 0, new ArrayBuffer(3), + {'audioSamplingRate': 48000, 'channelsCount': 2}); + await p; + + expect(fakeStream.audioSamplingRate).toBe(48000); + expect(fakeStream.channelsCount).toBe(2); + }); + + it('does not modify stream when mutations are null', async () => { + const id = getInstanceId(transmuxer); + fakeStream.audioSamplingRate = 44100; + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'audio'); + simulateTransmuxed(id, 0, new ArrayBuffer(3), null); + await p; + + expect(fakeStream.audioSamplingRate).toBe(44100); + }); + + it('rejects with shaka.util.Error from worker error response', async () => { + const id = getInstanceId(transmuxer); + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + + // Register rejection handler before triggering the error to avoid + // an unhandled-rejection event racing with the expectAsync handler. + const assertion = expectAsync(p).toBeRejectedWith( + jasmine.objectContaining({ + code: shaka.util.Error.Code.TRANSMUXING_FAILED, + })); + + capturedMessageHandler({data: { + 'cmd': 'error', + 'id': id, + 'reqId': 0, + 'error': { + 'severity': shaka.util.Error.Severity.CRITICAL, + 'category': shaka.util.Error.Category.MEDIA, + 'code': shaka.util.Error.Code.TRANSMUXING_FAILED, + 'data': ['test error'], + }, + }}); + + await assertion; + }); + + it('ignores messages with unknown reqId', async () => { + const id = getInstanceId(transmuxer); + // Kick off a transmux to initialize the worker, then simulate a stale + // message with an unknown reqId — should not throw. + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + + expect(() => { + capturedMessageHandler({data: { + 'cmd': 'transmuxed', + 'id': id, + 'reqId': 9999, + 'output': {'type': 'raw', 'data': new ArrayBuffer(1)}, + 'streamMutations': null, + }}); + }).not.toThrow(); + + // Clean up: resolve the real pending request so afterEach is happy. + simulateTransmuxed(id, 0, new ArrayBuffer(1)); + await p; + }); + + it('ignores messages with unknown instance id', async () => { + const id = getInstanceId(transmuxer); + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + + // A message with a different instance id should be silently ignored. + expect(() => { + capturedMessageHandler({data: { + 'cmd': 'transmuxed', + 'id': id + 100, + 'reqId': 0, + 'output': {'type': 'raw', 'data': new ArrayBuffer(1)}, + 'streamMutations': null, + }}); + }).not.toThrow(); + + simulateTransmuxed(id, 0, new ArrayBuffer(1)); + await p; + }); + + it('assigns sequential reqIds to concurrent calls', async () => { + const id = getInstanceId(transmuxer); + const p1 = transmuxer.transmux(fakeData, fakeStream, null, 10, 'v'); + const p2 = transmuxer.transmux(fakeData, fakeStream, null, 10, 'v'); + + simulateTransmuxed(id, 1, new ArrayBuffer(2)); + simulateTransmuxed(id, 0, new ArrayBuffer(2)); + + await Promise.all([p1, p2]); + + const transmuxCalls = mockWorker.postMessage.calls.all() + .filter((c) => c.args[0]['cmd'] === 'transmux'); + expect(transmuxCalls.length).toBe(2); + expect(transmuxCalls[0].args[0]['reqId']).toBe(0); + expect(transmuxCalls[1].args[0]['reqId']).toBe(1); + }); + + it('sends destroy message and terminates worker on destroy', async () => { + const id = getInstanceId(transmuxer); + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + simulateTransmuxed(id, 0, new ArrayBuffer(1)); + await p; + + transmuxer.destroy(); + + expect(mockWorker.postMessage).toHaveBeenCalledWith( + jasmine.objectContaining({'cmd': 'destroy'})); + expect(mockWorker.terminate).toHaveBeenCalled(); + }); + + it('destroy rejects pending transmux', async () => { + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + + // Register rejection handler before destroy to avoid an + // unhandled-rejection event racing with the expectAsync handler. + const assertion = expectAsync(p).toBeRejectedWith( + jasmine.objectContaining({ + code: shaka.util.Error.Code.TRANSMUXING_FAILED, + })); + + transmuxer.destroy(); + + await assertion; + }); + + describe('timeout', () => { + beforeEach(() => jasmine.clock().install()); + afterEach(() => jasmine.clock().uninstall()); + + it('rejects on timeout when worker does not respond', async () => { + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + const assertion = expectAsync(p).toBeRejectedWith( + jasmine.objectContaining({ + code: shaka.util.Error.Code.TRANSMUXING_FAILED, + })); + + jasmine.clock().tick(30001); + await assertion; + }); + + it('falls back to main thread after timeout', async () => { + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + const assertion = expectAsync(p).toBeRejectedWith( + jasmine.objectContaining({ + code: shaka.util.Error.Code.TRANSMUXING_FAILED, + })); + jasmine.clock().tick(30001); + await assertion; + + // mockInner.transmux returns a resolved promise, so this does not + // involve setTimeout and is safe to await with the clock still running. + await transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + expect(mockInner.transmux).toHaveBeenCalled(); + }); + }); + + it('sends reference props when reference is provided', async () => { + const id = getInstanceId(transmuxer); + const ref = /** @type {?} */({ + discontinuitySequence: 2, + startTime: 10, + endTime: 14, + getUris: () => ['http://example.com/seg.ts'], + }); + + const p = transmuxer.transmux(fakeData, fakeStream, ref, 4, 'video'); + simulateTransmuxed(id, 0, new ArrayBuffer(1)); + await p; + + const call = mockWorker.postMessage.calls.all() + .find((c) => c.args[0]['cmd'] === 'transmux'); + expect(call.args[0]['refProps']['discontinuitySequence']).toBe(2); + expect(call.args[0]['refProps']['startTime']).toBe(10); + expect(call.args[0]['refProps']['endTime']).toBe(14); + expect(call.args[0]['refProps']['uris']) + .toEqual(['http://example.com/seg.ts']); + }); + + it('sends null refProps when reference is null', async () => { + const id = getInstanceId(transmuxer); + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + simulateTransmuxed(id, 0, new ArrayBuffer(1)); + await p; + + const call = mockWorker.postMessage.calls.all() + .find((c) => c.args[0]['cmd'] === 'transmux'); + expect(call.args[0]['refProps']).toBeNull(); + }); + + describe('worker global error event', () => { + it('rejects the pending request', async () => { + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + + const assertion = expectAsync(p).toBeRejectedWith( + jasmine.objectContaining({ + code: shaka.util.Error.Code.TRANSMUXING_FAILED, + })); + + capturedErrorHandler(new Event('error')); + await assertion; + }); + + it('falls back to main thread after worker error', async () => { + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + const rejection = expectAsync(p).toBeRejected(); + capturedErrorHandler(new Event('error')); + await rejection; + + await transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + expect(mockInner.transmux).toHaveBeenCalled(); + }); + }); + + it('surfaces init error with correct reqId on first transmux', async () => { + const id = getInstanceId(transmuxer); + const p = transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + + // Simulate the worker responding with an error because init failed + // (no plugin found). The worker now returns the error with the + // correct reqId from the transmux request, not reqId: -1. + const assertion = expectAsync(p).toBeRejectedWith( + jasmine.objectContaining({ + code: shaka.util.Error.Code.TRANSMUXING_FAILED, + })); + + capturedMessageHandler({data: { + 'cmd': 'error', + 'id': id, + 'reqId': 0, + 'error': { + 'severity': shaka.util.Error.Severity.CRITICAL, + 'category': shaka.util.Error.Category.MEDIA, + 'code': shaka.util.Error.Code.TRANSMUXING_FAILED, + 'data': ['No transmuxer initialized for id ' + id], + }, + }}); + + await assertion; + }); + + describe('Worker constructor failure', () => { + beforeEach(() => { + window['Worker'] = /** @type {?} */(class { + constructor() { + throw new Error('Worker creation not supported'); + } + }); + }); + + it('falls back to main thread when Worker constructor throws', + async () => { + const result = await transmuxer.transmux( + fakeData, fakeStream, null, 10, 'video'); + expect(mockInner.transmux).toHaveBeenCalled(); + expect(result).toEqual(jasmine.any(Uint8Array)); + }); + + it('continues falling back after constructor failure', async () => { + await transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + await transmuxer.transmux(fakeData, fakeStream, null, 10, 'video'); + expect(mockInner.transmux).toHaveBeenCalledTimes(2); + }); + }); + }); + + /** + * @suppress {visibility} + * "suppress visibility" has function scope, so this is a mini-function that + * exists solely to suppress visibility rules for these actions. + */ + function resetClassState() { + shaka.transmuxer.TransmuxerProxy.sharedWorker_ = null; + shaka.transmuxer.TransmuxerProxy.activeInstances_.clear(); + } + + /** + * @suppress {visibility} + * "suppress visibility" has function scope, so this is a mini-function that + * exists solely to suppress visibility rules for these actions. + * @param {!shaka.transmuxer.TransmuxerProxy} tx + * @return {number} + */ + function getInstanceId(tx) { + return tx.id_; + } +}); diff --git a/transmuxer_worker.uncompiled.js b/transmuxer_worker.uncompiled.js new file mode 100644 index 000000000..33b042b0a --- /dev/null +++ b/transmuxer_worker.uncompiled.js @@ -0,0 +1,66 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Entry point for the transmuxer Web Worker in uncompiled + * (development) mode. Loads Closure Library via importScripts, pulls in the + * dependency graph, then goog.require's the transmuxer plugins and the worker + * entry point. + * + * In compiled builds this file is not used — the compiled bundle is loaded + * directly as the Worker script. + */ + +// Closure's base.js uses document.currentScript (or document.write) to resolve +// relative paths. Neither exists inside a Worker, so we provide a +// CLOSURE_IMPORT_SCRIPT hook that uses importScripts instead, and set +// CLOSURE_BASE_PATH so Closure can find its own modules. + +/* eslint-disable no-restricted-syntax */ +// @ts-ignore +self.CLOSURE_BASE_PATH = + 'node_modules/google-closure-library/closure/goog/'; + +// Tell Closure to use importScripts for loading dependencies. +self.CLOSURE_IMPORT_SCRIPT = (src) => { + importScripts(src); + return true; +}; + +// Load Closure Library base and the generated dependency map. +importScripts( + 'node_modules/google-closure-library/closure/goog/base.js', + 'dist/deps.js'); + +// Pull in all device plugins so DeviceFactory.getDevice() returns the correct +// device inside the worker (needed by Ac3Transmuxer and MimeUtils codec +// conversion). Without these, getDevice() always returns DefaultBrowser even +// on Tizen/WebOS/Xbox/etc. +goog.require('shaka.device.AppleBrowser'); +goog.require('shaka.device.Chromecast'); +goog.require('shaka.device.DefaultBrowser'); +goog.require('shaka.device.Hisense'); +goog.require('shaka.device.PlayStation'); +goog.require('shaka.device.TitanOS'); +goog.require('shaka.device.Tizen'); +goog.require('shaka.device.Vizio'); +goog.require('shaka.device.WebKitSTB'); +goog.require('shaka.device.WebOS'); +goog.require('shaka.device.Xbox'); + +// Pull in the transmuxer plugins so they self-register with +// TransmuxerEngine, then load the worker entry point. +goog.require('shaka.transmuxer.AacTransmuxer'); +goog.require('shaka.transmuxer.Ac3Transmuxer'); +goog.require('shaka.transmuxer.Ec3Transmuxer'); +goog.require('shaka.transmuxer.LocTransmuxer'); +goog.require('shaka.transmuxer.Mp3Transmuxer'); +goog.require('shaka.transmuxer.MpegTsTransmuxer'); +goog.require('shaka.transmuxer.TsTransmuxer'); +goog.require('shaka.transmuxer.TransmuxerWorker'); + +// The auto-boot at the bottom of transmuxer_worker.js will have already +// called TransmuxerWorker.boot() when it was loaded by goog.require above.