From f044148d973403ade3db1f1eabf13a9770596ccb Mon Sep 17 00:00:00 2001 From: Tsachi Shlidor Date: Mon, 2 Dec 2024 16:26:37 +0200 Subject: [PATCH 1/6] feat: support srt subtitle format --- src/plugins/index.js | 2 + src/plugins/srt-text-tracks/index.js | 10 ++ .../srt-text-tracks/srt-text-tracks.js | 107 ++++++++++++++++++ src/utils/cloudinary.js | 2 + 4 files changed, 121 insertions(+) create mode 100644 src/plugins/srt-text-tracks/index.js create mode 100644 src/plugins/srt-text-tracks/srt-text-tracks.js diff --git a/src/plugins/index.js b/src/plugins/index.js index 684421a3..90e26c1c 100644 --- a/src/plugins/index.js +++ b/src/plugins/index.js @@ -20,6 +20,7 @@ import chapters from './chapters'; import imaPlugin from './ima'; import playlist from './playlist'; import shoppable from './shoppable-plugin'; +import srtTextTracks from './srt-text-tracks'; import styledTextTracks from './styled-text-tracks'; import interactionAreas from './interaction-areas'; @@ -40,6 +41,7 @@ const plugins = { imaPlugin, playlist, shoppable, + srtTextTracks, styledTextTracks, interactionAreas }; diff --git a/src/plugins/srt-text-tracks/index.js b/src/plugins/srt-text-tracks/index.js new file mode 100644 index 00000000..19dc9c79 --- /dev/null +++ b/src/plugins/srt-text-tracks/index.js @@ -0,0 +1,10 @@ +import srtTextTracks from './srt-text-tracks'; + +export default async function srtTextTracksPlugin(config) { + const player = this; + try { + player.ready(() => srtTextTracks(config, player)); + } catch (error) { + console.error('Failed to load plugin:', error); + } +} diff --git a/src/plugins/srt-text-tracks/srt-text-tracks.js b/src/plugins/srt-text-tracks/srt-text-tracks.js new file mode 100644 index 00000000..3d4dcd77 --- /dev/null +++ b/src/plugins/srt-text-tracks/srt-text-tracks.js @@ -0,0 +1,107 @@ +function srtTextTracks(config, player) { + + // Load the SRT file and convert it to WebVTT + const initSRT = async () => { + let srtResponse; + if (config.src) { + try { + srtResponse = await fetch(config.src); + if (!srtResponse.ok) { + throw new Error(`Failed fetching from ${config.src} with status code ${srtResponse.status}`); + } + } catch (error) { + console.error(error); + } + } + if (!srtResponse.ok) return; + + const srtData = await srtResponse.text(); + const webvttCues = srt2webvtt(srtData); // Get the array of cues + + const srtTrack = player.addRemoteTextTrack({ + kind: config.kind || 'subtitles', + label: config.label || 'Subtitles', + srclang: config.srclang, + default: config.default, + mode: config.default ? 'showing' : 'disabled' + }); + + // required for Safari to display the captions + // https://github.com/videojs/video.js/issues/8519 + await new Promise(resolve => setTimeout(resolve, 100)); + + // Add the WebVTT data to the track + webvttCues.forEach(caption => { + if (caption) { + srtTrack.track.addCue(new VTTCue(caption.startTime, caption.endTime, caption.text)); + } + }); + }; + + player.one('loadedmetadata', () => { + initSRT(); + }); +} + +// SRT to WebVTT conversion functions +const srt2webvtt = (data) => { + // Remove DOS newlines + const srt = data.replace(/\r+/g, '').trim(); + + // Get cues + const cuelist = srt.split('\n\n'); + const cues = []; + + for (const cueString of cuelist) { + const cue = convertSrtCue(cueString); + if (cue) { + cues.push(cue); // Add the cue object to the array + } + } + + return cues; // Return the array of cues +}; + +const convertSrtCue = (caption) => { + const cue = {}; + const lines = caption.split(/\n/); + + // Concatenate multi-line string separated in array into one + while (lines.length > 3) { + for (let i = 3; i < lines.length; i++) { + lines[2] += `\n${lines[i]}`; + } + lines.splice(3, lines.length - 3); + } + + let line = 0; + + // Detect identifier + if (!lines[0].match(/\d+:\d+:\d+/) && lines[1].match(/\d+:\d+:\d+/)) { + line += 1; // Skip the identifier line + } + + // Get time strings + if (lines[line].match(/\d+:\d+:\d+/)) { + const timeMatch = lines[line].match(/(\d+):(\d+):(\d+)(?:,(\d+))?\s*-->\s*(\d+):(\d+):(\d+)(?:,(\d+))?/); + if (timeMatch) { + const [, startHours, startMinutes, startSeconds, startMilliseconds, endHours, endMinutes, endSeconds, endMilliseconds] = timeMatch; + cue.startTime = (parseInt(startHours) * 3600) + (parseInt(startMinutes) * 60) + parseInt(startSeconds) + (parseInt(startMilliseconds) / 1000); + cue.endTime = (parseInt(endHours) * 3600) + (parseInt(endMinutes) * 60) + parseInt(endSeconds) + (parseInt(endMilliseconds) / 1000); + line += 1; + } else { + return null; // Return null if the cue is invalid + } + } else { + return null; // Return null if the cue is invalid + } + + // Get cue text + if (lines[line]) { + cue.text = lines[line].trim(); // Trim whitespace from the text + } + + return cue; // Return the cue object +}; + +export default srtTextTracks; diff --git a/src/utils/cloudinary.js b/src/utils/cloudinary.js index cf7e83a1..4ecf9108 100644 --- a/src/utils/cloudinary.js +++ b/src/utils/cloudinary.js @@ -107,6 +107,8 @@ const addTextTracks = (tracks, videojs) => { videojs.addRemoteTextTrack(track, true); } }); + } else if (track.src && track.src.endsWith('.srt')) { + videojs.srtTextTracks(track); } else if (videojs.pacedTranscript && (!track.src || track.src.endsWith('.transcript'))) { videojs.pacedTranscript(track); } From 6f1c21389eade76f909de0d100da2e69c4d67063 Mon Sep 17 00:00:00 2001 From: Tsachi Shlidor Date: Mon, 2 Dec 2024 17:58:05 +0200 Subject: [PATCH 2/6] chore: add example and usage monitoring --- docs/subtitles-and-captions.html | 22 +++++++++------------- src/plugins/paced-transcript/index.js | 2 +- src/utils/get-analytics-player-options.js | 3 ++- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/docs/subtitles-and-captions.html b/docs/subtitles-and-captions.html index cde74bb7..44109a40 100644 --- a/docs/subtitles-and-captions.html +++ b/docs/subtitles-and-captions.html @@ -28,29 +28,25 @@ window.addEventListener('load', function(){ var player = cloudinary.videoPlayer('player', { - cloud_name: 'demo' + cloud_name: 'prod' }); player.source( - 'video-player/stubhub', + 'video/examples/big_buck_bunny_trailer_720p', { textTracks: { + options: { + theme: "videojs-default", // one of 'default', 'videojs-default', 'yellow-outlined', 'player-colors' & '3d' + }, captions: { - label: 'English captions', - language: 'en', + label: 'VTT from URL', default: true, - url: 'https://res.cloudinary.com/demo/raw/upload/v1636972013/video-player/vtt/Meetup_english.vtt' + url: 'https://res.cloudinary.com/prod/raw/upload/video/examples/big_buck_bunny_trailer_720p.vtt' }, subtitles: [ { - label: 'German subtitles', - language: 'de', - url: 'https://res.cloudinary.com/demo/raw/upload/v1636970250/video-player/vtt/Meetup_german.vtt' - }, - { - label: 'Russian subtitles', - language: 'ru', - url: 'https://res.cloudinary.com/demo/raw/upload/v1636970275/video-player/vtt/Meetup_russian.vtt' + label: 'SRT from URL', + url: 'https://res.cloudinary.com/prod/raw/upload/video/examples/big_buck_bunny_trailer_720p.srt' } ] } diff --git a/src/plugins/paced-transcript/index.js b/src/plugins/paced-transcript/index.js index 6be26564..ab4f6c61 100644 --- a/src/plugins/paced-transcript/index.js +++ b/src/plugins/paced-transcript/index.js @@ -52,7 +52,7 @@ function pacedTranscript(config) { transcriptResponse = await fallbackFetch(`${basePath}.transcript`); } } - if (!transcriptResponse.ok) return; + if (!transcriptResponse?.ok) return; const transcriptData = await transcriptResponse.json(); const captions = parseTranscript(transcriptData); diff --git a/src/utils/get-analytics-player-options.js b/src/utils/get-analytics-player-options.js index ffb33eec..b75f8dc8 100644 --- a/src/utils/get-analytics-player-options.js +++ b/src/utils/get-analytics-player-options.js @@ -28,7 +28,8 @@ const getTranscriptOptions = (textTracks = {}) => { transcriptAutoLoaded: tracksArr.some((track) => !track.url) || null, transcriptFromURl: tracksArr.some((track) => track.url?.endsWith('.transcript')) || null, transcriptLanguages: tracksArr.filter((track) => !track.url).map((track) => track.language || '').join(',') || null, - vttFromUrl: tracksArr.some((track) => track.url?.endsWith('.vtt')) || null + vttFromUrl: tracksArr.some((track) => track.url?.endsWith('.vtt')) || null, + srtFromUrl: tracksArr.some((track) => track.url?.endsWith('.srt')) || null }; }; From 82f2eddac5800d98ca79f3a4e59ea132870b333b Mon Sep 17 00:00:00 2001 From: Tsachi Shlidor Date: Mon, 2 Dec 2024 18:23:42 +0200 Subject: [PATCH 3/6] chore: esm examples --- docs/es-modules/subtitles-and-captions.html | 44 ++++++++++----------- docs/subtitles-and-captions.html | 26 ++++++------ src/utils/get-analytics-player-options.js | 4 +- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/docs/es-modules/subtitles-and-captions.html b/docs/es-modules/subtitles-and-captions.html index c1e0f7c0..136a9b71 100644 --- a/docs/es-modules/subtitles-and-captions.html +++ b/docs/es-modules/subtitles-and-captions.html @@ -116,32 +116,32 @@

Karaoke player

import 'cloudinary-video-player/cld-video-player.min.css'; import 'cloudinary-video-player/playlist'; - const player = videoPlayer('player', { - cloudName: 'demo' + const player = cloudinary.videoPlayer('player', { + cloud_name: 'prod' }); - player.source('video-player/stubhub', { - textTracks: { - captions: { - label: 'English captions', - language: 'en', - default: true, - url: 'https://res.cloudinary.com/demo/raw/upload/v1636972013/video-player/vtt/Meetup_english.vtt' - }, - subtitles: [ - { - label: 'German subtitles', - language: 'de', - url: 'https://res.cloudinary.com/demo/raw/upload/v1636970250/video-player/vtt/Meetup_german.vtt' + player.source( + 'video/examples/big_buck_bunny_trailer_720p', + { + info: { title: 'SRT & VTT from URL' }, + textTracks: { + options: { + theme: "videojs-default" }, - { - label: 'Russian subtitles', - language: 'ru', - url: 'https://res.cloudinary.com/demo/raw/upload/v1636970275/video-player/vtt/Meetup_russian.vtt' - } - ] + captions: { + label: 'VTT from URL', + default: true, + url: 'https://res.cloudinary.com/prod/raw/upload/video/examples/big_buck_bunny_trailer_720p.vtt' + }, + subtitles: [ + { + label: 'SRT from URL', + url: 'https://res.cloudinary.com/prod/raw/upload/video/examples/big_buck_bunny_trailer_720p.srt' + } + ] + } } - }); + ); // Playlist const playlist = videoPlayer('playlist', { diff --git a/docs/subtitles-and-captions.html b/docs/subtitles-and-captions.html index 44109a40..c115377d 100644 --- a/docs/subtitles-and-captions.html +++ b/docs/subtitles-and-captions.html @@ -34,9 +34,10 @@ player.source( 'video/examples/big_buck_bunny_trailer_720p', { + info: { title: 'SRT & VTT from URL' }, textTracks: { options: { - theme: "videojs-default", // one of 'default', 'videojs-default', 'yellow-outlined', 'player-colors' & '3d' + theme: "videojs-default" }, captions: { label: 'VTT from URL', @@ -349,29 +350,26 @@

Example Code:

// Initialize players var player = cloudinary.videoPlayer('player', { - cloud_name: 'demo' + cloud_name: 'prod' }); player.source( - 'video-player/stubhub', + 'video/examples/big_buck_bunny_trailer_720p', { + info: { title: 'SRT & VTT from URL' }, textTracks: { + options: { + theme: "videojs-default" + }, captions: { - label: 'English captions', - language: 'en', + label: 'VTT from URL', default: true, - url: 'https://res.cloudinary.com/demo/raw/upload/v1636972013/video-player/vtt/Meetup_english.vtt' + url: 'https://res.cloudinary.com/prod/raw/upload/video/examples/big_buck_bunny_trailer_720p.vtt' }, subtitles: [ { - label: 'German subtitles', - language: 'de', - url: 'https://res.cloudinary.com/demo/raw/upload/v1636970250/video-player/vtt/Meetup_german.vtt' - }, - { - label: 'Russian subtitles', - language: 'ru', - url: 'https://res.cloudinary.com/demo/raw/upload/v1636970275/video-player/vtt/Meetup_russian.vtt' + label: 'SRT from URL', + url: 'https://res.cloudinary.com/prod/raw/upload/video/examples/big_buck_bunny_trailer_720p.srt' } ] } diff --git a/src/utils/get-analytics-player-options.js b/src/utils/get-analytics-player-options.js index b75f8dc8..7c9ab48f 100644 --- a/src/utils/get-analytics-player-options.js +++ b/src/utils/get-analytics-player-options.js @@ -23,11 +23,13 @@ const getTranscriptOptions = (textTracks = {}) => { const tracksArr = [textTracks.captions, ...textTracks.subtitles]; return { textTracks: hasConfig(textTracks), + textTracksLength: tracksArr.length, + textTracksOptions: hasConfig(textTracks.options) || Object.keys(textTracks.options).join(','), pacedTextTracks: hasConfig(textTracks) && JSON.stringify(textTracks || {}).includes('"maxWords":') || null, wordHighlight: hasConfig(textTracks) && JSON.stringify(textTracks || {}).includes('"wordHighlight":') || null, + transcriptLanguages: tracksArr.filter((track) => !track.url).map((track) => track.language || '').join(',') || null, transcriptAutoLoaded: tracksArr.some((track) => !track.url) || null, transcriptFromURl: tracksArr.some((track) => track.url?.endsWith('.transcript')) || null, - transcriptLanguages: tracksArr.filter((track) => !track.url).map((track) => track.language || '').join(',') || null, vttFromUrl: tracksArr.some((track) => track.url?.endsWith('.vtt')) || null, srtFromUrl: tracksArr.some((track) => track.url?.endsWith('.srt')) || null }; From fe5c43f2d3120a6af9e44da1bfd70cb7fa98fb0b Mon Sep 17 00:00:00 2001 From: Tsachi Shlidor Date: Tue, 3 Dec 2024 11:11:19 +0200 Subject: [PATCH 4/6] chore: esm example --- docs/es-modules/subtitles-and-captions.html | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/es-modules/subtitles-and-captions.html b/docs/es-modules/subtitles-and-captions.html index 136a9b71..186cbe1f 100644 --- a/docs/es-modules/subtitles-and-captions.html +++ b/docs/es-modules/subtitles-and-captions.html @@ -104,6 +104,17 @@

Karaoke player

width="500" > +

Translated Transcript

+ + +

Full documentation Date: Wed, 4 Dec 2024 11:28:25 +0200 Subject: [PATCH 5/6] chore: examples --- docs/es-modules/subtitles-and-captions.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/es-modules/subtitles-and-captions.html b/docs/es-modules/subtitles-and-captions.html index 186cbe1f..9e2e048e 100644 --- a/docs/es-modules/subtitles-and-captions.html +++ b/docs/es-modules/subtitles-and-captions.html @@ -127,8 +127,8 @@

Translated Transcript

import 'cloudinary-video-player/cld-video-player.min.css'; import 'cloudinary-video-player/playlist'; - const player = cloudinary.videoPlayer('player', { - cloud_name: 'prod' + const player = videoPlayer('player', { + cloudName: 'prod' }); player.source( @@ -213,7 +213,7 @@

Translated Transcript

playlist.playlist(playlistSources, playlistOptions); // Paced - const pacedPlayer = cloudinary.videoPlayer('paced', { + const pacedPlayer = videoPlayer('paced', { cloudName: 'prod' }); @@ -261,7 +261,7 @@

Translated Transcript

}); // Karaoke - const karaokePlayer = cloudinary.videoPlayer('karaoke', { + const karaokePlayer = videoPlayer('karaoke', { cloudName: 'prod' }); @@ -293,7 +293,7 @@

Translated Transcript

}); // Auto-translated transcript - const translatedTranscriptPlayer = cloudinary.videoPlayer('translated-transcript', { + const translatedTranscriptPlayer = videoPlayer('translated-transcript', { cloudName: 'prod' }); From cd45c826a25a0d183b684e48c58564138cc558be Mon Sep 17 00:00:00 2001 From: Tsachi Shlidor Date: Thu, 5 Dec 2024 11:52:23 +0200 Subject: [PATCH 6/6] feat: support srt subtitle format --- package-lock.json | 13 +++ package.json | 1 + .../srt-text-tracks/srt-text-tracks.js | 79 ++++--------------- 3 files changed, 30 insertions(+), 63 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5fa186f3..1cc66c93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "cloudinary-video-analytics": "1.7.1", "cloudinary-video-player-profiles": "1.1.0", "lodash": "^4.17.21", + "srt-parser-2": "^1.2.3", "uuid": "^10.0.0", "video.js": "^8.17.1", "videojs-contrib-ads": "^7.5.2", @@ -17014,6 +17015,18 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, + "node_modules/srt-parser-2": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/srt-parser-2/-/srt-parser-2-1.2.3.tgz", + "integrity": "sha512-dANP1AyJTI503H0/kXwRza+7QxDB3BqeFvEKTF4MI9lQcBe8JbRUQTKVIGzGABJCwBovEYavZ2Qsdm/s8XKz8A==", + "license": "MIT", + "bin": { + "srt-parser-2": "bin/index.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", diff --git a/package.json b/package.json index 5dc4012e..cbce58f7 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "cloudinary-video-analytics": "1.7.1", "cloudinary-video-player-profiles": "1.1.0", "lodash": "^4.17.21", + "srt-parser-2": "^1.2.3", "uuid": "^10.0.0", "video.js": "^8.17.1", "videojs-contrib-ads": "^7.5.2", diff --git a/src/plugins/srt-text-tracks/srt-text-tracks.js b/src/plugins/srt-text-tracks/srt-text-tracks.js index 3d4dcd77..4859a733 100644 --- a/src/plugins/srt-text-tracks/srt-text-tracks.js +++ b/src/plugins/srt-text-tracks/srt-text-tracks.js @@ -1,5 +1,6 @@ -function srtTextTracks(config, player) { +import srtParser2 from 'srt-parser-2'; +function srtTextTracks(config, player) { // Load the SRT file and convert it to WebVTT const initSRT = async () => { let srtResponse; @@ -31,9 +32,9 @@ function srtTextTracks(config, player) { await new Promise(resolve => setTimeout(resolve, 100)); // Add the WebVTT data to the track - webvttCues.forEach(caption => { - if (caption) { - srtTrack.track.addCue(new VTTCue(caption.startTime, caption.endTime, caption.text)); + webvttCues.forEach(cue => { + if (cue) { + srtTrack.track.addCue(new VTTCue(cue.startTime, cue.endTime, cue.text)); } }); }; @@ -43,65 +44,17 @@ function srtTextTracks(config, player) { }); } -// SRT to WebVTT conversion functions -const srt2webvtt = (data) => { - // Remove DOS newlines - const srt = data.replace(/\r+/g, '').trim(); - - // Get cues - const cuelist = srt.split('\n\n'); - const cues = []; - - for (const cueString of cuelist) { - const cue = convertSrtCue(cueString); - if (cue) { - cues.push(cue); // Add the cue object to the array - } - } - - return cues; // Return the array of cues -}; - -const convertSrtCue = (caption) => { - const cue = {}; - const lines = caption.split(/\n/); - - // Concatenate multi-line string separated in array into one - while (lines.length > 3) { - for (let i = 3; i < lines.length; i++) { - lines[2] += `\n${lines[i]}`; - } - lines.splice(3, lines.length - 3); - } - - let line = 0; - - // Detect identifier - if (!lines[0].match(/\d+:\d+:\d+/) && lines[1].match(/\d+:\d+:\d+/)) { - line += 1; // Skip the identifier line - } - - // Get time strings - if (lines[line].match(/\d+:\d+:\d+/)) { - const timeMatch = lines[line].match(/(\d+):(\d+):(\d+)(?:,(\d+))?\s*-->\s*(\d+):(\d+):(\d+)(?:,(\d+))?/); - if (timeMatch) { - const [, startHours, startMinutes, startSeconds, startMilliseconds, endHours, endMinutes, endSeconds, endMilliseconds] = timeMatch; - cue.startTime = (parseInt(startHours) * 3600) + (parseInt(startMinutes) * 60) + parseInt(startSeconds) + (parseInt(startMilliseconds) / 1000); - cue.endTime = (parseInt(endHours) * 3600) + (parseInt(endMinutes) * 60) + parseInt(endSeconds) + (parseInt(endMilliseconds) / 1000); - line += 1; - } else { - return null; // Return null if the cue is invalid - } - } else { - return null; // Return null if the cue is invalid - } - - // Get cue text - if (lines[line]) { - cue.text = lines[line].trim(); // Trim whitespace from the text - } - - return cue; // Return the cue object +// SRT parser +const srt2webvtt = data => { + const SRTParser = new srtParser2(); + + const cues = SRTParser.fromSrt(data); + + return cues.map(cue => ({ + startTime: cue.startSeconds, + endTime: cue.endSeconds, + text: cue.text + })); }; export default srtTextTracks;