Initial commit

This commit is contained in:
David Schulte
2024-06-07 20:09:58 +02:00
commit 4cc048910d
19 changed files with 2487 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/dist/
/node_modules/
/.idea/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 David Schulte
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -0,0 +1 @@
This library renders the graphical subtitles format PGS _(.sub / .sup)_ in the browser.
+1867
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "libpgs",
"version": "0.1.0",
"author": "David Schulte",
"license": "MIT",
"description": "Renderer for graphical subtitles (PGS) in the browser. ",
"keywords": [
"libpgs",
"pgs",
"pgssub",
"subtitle"
],
"homepage": "https://github.com/Arcus92/libpgs-js",
"repository": {
"type": "git",
"url": "git+https://github.com/Arcus92/libpgs-js.git"
},
"bugs": {
"url": "https://github.com/Arcus92/libpgs-js/issues"
},
"main": "dist/libpgs.js",
"types": "dist/libpgs.d.ts",
"scripts": {
"build": "webpack -c webpack.configuration.js",
"clear": "rm -rf ./dist"
},
"devDependencies": {
"@tsconfig/recommended": "^1.0.6",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"typescript": "^5.4.5",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4"
}
}
+2
View File
@@ -0,0 +1,2 @@
import {SubtitleRenderer} from "./subtitleRenderer";
export {SubtitleRenderer}
+70
View File
@@ -0,0 +1,70 @@
import {BigEndianBinaryReader} from "../utils/bigEndianBinaryReader";
import {PresentationCompositionSegment} from "./presentationCompositionSegment";
import {PaletteDefinitionSegment} from "./paletteDefinitionSegment";
import {ObjectDefinitionSegment} from "./objectDefinitionSegment";
import {WindowDefinitionSegment} from "./windowDefinitionSegment";
import {SegmentType} from "./segmentType";
export class DisplaySet {
public presentationTimestamp: number = 0;
public decodingTimestamp: number = 0;
public presentationComposition?: PresentationCompositionSegment;
public paletteDefinitions: PaletteDefinitionSegment[] = [];
public objectDefinitions: ObjectDefinitionSegment[] = [];
public windowDefinitions: WindowDefinitionSegment[] = [];
public read(reader: BigEndianBinaryReader, includeHeader: boolean) {
// Clear
this.presentationTimestamp = 0;
this.decodingTimestamp = 0;
this.presentationComposition = undefined;
this.paletteDefinitions = [];
this.objectDefinitions = [];
this.windowDefinitions = [];
while (true)
{
// The header is included before every segment. Even for the end segment.
if (includeHeader)
{
const magicNumber = reader.readUInt16();
if (magicNumber != 0x5047) {
throw new Error("Invalid magic number!");
}
this.presentationTimestamp = reader.readUInt32();
this.decodingTimestamp = reader.readUInt32();
}
const type = reader.readUInt8();
const size = reader.readUInt16()
switch (type) {
case SegmentType.paletteDefinition:
const pds = new PaletteDefinitionSegment();
pds.read(reader, size);
this.paletteDefinitions.push(pds);
break;
case SegmentType.objectDefinition:
const ods = new ObjectDefinitionSegment();
ods.read(reader, size);
this.objectDefinitions.push(ods);
break;
case SegmentType.presentationComposition:
const pcs = new PresentationCompositionSegment();
pcs.read(reader, size);
this.presentationComposition = pcs;
break;
case SegmentType.windowDefinition:
const wds = new WindowDefinitionSegment();
wds.read(reader, size);
this.windowDefinitions.push(wds);
break;
case SegmentType.end:
return;
default:
throw new Error(`Unsupported segment type ${type}`);
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import { BigEndianBinaryReader } from "../utils/bigEndianBinaryReader";
import {Segment} from "./segment";
import {SegmentType} from "./segmentType";
export class EndSegment implements Segment {
public get segmentType(): number {
return SegmentType.end;
}
public read(reader: BigEndianBinaryReader, length: number): void {
// No data to read
}
}
+42
View File
@@ -0,0 +1,42 @@
import {Segment} from "./segment";
import {SegmentType} from "./segmentType";
import {BigEndianBinaryReader} from "../utils/bigEndianBinaryReader";
export class ObjectDefinitionSegment implements Segment {
public id: number = 0;
public versionNumber: number = 0;
public lastInSequenceFlag: number = 0;
public width: number = 0;
public height: number = 0;
public dataLength: number = 0;
public data?: Uint8Array;
public get isFirstInSequence(): boolean {
return (this.lastInSequenceFlag & 0x80) != 0;
}
public get isLastInSequence(): boolean {
return (this.lastInSequenceFlag & 0x40) != 0;
}
public get segmentType(): number {
return SegmentType.objectDefinition;
}
public read(reader: BigEndianBinaryReader, length: number): void {
this.id = reader.readUInt16();
this.versionNumber = reader.readUInt8();
this.lastInSequenceFlag = reader.readUInt8();
// This is the total data length of ALL segments.
// We need to add all data segments to encode the image.
if (this.isFirstInSequence) {
this.dataLength = reader.readUInt24();
this.width = reader.readUInt16();
this.height = reader.readUInt16();
this.data = reader.readBytes(length - 11);
} else {
this.data = reader.readBytes(length - 4);
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import {Segment} from "./segment";
import {SegmentType} from "./segmentType";
import {BigEndianBinaryReader} from "../utils/bigEndianBinaryReader";
export class PaletteEntry {
public y: number = 0;
public cr: number = 0;
public cb: number = 0;
public r: number = 0;
public g: number = 0;
public b: number = 0;
public a: number = 0;
}
export class PaletteDefinitionSegment implements Segment {
public id: number = 0;
public versionNumber: number = 0;
public entries: { [key: number]: PaletteEntry } = {};
public get segmentType(): number {
return SegmentType.paletteDefinition;
}
public read(reader: BigEndianBinaryReader, length: number): void {
this.id = reader.readUInt8();
this.versionNumber = reader.readUInt8();
const count = (length - 2) / 5;
this.entries = {};
for (let i = 0; i < count; i++) {
const id = reader.readUInt8();
const entry = new PaletteEntry();
// Load the YCrCbA value
entry.y = reader.readUInt8();
entry.cr = reader.readUInt8();
entry.cb = reader.readUInt8();
entry.a = reader.readUInt8();
// Also store the RGBA value
const y = entry.y;
const cb = entry.cb - 128;
const cr = entry.cr - 128;
entry.r = PaletteDefinitionSegment.clamp(Math.round(y + 1.40200 * cr), 0, 255);
entry.g = PaletteDefinitionSegment.clamp(Math.round(y - 0.34414 * cb - 0.71414 * cr), 0, 255);
entry.b = PaletteDefinitionSegment.clamp(Math.round(y + 1.77200 * cb), 0, 255);
this.entries[id] = entry;
}
}
private static clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max));
}
}
+62
View File
@@ -0,0 +1,62 @@
import {Segment} from "./segment";
import {SegmentType} from "./segmentType";
import {BigEndianBinaryReader} from "../utils/bigEndianBinaryReader";
export class CompositionObject {
public id: number = 0;
public windowId: number = 0;
public croppedFlag: number = 0;
public horizontalPosition: number = 0;
public verticalPosition: number = 0;
public croppingHorizontalPosition: number = 0;
public croppingVerticalPosition: number = 0;
public croppingWidth: number = 0;
public croppingHeightPosition: number = 0;
public get hasCropping(): boolean {
return (this.croppedFlag & 0x80) != 0
}
}
export class PresentationCompositionSegment implements Segment {
public width: number = 0;
public height: number = 0;
public frameRate: number = 0;
public compositionNumber: number = 0;
public compositionState: number = 0;
public paletteUpdateFlag: number = 0;
public paletteId: number = 0;
public compositionObjects: CompositionObject[] = [];
public get segmentType(): number {
return SegmentType.presentationComposition;
}
public read(reader: BigEndianBinaryReader, length: number): void {
this.width = reader.readUInt16();
this.height = reader.readUInt16();
this.frameRate = reader.readUInt8();
this.compositionNumber = reader.readUInt16();
this.compositionState = reader.readUInt8();
this.paletteUpdateFlag = reader.readUInt8();
this.paletteId = reader.readUInt8();
const count = reader.readUInt8();
this.compositionObjects = [];
for (let i = 0; i < count; i++) {
const compositionObject = new CompositionObject();
compositionObject.id = reader.readUInt16();
compositionObject.windowId = reader.readUInt8();
compositionObject.croppedFlag = reader.readUInt8();
compositionObject.horizontalPosition = reader.readUInt16();
compositionObject.verticalPosition = reader.readUInt16();
if (compositionObject.hasCropping) {
compositionObject.croppingHorizontalPosition = reader.readUInt16();
compositionObject.croppingVerticalPosition = reader.readUInt16();
compositionObject.croppingWidth = reader.readUInt16();
compositionObject.croppingHeightPosition = reader.readUInt16();
}
this.compositionObjects.push(compositionObject);
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import {BigEndianBinaryReader} from "../utils/bigEndianBinaryReader";
export interface Segment {
/// Gets the segment type
get segmentType() : number;
/// Reads the segment from the data stream
read(reader: BigEndianBinaryReader, length: number): void;
}
+7
View File
@@ -0,0 +1,7 @@
export enum SegmentType {
paletteDefinition = 0x14,
objectDefinition = 0x15,
presentationComposition = 0x16,
windowDefinition = 0x17,
end = 0x80,
}
+35
View File
@@ -0,0 +1,35 @@
import {Segment} from "./segment";
import {SegmentType} from "./segmentType";
import {BigEndianBinaryReader} from "../utils/bigEndianBinaryReader";
export class WindowDefinition {
public id: number = 0;
public horizontalPosition: number = 0;
public verticalPosition: number = 0;
public width: number = 0;
public height: number = 0;
}
export class WindowDefinitionSegment implements Segment {
public windows: WindowDefinition[] = [];
public get segmentType(): number {
return SegmentType.windowDefinition;
}
public read(reader: BigEndianBinaryReader, length: number): void {
const count = reader.readUInt8();
this.windows = [];
for (let i = 0; i < count; i++) {
const window = new WindowDefinition();
window.id = reader.readUInt8();
window.horizontalPosition = reader.readUInt16();
window.verticalPosition = reader.readUInt16();
window.width = reader.readUInt16();
window.height = reader.readUInt16();
this.windows.push(window);
}
}
}
+141
View File
@@ -0,0 +1,141 @@
import {DisplaySet} from "./pgs/displaySet";
import {BigEndianBinaryReader} from "./utils/bigEndianBinaryReader";
import {runLengthEncoding} from "./utils/runLengthEncoding";
import {CompositionObject} from "./pgs/presentationCompositionSegment";
export class SubtitleRenderer {
private readonly canvas: HTMLCanvasElement;
private readonly context: CanvasRenderingContext2D;
private readonly video: HTMLVideoElement;
private displaySets: DisplaySet[] = [];
private currentIndex: number = -1;
public constructor(canvas: HTMLCanvasElement, video: HTMLVideoElement) {
this.canvas = canvas;
this.video = video;
this.context = canvas.getContext('2d')!;
this.video.addEventListener('timeupdate', (e) => this.onTimeUpdate(e));
}
private onTimeUpdate(e: Event) {
this.renderDisplaySetAt(this.video.currentTime);
}
public async loadFromUrlAsync(url: string) {
const result = await fetch(url);
const buffer = await result.arrayBuffer();
this.loadFromBuffer(buffer);
}
public loadFromBuffer(buffer: ArrayBuffer) {
this.displaySets = [];
const reader = new BigEndianBinaryReader(new Uint8Array(buffer));
while (reader.position < reader.length) {
const displaySet = new DisplaySet();
displaySet.read(reader, true);
this.displaySets.push(displaySet);
}
}
public renderDisplaySetAt(time: number): void {
let index = -1;
time = time * 1000 * 90;
for (const displaySet of this.displaySets) {
if (displaySet.presentationTimestamp > time) {
break;
}
index++;
}
if (this.currentIndex == index) return;
this.currentIndex = index;
console.log('Subtitle update!');
if (index < 0) return;
const displaySet= this.displaySets[index];
this.renderDisplaySet(displaySet);
}
private renderDisplaySet(displaySet: DisplaySet) {
if (!displaySet.presentationComposition) return;
this.canvas.width = displaySet.presentationComposition.width;
this.canvas.height = displaySet.presentationComposition.height;
for (const composition of displaySet.presentationComposition.compositionObjects) {
this.renderComposition(displaySet, composition);
}
}
private renderComposition(displaySet: DisplaySet, composition: CompositionObject): void {
if (!displaySet.presentationComposition) return;
let window = displaySet.windowDefinitions
.flatMap(w => w.windows).find(w => w.id === composition.windowId);
if (!window) return;
const pixelData = this.getPixelDataFromComposition(displaySet, composition);
if (pixelData) {
this.context.drawImage(pixelData, window.horizontalPosition, window.verticalPosition);
}
}
private getPixelDataFromComposition(displaySet: DisplaySet, composition: CompositionObject): HTMLCanvasElement | undefined {
if (!displaySet.presentationComposition) return undefined;
let palette = displaySet.paletteDefinitions
.find(p => p.id === displaySet.presentationComposition?.paletteId);
if (!palette) return undefined;
// Collect meta data
let width: number = 0;
let height: number = 0;
let dataLength: number = 0;
const dataChunks: Uint8Array[] = [];
for (const ods of displaySet.objectDefinitions) {
if (ods.id != composition.id) continue;
if (ods.isFirstInSequence) {
width = ods.width;
height = ods.height;
}
if (ods.data) {
dataLength += ods.data.length;
dataChunks.push(ods.data);
}
}
if (dataLength == 0) {
return undefined;
}
// Merge the data into one binary blob.
// TODO: Make this more memory efficient...
const runLengthData = new Uint8Array(dataLength);
let offset: number = 0;
for (const dataChunk of dataChunks) {
runLengthData.set(dataChunk, offset);
offset += dataChunk.length;
}
// Building the subtitle image.
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d')!;
canvas.width = width;
canvas.height = height;
const imageData = context.createImageData(width, height);
const buffer = imageData.data;
// The pixel data is run-length encoded. The value is the palette entry index.
runLengthEncoding(runLengthData, (idx, x, y, value) => {
const col = palette?.entries[value];
if (!col) return;
buffer[idx * 4] = col.r;
buffer[idx * 4 + 1] = col.g;
buffer[idx * 4 + 2] = col.b;
buffer[idx * 4 + 3] = col.a;
});
context.putImageData(imageData, 0, 0);
return canvas;
}
}
+48
View File
@@ -0,0 +1,48 @@
export class BigEndianBinaryReader {
private readonly buffer: Uint8Array;
private current: number = 0;
public constructor(buffer: Uint8Array) {
this.buffer = buffer;
}
public get position(): number {
return this.current;
}
public get length(): number {
return this.buffer.length;
}
public readUInt8(): number {
return this.buffer[this.current++];
}
public readUInt16(): number {
const b1 = this.readUInt8();
const b2 = this.readUInt8();
return (b1 << 8) + b2;
}
public readUInt24(): number {
const b1 = this.readUInt8();
const b2 = this.readUInt8();
const b3 = this.readUInt8();
return (b1 << 16) + (b2 << 8) + b3;
}
public readUInt32(): number {
const b1 = this.readUInt8();
const b2 = this.readUInt8();
const b3 = this.readUInt8();
const b4 = this.readUInt8();
return (b1 << 24) + (b2 << 16) + (b3 << 8) + b4;
}
public readBytes(count: number): Uint8Array {
const data = this.buffer.slice(this.current, this.current + count);
this.current += count;
return data;
}
}
+33
View File
@@ -0,0 +1,33 @@
export function runLengthEncoding(data: Uint8Array, setter: (idx: number, x: number, y: number, value: number) => void): void {
let x = 0;
let y = 0;
let p = 0;
let idx = 0;
while (p < data.length) {
const byte1 = data[p++];
// Raw byte
if (byte1 != 0x00) {
setter(idx++, x++, y, byte1);
continue;
}
const byte2 = data[p++];
// End of line
if (byte2 == 0x00) {
x = 0;
y++;
continue;
}
const bit8 = (byte2 & 0b10000000) != 0;
const bit7 = (byte2 & 0b01000000) != 0;
let num = byte2 & 0b00111111;
if (bit7) {
num = (num << 8) + data[p++];
}
const value = bit8 ? data[p++] : 0x00;
for (let i = 0; i < num; i++) {
setter(idx++, x++, y, value);
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@tsconfig/recommended/tsconfig.json",
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"declaration": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
+24
View File
@@ -0,0 +1,24 @@
const path = require("path");
module.exports = {
mode: 'production',
entry: './src/libpgs.ts',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'libpgs.js',
library: 'libpgs',
libraryTarget: 'umd'
}
}