diff --git a/package-lock.json b/package-lock.json
index 3002d10d2f..c827acd891 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -45,6 +45,7 @@
"jquery": "3.7.1",
"jstree": "3.3.17",
"libarchive.js": "2.0.2",
+ "libpgs": "0.6.0",
"lodash-es": "4.17.21",
"markdown-it": "14.1.0",
"material-design-icons-iconfont": "6.7.0",
@@ -14913,6 +14914,12 @@
"comlink": "^4.4.1"
}
},
+ "node_modules/libpgs": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/libpgs/-/libpgs-0.6.0.tgz",
+ "integrity": "sha512-k8ic6VTJTCun8Ump8iAe+tZi3pa1ZhDlq1v4hmZOmYQzSQ44QpZoClMXuSfJ1B91eRWOO6q50rXhyCPuB9dXbg==",
+ "license": "MIT"
+ },
"node_modules/lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
@@ -36350,6 +36357,11 @@
"comlink": "^4.4.1"
}
},
+ "libpgs": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/libpgs/-/libpgs-0.6.0.tgz",
+ "integrity": "sha512-k8ic6VTJTCun8Ump8iAe+tZi3pa1ZhDlq1v4hmZOmYQzSQ44QpZoClMXuSfJ1B91eRWOO6q50rXhyCPuB9dXbg=="
+ },
"lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
diff --git a/package.json b/package.json
index 1033cf2c1e..f459b07f45 100644
--- a/package.json
+++ b/package.json
@@ -106,6 +106,7 @@
"jquery": "3.7.1",
"jstree": "3.3.17",
"libarchive.js": "2.0.2",
+ "libpgs": "0.6.0",
"lodash-es": "4.17.21",
"markdown-it": "14.1.0",
"material-design-icons-iconfont": "6.7.0",
diff --git a/src/components/subtitlesettings/subtitlesettings.js b/src/components/subtitlesettings/subtitlesettings.js
index 5e55adfd51..da548a747c 100644
--- a/src/components/subtitlesettings/subtitlesettings.js
+++ b/src/components/subtitlesettings/subtitlesettings.js
@@ -61,6 +61,9 @@ function loadForm(context, user, userSettings, appearanceSettings, apiClient) {
context.querySelector('#sliderVerticalPosition').value = appearanceSettings.verticalPosition;
context.querySelector('#selectSubtitleBurnIn').value = appSettings.get('subtitleburnin') || '';
+ context.querySelector('#chkSubtitleRenderPgs').checked = appSettings.get('subtitlerenderpgs') === 'true';
+
+ context.querySelector('#selectSubtitleBurnIn').dispatchEvent(new CustomEvent('change', {}));
onAppearanceFieldChange({
target: context.querySelector('#selectTextSize')
@@ -86,6 +89,7 @@ function save(instance, context, userId, userSettings, apiClient, enableSaveConf
loading.show();
appSettings.set('subtitleburnin', context.querySelector('#selectSubtitleBurnIn').value);
+ appSettings.set('subtitlerenderpgs', context.querySelector('#chkSubtitleRenderPgs').checked);
apiClient.getUser(userId).then(function (user) {
saveUser(context, user, userSettings, instance.appearanceKey, apiClient).then(function () {
@@ -111,6 +115,14 @@ function onSubtitleModeChange(e) {
view.querySelector('.subtitles' + this.value + 'Help').classList.remove('hide');
}
+function onSubtitleBurnInChange(e) {
+ const view = dom.parentWithClass(e.target, 'subtitlesettings');
+ const fieldRenderPgs = view.querySelector('.fldRenderPgs');
+
+ // Pgs option is only available if burn-in mode is set to 'auto' (empty string)
+ fieldRenderPgs.classList.toggle('hide', !!this.value);
+}
+
function onAppearanceFieldChange(e) {
const view = dom.parentWithClass(e.target, 'subtitlesettings');
@@ -166,6 +178,7 @@ function embed(options, self) {
options.element.querySelector('form').addEventListener('submit', self.onSubmit.bind(self));
options.element.querySelector('#selectSubtitlePlaybackMode').addEventListener('change', onSubtitleModeChange);
+ options.element.querySelector('#selectSubtitleBurnIn').addEventListener('change', onSubtitleBurnInChange);
options.element.querySelector('#selectTextSize').addEventListener('change', onAppearanceFieldChange);
options.element.querySelector('#selectTextWeight').addEventListener('change', onAppearanceFieldChange);
options.element.querySelector('#selectDropShadow').addEventListener('change', onAppearanceFieldChange);
diff --git a/src/components/subtitlesettings/subtitlesettings.template.html b/src/components/subtitlesettings/subtitlesettings.template.html
index 5f1f14ae9d..003a2a4e22 100644
--- a/src/components/subtitlesettings/subtitlesettings.template.html
+++ b/src/components/subtitlesettings/subtitlesettings.template.html
@@ -32,6 +32,14 @@
${BurnSubtitlesHelp}
+
+
+
+
${RenderPgsSubtitleHelp}
+
diff --git a/src/plugins/htmlVideoPlayer/plugin.js b/src/plugins/htmlVideoPlayer/plugin.js
index 83e247eb83..46a3062a11 100644
--- a/src/plugins/htmlVideoPlayer/plugin.js
+++ b/src/plugins/htmlVideoPlayer/plugin.js
@@ -100,7 +100,7 @@ function enableNativeTrackSupport(mediaSource, track) {
if (track) {
const format = (track.Codec || '').toLowerCase();
- if (format === 'ssa' || format === 'ass') {
+ if (format === 'ssa' || format === 'ass' || format === 'pgssub') {
return false;
}
}
@@ -213,6 +213,10 @@ export class HtmlVideoPlayer {
* @type {any | null | undefined}
*/
#currentAssRenderer;
+ /**
+ * @type {any | null | undefined}
+ */
+ #currentPgsRenderer;
/**
* @type {number | undefined}
*/
@@ -590,6 +594,9 @@ export class HtmlVideoPlayer {
if (this.#currentAssRenderer) {
this.updateCurrentTrackOffset(offsetValue);
this.#currentAssRenderer.timeOffset = (this._currentPlayOptions.transcodingOffsetTicks || 0) / 10000000 + offsetValue;
+ } else if (this.#currentPgsRenderer) {
+ this.updateCurrentTrackOffset(offsetValue);
+ this.#currentPgsRenderer.timeOffset = (this._currentPlayOptions.transcodingOffsetTicks || 0) / 10000000 + offsetValue;
} else {
const trackElements = this.getTextTracks();
// if .vtt currently rendering
@@ -1172,6 +1179,12 @@ export class HtmlVideoPlayer {
octopus.dispose();
}
this.#currentAssRenderer = null;
+
+ const pgsRenderer = this.#currentPgsRenderer;
+ if (pgsRenderer) {
+ pgsRenderer.dispose();
+ }
+ this.#currentPgsRenderer = null;
}
/**
@@ -1316,6 +1329,21 @@ export class HtmlVideoPlayer {
});
}
+ /**
+ * @private
+ */
+ renderPgs(videoElement, track, item) {
+ import('libpgs').then((libpgs) => {
+ const options = {
+ video: videoElement,
+ subUrl: getTextTrackUrl(track, item),
+ workerUrl: `${appRouter.baseUrl()}/libraries/libpgs.worker.js`,
+ timeOffset: (this._currentPlayOptions.transcodingOffsetTicks || 0) / 10000000
+ };
+ this.#currentPgsRenderer = new libpgs.PgsRenderer(options);
+ });
+ }
+
/**
* @private
*/
@@ -1434,6 +1462,10 @@ export class HtmlVideoPlayer {
this.renderSsaAss(videoElement, track, item);
return;
}
+ if (format === 'pgssub') {
+ this.renderPgs(videoElement, track, item);
+ return;
+ }
if (this.requiresCustomSubtitlesElement()) {
this.renderSubtitlesWithCustomElement(videoElement, track, item, targetTextTrackIndex);
diff --git a/src/scripts/browserDeviceProfile.js b/src/scripts/browserDeviceProfile.js
index 4a52433553..7dea81298b 100644
--- a/src/scripts/browserDeviceProfile.js
+++ b/src/scripts/browserDeviceProfile.js
@@ -48,6 +48,15 @@ function supportsTextTracks() {
return _supportsTextTracks;
}
+let _supportsCanvas2D;
+function supportsCanvas2D() {
+ if (_supportsCanvas2D == null) {
+ _supportsCanvas2D = document.createElement('canvas').getContext('2d') != null;
+ }
+
+ return _supportsCanvas2D;
+}
+
let _canPlayHls;
function canPlayHls() {
if (_canPlayHls == null) {
@@ -1424,6 +1433,7 @@ export default function (options) {
// External vtt or burn in
profile.SubtitleProfiles = [];
const subtitleBurninSetting = appSettings.get('subtitleburnin');
+ const subtitleRenderPgsSetting = appSettings.get('subtitlerenderpgs') === 'true';
if (subtitleBurninSetting !== 'all') {
if (supportsTextTracks()) {
profile.SubtitleProfiles.push({
@@ -1441,6 +1451,14 @@ export default function (options) {
Method: 'External'
});
}
+
+ if (supportsCanvas2D() && options.enablePgsRender !== false && !options.isRetry && subtitleRenderPgsSetting
+ && subtitleBurninSetting !== 'allcomplexformats' && subtitleBurninSetting !== 'onlyimageformats') {
+ profile.SubtitleProfiles.push({
+ Format: 'pgssub',
+ Method: 'External'
+ });
+ }
}
profile.ResponseProfiles = [];
diff --git a/src/strings/en-us.json b/src/strings/en-us.json
index 7c50de723f..a43a7bd8cc 100644
--- a/src/strings/en-us.json
+++ b/src/strings/en-us.json
@@ -1393,6 +1393,8 @@
"Remixer": "Remixer",
"RemoveFromCollection": "Remove from collection",
"RemoveFromPlaylist": "Remove from playlist",
+ "RenderPgsSubtitle": "Experimental PGS subtitle rendering",
+ "RenderPgsSubtitleHelp": "Determine if the client should render PGS subtitles instead of using burned in subtitles. This can avoid server-side transcoding in exchange of client-side rendering performance.",
"Repeat": "Repeat",
"RepeatAll": "Repeat all",
"RepeatEpisodes": "Repeat episodes",
diff --git a/webpack.common.js b/webpack.common.js
index b58058f53f..61fe394830 100644
--- a/webpack.common.js
+++ b/webpack.common.js
@@ -15,7 +15,8 @@ const Assets = [
'@jellyfin/libass-wasm/dist/js/subtitles-octopus-worker.js',
'@jellyfin/libass-wasm/dist/js/subtitles-octopus-worker.wasm',
'@jellyfin/libass-wasm/dist/js/subtitles-octopus-worker-legacy.js',
- 'pdfjs-dist/build/pdf.worker.js'
+ 'pdfjs-dist/build/pdf.worker.js',
+ 'libpgs/dist/libpgs.worker.js'
];
const DEV_MODE = process.env.NODE_ENV !== 'production';