Skip to content

Instantly share code, notes, and snippets.

@daniel-j
Last active February 8, 2024 09:27
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save daniel-j/2da75add26f548e57aae85d6471667dd to your computer and use it in GitHub Desktop.
Save daniel-j/2da75add26f548e57aae85d6471667dd to your computer and use it in GitHub Desktop.
HDMV/PGS subtitle Javascript parser
'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'
}
<!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>
'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
}
})
.vjs-hdmvpgs {
position: absolute;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/*! 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))
@Xaekai
Copy link

Xaekai commented Feb 14, 2022

Was this ever functional? I have a significant need a canvas renderer for PGS subs.

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