webm-duration-fix
Version:
based on ts-ebml and support large file(than 2GB) and optimize memory usage during repair
378 lines (354 loc) • 16 kB
text/typescript
import {EventEmitter} from "events";
import * as EBML from './EBML';
import * as tools from './tools';
/**
* This is an informal code for reference.
* EBMLReader is a class for getting information to enable seeking Webm recorded by MediaRecorder.
* So please do not use for regular WebM files.
*/
export default class EBMLReader extends EventEmitter {
private metadataloaded: boolean;
private stack: (EBML.MasterElement & EBML.ElementDetail)[];
private chunks: EBML.EBMLElementDetail[];
private segmentOffset: number;
private last2SimpleBlockVideoTrackTimecode: [number, number];
private last2SimpleBlockAudioTrackTimecode: [number, number];
private lastClusterTimecode: number;
private lastClusterPosition: number;
private firstVideoBlockRead: boolean;
private firstAudioBlockRead: boolean;
timecodeScale: number;
metadataSize: number;
metadatas: EBML.EBMLElementDetail[];
private currentTrack: {TrackNumber: number, TrackType: number, DefaultDuration: (number | null), CodecDelay: (number | null) };
private trackTypes: number[]; // equals { [trackID: number]: number };
private trackDefaultDuration: (number | null)[];
private trackCodecDelay: (number | null)[];
private first_video_simpleblock_of_cluster_is_loaded: boolean;
private ended: boolean;
trackInfo: { type: "video" | "audio" | "both"; trackNumber: number; } | { type: "nothing" };
/**
* usefull for thumbnail creation.
*/
use_webp: boolean;
use_duration_every_simpleblock: boolean; // heavy
logging: boolean;
logGroup: string = "";
private hasLoggingStarted: boolean = false;
/**
* usefull for recording chunks.
*/
use_segment_info: boolean;
/** see: https://bugs.chromium.org/p/chromium/issues/detail?id=606000#c22 */
drop_default_duration: boolean;
cues: {CueTrack: number; CueClusterPosition: number; CueTime: number; }[];
constructor(){
super();
this.metadataloaded = false;
this.chunks = [];
this.stack = [];
this.segmentOffset = 0;
this.last2SimpleBlockVideoTrackTimecode = [0, 0];
this.last2SimpleBlockAudioTrackTimecode = [0, 0];
this.lastClusterTimecode = 0;
this.lastClusterPosition = 0;
this.timecodeScale = 1000000; // webm default TimecodeScale is 1ms
this.metadataSize = 0;
this.metadatas = [];
this.cues = [];
this.firstVideoBlockRead = false;
this.firstAudioBlockRead = false;
this.currentTrack = {TrackNumber: -1, TrackType: -1, DefaultDuration: null, CodecDelay: null};
this.trackTypes = [];
this.trackDefaultDuration = [];
this.trackCodecDelay = [];
this.trackInfo = { type: "nothing" };
this.ended = false;
this.logging = false;
this.use_duration_every_simpleblock = false;
this.use_webp = false;
this.use_segment_info = true;
this.drop_default_duration = true;
}
/**
* emit final state.
*/
stop() {
this.ended = true;
this.emit_segment_info();
// clean up any unclosed Master Elements at the end of the stream.
while (this.stack.length) {
this.stack.pop();
if (this.logging) {
console.groupEnd();
}
}
// close main group if set, logging is enabled, and has actually logged anything.
if (this.logging && this.hasLoggingStarted && this.logGroup) {
console.groupEnd();
}
}
/**
* emit chunk info
*/
private emit_segment_info(){
const data = this.chunks;
this.chunks = [];
if(!this.metadataloaded){
this.metadataloaded = true;
this.metadatas = data;
const videoTrackNum = this.trackTypes.indexOf(1); // find first video track
const audioTrackNum = this.trackTypes.indexOf(2); // find first audio track
this.trackInfo = videoTrackNum >= 0 && audioTrackNum >= 0 ? {type: "both", trackNumber: videoTrackNum }
: videoTrackNum >= 0 ? {type: "video", trackNumber: videoTrackNum }
: audioTrackNum >= 0 ? {type: "audio", trackNumber: audioTrackNum }
: {type: "nothing" };
if(!this.use_segment_info){ return; }
this.emit("metadata", {data, metadataSize: this.metadataSize});
}else{
if(!this.use_segment_info){ return; }
const timecode = this.lastClusterTimecode;
const duration = this.duration;
const timecodeScale = this.timecodeScale;
this.emit("cluster", {timecode, data});
this.emit("duration", {timecodeScale, duration});
}
}
read(elm: EBML.EBMLElementDetail){
let drop = false;
if(this.ended){
// reader is finished
return;
}
if(elm.type === "m"){
// 閉じタグの自動挿入
if(elm.isEnd){
this.stack.pop();
}else{
const parent = this.stack[this.stack.length-1];
if(parent != null && parent.level >= elm.level){
// 閉じタグなしでレベルが下がったら閉じタグを挿入
this.stack.pop();
// From http://w3c.github.io/media-source/webm-byte-stream-format.html#webm-media-segments
// This fixes logging for webm streams with Cluster of unknown length and no Cluster closing elements.
if (this.logging){
console.groupEnd();
}
parent.dataEnd = elm.dataEnd;
parent.dataSize = elm.dataEnd - parent.dataStart;
parent.unknownSize = false;
const o = Object.assign({}, parent, {name: parent.name, type: parent.type, isEnd: true});
this.chunks.push(o);
}
this.stack.push(elm);
}
}
if(elm.type === "m" && elm.name == "Segment"){
if(this.segmentOffset != 0) {
console.warn("Multiple segments detected!");
}
this.segmentOffset = elm.dataStart;
this.emit("segment_offset", this.segmentOffset);
}else if(elm.type === "b" && elm.name === "SimpleBlock"){
const {timecode, trackNumber, frames} = tools.ebmlBlock(elm.data);
if(this.trackTypes[trackNumber] === 1){ // trackType === 1 => video track
if(!this.firstVideoBlockRead){
this.firstVideoBlockRead = true;
if(this.trackInfo.type === "both" || this.trackInfo.type === "video"){
const CueTime = this.lastClusterTimecode + timecode;
this.cues.push({CueTrack: trackNumber, CueClusterPosition: this.lastClusterPosition, CueTime});
this.emit("cue_info", {CueTrack: trackNumber, CueClusterPosition: this.lastClusterPosition, CueTime: this.lastClusterTimecode});
this.emit("cue", {CueTrack: trackNumber, CueClusterPosition: this.lastClusterPosition, CueTime});
}
}
this.last2SimpleBlockVideoTrackTimecode = [this.last2SimpleBlockVideoTrackTimecode[1], timecode];
}else if(this.trackTypes[trackNumber] === 2){ // trackType === 2 => audio track
if(!this.firstAudioBlockRead){
this.firstAudioBlockRead = true;
if(this.trackInfo.type === "audio"){
const CueTime = this.lastClusterTimecode + timecode;
this.cues.push({CueTrack: trackNumber, CueClusterPosition: this.lastClusterPosition, CueTime});
this.emit("cue_info", {CueTrack: trackNumber, CueClusterPosition: this.lastClusterPosition, CueTime: this.lastClusterTimecode});
this.emit("cue", {CueTrack: trackNumber, CueClusterPosition: this.lastClusterPosition, CueTime});
}
}
this.last2SimpleBlockAudioTrackTimecode = [this.last2SimpleBlockAudioTrackTimecode[1], timecode];
}
if(this.use_duration_every_simpleblock){
this.emit("duration", {timecodeScale: this.timecodeScale, duration: this.duration});
}
if(this.use_webp){
frames.forEach((frame)=>{
const startcode = frame.slice(3, 6).toString("hex");
if(startcode !== "9d012a"){ return; }; // VP8 の場合
const webpBuf = tools.VP8BitStreamToRiffWebPBuffer(frame);
const webp = new Blob([webpBuf], {type: "image/webp"});
const currentTime = this.duration;
this.emit("webp", {currentTime, webp});
});
}
}else if(elm.type === "m" && elm.name === "Cluster" && elm.isEnd === false){
this.firstVideoBlockRead = false;
this.firstAudioBlockRead = false;
this.emit_segment_info();
this.emit("cluster_ptr", elm.tagStart);
this.lastClusterPosition = elm.tagStart;
}else if(elm.type === "u" && elm.name === "Timecode"){
this.lastClusterTimecode = elm.value;
}else if(elm.type === "u" && elm.name === "TimecodeScale"){
this.timecodeScale = elm.value;
}else if(elm.type === "m" && elm.name === "TrackEntry"){
if(elm.isEnd){
this.trackTypes[this.currentTrack.TrackNumber] = this.currentTrack.TrackType;
this.trackDefaultDuration[this.currentTrack.TrackNumber] = this.currentTrack.DefaultDuration;
this.trackCodecDelay[this.currentTrack.TrackNumber] = this.currentTrack.CodecDelay;
}else{
this.currentTrack = {TrackNumber: -1, TrackType: -1, DefaultDuration: null, CodecDelay: null };
}
}else if(elm.type === "u" && elm.name === "TrackType"){
this.currentTrack.TrackType = elm.value;
}else if(elm.type === "u" && elm.name === "TrackNumber"){
this.currentTrack.TrackNumber = elm.value;
}else if(elm.type === "u" && elm.name === "CodecDelay"){
this.currentTrack.CodecDelay = elm.value;
}else if(elm.type === "u" && elm.name === "DefaultDuration"){
// media source api は DefaultDuration を計算するとバグる。
// https://bugs.chromium.org/p/chromium/issues/detail?id=606000#c22
// chrome 58 ではこれを回避するために DefaultDuration 要素を抜き取った。
// chrome 58 以前でもこのタグを抜き取ることで回避できる
if(this.drop_default_duration){
console.warn("DefaultDuration detected!, remove it");
drop = true;
}else{
this.currentTrack.DefaultDuration = elm.value;
}
}else if(elm.name === "unknown"){
console.warn(elm);
}
if(!this.metadataloaded && elm.dataEnd > 0){
this.metadataSize = elm.dataEnd;
}
if(!drop){ this.chunks.push(elm); }
if(this.logging){ this.put(elm); }
}
/**
* DefaultDuration が定義されている場合は最後のフレームのdurationも考慮する
* 単位 timecodeScale
*
* !!! if you need duration with seconds !!!
* ```js
* const nanosec = reader.duration * reader.timecodeScale;
* const sec = nanosec / 1000 / 1000 / 1000;
* ```
*/
get duration(){
if(this.trackInfo.type === "nothing"){
console.warn("no video, no audio track");
return 0;
}
// defaultDuration は 生の nano sec
let defaultDuration = 0;
// nanoseconds
let codecDelay = 0;
let lastTimecode = 0;
const _defaultDuration = this.trackDefaultDuration[this.trackInfo.trackNumber];
if(typeof _defaultDuration === "number"){
defaultDuration = _defaultDuration;
}else{
// https://bugs.chromium.org/p/chromium/issues/detail?id=606000#c22
// default duration がないときに使う delta
if(this.trackInfo.type === "both"){
if(this.last2SimpleBlockAudioTrackTimecode[1] > this.last2SimpleBlockVideoTrackTimecode[1]){
// audio diff
defaultDuration = (this.last2SimpleBlockAudioTrackTimecode[1] - this.last2SimpleBlockAudioTrackTimecode[0]) * this.timecodeScale;
// audio delay
const delay = this.trackCodecDelay[this.trackTypes.indexOf(2)]; // 2 => audio
if(typeof delay === "number"){ codecDelay = delay; }
// audio timecode
lastTimecode = this.last2SimpleBlockAudioTrackTimecode[1];
}else{
// video diff
defaultDuration = (this.last2SimpleBlockVideoTrackTimecode[1] - this.last2SimpleBlockVideoTrackTimecode[0]) * this.timecodeScale;
// video delay
const delay = this.trackCodecDelay[this.trackTypes.indexOf(1)]; // 1 => video
if(typeof delay === "number"){ codecDelay = delay; }
// video timecode
lastTimecode = this.last2SimpleBlockVideoTrackTimecode[1];
}
}else if(this.trackInfo.type === "video"){
defaultDuration = (this.last2SimpleBlockVideoTrackTimecode[1] - this.last2SimpleBlockVideoTrackTimecode[0]) * this.timecodeScale;
const delay = this.trackCodecDelay[this.trackInfo.trackNumber]; // 2 => audio
if(typeof delay === "number"){ codecDelay = delay; }
lastTimecode = this.last2SimpleBlockVideoTrackTimecode[1];
}else if(this.trackInfo.type === "audio"){
defaultDuration = (this.last2SimpleBlockAudioTrackTimecode[1] - this.last2SimpleBlockAudioTrackTimecode[0]) * this.timecodeScale;
const delay = this.trackCodecDelay[this.trackInfo.trackNumber]; // 1 => video
if(typeof delay === "number"){ codecDelay = delay; }
lastTimecode = this.last2SimpleBlockAudioTrackTimecode[1];
}// else { not reached }
}
// convert to timecodescale
const duration_nanosec = ((this.lastClusterTimecode + lastTimecode) * this.timecodeScale) + defaultDuration - codecDelay;
const duration = duration_nanosec / this.timecodeScale;
return Math.floor(duration);
}
/**
* @deprecated
* emit on every segment
* https://www.matroska.org/technical/specs/notes.html#Position_References
*/
addListener(event: "segment_offset", listener: (ev: number )=> void): this;
/**
* @deprecated
* emit on every cluster element start.
* Offset byte from __file start__. It is not an offset from the Segment element.
*/
addListener(event: "cluster_ptr", listener: (ev: number )=> void): this;
/** @deprecated
* emit on every cue point for cluster to create seekable webm file from MediaRecorder
* */
addListener(event: "cue_info", listener: (ev: CueInfo )=> void): this;
/** emit on every cue point for cluster to create seekable webm file from MediaRecorder */
addListener(event: "cue", listener: (ev: CueInfo )=> void): this;
/** latest EBML > Info > TimecodeScale and EBML > Info > Duration to create seekable webm file from MediaRecorder */
addListener(event: "duration", listener: (ev: DurationInfo )=> void): this;
/** EBML header without Cluster Element for recording metadata chunk */
addListener(event: "metadata", listener: (ev: SegmentInfo & {metadataSize: number})=> void): this;
/** emit every Cluster Element and its children for recording chunk */
addListener(event: "cluster", listener: (ev: SegmentInfo & {timecode: number})=> void): this;
/** for thumbnail */
addListener(event: "webp", listener: (ev: ThumbnailInfo)=> void): this;
addListener(event: string, listener: (ev: any)=> void): this {
return super.addListener(event, listener);
}
put(elm: EBML.EBMLElementDetail) {
if (!this.hasLoggingStarted) {
this.hasLoggingStarted = true;
if (this.logging && this.logGroup) {
console.groupCollapsed(this.logGroup);
}
}
if(elm.type === "m"){
if(elm.isEnd){
console.groupEnd();
}else{
console.group(elm.name+":"+elm.tagStart);
}
}else if(elm.type === "b"){
// for debug
//if(elm.name === "SimpleBlock"){
//const o = EBML.tools.ebmlBlock(elm.value);
//console.log(elm.name, elm.type, o.trackNumber, o.timecode);
//}else{
console.log(elm.name, elm.type);
//}
}else{
console.log(elm.name, elm.tagStart, elm.type, elm.value);
}
}
}
/** CueClusterPosition: Offset byte from __file start__. It is not an offset from the Segment element. */
export interface CueInfo {CueTrack: number; CueClusterPosition: number; CueTime: number; };
export interface SegmentInfo {data: EBML.EBMLElementDetail[];};
export interface DurationInfo {duration: number; timecodeScale: number;};
export interface ThumbnailInfo {webp: Blob; currentTime: number;};