You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
454 lines
16 KiB
TypeScript
454 lines
16 KiB
TypeScript
// Copyright (C) 2021-2022 Intel Corporation
|
|
// Copyright (C) 2023 CVAT.ai Corporation
|
|
//
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
import { Mutex } from 'async-mutex';
|
|
import { MP4Reader, Bytestream } from './3rdparty/mp4';
|
|
import ZipDecoder from './unzip_imgs.worker';
|
|
import H264Decoder from './3rdparty/Decoder.worker';
|
|
|
|
export enum BlockType {
|
|
MP4VIDEO = 'mp4video',
|
|
ARCHIVE = 'archive',
|
|
}
|
|
|
|
export enum DimensionType {
|
|
DIMENSION_3D = '3d',
|
|
DIMENSION_2D = '2d',
|
|
}
|
|
|
|
export function decodeZip(
|
|
block: any, start: number, end: number, dimension: any,
|
|
): Promise<Record<string, ImageBitmap>> {
|
|
return new Promise((resolve, reject) => {
|
|
decodeZip.mutex.acquire().then((release) => {
|
|
const worker = new ZipDecoder();
|
|
const result: Record<string, ImageBitmap> = {};
|
|
let decoded = 0;
|
|
|
|
worker.onerror = (e: ErrorEvent) => {
|
|
release();
|
|
worker.terminate();
|
|
reject(new Error(`Archive can not be decoded. ${e.message}`));
|
|
};
|
|
|
|
worker.onmessage = async (event) => {
|
|
const { error, fileName } = event.data;
|
|
if (error) {
|
|
worker.onerror(new ErrorEvent('error', { message: error.toString() }));
|
|
}
|
|
|
|
const { data } = event.data;
|
|
result[fileName.split('.')[0]] = data;
|
|
decoded++;
|
|
|
|
if (decoded === end) {
|
|
release();
|
|
worker.terminate();
|
|
resolve(result);
|
|
}
|
|
};
|
|
|
|
worker.postMessage({
|
|
block,
|
|
start,
|
|
end,
|
|
dimension,
|
|
dimension2D: DimensionType.DIMENSION_2D,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
decodeZip.mutex = new Mutex();
|
|
|
|
interface BlockToDecode {
|
|
start: number;
|
|
end: number;
|
|
block: ArrayBuffer;
|
|
resolveCallback: (frame: number) => void;
|
|
rejectCallback: (e: ErrorEvent) => void;
|
|
}
|
|
|
|
export class FrameProvider {
|
|
private blocksRanges: string[];
|
|
private blockSize: number;
|
|
private blockType: BlockType;
|
|
|
|
/*
|
|
ImageBitmap when decode zip chunks
|
|
ImageData when decode video chunks
|
|
Blob when 3D dimension
|
|
null when not decoded yet
|
|
*/
|
|
private frames: Record<string, ImageBitmap | ImageData | Blob | null>;
|
|
private requestedBlockToDecode: null | BlockToDecode;
|
|
private blocksAreBeingDecoded: Record<string, BlockToDecode>;
|
|
private promisedFrames: Record<string, {
|
|
resolve: (data: ImageBitmap | ImageData | Blob) => void;
|
|
reject: () => void;
|
|
}>;
|
|
private currentDecodingThreads: number;
|
|
private currentFrame: number;
|
|
private mutex: Mutex;
|
|
|
|
private dimension: DimensionType;
|
|
private workerThreadsLimit: number;
|
|
private cachedEncodedBlocksLimit: number;
|
|
private cachedDecodedBlocksLimit: number;
|
|
|
|
// used for video chunks to resize after decoding
|
|
private renderWidth: number;
|
|
private renderHeight: number;
|
|
|
|
constructor(
|
|
blockType: BlockType,
|
|
blockSize: number,
|
|
cachedBlockCount: number,
|
|
decodedBlocksCacheSize = 5,
|
|
maxWorkerThreadCount = 2,
|
|
dimension: DimensionType = DimensionType.DIMENSION_2D,
|
|
) {
|
|
this.mutex = new Mutex();
|
|
this.blocksRanges = [];
|
|
this.frames = {};
|
|
this.promisedFrames = {};
|
|
this.currentDecodingThreads = 0;
|
|
this.currentFrame = -1;
|
|
|
|
this.cachedEncodedBlocksLimit = Math.max(1, cachedBlockCount); // number of stored blocks
|
|
this.cachedDecodedBlocksLimit = decodedBlocksCacheSize;
|
|
this.workerThreadsLimit = maxWorkerThreadCount;
|
|
this.dimension = dimension;
|
|
|
|
this.renderWidth = 1920;
|
|
this.renderHeight = 1080;
|
|
this.blockSize = blockSize;
|
|
this.blockType = blockType;
|
|
|
|
// todo: sort out with logic of blocks
|
|
this._blocks = {};
|
|
this.requestedBlockToDecode = null;
|
|
this.blocksAreBeingDecoded = {};
|
|
|
|
setTimeout(this._checkDecodeRequests.bind(this), 100);
|
|
}
|
|
|
|
_checkDecodeRequests(): void {
|
|
if (this.requestedBlockToDecode !== null && this.currentDecodingThreads < this.workerThreadsLimit) {
|
|
this.startDecode().then(() => {
|
|
setTimeout(this._checkDecodeRequests.bind(this), 100);
|
|
});
|
|
} else {
|
|
setTimeout(this._checkDecodeRequests.bind(this), 100);
|
|
}
|
|
}
|
|
|
|
isChunkCached(start: number, end: number): boolean {
|
|
// todo: always returns false because this.blocksRanges is Array, not dictionary
|
|
// but if try to correct other errors happens, need to debug..
|
|
return `${start}:${end}` in this.blocksRanges;
|
|
}
|
|
|
|
/* This method removes extra data from a cache when memory overflow */
|
|
async _cleanup(): Promise<void> {
|
|
if (this.blocksRanges.length > this.cachedEncodedBlocksLimit) {
|
|
const shifted = this.blocksRanges.shift(); // get the oldest block
|
|
const [start, end] = shifted.split(':').map((el) => +el);
|
|
delete this._blocks[Math.floor(start / this.blockSize)];
|
|
for (let i = start; i <= end; i++) {
|
|
delete this.frames[i];
|
|
}
|
|
}
|
|
|
|
// delete frames whose are not in areas of current frame
|
|
const distance = Math.floor(this.cachedDecodedBlocksLimit / 2);
|
|
for (let i = 0; i < this.blocksRanges.length; i++) {
|
|
const [start, end] = this.blocksRanges[i].split(':').map((el) => +el);
|
|
if (
|
|
end < this.currentFrame - distance * this.blockSize ||
|
|
start > this.currentFrame + distance * this.blockSize
|
|
) {
|
|
for (let j = start; j <= end; j++) {
|
|
delete this.frames[j];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async requestDecodeBlock(
|
|
block: ArrayBuffer,
|
|
start: number,
|
|
end: number,
|
|
resolveCallback: () => void,
|
|
rejectCallback: () => void,
|
|
): Promise<void> {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
if (this.requestedBlockToDecode !== null) {
|
|
if (start === this.requestedBlockToDecode.start && end === this.requestedBlockToDecode.end) {
|
|
// only rewrite callbacks if the same block was requested again
|
|
this.requestedBlockToDecode.resolveCallback = resolveCallback;
|
|
this.requestedBlockToDecode.rejectCallback = rejectCallback;
|
|
|
|
// todo: should we reject the previous request here?
|
|
} else if (this.requestedBlockToDecode.rejectCallback) {
|
|
// if another block requested, the previous request should be rejected
|
|
this.requestedBlockToDecode.rejectCallback();
|
|
}
|
|
}
|
|
|
|
if (!(`${start}:${end}` in this.blocksAreBeingDecoded)) {
|
|
this.requestedBlockToDecode = {
|
|
block: block || this._blocks[Math.floor(start / this.blockSize)],
|
|
start,
|
|
end,
|
|
resolveCallback,
|
|
rejectCallback,
|
|
};
|
|
} else {
|
|
this.blocksAreBeingDecoded[`${start}:${end}`].rejectCallback = rejectCallback;
|
|
this.blocksAreBeingDecoded[`${start}:${end}`].resolveCallback = resolveCallback;
|
|
}
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
setRenderSize(width: number, height: number): void {
|
|
this.renderWidth = width;
|
|
this.renderHeight = height;
|
|
}
|
|
|
|
/* Method returns frame from collection. Else method returns null */
|
|
async frame(frameNumber: number): Promise<ImageBitmap | ImageData | Blob> {
|
|
this.currentFrame = frameNumber;
|
|
return new Promise((resolve, reject) => {
|
|
if (frameNumber in this.frames) {
|
|
if (this.frames[frameNumber] !== null) {
|
|
resolve(this.frames[frameNumber]);
|
|
} else {
|
|
this.promisedFrames[frameNumber] = { resolve, reject };
|
|
}
|
|
} else {
|
|
resolve(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
isNextChunkExists(frameNumber: number): boolean {
|
|
const nextChunkNum = Math.floor(frameNumber / this.blockSize) + 1;
|
|
return nextChunkNum in this._blocks;
|
|
}
|
|
|
|
setReadyToLoading(chunkNumber: number): void {
|
|
this._blocks[chunkNumber] = 'loading';
|
|
}
|
|
|
|
static cropImage(
|
|
imageBuffer: ArrayBuffer,
|
|
imageWidth: number,
|
|
imageHeight: number,
|
|
xOffset: number,
|
|
yOffset: number,
|
|
width: number,
|
|
height: number,
|
|
): ImageData {
|
|
if (xOffset === 0 && width === imageWidth && yOffset === 0 && height === imageHeight) {
|
|
return new ImageData(new Uint8ClampedArray(imageBuffer), width, height);
|
|
}
|
|
const source = new Uint32Array(imageBuffer);
|
|
|
|
const bufferSize = width * height * 4;
|
|
const buffer = new ArrayBuffer(bufferSize);
|
|
const rgbaInt32 = new Uint32Array(buffer);
|
|
const rgbaInt8Clamped = new Uint8ClampedArray(buffer);
|
|
|
|
if (imageWidth === width) {
|
|
return new ImageData(new Uint8ClampedArray(imageBuffer, yOffset * 4, bufferSize), width, height);
|
|
}
|
|
|
|
let writeIdx = 0;
|
|
for (let row = yOffset; row < height; row++) {
|
|
const start = row * imageWidth + xOffset;
|
|
rgbaInt32.set(source.subarray(start, start + width), writeIdx);
|
|
writeIdx += width;
|
|
}
|
|
|
|
return new ImageData(rgbaInt8Clamped, width, height);
|
|
}
|
|
|
|
async startDecode(): Promise<void> {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
const height = this.renderHeight;
|
|
const width = this.renderWidth;
|
|
const { start, end, block } = this.requestedBlockToDecode;
|
|
|
|
this.blocksRanges.push(`${start}:${end}`);
|
|
this.blocksAreBeingDecoded[`${start}:${end}`] = this.requestedBlockToDecode;
|
|
this.requestedBlockToDecode = null;
|
|
this._blocks[Math.floor((start + 1) / this.blockSize)] = block;
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
this.frames[i] = null;
|
|
}
|
|
|
|
this._cleanup();
|
|
this.currentDecodingThreads++;
|
|
|
|
if (this.blockType === BlockType.MP4VIDEO) {
|
|
const worker = new H264Decoder();
|
|
let index = start;
|
|
|
|
worker.onmessage = (e) => {
|
|
if (e.data.consoleLog) {
|
|
// ignore initialization message
|
|
return;
|
|
}
|
|
|
|
const scaleFactor = Math.ceil(height / e.data.height);
|
|
this.frames[index] = FrameProvider.cropImage(
|
|
e.data.buf,
|
|
e.data.width,
|
|
e.data.height,
|
|
0,
|
|
0,
|
|
Math.floor(width / scaleFactor),
|
|
Math.floor(height / scaleFactor),
|
|
);
|
|
|
|
const { resolveCallback } = this.blocksAreBeingDecoded[`${start}:${end}`];
|
|
if (resolveCallback) {
|
|
resolveCallback(index);
|
|
}
|
|
|
|
if (index in this.promisedFrames) {
|
|
const { resolve } = this.promisedFrames[index];
|
|
delete this.promisedFrames[index];
|
|
resolve(this.frames[index]);
|
|
}
|
|
|
|
if (index === end) {
|
|
worker.terminate();
|
|
this.currentDecodingThreads--;
|
|
delete this.blocksAreBeingDecoded[`${start}:${end}`];
|
|
}
|
|
|
|
index++;
|
|
};
|
|
|
|
worker.onerror = (e: ErrorEvent) => {
|
|
worker.terminate();
|
|
this.currentDecodingThreads--;
|
|
|
|
for (let i = index; i <= end; i++) {
|
|
// reject all the following frames
|
|
if (i in this.promisedFrames) {
|
|
const { reject } = this.promisedFrames[i];
|
|
delete this.promisedFrames[i];
|
|
reject();
|
|
}
|
|
}
|
|
|
|
if (this.blocksAreBeingDecoded[`${start}:${end}`].rejectCallback) {
|
|
this.blocksAreBeingDecoded[`${start}:${end}`].rejectCallback(e);
|
|
}
|
|
|
|
delete this.blocksAreBeingDecoded[`${start}:${end}`];
|
|
};
|
|
|
|
worker.postMessage({
|
|
type: 'Broadway.js - Worker init',
|
|
options: {
|
|
rgb: true,
|
|
reuseMemory: false,
|
|
},
|
|
});
|
|
|
|
const reader = new MP4Reader(new Bytestream(block));
|
|
reader.read();
|
|
const video = reader.tracks[1];
|
|
|
|
const avc = reader.tracks[1].trak.mdia.minf.stbl.stsd.avc1.avcC;
|
|
const sps = avc.sps[0];
|
|
const pps = avc.pps[0];
|
|
|
|
/* Decode Sequence & Picture Parameter Sets */
|
|
worker.postMessage({ buf: sps, offset: 0, length: sps.length });
|
|
worker.postMessage({ buf: pps, offset: 0, length: pps.length });
|
|
|
|
/* Decode Pictures */
|
|
for (let sample = 0; sample < video.getSampleCount(); sample++) {
|
|
video.getSampleNALUnits(sample).forEach((nal) => {
|
|
worker.postMessage({ buf: nal, offset: 0, length: nal.length });
|
|
});
|
|
}
|
|
} else {
|
|
const worker = new ZipDecoder();
|
|
let index = start;
|
|
|
|
worker.onmessage = async (event) => {
|
|
this.frames[event.data.index] = event.data.data;
|
|
|
|
const { resolveCallback } = this.blocksAreBeingDecoded[`${start}:${end}`];
|
|
if (resolveCallback) {
|
|
resolveCallback(event.data.index);
|
|
}
|
|
|
|
if (event.data.index in this.promisedFrames) {
|
|
const { resolve } = this.promisedFrames[event.data.index];
|
|
delete this.promisedFrames[event.data.index];
|
|
resolve(this.frames[event.data.index]);
|
|
}
|
|
|
|
if (index === end) {
|
|
worker.terminate();
|
|
this.currentDecodingThreads--;
|
|
delete this.blocksAreBeingDecoded[`${start}:${end}`];
|
|
}
|
|
index++;
|
|
};
|
|
|
|
worker.onerror = (e: ErrorEvent) => {
|
|
for (let i = start; i <= end; i++) {
|
|
if (i in this.promisedFrames) {
|
|
const { reject } = this.promisedFrames[i];
|
|
delete this.promisedFrames[i];
|
|
reject();
|
|
}
|
|
}
|
|
if (this.blocksAreBeingDecoded[`${start}:${end}`].rejectCallback) {
|
|
this.blocksAreBeingDecoded[`${start}:${end}`].rejectCallback(e);
|
|
}
|
|
this.currentDecodingThreads--;
|
|
worker.terminate();
|
|
};
|
|
|
|
worker.postMessage({
|
|
block,
|
|
start,
|
|
end,
|
|
dimension: this.dimension,
|
|
dimension2D: DimensionType.DIMENSION_2D,
|
|
});
|
|
}
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
get decodedBlocksCacheSize(): number {
|
|
return this.cachedDecodedBlocksLimit;
|
|
}
|
|
|
|
/*
|
|
Method returns a list of cached ranges
|
|
Is an array of strings like "start:end"
|
|
*/
|
|
get cachedFrames(): string[] {
|
|
return [...this.blocksRanges].sort((a, b) => +a.split(':')[0] - +b.split(':')[0]);
|
|
}
|
|
}
|