| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import {cloneFrame} from '@/common/codecs/WebCodecUtils';
|
| import {FileStream} from '@/common/utils/FileUtils';
|
| import {
|
| createFile,
|
| DataStream,
|
| MP4ArrayBuffer,
|
| MP4File,
|
| MP4Sample,
|
| MP4VideoTrack,
|
| } from 'mp4box';
|
| import {isAndroid, isChrome, isEdge, isWindows} from 'react-device-detect';
|
|
|
| export type ImageFrame = {
|
| bitmap: VideoFrame;
|
| timestamp: number;
|
| duration: number;
|
| };
|
|
|
| export type DecodedVideo = {
|
| width: number;
|
| height: number;
|
| frames: ImageFrame[];
|
| numFrames: number;
|
| fps: number;
|
| };
|
|
|
| function decodeInternal(
|
| identifier: string,
|
| onReady: (mp4File: MP4File) => Promise<void>,
|
| onProgress: (decodedVideo: DecodedVideo) => void,
|
| ): Promise<DecodedVideo> {
|
| return new Promise((resolve, reject) => {
|
| const imageFrames: ImageFrame[] = [];
|
| const globalSamples: MP4Sample[] = [];
|
|
|
| let decoder: VideoDecoder;
|
|
|
| let track: MP4VideoTrack | null = null;
|
| const mp4File = createFile();
|
|
|
| mp4File.onError = reject;
|
| mp4File.onReady = async info => {
|
| if (info.videoTracks.length > 0) {
|
| track = info.videoTracks[0];
|
| } else {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| track = info.otherTracks[0];
|
| }
|
|
|
| if (track == null) {
|
| reject(new Error(`${identifier} does not contain a video track`));
|
| return;
|
| }
|
|
|
| const timescale = track.timescale;
|
| const edits = track.edits;
|
|
|
| let frame_n = 0;
|
| decoder = new VideoDecoder({
|
|
|
|
|
| async output(inputFrame) {
|
| if (track == null) {
|
| reject(new Error(`${identifier} does not contain a video track`));
|
| return;
|
| }
|
|
|
| const saveTrack = track;
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (edits != null && edits.length > 0) {
|
| const cts = Math.round(
|
| (inputFrame.timestamp * timescale) / 1_000_000,
|
| );
|
| if (cts < edits[0].media_time) {
|
| inputFrame.close();
|
| return;
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (
|
| (isAndroid && isChrome) ||
|
| (isWindows && isChrome) ||
|
| (isWindows && isEdge)
|
| ) {
|
| const clonedFrame = await cloneFrame(inputFrame);
|
| inputFrame.close();
|
| inputFrame = clonedFrame;
|
| }
|
|
|
| const sample = globalSamples[frame_n];
|
| if (sample != null) {
|
| const duration = (sample.duration * 1_000_000) / sample.timescale;
|
| imageFrames.push({
|
| bitmap: inputFrame,
|
| timestamp: inputFrame.timestamp,
|
| duration,
|
| });
|
|
|
|
|
| imageFrames.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
|
|
|
| if (onProgress != null && frame_n % 100 === 0) {
|
| onProgress({
|
| width: saveTrack.track_width,
|
| height: saveTrack.track_height,
|
| frames: imageFrames,
|
| numFrames: saveTrack.nb_samples,
|
| fps:
|
| (saveTrack.nb_samples / saveTrack.duration) *
|
| saveTrack.timescale,
|
| });
|
| }
|
| }
|
| frame_n++;
|
|
|
| if (saveTrack.nb_samples === frame_n) {
|
|
|
|
|
| imageFrames.sort((a, b) => (a.timestamp > b.timestamp ? 1 : -1));
|
| resolve({
|
| width: saveTrack.track_width,
|
| height: saveTrack.track_height,
|
| frames: imageFrames,
|
| numFrames: saveTrack.nb_samples,
|
| fps:
|
| (saveTrack.nb_samples / saveTrack.duration) *
|
| saveTrack.timescale,
|
| });
|
| }
|
| },
|
| error(error) {
|
| reject(error);
|
| },
|
| });
|
|
|
| let description;
|
| const trak = mp4File.getTrackById(track.id);
|
| const entries = trak?.mdia?.minf?.stbl?.stsd?.entries;
|
| if (entries == null) {
|
| return;
|
| }
|
| for (const entry of entries) {
|
| if (entry.avcC || entry.hvcC) {
|
| const stream = new DataStream(undefined, 0, DataStream.BIG_ENDIAN);
|
| if (entry.avcC) {
|
| entry.avcC.write(stream);
|
| } else if (entry.hvcC) {
|
| entry.hvcC.write(stream);
|
| }
|
| description = new Uint8Array(stream.buffer, 8);
|
| break;
|
| }
|
| }
|
|
|
| const configuration: VideoDecoderConfig = {
|
| codec: track.codec,
|
| codedWidth: track.track_width,
|
| codedHeight: track.track_height,
|
| description,
|
| };
|
| const supportedConfig =
|
| await VideoDecoder.isConfigSupported(configuration);
|
| if (supportedConfig.supported == true) {
|
| decoder.configure(configuration);
|
|
|
| mp4File.setExtractionOptions(track.id, null, {
|
| nbSamples: Infinity,
|
| });
|
| mp4File.start();
|
| } else {
|
| reject(
|
| new Error(
|
| `Decoder config faile: config ${JSON.stringify(
|
| supportedConfig.config,
|
| )} is not supported`,
|
| ),
|
| );
|
| return;
|
| }
|
| };
|
|
|
| mp4File.onSamples = async (
|
| _id: number,
|
| _user: unknown,
|
| samples: MP4Sample[],
|
| ) => {
|
| for (const sample of samples) {
|
| globalSamples.push(sample);
|
| decoder.decode(
|
| new EncodedVideoChunk({
|
| type: sample.is_sync ? 'key' : 'delta',
|
| timestamp: (sample.cts * 1_000_000) / sample.timescale,
|
| duration: (sample.duration * 1_000_000) / sample.timescale,
|
| data: sample.data,
|
| }),
|
| );
|
| }
|
| await decoder.flush();
|
| decoder.close();
|
| };
|
|
|
| onReady(mp4File);
|
| });
|
| }
|
|
|
| export function decode(
|
| file: File,
|
| onProgress: (decodedVideo: DecodedVideo) => void,
|
| ): Promise<DecodedVideo> {
|
| return decodeInternal(
|
| file.name,
|
| async (mp4File: MP4File) => {
|
| const reader = new FileReader();
|
| reader.onload = function () {
|
| const result = this.result as MP4ArrayBuffer;
|
| if (result != null) {
|
| result.fileStart = 0;
|
| mp4File.appendBuffer(result);
|
| }
|
| mp4File.flush();
|
| };
|
| reader.readAsArrayBuffer(file);
|
| },
|
| onProgress,
|
| );
|
| }
|
|
|
| export function decodeStream(
|
| fileStream: FileStream,
|
| onProgress: (decodedVideo: DecodedVideo) => void,
|
| ): Promise<DecodedVideo> {
|
| return decodeInternal(
|
| 'stream',
|
| async (mp4File: MP4File) => {
|
| let part = await fileStream.next();
|
| while (part.done === false) {
|
| const result = part.value.data.buffer as MP4ArrayBuffer;
|
| if (result != null) {
|
| result.fileStart = part.value.range.start;
|
| mp4File.appendBuffer(result);
|
| }
|
| mp4File.flush();
|
| part = await fileStream.next();
|
| }
|
| },
|
| onProgress,
|
| );
|
| }
|
|
|