mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-13 15:46:46 +03:00
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:
@@ -231,6 +231,16 @@ def main(args):
|
|||||||
for mode in modes:
|
for mode in modes:
|
||||||
tasks = []
|
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 lang_out, suffix in language_variants:
|
||||||
for build_args in builds:
|
for build_args in builds:
|
||||||
args = list(build_args)
|
args = list(build_args)
|
||||||
@@ -242,6 +252,7 @@ def main(args):
|
|||||||
# Add language and mode flags.
|
# Add language and mode flags.
|
||||||
args += ['--langout', lang_out]
|
args += ['--langout', lang_out]
|
||||||
args += ['--mode', mode]
|
args += ['--mode', mode]
|
||||||
|
args += ['--skip-worker']
|
||||||
|
|
||||||
# Prepare environment and command for a separate process.
|
# Prepare environment and command for a separate process.
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
|
|||||||
+78
-3
@@ -192,6 +192,13 @@ class Build(object):
|
|||||||
return True
|
return True
|
||||||
return False
|
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):
|
def generate_localizations(self, locales, force):
|
||||||
localizations = compiler.GenerateLocalizations(locales)
|
localizations = compiler.GenerateLocalizations(locales)
|
||||||
localizations.generate(force)
|
localizations.generate(force)
|
||||||
@@ -265,7 +272,56 @@ class Build(object):
|
|||||||
|
|
||||||
return True
|
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|.
|
"""Builds Shaka Player using the files in |self.include|.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -275,6 +331,7 @@ class Build(object):
|
|||||||
force: True to rebuild, False to ignore if no changes are detected.
|
force: True to rebuild, False to ignore if no changes are detected.
|
||||||
is_debug: True to compile for debugging, false for release.
|
is_debug: True to compile for debugging, false for release.
|
||||||
skip_ts: True to skip generation of TypeScript definitions.
|
skip_ts: True to skip generation of TypeScript definitions.
|
||||||
|
build_worker: True to build the standalone transmuxer worker if needed.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True on success; False on failure.
|
True on success; False on failure.
|
||||||
@@ -289,6 +346,11 @@ class Build(object):
|
|||||||
if not self.has_cast():
|
if not self.has_cast():
|
||||||
self.include.add(os.path.abspath('conditional/dummy_cast_proxy.js'))
|
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:
|
if is_debug:
|
||||||
name += '.debug'
|
name += '.debug'
|
||||||
|
|
||||||
@@ -385,6 +447,16 @@ def main(args):
|
|||||||
help='Skips generation of TypeScript definition files (.d.ts).',
|
help='Skips generation of TypeScript definition files (.d.ts).',
|
||||||
action='store_true')
|
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)
|
parsed_args, commands = parser.parse_known_args(args)
|
||||||
|
|
||||||
# Make the dist/ folder, ignore errors.
|
# Make the dist/ folder, ignore errors.
|
||||||
@@ -501,8 +573,11 @@ def main(args):
|
|||||||
is_debug = parsed_args.mode == 'debug'
|
is_debug = parsed_args.mode == 'debug'
|
||||||
skip_ts = parsed_args.skip_ts
|
skip_ts = parsed_args.skip_ts
|
||||||
|
|
||||||
if not custom_build.build_library(name, langout, locales, force, is_debug,
|
if parsed_args.worker:
|
||||||
skip_ts):
|
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
|
return 1
|
||||||
|
|
||||||
# Persist (merge) the updated state under lock so we don't clobber parallel updates.
|
# Persist (merge) the updated state under lock so we don't clobber parallel updates.
|
||||||
|
|||||||
+7
-3
@@ -17,7 +17,7 @@
|
|||||||
"""This is used to validate that the library is correct.
|
"""This is used to validate that the library is correct.
|
||||||
|
|
||||||
This checks:
|
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
|
* Runs a compiler pass over the test code to check for type errors
|
||||||
* Run the linter to check for style violations.
|
* 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
|
# 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
|
# object directly, we need to include it here. When using main(), it will
|
||||||
# call addCore which will ensure core is included.
|
# 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')
|
logging.error('Error parsing complete build')
|
||||||
return False
|
return False
|
||||||
return complete.include
|
return complete.include
|
||||||
@@ -120,7 +124,7 @@ def check_html_lint(args):
|
|||||||
|
|
||||||
@_Check('complete')
|
@_Check('complete')
|
||||||
def 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
|
This is used by the build script to ensure that every file is included in at
|
||||||
least one build type.
|
least one build type.
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
+../../lib/text/web_vtt_generator.js
|
+../../lib/text/web_vtt_generator.js
|
||||||
|
|
||||||
+../../lib/transmuxer/transmuxer_engine.js
|
+../../lib/transmuxer/transmuxer_engine.js
|
||||||
|
+../../lib/transmuxer/transmuxer_proxy.js
|
||||||
+../../lib/transmuxer/transmuxer_utils.js
|
+../../lib/transmuxer/transmuxer_utils.js
|
||||||
|
|
||||||
+../../lib/util/abortable_operation.js
|
+../../lib/util/abortable_operation.js
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -806,6 +806,18 @@ shakaDemo.Config = class {
|
|||||||
'mediaSource.useSourceElements')
|
'mediaSource.useSourceElements')
|
||||||
.addBoolInput_('Expect updateEnd when duration is truncated',
|
.addBoolInput_('Expect updateEnd when duration is truncated',
|
||||||
'mediaSource.durationReductionEmitsUpdateEnd');
|
'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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -429,6 +429,13 @@ shakaDemo.Main = class {
|
|||||||
this.player_.configure(
|
this.player_.configure(
|
||||||
'manifest.dash.clockSyncUri', 'https://time.akamai.com/?ms&iso');
|
'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.
|
// Get default config.
|
||||||
this.defaultConfig_ = this.player_.getConfiguration();
|
this.defaultConfig_ = this.player_.getConfiguration();
|
||||||
this.desiredConfig_ = this.player_.getConfiguration();
|
this.desiredConfig_ = this.player_.getConfiguration();
|
||||||
@@ -1228,6 +1235,29 @@ shakaDemo.Main = class {
|
|||||||
return params;
|
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
|
* Recovers the value from the given config field, from an arbitrary config
|
||||||
* object.
|
* object.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
{ "license-wrapping": { "title": "License Wrapping" } },
|
{ "license-wrapping": { "title": "License Wrapping" } },
|
||||||
{ "moq": { "title": "MoQ" } },
|
{ "moq": { "title": "MoQ" } },
|
||||||
{ "preload": { "title": "Preloading" } },
|
{ "preload": { "title": "Preloading" } },
|
||||||
|
{ "transmuxing-in-worker": { "title": "Transmuxing in Worker" } },
|
||||||
{ "ui": { "title": "UI Library" } },
|
{ "ui": { "title": "UI Library" } },
|
||||||
{ "ui-customization": { "title": "Configuring the UI" } },
|
{ "ui-customization": { "title": "Configuring the UI" } },
|
||||||
{ "text-displayer": { "title": "Configuring text displayer" } },
|
{ "text-displayer": { "title": "Configuring text displayer" } },
|
||||||
|
|||||||
@@ -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
@@ -2357,7 +2357,8 @@ shaka.extern.NetworkingConfiguration;
|
|||||||
* modifyCueCallback: shaka.extern.TextParser.ModifyCueCallback,
|
* modifyCueCallback: shaka.extern.TextParser.ModifyCueCallback,
|
||||||
* dispatchAllEmsgBoxes: boolean,
|
* dispatchAllEmsgBoxes: boolean,
|
||||||
* useSourceElements: boolean,
|
* useSourceElements: boolean,
|
||||||
* durationReductionEmitsUpdateEnd: boolean
|
* durationReductionEmitsUpdateEnd: boolean,
|
||||||
|
* transmuxWorkerUrl: string
|
||||||
* }}
|
* }}
|
||||||
*
|
*
|
||||||
* @description
|
* @description
|
||||||
@@ -2425,6 +2426,19 @@ shaka.extern.NetworkingConfiguration;
|
|||||||
* smaller than existing value.
|
* smaller than existing value.
|
||||||
* <br>
|
* <br>
|
||||||
* Defaults to <code>true</code>.
|
* 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
|
* @exportDoc
|
||||||
*/
|
*/
|
||||||
shaka.extern.MediaSourceConfiguration;
|
shaka.extern.MediaSourceConfiguration;
|
||||||
|
|||||||
@@ -309,6 +309,9 @@ module.exports = (config) => {
|
|||||||
{pattern: 'test/test/assets/lcevc-sei-ts/*', included: false},
|
{pattern: 'test/test/assets/lcevc-sei-ts/*', included: false},
|
||||||
{pattern: 'test/test/assets/lcevc-dual-track/*', included: false},
|
{pattern: 'test/test/assets/lcevc-dual-track/*', included: false},
|
||||||
{pattern: 'dist/shaka-player.experimental.js', 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: 'dist/locales.js', included: false},
|
||||||
{pattern: 'demo/**/*.js', included: false},
|
{pattern: 'demo/**/*.js', included: false},
|
||||||
{pattern: 'demo/locales/en.json', included: false},
|
{pattern: 'demo/locales/en.json', included: false},
|
||||||
|
|||||||
+3
-1
@@ -134,7 +134,9 @@ shaka.log.MAX_LOG_LEVEL = 3;
|
|||||||
shaka.log.oneTimeWarningIssued_ = new Set();
|
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(...*)>} */
|
/** @private {!Map<shaka.log.Level, function(...*)>} */
|
||||||
shaka.log.logMap_ = new Map()
|
shaka.log.logMap_ = new Map()
|
||||||
.set(shaka.log.Level.ERROR, (...args) => console.error(...args))
|
.set(shaka.log.Level.ERROR, (...args) => console.error(...args))
|
||||||
|
|||||||
@@ -382,6 +382,13 @@ shaka.device.AbstractDevice = class {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
supportsWorkerTransmux() {
|
||||||
|
return typeof Worker !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @override
|
* @override
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ shaka.device.Hisense = class extends shaka.device.AbstractDevice {
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
supportsWorkerTransmux() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return {boolean}
|
* @return {boolean}
|
||||||
* @private
|
* @private
|
||||||
|
|||||||
@@ -262,6 +262,15 @@ shaka.device.IDevice = class {
|
|||||||
* @return {?RemotePlayback}
|
* @return {?RemotePlayback}
|
||||||
*/
|
*/
|
||||||
getRemote(video) {}
|
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() {}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -206,6 +206,18 @@ shaka.device.Tizen = class extends shaka.device.AbstractDevice {
|
|||||||
return this.getVersion() === 3;
|
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}
|
* @return {boolean}
|
||||||
* @private
|
* @private
|
||||||
|
|||||||
@@ -214,6 +214,18 @@ shaka.device.WebOS = class extends shaka.device.AbstractDevice {
|
|||||||
version >= 6 : super.supportsCbcsWithoutEncryptionSchemeSupport();
|
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}
|
* @return {boolean}
|
||||||
* @private
|
* @private
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ shaka.media.Capabilities = class {
|
|||||||
static isTypeSupported(type) {
|
static isTypeSupported(type) {
|
||||||
const supportMap = shaka.media.Capabilities.MediaSourceTypeSupportMap;
|
const supportMap = shaka.media.Capabilities.MediaSourceTypeSupportMap;
|
||||||
return supportMap.getOrInsertComputed(type, () => {
|
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;
|
return mediaSource?.isTypeSupported(type) ?? false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -34,7 +36,8 @@ shaka.media.Capabilities = class {
|
|||||||
* @return {boolean}
|
* @return {boolean}
|
||||||
*/
|
*/
|
||||||
static isInfiniteLiveStreamDurationSupported() {
|
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
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
if (mediaSource && mediaSource.prototype) {
|
if (mediaSource && mediaSource.prototype) {
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ goog.require('shaka.media.SegmentReference');
|
|||||||
goog.require('shaka.media.TimeRangesUtils');
|
goog.require('shaka.media.TimeRangesUtils');
|
||||||
goog.require('shaka.text.TextEngine');
|
goog.require('shaka.text.TextEngine');
|
||||||
goog.require('shaka.transmuxer.TransmuxerEngine');
|
goog.require('shaka.transmuxer.TransmuxerEngine');
|
||||||
|
goog.require('shaka.transmuxer.TransmuxerProxy');
|
||||||
goog.require('shaka.util.ArrayUtils');
|
goog.require('shaka.util.ArrayUtils');
|
||||||
goog.require('shaka.util.BufferUtils');
|
goog.require('shaka.util.BufferUtils');
|
||||||
goog.require('shaka.util.Destroyer');
|
goog.require('shaka.util.Destroyer');
|
||||||
@@ -33,7 +34,6 @@ goog.require('shaka.util.MimeUtils');
|
|||||||
goog.require('shaka.util.Mp4BoxParsers');
|
goog.require('shaka.util.Mp4BoxParsers');
|
||||||
goog.require('shaka.util.Mp4Parser');
|
goog.require('shaka.util.Mp4Parser');
|
||||||
goog.require('shaka.util.NumberUtils');
|
goog.require('shaka.util.NumberUtils');
|
||||||
goog.require('shaka.util.StreamUtils');
|
|
||||||
goog.require('shaka.util.TimeUtils');
|
goog.require('shaka.util.TimeUtils');
|
||||||
goog.require('shaka.util.TsParser');
|
goog.require('shaka.util.TsParser');
|
||||||
goog.require('shaka.lcevc.Dec');
|
goog.require('shaka.lcevc.Dec');
|
||||||
@@ -388,7 +388,7 @@ shaka.media.MediaSourceEngine = class {
|
|||||||
support[type] = true;
|
support[type] = true;
|
||||||
} else if (device.supportsMediaSource()) {
|
} else if (device.supportsMediaSource()) {
|
||||||
const baseMimeType = MimeUtils.getBasicType(type);
|
const baseMimeType = MimeUtils.getBasicType(type);
|
||||||
const codecs = shaka.util.StreamUtils.getCorrectAudioCodecs(
|
const codecs = MimeUtils.getCorrectAudioCodecs(
|
||||||
MimeUtils.getCodecs(type), baseMimeType);
|
MimeUtils.getCodecs(type), baseMimeType);
|
||||||
const newType = MimeUtils.getFullType(baseMimeType, codecs);
|
const newType = MimeUtils.getFullType(baseMimeType, codecs);
|
||||||
support[type] = shaka.media.Capabilities.isTypeSupported(newType) ||
|
support[type] = shaka.media.Capabilities.isTypeSupported(newType) ||
|
||||||
@@ -574,13 +574,13 @@ shaka.media.MediaSourceEngine = class {
|
|||||||
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
||||||
|
|
||||||
if (contentType == ContentType.AUDIO && codecs) {
|
if (contentType == ContentType.AUDIO && codecs) {
|
||||||
codecs = shaka.util.StreamUtils.getCorrectAudioCodecs(
|
codecs = shaka.util.MimeUtils.getCorrectAudioCodecs(
|
||||||
codecs, stream.mimeType);
|
codecs, stream.mimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentType == ContentType.VIDEO && codecs &&
|
if (contentType == ContentType.VIDEO && codecs &&
|
||||||
stream.mimeType == 'video/mp4') {
|
stream.mimeType == 'video/mp4') {
|
||||||
codecs = shaka.util.StreamUtils.getCorrectVideoCodecs(codecs);
|
codecs = shaka.util.MimeUtils.getCorrectVideoCodecs(codecs);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mimeType = shaka.util.MimeUtils.getFullType(
|
let mimeType = shaka.util.MimeUtils.getFullType(
|
||||||
@@ -611,7 +611,8 @@ shaka.media.MediaSourceEngine = class {
|
|||||||
const transmuxerPlugin = shaka.transmuxer.TransmuxerEngine
|
const transmuxerPlugin = shaka.transmuxer.TransmuxerEngine
|
||||||
.findTransmuxer(mimeTypeWithAllCodecs);
|
.findTransmuxer(mimeTypeWithAllCodecs);
|
||||||
if (transmuxerPlugin) {
|
if (transmuxerPlugin) {
|
||||||
const transmuxer = transmuxerPlugin();
|
const transmuxer = new shaka.transmuxer.TransmuxerProxy(
|
||||||
|
transmuxerPlugin(), this.config_.transmuxWorkerUrl);
|
||||||
this.transmuxers_.set(contentType, transmuxer);
|
this.transmuxers_.set(contentType, transmuxer);
|
||||||
mimeType =
|
mimeType =
|
||||||
transmuxer.convertCodecs(contentType, mimeTypeWithAllCodecs);
|
transmuxer.convertCodecs(contentType, mimeTypeWithAllCodecs);
|
||||||
@@ -2505,7 +2506,8 @@ shaka.media.MediaSourceEngine = class {
|
|||||||
const transmuxerPlugin =
|
const transmuxerPlugin =
|
||||||
TransmuxerEngine.findTransmuxer(newMimeTypeWithAllCodecs);
|
TransmuxerEngine.findTransmuxer(newMimeTypeWithAllCodecs);
|
||||||
if (transmuxerPlugin) {
|
if (transmuxerPlugin) {
|
||||||
transmuxer = transmuxerPlugin();
|
transmuxer = new shaka.transmuxer.TransmuxerProxy(
|
||||||
|
transmuxerPlugin(), this.config_.transmuxWorkerUrl);
|
||||||
if (audioCodec && videoCodec) {
|
if (audioCodec && videoCodec) {
|
||||||
transmuxerMuxed = true;
|
transmuxerMuxed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ goog.require('shaka.util.BufferUtils');
|
|||||||
goog.require('shaka.util.ManifestParserUtils');
|
goog.require('shaka.util.ManifestParserUtils');
|
||||||
goog.require('shaka.util.MimeUtils');
|
goog.require('shaka.util.MimeUtils');
|
||||||
goog.require('shaka.util.Mp4Generator');
|
goog.require('shaka.util.Mp4Generator');
|
||||||
goog.require('shaka.util.StreamUtils');
|
|
||||||
|
|
||||||
goog.requireType('shaka.media.SegmentReference');
|
goog.requireType('shaka.media.SegmentReference');
|
||||||
|
|
||||||
@@ -146,12 +145,12 @@ shaka.transmuxer.LocTransmuxer = class {
|
|||||||
convertCodecs(contentType, mimeType) {
|
convertCodecs(contentType, mimeType) {
|
||||||
if (this.isLocContainer_(mimeType)) {
|
if (this.isLocContainer_(mimeType)) {
|
||||||
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
||||||
const StreamUtils = shaka.util.StreamUtils;
|
const MimeUtils = shaka.util.MimeUtils;
|
||||||
const codecs = shaka.util.MimeUtils.getCodecs(mimeType).split(',')
|
const codecs = MimeUtils.getCodecs(mimeType).split(',')
|
||||||
.map((codecs) => {
|
.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) {
|
if (contentType == ContentType.AUDIO) {
|
||||||
return `audio/mp4; codecs="${codecs}"`;
|
return `audio/mp4; codecs="${codecs}"`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,24 +58,45 @@ shaka.transmuxer.TransmuxerEngine = class {
|
|||||||
* @export
|
* @export
|
||||||
*/
|
*/
|
||||||
static findTransmuxer(mimeType, contentType) {
|
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 TransmuxerEngine = shaka.transmuxer.TransmuxerEngine;
|
||||||
const normalizedMimetype = TransmuxerEngine.normalizeMimeType_(mimeType);
|
const normalizedMimetype = TransmuxerEngine.normalizeMimeType_(mimeType);
|
||||||
const priorities = [
|
const priorities = priority !== null ?
|
||||||
TransmuxerEngine.PluginPriority.APPLICATION,
|
[priority] : TransmuxerEngine.PLUGIN_PRIORITY_ORDER;
|
||||||
TransmuxerEngine.PluginPriority.PREFERRED,
|
for (const p of priorities) {
|
||||||
TransmuxerEngine.PluginPriority.PREFERRED_SECONDARY,
|
const key = normalizedMimetype + '-' + p;
|
||||||
TransmuxerEngine.PluginPriority.FALLBACK,
|
|
||||||
];
|
|
||||||
for (const priority of priorities) {
|
|
||||||
const key = normalizedMimetype + '-' + priority;
|
|
||||||
const object = TransmuxerEngine.transmuxerMap_.get(key);
|
const object = TransmuxerEngine.transmuxerMap_.get(key);
|
||||||
if (object) {
|
if (object) {
|
||||||
const transmuxer = object.plugin();
|
return object.plugin;
|
||||||
const isSupported = transmuxer.isSupported(mimeType, contentType);
|
|
||||||
transmuxer.destroy();
|
|
||||||
if (isSupported) {
|
|
||||||
return object.plugin;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -159,3 +180,17 @@ shaka.transmuxer.TransmuxerEngine.PluginPriority = {
|
|||||||
'APPLICATION': 4,
|
'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,
|
||||||
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -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();
|
||||||
@@ -23,7 +23,6 @@ goog.require('shaka.util.Id3Utils');
|
|||||||
goog.require('shaka.util.ManifestParserUtils');
|
goog.require('shaka.util.ManifestParserUtils');
|
||||||
goog.require('shaka.util.MimeUtils');
|
goog.require('shaka.util.MimeUtils');
|
||||||
goog.require('shaka.util.Mp4Generator');
|
goog.require('shaka.util.Mp4Generator');
|
||||||
goog.require('shaka.util.StreamUtils');
|
|
||||||
goog.require('shaka.util.TsParser');
|
goog.require('shaka.util.TsParser');
|
||||||
goog.require('shaka.util.Uint8ArrayUtils');
|
goog.require('shaka.util.Uint8ArrayUtils');
|
||||||
|
|
||||||
@@ -148,17 +147,17 @@ shaka.transmuxer.TsTransmuxer = class {
|
|||||||
convertCodecs(contentType, mimeType) {
|
convertCodecs(contentType, mimeType) {
|
||||||
if (this.isTsContainer_(mimeType)) {
|
if (this.isTsContainer_(mimeType)) {
|
||||||
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
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
|
// The replace it's necessary because Firefox(the only browser that
|
||||||
// supports MP3 in MP4) only support the MP3 codec with the mp3 string.
|
// 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="mp4a.40.34"') -> false
|
||||||
// MediaSource.isTypeSupported('audio/mp4; codecs="mp3"') -> true
|
// 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(',')
|
.replace('mp4a.40.34', 'mp3').split(',')
|
||||||
.map((codecs) => {
|
.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) {
|
if (contentType == ContentType.AUDIO) {
|
||||||
return `audio/mp4; codecs="${codecs}"`;
|
return `audio/mp4; codecs="${codecs}"`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
goog.provide('shaka.util.MimeUtils');
|
goog.provide('shaka.util.MimeUtils');
|
||||||
|
|
||||||
|
goog.require('shaka.device.DeviceFactory');
|
||||||
|
goog.require('shaka.device.IDevice');
|
||||||
goog.require('shaka.transmuxer.TransmuxerEngine');
|
goog.require('shaka.transmuxer.TransmuxerEngine');
|
||||||
goog.require('shaka.util.ManifestParserUtils');
|
goog.require('shaka.util.ManifestParserUtils');
|
||||||
|
|
||||||
@@ -275,6 +277,90 @@ shaka.util.MimeUtils = class {
|
|||||||
mimeType === 'video/vnd.mpeg.dash.mpd';
|
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
|
* Get the base and profile of a codec string. Where [0] will be the codec
|
||||||
* base and [1] will be the profile.
|
* base and [1] will be the profile.
|
||||||
|
|||||||
@@ -386,6 +386,7 @@ shaka.util.PlayerConfiguration = class {
|
|||||||
dispatchAllEmsgBoxes: false,
|
dispatchAllEmsgBoxes: false,
|
||||||
useSourceElements: true,
|
useSourceElements: true,
|
||||||
durationReductionEmitsUpdateEnd: true,
|
durationReductionEmitsUpdateEnd: true,
|
||||||
|
transmuxWorkerUrl: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ads = {
|
const ads = {
|
||||||
|
|||||||
@@ -778,7 +778,6 @@ shaka.util.StreamUtils = class {
|
|||||||
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
||||||
const ManifestParserUtils = shaka.util.ManifestParserUtils;
|
const ManifestParserUtils = shaka.util.ManifestParserUtils;
|
||||||
const MimeUtils = shaka.util.MimeUtils;
|
const MimeUtils = shaka.util.MimeUtils;
|
||||||
const StreamUtils = shaka.util.StreamUtils;
|
|
||||||
|
|
||||||
const videoConfigs = [];
|
const videoConfigs = [];
|
||||||
const audioConfigs = [];
|
const audioConfigs = [];
|
||||||
@@ -798,7 +797,7 @@ shaka.util.StreamUtils = class {
|
|||||||
|
|
||||||
let audioCodecs = ManifestParserUtils.guessCodecs(
|
let audioCodecs = ManifestParserUtils.guessCodecs(
|
||||||
ContentType.AUDIO, allCodecs);
|
ContentType.AUDIO, allCodecs);
|
||||||
audioCodecs = StreamUtils.getCorrectAudioCodecs(
|
audioCodecs = MimeUtils.getCorrectAudioCodecs(
|
||||||
audioCodecs, baseMimeType);
|
audioCodecs, baseMimeType);
|
||||||
|
|
||||||
const audioFullType = MimeUtils.getFullOrConvertedType(
|
const audioFullType = MimeUtils.getFullOrConvertedType(
|
||||||
@@ -813,7 +812,7 @@ shaka.util.StreamUtils = class {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
videoCodecs = StreamUtils.getCorrectVideoCodecs(videoCodecs);
|
videoCodecs = MimeUtils.getCorrectVideoCodecs(videoCodecs);
|
||||||
const fullType = MimeUtils.getFullOrConvertedType(
|
const fullType = MimeUtils.getFullOrConvertedType(
|
||||||
MimeUtils.getBasicType(fullMimeType), videoCodecs,
|
MimeUtils.getBasicType(fullMimeType), videoCodecs,
|
||||||
ContentType.VIDEO);
|
ContentType.VIDEO);
|
||||||
@@ -855,7 +854,7 @@ shaka.util.StreamUtils = class {
|
|||||||
if (audio) {
|
if (audio) {
|
||||||
for (const fullMimeType of audio.fullMimeTypes) {
|
for (const fullMimeType of audio.fullMimeTypes) {
|
||||||
const baseMimeType = MimeUtils.getBasicType(fullMimeType);
|
const baseMimeType = MimeUtils.getBasicType(fullMimeType);
|
||||||
const codecs = StreamUtils.getCorrectAudioCodecs(
|
const codecs = MimeUtils.getCorrectAudioCodecs(
|
||||||
MimeUtils.getCodecs(fullMimeType), baseMimeType);
|
MimeUtils.getCodecs(fullMimeType), baseMimeType);
|
||||||
const fullType = MimeUtils.getFullOrConvertedType(
|
const fullType = MimeUtils.getFullOrConvertedType(
|
||||||
baseMimeType, codecs, ContentType.AUDIO);
|
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
|
* Alters the given Manifest to filter out any streams incompatible with the
|
||||||
* current variant.
|
* current variant.
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ shaka.util.StringUtils = class {
|
|||||||
uint8 = uint8.subarray(3);
|
uint8 = uint8.subarray(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.TextDecoder && !shaka.device.DeviceFactory.getDevice()
|
if (self.TextDecoder && !shaka.device.DeviceFactory.getDevice()
|
||||||
.shouldAvoidUseTextDecoderEncoder()) {
|
.shouldAvoidUseTextDecoderEncoder()) {
|
||||||
// Use the TextDecoder interface to decode the text. This has the
|
// Use the TextDecoder interface to decode the text. This has the
|
||||||
// advantage compared to the previously-standard decodeUriComponent that
|
// advantage compared to the previously-standard decodeUriComponent that
|
||||||
@@ -209,7 +209,7 @@ shaka.util.StringUtils = class {
|
|||||||
* @export
|
* @export
|
||||||
*/
|
*/
|
||||||
static toUTF8(str) {
|
static toUTF8(str) {
|
||||||
if (window.TextEncoder && !shaka.device.DeviceFactory.getDevice()
|
if (self.TextEncoder && !shaka.device.DeviceFactory.getDevice()
|
||||||
.shouldAvoidUseTextDecoderEncoder()) {
|
.shouldAvoidUseTextDecoderEncoder()) {
|
||||||
const utf8Encoder = new TextEncoder();
|
const utf8Encoder = new TextEncoder();
|
||||||
return shaka.util.BufferUtils.toArrayBuffer(utf8Encoder.encode(str));
|
return shaka.util.BufferUtils.toArrayBuffer(utf8Encoder.encode(str));
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ shaka.util.Uint8ArrayUtils = class {
|
|||||||
if (!('fromBase64' in Uint8Array)) {
|
if (!('fromBase64' in Uint8Array)) {
|
||||||
// atob creates a "raw string" where each character is interpreted as a
|
// atob creates a "raw string" where each character is interpreted as a
|
||||||
// byte.
|
// 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);
|
const result = new Uint8Array(bytes.length);
|
||||||
for (let i = 0; i < bytes.length; ++i) {
|
for (let i = 0; i < bytes.length; ++i) {
|
||||||
result[i] = bytes.charCodeAt(i);
|
result[i] = bytes.charCodeAt(i);
|
||||||
@@ -91,7 +91,7 @@ shaka.util.Uint8ArrayUtils = class {
|
|||||||
const size = str.length / 2;
|
const size = str.length / 2;
|
||||||
const arr = new Uint8Array(size);
|
const arr = new Uint8Array(size);
|
||||||
for (let i = 0; i < size; i++) {
|
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;
|
return arr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,10 @@ describe('MediaSourceEngine', () => {
|
|||||||
onEvent = jasmine.createSpy('onEvent');
|
onEvent = jasmine.createSpy('onEvent');
|
||||||
onManifestUpdate = jasmine.createSpy('onManifestUpdate');
|
onManifestUpdate = jasmine.createSpy('onManifestUpdate');
|
||||||
const config = shaka.util.PlayerConfiguration.createDefault().mediaSource;
|
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(
|
mediaSourceEngine = new shaka.media.MediaSourceEngine(
|
||||||
video,
|
video,
|
||||||
|
|||||||
@@ -55,11 +55,9 @@ describe('MediaSourceEngine', () => {
|
|||||||
const originalIsSupported =
|
const originalIsSupported =
|
||||||
shaka.transmuxer.TransmuxerEngine.isSupported;
|
shaka.transmuxer.TransmuxerEngine.isSupported;
|
||||||
|
|
||||||
// Jasmine Spies don't handle toHaveBeenCalledWith well with objects, so use
|
const buffer = new Uint8Array([0x01]);
|
||||||
// some numbers instead.
|
const buffer2 = new Uint8Array([0x02]);
|
||||||
const buffer = /** @type {!ArrayBuffer} */ (/** @type {?} */ (1));
|
const buffer3 = new Uint8Array([0x03]);
|
||||||
const buffer2 = /** @type {!ArrayBuffer} */ (/** @type {?} */ (2));
|
|
||||||
const buffer3 = /** @type {!ArrayBuffer} */ (/** @type {?} */ (3));
|
|
||||||
|
|
||||||
const makeFakeStream = (mimeType) => {
|
const makeFakeStream = (mimeType) => {
|
||||||
const segmentIndex = {
|
const segmentIndex = {
|
||||||
@@ -252,6 +250,10 @@ describe('MediaSourceEngine', () => {
|
|||||||
mockClosedCaptionParser = new shaka.test.FakeClosedCaptionParser();
|
mockClosedCaptionParser = new shaka.test.FakeClosedCaptionParser();
|
||||||
mockTextDisplayer = new shaka.test.FakeTextDisplayer();
|
mockTextDisplayer = new shaka.test.FakeTextDisplayer();
|
||||||
const config = shaka.util.PlayerConfiguration.createDefault().mediaSource;
|
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(
|
mediaSourceEngine = new shaka.media.MediaSourceEngine(
|
||||||
video,
|
video,
|
||||||
@@ -452,6 +454,31 @@ describe('MediaSourceEngine', () => {
|
|||||||
expect(mockMediaSource.addSourceBuffer).not.toHaveBeenCalled();
|
expect(mockMediaSource.addSourceBuffer).not.toHaveBeenCalled();
|
||||||
expect(shaka.text.TextEngine).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', () => {
|
describe('bufferStart and bufferEnd', () => {
|
||||||
@@ -1269,7 +1296,7 @@ describe('MediaSourceEngine', () => {
|
|||||||
await expectAsync(p1).toBeRejected();
|
await expectAsync(p1).toBeRejected();
|
||||||
expect(mockMediaSource.endOfStream).toHaveBeenCalled();
|
expect(mockMediaSource.endOfStream).toHaveBeenCalled();
|
||||||
await Util.shortDelay();
|
await Util.shortDelay();
|
||||||
expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(1);
|
expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
|
||||||
audioSourceBuffer.updateend();
|
audioSourceBuffer.updateend();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -379,7 +379,6 @@ shaka.test.Util = class {
|
|||||||
static async isTypeSupported(mimetype, width, height) {
|
static async isTypeSupported(mimetype, width, height) {
|
||||||
const MimeUtils = shaka.util.MimeUtils;
|
const MimeUtils = shaka.util.MimeUtils;
|
||||||
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
||||||
const StreamUtils = shaka.util.StreamUtils;
|
|
||||||
|
|
||||||
/** @type {!MediaDecodingConfiguration} */
|
/** @type {!MediaDecodingConfiguration} */
|
||||||
const mediaDecodingConfig = {
|
const mediaDecodingConfig = {
|
||||||
@@ -387,7 +386,7 @@ shaka.test.Util = class {
|
|||||||
};
|
};
|
||||||
if (mimetype.startsWith('audio')) {
|
if (mimetype.startsWith('audio')) {
|
||||||
const baseMimeType = MimeUtils.getBasicType(mimetype);
|
const baseMimeType = MimeUtils.getBasicType(mimetype);
|
||||||
const codecs = StreamUtils.getCorrectAudioCodecs(
|
const codecs = MimeUtils.getCorrectAudioCodecs(
|
||||||
MimeUtils.getCodecs(mimetype), baseMimeType);
|
MimeUtils.getCodecs(mimetype), baseMimeType);
|
||||||
// AudioConfiguration
|
// AudioConfiguration
|
||||||
mediaDecodingConfig.audio = {
|
mediaDecodingConfig.audio = {
|
||||||
@@ -395,7 +394,7 @@ shaka.test.Util = class {
|
|||||||
baseMimeType, codecs, ContentType.AUDIO),
|
baseMimeType, codecs, ContentType.AUDIO),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const codecs = StreamUtils.getCorrectVideoCodecs(
|
const codecs = MimeUtils.getCorrectVideoCodecs(
|
||||||
MimeUtils.getCodecs(mimetype));
|
MimeUtils.getCodecs(mimetype));
|
||||||
const baseMimeType = MimeUtils.getBasicType(mimetype);
|
const baseMimeType = MimeUtils.getBasicType(mimetype);
|
||||||
if (codecs.startsWith('hvc1.') && deviceDetected.disableHEVCSupport()) {
|
if (codecs.startsWith('hvc1.') && deviceDetected.disableHEVCSupport()) {
|
||||||
|
|||||||
@@ -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_;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user