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.