UNPKG

shaka-player

Version:
492 lines (433 loc) 17.6 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ goog.provide('shaka.cea.Mp4CeaParser'); goog.require('goog.asserts'); goog.require('shaka.cea.CeaUtils'); goog.require('shaka.cea.SeiProcessor'); goog.require('shaka.log'); goog.require('shaka.media.ClosedCaptionParser'); goog.require('shaka.util.DataViewReader'); goog.require('shaka.util.Error'); goog.require('shaka.util.Mp4Parser'); goog.require('shaka.util.Mp4BoxParsers'); /** * MPEG4 stream parser used for extracting 708 closed captions data. * @implements {shaka.extern.ICeaParser} * @export */ shaka.cea.Mp4CeaParser = class { constructor() { /** * SEI data processor. * @private * @const {!shaka.cea.SeiProcessor} */ this.seiProcessor_ = new shaka.cea.SeiProcessor(); /** * Map of track id to corresponding timescale. * @private {!Map<number, number>} */ this.trackIdToTimescale_ = new Map(); /** * Default sample duration, as specified by the TREX box. * @private {number} */ this.defaultSampleDuration_ = 0; /** * Default sample size, as specified by the TREX box. * @private {number} */ this.defaultSampleSize_ = 0; /** * @private {?number} */ this.c608TrackId_ = null; /** * @private {shaka.cea.Mp4CeaParser.BitstreamFormat} */ this.bitstreamFormat_ = shaka.cea.Mp4CeaParser.BitstreamFormat.UNKNOWN; } /** * Parses the init segment. Gets Default Sample Duration and Size from the * TREX box, and constructs a map of Track IDs to timescales. Each TRAK box * contains a track header (TKHD) containing track ID, and a media header box * (MDHD) containing the timescale for the track * @override */ init(initSegment) { const Mp4Parser = shaka.util.Mp4Parser; const BitstreamFormat = shaka.cea.Mp4CeaParser.BitstreamFormat; const trackIds = []; const timescales = []; let currentTrackId = null; const codecBoxParser = (box) => this.setBitstreamFormat_(box.name); new Mp4Parser() .boxes([...Mp4Parser.SAMPLE_TABLE_PATH, 'mvex'], Mp4Parser.children) .fullBox('trex', (box) => { const parsedTREXBox = shaka.util.Mp4BoxParsers.parseTREX( box.reader); this.defaultSampleDuration_ = parsedTREXBox.defaultSampleDuration; this.defaultSampleSize_ = parsedTREXBox.defaultSampleSize; }) .fullBox('tkhd', (box) => { goog.asserts.assert( box.version != null, 'TKHD is a full box and should have a valid version.'); const parsedTKHDBox = shaka.util.Mp4BoxParsers.parseTKHD( box.reader, box.version); currentTrackId = parsedTKHDBox.trackId; trackIds.push(parsedTKHDBox.trackId); }) .fullBox('mdhd', (box) => { goog.asserts.assert( box.version != null, 'MDHD is a full box and should have a valid version.'); const parsedMDHDBox = shaka.util.Mp4BoxParsers.parseMDHD( box.reader, box.version); timescales.push(parsedMDHDBox.timescale); }) .fullBox('stsd', Mp4Parser.sampleDescription) // These are the various boxes that signal a codec. .boxes(Mp4Parser.AVC_HEVC_VVC_DV, codecBoxParser) // This signals an encrypted sample, which we can go inside of to find // the codec used. .box('encv', Mp4Parser.visualSampleEntry) .box('sinf', Mp4Parser.children) .box('frma', (box) => { const {codec} = shaka.util.Mp4BoxParsers.parseFRMA(box.reader); this.setBitstreamFormat_(codec); }) // Raw CEA-608 (Apple format) .box('c608', (box) => { this.bitstreamFormat_ = shaka.cea.Mp4CeaParser.BitstreamFormat.C608_TRACK; this.c608TrackId_ = currentTrackId; }) .parse(initSegment, /* partialOkay= */ true, /* stopOnPartial= */ true); // At least one track should exist, and each track should have a // corresponding Id in TKHD box, and timescale in its MDHD box if (!trackIds.length|| !timescales.length || trackIds.length != timescales.length) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.TEXT, shaka.util.Error.Code.INVALID_MP4_CEA); } if (this.bitstreamFormat_ == BitstreamFormat.UNKNOWN) { shaka.log.alwaysWarn( 'Unable to determine bitstream format for CEA parsing!'); } // Populate the map from track Id to timescale trackIds.forEach((trackId, idx) => { this.trackIdToTimescale_.set(trackId, timescales[idx]); }); } /** * Parses each video segment. In fragmented MP4s, MOOF and MDAT come in * pairs. The following logic gets the necessary info from MOOFs to parse * MDATs (base media decode time, sample sizes/offsets/durations, etc), * and then parses the MDAT boxes for CEA-708 packets using this information. * CEA-708 packets are returned in the callback. * @override */ parse(mediaSegment) { const Mp4Parser = shaka.util.Mp4Parser; const BitstreamFormat = shaka.cea.Mp4CeaParser.BitstreamFormat; if (this.bitstreamFormat_ == BitstreamFormat.UNKNOWN) { // We don't know how to extract SEI from this. return []; } /** @type {!Array<!shaka.extern.ICeaParser.CaptionPacket>} **/ const captionPackets = []; let moofOffset = 0; /** @type {!Array<!shaka.cea.Mp4CeaParser.ParsedTRAF>} */ let parsedTRAFs = []; new Mp4Parser() .box('moof', (box) => { moofOffset = box.start; // traf box parsing is reset on each moof. parsedTRAFs = []; Mp4Parser.children(box); }) .box('traf', (box) => { parsedTRAFs.push({ trackId: null, baseMediaDecodeTime: null, defaultSampleDuration: this.defaultSampleDuration_, defaultSampleSize: this.defaultSampleSize_, parsedTRUNs: [], timescale: shaka.cea.CeaUtils.DEFAULT_TIMESCALE_VALUE, }); Mp4Parser.children(box); }) .fullBox('trun', (box) => { goog.asserts.assert( box.version != null && box.flags != null, 'TRUN is a full box and should have a valid version & flags.'); const lastTRAF = parsedTRAFs[parsedTRAFs.length - 1]; const parsedTRUN = shaka.util.Mp4BoxParsers.parseTRUN( box.reader, box.version, box.flags); lastTRAF.parsedTRUNs.push(parsedTRUN); }) .fullBox('tfhd', (box) => { goog.asserts.assert( box.flags != null, 'TFHD is a full box and should have valid flags.'); const lastTRAF = parsedTRAFs[parsedTRAFs.length - 1]; const parsedTFHD = shaka.util.Mp4BoxParsers.parseTFHD( box.reader, box.flags); lastTRAF.trackId = parsedTFHD.trackId; // If specified, defaultSampleDuration and defaultSampleSize // override the ones specified in the TREX box lastTRAF.defaultSampleDuration = parsedTFHD.defaultSampleDuration || this.defaultSampleDuration_; lastTRAF.defaultSampleSize = parsedTFHD.defaultSampleSize || this.defaultSampleSize_; const trackId = parsedTFHD.trackId; // Get the timescale from the track Id if (this.trackIdToTimescale_.has(trackId)) { lastTRAF.timescale = this.trackIdToTimescale_.get(trackId); } }) .fullBox('tfdt', (box) => { goog.asserts.assert( box.version != null, 'TFDT is a full box and should have a valid version.'); const lastTRAF = parsedTRAFs[parsedTRAFs.length - 1]; const parsedTFDT = shaka.util.Mp4BoxParsers.parseTFDTInaccurate( box.reader, box.version); lastTRAF.baseMediaDecodeTime = parsedTFDT.baseMediaDecodeTime; }) .box('mdat', (box) => { const offset = moofOffset - box.start - 8; const initialPosition = box.reader.getPosition(); for (const parsedTRAF of parsedTRAFs) { if (this.bitstreamFormat_ == BitstreamFormat.C608_TRACK && parsedTRAF.trackId !== this.c608TrackId_) { continue; } if (parsedTRAF.baseMediaDecodeTime === null) { // This field should have been populated by the Base Media Decode // Time in the tfdt box. shaka.log.alwaysWarn( 'Unable to find base media decode time for CEA captions!'); continue; } box.reader.seek(initialPosition); if (this.bitstreamFormat_ === BitstreamFormat.C608_TRACK) { this.parseC608Mdat_(box.reader, parsedTRAF.baseMediaDecodeTime, parsedTRAF.timescale, parsedTRAF.defaultSampleDuration, parsedTRAF.defaultSampleSize, offset, parsedTRAF.parsedTRUNs, captionPackets); } else { this.parseMdat_(box.reader, parsedTRAF.baseMediaDecodeTime, parsedTRAF.timescale, parsedTRAF.defaultSampleDuration, parsedTRAF.defaultSampleSize, offset, parsedTRAF.parsedTRUNs, captionPackets); } } }) .parse(mediaSegment, /* partialOkay= */ false, /* stopOnPartial= */ true); return captionPackets; } /** * Parse MDAT box. * @param {!shaka.util.DataViewReader} reader * @param {number} time * @param {number} timescale * @param {number} defaultSampleDuration * @param {number} defaultSampleSize * @param {number} offset * @param {!Array<shaka.util.ParsedTRUNBox>} parsedTRUNs * @param {!Array<!shaka.extern.ICeaParser.CaptionPacket>} captionPackets * @private */ parseMdat_(reader, time, timescale, defaultSampleDuration, defaultSampleSize, offset, parsedTRUNs, captionPackets) { const BitstreamFormat = shaka.cea.Mp4CeaParser.BitstreamFormat; const CeaUtils = shaka.cea.CeaUtils; goog.asserts.assert(this.bitstreamFormat_ != BitstreamFormat.UNKNOWN, 'Bitstream format should have been checked before now!'); // We can't assume the samples described across multiple trun boxes are // contiguous in the mdat. A track's runs may be interleaved with another // track's data in a shared mdat, so each run is read starting from its own // data offset, and we only walk the NAL units that belong to each sample // (bounded by the sample size). Reading past a run would pick up bytes from // the other track and misinterpret them as NAL units. let currentTime = time; for (const parsedTRUN of parsedTRUNs) { if (parsedTRUN.dataOffset != null) { reader.seek(offset + parsedTRUN.dataOffset); } for (const sample of parsedTRUN.sampleData) { // The fields in each ParsedTRUNSample are nullable. For sample size and // duration we use the defaults provided by the TREX/TFHD boxes, and for // the composition time offset we default to 0. const timeOffset = sample.sampleCompositionTimeOffset || 0; const pts = (currentTime + timeOffset) / timescale; let sampleSize = sample.sampleSize || defaultSampleSize; while (sampleSize > 0 && reader.hasMoreData()) { const naluSize = reader.readUint32(); const naluHeader = reader.readUint8(); let naluType = null; let isSeiMessage = false; let naluHeaderSize = 1; switch (this.bitstreamFormat_) { case BitstreamFormat.H264: naluType = naluHeader & 0x1f; isSeiMessage = CeaUtils.isSeiNaluType(CeaUtils.CodecFamily.H264, naluType); break; // H.265 and H.266 share the same NAL unit header parsing and only // differ in which codec family's SEI types to match against. case BitstreamFormat.H265: case BitstreamFormat.H266: { naluHeaderSize = 2; reader.skip(1); naluType = (naluHeader >> 1) & 0x3f; const family = this.bitstreamFormat_ === BitstreamFormat.H265 ? CeaUtils.CodecFamily.H265 : CeaUtils.CodecFamily.H266; isSeiMessage = CeaUtils.isSeiNaluType(family, naluType); break; } default: return; } if (isSeiMessage) { const nalu = reader.readBytes( naluSize - naluHeaderSize, // Don't clone. The nalu is temporary and is not stored. /* clone= */ false); for (const packet of this.seiProcessor_.process(nalu)) { captionPackets.push({ packet, pts, format: shaka.media.ClosedCaptionParser.CaptionPacketFormat.SEI, }); } } else { try { reader.skip(naluSize - naluHeaderSize); } catch (e) { // It is necessary to ignore this error because it can break the // start of playback even if the user does not want to see the // subtitles. return; } } sampleSize -= (naluSize + 4); } currentTime += sample.sampleDuration || defaultSampleDuration; } } } /** * Parse MDAT box for C608 format * @param {!shaka.util.DataViewReader} reader * @param {number} time * @param {number} timescale * @param {number} defaultSampleDuration * @param {number} defaultSampleSize * @param {number} offset * @param {!Array<shaka.util.ParsedTRUNBox>} parsedTRUNs * @param {!Array<!shaka.extern.ICeaParser.CaptionPacket>} captionPackets * @private */ parseC608Mdat_(reader, time, timescale, defaultSampleDuration, defaultSampleSize, offset, parsedTRUNs, captionPackets) { let currentTime = time; // We can't assume the samples described across multiple trun boxes are // contiguous in the mdat. A track's runs may be interleaved with another // track's data in a shared mdat (e.g. Apple HLS interleaves the CEA-608 // 'c608' track's runs between the video runs), so each run must be read // starting from its own data offset. Reading them contiguously would pick // up bytes belonging to the other track, which can pass the CEA-608 parity // check and inject garbage characters into the captions. for (const parsedTRUN of parsedTRUNs) { if (parsedTRUN.dataOffset != null) { reader.seek(offset + parsedTRUN.dataOffset); } for (const sample of parsedTRUN.sampleData) { const size = sample.sampleSize || defaultSampleSize; const pts = currentTime / timescale; const bytes = reader.readBytes(size, /* clone= */ false); captionPackets.push({ packet: bytes, pts, format: shaka.media.ClosedCaptionParser.CaptionPacketFormat.RAW608, }); currentTime += sample.sampleDuration || defaultSampleDuration; } } } /** * @param {string} codec A fourcc for a codec. * @private */ setBitstreamFormat_(codec) { if (shaka.cea.Mp4CeaParser.CodecBitstreamMap_.has(codec)) { this.bitstreamFormat_ = shaka.cea.Mp4CeaParser.CodecBitstreamMap_.get(codec); } } }; /** @enum {number} */ shaka.cea.Mp4CeaParser.BitstreamFormat = { UNKNOWN: 0, H264: 1, H265: 2, H266: 3, C608_TRACK: 4, }; /** @private {Map<string, shaka.cea.Mp4CeaParser.BitstreamFormat>} */ shaka.cea.Mp4CeaParser.CodecBitstreamMap_ = new Map() // AVC .set('avc1', shaka.cea.Mp4CeaParser.BitstreamFormat.H264) .set('avc3', shaka.cea.Mp4CeaParser.BitstreamFormat.H264) // Dolby Vision based in AVC .set('dvav', shaka.cea.Mp4CeaParser.BitstreamFormat.H264) .set('dva1', shaka.cea.Mp4CeaParser.BitstreamFormat.H264) // HEVC .set('hev1', shaka.cea.Mp4CeaParser.BitstreamFormat.H265) .set('hvc1', shaka.cea.Mp4CeaParser.BitstreamFormat.H265) // Dolby Vision based in HEVC .set('dvh1', shaka.cea.Mp4CeaParser.BitstreamFormat.H265) .set('dvhe', shaka.cea.Mp4CeaParser.BitstreamFormat.H265) // VVC .set('vvc1', shaka.cea.Mp4CeaParser.BitstreamFormat.H266) .set('vvi1', shaka.cea.Mp4CeaParser.BitstreamFormat.H266) // Dolby Vision based in VVC .set('dvc1', shaka.cea.Mp4CeaParser.BitstreamFormat.H266) .set('dvi1', shaka.cea.Mp4CeaParser.BitstreamFormat.H266); /** * @typedef {{ * trackId: ?number, * baseMediaDecodeTime: ?number, * defaultSampleDuration: number, * defaultSampleSize: number, * parsedTRUNs: !Array<shaka.util.ParsedTRUNBox>, * timescale: number, * }} * * @property {?number} trackId * @property {?number} baseMediaDecodeTime * @property {number} defaultSampleDuration * @property {number} defaultSampleSize * @property {!Array<shaka.util.ParsedTRUNBox>} parsedTRUNs * @property {?number} timescale */ shaka.cea.Mp4CeaParser.ParsedTRAF; shaka.media.ClosedCaptionParser.registerParser('video/mp4', () => new shaka.cea.Mp4CeaParser());