libpag
Version:
Portable Animated Graphics
228 lines (205 loc) • 6.87 kB
text/typescript
import { MP4_CACHE_PATH } from './constant';
import { removeFile, touchDirectory, writeFile } from './file-utils';
import { ArrayBufferImage, FrameData } from '@tgfx/wechat/array-buffer-image';
import type { FrameDataOptions, VideoDecoder, wx } from './interfaces';
import {PAGModule} from "../pag-module";
import type {PAGPlayer} from "../pag-player";
import {destroyVerify} from "../utils/decorators";
declare const wx: wx;
declare const setInterval: (callback: () => void, delay: number) => number;
const BUFFER_MAX_SIZE = 6;
const BUFFER_MIN_SIZE = 2;
const GET_FRAME_DATA_INTERVAL = 2; // ms
const frameDataOptions2FrameData = (id: number, options: FrameDataOptions): FrameData => {
const data = new ArrayBuffer(options.data.byteLength);
new Uint8Array(data).set(new Uint8Array(options.data));
return {
id: id,
data: data,
width: options.width,
height: options.height,
};
};
export interface TimeRange {
start: number;
end: number;
}
export class VideoReader {
public static async create(
mp4Data: Uint8Array,
width: number,
height: number,
frameRate: number,
staticTimeRanges: TimeRange[],
): Promise<VideoReader> {
return new VideoReader(mp4Data, width, height, frameRate, staticTimeRanges);
}
public isSought = false; // Web SDK use this property to check if video is seeked
public isPlaying = false; // Web SDK use this property to check if video is playing
public isDestroyed = false;
private player: PAGPlayer | null = null;
private error: any = null;
private readonly frameRate: number;
private currentFrame: number;
private mp4Path: string;
private videoDecoder: VideoDecoder;
private videoDecoderPromise: Promise<void> | undefined;
private frameDataBuffers: FrameData[] = [];
private bufferIndex = 0; // next frameData id
private getFrameDataLooping = false;
private getFrameDataResolve: ((frameData: FrameData) => void) | null = null;
private getFrameDataLoopTimer: number | null = null;
private seeking = false;
private arrayBufferImage = new ArrayBufferImage(new ArrayBuffer(0), 0, 0);
public constructor(
mp4Data: Uint8Array,
width: number,
height: number,
frameRate: number,
staticTimeRanges: TimeRange[],
) {
this.frameRate = frameRate;
this.currentFrame = -1;
this.mp4Path = `${MP4_CACHE_PATH}${new Date().getTime()}.mp4`;
touchDirectory(MP4_CACHE_PATH);
writeFile(this.mp4Path, mp4Data.buffer.slice(mp4Data.byteOffset, mp4Data.byteLength + mp4Data.byteOffset));
this.videoDecoder = wx.createVideoDecoder();
this.videoDecoder.on('ended', () => {
this.videoDecoder?.seek(0).then(() => {
this.bufferIndex = 0;
});
});
this.videoDecoderPromise = this.videoDecoder.start({ source: this.mp4Path, mode: 1 }).then(() => {
this.startGetFrameDataLoop();
});
this.linkPlayer(PAGModule.currentPlayer);
}
public async prepare(targetFrame: number) {
if (this.isDestroyed || targetFrame === this.currentFrame) return;
this.isSought = false;
// Wait for videoDecoder ready
await this.videoDecoderPromise;
if (this.isDestroyed) return;
if (this.frameDataBuffers.length > 0) {
const index = this.frameDataBuffers.findIndex((frameData) => frameData.id === targetFrame);
if (index !== -1) {
this.frameDataBuffers = this.frameDataBuffers.slice(index);
this.arrayBufferImage.setFrameData(await this.getFrameData());
if (this.isDestroyed) return;
this.currentFrame = targetFrame;
return;
}
this.frameDataBuffers = [];
}
if (targetFrame !== this.bufferIndex) {
this.seeking = true;
this.isSought = true;
this.bufferIndex = targetFrame;
await this.videoDecoder.seek(Math.floor((targetFrame / this.frameRate) * 1000));
if (this.isDestroyed) {
this.seeking = false;
return;
}
this.seeking = false;
}
this.arrayBufferImage.setFrameData(await this.getFrameData());
if (this.isDestroyed) return;
this.currentFrame = targetFrame;
return;
}
public getVideo() {
return this.arrayBufferImage;
}
public getCurrentFrame(): number {
return this.currentFrame;
}
public async play() {
// Web SDK use this function to play video.
}
public pause() {
// Web SDK use this function to pause video.
}
public stop() {
// Web SDK use this function to stop video.
}
public getError(): any {
return this.error;
}
public onDestroy() {
this.isDestroyed = true;
if (this.getFrameDataResolve) {
this.getFrameDataResolve({ id: -1, data: new ArrayBuffer(0), width: 0, height: 0 });
this.getFrameDataResolve = null;
}
if (this.player) {
this.player.unlinkVideoReader(this);
this.player = null;
}
this.clearFrameDataLoop();
this.videoDecoder.remove();
if (this.mp4Path) {
removeFile(this.mp4Path);
}
}
private getFrameData() {
return new Promise<FrameData>((resolve) => {
if (this.frameDataBuffers.length <= BUFFER_MIN_SIZE && !this.getFrameDataLooping) {
this.startGetFrameDataLoop();
}
if (this.frameDataBuffers.length === 0) {
this.getFrameDataResolve = resolve;
return;
}
const res = this.frameDataBuffers.shift();
if (!res) {
this.getFrameDataResolve = resolve;
return;
}
resolve(res);
});
}
private startGetFrameDataLoop() {
this.getFrameDataLooping = true;
this.getFrameDataLoopTimer = setInterval(() => {
this.getFrameDataLoop();
}, GET_FRAME_DATA_INTERVAL);
}
private getFrameDataLoop() {
if (this.isDestroyed) return;
if (this.seeking) return;
if (!this.videoDecoder) {
this.clearFrameDataLoop();
this.error = 'VideoDecoder is not ready.';
return;
}
if (this.frameDataBuffers.length >= BUFFER_MAX_SIZE) {
this.getFrameDataLooping = false;
this.clearFrameDataLoop();
return;
}
const frameDataOptions = this.videoDecoder.getFrameData();
if (frameDataOptions !== null) {
if (this.getFrameDataResolve) {
this.getFrameDataResolve(frameDataOptions2FrameData(this.bufferIndex, frameDataOptions));
this.getFrameDataResolve = null;
} else {
this.frameDataBuffers.push(frameDataOptions2FrameData(this.bufferIndex, frameDataOptions));
}
this.bufferIndex += 1;
}
}
private clearFrameDataLoop() {
if (this.getFrameDataLoopTimer) {
clearInterval(this.getFrameDataLoopTimer);
this.getFrameDataLoopTimer = null;
}
this.getFrameDataLooping = false;
}
private linkPlayer(player: PAGPlayer | null) {
this.player = player;
if (player) {
player.linkVideoReader(this);
}
}
}