feat: transmux in a worker (#9914)

This PR introduces a Web Worker for transmuxing resolving
https://github.com/shaka-project/shaka-player/issues/1735

- The worker bundle is compiled separately
- The build output is embedded as a string constant and then wrapped in
a Blob to create an inline Worker URL (HLS.js does this very similarly)
- `TransmuxerProxy` is created wrapping a real transmuxer, but no worker
is started yet - on the first `transmux()` call, it checks if the device
supports worker transmuxing
- For each transmux() call: the buffer is copied, then zero-copy
transferred to the worker. A PublicPromise is stored under a reqId with
a timeout timer, and the main thread awaits it.
- The worker transmuxes and posts back transmuxed (or error). The shared
message listener routes the response to the right proxy instance by id,
which resolves the promise and cancels the timer.
- When the last proxy instance is destroyed, the worker is terminated
and the blob URL is revoked.
  loaded inside the worker.
- Some low-end devices have been excluded since their Worker support is
questionable

There most likely is a better way to do this - please let me know
This commit is contained in:
Ivan
2026-05-27 21:51:03 +02:00
committed by GitHub
parent ad8b674784
commit 921206dc1d
34 changed files with 1955 additions and 142 deletions
+11
View File
@@ -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()
+78 -3
View File
@@ -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.
+7 -3
View File
@@ -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.
+1
View File
@@ -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
+10
View File
@@ -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
+12
View File
@@ -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;
}
}
/**
+30
View File
@@ -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.
+1
View File
@@ -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" } },
+205
View File
@@ -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 `<script>` tag
If you ship `shaka-player.compiled.js` from `/static/`, copy the worker
alongside it and point Shaka at the same directory:
```html
<script src="/static/shaka-player.compiled.js"></script>
<script>
const player = new shaka.Player();
player.configure(
'mediaSource.transmuxWorkerUrl',
'/static/shaka-player.transmuxer-worker.js');
</script>
```
##### 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: <your page 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}.
+15 -1
View File
@@ -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.
* <br>
* Defaults to <code>true</code>.
* @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.
* <br>
* The library does not auto-detect this URL; the integrating application
* is responsible for serving the worker script (e.g.,
* <code>shaka-player.transmuxer-worker.js</code> from <code>dist/</code>)
* and providing the URL here. Falls back to main-thread transmuxing if the
* worker fails to load or the device does not support Workers.
* <br>
* Defaults to <code>''</code> (worker disabled).
* @exportDoc
*/
shaka.extern.MediaSourceConfiguration;
+3
View File
@@ -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},
+3 -1
View File
@@ -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.Level, function(...*)>} */
shaka.log.logMap_ = new Map()
.set(shaka.log.Level.ERROR, (...args) => console.error(...args))
+7
View File
@@ -382,6 +382,13 @@ shaka.device.AbstractDevice = class {
return null;
}
/**
* @override
*/
supportsWorkerTransmux() {
return typeof Worker !== 'undefined';
}
/**
* @override
*/
+7
View File
@@ -91,6 +91,13 @@ shaka.device.Hisense = class extends shaka.device.AbstractDevice {
return config;
}
/**
* @override
*/
supportsWorkerTransmux() {
return false;
}
/**
* @return {boolean}
* @private
+9
View File
@@ -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() {}
};
/**
+12
View File
@@ -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
+12
View File
@@ -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
+5 -2
View File
@@ -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
+8 -6
View File
@@ -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;
}
+4 -5
View File
@@ -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}"`;
}
+49 -14
View File
@@ -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<number>}
*/
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,
];
+416
View File
@@ -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<number, {resolve: function(*), reject: function(*),
* timer: shaka.util.Timer}>}
*/
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<number, !shaka.transmuxer.TransmuxerProxy>}
*/
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();
};
+267
View File
@@ -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<number, !shaka.extern.Transmuxer>}
*/
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();
+4 -5
View File
@@ -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}"`;
}
+86
View File
@@ -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.
+1
View File
@@ -386,6 +386,7 @@ shaka.util.PlayerConfiguration = class {
dispatchAllEmsgBoxes: false,
useSourceElements: true,
durationReductionEmitsUpdateEnd: true,
transmuxWorkerUrl: '',
};
const ads = {
+3 -89
View File
@@ -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.
+2 -2
View File
@@ -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));
+2 -2
View File
@@ -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;
}
@@ -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,
+33 -6
View File
@@ -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();
});
});
+2 -3
View File
@@ -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()) {
+580
View File
@@ -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_;
}
});
+66
View File
@@ -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.