ts-ebml-esm
Version:
ebml decoder and encoder
524 lines (504 loc) • 17.5 kB
text/typescript
import { EventEmitter } from "events";
import * as EBML from "./EBML.js";
import * as tools from "./tools.js";
/**
* 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 last2SimpleBlockVideoTrackTimestamp: [number, number];
private last2SimpleBlockAudioTrackTimestamp: [number, number];
private lastClusterTimestamp: number;
private lastClusterPosition: number;
private firstVideoBlockRead: boolean;
private firstAudioBlockRead: boolean;
timestampScale: number;
metadataSize: number;
metadatas: EBML.EBMLElementDetail[];
private currentTrack: {
TrackNumber: number;
TrackType: number;
DefaultDuration: number | null;
CodecDelay: number | null;
};
// this type is equal to
// trackTypes: { [trackID: number]: number };
private trackTypes: number[];
private trackDefaultDuration: (number | null)[];
private trackCodecDelay: (number | null)[];
private ended: boolean;
trackInfo:
| { type: "video" | "audio" | "both"; trackNumber: number }
| { type: "nothing" };
/**
* usefull for thumbnail creation.
*/
use_webp: boolean;
/** Enabling this flag will slow down the operation */
use_duration_every_simpleblock: boolean;
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.last2SimpleBlockVideoTrackTimestamp = [0, 0];
this.last2SimpleBlockAudioTrackTimestamp = [0, 0];
this.lastClusterTimestamp = 0;
this.lastClusterPosition = 0;
// webm default TimestampScale is 1ms
this.timestampScale = 1000000;
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;
// find first video track
const videoTrackNum = this.trackTypes.indexOf(1);
// find first audio track
const audioTrackNum = this.trackTypes.indexOf(2);
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 timestamp = this.lastClusterTimestamp;
const duration = this.duration;
const timestampScale = this.timestampScale;
this.emit("cluster", { timestamp, data });
this.emit("duration", { timestampScale, 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: timestamp,
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.lastClusterTimestamp + timestamp;
this.cues.push({
CueTrack: trackNumber,
CueClusterPosition: this.lastClusterPosition,
CueTime
});
this.emit("cue_info", {
CueTrack: trackNumber,
CueClusterPosition: this.lastClusterPosition,
CueTime: this.lastClusterTimestamp
});
this.emit("cue", {
CueTrack: trackNumber,
CueClusterPosition: this.lastClusterPosition,
CueTime
});
}
}
this.last2SimpleBlockVideoTrackTimestamp = [
this.last2SimpleBlockVideoTrackTimestamp[1],
timestamp
];
} else if (this.trackTypes[trackNumber] === 2) {
// trackType === 2 => audio track
if (!this.firstAudioBlockRead) {
this.firstAudioBlockRead = true;
if (this.trackInfo.type === "audio") {
const CueTime = this.lastClusterTimestamp + timestamp;
this.cues.push({
CueTrack: trackNumber,
CueClusterPosition: this.lastClusterPosition,
CueTime
});
this.emit("cue_info", {
CueTrack: trackNumber,
CueClusterPosition: this.lastClusterPosition,
CueTime: this.lastClusterTimestamp
});
this.emit("cue", {
CueTrack: trackNumber,
CueClusterPosition: this.lastClusterPosition,
CueTime
});
}
}
this.last2SimpleBlockAudioTrackTimestamp = [
this.last2SimpleBlockAudioTrackTimestamp[1],
timestamp
];
}
if (this.use_duration_every_simpleblock) {
this.emit("duration", {
timestampScale: this.timestampScale,
duration: this.duration
});
}
if (this.use_webp) {
for (const frame of frames) {
const startcode = frame.subarray(3, 6).toString("hex");
// this is not a good way to detect VP8
// see rfc6386 -- VP8 Data Format and Decoding Guide
if (startcode !== "9d012a") {
break;
}
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) {
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 === "Timestamp") {
this.lastClusterTimestamp = elm.value;
} else if (elm.type === "u" && elm.name === "TimestampScale") {
this.timestampScale = 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も考慮する
* 単位 timestampScale
*
* !!! if you need duration with seconds !!!
* ```js
* const nanosec = reader.duration * reader.timestampScale;
* 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 lastTimestamp = 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.last2SimpleBlockAudioTrackTimestamp[1] >
this.last2SimpleBlockVideoTrackTimestamp[1]
) {
// audio diff
defaultDuration =
(this.last2SimpleBlockAudioTrackTimestamp[1] -
this.last2SimpleBlockAudioTrackTimestamp[0]) *
this.timestampScale;
// audio delay
// 2 => audio
const delay = this.trackCodecDelay[this.trackTypes.indexOf(2)];
if (typeof delay === "number") {
codecDelay = delay;
}
// audio timestamp
lastTimestamp = this.last2SimpleBlockAudioTrackTimestamp[1];
} else {
// video diff
defaultDuration =
(this.last2SimpleBlockVideoTrackTimestamp[1] -
this.last2SimpleBlockVideoTrackTimestamp[0]) *
this.timestampScale;
// video delay
// 1 => video
const delay = this.trackCodecDelay[this.trackTypes.indexOf(1)];
if (typeof delay === "number") {
codecDelay = delay;
}
// video timestamp
lastTimestamp = this.last2SimpleBlockVideoTrackTimestamp[1];
}
} else if (this.trackInfo.type === "video") {
defaultDuration =
(this.last2SimpleBlockVideoTrackTimestamp[1] -
this.last2SimpleBlockVideoTrackTimestamp[0]) *
this.timestampScale;
const delay = this.trackCodecDelay[this.trackInfo.trackNumber];
if (typeof delay === "number") {
codecDelay = delay;
}
lastTimestamp = this.last2SimpleBlockVideoTrackTimestamp[1];
} else if (this.trackInfo.type === "audio") {
defaultDuration =
(this.last2SimpleBlockAudioTrackTimestamp[1] -
this.last2SimpleBlockAudioTrackTimestamp[0]) *
this.timestampScale;
const delay = this.trackCodecDelay[this.trackInfo.trackNumber];
if (typeof delay === "number") {
codecDelay = delay;
}
lastTimestamp = this.last2SimpleBlockAudioTrackTimestamp[1];
}
// else { never }
}
// convert to timestampscale
const duration_nanosec =
(this.lastClusterTimestamp + lastTimestamp) * this.timestampScale +
defaultDuration -
codecDelay;
const duration = duration_nanosec / this.timestampScale;
return Math.floor(duration);
}
/**
* @deprecated
* emit on every segment
* https://www.matroska.org/technical/specs/notes.html#Position_References
*/
override 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.
*/
override addListener(
event: "cluster_ptr",
listener: (ev: number) => void
): this;
/** @deprecated
* emit on every cue point for cluster to create seekable webm file from MediaRecorder
* */
override addListener(
event: "cue_info",
listener: (ev: CueInfo) => void
): this;
/** emit on every cue point for cluster to create seekable webm file from MediaRecorder */
override addListener(event: "cue", listener: (ev: CueInfo) => void): this;
/** latest EBML > Info > TimestampScale and EBML > Info > Duration to create seekable webm file from MediaRecorder */
override addListener(
event: "duration",
listener: (ev: DurationInfo) => void
): this;
/** EBML header without Cluster Element for recording metadata chunk */
override addListener(
event: "metadata",
listener: (ev: SegmentInfo & { metadataSize: number }) => void
): this;
/** emit every Cluster Element and its children for recording chunk */
override addListener(
event: "cluster",
listener: (ev: SegmentInfo & { timestamp: number }) => void
): this;
/** for thumbnail */
override addListener(
event: "webp",
listener: (ev: ThumbnailInfo) => void
): this;
override 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.timestamp);
//}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;
timestampScale: number;
}
export interface ThumbnailInfo {
webp: Blob;
currentTime: number;
}