UNPKG

libpgs

Version:

Renderer for graphical subtitles (PGS) in the browser.

253 lines (215 loc) 11 kB
import {BigEndianBinaryReader} from "./utils/bigEndianBinaryReader"; import {DisplaySet} from "./pgs/displaySet"; import {BinaryReader} from "./utils/binaryReader"; import {ArrayBinaryReader} from "./utils/arrayBinaryReader"; import {StreamBinaryReader} from "./utils/streamBinaryReader"; import {SubtitleCompositionData, SubtitleData} from "./subtitleData"; import {CompositionObject} from "./pgs/presentationCompositionSegment"; import {PaletteDefinitionSegment} from "./pgs/paletteDefinitionSegment"; import {ObjectDefinitionSegment} from "./pgs/objectDefinitionSegment"; import {CombinedBinaryReader} from "./utils/combinedBinaryReader"; import {RunLengthEncoding} from "./utils/runLengthEncoding"; import {WindowDefinition} from "./pgs/windowDefinitionSegment"; import {PgsRendererHelper} from "./pgsRendererHelper"; export interface PgsLoadOptions { /** * Async pgs streams can return partial updates. When invoked, the `displaySets` and `updateTimestamps` are updated * to the last available subtitle. There is a minimum threshold of one-second to prevent to many updates. */ onProgress?: () => void; } /** * The PGS subtitle data class. This can load and cache sup files from a buffer or url. * It can also build image data for a given timestamp or timestamp index. */ export class Pgs { /** * The currently loaded display sets. */ public displaySets: DisplaySet[] = []; /** * The PGS timestamps when a display set with the same index is presented. */ public updateTimestamps: number[] = []; /** * Loads the subtitle file from the given url. * @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> { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } // The `body` and therefore readable streams are only available since Chrome 105. With this available we can utilize // partial reading while downloading. As a fallback we wait for the whole file to download before reading. const stream = response.body?.getReader(); let reader: BinaryReader; if (stream) { reader = new StreamBinaryReader(stream); } else { const buffer = await response.arrayBuffer(); reader = new ArrayBinaryReader(new Uint8Array(buffer)); } await this.loadFromReader(reader, options); } /** * Loads the subtitle file from the given buffer. * @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); } /** * Loads the subtitle file from the given buffer. * @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> { this.displaySets = []; this.updateTimestamps = []; this.cachedSubtitleData = undefined; let lastUpdateTime = performance.now(); const bigEndianReader = new BigEndianBinaryReader(reader); while (!reader.eof) { const displaySet = new DisplaySet(); await displaySet.read(bigEndianReader, true); this.displaySets.push(displaySet); this.updateTimestamps.push(displaySet.presentationTimestamp); // For async loading, we support frequent progress updates. Sending one update for every new display set // would be too much. Instead, we use a one-second threshold. if (options?.onProgress) { let now = performance.now(); if (now > lastUpdateTime + 1000) { lastUpdateTime = now; options.onProgress(); } } } // Call final update. if (options?.onProgress) { options.onProgress(); } } // Information about the next compiled subtitle 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 cachedSubtitleData?: { index: number, data: SubtitleData | undefined }; /** * Pre-compiles and caches the subtitle data for the given index. * This will speed up the next call to `buildSubtitleDataAtIndex` with the same index. * @param index The index of the display set to cache. */ public cacheSubtitleAtIndex(index: number) { // Pre-calculating the next subtitle, so it is ready whenever the next subtitle change is requested. const nextSubtitleData = this.getSubtitleAtIndex(index); this.cachedSubtitleData = { index: index, data: nextSubtitleData }; } /** * Renders the subtitle at the given timestamp. * @param time The timestamp in seconds. */ public getSubtitleAtTimestamp(time: number): SubtitleData | undefined { const index = PgsRendererHelper.getIndexFromTimestamps(time, this.updateTimestamps); return this.getSubtitleAtIndex(index); } /** * Pre-compiles the required subtitle data (windows and pixel data) for the frame at the given index. * @param index The index of the display set to render. */ public getSubtitleAtIndex(index: number): SubtitleData | undefined { // Check if this index was already cached. if (this.cachedSubtitleData && this.cachedSubtitleData.index === index) { return this.cachedSubtitleData.data; } if (index < 0 || index >= this.displaySets.length) { return; } const displaySet = this.displaySets[index]; if (!displaySet.presentationComposition) return; // We need to collect all valid objects and palettes up to this point. PGS can update and reuse elements from // previous display sets. The `compositionState` defines if the previous elements should be cleared. // If it is `0` previous elements should be kept. Because the user can seek through the file, we can not store // previous elements, and we should collect these elements for every new render. const ctxObjects: ObjectDefinitionSegment[] = []; const ctxPalettes: PaletteDefinitionSegment[] = []; const ctxWindows: WindowDefinition[] = []; let curIndex = index; while (curIndex >= 0) { const displaySet = this.displaySets[curIndex]; // Because we are moving backwards, we would end up with the inverted array order. // We'll use `unshift` to add these elements to the front of the array. ctxObjects.unshift(...displaySet.objectDefinitions); ctxPalettes.unshift(...displaySet.paletteDefinitions); // `flatMap` is available since Chrome 69. for (const windowDefinition of displaySet.windowDefinitions) { ctxWindows.unshift(...windowDefinition.windows); } // Any other state that `0` frees all previous segments, so we can stop here. if (this.displaySets[curIndex].presentationComposition?.compositionState !== 0) { break; } curIndex--; } // Find the used palette for this composition. let palette = ctxPalettes .find(w => w.id === displaySet.presentationComposition?.paletteId); if (!palette) return; const compositionData: SubtitleCompositionData[] = []; for (const compositionObject of displaySet.presentationComposition.compositionObjects) { // Find the window to draw on. let window = ctxWindows.find(w => w.id === compositionObject.windowId); if (!window) continue; // Builds the subtitle. const pixelData = this.getPixelDataFromComposition(compositionObject, palette, ctxObjects); if (pixelData) { compositionData.push(new SubtitleCompositionData(compositionObject, window, pixelData)); } } if (compositionData.length === 0) return; return new SubtitleData(displaySet.presentationComposition.width, displaySet.presentationComposition.height, compositionData); } private getPixelDataFromComposition(composition: CompositionObject, palette: PaletteDefinitionSegment, ctxObjects: ObjectDefinitionSegment[]): ImageData | undefined { // Multiple object definition can define a single subtitle image. // However, only the first element in sequence hold the image size. let width: number = 0; let height: number = 0; const dataChunks: Uint8Array[] = []; for (const ods of ctxObjects) { if (ods.id != composition.id) continue; if (ods.isFirstInSequence) { width = ods.width; height = ods.height; } if (ods.data) { dataChunks.push(ods.data); } } if (dataChunks.length == 0) { return undefined; } // Using a combined reader instead of stitching the data together. // This hopefully avoids a larger memory allocation. const data = new CombinedBinaryReader(dataChunks); // Detect if we are running in a web-worker or in main browser if (typeof document !== 'undefined') { // We have to use the canvas api to create image data. const canvas = document.createElement("canvas"); const context = canvas.getContext("2d")!; const imageData = context.createImageData(width, height); const imageBuffer = new Uint32Array(imageData.data.buffer); // The pixel data is run-length encoded. The decoded value is the palette entry index. RunLengthEncoding.decode(data, palette.rgba, imageBuffer); return imageData; } else { // Building a canvas element with the subtitle image data. const imageBuffer = new Uint32Array(width * height); // The pixel data is run-length encoded. The decoded value is the palette entry index. RunLengthEncoding.decode(data, palette.rgba, imageBuffer); // We can use the image data constructor in web-workers. return new ImageData(new Uint8ClampedArray(imageBuffer.buffer), width, height); } } }