From 99c18309c5ebbee5b50c41537d73abd30955b285 Mon Sep 17 00:00:00 2001 From: Tsachi Shlidor Date: Mon, 26 Feb 2024 18:59:57 +0200 Subject: [PATCH] feat: kareoke style subtitles (#563) * feat: karaoke style subtitles * fix: types exports * feat: kareoke style subtitles * feat: kareoke style subtitles * feat: kareoke style subtitles * chore: add example page --- docs/subtitles-and-captions.html | 173 +++++++++++++----- src/assets/styles/components/text-tracks.scss | 6 +- src/plugins/paced-transcript/index.js | 64 +++++-- .../styled-text-tracks/styled-text-tracks.js | 11 +- .../styled-text-tracks.scss | 6 +- src/utils/cloudinary.js | 2 +- src/utils/get-analytics-player-options.js | 4 +- src/validators/validators.js | 11 +- 8 files changed, 210 insertions(+), 67 deletions(-) diff --git a/docs/subtitles-and-captions.html b/docs/subtitles-and-captions.html index 258cb2bb..c04a45ab 100644 --- a/docs/subtitles-and-captions.html +++ b/docs/subtitles-and-captions.html @@ -178,9 +178,42 @@ }); }); + // Karaoke + const karaokePlayer = cloudinary.videoPlayer('karaoke', { + cloudName: 'demo', + autoplay: true, + muted: true + }); + + karaokePlayer.source('lincoln', { + textTracks: { + options: { + fontFace: 'Lobster', + fontSize: '200%', + gravity: 'top', + wordHighlightStyle: { + color: 'white', + 'text-shadow': `2px 2px 0px violet, + 4px 4px 0px indigo, + 6px 6px 0px blue, + 8px 8px 0px green, + 10px 10px 0px yellow, + 12px 12px 0px orange, + 14px 14px 0px red` + } + }, + captions: { + label: 'KARAOKE', + language: 'en', + wordHighlight: true, + maxWords: 5, + timeOffset: -0.2, + default: true + } + } + }); }, false); -
@@ -198,8 +231,8 @@

Subtitles & Captions

autoplay class="cld-video-player cld-fluid" crossorigin="anonymous" - width="500"> - + width="500" + >

Playlist Subtitles (switch per source)

@@ -210,8 +243,8 @@

Playlist Subtitles (switch per source)

muted class="cld-video-player cld-fluid" crossorigin="anonymous" - width="500"> - + width="500" + >

Paced & Styled Captions

@@ -260,8 +293,20 @@

Paced & Styled Captions

muted class="cld-video-player cld-fluid" crossorigin="anonymous" - width="500"> - + width="500" + > + +

Karaoke player

+ +

Full documentation @@ -298,6 +343,16 @@

Example Code:

crossorigin="anonymous" width="500"> </video> + + <video + id="karaoke" + controls + muted + autoplay + class="cld-video-player" + crossorigin="anonymous" + width="500"> + </video> @@ -393,43 +448,77 @@

Example Code:

muted: true }); - const textTracks = { - options: { - // theme: "", // one of 'default', 'videojs-default', 'yellow-outlined', 'player-colors' & '3d' - // fontFace: "", // any Google font - // fontSize: "", // any CSS value - // gravity: "", // i.e. 'top-left', 'center' etc - // box: { // Object of x/y/width/height - // x: "0%", - // y: "0%", - // width: "100%", - // height: "100%" - // }, - // style: { // Styles key-value object - // color: "hotpink" - // } - }, - captions: { - label: 'English captions', - language: 'en', - maxWords: 4, - default: true, - }, - subtitles: [ - { - label: 'German subtitles', - language: 'de', - url: 'https://res.cloudinary.com/demo/raw/upload/v1636970250/video-player/vtt/Meetup_german.vtt' + pacedPlayer.source('lincoln', { + textTracks: { + options: { + // theme: "", // one of 'default', 'videojs-default', 'yellow-outlined', 'player-colors' & '3d' + // fontFace: "", // any Google font + // fontSize: "", // any CSS value + // gravity: "", // i.e. 'top-left', 'center' etc + // box: { // Object of x/y/width/height + // x: "0%", + // y: "0%", + // width: "100%", + // height: "100%" + // }, + // style: { // Styles key-value object + // color: "hotpink" + // } }, - { - label: 'Russian subtitles', - language: 'ru', - url: 'https://res.cloudinary.com/demo/raw/upload/v1636970275/video-player/vtt/Meetup_russian.vtt' + captions: { + label: 'English Paced', + language: 'en', + maxWords: 4, + default: true + }, + 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' + } + ] + } + }); + + // Karaoke + const karaokePlayer = cloudinary.videoPlayer('karaoke', { + cloudName: 'demo', + autoplay: true, + muted: true + }); + + karaokePlayer.source('lincoln', { + textTracks: { + options: { + fontFace: 'Lobster', + fontSize: '200%', + gravity: 'top', + wordHighlightStyle: { + color: 'white', + 'text-shadow': `2px 2px 0px violet, + 4px 4px 0px indigo, + 6px 6px 0px blue, + 8px 8px 0px green, + 10px 10px 0px yellow, + 12px 12px 0px orange, + 14px 14px 0px red` + } + }, + captions: { + label: 'KARAOKE', + language: 'en', + wordHighlight: true, + maxWords: 5, + timeOffset: -0.2, + default: true } - ] - } - pacedPlayer.source('lincoln', { - textTracks + } });
diff --git a/src/assets/styles/components/text-tracks.scss b/src/assets/styles/components/text-tracks.scss index 145e784c..5b50ad9f 100644 --- a/src/assets/styles/components/text-tracks.scss +++ b/src/assets/styles/components/text-tracks.scss @@ -7,6 +7,10 @@ > div { margin: 3% !important; } + // Word highlight + &.cld-paced-text-tracks b { + color: var(--color-accent); + } } .vjs-text-track-cue { top: auto !important; @@ -27,7 +31,7 @@ .vjs-text-track-display:not(.cld-styled-text-tracks-theme-videojs-default) { .vjs-text-track-cue { font-family: inherit !important; - & > div { + > div { font-weight: 700; background-color: transparent !important; text-shadow: 0 0 0.2em rgba(0, 0, 0, 0.8); diff --git a/src/plugins/paced-transcript/index.js b/src/plugins/paced-transcript/index.js index 60d04069..5ff2fa5d 100644 --- a/src/plugins/paced-transcript/index.js +++ b/src/plugins/paced-transcript/index.js @@ -13,9 +13,14 @@ function pacedTranscript(config) { source.publicId(), extendCloudinaryConfig(player.cloudinary.cloudinaryConfig(), { resource_type: 'raw' }), ) + '.transcript', - maxWords: config.maxWords || 5 // Number of words per caption + maxWords: config.maxWords, + wordHighlight: config.wordHighlight, + timeOffset: config.timeOffset || 0 }; + const classNames = player.textTrackDisplay.el().classList; + classNames.add('cld-paced-text-tracks'); + // Load the transcription file const initTranscript = async () => { try { @@ -46,26 +51,55 @@ function pacedTranscript(config) { // Generate captions from the transcription data const parseTranscript = transcriptionData => { - const maxWords = options.maxWords; const captions = []; + const addCaption = ({ startTime, endTime, text }) => { + captions.push({ + startTime: startTime + options.timeOffset, + endTime: endTime + options.timeOffset, + text + }); + }; + transcriptionData.forEach(segment => { const words = segment.words; + const maxWords = options.maxWords || words.length; for (let i = 0; i < words.length; i += maxWords) { - const startTime = words[i].start_time; - const endTime = words[Math.min(i + maxWords - 1, words.length - 1)].end_time; - - const captionText = words - .slice(i, i + maxWords) - .map(word => word.word) - .join(' '); - - captions.push({ - startTime: startTime, - endTime: endTime, - text: captionText - }); + if (options.wordHighlight) { + // Create a caption for every word, in which the current word is highlighted + words.slice(i, Math.min(i + maxWords, words.length)).forEach((word, idx) => { + addCaption({ + startTime: word.start_time, + endTime: word.end_time, + text: words + .slice(i, i + maxWords) + .map(w => (w === word ? `${w.word}` : w.word)) + .join(' ') + }); + + // if we haven't reached the end of the words array, and there's a gap between the current word end_time and the next word start_time, add a non-highlighted caption to fill the gap + if (words[idx + 1] && word.end_time < words[idx + 1].start_time) { + addCaption({ + startTime: word.end_time, + endTime: words[idx + 1].start_time, + text: words + .slice(i, i + maxWords) + .map(word => word.word) + .join(' ') + }); + } + }); + } else { + captions.push({ + startTime: words[i].start_time, + endTime: words[Math.min(i + maxWords - 1, words.length - 1)].end_time, + text: words + .slice(i, i + maxWords) + .map(word => word.word) + .join(' ') + }); + } } }); diff --git a/src/plugins/styled-text-tracks/styled-text-tracks.js b/src/plugins/styled-text-tracks/styled-text-tracks.js index 1c73d937..549455b7 100644 --- a/src/plugins/styled-text-tracks/styled-text-tracks.js +++ b/src/plugins/styled-text-tracks/styled-text-tracks.js @@ -10,7 +10,8 @@ const styledTextTracks = (config, player) => { fontSize: config.fontSize, gravity: config.gravity || 'bottom', box: config.box, - style: config.style + style: config.style, + wordHighlightStyle: config.wordHighlightStyle }; // Class Names - Theme/Gravity @@ -75,6 +76,14 @@ const styledTextTracks = (config, player) => { '.vjs-text-track-display.cld-styled-text-tracks .vjs-text-track-cue > div' ); } + + // Custom styles + if (options.wordHighlightStyle) { + applyImportantStyle( + options.wordHighlightStyle, + '.vjs-text-track-display.cld-styled-text-tracks .vjs-text-track-cue b' + ); + } }; export default styledTextTracks; diff --git a/src/plugins/styled-text-tracks/styled-text-tracks.scss b/src/plugins/styled-text-tracks/styled-text-tracks.scss index b779bf93..71ab97a5 100644 --- a/src/plugins/styled-text-tracks/styled-text-tracks.scss +++ b/src/plugins/styled-text-tracks/styled-text-tracks.scss @@ -31,7 +31,7 @@ .vjs-text-track-display { &.cld-styled-text-tracks-theme-yellow-outlined { - .vjs-text-track-cue { + div.vjs-text-track-cue { & > div { color: #FEF94A !important; text-shadow: @@ -44,7 +44,7 @@ } &.cld-styled-text-tracks-theme-3d { - .vjs-text-track-cue { + div.vjs-text-track-cue { & > div { $base-size: 0.03em; $base-color: #ff76ad; @@ -59,7 +59,7 @@ } &.cld-styled-text-tracks-theme-player-colors { - .vjs-text-track-cue { + div.vjs-text-track-cue { & > div { color: var(--color-text) !important; background-color: var(--color-accent) !important; diff --git a/src/utils/cloudinary.js b/src/utils/cloudinary.js index 72f491c2..a8ddcd27 100644 --- a/src/utils/cloudinary.js +++ b/src/utils/cloudinary.js @@ -133,7 +133,7 @@ const isKeyInTransformation = (transformation, key) => { const addTextTracks = (tracks, videojs) => { tracks.forEach(track => { - if (track.maxWords && videojs.pacedTranscript) { + if ((track.maxWords || track.wordHighlight) && videojs.pacedTranscript) { videojs.pacedTranscript(track); } else if (track.src) { fetch(track.src, GET_ERROR_DEFAULT_REQUEST).then(r => { diff --git a/src/utils/get-analytics-player-options.js b/src/utils/get-analytics-player-options.js index ef8a41a0..ddcf4a5a 100644 --- a/src/utils/get-analytics-player-options.js +++ b/src/utils/get-analytics-player-options.js @@ -31,13 +31,15 @@ const getSourceOptions = (sourceOptions = {}) => ({ ...(sourceOptions.textTracks ? { textTracks: hasConfig(sourceOptions.textTracks), pacedTextTracks: hasConfig(sourceOptions.textTracks) && JSON.stringify(sourceOptions.textTracks || {}).includes('"maxWords":'), + wordHighlight: hasConfig(sourceOptions.textTracks) && JSON.stringify(sourceOptions.textTracks || {}).includes('"wordHighlight":'), ...(sourceOptions.textTracks.options ? { styledTextTracksTheme: sourceOptions.textTracks.options.theme, styledTextTracksFont: sourceOptions.textTracks.options.fontFace, styledTextTracksFontSize: sourceOptions.textTracks.options.fontSize, styledTextTracksGravity: sourceOptions.textTracks.options.gravity, styledTextTracksBox: hasConfig(sourceOptions.textTracks.options.box), - styledTextTracksStyle: hasConfig(sourceOptions.textTracks.options.style) + styledTextTracksStyle: hasConfig(sourceOptions.textTracks.options.style), + styledTextTracksWordHighlightStyle: hasConfig(sourceOptions.textTracks.options.wordHighlightStyle) } : {}) } : {}) }); diff --git a/src/validators/validators.js b/src/validators/validators.js index ab39ae95..42fc8047 100644 --- a/src/validators/validators.js +++ b/src/validators/validators.js @@ -98,21 +98,26 @@ export const sourceValidators = { fontSize: validator.isString, gravity: validator.isString, box: validator.isPlainObject, - style: validator.isPlainObject + style: validator.isPlainObject, + wordHighlightStyle: validator.isPlainObject }, captions: { label: validator.isString, language: validator.isString, default: validator.isBoolean, url: validator.isString, - maxWords: validator.isNumber + maxWords: validator.isNumber, + wordHighlight: validator.isBoolean, + timeOffset: validator.isNumber }, subtitles: validator.isArrayOfObjects({ label: validator.isString, language: validator.isString, default: validator.isBoolean, url: validator.isString, - maxWords: validator.isNumber + maxWords: validator.isNumber, + wordHighlight: validator.isBoolean, + timeOffset: validator.isNumber }) }, info: {