Skip to content

Instantly share code, notes, and snippets.

@denilsonsa
Last active May 1, 2024 11:41
Show Gist options
  • Save denilsonsa/aeb06c662cf98e29c379 to your computer and use it in GitHub Desktop.
Save denilsonsa/aeb06c662cf98e29c379 to your computer and use it in GitHub Desktop.
Embed VTT subtitles into HTML

Embed VTT subtitles into HTML

Objective

I have a video file on my local disk. I also have some subtitles (in HTML5's WebVTT format).

I want to create an HTML file to play that video with subtitles, all from the local filesystem.

Problem

Loading an external VTT file from the local filesystem fails due to same-origin policy.

Solution 1 (doesn't work)

Disable cross-origin protection in the browser.

While it works, it is troublesome and dangerous. Please don't do it.

Solution 2 (works, no JavaScript needed, but not human-readable)

Try embedding the VTT file as a data: URI inside <track src="">.

Unfortunately, I still got issues related to cross-origin, so I abandoned this idea.

I had trouble in 2016, but now in 2024 this solution works fine! According to the comments, it has been working fine since 2022 or earlier. However, this solution has a few drawbacks:

  • data: URIs are ugly. The nice human-readable subtitle files have to be converted to an ugly string of characters.
  • data: URIs might have size limits, which means longer subtitles may not work correctly.
  • Due to encoding, data: URIs are larger than raw VTT files.

It still has a few advantages, though:

  • No JavaScript required, just plain HTML.
  • Works fine enough for shorter subtitles.

Solution 3 (works, simple Blob URL)

Try embedding the VTT file inside the HTML file, generating a Blob URL, and adding a new <track src=""> to the video.

Unfortunately, it did not work for me.

I had trouble in 2016, but now in 2024 this solution works fine! Probably because the VTT format doesn't allow any leading whitespace, so we need to use .trimStart() when extracting the text from the <script> tag. Thanks to @hugoheden for figuring it out!

Solution 4 (bloated, non-native subtitles, potentially insecure)

Try embedding the SRT file inside the HTML file and use JavaScript code to dynamically parse and display the subtitles.

That was my previous attempt, I used a highly modified version of VideoSub. Still, this solution felt a bit bloated, specially because it runs JavaScript code while the video is playing. In addition, it was insecure, because it added the subtitle text directly through innerHTML.

Solution 5 (works, custom parser)

Try embedding the VTT file inside the HTML file and dynamically create a new Text Track when the document loads.

This is the solution implemented in this Gist. It is relatively small (the bigger portion is the VTT parser), and the JavaScript code runs only once (when the document is loading).

The text style can be tweaked using video::cue selector. Styling can be refined, for instance, we can set the style of underlined subtitles through video::cue(u) selector.

Additional features

These examples in this Gist are assuming you have only one subtitle track and you want it to be enabled by default. Adding support for multiple tracks is left as an exercise to the reader. (Or just look at the comments down below, someone might have implemented it.)

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>(1) Playing a local video</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<style>
html, body {
background: black;
color: white;
}
html, body, video {
padding: 0;
margin: 0;
}
video {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<video controls>
<source src="Your local video file.mp4" type="video/mp4">
<!--
Fails on Chrome with:
Unsafe attempt to load URL file:…/solution-1.vtt from frame with URL file:…/play_local_video_with_vtt_subtitles-solution-1.html. 'file:' URLs are treated as unique security origins.
Fails on Firefox:
Security Error: Content at file:…/play_local_video_with_vtt_subtitles-solution-1.html may not load data from file:…/solution-1.vtt.
-->
<track kind="subtitles" label="English" srclang="en" default src="solution-1.vtt">
</video>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>(2) Playing a local video</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<style>
html, body {
background: black;
color: white;
}
html, body, video {
padding: 0;
margin: 0;
}
video {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<video controls>
<source src="Your local video file.mp4" type="video/mp4">
<track kind="subtitles" label="English" srclang="en" default src="data:text/vtt;charset=utf-8,WEBVTT%0D%0A%0D%0A1%0D%0A00%3A00%3A02.500%20--%3E%2000%3A00%3A05.250%0D%0AInstead%20of%20loading%20an%20external%20.vtt%20file%2C%0D%0A%0D%0A2%0D%0A00%3A00%3A05.250%20--%3E%2000%3A00%3A09.750%0D%0AThe%20workaround%20is%20to%20add%20it%20as%20a%20data%3A%20URI%2C%0D%0A%0D%0A3%0D%0A00%3A00%3A10.001%20--%3E%2000%3A00%3A15.000%0D%0Abut%20it%20can%20get%20too%20long%2C%0D%0Aand%20still%20suffer%20from%20cross-origin.">
</video>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>(3) Playing a local video</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<style>
html, body {
background: black;
color: white;
}
html, body, video {
padding: 0;
margin: 0;
}
video {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<video controls>
<source src="Your local video file.mp4" type="video/mp4">
</video>
<!-- Check https://quuz.org/webvtt/ for validation. -->
<script type="text/vtt" id="subtitle" data-label="English" data-lang="en">
WEBVTT
1
00:00:02.500 --> 00:00:05.250
Instead of loading an external .vtt file,
2
00:00:05.250 --> 00:00:09.750
The workaround is to embed it inside a script tag,
3
00:00:10.001 --> 00:00:13.000
And then dynamically add it to the DOM,
4
00:00:13.001 --> 00:00:16.000
Using a Blob URL.
</script>
<script>
function init() {
// http://www.html5rocks.com/en/tutorials/track/basics/
// https://www.iandevlin.com/blog/2015/02/javascript/dynamically-adding-text-tracks-to-html5-video
var video = document.querySelector('video');
var subtitle = document.getElementById('subtitle');
var blob = new Blob([subtitle.textContent.trimStart()], {type: 'text/vtt'});
var track = document.createElement('track');
track.kind = 'subtitles';
track.label = subtitle.dataset.label;
track.srclang = subtitle.dataset.lang;
track.default = true;
track.src = URL.createObjectURL(blob);
video.appendChild(track);
}
init();
</script>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>(5) Playing a local video</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<style>
html, body {
background: black;
color: white;
}
html, body, video {
padding: 0;
margin: 0;
}
video {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<video controls>
<source src="Your local video file.mp4" type="video/mp4">
</video>
<!-- Check https://quuz.org/webvtt/ for validation. -->
<script type="text/vtt" id="subtitle" data-label="English" data-lang="en">
WEBVTT
1
00:00:02.500 --> 00:00:05.250
Instead of loading an external .vtt file,
2
00:00:05.250 --> 00:00:09.750
The workaround is to embed it inside a script tag,
3
00:00:10.001 --> 00:00:15.000
And then parse it using JavaScript
and dynamically add it as a new TextTrack.
</script>
<script>
function parse_timestamp(s) {
//var match = s.match(/^(?:([0-9]{2,}):)?([0-5][0-9]):([0-5][0-9][.,][0-9]{0,3})/);
// Relaxing the timestamp format:
var match = s.match(/^(?:([0-9]+):)?([0-5][0-9]):([0-5][0-9](?:[.,][0-9]{0,3})?)/);
if (match == null) {
throw 'Invalid timestamp format: ' + s;
}
var hours = parseInt(match[1] || "0", 10);
var minutes = parseInt(match[2], 10);
var seconds = parseFloat(match[3].replace(',', '.'));
return seconds + 60 * minutes + 60 * 60 * hours;
}
// https://w3c.github.io/webvtt/
// https://developer.mozilla.org/en/docs/Web/API/Web_Video_Text_Tracks_Format
// https://en.wikipedia.org/wiki/WebVTT
//
// For better parsers, look at:
// https://github.com/annevk/webvtt
// https://github.com/mozilla/vtt.js
function quick_and_dirty_vtt_or_srt_parser(vtt) {
var lines = vtt.trim().replace('\r\n', '\n').split(/[\r\n]/).map(function(line) {
return line.trim();
});
var cues = [];
var start = null;
var end = null;
var payload = null;
for (var i = 0; i < lines.length; i++) {
if (lines[i].indexOf('-->') >= 0) {
var splitted = lines[i].split(/[ \t]+-->[ \t]+/);
if (splitted.length != 2) {
throw 'Error when splitting "-->": ' + lines[i];
}
// Already ignoring anything past the "end" timestamp (i.e. cue settings).
start = parse_timestamp(splitted[0]);
end = parse_timestamp(splitted[1]);
} else if (lines[i] == '') {
if (start && end) {
var cue = new VTTCue(start, end, payload);
cues.push(cue);
start = null;
end = null;
payload = null;
}
} else if(start && end) {
if (payload == null) {
payload = lines[i];
} else {
payload += '\n' + lines[i];
}
}
}
if (start && end) {
var cue = new VTTCue(start, end, payload);
cues.push(cue);
}
return cues;
}
function init() {
// http://www.html5rocks.com/en/tutorials/track/basics/
// https://www.iandevlin.com/blog/2015/02/javascript/dynamically-adding-text-tracks-to-html5-video
var video = document.querySelector('video');
var subtitle = document.getElementById('subtitle');
var track = video.addTextTrack('subtitles', subtitle.dataset.label, subtitle.dataset.lang);
track.mode = "showing";
quick_and_dirty_vtt_or_srt_parser(subtitle.innerHTML).map(function(cue) {
track.addCue(cue);
});
}
init();
</script>
</body>
</html>
WEBVTT
1
00:00:02.500 --> 00:00:05.250
We can try loading an external .vtt file,
2
00:00:05.250 --> 00:00:09.750
But it can suffer from cross-origin/same-origin,
3
00:00:10.001 --> 00:00:15.000
Because file: isn't trustworthy.
@minasana
Copy link

minasana commented Aug 5, 2016

Hello, I'm a newbie, and I've read about video::cue, but still can't implements it, the text still white with black opaque whatever I've done. Can you give me an example that works to change those style? I want white text without black opaque. Thanks.

@borzaka
Copy link

borzaka commented Sep 29, 2017

Is it possible to do this without any parsing? For example using addRemoteTextTrack. Because the parser is already in the Video.js, why would you do that again.

player.addRemoteTextTrack({
	kind: 'subtitles',
	src: document.getElementById("subtitle").innerHTML,
	srclang: 'en',
	label: 'English'
}, false);

Unfortunately the src this way won't work, only with the data: URI. IE11 don't like this, only Edge. Chrome, Firefox is fine.
Or something else?

@jmgz4e9
Copy link

jmgz4e9 commented Jun 2, 2018

A small change to init() to support multiple languages.

function init() {
	// http://www.html5rocks.com/en/tutorials/track/basics/
	// https://www.iandevlin.com/blog/2015/02/javascript/dynamically-adding-text-tracks-to-html5-video
	var video = document.querySelector('video');
	var subtitles = document.getElementsByTagName('script');
	Array.prototype.slice.call(subtitles)
		.filter(node => node.type === "text/vtt")
		.map(subtitle => {
			var track = video.addTextTrack('subtitles', subtitle.dataset.label, subtitle.dataset.lang);
			track.mode = "showing";
			quick_and_dirty_vtt_or_srt_parser(subtitle.innerHTML).map(function(cue) {
				track.addCue(cue);
			});
		});
}

@ambiflextrous
Copy link

Is there a way I can remove all cues?

@thihille
Copy link

A small adjust to init(), to not showing all subtitles same time.

function init() {
     // http://www.html5rocks.com/en/tutorials/track/basics/
     // https://www.iandevlin.com/blog/2015/02/javascript/dynamically-adding-text-tracks-to-html5-video
     var video = document.querySelector('video');
     var subtitles = document.getElementsByTagName('script');
     Array.prototype.slice.call(subtitles)
        .filter(node => node.type === "text/vtt")
        .map((subtitle,index) => {
           var track = video.addTextTrack('subtitles', subtitle.dataset.label, subtitle.dataset.lang);
           var first_sub = index == 0 ? "showing" : "hidden"; 
           track.mode = first_sub;
           quick_and_dirty_vtt_or_srt_parser(subtitle.innerHTML).map(function(cue) {
              track.addCue(cue);
           });
        });
  }

@bkimminich
Copy link

Hey @denilsonsa! Your play_local_video_with_vtt_subtitles.html ended up in my own https://github.com/bkimminich/juice-shop project after being converted into a Jade template in juice-shop/juice-shop#891. As you didn't put this file under any OSS license, I wanted to ask if this is okay for you. I'd add your name to our https://github.com/bkimminich/juice-shop/blob/master/HALL_OF_FAME.md in the "Special Thanks" section if you like and also refer to the original Gist from there! Please let me know what you think! Thanks!

@manikandanraji
Copy link

thank you for your solution, finally got mine working

@denilsonsa
Copy link
Author

@bkimminich, it's fine, feel free to reuse my solution any way you like. Credits are always appreciated, but of course not required. (Also, GitHub doesn't always send notifications for comments on Gists.)

Copy link

ghost commented Nov 22, 2020

Thanks. I've been struggling all day with this. I'll try to implement your solution.

@rgdesignco
Copy link

Well DONE! Thank you for you solution! This works great for any offline projects that can't connect to a server.

@Caspearious
Copy link

Caspearious commented Jun 6, 2022

Just as an FYI... I was able to embed a VTT file directly into an HTML page (Solution 2). It requires that you base64 encode the VTT file's content and copy that into the src of the track tag.

Example:
<track label="English" kind="subtitles" srclang="en" src="data:text/plain;base64,V0VCVlRUCgoxC">
Note: Everything after the "base64," is usually a very long text of gibberish which is the converted data of any file you base64 encode.

It just makes it nearly impossible to directly manipulate unless you are a machine... or you need to keep a copy of the VTT around and re-encode any changes you may make in the future.

It can be used for other files you may want to embed, outside of merely VTTs, such as images, text files, and even binary data that I used to embed a "tape" file for a localized web-based emulator of some old computer, recently. You just need to use the correct "data:" header for the file you are working with, such as:
data:text/plain;
data:image/png;
etc...

@denilsonsa
Copy link
Author

I was able to embed a VTT file directly into an HTML page (Solution 2). It requires that you base64 encode the VTT file's content and copy that into the src of the track tag.

Well, good that solution 2 worked for you, @Caspearious . However, by looking at my notes from half decade ago:

Unfortunately, I still got issues related to cross-origin, so I abandoned this idea.

I think some browser(s) refused to load it (or load it correctly) because they thought data: was a different origin than the local file. Not sure if this was a bug that was fixed in the past years, I'm not even sure which browsers were affected. But for sure I abandoned the solution 2 for a reason. It's worth rechecking if this reason is still valid today.

@kwolk
Copy link

kwolk commented Aug 29, 2022

Nice workaround.

Apparently Google will crawl a VTT file anyway, so is there any SEO advantage to this method please ?

Copy link

ghost commented Aug 29, 2022

Nice workaround.

Apparently Google will crawl a VTT file anyway, so is there any SEO advantage to this method please ?

This is for local filesystem use

@hugoheden
Copy link

hugoheden commented Feb 20, 2024

Solution 3
Try embedding the VTT file inside the HTML file, generating a [Blob URL]> (https://developer.mozilla.org/en/docs/Web/API/Blob#Example_for_creating_a_URL_to_a_typed_array_using_a_blob), and adding a new > to the video.

Unfortunately, it did not work for me.

Yeah, that method actually seems to work for me (using Chromium 120), which is nice. Maybe browsers have evolved over the last few years.

So we can get rid of the parsing. Full working example:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Playing a local video</title>
</head>
<body>

<video controls>
    <source src="Video.mp4" type="video/mp4">
</video>

<script>
    document.addEventListener('DOMContentLoaded', () => {
        const videoElement = document.querySelector('video');
        videoElement.addEventListener('error', () => {
            const error = videoElement.error;
            console.error(`Error occurred with video playback: Code ${error.code}`, error);
        });

        const subtitleElements = document.querySelectorAll('.subtitle');
        subtitleElements.forEach((subtitleElement, index) => {
            // Use .trimStart() to remove leading whitespace - there must be no leading whitespace before the
            // WEBVTT header. If there is, the subs will fail to show.
            const subtitleContent = subtitleElement.textContent.trimStart();
            const subtitleBlob = new Blob([subtitleContent], {type: 'text/vtt'});
            const subtitleUrl = URL.createObjectURL(subtitleBlob);
            const trackElement = document.createElement('track');
            Object.assign(trackElement, {
                kind: 'subtitles',
                label: `${subtitleElement.dataset.label || 'Unknown'}`,
                src: subtitleUrl,
                default: index === 0
            });
            videoElement.appendChild(trackElement);
        });
    });
</script>


<!-- Check https://quuz.org/webvtt/ for validation. -->
<script type="text/vtt" class="subtitle" data-label="English" data-lang="en">WEBVTT

    1
    00:00:02.500 --> 00:00:05.250
    Instead of loading an external .vtt file,

    2
    00:00:05.250 --> 00:00:09.750
    The workaround is to embed it inside a script tag,

</script>

<script type="text/vtt" class="subtitle" data-label="French" data-lang="fr">WEBVTT

    1
    00:00:00.500 --> 00:00:30.000
    Balle de match.

</script>

</body>
</html>

Important: There must be no initial whitespace before that WEBVTT header. I suspect many may have stumbled upon this.

So, either write something like <script>WEBVTT or const subtitleContent = subtitleElement.textContent.trimStart();. (The example code does both).

@denilsonsa
Copy link
Author

@hugoheden, thank you! It is likely that I had forgotten about the leading whitespace when I tried this approach in 2016. Thank you for figuring it out, it's a much simpler solution than a custom parser, and more human-readable than a data: URI. I've added it to the Gist.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment