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';