|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import {ImageFrame} from '@/common/codecs/VideoDecoder'; |
|
|
import {MP4ArrayBuffer, createFile} from 'mp4box'; |
|
|
|
|
|
|
|
|
|
|
|
const TIMESCALE = 90000; |
|
|
const SECONDS_PER_KEY_FRAME = 2; |
|
|
|
|
|
export function encode( |
|
|
width: number, |
|
|
height: number, |
|
|
numFrames: number, |
|
|
framesGenerator: AsyncGenerator<ImageFrame, unknown>, |
|
|
progressCallback?: (progress: number) => void, |
|
|
): Promise<MP4ArrayBuffer> { |
|
|
return new Promise((resolve, reject) => { |
|
|
let encodedFrameIndex = 0; |
|
|
let nextKeyFrameTimestamp = 0; |
|
|
let trackID: number | null = null; |
|
|
const durations: number[] = []; |
|
|
|
|
|
const outputFile = createFile(); |
|
|
|
|
|
const encoder = new VideoEncoder({ |
|
|
output(chunk, metaData) { |
|
|
const uint8 = new Uint8Array(chunk.byteLength); |
|
|
chunk.copyTo(uint8); |
|
|
|
|
|
const description = metaData?.decoderConfig?.description; |
|
|
if (trackID === null) { |
|
|
trackID = outputFile.addTrack({ |
|
|
width: width, |
|
|
height: height, |
|
|
timescale: TIMESCALE, |
|
|
avcDecoderConfigRecord: description, |
|
|
}); |
|
|
} |
|
|
const shiftedDuration = durations.shift(); |
|
|
if (shiftedDuration != null) { |
|
|
outputFile.addSample(trackID, uint8, { |
|
|
duration: getScaledDuration(shiftedDuration), |
|
|
is_sync: chunk.type === 'key', |
|
|
}); |
|
|
encodedFrameIndex++; |
|
|
progressCallback?.(encodedFrameIndex / numFrames); |
|
|
} |
|
|
|
|
|
if (encodedFrameIndex === numFrames) { |
|
|
resolve(outputFile.getBuffer()); |
|
|
} |
|
|
}, |
|
|
error(error) { |
|
|
reject(error); |
|
|
return; |
|
|
}, |
|
|
}); |
|
|
|
|
|
const setConfigurationAndEncodeFrames = async () => { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const configuration: VideoEncoderConfig = { |
|
|
codec: 'avc1.4d0034', |
|
|
width: roundToNearestEven(width), |
|
|
height: roundToNearestEven(height), |
|
|
bitrate: 14_000_000, |
|
|
alpha: 'discard', |
|
|
bitrateMode: 'variable', |
|
|
latencyMode: 'realtime', |
|
|
}; |
|
|
const supportedConfig = |
|
|
await VideoEncoder.isConfigSupported(configuration); |
|
|
if (supportedConfig.supported === true) { |
|
|
encoder.configure(configuration); |
|
|
} else { |
|
|
throw new Error( |
|
|
`Unsupported video encoder config ${JSON.stringify(supportedConfig)}`, |
|
|
); |
|
|
} |
|
|
|
|
|
for await (const frame of framesGenerator) { |
|
|
const {bitmap, duration, timestamp} = frame; |
|
|
durations.push(duration); |
|
|
let keyFrame = false; |
|
|
if (timestamp >= nextKeyFrameTimestamp) { |
|
|
await encoder.flush(); |
|
|
keyFrame = true; |
|
|
nextKeyFrameTimestamp = timestamp + SECONDS_PER_KEY_FRAME * 1e6; |
|
|
} |
|
|
encoder.encode(bitmap, {keyFrame}); |
|
|
bitmap.close(); |
|
|
} |
|
|
|
|
|
await encoder.flush(); |
|
|
encoder.close(); |
|
|
}; |
|
|
|
|
|
setConfigurationAndEncodeFrames(); |
|
|
}); |
|
|
} |
|
|
|
|
|
function getScaledDuration(rawDuration: number) { |
|
|
return rawDuration / (1_000_000 / TIMESCALE); |
|
|
} |
|
|
|
|
|
function roundToNearestEven(dim: number) { |
|
|
const rounded = Math.round(dim); |
|
|
|
|
|
if (rounded % 2 === 0) { |
|
|
return rounded; |
|
|
} else { |
|
|
return rounded + (rounded > dim ? -1 : 1); |
|
|
} |
|
|
} |
|
|
|