Last active
February 8, 2024 09:27
-
-
Save daniel-j/2da75add26f548e57aae85d6471667dd to your computer and use it in GitHub Desktop.
HDMV/PGS subtitle Javascript parser
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use strict' | |
function HDMVPGS (ctx) { | |
this.ctx = ctx || document.createElement('canvas').getContext('2d') | |
this.lastVisibleSegment = null | |
this.segments = [] | |
this.loaded = false | |
} | |
HDMVPGS.prototype.loadBuffer = function (arraybuffer) { | |
arraybuffer = arraybuffer || this.buffer | |
if (this.loaded) return false | |
console.log('loading buffer') | |
let view = new DataView(arraybuffer) | |
let headerSize = 13 | |
let offset = 0 | |
let palette = [] | |
let xPosition = 0 | |
let yPosition = 0 | |
let subtitleIndex = 0 | |
let hasBitmap = false | |
let segment | |
let segments = this.segments | |
segments.length = 0 | |
let subtitleFinished = true | |
while (offset < view.byteLength) { | |
let magic = view.getUint16(offset + 0) | |
if (magic !== 0x5047) { | |
break | |
} | |
subtitleFinished = false | |
let pts = view.getUint32(offset + 2) / 90 | |
let dts = view.getUint32(offset + 6) / 90 | |
let segmentType = view.getUint8(offset + 10) | |
let dataLength = view.getUint16(offset + 11) | |
// console.log(magic, pts, dts, segmentType, dataLength) | |
offset += headerSize | |
if (segmentType !== 0x80) { | |
// console.log("0x" + segmentType.toString(16), dataLength, data.subarray(offset, offset + dataLength)) | |
} | |
let width, height | |
switch (segmentType) { | |
case 0x16: // SEGMENT | |
width = view.getUint16(offset + 0) | |
height = view.getUint16(offset + 2) | |
let framerate = view.getUint8(offset + 4) | |
subtitleIndex = view.getUint16(offset + 5) | |
let subtitleState = view.getUint8(offset + 7) | |
let paletteUpdateFlag = view.getUint8(offset + 8) | |
let paletteId = view.getUint8(offset + 9) | |
let numTimesBlocks = view.getUint8(offset + 10) | |
// console.log(width, height, subtitleIndex, numTimesBlocks) | |
for (let i = 0; i < numTimesBlocks; i++) { | |
let forced = view.getUint8(offset + 11 + i * 8 + 3) | |
xPosition = view.getUint16(offset + 11 + i * 8 + 4) | |
yPosition = view.getUint16(offset + 11 + i * 8 + 6) | |
// console.log(forced, xPosition, yPosition) | |
} | |
let startTime = pts | |
hasBitmap = false | |
segment = segments[subtitleIndex] = { | |
width: width, | |
height: height, | |
startTime: pts / 1000, | |
bitmap: null | |
} | |
break | |
case 0x17: // WINDOW | |
let numSizeBlocks = view.getUint8(offset + 0) | |
for (let i = 0; i < numSizeBlocks; i++) { | |
let blockId = view.getUint8(offset + 1 + i * 9 + 0) | |
let x = view.getUint16(offset + 1 + i * 9 + 1) | |
let y = view.getUint16(offset + 1 + i * 9 + 3) | |
width = view.getUint16(offset + 1 + i * 9 + 5) | |
height = view.getUint16(offset + 1 + i * 9 + 7) | |
// console.log('window', blockId, x, y, width, height) | |
} | |
break | |
case 0x14: // PALETTE | |
let unknown2 = view.getUint16(offset + 0) | |
let numEntries = (dataLength - 2) / 5 | |
for (let i = 0; i < numEntries; i++) { | |
let index = view.getUint8(offset + 2 + i * 5 + 0) | |
let y = view.getUint8(offset + 2 + i * 5 + 1) - 16 | |
let cr = view.getUint8(offset + 2 + i * 5 + 2) - 128 | |
let cb = view.getUint8(offset + 2 + i * 5 + 3) - 128 | |
let alpha = view.getUint8(offset + 2 + i * 5 + 4) | |
let r = Math.min(Math.max(Math.round(1.1644 * y + 1.596 * cr), 0), 255) | |
let g = Math.min(Math.max(Math.round(1.1644 * y - 0.813 * cr - 0.391 * cb), 0), 255) | |
let b = Math.min(Math.max(Math.round(1.1644 * y + 2.018 * cb), 0), 255) | |
palette[index] = [r, g, b, alpha] | |
} | |
break | |
case 0x15: // BITMAP | |
hasBitmap = true | |
let objectId = view.getUint16(offset + 0) | |
let version = view.getUint8(offset + 2) | |
let continuation = view.getUint8(offset + 3) | |
console.log(continuation.toString(16)) | |
if ((continuation & 0x80) !== 0) { | |
let bitmapDataLength = view.getUint32(offset + 3) & 0xFFFFFF | |
width = view.getUint16(offset + 7) | |
height = view.getUint16(offset + 9) | |
let pixels = this.ctx.createImageData(width, height) | |
segment.bitmap = { | |
width: width, | |
height: height, | |
pixels: pixels, | |
x: xPosition, | |
y: yPosition | |
} | |
let imdata = pixels.data | |
let bitmapoffset = offset + 11 | |
let pixelpos = 0 | |
let x = 0 | |
let y = 0 | |
let eol = false | |
// console.log('bitmap', objectId, width, height) | |
while (y < height) { | |
let byte = view.getUint8(bitmapoffset++) | |
let color = byte | |
let count = 1 | |
if (byte === 0) { | |
byte = view.getUint8(bitmapoffset++) | |
count = 0 | |
let flag = byte >> 6 & 0x03 | |
if (flag === 0) { | |
count = byte & 0x3F | |
if (count === 0) eol = true | |
} else if (flag === 1) { | |
count = view.getUint16(bitmapoffset-1) & 0x3FFF | |
bitmapoffset++ | |
} else if (flag === 2) { | |
count = byte & 0x3F | |
color = view.getUint8(bitmapoffset++) | |
} else if (flag === 3) { | |
count = view.getUint16(bitmapoffset-1) & 0x3FFF | |
bitmapoffset++ | |
color = view.getUint8(bitmapoffset++) | |
} | |
} | |
if (!eol) { | |
let xe = x + count | |
if (xe > width) { | |
console.log('too long line', xe, width) | |
} | |
for (; x < xe; x++) { | |
let pos = x + y * width | |
imdata[pos * 4 + 0] = palette[color][0] | |
imdata[pos * 4 + 1] = palette[color][1] | |
imdata[pos * 4 + 2] = palette[color][2] | |
imdata[pos * 4 + 3] = palette[color][3] | |
} | |
} else { | |
/*if (x < width) { | |
for (; x < width; x++) { | |
ctx.fillRect(xPosition + x, yPosition + y, 1, 1) | |
} | |
}*/ | |
eol = false | |
x = 0 | |
y++ | |
} | |
} | |
} else { | |
console.log('aaaa') | |
} | |
break | |
case 0x80: // END | |
subtitleFinished = true | |
break | |
} | |
offset += dataLength | |
} | |
this.loaded = true | |
return true | |
} | |
HDMVPGS.prototype.render = function (timestamp, ctx) { | |
ctx = ctx || this.ctx | |
let canvas = ctx.canvas | |
let visibleSegment = null | |
for (var i = 0; i < this.segments.length; i++) { | |
if (this.segments[i].startTime < timestamp) visibleSegment = this.segments[i] | |
if (this.segments[i].startTime > timestamp) break | |
} | |
if (!visibleSegment) { | |
ctx.clearRect(0, 0, canvas.width, canvas.height) | |
return | |
} | |
if (visibleSegment === this.lastVisibleSegment) return | |
this.lastVisibleSegment = visibleSegment | |
if (canvas.width !== visibleSegment.width) { | |
canvas.width = visibleSegment.width | |
} | |
if (canvas.height !== visibleSegment.height) { | |
canvas.height = visibleSegment.height | |
} | |
console.log('render') | |
ctx.clearRect(0, 0, canvas.width, canvas.height) | |
if (!visibleSegment || !visibleSegment.bitmap) return | |
ctx.putImageData(visibleSegment.bitmap.pixels, visibleSegment.bitmap.x, visibleSegment.bitmap.y) | |
} | |
HDMVPGS.prototype.clear = function (ctx) { | |
ctx = ctx || this.ctx | |
let canvas = ctx.canvas | |
ctx.clearRect(0, 0, canvas.width, canvas.height) | |
this.lastVisibleSegment = null | |
} | |
HDMVPGS.prototype.destroy = function (ctx) { | |
this.clear(ctx) | |
this.segments.length = 0 | |
this.loaded = false | |
} | |
HDMVPGS.prototype.resize = function (width, height, left, top, ctx) { | |
ctx = ctx || this.ctx | |
ctx.canvas.style.width = width + 'px' | |
ctx.canvas.style.height = height + 'px' | |
ctx.canvas.style.left = left + 'px' | |
ctx.canvas.style.top = top + 'px' | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="utf-8"/> | |
<title>parse pgs</title> | |
<link rel="stylesheet" type="text/css" href="https://djazz.se/nas/video/player/node_modules/video.js/dist/video-js.css"> | |
<link rel="stylesheet" type="text/css" href="videojs.hdmvpgs.css"> | |
<script type="text/javascript" src="https://djazz.se/nas/video/player/node_modules/video.js/dist/video.js"></script> | |
<script type="text/javascript" src="https://djazz.se/nas/video/player/node_modules/videojs-playlist/dist/videojs-playlist.js"></script> | |
<script type="text/javascript" src="https://djazz.se/nas/video/player/node_modules/videojs-hotkeys/videojs.hotkeys.min.js"></script> | |
<script type="text/javascript" src="hdmvpgs.js"></script> | |
<script type="text/javascript" src="videojs.hdmvpgs.js"></script> | |
<style type="text/css"> | |
</style> | |
</head> | |
<body> | |
<video controls class="video-js" id="player" preload="none" playsinline webkit-playsinline></video> | |
<script type="text/javascript" src="english.sup.js"></script> | |
<script type="text/javascript" src="english-forced.sup.js"></script> | |
<script type="text/javascript" src="parse-pgs.js"></script> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use strict' | |
const vjs = window.videojs('player', {}, () => { | |
vjs.hotkeys({ | |
seekStep: 10, | |
enableVolumeScroll: false, | |
alwaysCaptureHotkeys: true, | |
enableInactiveFocus: true, | |
enableFullscreen: false | |
}) | |
pgsPlugin = vjs.hdmvpgs() | |
}) | |
let playlist = [{ | |
sources: [{ | |
src: 'episode.mp4', | |
type: 'video/mp4' | |
}, { | |
src: 'episode.ja.aac', | |
type: 'audio/aac', | |
language: 'ja' | |
}] | |
// poster: thumbnails[i]] | |
}] | |
vjs.audioTracks().addEventListener('change', function () { | |
console.log('vvvvvvv') | |
let tracks = vjs.audioTracks() | |
for (let i = 0; i < tracks.length; i++) { | |
let track = tracks[i] | |
if (track.enabled) { | |
console.log(track.label) | |
} | |
} | |
}) | |
let audioTracks = vjs.audioTracks() | |
audioTracks.addTrack(new videojs.AudioTrack({ | |
id: 'english', | |
kind: 'main', | |
label: 'English', | |
language: 'en', | |
enabled: true | |
})) | |
audioTracks.addTrack(new videojs.AudioTrack({ | |
id: 'japanese', | |
kind: 'main', | |
label: 'Japanese', | |
language: 'ja', | |
enabled: false | |
})) | |
console.log(vjs.audioTracks()) | |
vjs.playlist(playlist, 0) | |
vjs.playlist.autoadvance(0) | |
let pgsPlugin | |
let overrideTime = 0 | |
vjs.on('playlistitem', (e, data) => { | |
console.log(data) | |
let subfile = data.sources[0].src.replace('.mp4', '.sup') | |
pgsPlugin.loadSubtitle(file2, 'English (Forced)', 'en', false) | |
pgsPlugin.loadSubtitle(file, 'English', 'en', true) | |
let index = vjs.playlist.currentItem() | |
if (overrideTime) { | |
vjs.currentTime(overrideTime) | |
} | |
// document.location.hash = '#' + (vjs.playlist.currentItem() + 1) + (vjs.currentTime() > 0 ? ':' + vjs.currentTime() : '') | |
// resize() | |
}) | |
vjs.on('play', () => { | |
if (overrideTime) { | |
vjs.currentTime(overrideTime) | |
overrideTime = 0 | |
} | |
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.vjs-hdmvpgs { | |
position: absolute; | |
top: 0; | |
width: 100%; | |
height: 100%; | |
pointer-events: none; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/*! videojs-hdmvpgs */ | |
(function (videojs, HDMVPGS) { | |
'use strict'; | |
function vjs_hdmvpgs (options) { | |
options = options || {} | |
var cur_id = 0, | |
id_count = 0, | |
overlay = document.createElement('div'), | |
clockRate = options.rate || 1, | |
delay = options.delay || 0, | |
player = this, | |
OverlayComponent = null, | |
trackIdMap = {}, | |
tracks = player.textTracks(), | |
isTrackSwitching = false, | |
canvas = document.createElement('canvas'), | |
ctx = canvas.getContext('2d'), | |
renderers = [] | |
overlay.appendChild(canvas) | |
overlay.className = 'vjs-hdmvpgs' | |
OverlayComponent = { | |
name: function () { | |
return 'HDMVPGSOverlay' | |
}, | |
el: function () { | |
return overlay | |
} | |
} | |
player.addChild(OverlayComponent, {}, 3) | |
function getCurrentTime() { | |
return player.currentTime() - delay | |
} | |
player.on('play', function () { | |
renderers[cur_id].loadBuffer() | |
}) | |
player.on('pause', function () { | |
}) | |
player.on('seeking', function () { | |
}) | |
player.on('timeupdate', function () { | |
renderers[cur_id].render(getCurrentTime()) | |
}) | |
/* | |
function updateClockRate() { | |
clockRate = player.playbackRate() | |
} | |
updateClockRate() | |
player.on('ratechange', updateClockRate) | |
*/ | |
function updateDisplayArea() { | |
setTimeout(function () { | |
// player might not have information on video dimensions when using external providers | |
var videoWidth = options.videoWidth || player.videoWidth() || player.el().offsetWidth, | |
videoHeight = options.videoHeight || player.videoHeight() || player.el().offsetHeight, | |
videoOffsetWidth = player.el().offsetWidth, | |
videoOffsetHeight = player.el().offsetHeight, | |
ratio = Math.min(videoOffsetWidth / videoWidth, videoOffsetHeight / videoHeight), | |
subsWrapperWidth = videoWidth * ratio, | |
subsWrapperHeight = videoHeight * ratio, | |
subsWrapperLeft = (videoOffsetWidth - subsWrapperWidth) / 2, | |
subsWrapperTop = (videoOffsetHeight - subsWrapperHeight) / 2; | |
renderers[cur_id].resize(subsWrapperWidth, subsWrapperHeight, subsWrapperLeft, subsWrapperTop) | |
}, 100) | |
} | |
window.addEventListener('resize', updateDisplayArea) | |
player.on('loadedmetadata', updateDisplayArea) | |
player.on('resize', updateDisplayArea) | |
player.on('fullscreenchange', updateDisplayArea) | |
player.on('dispose', function () { | |
// clean up | |
this.renderers.forEach(function (r) { | |
r.destroy() | |
}) | |
window.removeEventListener('resize', updateDisplayArea) | |
}) | |
tracks.on('change', function () { | |
if (isTrackSwitching) { | |
return | |
} | |
var activeTrack = this.tracks_.find(function (track) { | |
return track.mode === 'showing' | |
}) | |
if (activeTrack) { | |
overlay.style.display = '' | |
switchTrackTo(trackIdMap[activeTrack.language + activeTrack.label]); | |
} else { | |
overlay.style.display = 'none' | |
} | |
}) | |
function addTrack (url, opts) { | |
var newTrack = player.addRemoteTextTrack({ | |
src: "", | |
kind: 'subtitles', | |
label: opts.label || 'HDMVPGS #' + cur_id, | |
srclang: opts.srclang || 'vjs-hdmvpgs-' + cur_id, | |
default: opts.switchImmediately | |
}, true) | |
trackIdMap[newTrack.srclang + newTrack.label] = cur_id | |
if(!opts.switchImmediately) { | |
// fix multiple track selected highlight issue | |
for (var t = 0; t < tracks.length; t++) { | |
if (tracks[t].mode === "showing") { | |
tracks[t].mode = "showing" | |
} | |
} | |
return | |
} | |
isTrackSwitching = true | |
for (var t = 0; t < tracks.length; t++) { | |
if (tracks[t].label == newTrack.label && tracks[t].language == newTrack.srclang) { | |
if (tracks[t].mode !== "showing") { | |
tracks[t].mode = "showing" | |
} | |
} else { | |
if (tracks[t].mode === "showing") { | |
tracks[t].mode = "disabled" | |
} | |
} | |
} | |
isTrackSwitching = false | |
} | |
function switchTrackTo (selected_track_id) { | |
renderers.forEach(function (r) { | |
r.clear() | |
}) | |
cur_id = selected_track_id | |
if (cur_id == undefined) { | |
// case when we switch to regular non PGS closed captioning | |
return | |
} | |
updateDisplayArea() | |
if (!player.paused()) { | |
renderers[cur_id].loadBuffer() | |
} | |
renderers[cur_id].render(getCurrentTime()) | |
} | |
function loadSubtitle (url, label, srclang, switchImmediately) { | |
var old_id = cur_id | |
if (switchImmediately && renderers[cur_id]) { | |
renderers.forEach(function (r) { | |
r.clear() | |
}) | |
} | |
let len = url.length | |
let data = new Uint8Array(len) | |
for (let i = 0; i < len; i++) { | |
data[i] = url.charCodeAt(i) | |
} | |
// load data with fetch/xhr | |
{ | |
cur_id = ++id_count | |
renderers[cur_id] = new HDMVPGS(ctx) | |
renderers[cur_id].buffer = data.buffer | |
updateDisplayArea() | |
addTrack('url', { label: label, srclang: srclang, switchImmediately: switchImmediately }) | |
if (!switchImmediately) { | |
cur_id = old_id | |
} | |
} | |
} | |
return { | |
loadSubtitle: loadSubtitle | |
} | |
} | |
videojs.registerPlugin('hdmvpgs', vjs_hdmvpgs) | |
}(window.videojs, window.HDMVPGS)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Was this ever functional? I have a significant need a canvas renderer for PGS subs.