|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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, |
|
|
); |
|
|
} |
|
|
|