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.
381 lines
14 KiB
JavaScript
381 lines
14 KiB
JavaScript
// Copyright (C) 2021 Intel Corporation
|
|
//
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
const { Mutex } = require('async-mutex');
|
|
// eslint-disable-next-line max-classes-per-file
|
|
const { MP4Reader, Bytestream } = require('./3rdparty/mp4');
|
|
const ZipDecoder = require('./unzip_imgs.worker');
|
|
const H264Decoder = require('./3rdparty/Decoder.worker');
|
|
|
|
const BlockType = Object.freeze({
|
|
MP4VIDEO: 'mp4video',
|
|
ARCHIVE: 'archive',
|
|
});
|
|
|
|
const DimensionType = Object.freeze({
|
|
DIM_3D: '3d',
|
|
DIM_2D: '2d',
|
|
});
|
|
|
|
class FrameProvider {
|
|
constructor(
|
|
blockType,
|
|
blockSize,
|
|
cachedBlockCount,
|
|
decodedBlocksCacheSize = 5,
|
|
maxWorkerThreadCount = 2,
|
|
dimension = DimensionType.DIM_2D,
|
|
) {
|
|
this._frames = {};
|
|
this._cachedBlockCount = Math.max(1, cachedBlockCount); // number of stored blocks
|
|
this._decodedBlocksCacheSize = decodedBlocksCacheSize;
|
|
this._blocksRanges = [];
|
|
this._blocks = {};
|
|
this._running = false;
|
|
this._blockSize = blockSize;
|
|
this._blockType = blockType;
|
|
this._currFrame = -1;
|
|
this._requestedBlockDecode = null;
|
|
this._width = null;
|
|
this._height = null;
|
|
this._decodingBlocks = {};
|
|
this._decodeThreadCount = 0;
|
|
this._timerId = setTimeout(this._worker.bind(this), 100);
|
|
this._mutex = new Mutex();
|
|
this._promisedFrames = {};
|
|
this._maxWorkerThreadCount = maxWorkerThreadCount;
|
|
this._dimension = dimension;
|
|
}
|
|
|
|
async _worker() {
|
|
if (this._requestedBlockDecode !== null && this._decodeThreadCount < this._maxWorkerThreadCount) {
|
|
await this.startDecode();
|
|
}
|
|
this._timerId = setTimeout(this._worker.bind(this), 100);
|
|
}
|
|
|
|
isChunkCached(start, end) {
|
|
return `${start}:${end}` in this._blocksRanges;
|
|
}
|
|
|
|
/* This method removes extra data from a cache when memory overflow */
|
|
async _cleanup() {
|
|
if (this._blocksRanges.length > this._cachedBlockCount) {
|
|
const shifted = this._blocksRanges.shift(); // get the oldest block
|
|
const [start, end] = shifted.split(':').map((el) => +el);
|
|
delete this._blocks[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._decodedBlocksCacheSize / 2);
|
|
for (let i = 0; i < this._blocksRanges.length; i++) {
|
|
const [start, end] = this._blocksRanges[i].split(':').map((el) => +el);
|
|
if (
|
|
end < this._currFrame - distance * this._blockSize ||
|
|
start > this._currFrame + distance * this._blockSize
|
|
) {
|
|
for (let j = start; j <= end; j++) {
|
|
delete this._frames[j];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async requestDecodeBlock(block, start, end, resolveCallback, rejectCallback) {
|
|
const release = await this._mutex.acquire();
|
|
try {
|
|
if (this._requestedBlockDecode !== null) {
|
|
if (start === this._requestedBlockDecode.start && end === this._requestedBlockDecode.end) {
|
|
this._requestedBlockDecode.resolveCallback = resolveCallback;
|
|
this._requestedBlockDecode.rejectCallback = rejectCallback;
|
|
} else if (this._requestedBlockDecode.rejectCallback) {
|
|
this._requestedBlockDecode.rejectCallback();
|
|
}
|
|
}
|
|
if (!(`${start}:${end}` in this._decodingBlocks)) {
|
|
this._requestedBlockDecode = {
|
|
block: block || this._blocks[Math.floor(start / this._blockSize)],
|
|
start,
|
|
end,
|
|
resolveCallback,
|
|
rejectCallback,
|
|
};
|
|
} else {
|
|
this._decodingBlocks[`${start}:${end}`].rejectCallback = rejectCallback;
|
|
this._decodingBlocks[`${start}:${end}`].resolveCallback = resolveCallback;
|
|
}
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
isRequestExist() {
|
|
return this._requestedBlockDecode !== null;
|
|
}
|
|
|
|
setRenderSize(width, height) {
|
|
this._width = width;
|
|
this._height = height;
|
|
}
|
|
|
|
/* Method returns frame from collection. Else method returns 0 */
|
|
async frame(frameNumber) {
|
|
this._currFrame = 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) {
|
|
const nextChunkNum = Math.floor(frameNumber / this._blockSize) + 1;
|
|
if (this._blocks[nextChunkNum] === 'loading') {
|
|
return true;
|
|
}
|
|
|
|
return nextChunkNum in this._blocks;
|
|
}
|
|
|
|
/*
|
|
Method start asynchronic decode a block of data
|
|
|
|
@param block - is a data from a server as is (ts file or archive)
|
|
@param start {number} - is the first frame of a block
|
|
@param end {number} - is the last frame of a block + 1
|
|
@param callback - callback)
|
|
|
|
*/
|
|
|
|
setReadyToLoading(chunkNumber) {
|
|
this._blocks[chunkNumber] = 'loading';
|
|
}
|
|
|
|
static cropImage(imageBuffer, imageWidth, imageHeight, xOffset, yOffset, width, height) {
|
|
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() {
|
|
const release = await this._mutex.acquire();
|
|
try {
|
|
const height = this._height;
|
|
const width = this._width;
|
|
const { start, end, block } = this._requestedBlockDecode;
|
|
|
|
this._blocksRanges.push(`${start}:${end}`);
|
|
this._decodingBlocks[`${start}:${end}`] = this._requestedBlockDecode;
|
|
this._requestedBlockDecode = null;
|
|
this._blocks[Math.floor((start + 1) / this._blockSize)] = block;
|
|
for (let i = start; i <= end; i++) {
|
|
this._frames[i] = null;
|
|
}
|
|
this._cleanup();
|
|
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(this._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),
|
|
);
|
|
|
|
if (this._decodingBlocks[`${start}:${end}`].resolveCallback) {
|
|
this._decodingBlocks[`${start}:${end}`].resolveCallback(index);
|
|
}
|
|
|
|
if (index in this._promisedFrames) {
|
|
this._promisedFrames[index].resolve(this._frames[index]);
|
|
delete this._promisedFrames[index];
|
|
}
|
|
if (index === end) {
|
|
this._decodeThreadCount--;
|
|
delete this._decodingBlocks[`${start}:${end}`];
|
|
worker.terminate();
|
|
}
|
|
index++;
|
|
};
|
|
|
|
worker.onerror = (e) => {
|
|
worker.terminate();
|
|
this._decodeThreadCount--;
|
|
|
|
for (let i = index; i <= end; i++) {
|
|
if (i in this._promisedFrames) {
|
|
this._promisedFrames[i].reject();
|
|
delete this._promisedFrames[i];
|
|
}
|
|
}
|
|
|
|
if (this._decodingBlocks[`${start}:${end}`].rejectCallback) {
|
|
this._decodingBlocks[`${start}:${end}`].rejectCallback(Error(e));
|
|
}
|
|
delete this._decodingBlocks[`${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 });
|
|
});
|
|
}
|
|
this._decodeThreadCount++;
|
|
} else {
|
|
const worker = new ZipDecoder();
|
|
let index = start;
|
|
|
|
worker.onerror = (e) => {
|
|
for (let i = start; i <= end; i++) {
|
|
if (i in this._promisedFrames) {
|
|
this._promisedFrames[i].reject();
|
|
delete this._promisedFrames[i];
|
|
}
|
|
}
|
|
if (this._decodingBlocks[`${start}:${end}`].rejectCallback) {
|
|
this._decodingBlocks[`${start}:${end}`].rejectCallback(Error(e));
|
|
}
|
|
this._decodeThreadCount--;
|
|
worker.terminate();
|
|
};
|
|
|
|
worker.onmessage = async (event) => {
|
|
if (this._dimension === DimensionType.DIM_2D && event.data.isRaw) {
|
|
// safary doesn't support createImageBitmap
|
|
// there is a way to polyfill it with using document.createElement
|
|
// but document.createElement doesn't work in worker
|
|
// so, we get raw data and decode it here, no other way
|
|
|
|
const createImageBitmap = async function (blob) {
|
|
return new Promise((resolve) => {
|
|
const img = document.createElement('img');
|
|
img.addEventListener('load', function () {
|
|
resolve(this);
|
|
});
|
|
img.src = URL.createObjectURL(blob);
|
|
});
|
|
};
|
|
|
|
event.data.data = await createImageBitmap(event.data.data);
|
|
}
|
|
|
|
this._frames[event.data.index] = event.data.data;
|
|
|
|
if (this._decodingBlocks[`${start}:${end}`].resolveCallback) {
|
|
this._decodingBlocks[`${start}:${end}`].resolveCallback(event.data.index);
|
|
}
|
|
|
|
if (event.data.index in this._promisedFrames) {
|
|
this._promisedFrames[event.data.index].resolve(this._frames[event.data.index]);
|
|
delete this._promisedFrames[event.data.index];
|
|
}
|
|
|
|
if (index === end) {
|
|
worker.terminate();
|
|
delete this._decodingBlocks[`${start}:${end}`];
|
|
this._decodeThreadCount--;
|
|
}
|
|
index++;
|
|
};
|
|
const dimension = this._dimension;
|
|
worker.postMessage({
|
|
block,
|
|
start,
|
|
end,
|
|
dimension,
|
|
dimension2D: DimensionType.DIM_2D,
|
|
});
|
|
this._decodeThreadCount++;
|
|
}
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
get decodeThreadCount() {
|
|
return this._decodeThreadCount;
|
|
}
|
|
|
|
get decodedBlocksCacheSize() {
|
|
return this._decodedBlocksCacheSize;
|
|
}
|
|
|
|
/*
|
|
Method returns a list of cached ranges
|
|
Is an array of strings like "start:end"
|
|
*/
|
|
get cachedFrames() {
|
|
return [...this._blocksRanges].sort((a, b) => a.split(':')[0] - b.split(':')[0]);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
FrameProvider,
|
|
BlockType,
|
|
DimensionType,
|
|
};
|