/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.mkv.MatroskaParser');
goog.require('shaka.codec.VP9');
goog.require('shaka.log');
goog.require('shaka.media.InitSegmentReference');
goog.require('shaka.media.ManifestParser');
goog.require('shaka.media.PresentationTimeline');
goog.require('shaka.media.SegmentIndex');
goog.require('shaka.media.SegmentReference');
goog.require('shaka.mkv.CodecId');
goog.require('shaka.mkv.ElementId');
goog.require('shaka.mkv.MatroskaClusterParser');
goog.require('shaka.mkv.MatroskaCodecs');
goog.require('shaka.mkv.MatroskaConstants');
goog.require('shaka.mkv.MatroskaIndexParser');
goog.require('shaka.text.MatroskaTextParser');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.net.NetworkingUtils');
goog.require('shaka.util.BufferUtils');
goog.require('shaka.util.Error');
goog.require('shaka.util.LanguageUtils');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.StreamUtils');
/**
* Parses a Matroska (.mkv, .mka) file into a manifest. Every track becomes a
* stream, and the Cues of the file give the segments: byte ranges of the file,
* each starting at a Cluster with a keyframe. Nothing is downloaded but the
* header, the Cues and the Chapters until the streams are played; the media is
* repackaged by shaka.transmuxer.MatroskaTransmuxer, and the
* subtitles are read by shaka.text.MatroskaTextParser.
*
* @implements {shaka.extern.ManifestParser}
* @export
*/
shaka.mkv.MatroskaParser = class {
constructor() {
/** @private {?shaka.extern.ManifestConfiguration} */
this.config_ = null;
/** @private {?shaka.extern.ManifestParser.PlayerInterface} */
this.playerInterface_ = null;
/** @private {!Set<!shaka.extern.IAbortableOperation>} */
this.operations_ = new Set();
/** @private {boolean} */
this.stopped_ = false;
}
/**
* @override
* @export
*/
configure(config) {
this.config_ = config;
}
/**
* @override
* @export
*/
async start(uri, playerInterface) {
this.playerInterface_ = playerInterface;
try {
return await this.load_(uri);
} catch (error) {
throw this.addUri_(error, uri);
}
}
/**
* @override
* @export
*/
async stop() {
this.stopped_ = true;
await Promise.all(Array.from(this.operations_).map((operation) => {
return operation.abort();
}));
this.operations_.clear();
this.playerInterface_ = null;
}
/** @override */
update() {}
/** @override */
onExpirationUpdated(sessionId, expiration) {}
/** @override */
onInitialVariantChosen(variant) {}
/** @override */
banLocation(uri) {}
/** @override */
setMediaElement(mediaElement) {}
/**
* @param {string} uri
* @return {!Promise<shaka.extern.Manifest>}
* @private
*/
async load_(uri) {
const {header, totalSize} = await this.readHeader_(uri);
const candidates = this.getCandidates_(header);
if (!candidates.length) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.MKV_NO_SUPPORTED_TRACKS,
uri);
}
// The ranges the streams are made of come from the Cues of one track: a
// video track when there is one, as only video is cut at keyframes.
const indexTrack = (candidates.find((candidate) => {
return candidate.contentType ==
shaka.util.ManifestParserUtils.ContentType.VIDEO;
}) || candidates[0]).track;
const cues = await this.readCues_(uri, header);
const announcedDuration = header.duration != null ? header.duration :
shaka.mkv.MatroskaParser.estimateDuration_(cues, indexTrack.number);
if (!announcedDuration || !Number.isFinite(announcedDuration) ||
announcedDuration <= 0) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.MKV_MISSING_INDEX,
uri);
}
const duration = announcedDuration;
// The streams that are wanted.
const ContentType = shaka.util.ManifestParserUtils.ContentType;
const wanted = candidates.filter((candidate) => {
if (!this.config_) {
return true;
}
switch (candidate.contentType) {
case ContentType.VIDEO:
return !this.config_.disableVideo;
case ContentType.AUDIO:
return !this.config_.disableAudio;
default:
return !this.config_.disableText;
}
});
await this.probeVp9_(uri, header, cues, indexTrack, wanted);
const bandwidth = totalSize ? Math.round(totalSize * 8 / duration) : 0;
const makeReferences = (initReference, timestampOffset) => {
return shaka.mkv.MatroskaParser.makeReferences_(cues, header,
indexTrack.number, duration, uri, initReference, timestampOffset);
};
const videoStreams = [];
const audioStreams = [];
const textStreams = [];
for (const candidate of wanted) {
// The two streams of a track are told apart by the initialization
// segment, which the StreamingEngine appends again when it is another
// range than the last one. So the second one takes a byte more. The
// byte is the start of the element that follows, and an element takes two
// at least, so it does not add anything to what the segment says.
const initEnd = shaka.mkv.MatroskaIndexParser.getInitEndOffset(
header, candidate.track) + (candidate.isAlternate ? 1 : 0);
const initReference = new shaka.media.InitSegmentReference(
() => [uri], 0, initEnd);
// The transmuxer writes the audio and the video later than they are in
// the file; see MEDIA_TIME_SHIFT. Subtitles are not moved.
const references = makeReferences(initReference,
candidate.contentType == ContentType.TEXT ?
0 : -shaka.mkv.MatroskaConstants.MEDIA_TIME_SHIFT);
if (shaka.mkv.MatroskaCodecs.usesRawMpegAudio(candidate.track.codecId)) {
// Raw MPEG audio has no timestamps. Anchor each append to its segment,
// including after a seek, instead of applying the MP4/WebM shift.
for (const reference of references) {
reference.timestampOffset = reference.startTime;
}
}
if (!references.length) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.MKV_MISSING_INDEX,
uri);
}
const stream = this.createStream_(candidate, references, bandwidth);
switch (candidate.contentType) {
case ContentType.VIDEO:
videoStreams.push(stream);
break;
case ContentType.AUDIO:
audioStreams.push(stream);
break;
default:
textStreams.push(stream);
break;
}
}
const variants = shaka.mkv.MatroskaParser.makeVariants_(
videoStreams, audioStreams, bandwidth);
if (!variants.length) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.MKV_NO_SUPPORTED_TRACKS,
uri);
}
const timeline = new shaka.media.PresentationTimeline(null, 0);
timeline.setStatic(true);
timeline.setDuration(duration);
const chapters = await this.readChapters_(uri, header);
const firstChapterId =
Math.max(0, ...header.tracks.map((track) => track.number)) + 1;
return {
presentationTimeline: timeline,
variants,
textStreams,
imageStreams: [],
chapterStreams: shaka.mkv.MatroskaParser.makeChapterStreams_(
chapters, duration, firstChapterId),
offlineSessionIds: [],
sequenceMode: false,
ignoreManifestTimestampsInSegmentsMode: false,
type: shaka.media.ManifestParser.MKV,
serviceDescription: null,
nextUrl: null,
periodCount: 1,
gapCount: 0,
isLowLatency: false,
startTime: null,
};
}
/**
* Reads the header of the file: everything before the first Cluster. Its
* size is not known beforehand, so the range that is requested grows until
* the first Cluster is in it.
*
* @param {string} uri
* @return {!Promise<{header: shaka.mkv.MatroskaIndexParser.Header,
* totalSize: ?number}>}
* @private
*/
async readHeader_(uri) {
let size = shaka.mkv.MatroskaParser.INITIAL_HEADER_READ_SIZE_;
let totalSize = null;
while (true) {
// The size of the header is unknown until it has been read, so the
// requests cannot be made at the same time.
// eslint-disable-next-line no-await-in-loop
const response = await this.requestRange_(uri, 0, size - 1);
if (totalSize == null) {
totalSize = shaka.mkv.MatroskaParser.getTotalSize_(response);
}
const header = shaka.mkv.MatroskaIndexParser.parseHeader(response.data);
if (header.firstClusterOffset != null) {
return {header, totalSize};
}
// Either the file is over (it has no media) or the header is still to
// come.
const received = shaka.util.BufferUtils.toUint8(response.data).length;
const maxSize = shaka.mkv.MatroskaParser.MAX_HEADER_READ_SIZE_;
if (received < size || size >= maxSize) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.MKV_INVALID_FILE,
uri);
}
size *= 2;
}
}
/**
* Lists the tracks that become streams, with what is known of them.
*
* @param {shaka.mkv.MatroskaIndexParser.Header} header
* @return {!Array<shaka.mkv.MatroskaParser.Candidate>}
* @private
*/
getCandidates_(header) {
const MatroskaCodecs = shaka.mkv.MatroskaCodecs;
const numbers = new Set();
const candidates = [];
for (const track of header.tracks) {
const contentType = MatroskaCodecs.getContentType(track);
if (!contentType || !track.enabled) {
continue;
}
if (!track.number || numbers.has(track.number)) {
// The number is what the blocks refer to, so it has to be unique.
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.MKV_INVALID_FILE);
}
numbers.add(track.number);
const codecs = MatroskaCodecs.getCodecs(track);
if (!codecs) {
shaka.log.warning('Ignoring the Matroska track', track.number,
'with the unsupported codec', track.codecId);
continue;
}
const supplementalCodecs = this.config_ &&
this.config_.ignoreSupplementalCodecs ? '' :
shaka.mkv.MatroskaCodecs.getSupplementalCodecs(track);
candidates.push({
track,
contentType,
codecs,
supplementalCodecs,
streamId: track.number,
isAlternate: false,
});
if (supplementalCodecs) {
// A second stream for the platforms that decode Dolby Vision, next to
// the one for those that do not. The player keeps the ones it plays.
candidates.push({
track,
contentType,
codecs: supplementalCodecs,
supplementalCodecs,
streamId: track.number +
shaka.mkv.MatroskaConstants.ALTERNATE_STREAM_ID_OFFSET,
isAlternate: true,
});
}
}
return candidates;
}
/**
* A VP9 track normally has no CodecPrivate, so the profile and the bit depth
* that the codec string needs are only in the first key frame. Reads it.
*
* @param {string} uri
* @param {shaka.mkv.MatroskaIndexParser.Header} header
* @param {!Array<shaka.mkv.MatroskaIndexParser.CuePoint>} cues
* @param {!shaka.mkv.MatroskaIndexParser.Track} indexTrack
* @param {!Array<shaka.mkv.MatroskaParser.Candidate>} candidates
* @return {!Promise}
* @private
*/
async probeVp9_(uri, header, cues, indexTrack, candidates) {
const toProbe = candidates.filter((candidate) => {
return candidate.track.codecId == shaka.mkv.CodecId.VP9 &&
!candidate.track.codecPrivate;
});
if (!toProbe.length) {
return;
}
const first = shaka.mkv.MatroskaParser.makeReferences_(cues, header,
indexTrack.number, Number.MAX_VALUE, uri, null)[0];
if (!first) {
return;
}
const start = first.getStartByte();
const end = Math.min(first.getEndByte() ?? Infinity,
start + shaka.mkv.MatroskaParser.VP9_PROBE_SIZE_ - 1);
const response = await this.requestRange_(uri, start, end);
for (const candidate of toProbe) {
const frames = shaka.mkv.MatroskaClusterParser.parseFrames(
response.data, candidate.track, header.timecodeScale,
/* allowPartial= */ true);
const keyframe = frames.find((frame) => frame.keyframe);
const info = keyframe && shaka.codec.VP9.parseKeyFrame(keyframe.data);
if (info) {
candidate.codecs = shaka.codec.VP9.getCodecs(info,
candidate.track.width || info.width,
candidate.track.height || info.height,
candidate.track.defaultDuration);
}
}
}
/**
* @param {string} uri
* @param {shaka.mkv.MatroskaIndexParser.Header} header
* @return {!Promise<!Array<shaka.mkv.MatroskaIndexParser.CuePoint>>}
* @private
*/
async readCues_(uri, header) {
const cuesOffset = header.cuesOffset;
if (cuesOffset == null) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.MKV_MISSING_INDEX,
uri);
}
const data = await this.requestElement_(
uri, cuesOffset, shaka.mkv.ElementId.CUES);
const cues = shaka.mkv.MatroskaIndexParser.parseCues(
data, header.segmentOffset, header.timecodeScale);
if (!cues.length) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.MKV_MISSING_INDEX,
uri);
}
return cues;
}
/**
* @param {string} uri
* @param {shaka.mkv.MatroskaIndexParser.Header} header
* @return {!Promise<!Array<shaka.mkv.MatroskaIndexParser.Chapter>>}
* @private
*/
async readChapters_(uri, header) {
if (header.chaptersOffset == null ||
(this.config_ && this.config_.disableChapters)) {
return [];
}
const data = await this.requestElement_(
uri, header.chaptersOffset, shaka.mkv.ElementId.CHAPTERS);
return shaka.mkv.MatroskaIndexParser.parseChapters(data);
}
/**
* Downloads a top-level element whose size is not known: its header first,
* then exactly the element.
*
* @param {string} uri
* @param {number} offset
* @param {shaka.mkv.ElementId} id
* @return {!Promise<BufferSource>}
* @private
*/
async requestElement_(uri, offset, id) {
const head = await this.requestRange_(uri, offset,
offset + shaka.mkv.MatroskaParser.ELEMENT_HEADER_READ_SIZE_ - 1);
const length = shaka.mkv.MatroskaIndexParser.getElementLength(
head.data, id);
if (length > shaka.mkv.MatroskaParser.MAX_ELEMENT_SIZE_) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.MKV_INVALID_FILE,
uri);
}
const body = await this.requestRange_(uri, offset, offset + length - 1);
return body.data;
}
/**
* @param {string} uri
* @param {number} start
* @param {number} end
* @return {!Promise<shaka.extern.Response>}
* @private
*/
async requestRange_(uri, start, end) {
if (this.stopped_ || !this.playerInterface_) {
throw this.makeAbortedError_();
}
const request = shaka.net.NetworkingUtils.createSegmentRequest(
[uri], start, end, this.playerInterface_.getStreamingRetryParameters());
const operation = this.playerInterface_.networkingEngine.request(
shaka.net.NetworkingEngine.RequestType.SEGMENT, request);
this.operations_.add(operation);
try {
const response = await operation.promise;
if (this.stopped_) {
throw this.makeAbortedError_();
}
// A server that ignores the Range header answers with the whole file.
if (shaka.util.BufferUtils.toUint8(response.data).byteLength >
end - start + 1) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.MKV_INVALID_FILE,
uri);
}
return response;
} finally {
this.operations_.delete(operation);
}
}
/**
* @param {!shaka.mkv.MatroskaParser.Candidate} candidate
* @param {!Array<!shaka.media.SegmentReference>} references
* @param {number} bandwidth
* @return {!shaka.extern.Stream}
* @private
*/
createStream_(candidate, references, bandwidth) {
const {track, contentType, codecs} = candidate;
const ContentType = shaka.util.ManifestParserUtils.ContentType;
const MatroskaCodecs = shaka.mkv.MatroskaCodecs;
const AccessibilityPurpose =
shaka.media.ManifestParser.AccessibilityPurpose;
const isVideo = contentType == ContentType.VIDEO;
const isText = contentType == ContentType.TEXT;
/** @type {!Object<string, *>} */
const overrides = {
id: candidate.streamId,
originalId: String(track.number),
type: contentType,
mimeType: isText ? shaka.text.MatroskaTextParser.MIME_TYPE :
contentType + '/x-matroska',
codecs,
supplementalCodecs: candidate.supplementalCodecs,
language: shaka.util.LanguageUtils.normalize(track.language),
originalLanguage: track.language,
label: track.name || null,
primary: track.isDefault,
forced: track.forced,
};
if (isVideo) {
const pixelAspectRatio = MatroskaCodecs.getPixelAspectRatio(track);
Object.assign(overrides, {
width: track.width,
height: track.height,
frameRate: MatroskaCodecs.getFrameRate(track),
pixelAspectRatio: pixelAspectRatio ?
pixelAspectRatio.horizontal + ':' + pixelAspectRatio.vertical :
undefined,
hdr: MatroskaCodecs.getHdr(track),
// Every stream is read from the same bytes, so the file's bitrate is
// what any of them costs.
bandwidth,
});
} else if (!isText) {
Object.assign(overrides, {
channelsCount: track.channels,
audioSamplingRate: track.outputSampleRate || track.sampleRate,
});
}
if (track.hearingImpaired) {
overrides['accessibilityPurpose'] = AccessibilityPurpose.HARD_OF_HEARING;
overrides['roles'] = ['caption'];
} else if (track.visualImpaired) {
overrides['accessibilityPurpose'] =
AccessibilityPurpose.VISUALLY_IMPAIRED;
overrides['roles'] = ['description'];
} else if (track.commentary) {
overrides['roles'] = ['commentary'];
}
if (isText) {
overrides['kind'] = track.hearingImpaired ? 'caption' : 'subtitle';
// The text engine waits for the timestamp offset that MediaSource
// reports for the media, which this container never has: the cues have
// absolute times. External streams are the ones that do not wait.
overrides['external'] = true;
}
const stream = shaka.util.StreamUtils.createStream(overrides);
stream.createSegmentIndex = () => {
// The index may be released and created again, so it gets a copy.
stream.segmentIndex = new shaka.media.SegmentIndex(references.slice());
return Promise.resolve();
};
stream.closeSegmentIndex = () => {
if (stream.segmentIndex) {
stream.segmentIndex.release();
stream.segmentIndex = null;
}
};
return stream;
}
/**
* Adds the URI to the errors of the file, which are created without it.
*
* @param {*} error
* @param {string} uri
* @return {*}
* @private
*/
addUri_(error, uri) {
const Code = shaka.util.Error.Code;
if (error instanceof shaka.util.Error && !error.data.length &&
[Code.MKV_INVALID_FILE, Code.MKV_MISSING_INDEX,
Code.MKV_NO_SUPPORTED_TRACKS].includes(error.code)) {
return new shaka.util.Error(
error.severity, error.category, error.code, uri);
}
return error;
}
/** @return {!shaka.util.Error} @private */
makeAbortedError_() {
return new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.PLAYER,
shaka.util.Error.Code.OPERATION_ABORTED);
}
/**
* Makes references to consecutive cluster ranges. A range may contain more
* than one cluster when not every cluster has a CuePoint. Each reference
* therefore starts on a random access point indicated by Cues.
*
* @param {!Array<shaka.mkv.MatroskaIndexParser.CuePoint>} cues
* @param {shaka.mkv.MatroskaIndexParser.Header} header
* @param {number} trackNumber The track whose CuePoints define the ranges.
* Cues of other tracks (e.g. subtitles) do not fall on keyframes.
* @param {number} duration
* @param {string} uri
* @param {shaka.media.InitSegmentReference} initSegmentReference
* @param {number=} timestampOffset
* @return {!Array<!shaka.media.SegmentReference>}
* @private
*/
static makeReferences_(cues, header, trackNumber, duration, uri,
initSegmentReference, timestampOffset = 0) {
const firstClusterOffset = header.firstClusterOffset;
if (firstClusterOffset == null) {
return [];
}
const pointsByOffset = new Map();
pointsByOffset.set(firstClusterOffset, 0);
for (const cue of cues) {
if (cue.track == trackNumber && cue.offset >= firstClusterOffset &&
cue.time < duration) {
const oldTime = pointsByOffset.get(cue.offset);
pointsByOffset.set(cue.offset, oldTime == null ? cue.time :
Math.min(oldTime, cue.time));
}
}
const points = Array.from(pointsByOffset).sort((a, b) => a[0] - b[0]);
const references = [];
for (let i = 0; i < points.length; i++) {
const [startByte, startTime] = points[i];
const next = points[i + 1];
const endTime = next ? next[1] : duration;
if (endTime <= startTime) {
continue;
}
// The last range stops before the Cues (or any other element that
// follows the Clusters) so it does not download them.
const endByte = next ? next[0] - 1 :
(header.mediaEndOffset == null ? null : header.mediaEndOffset - 1);
references.push(new shaka.media.SegmentReference(
startTime, endTime, () => [uri], startByte, endByte,
initSegmentReference,
timestampOffset, /* appendWindowStart= */ 0,
/* appendWindowEnd= */ duration));
}
return references;
}
/**
* @param {!shaka.extern.Response} response
* @return {?number} The size of the whole file, from a Content-Range header.
* @private
*/
static getTotalSize_(response) {
const contentRange = response.headers['content-range'];
const match = contentRange && /\/(\d+)\s*$/.exec(contentRange);
return match ? parseInt(match[1], 10) : null;
}
/**
* Guesses the duration of a file that does not write it: the time of the
* last keyframe, and one more interval like the last one.
*
* @param {!Array<shaka.mkv.MatroskaIndexParser.CuePoint>} cues
* @param {number} trackNumber
* @return {?number}
* @private
*/
static estimateDuration_(cues, trackNumber) {
const times = cues.filter((cue) => cue.track == trackNumber)
.map((cue) => cue.time).sort((a, b) => a - b);
if (times.length < 2) {
return null;
}
const last = times[times.length - 1];
return last + (last - times[times.length - 2]);
}
/**
* Combines every video with every audio: they are all in the same file, so
* any pair can be played.
*
* @param {!Array<!shaka.extern.Stream>} videoStreams
* @param {!Array<!shaka.extern.Stream>} audioStreams
* @param {number} bandwidth
* @return {!Array<!shaka.extern.Variant>}
* @private
*/
static makeVariants_(videoStreams, audioStreams, bandwidth) {
/** @type {!Array<!shaka.extern.Variant>} */
const variants = [];
for (const video of videoStreams.length ? videoStreams : [null]) {
for (const audio of audioStreams.length ? audioStreams : [null]) {
if (!video && !audio) {
continue;
}
variants.push({
id: variants.length,
language: audio ? audio.language : 'und',
disabledUntilTime: 0,
primary: false,
audio,
video,
bandwidth,
allowedByApplication: true,
allowedByKeySystem: true,
decodingInfos: [],
});
}
}
// The player is to start with what the file marks as default.
const defaultVariant = variants.find((variant) => {
return (!variant.video || variant.video.primary) &&
(!variant.audio || variant.audio.primary);
}) || variants[0];
if (defaultVariant) {
defaultVariant.primary = true;
}
return variants;
}
/**
* @param {!Array<shaka.mkv.MatroskaIndexParser.Chapter>} chapters
* @param {number} duration
* @param {number} firstId
* @return {!Array<!shaka.extern.Stream>}
* @private
*/
static makeChapterStreams_(chapters, duration, firstId) {
/** @type {!Map<string, !Array<shaka.mkv.MatroskaIndexParser.Chapter>>} */
const byLanguage = new Map();
for (const chapter of chapters) {
if (chapter.start < duration) {
if (!byLanguage.has(chapter.language)) {
byLanguage.set(chapter.language, []);
}
byLanguage.get(chapter.language).push(chapter);
}
}
const result = [];
for (const language of byLanguage.keys()) {
const entries = byLanguage.get(language);
entries.sort((a, b) => a.start - b.start);
const references = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
// A chapter that has no end lasts until the next one starts.
const nextStart = i + 1 < entries.length ?
entries[i + 1].start : duration;
const end = Math.min(duration, entry.end ?? nextStart);
if (end <= entry.start) {
continue;
}
const reference = new shaka.media.SegmentReference(
entry.start, end, () => [], 0, null, null, 0, 0, Infinity);
reference.setMetadata({title: entry.title, images: []});
references.push(reference);
}
const stream = shaka.util.StreamUtils.createStream({
id: firstId++,
type: shaka.util.ManifestParserUtils.ContentType.CHAPTER,
mimeType: 'text/plain',
language: shaka.util.LanguageUtils.normalize(language),
originalLanguage: language,
external: true,
createSegmentIndex: () => {
stream.segmentIndex = new shaka.media.SegmentIndex(
references.slice());
return Promise.resolve();
},
});
result.push(stream);
}
return result;
}
};
/**
* @typedef {{
* track: !shaka.mkv.MatroskaIndexParser.Track,
* contentType: string,
* codecs: string,
* supplementalCodecs: string,
* streamId: number,
* isAlternate: boolean,
* }}
*
* @property {!shaka.mkv.MatroskaIndexParser.Track} track
* @property {string} contentType
* @property {string} codecs
* @property {string} supplementalCodecs
* The codecs of the Dolby Vision version of the track, if it has one.
* @property {number} streamId
* @property {boolean} isAlternate
* True for the second stream of a track: the one with the supplemental
* codecs.
*/
shaka.mkv.MatroskaParser.Candidate;
/**
* How much of the file is read first. The EBML header, the SeekHead, the Info
* and the Tracks add up to a few kilobytes, so this is enough for almost any
* file in one request.
*
* @private @const {number}
*/
shaka.mkv.MatroskaParser.INITIAL_HEADER_READ_SIZE_ = 64 * 1024;
/**
* The most that is read to find the first Cluster. Files that put large
* elements (cover art, big Void areas) before their media need more than the
* first read, but there is a limit to what can be a header.
*
* @private @const {number}
*/
shaka.mkv.MatroskaParser.MAX_HEADER_READ_SIZE_ = 4 * 1024 * 1024;
/**
* The most an element that is downloaded whole (Cues, Chapters) may be. Cues
* take about 20 bytes per keyframe, so this holds those of days of video; it
* keeps a corrupt size from asking for the whole file.
*
* @private @const {number}
*/
shaka.mkv.MatroskaParser.MAX_ELEMENT_SIZE_ = 16 * 1024 * 1024;
/**
* The size of an element header: an ID (at most 4 bytes) and a size (at most
* 8 bytes), rounded up.
*
* @private @const {number}
*/
shaka.mkv.MatroskaParser.ELEMENT_HEADER_READ_SIZE_ = 16;
/**
* How many bytes of the first Cluster are read to look for a VP9 key frame.
* Frames of the track come early in the Cluster, and a key frame of a VP9 track
* at 4K is a few hundred kilobytes.
*
* @private @const {number}
*/
shaka.mkv.MatroskaParser.VP9_PROBE_SIZE_ = 2 * 1024 * 1024;
/**
* The MIME types of Matroska: the ones IANA registered and the ones that are
* really sent.
*
* @private @const {!Array<string>}
*/
shaka.mkv.MatroskaParser.MIME_TYPES_ = [
'video/x-matroska',
'video/matroska',
'audio/x-matroska',
'audio/matroska',
];
for (const mimeType of shaka.mkv.MatroskaParser.MIME_TYPES_) {
shaka.media.ManifestParser.registerParserByMime(
mimeType, () => new shaka.mkv.MatroskaParser());
}
// Other builds keep the MIME types these extensions have always had.
shaka.net.NetworkingUtils.registerExtension('mkv', 'video/x-matroska');
shaka.net.NetworkingUtils.registerExtension('mk3d', 'video/x-matroska');
shaka.net.NetworkingUtils.registerExtension('mka', 'audio/x-matroska');