Added better error handling when loading PGS streams.

This commit is contained in:
David Schulte
2024-07-17 23:10:49 +02:00
parent 4f342ba3e8
commit 88b5f104bc
7 changed files with 142 additions and 41 deletions
+34 -21
View File
@@ -4,7 +4,7 @@ import {PaletteDefinitionSegment} from "./paletteDefinitionSegment";
import {ObjectDefinitionSegment} from "./objectDefinitionSegment";
import {WindowDefinitionSegment} from "./windowDefinitionSegment";
import {SegmentType} from "./segmentType";
import {AsyncBinaryReader} from "../utils/asyncBinaryReader";
import {PgsRendererResult} from "../pgsRendererResult";
/**
* The PGS display set holds all data for the current subtitle update at a given timestamp.
@@ -23,7 +23,7 @@ export class DisplaySet {
* @param includeHeader If true, the magic-number and timestamps are read. If false, reading starts at the first
* segment.
*/
public async read(reader: BigEndianBinaryReader, includeHeader: boolean) {
public async read(reader: BigEndianBinaryReader, includeHeader: boolean): Promise<PgsRendererResult> {
// Clear
this.presentationTimestamp = 0;
@@ -33,12 +33,6 @@ export class DisplaySet {
this.objectDefinitions = [];
this.windowDefinitions = [];
// Handles async readers
let asyncReader: AsyncBinaryReader | undefined = undefined;
if ('requestData' in reader.baseReader) {
asyncReader = reader.baseReader as AsyncBinaryReader;
}
while (true)
{
let presentationTimestamp: number = 0;
@@ -47,35 +41,46 @@ export class DisplaySet {
// The header is included before every segment. Even for the end segment.
if (includeHeader)
{
await asyncReader?.requestData(10);
if (!await reader.requestData(10)) {
console.error(`[libpgs] Unexpected end of stream at 0x${reader.position.toString(16)}`);
return PgsRendererResult.ErrUnexpectedEndOfStream;
}
const magicNumber = reader.readUInt16();
if (magicNumber != 0x5047) {
throw new Error("Invalid magic number!");
console.error(`[libpgs] Invalid magic number: Expected: 0x5047 - Given: 0x${magicNumber.toString(16)}`);
return PgsRendererResult.ErrInvalidMagicNumber;
}
presentationTimestamp = reader.readUInt32();
decodingTimestamp = reader.readUInt32();
}
await asyncReader?.requestData(3);
const type = reader.readUInt8();
const size = reader.readUInt16()
if (!await reader.requestData(3)) {
console.error(`[libpgs] Unexpected end of stream at 0x${reader.position.toString(16)}`);
return PgsRendererResult.ErrUnexpectedEndOfStream;
}
const segmentType = reader.readUInt8();
const segmentSize = reader.readUInt16();
const segmentStart = reader.position;
await asyncReader?.requestData(size);
switch (type) {
if (!await reader.requestData(segmentSize)) {
console.error(`[libpgs] Unexpected end of stream at 0x${reader.position.toString(16)}`);
return PgsRendererResult.ErrUnexpectedEndOfStream;
}
switch (segmentType) {
case SegmentType.paletteDefinition:
const pds = new PaletteDefinitionSegment();
pds.read(reader, size);
pds.read(reader, segmentSize);
this.paletteDefinitions.push(pds);
break;
case SegmentType.objectDefinition:
const ods = new ObjectDefinitionSegment();
ods.read(reader, size);
ods.read(reader, segmentSize);
this.objectDefinitions.push(ods);
break;
case SegmentType.presentationComposition:
const pcs = new PresentationCompositionSegment();
pcs.read(reader, size);
pcs.read(reader, segmentSize);
this.presentationComposition = pcs;
// SubtitleEdit only writes the relevant timestamp to the PCS.
@@ -84,13 +89,21 @@ export class DisplaySet {
break;
case SegmentType.windowDefinition:
const wds = new WindowDefinitionSegment();
wds.read(reader, size);
wds.read(reader, segmentSize);
this.windowDefinitions.push(wds);
break;
case SegmentType.end:
return;
return PgsRendererResult.Success;
default:
throw new Error(`Unsupported segment type ${type}`);
console.error(`[libpgs] Unknown segment type: 0x${segmentType.toString(16)}`);
return PgsRendererResult.ErrUnknownSegment;
}
// Validates the current stream position
const expectedPosition = segmentStart + segmentSize;
if (reader.position !== expectedPosition) {
console.error(`[libpgs] Invalid stream position after segment. Expected: 0x${expectedPosition.toString(16)} - Given: 0x${reader.position.toString(16)}`);
return PgsRendererResult.ErrStreamPositionMismatch;
}
}
}
+8 -2
View File
@@ -1,5 +1,6 @@
import {PgsRendererOptions} from "./pgsRendererOptions";
import {PgsRendererHelper} from "./pgsRendererHelper";
import {PgsRendererResult} from "./pgsRendererResult";
/**
* Renders PGS subtitle on-top of a video element using a canvas element. This also handles timestamp updates if a
@@ -154,8 +155,13 @@ export class PgsRenderer {
private onWorkerMessage = (e: MessageEvent) => {
switch (e.data.op) {
// Is called once a subtitle file was loaded.
case 'updateTimestamps':
// Is called for every progress update of for loading the file.
case 'progress':
const result = e.data.result as PgsRendererResult;
if (result !== PgsRendererResult.Success && result !== PgsRendererResult.Pending) {
console.error(`[libpgs] Couldn't load subtitle stream. Worker returned: ${result}`);
}
// Stores the update timestamps, so we don't need to push the timestamp to the worker on every tick.
// Instead, we push the timestamp index if it was changed.
this.updateTimestamps = e.data.updateTimestamps;
+15 -8
View File
@@ -12,6 +12,7 @@ import {BinaryReader} from "./utils/binaryReader";
import {ArrayBinaryReader} from "./utils/arrayBinaryReader";
import {CompositionRenderData, RenderData} from "./renderData";
import {PgsRendererHelper} from "./pgsRendererHelper";
import {PgsRendererResult} from "./pgsRendererResult";
export interface PgsLoadOptions {
/**
@@ -44,15 +45,16 @@ export class PgsRendererInternal {
* @param url The url to the PGS file.
* @param options Optional loading options. Use `onProgress` as callback for partial update while loading.
*/
public async loadFromUrl(url: string, options?: PgsLoadOptions): Promise<void> {
public async loadFromUrl(url: string, options?: PgsLoadOptions): Promise<PgsRendererResult> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
console.error(`[libpgs] Failed to load subtitle stream from url. HTTP Status: ${response.status}`);
return PgsRendererResult.ErrHttp;
}
const stream = response.body?.getReader()!;
const reader = new StreamBinaryReader(stream)
await this.loadFromReader(reader, options);
return await this.loadFromReader(reader, options);
}
/**
@@ -60,8 +62,8 @@ export class PgsRendererInternal {
* @param buffer The PGS data.
* @param options Optional loading options. Use `onProgress` as callback for partial update while loading.
*/
public async loadFromBuffer(buffer: ArrayBuffer, options?: PgsLoadOptions): Promise<void> {
await this.loadFromReader(new ArrayBinaryReader(new Uint8Array(buffer)), options);
public async loadFromBuffer(buffer: ArrayBuffer, options?: PgsLoadOptions): Promise<PgsRendererResult> {
return await this.loadFromReader(new ArrayBinaryReader(new Uint8Array(buffer)), options);
}
/**
@@ -69,7 +71,7 @@ export class PgsRendererInternal {
* @param reader The PGS data reader.
* @param options Optional loading options. Use `onProgress` as callback for partial update while loading.
*/
public async loadFromReader(reader: BinaryReader, options?: PgsLoadOptions): Promise<void> {
public async loadFromReader(reader: BinaryReader, options?: PgsLoadOptions): Promise<PgsRendererResult> {
this.displaySets = [];
this.updateTimestamps = [];
this.cachedRenderData = undefined;
@@ -79,7 +81,10 @@ export class PgsRendererInternal {
const bigEndianReader = new BigEndianBinaryReader(reader);
while (!reader.eof) {
const displaySet = new DisplaySet();
await displaySet.read(bigEndianReader, true);
const result = await displaySet.read(bigEndianReader, true);
if (result !== PgsRendererResult.Success) {
return result;
}
this.displaySets.push(displaySet);
this.updateTimestamps.push(displaySet.presentationTimestamp);
@@ -98,6 +103,8 @@ export class PgsRendererInternal {
if (options?.onProgress) {
options.onProgress();
}
return PgsRendererResult.Success;
}
// endregion
@@ -107,7 +114,7 @@ export class PgsRendererInternal {
private canvas?: OffscreenCanvas | HTMLCanvasElement;
private context?: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
// Information about the next compiled render data. This is calculated after the current subtitle is renderd.
// Information about the next compiled render data. This is calculated after the current subtitle is rendered.
// So by the time the next subtitle change is requested, this should already be completed.
private cachedRenderData?: { index: number, data: RenderData | undefined };
+36
View File
@@ -0,0 +1,36 @@
export enum PgsRendererResult {
/**
* The renderer has loaded and processed the subtitle stream.
*/
Success,
/**
* The renderer is still processing the subtitle stream.
*/
Pending,
/**
* Processing the subtitle stream failed due to an invalid magic number. Probably not a PGS file.
*/
ErrInvalidMagicNumber,
/**
* Processing the subtitle stream failed due to an unexpected end of the stream.
*/
ErrUnexpectedEndOfStream,
/**
* Processing the subtitle stream failed due to an unknown segment type.
*/
ErrUnknownSegment,
/**
* Processing the subtitle stream failed due to a stream position mismatch after reading a segment.
*/
ErrStreamPositionMismatch,
/**
* Loading the subtitle stream failed due to an HTTP error.
*/
ErrHttp
}
+26 -1
View File
@@ -1,11 +1,17 @@
import {BinaryReader} from "./binaryReader";
import {ArrayBinaryReader} from "./arrayBinaryReader";
import {AsyncBinaryReader} from "./asyncBinaryReader";
export class BigEndianBinaryReader {
/**
* The base binary reader.
*/
public readonly baseReader: BinaryReader;
private readonly baseReader: BinaryReader;
/**
* Set if `baseReader` is an async reader.
*/
private readonly asyncReader?: AsyncBinaryReader;
public constructor(buffer: BinaryReader | Uint8Array) {
if (buffer instanceof Uint8Array) {
@@ -14,6 +20,11 @@ export class BigEndianBinaryReader {
else {
this.baseReader = buffer;
}
// Handles async readers
if ('requestData' in this.baseReader) {
this.asyncReader = this.baseReader as AsyncBinaryReader;
}
}
public get position(): number {
@@ -56,4 +67,18 @@ export class BigEndianBinaryReader {
public readBytes(count: number): Uint8Array {
return this.baseReader.readBytes(count);
}
/**
* Ensures that the given number of bytes is available to read synchronously.
* This will wait until the data is ready to read if an underlying async stream is used.
* @param count The number of bytes requested.
* @return Returns if the requested number of bytes could be loaded.
*/
public async requestData(count: number): Promise<boolean> {
if (this.asyncReader) {
return await this.asyncReader.requestData(count);
}
return this.baseReader.position + count <= this.baseReader.length;
}
}
+10 -8
View File
@@ -1,11 +1,13 @@
import {PgsRendererInternal} from "./pgsRendererInternal";
import {PgsRendererResult} from "./pgsRendererResult";
const renderer = new PgsRendererInternal();
// Inform the main process that the subtitle data was loaded and return all update timestamps
const submitTimestamps = () => {
// Inform the main process about the renderer status update.
const sendProgressUpdate = (result: PgsRendererResult) => {
postMessage({
op: 'updateTimestamps',
op: 'progress',
result: result,
updateTimestamps: renderer.updateTimestamps
})
}
@@ -22,17 +24,17 @@ onmessage = (e: MessageEvent) => {
const url: string = e.data.url;
renderer.loadFromUrl(url, {
onProgress: () => {
submitTimestamps();
sendProgressUpdate(PgsRendererResult.Pending);
}
}).then(() => {
submitTimestamps();
}).then((result) => {
sendProgressUpdate(result);
});
break;
case 'loadFromBuffer':
const buffer: ArrayBuffer = e.data.buffer;
renderer.loadFromBuffer(buffer).then(() => {
submitTimestamps();
renderer.loadFromBuffer(buffer).then((result) => {
sendProgressUpdate(result);
});
break;
+13 -1
View File
@@ -4,6 +4,7 @@
import {PgsRendererInternal} from "../src/pgsRendererInternal";
import * as fs from "node:fs";
import {PgsRendererResult} from "../src/pgsRendererResult";
test('load and render pgs', async () => {
const dataSup = fs.readFileSync(`${__dirname}/files/test.sup`);
@@ -12,7 +13,8 @@ test('load and render pgs', async () => {
const context = canvas.getContext("2d")!;
const renderer = new PgsRendererInternal();
renderer.setCanvas(canvas);
await renderer.loadFromBuffer(dataSup);
const result = await renderer.loadFromBuffer(dataSup);
expect(result).toBe(PgsRendererResult.Success);
// Helper function to render and compare the image in the test directory.
// Since we only set pixel data and don't use font rendering this should be deterministic on every machine.
@@ -33,3 +35,13 @@ test('load and render pgs', async () => {
expectImageAtTimestamp('test-1.rgba', 2.5);
expectImageAtTimestamp('test-2.rgba', 3.5);
});
test('load non-pgs file and catch error', async () => {
const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation();
const dataSup = fs.readFileSync(`${__dirname}/files/test.srt`);
const renderer = new PgsRendererInternal();
const result = await renderer.loadFromBuffer(dataSup);
expect(result).toBe(PgsRendererResult.ErrInvalidMagicNumber);
expect(consoleErrorMock).toHaveBeenCalled();
})