diff --git a/src/pgs/displaySet.ts b/src/pgs/displaySet.ts index 409930e..4f3f094 100644 --- a/src/pgs/displaySet.ts +++ b/src/pgs/displaySet.ts @@ -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 { // 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; } } } diff --git a/src/pgsRenderer.ts b/src/pgsRenderer.ts index 566e802..9efadda 100644 --- a/src/pgsRenderer.ts +++ b/src/pgsRenderer.ts @@ -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; diff --git a/src/pgsRendererInternal.ts b/src/pgsRendererInternal.ts index 32e05a0..f231c48 100644 --- a/src/pgsRendererInternal.ts +++ b/src/pgsRendererInternal.ts @@ -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 { + public async loadFromUrl(url: string, options?: PgsLoadOptions): Promise { 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 { - await this.loadFromReader(new ArrayBinaryReader(new Uint8Array(buffer)), options); + public async loadFromBuffer(buffer: ArrayBuffer, options?: PgsLoadOptions): Promise { + 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 { + public async loadFromReader(reader: BinaryReader, options?: PgsLoadOptions): Promise { 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 }; diff --git a/src/pgsRendererResult.ts b/src/pgsRendererResult.ts new file mode 100644 index 0000000..0b0d7df --- /dev/null +++ b/src/pgsRendererResult.ts @@ -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 +} diff --git a/src/utils/bigEndianBinaryReader.ts b/src/utils/bigEndianBinaryReader.ts index 925ceca..cb610f2 100644 --- a/src/utils/bigEndianBinaryReader.ts +++ b/src/utils/bigEndianBinaryReader.ts @@ -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 { + if (this.asyncReader) { + return await this.asyncReader.requestData(count); + } + + return this.baseReader.position + count <= this.baseReader.length; + } } diff --git a/src/worker.ts b/src/worker.ts index 8cc7d0d..7335772 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -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; diff --git a/tests/pgsRenderer.test.ts b/tests/pgsRenderer.test.ts index bf14aa4..01b3929 100644 --- a/tests/pgsRenderer.test.ts +++ b/tests/pgsRenderer.test.ts @@ -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(); +})