diff --git a/build/types/cea b/build/types/cea index 6d0d0eb3267..ff68fb69ff3 100644 --- a/build/types/cea +++ b/build/types/cea @@ -1,5 +1,7 @@ # Inband closed caption support. -+../../lib/cea/mp4_cea_parser.js ++../../lib/cea/atsc_decoder.js ++../../lib/cea/i_caption_decoder.js +../../lib/cea/i_cea_parser.js ++../../lib/cea/mp4_cea_parser.js +../../lib/cea/sei_processor.js diff --git a/lib/cea/atsc_decoder.js b/lib/cea/atsc_decoder.js new file mode 100644 index 00000000000..340ea0ab00f --- /dev/null +++ b/lib/cea/atsc_decoder.js @@ -0,0 +1,1327 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.cea.AtscDecoder'); + +/** + * CEA-X08 captions decoder. Currently only CEA-608 supported. + * @implements {shaka.cea.ICaptionDecoder} + */ +shaka.cea.AtscDecoder = class { + constructor() { + /** + * Contains parsed cues. + * @type {!Array.} + */ + this.cues_ = []; + + /** + * Number of bad frames decoded in a row. + * @private {!number} + */ + this.badFrames_ = 0; + + /** + * An array of closed captions data extracted for decoding. + * @private {!Array} + */ + this.ccDataArray_ = []; + + /** + * @private {!Array} + */ + this.cea608Modes_ = [ + new shaka.cea.AtscDecoder.Cea608DataStream(this, 0, 0), // F1 + C1 -> CC1 + new shaka.cea.AtscDecoder.Cea608DataStream(this, 0, 1), // F1 + C2 -> CC2 + new shaka.cea.AtscDecoder.Cea608DataStream(this, 1, 0), // F2 + C1 -> CC3 + new shaka.cea.AtscDecoder.Cea608DataStream(this, 1, 1), // F2 + C2 -> CC4 + ]; + + /** + * The current channel that is active on Line 21 (CEA-608) field 1. + * @private {!number} + */ + this.line21Field1Channel_ = 0; + + /** + * The current channel that is active on Line 21 (CEA-608) field 2. + * @private {!number} + */ + this.line21Field2Channel_ = 0; + + this.reset(); + } + + /** + * Adds a new parsed cue to the current parsed cues list + * @param {!shaka.cea.ICaptionDecoder.Cue} cue + */ + addCue(cue) { + this.cues_.push(cue); + } + + /** + * Clears the decoder. + * @override + */ + clear() { + this.badFrames_ = 0; + this.ccDataArray_ = []; + this.cues_ = []; + this.reset(); + } + + /** + * Resets the decoder. + */ + reset() { + this.line21Field1Channel_ = 0; + this.line21Field2Channel_ = 0; + for (const stream of this.cea608Modes_) { + stream.reset(); + } + } + + /** + * Extracts closed caption bytes from CEA-608 parsed from the stream based on + * ANSI/SCTE 128 and A/53, Part 4. + * @param {!Uint8Array} closedCaptionData + * This is a User Data registered by Rec.ITU-T T.35 SEI message. + * It is described in sections D.1.6 and D.2.6 of Rec. ITU-T H.264 (06/2019). + * @param {!number} time Time for the stream data. + * @override + */ + extract(closedCaptionData, time) { + const reader = new shaka.util.DataViewReader( + closedCaptionData, shaka.util.DataViewReader.Endianness.BIG_ENDIAN); + + // Verify the itu_t_t35_country_code is for the USA + if (reader.readUint8() != 0xB5) { // 0xB5 is USA's code (Rec. ITU-T T.35) + return; + } + + // Verify the itu_t_35_provider_code is for ATSC user_data + if (reader.readUint16() != 0x0031) { // When it is set to 49, it is for ATSC + return; + } + + // When the provider is 49, the ATSC_user_identifier code + // for ATSC1_data is "GA94" (0x47413934) + if (reader.readUint32() != 0x47413934) { + return; + } + + // user_data_type_code: 0x03 - cc_data() + if (reader.readUint8() != 0x03) { + return; + } + + // 1 bit reserved + // 1 bit process_cc_data_flag + // 1 bit zero_bit + // 5 bits cc_count + const captionData = reader.readUint8(); + // If process_cc_data_flag is not set, do not process this data. + if ((captionData & 0x40) == 0) { + return; + } + + const count = captionData & 0x1F; + + // 8 bits reserved + reader.skip(1); + + for (let i = 0; i < count; i++) { + const cc = reader.readUint8(); + // When ccValid is 0, the next two bytes should be discarded. + const ccValid = (cc & 0x04) >> 2; + const cc1 = reader.readUint8(); + const cc2 = reader.readUint8(); + + if (ccValid) { + const ccType = cc & 0x03; + const ccData = { + pts: time, + type: ccType, + cc1: cc1, + cc2: cc2, + order: this.ccDataArray_.length, + }; + this.ccDataArray_.push(ccData); + } + } + } + + /** + * Decodes extracted closed caption data. + * @override + */ + decode() { + // Clear previously buffered cues (which should already have been received) + this.cues_ = []; + + // In some versions of Chrome, and other browsers, the default sorting + // algorithm isn't stable. The following sort is stable. + this.ccDataArray_.sort( + /** + * Stable sorting function. + * @param {!shaka.cea.AtscDecoder.CcData} a CC data to compare. + * @param {!shaka.cea.AtscDecoder.CcData} b CC data to compare. + * @return {!number} + */ + (a, b) => { + const diff = a.pts - b.pts; + const isEqual = diff == 0; + return isEqual ? a.order - b.order : diff; + }); + + for (const ccPacket of this.ccDataArray_) { + // Only consider packets that are NTSC line 21 (CEA-608) for now, with + // type<2. Types 2 and 3 contain DVTCC data, for a future CEA-708 decoder. + if (ccPacket.type === shaka.cea.AtscDecoder.NTSC_CC_FIELD_1 || + ccPacket.type === shaka.cea.AtscDecoder.NTSC_CC_FIELD_2) { + this.decodeCea608_(ccPacket); + } + } + + this.ccDataArray_.length = 0; + return this.cues_; + } + + /** + * Decodes a CEA-608 closed caption packet based on ANSI/CEA-608. + * @param {shaka.cea.AtscDecoder.CcData} ccPacket + * @private + */ + decodeCea608_(ccPacket) { + const fieldNum = ccPacket.type; + let b1 = ccPacket.cc1; + let b2 = ccPacket.cc2; + + // If this packet is a control code, then it also sets the channel. + // For control codes, cc_data_1 has the form |P|0|0|1|C|X|X|X|. + // "C" is the channel bit. It indicates whether to set C2 active. + if (shaka.cea.AtscDecoder.Cea608DataStream.isControlCode(b1)) { + const channelNum = (b1 >> 3) & 0x01; // Get channel bit. + + // Change the stream based on the field, and the new channel + if (fieldNum == 0) { + this.line21Field1Channel_ = channelNum; + } else { + this.line21Field2Channel_ = channelNum; + } + } + + // Get the correct stream for this caption packet (CC1, ..., CC4) + const selectedChannel = fieldNum ? + this.line21Field2Channel_ : this.line21Field1Channel_; + const selectedStream = this.cea608Modes_[(fieldNum << 1) | selectedChannel]; + + // If on field 1, check if there are too many bad frames + if (fieldNum == 0) { + if ((b1 == 0xFF && b2 == 0xFF) || (!b1 && !b2)) { + // Completely invalid frame. FCC says after 45 of them, reset. + if (++this.badFrames_ >= 45) { + this.reset(); + } + } + } + + // Validate the parity + if (!this.isOddParity_(b1) || !this.isOddParity_(b2)) { + return; + } + + // Remove the MSB (parity bit). + ccPacket.cc1 = (b1 &= 0x7f); + ccPacket.cc2 = (b2 &= 0x7f); + + // Check for empty captions and skip them. + if (!b1 && !b2) { + return; + } + + // Process the clean CC data pair. + if (shaka.cea.AtscDecoder.Cea608DataStream.isControlCode(b1)) { + selectedStream.handleControlCode(ccPacket); + } else { + // Handle as a Basic North American Character. + selectedStream.handleText(b1, b2); + } + } + + /** + * Checks if the provided byte has odd parity, + * @param {!number} byte + * @return {!boolean} True if the byte has odd parity. + * @private + */ + isOddParity_(byte) { + let parity = 0; + while (byte) { + parity ^= 1; + byte &= (byte - 1); + } + return parity === 1; + } +}; + +/** + * CEA-608 captions memory/buffer. + */ +shaka.cea.AtscDecoder.Cea608Memory = class { + /** + * @param {!shaka.cea.AtscDecoder} decoder CEA-608 decoder. + * @param {!number} fieldNum Field number. + * @param {!number} chanNum Channel number. + */ + constructor(decoder, fieldNum, chanNum) { + /** + * Buffer for storing decoded characters. + * @private @const {!Array} + */ + this.rows_ = []; + + /** + * Current row. + * @type {number} + */ + this.row = 0; + + /** + * Number of rows in the scroll window. Used for rollup mode. + * @type {number} + */ + this.scrollRows = 0; + + /** + * Field number. + * @private {!number} + */ + this.fldnum_ = fieldNum; + + /** + * Channel number. + * @private {!number} + */ + this.chnum_ = chanNum; + + /** + * Text mode on/off. + * @private {!boolean} + */ + this.textmode_ = false; + + /** + * Maps row to currently open style tags (italics, underlines, colors). + * @private {!Map>} + */ + this.styleTags_ = new Map(); + + /** + * CEA-608 decoder. + * @private @const {!shaka.cea.AtscDecoder} + */ + this.decoder_ = decoder; + + this.resetAllRows(); + + // Text mode currently not emitted, so it is unused. + shaka.util.Functional.ignored(this.textmode_); + } + + /** + * Emits a cue based on the state of the buffer. + * @param {!number} startTime Start time of the cue. + * @param {!number} endTime End time of the cue. + */ + forceEmit(startTime, endTime) { + // If we emit text mode in future, prefix would be based on this.textmode. + const stream = `CC${(this.fldnum_<< 1) | this.chnum_ +1}`; + + const text = this.rows_.reduce((accumulatedText, rowText, i) => { + if (rowText) { + const escapeLine = i === this.rows_.length - 1; + accumulatedText += escapeLine ? `${rowText}\n` : rowText; + } + return accumulatedText; + }); + + if (text) { + this.decoder_.addCue({ + startTime, + endTime, + stream, + text, + }); + } + } + + /** + * Resets the memory buffer. + * @param {!number} fldnum field number. + * @param {!number} chnum channel number (0 or 1 within this field). + * @param {!boolean} textmode indicates whether text mode is on. + */ + reset(fldnum, chnum, textmode) { + this.resetAllRows(); + this.fldnum_ = fldnum; + this.chnum_ = chnum; + this.textmode_ = textmode; + this.row = 1; + } + + /** + * Opens the supplied style tag at the current position. + * @param {!string} tag + */ + openStyleTag(tag) { + if (!this.styleTags_.has(this.row)) { + this.styleTags_.set(this.row, []); + } + this.rows_[this.row] += `<${tag}>`; + this.styleTags_.get(this.row).push(tag); + } + + /** + * Closes and clears all currently active style tags. + */ + closeAndClearStyleTags() { + if (!this.styleTags_.has(this.row)) { + return; + } + const openStylesForRow = this.styleTags_.get(this.row); + + // Close tags in the reverse order of which they were opened. + for (let i = openStylesForRow.length -1; i >= 0; i--) { + this.rows_[this.row] += ``; + } + openStylesForRow.length = 0; + } + + /** + * Converts a CC character value into a UTF-32 value. + * @param {!number} set Character set. + * @param {!number} b CC byte to convert. + * @return {!number} UTF-32 value of the CC character. + * @private + */ + static ccchar2UTF32_(set, b) { + switch (set) { + case 0: // Normal CC set 0x20-0x7F + return shaka.cea.AtscDecoder.Cea608Memory.ccutftab0_[(b & 0x7F) - 0x20]; + + case 1: // Extended CC set 1 (MANDATORY) 0x30-0x3F + return shaka.cea.AtscDecoder.Cea608Memory.ccutftab1_[b & 0x0F]; + } + return 0; + } + + /** + * Adds a character to the buffer. + * @param {!number} set Character set. + * @param {!number} b CC byte to add. + */ + addChar(set, b) { + // Sets 2 or 3 are "Extended Char", which do a BS over preceding char. + if (set >= 2) { + this.eraseChar(); + } + this.rows_[this.row] += String.fromCharCode( + shaka.cea.AtscDecoder.Cea608Memory.ccchar2UTF32_(set, b)); + } + + /** + * Erases a character from the buffer. + */ + eraseChar() { + this.rows_[this.row].slice(0, -1); + } + + /** + * Moves rows of characters. + * @param {!number} dst Destination row index. + * @param {!number} src Source row index. + * @param {!number} count Count of rows to move. + */ + moveRows(dst, src, count) { + for (let i = 0; i < count; i++) { + this.rows_[dst + i] = this.rows_[src + i]; + + // Shift the styles of the rows up as well. + if (this.styleTags_.has(src + i)) { + this.styleTags_.set(dst + i, this.styleTags_.get(src + i)); + } + } + } + + /** + * Resets rows of characters. + * @param {!number} idx Starting index. + * @param {!number} count Count of rows to reset. + */ + resetRows(idx, count) { + for (let i = 0; i <= count; i++) { + this.rows_[idx + i] = ''; + this.styleTags_.delete(idx + i); + } + } + + /** + * Resets the entire memory buffer. + */ + resetAllRows() { + this.resetRows(0, shaka.cea.AtscDecoder.Cea608Memory.CC_ROWS); + } + + /** + * Erases entire memory buffer. + * Doesn't change scroll state or number of rows. + */ + eraseBuffer() { + this.row = (this.scrollRows > 0) ? this.scrollRows : 1; + this.resetAllRows(); + } +}; + +/** + * 608 closed captions channel. + */ +shaka.cea.AtscDecoder.Cea608DataStream = class { + /** + * @param {!shaka.cea.AtscDecoder} decoder CEA-608 decoder. + * @param {!number} fieldNum Field number. + * @param {!number} chanNum Channel number. + */ + constructor(decoder, fieldNum, chanNum) { + /** + * CEA-608 decoder. + * @private @const {!shaka.cea.AtscDecoder} + */ + this.decoder_ = decoder; + + /** + * 0 for chan 1 (CC1, CC3), 1 for chan 2 (CC2, CC4) + * @public {number} + */ + this.chnum = chanNum; + + /** + * Field number. + * @private {!number} + */ + this.fldnum_ = fieldNum; + + /** + * Current Caption Type. + * @public {!shaka.cea.AtscDecoder.CaptionType} + */ + this.type_ = shaka.cea.AtscDecoder.CaptionType.NONE; + + /** + * Text buffer. Although we have this, we don't currently emit text mode. + * @private @const {!shaka.cea.AtscDecoder.Cea608Memory} + */ + this.text_ = + new shaka.cea.AtscDecoder.Cea608Memory(decoder, fieldNum, chanNum); + + /** + * Displayed memory. + * @private {!shaka.cea.AtscDecoder.Cea608Memory} + */ + this.dpm_ = + new shaka.cea.AtscDecoder.Cea608Memory(decoder, fieldNum, chanNum); + + /** + * Non-displayed memory. + * @private {!shaka.cea.AtscDecoder.Cea608Memory} + */ + this.ndm_ = + new shaka.cea.AtscDecoder.Cea608Memory(decoder, fieldNum, chanNum); + + /** + * Points to current buffer. + * @public {!shaka.cea.AtscDecoder.Cea608Memory} + */ + this.curbuf = this.dpm_; + + /** + * End time of the previous caption, serves as start time of next caption. + * @private {!number} + */ + this.prevEndTime_ = 0; + + /** + * Last control pair, 16 bits representing byte 1 and byte 2 + * @private {?number} + */ + this.lastcp_ = null; + } + + /** + * Resets channel state. + */ + reset() { + this.type_ = shaka.cea.AtscDecoder.CaptionType.PAINTON; + this.curbuf = this.dpm_; + this.lastcp_ = null; + this.dpm_.reset(this.fldnum_, this.chnum, false); + this.ndm_.reset(this.fldnum_, this.chnum, false); + this.text_.reset(this.fldnum_, this.chnum, true); + } + + /** + * Converts Preamble Address Code to a Row Index. + * @param {!number} b1 Byte 1. + * @param {!number} b2 Byte 2. + * @return {!number} Row index. + * @private + */ + static ccpac2row_(b1, b2) { + const ccrowtab = [ + 11, 11, // 0x00 or 0x01 + 1, 2, // 0x02 -> 0x03 + 3, 4, // 0x04 -> 0x05 + 12, 13, // 0x06 -> 0x07 + 14, 15, // 0x08 -> 0x09 + 5, 6, // 0x0A -> 0x0B + 7, 8, // 0x0C -> 0x0D + 9, 10, // 0x0E -> 0x0F + ]; + return ccrowtab[((b1 & 0x07) << 1) | ((b2 >> 5) & 0x01)]; + } + + /** + * PAC - Preamble Address Code. + * b1 is of the form |P|0|0|1|C|0|ROW| + * b2 is of the form |P|1|N|ATTRIBUTE|U| + * @param {!number} b1 Byte 1. + * @param {!number} b2 Byte 2. + */ + controlPac(b1, b2) { + let row = shaka.cea.AtscDecoder.Cea608DataStream.ccpac2row_(b1, b2); + + // PACs change rows, and should reset styles of current row before doing so. + this.curbuf.closeAndClearStyleTags(); + + // Get attribute bits (4 bits) + const attr = (b2 & 0x1E) >> 1; + + // Set up the defaults. + let textColor = 'white'; + let italics = false; + + + // Attributes < 7 are colors, = 7 is white w/ italics, and >7 are indents + if (attr < 7) { + textColor = shaka.cea.AtscDecoder.TEXT_COLORS[attr]; + } else if (attr == 7) { + italics = true; // color stays white + } + + // PACs toggle underline on the last bit of b2. + const underline = (b2 & 0x01) == 0x01; + + // Execute the PAC. + const buf = this.curbuf; + if (this.type_ === shaka.cea.AtscDecoder.CaptionType.TEXT) { + row = buf.row; // Don't change row if in text mode. + } else if (this.type_ === shaka.cea.AtscDecoder.CaptionType.ROLLUP) { + if (row != buf.row) { // Move the entire scroll window to a new base. + const oldtoprow = 1 + buf.row - buf.scrollRows; + const newtoprow = 1 + row - buf.scrollRows; + + // Shift up the scroll window. + buf.moveRows(newtoprow, oldtoprow, buf.scrollRows); + + // Clear everything outside of the new scroll window. + const numRowsToClear = Math.abs(newtoprow - oldtoprow) - 1; + const clearStartIdx = newtoprow < oldtoprow ? + newtoprow : oldtoprow; + buf.resetRows(clearStartIdx, numRowsToClear); + } + } + + buf.row = row; + + // Apply all styles to rows. + if (underline) { + this.curbuf.openStyleTag('u'); + } + if (italics) { + this.curbuf.openStyleTag('i'); + } + shaka.util.Functional.ignored(textColor); + } + + /** + * Mid-Row Attribute setting. + * @param {!number} b1 Byte #1 + * @param {!number} b2 Byte #2. + */ + controlMidrow(b1, b2) { + // This style will override any previous styles applied to this row. + this.curbuf.closeAndClearStyleTags(); + + // Mid-row attrs use a space. + this.curbuf.addChar(0, ' '.charCodeAt(0)); + + let backgroundColor = 'black'; + let textColor = 'white'; + let italics = false; + let underline = false; + + // Check what type of midrow style change this is + switch (b1 & 0x07) { + case 0x0: // 0|0|0 is bg color + // b2 has the form |P|0|1|0|COLOR|T| + backgroundColor = shaka.cea.AtscDecoder.BG_COLORS[(b2 & 0xe) >> 1]; + break; + case 0x1: // 0|0|1 is midrow style + // b2 has the form |P|0|1|0|STYLE|U| + textColor = shaka.cea.AtscDecoder.TEXT_COLORS[(b2 & 0xe) >> 1]; + if (textColor === 'white_italics') { + textColor = 'white'; + italics = true; + } + underline = (b2 & 0x01) == 0x01; + break; + case 0x7: + // 1|1|1 can be "no bg" or "black text" Check b2 to determine which one. + if ((b2 & 0xe) == 0x0e) { // black text + textColor = 'black'; + } else if ((b2 & 0xe) == 0x0c) { // no bg + // Todo handle this + shaka.Functional.ignored(); + } + break; + } + + if (underline) { + this.curbuf.openStyleTag('u'); + } + if (italics) { + this.curbuf.openStyleTag('i'); + } + + shaka.util.Functional.ignored(backgroundColor); + } + + /** + * The Cea608DataStream control methods implement all CC control operations. + * @param {!shaka.cea.AtscDecoder.CcData} ccPacket + */ + controlMiscellaneous(ccPacket) { + const b2 = ccPacket.cc2; + const pts = ccPacket.pts; + switch (b2) { + case shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_ + .RCL: // Resume Caption Loading + this.controlRcl_(); + break; + + case shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_.BS: // BackSpace + this.controlBs_(); + break; + + // unused (alarm off and alarm on) + case shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_.AOD: + case shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_.AON: + break; + + case shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_.DER: + // Delete to End of Row. Not implemented since position not supported. + break; + + case shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_.RU2: + // Roll-Up, 2 rows + this.controlRu_(2, pts); + break; + case shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_.RU3: + // Roll-Up, 3 rows + this.controlRu_(3, pts); + break; + case shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_.RU4: + // Roll-Up, 4 rows + this.controlRu_(4, pts); + break; + + case shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_.FON: + // Flash On + this.controlFon_(); + break; + + case shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_.RDC: + // Resume Direct Captions + this.controlRdc_(pts); + break; + + case shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_.TR: + // Text Restart + this.controlTr_(); + break; + + case shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_.RTD: + // Resume Text Display + this.controlRtd_(); + break; + + case shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_.EDM: + // Erase Displayed Mem + this.controlEdm_(pts); + break; + + case shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_.CR: + // Start new row + this.controlCr_(pts); + break; + + case shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_.ENM: + // Erase Nondisplayed Mem + this.controlEnm_(); + break; + + case shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_.EOC: + // End Of Caption (flip) + this.controlEoc_(pts); + break; + } + } + + /** + * Handles CR - Carriage Return (Start new row). + * CR only affects scroll windows (Rollup and Text modes). + * Any currently buffered line needs to be emitted, along + * with a window scroll action. + * @param {!number} pts in seconds. + * @private + */ + controlCr_(pts) { + const buf = this.curbuf; + + // Only rollup and text mode is affected, but we don't emit text mode. + if (this.type_ !== shaka.cea.AtscDecoder.CaptionType.ROLLUP) { + return; + } + + // Force out anything currently on top row (which will be cleared). + buf.forceEmit(this.prevEndTime_, pts); + + // Calculate the top of the scroll window. + const toprow = (buf.row - buf.scrollRows) + 1; + + // Clear out anything that's outside of our current scroll window. + buf.resetRows(0, toprow - 1); + buf.resetRows(buf.row + 1, shaka.cea.AtscDecoder.Cea608Memory.CC_ROWS); + + // Shift up the rows. This overwrites the top row and erases bottom row. + buf.moveRows(toprow, toprow + 1, buf.scrollRows - 1); + buf.resetRows(buf.row, 1); + + // Update the end time so the next caption emits starting at this time. + this.prevEndTime_ = pts; + } + + /** + * Handles RU2, RU3, RU4 - Roll-Up, N rows. + * If in TEXT, POPON or PAINTON, any displayed captions are erased. + * This means must force emit of entire display buffer. + * @param {!number} scrollSize New scroll window size. + * @param {!number} pts + * @private + */ + controlRu_(scrollSize, pts) { + this.curbuf = this.dpm_; // Point to displayed memory + const buf = this.curbuf; + + // For any type except rollup, it should be emitted, and memories cleared. + switch (this.type_) { + case shaka.cea.AtscDecoder.CaptionType.TEXT: + if (buf.scrollRows > 0) { + // Was in Text mode, but display mem is already scroll window, + // so don't need to force or clear anything. + break; + } + // Else drop through to clear displayed mem + // eslint-disable-next-line no-fallthrough + case shaka.cea.AtscDecoder.CaptionType.POPON: + case shaka.cea.AtscDecoder.CaptionType.PAINTON: + // Force out any buffered disp mem + buf.forceEmit(this.prevEndTime_, pts); + + // Clear BOTH memories! + this.dpm_.eraseBuffer(); + this.ndm_.eraseBuffer(); + + // Rollup base row defaults to the last one (15). + buf.row = shaka.cea.AtscDecoder.Cea608Memory.CC_ROWS; + + break; + } + + this.type_ = shaka.cea.AtscDecoder.CaptionType.ROLLUP; + + // Set the new rollup window size., and go to column 1. + buf.scrollRows = scrollSize; + } + + /** + * Handles flash on. + * @private + */ + controlFon_() { + this.curbuf.addChar(0, ' '.charCodeAt(0)); + } + + + /** + * Handles EDM - Erase Displayed Mem + * Mode check: + * EDM affects all captioning modes (but not Text mode); + * @param {!number} pts + * @private + */ + controlEdm_(pts) { + const buf = this.dpm_; + if (this.type_ !== shaka.cea.AtscDecoder.CaptionType.TEXT) { + // Clearing displayed memory means we now know how long + // its contents were displayed, so force it out. + buf.forceEmit(this.prevEndTime_, pts); + } + buf.resetAllRows(); + } + + /** + * Handles RDC - Resume Direct Captions. Initiates Paint-On captioning mode. + * RDC does not affect current display, so nothing needs to be forced out yet. + * @param {!number} pts in seconds + * @private + */ + controlRdc_(pts) { + this.type_ = shaka.cea.AtscDecoder.CaptionType.PAINTON; + // Point to displayed memory + this.curbuf = this.dpm_; + + // No scroll window now + this.curbuf.scrollRows = 0; + + // The next paint-on caption needs this time as the start time. + this.prevEndTime_ = pts; + } + + + /** + * Handles ENM - Erase Nondisplayed Mem + * @private + */ + controlEnm_() { this.ndm_.resetAllRows(); } + + /** + * Handles EOC - End Of Caption (flip mem) + * This forces Pop-On mode, and swaps the displayed and nondisplayed memories. + * @private + * @param {!number} pts + */ + controlEoc_(pts) { + this.dpm_.forceEmit(this.prevEndTime_, pts); + // Swap memories + const buf = this.ndm_; + this.ndm_ = this.dpm_; // Swap buffers + this.dpm_ = buf; + + // Enter Pop-On mode. + this.controlRcl_(); + + // The caption ended, and so the previous end time should be updated. + this.prevEndTime_ = pts; + } + + /** + * Handles RCL - Resume Caption Loading + * Initiates Pop-On style captioning. No need to force anything out upon + * entering Pop-On mode because it does not affect the current display. + * @private + */ + controlRcl_() { + this.type_ = shaka.cea.AtscDecoder.CaptionType.POPON; + this.curbuf = this.ndm_; + // No scroll window now + this.curbuf.scrollRows = 0; + } + + + /** + * Handles BS - BackSpace. + * @private + */ + controlBs_() { + const buf = this.curbuf; + buf.eraseChar(); + } + + /** + * Handles TR - Text Restart. + * Clears text buffer and resumes Text Mode. + * @private + */ + controlTr_() { + const buf = this.text_; + + // Default scroll for text mode is the entire screen i.e. all of the rows. + buf.scrollRows = shaka.cea.AtscDecoder.Cea608Memory.CC_ROWS; + buf.eraseBuffer(); + this.controlRtd_(); // Put into text mode + } + + /** + * Handles RTD - Resume Text Display. + * Resumes text mode. Mo need to force anything out, because Text Mode doesn't + * affect the current display. Also, this decoder does not emit TEXTn anyway. + * @private + */ + controlRtd_() { + this.curbuf = this.text_; + } + + /** + * Decodes a pair of text bytes. + * @param {!number} b1 Byte 1. + * @param {!number} b2 Byte 2. + */ + handleText(b1, b2) { + if (b1 & 0x60) { + this.curbuf.addChar(0, b1); + } + if (b2 & 0x60) { + this.curbuf.addChar(0, b2); + } + } + + /** + * Decodes control code. + * Three types of control codes: + * Preamble Address Codes, Mid-Row Codes, and Miscellaneous Control Codes. + * @param {!shaka.cea.AtscDecoder.CcData} ccPacket + */ + handleControlCode(ccPacket) { + const b1 = ccPacket.cc1; + const b2 = ccPacket.cc2; + + // FCC wants control codes transmitted twice, and that will often be + // seen in broadcast captures. If the very next frame has a duplicate + // control code, that duplicate is ignored. Note that this only applies + // to the very next frame, and only for one match. + if (this.lastcp_ == ((b1 << 8) | b2)) { + this.lastcp_ = null; + return; + } + + // Remember valid control code for checking in next frame! + this.lastcp_ = (b1 << 8) | b2; + + if (this.isPAC(b1, b2)) { + this.controlPac(b1, b2); + } else if (this.isMidrow(b1, b2)) { + this.controlMidrow(b1, b2); + } else if (this.isSpecialNorthAmericanChar(b1, b2)) { + if (b2 == shaka.cea.AtscDecoder.Cea608DataStream.CC_CHAR1_TRANSP) { + // Forcefully add a non-breaking space. + this.curbuf.addChar(0, ' '.charCodeAt(0)); + } else { + this.curbuf.addChar(1, b2 & 0xF); + } + } else if (this.isExtendedWesternEuropeanChar(b1, b2)) { + // Get the char set from the LSB, which is the char set toggle bit. + const charSet = b2 & 0x01; + if (charSet == 0) { + this.curbuf.addChar(2, b2 & 0x1F); + } else { + this.curbuf.addChar(3, b2 & 0x1F); + } + } else if (this.isMiscellaneous(b1, b2)) { + this.controlMiscellaneous(ccPacket); + } + } + + /** + * Checks if this is a Miscellaneous control code. + * @param {!number} b1 Byte 1. + * @param {!number} b2 Byte 2. + * @return {!boolean} + */ + isMiscellaneous(b1, b2) { + // For Miscellaneous Control Codes, the bytes take the following form: + // b1 -> |0|0|0|1|C|1|0|F| + // b2 -> |0|0|1|0|X|X|X|X| + return ((b1 & 0xF6) == 0x14) && ((b2 & 0xF0) == 0x20); + } + + /** + * Checks if this is a PAC control code. + * @param {!number} b1 Byte 1. + * @param {!number} b2 Byte 2. + * @return {!boolean} + */ + isPAC(b1, b2) { + // For Preamble Address Codes, the bytes take the following form: + // b1 -> |0|0|0|1|X|X|X|X| + // b2 -> |0|1|X|X|X|X|X|X| + return ((b1 & 0xF0) == 0x10) && ((b2 & 0xC0) == 0x40); + } + + /** + * Checks if this is a Midrow control code. + * @param {!number} b1 Byte 1. + * @param {!number} b2 Byte 2. + * @return {!boolean} + */ + isMidrow(b1, b2) { + // For Midrow Control Codes, the bytes take the following form: + // b1 -> |0|0|0|1|C|0|0|1| + // b2 -> |0|0|1|0|X|X|X|X| + return ((b1 & 0xF7) == 0x11) && ((b2 & 0xF0) == 0x20); + } + + /** + * Checks if the character is in the Special North American char. set. + * @param {!number} b1 Byte 1. + * @param {!number} b2 Byte 2. + * @return {!boolean} + */ + isSpecialNorthAmericanChar(b1, b2) { + // The bytes take the following form: + // b1 -> |0|0|0|1|C|0|0|1| + // b2 -> |0|0|1|1| CHAR | + return ((b1 & 0xF7) == 0x11) && ((b2 & 0xF0) == 0x30); + } + + /** + * Checks if the character is in the Extended Western European char. set. + * @param {!number} b1 Byte 1. + * @param {!number} b2 Byte 2. + * @return {!boolean} + */ + isExtendedWesternEuropeanChar(b1, b2) { + // The bytes take the following form: + // b1 -> |0|0|0|1|C|0|1|S| + // b2 -> |0|0|1|CHARACTER| + return ((b1 & 0xF6) == 0x12) && ((b2 & 0xE0) == 0x20); + } + + /** + * Checks if the data contains a control code. + * @param {!number} b1 Byte 1. + * @return {!boolean} + */ + static isControlCode(b1) { + // For control codes, the first byte takes the following form: + // b1 -> |P|0|0|1|X|X|X|X| + return (b1 & 0x70) == 0x10; + } +}; + +/** + * Command codes. + * @enum {!number} + * @private + */ +shaka.cea.AtscDecoder.Cea608DataStream.B2Cmd_ = { + // "RCL - Resume Caption Loading" + RCL: 0x20, + + // "BS - BackSpace" + BS: 0x21, + + // "AOD - unused (alarm off)" + AOD: 0x22, + + // "AON - unused (alarm on)" + AON: 0x23, + + // "DER - Delete to End of Row" + DER: 0x24, + + // "RU2 - Roll-Up, 2 rows" + RU2: 0x25, + + // "RU3 - Roll-Up, 3 rows" + RU3: 0x26, + + // "RU4 - Roll-Up, 4 rows" + RU4: 0x27, + + // "FON - Flash On" + FON: 0x28, + + // "RDC - Resume Direct Captions" + RDC: 0x29, + + // "TR - Text Restart" + TR: 0x2A, + + // "RTD - Resume Text Display" + RTD: 0x2B, + + // "EDM - Erase Displayed Mem" + EDM: 0x2C, + + // "CR - start new row" + CR: 0x2D, + + // "ENM - Erase Nondisplayed Mem" + ENM: 0x2E, + + // "EOC - End Of Caption (flip mem)" + EOC: 0x2F, +}; + +/** + * Caption type. + * @private @const @enum {!number} + */ +shaka.cea.AtscDecoder.CaptionType = { + NONE: 0, + POPON: 1, + PAINTON: 2, // From here on are all "painty" (painton) + ROLLUP: 3, // From here on are all "rolly" (rollup) + TEXT: 4, +}; + +/** + * 608 closed captions data - time, byte 1, byte 2, and the order in which the + * data was received. + * @typedef {{ + * pts: number, + * type: number, + * cc1: number, + * cc2: number, + * order: number + * }} + */ +shaka.cea.AtscDecoder.CcData; + +shaka.cea.AtscDecoder.NTSC_CC_FIELD_1 = 0; + +shaka.cea.AtscDecoder.NTSC_CC_FIELD_2 = 1; + +/** + * @const {!Array} + */ +shaka.cea.AtscDecoder.BG_COLORS = [ + 'white', + 'green', + 'blue', + 'cyan', + 'red', + 'yellow', + 'magenta', + 'black', +]; + +/** + * @const {!Array} + */ +shaka.cea.AtscDecoder.TEXT_COLORS = [ + 'white', + 'green', + 'blue', + 'cyan', + 'red', + 'yellow', + 'magenta', + 'white_italics', +]; + +/** + * Style associated with a cue. + * @typedef {{ + * textColor: ?string, + * backgroundColor: ?string, + * italics: ?boolean, + * underline: ?boolean + * }} + */ +shaka.cea.AtscDecoder.Style; + + +shaka.cea.AtscDecoder.Cea608Memory.BLANK_CELL = 0; + +/** + * Maximum number of rows in the buffer. + * @private @const {!number} + */ +shaka.cea.AtscDecoder.Cea608Memory.CC_ROWS = 15; + +/** + * Transparent space + * No unicode equivalent for "transparent space", so code has to test for this. + * @private @const {!number} + */ +shaka.cea.AtscDecoder.Cea608DataStream.CC_CHAR1_TRANSP = 0x39; + +/** + * Basic one-byte 608 CC char set, mostly ASCII. + * Indexed by (char-0x20). + * @private @const {!Array} + */ +shaka.cea.AtscDecoder.Cea608Memory.ccutftab0_ = [ + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, + 0x0027, // ! " # $ % & ' + 0x0028, 0x0029, // ( ) + 0x00E1, // 2A: 225 'á' "Latin small letter A with acute" + 0x002b, 0x002c, 0x002d, 0x002e, 0x002f, // + , - . / + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, + 0x0037, // 0 1 2 3 4 5 6 7 + 0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, + 0x003f, // 8 9 : ; < = > ? + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, + 0x0047, // @ A B C D E F G + 0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, + 0x004f, // H I J K L M N O + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, + 0x0057, // P Q R S T U V W + 0x0058, 0x0059, 0x005a, 0x005b, // X Y Z [ + 0x00E9, // 5C: 233 'é' "Latin small letter E with acute" + 0x005d, // ] + 0x00ED, // 5E: 237 'í' "Latin small letter I with acute" + 0x00F3, // 5F: 243 'ó' "Latin small letter O with acute" + 0x00FA, // 60: 250 'ú' "Latin small letter U with acute" + 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, // a b c d e f g + 0x0068, 0x0069, 0x006a, 0x006b, 0x006c, 0x006d, 0x006e, + 0x006f, // h i j k l m n o + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, + 0x0077, // p q r s t u v w + 0x0078, 0x0079, 0x007a, // x y z + 0x00E7, // 7B: 231 'ç' "Latin small letter C with cedilla" + 0x00F7, // 7C: 247 '÷' "Division sign" + 0x00D1, // 7D: 209 'Ñ' "Latin capital letter N with tilde" + 0x00F1, // 7E: 241 'ñ' "Latin small letter N with tilde" + 0x25A0, // 7F: "Black Square" (NB: 2588 = Full Block) +]; + +/** + * Mandatory extended char set, command prefix 0x11. + * Extended CC set 1 (MANDATORY) 0x30-0x3F -- indexed by (char & 0x0F). + * @private @const {!Array} + */ +shaka.cea.AtscDecoder.Cea608Memory.ccutftab1_ = [ + 0xAE, // 30: 174 '®' "Registered Sign" - registered trademark symbol + 0xB0, // 31: 176 '°' "Degree Sign" + 0xBD, // 32: 189 '½' "Vulgar Fraction One Half" (1/2 symbol) + 0xBF, // 33: 191 '¿' "Inverted Question Mark" + 0x2122, // 34: "Trade Mark Sign" (tm superscript) + 0xA2, // 35: 162 '¢' "Cent Sign" + 0xA3, // 36: 163 '£' "Pound Sign" - pounds sterling + 0x266A, // 37: "Eighth Note" - music note + 0xE0, // 38: 224 'à' "Latin small letter A with grave" + 0x20, // 39: TRANSPARENT SPACE - for now use ordinary space + 0xE8, // 3A: 232 'è' "Latin small letter E with grave" + 0xE2, // 3B: 226 'â' "Latin small letter A with circumflex" + 0xEA, // 3C: 234 'ê' "Latin small letter E with circumflex" + 0xEE, // 3D: 238 'î' "Latin small letter I with circumflex" + 0xF4, // 3E: 244 'ô' "Latin small letter O with circumflex" + 0xFB, // 3F: 251 'û' "Latin small letter U with circumflex" +]; diff --git a/lib/cea/i_caption_decoder.js b/lib/cea/i_caption_decoder.js new file mode 100644 index 00000000000..5ef9cf1b799 --- /dev/null +++ b/lib/cea/i_caption_decoder.js @@ -0,0 +1,41 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.cea.ICaptionDecoder'); + +/** + * Interface for decoding inband closed captions from packets. + * @interface + */ +shaka.cea.ICaptionDecoder = class { + /** + * @param {!Uint8Array} data Caption stream data. + * @param {!number} time Time for the stream data. + */ + extract(data, time) {} + + /** + * Decodes extracted packets + * @return {!Array.} + */ + decode() {} + + /** + * Clears the decoder completely. + */ + clear() {} +}; + +/** + * Parsed Cue. + * @typedef {{ + * startTime: number, + * endTime: number, + * stream: string, + * text: string + * }} + */ +shaka.cea.ICaptionDecoder.Cue; diff --git a/lib/cea/i_cea_parser.js b/lib/cea/i_cea_parser.js index 4a29affe4ac..f388441a998 100644 --- a/lib/cea/i_cea_parser.js +++ b/lib/cea/i_cea_parser.js @@ -48,7 +48,7 @@ shaka.cea.ICeaParser.DEFAULT_TIMESCALE_VALUE = 90000; * registered by Recommendation ITU-T T.35 SEI message", from section D.1.6 * and section D.2.6 of Rec. ITU-T H.264 (06/2019). * @property {!number} pts - * The presentation timestamp (pts) at which the ITU-T T.35 data shows up, + * The presentation timestamp (pts) at which the ITU-T T.35 data shows up. * in seconds. * @exportDoc */ diff --git a/lib/media/closed_caption_parser.js b/lib/media/closed_caption_parser.js index 1ad5496af2d..346af1b04a5 100644 --- a/lib/media/closed_caption_parser.js +++ b/lib/media/closed_caption_parser.js @@ -20,20 +20,20 @@ goog.require('shaka.util.BufferUtils'); shaka.media.IClosedCaptionParser = class { /** * Initialize the caption parser. This should be called only once. - * @param {BufferSource} data + * @param {BufferSource} initSegment */ - init(data) {} + init(initSegment) {} /** * Parses embedded CEA closed captions and interacts with the underlying * CaptionStream, and calls the callback function when there are closed * captions. * - * @param {BufferSource} data + * @param {BufferSource} mediaFragment * @param {function(Array.)} onCaptions - * A callback function to handle the closed captions from parsed data. + * A callback function to handle the closed captions from parsed data. */ - parseFrom(data, onCaptions) {} + parseFrom(mediaFragment, onCaptions) {} /** * Resets the CaptionStream. @@ -67,10 +67,10 @@ shaka.media.MuxJSClosedCaptionParser = class { /** * @override */ - init(data) { + init(initSegment) { const probe = muxjs.mp4.probe; // Caption parser for Dash - const initBytes = shaka.util.BufferUtils.toUint8(data); + const initBytes = shaka.util.BufferUtils.toUint8(initSegment); this.videoTrackIds_ = probe.videoTrackIds(initBytes); this.timescales_ = probe.timescale(initBytes); this.muxCaptionParser_.init(); @@ -79,11 +79,12 @@ shaka.media.MuxJSClosedCaptionParser = class { /** * @override */ - parseFrom(data, onCaptions) { - const segmentBytes = shaka.util.BufferUtils.toUint8(data); + parseFrom(mediaFragment, onCaptions) { + const segmentBytes = shaka.util.BufferUtils.toUint8(mediaFragment); const dashParsed = this.muxCaptionParser_.parse( segmentBytes, this.videoTrackIds_, this.timescales_); if (dashParsed && dashParsed.captions) { + shaka.log.info(dashParsed.captions); onCaptions(dashParsed.captions); } // ParsedCaptions is used by mux.js to store the captions parsed so far. @@ -120,12 +121,12 @@ shaka.media.NoopCaptionParser = class { /** * @override */ - init(data) {} + init(initSegment) {} /** * @override */ - parseFrom(data, onCaptions) {} + parseFrom(mediaFragment, onCaptions) {} /** * @override @@ -143,32 +144,48 @@ shaka.media.NoopCaptionParser = class { shaka.media.ClosedCaptionParser = class { constructor() { /** - * MP4 Parser for MDAT, TREX, TRUN, and MDHD boxes + * MP4 Parser to extract closed caption packets from H.264 video. * @private {!shaka.cea.ICeaParser} */ this.ceaParser_ = new shaka.cea.Mp4CeaParser(); + + /** + * Decoder for decoding CEA-X08 data from closed caption packets. + * @private {!shaka.cea.ICaptionDecoder} + */ + this.ceaDecoder_ = new shaka.cea.AtscDecoder(); } /** * @override */ - init(data) { - this.ceaParser_.init(data); + init(initSegment) { + this.ceaParser_.init(initSegment); } /** * @override */ - parseFrom(data, onCaptions) { - const captionPackets = this.ceaParser_.parse(data); - shaka.util.Functional.ignored(captionPackets); - // Todo: This is where the parsed data will be passed - // to the decoder to decode + parseFrom(mediaFragment, onCaptions) { + // Parse the fragment. + const captionPackets = this.ceaParser_.parse(mediaFragment); + + // Extract the caption packets for decoding. + for (const captionPacket of captionPackets) { + const uint8ArrayData = + shaka.util.BufferUtils.toUint8(captionPacket.packet); + this.ceaDecoder_.extract(uint8ArrayData, captionPacket.pts); + } + + // Decode the captions. + const cues = this.ceaDecoder_.decode(); + onCaptions(cues); } /** * @override */ reset() { + this.ceaDecoder_.clear(); } }; diff --git a/lib/player.js b/lib/player.js index 43c1848b536..0f6b2f709fc 100644 --- a/lib/player.js +++ b/lib/player.js @@ -14,8 +14,7 @@ goog.require('shaka.media.BufferingObserver'); goog.require('shaka.media.DrmEngine'); goog.require('shaka.media.ManifestParser'); goog.require('shaka.media.MediaSourceEngine'); -goog.require('shaka.media.MuxJSClosedCaptionParser'); -goog.require('shaka.media.NoopCaptionParser'); +goog.require('shaka.media.ClosedCaptionParser'); goog.require('shaka.media.PlayRateController'); goog.require('shaka.media.Playhead'); goog.require('shaka.media.PlayheadObserverManager'); @@ -1428,10 +1427,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { this.mediaSourceEngine_ == null, 'We should not have a media source engine yet.'); - const closedCaptionsParser = - shaka.media.MuxJSClosedCaptionParser.isSupported() ? - new shaka.media.MuxJSClosedCaptionParser() : - new shaka.media.NoopCaptionParser(); + shaka.log.info('creating caption parser'); + const closedCaptionsParser = new shaka.media.ClosedCaptionParser(); // When changing text visibility we need to update both the text displayer // and streaming engine because we don't always stream text. To ensure that diff --git a/shaka-player.uncompiled.js b/shaka-player.uncompiled.js index ce09e88e4b7..6e3b18e5d1a 100644 --- a/shaka-player.uncompiled.js +++ b/shaka-player.uncompiled.js @@ -15,6 +15,7 @@ goog.require('shaka.abr.SimpleAbrManager'); goog.require('shaka.ads.AdManager'); goog.require('shaka.cast.CastProxy'); goog.require('shaka.cast.CastReceiver'); +goog.require('shaka.cea.AtscDecoder'); goog.require('shaka.cea.Mp4CeaParser'); goog.require('shaka.dash.DashParser'); goog.require('shaka.hls.HlsParser'); diff --git a/test/cea/atsc_decoder_unit.js b/test/cea/atsc_decoder_unit.js new file mode 100644 index 00000000000..389d03e3a0a --- /dev/null +++ b/test/cea/atsc_decoder_unit.js @@ -0,0 +1,115 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +describe('AtscDecoder', () => { + const ceaInitSegmentUri = '/base/test/test/assets/cea-init.mp4'; + const ceaSegmentUri = '/base/test/test/assets/cea-segment.mp4'; + + /** @type {!ArrayBuffer} */ + let ceaInitSegment; + /** @type {!ArrayBuffer} */ + let ceaSegment; + + beforeAll(async () => { + const responses = await Promise.all([ + shaka.test.Util.fetch(ceaInitSegmentUri), + shaka.test.Util.fetch(ceaSegmentUri), + ]); + ceaInitSegment = responses[0]; + ceaSegment = responses[1]; + }); + + it('decodes cea608 data from user registered itu_t_t35 sei messages', () => { + // This is a user registered itu_t_t35 sei message, as defined + // in D.1.6 and D.2.6 of Rec. ITU-T H.264 (06/2019). + const cea708Packet = new Uint8Array([ + 0xb5, 0x00, 0x31, 0x47, 0x41, + 0x39, 0x34, 0x03, 0xce, 0xff, + 0xfd, 0x94, 0x20, 0xfd, 0x94, + 0xae, 0xfd, 0x91, 0x62, 0xfd, + 0x73, 0xf7, 0xfd, 0xe5, 0xba, + 0xfd, 0x91, 0xb9, 0xfd, 0xb0, + 0xb0, 0xfd, 0xba, 0xb0, 0xfd, + 0xb0, 0xba, 0xfd, 0xb0, 0x31, + 0xfd, 0xba, 0xb0, 0xfd, 0xb0, + 0x80, 0xfd, 0x94, 0x2c, 0xfd, + 0x94, 0x2f, 0xff, + ]); + + const startTimeCaption1 = 1; + const startTimeCaption2 = 2; + + // The end time of the first caption is determined by the start time of the + // second caption. Thus, only the first caption should have been emitted. + const expectedCues = [{ + startTime: startTimeCaption1, + endTime: startTimeCaption2, + stream: 'CC3', + text: 'swe:00:00:01:00', + }]; + + const decoder = new shaka.cea.AtscDecoder(); + decoder.extract(cea708Packet, startTimeCaption1); + decoder.extract(cea708Packet, startTimeCaption2); + const cues = decoder.decode(); + + expect(cues).toBeDefined(); + expect(cues.length).toBe(1); + expect(cues).toEqual(expectedCues); + }); + + it('decodes captions from itu_t_t35 messages from MP4 streams', () => { + const t1 = 7.407407407407407e-7; + const t2 = 0.9666670370370372; + const expectedCues =[ + {startTime: t1, endTime: t2, stream: 'CC1', text: 'eng:00:00:00:00'}, + {startTime: t1, endTime: t2, stream: 'CC3', text: 'swe:00:00:00:00'}, + ]; + + const cea708Parser = new shaka.cea.Mp4CeaParser(); + + cea708Parser.init(ceaInitSegment); + const cea708Packets = cea708Parser.parse(ceaSegment); + + const decoder = new shaka.cea.AtscDecoder(); + for (const cap of cea708Packets) { + decoder.extract(cap.packet, cap.pts); + } + const cues = decoder.decode(); + + expect(cues).toBeDefined(); + expect(cues.length).toBe(2); + expect(cues).toEqual(expectedCues); + }); + + it('emits unemitted captions when forceOutCaptions is invoked', () => { + const t1 = 7.407407407407407e-7; + const t2 = 0.9666670370370372; + const t3 = 2; + const expectedCues =[ + {startTime: t1, endTime: t2, stream: 'CC1', text: 'eng:00:00:00:00'}, + {startTime: t1, endTime: t2, stream: 'CC3', text: 'swe:00:00:00:00'}, + {startTime: t2, endTime: t3, stream: 'CC1', text: 'eng:00:00:01:00'}, + {startTime: t2, endTime: t3, stream: 'CC3', text: 'swe:00:00:01:00'}, + ]; + + const cea708Parser = new shaka.cea.Mp4CeaParser(); + cea708Parser.init(ceaInitSegment); + const cea708Packets = cea708Parser.parse(ceaSegment); + + const decoder = new shaka.cea.AtscDecoder(); + for (const cap of cea708Packets) { + decoder.extract(cap.packet, cap.pts); + } + const cues = decoder.decode(); + decoder.forceOutCaptions(t3); // Force captions out with end time = t3 + + expect(cues).toBeDefined(); + expect(cues.length).toBe(4); + expect(cues).toEqual(expectedCues); + }); +}); +