diff --git a/build/types/cea b/build/types/cea index bfe08c197a4..705050a6646 100644 --- a/build/types/cea +++ b/build/types/cea @@ -1,8 +1,12 @@ # Inband closed caption support. +../../lib/cea/cea_decoder.js ++../../lib/cea/cea_utils.js +../../lib/cea/cea608_data_channel.js +../../lib/cea/cea608_memory.js ++../../lib/cea/cea708_service.js ++../../lib/cea/cea708_window.js ++../../lib/cea/dtvcc_packet_builder.js +../../lib/cea/i_caption_decoder.js +../../lib/cea/i_cea_parser.js +../../lib/cea/mp4_cea_parser.js diff --git a/lib/cea/cea608_data_channel.js b/lib/cea/cea608_data_channel.js index bd9b22aac7e..963de048ea2 100644 --- a/lib/cea/cea608_data_channel.js +++ b/lib/cea/cea608_data_channel.js @@ -7,6 +7,7 @@ goog.provide('shaka.cea.Cea608DataChannel'); goog.require('shaka.cea.Cea608Memory'); +goog.require('shaka.cea.CeaUtils'); /** @@ -114,7 +115,7 @@ shaka.cea.Cea608DataChannel = class { const attr = (b2 & 0x1E) >> 1; // Set up the defaults. - let textColor = shaka.cea.Cea608Memory.DEFAULT_TXT_COLOR; + let textColor = shaka.cea.CeaUtils.DEFAULT_TXT_COLOR; let italics = false; @@ -157,7 +158,7 @@ shaka.cea.Cea608DataChannel = class { this.curbuf_.setTextColor(textColor); // Clear the background color, since new row (PAC) should reset ALL styles. - this.curbuf_.setBackgroundColor(shaka.cea.Cea608Memory.DEFAULT_BG_COLOR); + this.curbuf_.setBackgroundColor(shaka.cea.CeaUtils.DEFAULT_BG_COLOR); } /** @@ -169,13 +170,13 @@ shaka.cea.Cea608DataChannel = class { // Clear all pre-existing midrow style attributes. this.curbuf_.setUnderline(false); this.curbuf_.setItalics(false); - this.curbuf_.setTextColor(shaka.cea.Cea608Memory.DEFAULT_TXT_COLOR); + this.curbuf_.setTextColor(shaka.cea.CeaUtils.DEFAULT_TXT_COLOR); // Mid-row attrs use a space. this.curbuf_.addChar( shaka.cea.Cea608Memory.CharSet.BASIC_NORTH_AMERICAN, ' '.charCodeAt(0)); - let textColor = shaka.cea.Cea608Memory.DEFAULT_TXT_COLOR; + let textColor = shaka.cea.CeaUtils.DEFAULT_TXT_COLOR; let italics = false; // Midrow codes set underline on last (LSB) bit. @@ -200,7 +201,7 @@ shaka.cea.Cea608DataChannel = class { * @private */ controlBackgroundAttribute_(b1, b2) { - let backgroundColor = shaka.cea.Cea608Memory.DEFAULT_BG_COLOR; + let backgroundColor = shaka.cea.CeaUtils.DEFAULT_BG_COLOR; if ((b1 & 0x07) === 0x0) { // If background provided, last 3 bits of b1 are |0|0|0|. Color is in b2. backgroundColor = shaka.cea.Cea608DataChannel.BG_COLORS[(b2 & 0xe) >> 1]; diff --git a/lib/cea/cea608_memory.js b/lib/cea/cea608_memory.js index 09e6a52d725..039f9330337 100644 --- a/lib/cea/cea608_memory.js +++ b/lib/cea/cea608_memory.js @@ -5,8 +5,8 @@ */ goog.provide('shaka.cea.Cea608Memory'); -goog.provide('shaka.cea.Cea608Char'); +goog.require('shaka.cea.CeaUtils'); goog.require('shaka.text.Cue'); @@ -21,7 +21,7 @@ shaka.cea.Cea608Memory = class { constructor(fieldNum, channelNum) { /** * Buffer for storing decoded characters. - * @private @const {!Array>} + * @private @const {!Array>} */ this.rows_ = []; @@ -62,12 +62,12 @@ shaka.cea.Cea608Memory = class { /** * @private {!string} */ - this.textColor_ = shaka.cea.Cea608Memory.DEFAULT_TXT_COLOR; + this.textColor_ = shaka.cea.CeaUtils.DEFAULT_TXT_COLOR; /** * @private {!string} */ - this.backgroundColor_ = shaka.cea.Cea608Memory.DEFAULT_BG_COLOR; + this.backgroundColor_ = shaka.cea.CeaUtils.DEFAULT_BG_COLOR; this.reset(); } @@ -80,121 +80,10 @@ shaka.cea.Cea608Memory = class { */ forceEmit(startTime, endTime) { const stream = `CC${(this.fieldNum_<< 1) | this.channelNum_ +1}`; - - // Find the first and last row that contains characters. - let firstNonEmptyRow = -1; - let lastNonEmptyRow = -1; - - for (let i = 0; i < this.rows_.length; i++) { - if (this.rows_[i].length) { - firstNonEmptyRow = i; - break; - } - } - - for (let i = this.rows_.length - 1; i >= 0; i--) { - if (this.rows_[i].length) { - lastNonEmptyRow = i; - break; - } - } - - // Exit early if no non-empty row was found. - if (firstNonEmptyRow === -1 || lastNonEmptyRow === -1) { - return null; - } - - // Keeps track of the current styles for a cue being emitted. - let currentUnderline = false; - let currentItalics = false; - let currentTextColor = shaka.cea.Cea608Memory.DEFAULT_TXT_COLOR; - let currentBackgroundColor = shaka.cea.Cea608Memory.DEFAULT_BG_COLOR; - - // Create first cue that will be nested in top level cue. Default styles. - let currentCue = this.createStyledCue_(startTime, endTime, - currentUnderline, currentItalics, - currentTextColor, currentBackgroundColor); - - // Logic: Reduce rows into a single top level cue containing nested cues. - // Each nested cue corresponds either a style change or a line break. const topLevelCue = new shaka.text.Cue( startTime, endTime, /* payload= */ ''); - - for (let i = firstNonEmptyRow; i <= lastNonEmptyRow; i++) { - for (const styledChar of this.rows_[i]) { - const underline = styledChar.isUnderlined(); - const italics = styledChar.isItalicized(); - const textColor = styledChar.getTextColor(); - const backgroundColor = styledChar.getBackgroundColor(); - - // If any style properties have changed, we need to open a new cue. - if (underline != currentUnderline || italics != currentItalics || - textColor != currentTextColor || - backgroundColor != currentBackgroundColor) { - // Push the currently built cue and start a new cue, with new styles. - if (currentCue.payload) { - topLevelCue.nestedCues.push(currentCue); - } - currentCue = this.createStyledCue_(startTime, endTime, - underline, italics, textColor, backgroundColor); - - currentUnderline = underline; - currentItalics = italics; - currentTextColor = textColor; - currentBackgroundColor = backgroundColor; - } - - currentCue.payload += styledChar.getChar(); - } - if (currentCue.payload) { - topLevelCue.nestedCues.push(currentCue); - } - - // Create and push a linebreak cue to create a new line. - if (i !== lastNonEmptyRow) { - const spacerCue = new shaka.text.Cue( - startTime, endTime, /* payload= */ ''); - spacerCue.spacer = true; - topLevelCue.nestedCues.push(spacerCue); - } - - // Create a new cue. - currentCue = this.createStyledCue_(startTime, endTime, - currentUnderline, currentItalics, - currentTextColor, currentBackgroundColor); - } - - if (topLevelCue.nestedCues.length) { - return { - cue: topLevelCue, - stream, - }; - } - - return null; - } - - /** - * @param {!number} startTime - * @param {!number} endTime - * @param {!boolean} underline - * @param {!boolean} italics - * @param {!string} txtColor - * @param {!string} bgColor - * @return {!shaka.text.Cue} - * @private - */ - createStyledCue_(startTime, endTime, underline, italics, txtColor, bgColor) { - const cue = new shaka.text.Cue(startTime, endTime, /* payload= */ ''); - if (underline) { - cue.textDecoration.push(shaka.text.Cue.textDecoration.UNDERLINE); - } - if (italics) { - cue.fontStyle = shaka.text.Cue.fontStyle.ITALIC; - } - cue.color = txtColor; - cue.backgroundColor = bgColor; - return cue; + return shaka.cea.CeaUtils.getParsedCaption( + topLevelCue, stream, this.rows_, startTime, endTime); } /** @@ -273,8 +162,9 @@ shaka.cea.Cea608Memory = class { } if (char) { - const styledChar = new shaka.cea.Cea608Char(char, this.underline_, - this.italics_, this.backgroundColor_, this.textColor_); + const styledChar = new shaka.cea.CeaUtils.StyledChar( + char, this.underline_, this.italics_, + this.backgroundColor_, this.textColor_); this.rows_[this.row_].push(styledChar); } } @@ -360,88 +250,12 @@ shaka.cea.Cea608Memory = class { } }; -shaka.cea.Cea608Char = class { - constructor(character, underline, italics, backgroundColor, textColor) { - /** - * @private {!string} - */ - this.character_ = character; - - /** - * @private {!boolean} - */ - this.underline_ = underline; - - /** - * @private {!boolean} - */ - this.italics_ = italics; - - /** - * @private {!string} - */ - this.backgroundColor_ = backgroundColor; - - /** - * @private {!string} - */ - this.textColor_ = textColor; - } - - /** - * @return {!string} - */ - getChar() { - return this.character_; - } - - /** - * @return {!boolean} - */ - isUnderlined() { - return this.underline_; - } - - /** - * @return {!boolean} - */ - isItalicized() { - return this.italics_; - } - - /** - * @return {!string} - */ - getBackgroundColor() { - return this.backgroundColor_; - } - - /** - * @return {!string} - */ - getTextColor() { - return this.textColor_; - } -}; - /** * Maximum number of rows in the buffer. * @const {!number} */ shaka.cea.Cea608Memory.CC_ROWS = 15; -/** - * Default background color for text. - * @const {!string} - */ -shaka.cea.Cea608Memory.DEFAULT_BG_COLOR = 'black'; - -/** - * Default text color. - * @const {!string} - */ -shaka.cea.Cea608Memory.DEFAULT_TXT_COLOR = 'white'; - /** * Characters sets. * @const @enum {!number} diff --git a/lib/cea/cea708_service.js b/lib/cea/cea708_service.js new file mode 100644 index 00000000000..179ba8a8402 --- /dev/null +++ b/lib/cea/cea708_service.js @@ -0,0 +1,703 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.cea.Cea708Service'); + +goog.require('shaka.cea.Cea708Window'); +goog.require('shaka.cea.DtvccPacket'); +goog.require('shaka.cea.ICaptionDecoder'); +goog.require('shaka.util.Error'); + + +/** + * CEA-708 closed captions service as defined by CEA-708-E. A decoder can own up + * to 63 services. Each service owns eight windows. + */ +shaka.cea.Cea708Service = class { + /** + * @param {!number} serviceNumber + */ + constructor(serviceNumber) { + /** + * Number for this specific service (1 - 63). + * @private {!number} + */ + this.serviceNumber_ = serviceNumber; + + /** + * Eight Cea708 Windows, as defined by the spec. + * @private {!Array} + */ + this.windows_ = [ + null, null, null, null, null, null, null, null, + ]; + + /** + * The current window for which window command operate on. + * @private {?shaka.cea.Cea708Window} + */ + this.currentWindow_ = null; + } + + /** + * Processes a CEA-708 control code. + * @param {!shaka.cea.DtvccPacket} dtvccPacket + * @return {?shaka.cea.ICaptionDecoder.ClosedCaption} + * @throws {!shaka.util.Error} + */ + handleCea708ControlCode(dtvccPacket) { + const blockData = dtvccPacket.readByte(); + let controlCode = blockData.value; + const pts = blockData.pts; + + // Read extended control code if needed. + if (controlCode === shaka.cea.Cea708Service.EXT_CEA708_CTRL_CODE_BYTE1) { + const extendedControlCodeBlock = dtvccPacket.readByte(); + controlCode = (controlCode << 16) | extendedControlCodeBlock.value; + } + + // Control codes are in 1 of 4 logical groups: + // CL (C0, C2), CR (C1, C3), GL (G0, G2), GR (G1, G2). + if (controlCode >= 0x00 && controlCode <= 0x1f) { + return this.handleC0_(controlCode, pts); + } else if (controlCode >= 0x80 && controlCode <= 0x9f) { + return this.handleC1_(dtvccPacket, controlCode, pts); + } else if (controlCode >= 0x1000 && controlCode <= 0x101f) { + this.handleC2_(dtvccPacket, controlCode & 0xff); + } else if (controlCode >= 0x1080 && controlCode <= 0x109f) { + this.handleC3_(dtvccPacket, controlCode & 0xff); + } else if (controlCode >= 0x20 && controlCode <= 0x7f) { + this.handleG0_(controlCode); + } else if (controlCode >= 0xa0 && controlCode <= 0xff) { + this.handleG1_(controlCode); + } else if (controlCode >= 0x1020 && controlCode <= 0x107f) { + this.handleG2_(controlCode & 0xff); + } else if (controlCode >= 0x10a0 && controlCode <= 0x10ff) { + this.handleG3_(controlCode & 0xff); + } + + return null; + } + + /** + * Handles G0 group data. + * @param {!number} controlCode + * @private + */ + handleG0_(controlCode) { + if (!this.currentWindow_) { + return; + } + // G0 contains ASCII from 0x20 to 0x7f, with the exception that 0x7f + // is replaced by a musical note. + if (controlCode === 0x7f) { + this.currentWindow_.setCharacter('♪'); + return; + } + this.currentWindow_.setCharacter(String.fromCharCode(controlCode)); + } + + /** + * Handles G1 group data. + * @param {!number} controlCode + * @private + */ + handleG1_(controlCode) { + if (!this.currentWindow_) { + return; + } + // G1 is the Latin-1 Character Set from 0xa0 to 0xff. + this.currentWindow_.setCharacter(String.fromCharCode(controlCode)); + } + + /** + * Handles G2 group data. + * @param {!number} controlCode + * @private + */ + handleG2_(controlCode) { + if (!this.currentWindow_) { + return; + } + if (!shaka.cea.Cea708Service.G2Charset.has(controlCode)) { + // If the character is unsupported, the spec says to put an underline. + this.currentWindow_.setCharacter('_'); + return; + } + + const char = shaka.cea.Cea708Service.G2Charset.get(controlCode); + this.currentWindow_.setCharacter(char); + } + + /** + * Handles G3 group data. + * @param {!number} controlCode + * @private + */ + handleG3_(controlCode) { + if (!this.currentWindow_) { + return; + } + + // As of CEA-708-E, the G3 group only contains 1 character. It's a + // [CC] character which has no unicode value on 0xa0. + if (controlCode != 0xa0) { + // Similar to G2, the spec decrees an underline if char is unsupported. + this.currentWindow_.setCharacter('_'); + return; + } + + this.currentWindow_.setCharacter('[CC]'); + } + + /** + * Handles C0 group data. + * @param {!number} controlCode + * @param {!number} pts + * @return {?shaka.cea.ICaptionDecoder.ClosedCaption} + * @private + */ + handleC0_(controlCode, pts) { + // All these commands pertain to the current window, so ensure it exists. + if (!this.currentWindow_) { + return null; + } + + const window = this.currentWindow_; + let parsedClosedCaption = null; + + // Note: This decoder ignores the "ETX" (end of text) control code. Since + // this is JavaScript, a '\0' is not needed to terminate a string. + switch (controlCode) { + case shaka.cea.Cea708Service.ASCII_BACKSPACE: + window.backspace(); + break; + case shaka.cea.Cea708Service.ASCII_CARRIAGE_RETURN: + // Force out the buffer, since the top row could be lost. + if (window.isVisible()) { + parsedClosedCaption = window.forceEmit(pts, this.serviceNumber_); + } + window.carriageReturn(); + break; + case shaka.cea.Cea708Service.ASCII_HOR_CARRIAGE_RETURN: + // Force out the buffer, a row will be erased. + if (window.isVisible()) { + parsedClosedCaption = window.forceEmit(pts, this.serviceNumber_); + } + window.horizontalCarriageReturn(); + break; + case shaka.cea.Cea708Service.ASCII_FORM_FEED: + // Clear window and move pen to (0,0). + // Force emit if the window is visible. + if (window.isVisible()) { + parsedClosedCaption = window.forceEmit(pts, this.serviceNumber_); + } + window.resetMemory(); + window.setPenLocation(0, 0); + break; + } + return parsedClosedCaption; + } + + /** + * Processes C1 group data. + * These are caption commands. + * @param {!shaka.cea.DtvccPacket} dtvccPacket + * @param {!number} captionCommand + * @param {!number} pts in seconds + * @return {?shaka.cea.ICaptionDecoder.ClosedCaption} + * @throws {!shaka.util.Error} a possible out-of-range buffer read. + * @private + */ + handleC1_(dtvccPacket, captionCommand, pts) { + // Note: This decoder ignores delay and delayCancel control codes in the C1. + // group. These control codes delay processing of data for a set amount of + // time, however this decoder processes that data immediately. + + if (captionCommand >= 0x80 && captionCommand <= 0x87) { + const windowNum = captionCommand & 0x07; + this.setCurrentWindow_(windowNum); + } else if (captionCommand === 0x88) { + const bitmap = dtvccPacket.readByte().value; + return this.clearWindows_(bitmap, pts); + } else if (captionCommand === 0x89) { + const bitmap = dtvccPacket.readByte().value; + this.displayWindows_(bitmap, pts); + } else if (captionCommand === 0x8a) { + const bitmap = dtvccPacket.readByte().value; + return this.hideWindows_(bitmap, pts); + } else if (captionCommand === 0x8b) { + const bitmap = dtvccPacket.readByte().value; + return this.toggleWindows_(bitmap, pts); + } else if (captionCommand === 0x8c) { + const bitmap = dtvccPacket.readByte().value; + return this.deleteWindows_(bitmap, pts); + } else if (captionCommand === 0x8f) { + return this.reset_(pts); + } else if (captionCommand === 0x90) { + this.setPenAttributes_(dtvccPacket); + } else if (captionCommand === 0x91) { + this.setPenColor_(dtvccPacket); + } else if (captionCommand === 0x92) { + this.setPenLocation_(dtvccPacket); + } else if (captionCommand === 0x97) { + this.setWindowAttributes_(dtvccPacket); + } else if (captionCommand >= 0x98 && captionCommand <= 0x9f) { + const windowNum = (captionCommand & 0x0f) - 8; + this.defineWindow_(dtvccPacket, windowNum, pts); + } + return null; + } + + /** + * Handles C2 group data. + * @param {!shaka.cea.DtvccPacket} dtvccPacket + * @param {!number} controlCode + * @private + */ + handleC2_(dtvccPacket, controlCode) { + // As of the CEA-708-E spec there are no commands on the C2 table, but if + // seen, then the appropriate number of bytes must be skipped as per spec. + if (controlCode >= 0x08 && controlCode <= 0x0f) { + dtvccPacket.skip(1); + } else if (controlCode >= 0x10 && controlCode <= 0x17) { + dtvccPacket.skip(2); + } else if (controlCode >= 0x18 && controlCode <= 0x1f) { + dtvccPacket.skip(3); + } + } + + /** + * Handles C3 group data. + * @param {!shaka.cea.DtvccPacket} dtvccPacket + * @param {!number} controlCode + * @private + */ + handleC3_(dtvccPacket, controlCode) { + // As of the CEA-708-E spec there are no commands on the C3 table, but if + // seen, then the appropriate number of bytes must be skipped as per spec. + if (controlCode >= 0x80 && controlCode <= 0x87) { + dtvccPacket.skip(4); + } else if (controlCode >= 0x88 && controlCode <= 0x8f) { + dtvccPacket.skip(5); + } + } + + /** + * @param {!number} windowNum + * @private + */ + setCurrentWindow_(windowNum) { + // If the window isn't created, ignore the command. + if (!this.windows_[windowNum]) { + return; + } + this.currentWindow_ = this.windows_[windowNum]; + } + + /** + * Yields each non-null window specified in the 8-bit bitmap. + * @param {!number} bitmap 8 bits corresponding to each of the 8 windows. + * @return {!Iterable.} + * @private + */ + * getSpecifiedWindowIds_(bitmap) { + for (let i = 0; i < 8; i++) { + const windowSpecified = (bitmap & 0x01) === 0x01; + if (windowSpecified && this.windows_[i]) { + yield i; + } + bitmap >>= 1; + } + } + + /** + * @param {!number} windowsBitmap + * @param {!number} pts + * @return {?shaka.cea.ICaptionDecoder.ClosedCaption} + * @private + */ + clearWindows_(windowsBitmap, pts) { + let parsedClosedCaption = null; + + // Clears windows from the 8 bit bitmap. + for (const windowId of this.getSpecifiedWindowIds_(windowsBitmap)) { + // If window visible and being cleared, emit buffer and reset start time! + const window = this.windows_[windowId]; + if (window.isVisible()) { + parsedClosedCaption = window.forceEmit(pts, this.serviceNumber_); + } + window.resetMemory(); + } + return parsedClosedCaption; + } + + /** + * @param {!number} windowsBitmap + * @param {!number} pts + * @private + */ + displayWindows_(windowsBitmap, pts) { + // Displays windows from the 8 bit bitmap. + for (const windowId of this.getSpecifiedWindowIds_(windowsBitmap)) { + const window = this.windows_[windowId]; + if (!window.isVisible()) { + // We are turning on the visibility, set the start time. + window.setStartTime(pts); + } + window.display(); + } + } + + /** + * @param {!number} windowsBitmap + * @param {!number} pts + * @return {?shaka.cea.ICaptionDecoder.ClosedCaption} + * @private + */ + hideWindows_(windowsBitmap, pts) { + let parsedClosedCaption = null; + + // Hides windows from the 8 bit bitmap. + for (const windowId of this.getSpecifiedWindowIds_(windowsBitmap)) { + const window = this.windows_[windowId]; + if (window.isVisible()) { + // We are turning off the visibility, emit! + parsedClosedCaption = window.forceEmit(pts, this.serviceNumber_); + } + window.hide(); + } + return parsedClosedCaption; + } + + /** + * @param {!number} windowsBitmap + * @param {!number} pts + * @return {?shaka.cea.ICaptionDecoder.ClosedCaption} + * @private + */ + toggleWindows_(windowsBitmap, pts) { + let parsedClosedCaption = null; + + // Toggles windows from the 8 bit bitmap. + for (const windowId of this.getSpecifiedWindowIds_(windowsBitmap)) { + const window = this.windows_[windowId]; + if (window.isVisible()) { + // We are turning off the visibility, emit! + parsedClosedCaption = window.forceEmit(pts, this.serviceNumber_); + } else { + // We are turning on visibility, set the start time. + window.setStartTime(pts); + } + + window.toggle(); + } + return parsedClosedCaption; + } + + /** + * @param {!number} windowsBitmap + * @param {!number} pts + * @return {?shaka.cea.ICaptionDecoder.ClosedCaption} + * @private + */ + deleteWindows_(windowsBitmap, pts) { + let parsedClosedCaption = null; + // Deletes windows from the 8 bit bitmap. + for (const windowId of this.getSpecifiedWindowIds_(windowsBitmap)) { + const window = this.windows_[windowId]; + if (window.isVisible()) { + // We are turning off the visibility, emit! + parsedClosedCaption = window.forceEmit(pts, this.serviceNumber_); + } + // Delete the window from the list of windows + this.windows_[windowId] = null; + } + return parsedClosedCaption; + } + + /** + * Emits anything currently present in any of the windows, and then + * deletes all windows, cancels all delays, reinitializes the service. + * @param {!number} pts + * @return {?shaka.cea.ICaptionDecoder.ClosedCaption} + * @private + */ + reset_(pts) { + const allWindowsBitmap = 0xff; // All windows should be deleted. + const caption = this.deleteWindows_(allWindowsBitmap, pts); + this.clear(); + return caption; + } + + /** + * Clears the state of the service completely. + */ + clear() { + this.currentWindow_ = null; + this.windows_ = [null, null, null, null, null, null, null, null]; + } + + /** + * @param {!shaka.cea.DtvccPacket} dtvccPacket + * @throws {!shaka.util.Error} + * @private + */ + setPenAttributes_(dtvccPacket) { + // Two bytes follow. For the purpose of this decoder, we are only concerned + // with byte 2, which is of the form |I|U|EDTYP|FNTAG|. + + // I (1 bit): Italics toggle. + // U (1 bit): Underline toggle. + // EDTYP (3 bits): Edge type (unused in this decoder). + // FNTAG (3 bits): Font tag (unused in this decoder). + // More info at https://en.wikipedia.org/wiki/CEA-708#SetPenAttributes_(0x90_+_2_bytes) + + dtvccPacket.skip(1); // Skip first byte + const attrByte2 = dtvccPacket.readByte().value; + + if (!this.currentWindow_) { + return; + } + + const italics = (attrByte2 & 0x80) > 0; + const underline = (attrByte2 & 0x40) > 0; + + this.currentWindow_.setPenItalics(italics); + this.currentWindow_.setPenUnderline(underline); + } + + /** + * @param {!shaka.cea.DtvccPacket} dtvccPacket + * @throws {!shaka.util.Error} + * @private + */ + setPenColor_(dtvccPacket) { + // Read foreground and background properties. + const foregroundByte = dtvccPacket.readByte().value; + const backgroundByte = dtvccPacket.readByte().value; + dtvccPacket.skip(1); // Edge color not supported, skip it. + + if (!this.currentWindow_) { + return; + } + + // Byte semantics are described at the following link: + // https://en.wikipedia.org/wiki/CEA-708#SetPenColor_(0x91_+_3_bytes) + + // Foreground color properties: |FOP|F_R|F_G|F_B|. + const foregroundBlue = foregroundByte & 0x03; + const foregroundGreen = (foregroundByte & 0x0c) >> 2; + const foregroundRed = (foregroundByte & 0x30) >> 4; + + // Background color properties: |BOP|B_R|B_G|B_B|. + const backgroundBlue = backgroundByte & 0x03; + const backgroundGreen = (backgroundByte & 0x0c) >> 2; + const backgroundRed = (backgroundByte & 0x30) >> 4; + + const foregroundColor = this.rgbColorToHex_( + foregroundRed, foregroundGreen, foregroundBlue); + + const backgroundColor = this.rgbColorToHex_( + backgroundRed, backgroundGreen, backgroundBlue); + + this.currentWindow_.setPenTextColor(foregroundColor); + this.currentWindow_.setPenBackgroundColor(backgroundColor); + } + + /** + * @param {!shaka.cea.DtvccPacket} dtvccPacket + * @throws {!shaka.util.Error} + * @private + */ + setPenLocation_(dtvccPacket) { + // Following 2 bytes take the following form: + // b1 = |0|0|0|0|ROW| and b2 = |0|0|COLUMN| + const locationByte1 = dtvccPacket.readByte().value; + const locationByte2 = dtvccPacket.readByte().value; + + if (!this.currentWindow_) { + return; + } + + const row = locationByte1 & 0x0f; + const col = locationByte2 & 0x3f; + this.currentWindow_.setPenLocation(row, col); + } + + /** + * @param {!shaka.cea.DtvccPacket} dtvccPacket + * @throws {!shaka.util.Error} + * @private + */ + setWindowAttributes_(dtvccPacket) { + // 4 bytes follow, with the following form: + // Byte 1 contains fill-color information. Unused in this decoder. + // Byte 2 contains border color information. Unused in this decoder. + // Byte 3 contains justification information. In this decoder, we only use + // the last 2 bits, which specifies text justification on the screen. + // Byte 4 is special effects. Unused in this decoder. + // More info at https://en.wikipedia.org/wiki/CEA-708#SetWindowAttributes_(0x97_+_4_bytes) + dtvccPacket.skip(1); // Fill color not supported, skip. + dtvccPacket.skip(1); // Border colors not supported, skip. + const b3 = dtvccPacket.readByte().value; + dtvccPacket.skip(1); // Effects not supported, skip. + + if (!this.currentWindow_) { + return; + } + + // Word wrap is outdated as of CEA-708-E, so we ignore those bits. + // Extract the text justification and set it on the window. + const justification = + /** @type {!shaka.cea.Cea708Window.TextJustification} */ (b3 & 0x03); + this.currentWindow_.setJustification(justification); + } + + /** + * @param {!shaka.cea.DtvccPacket} dtvccPacket + * @param {!number} windowNum + * @param {!number} pts + * @throws {!shaka.util.Error} + * @private + */ + defineWindow_(dtvccPacket, windowNum, pts) { + // Create the window if it doesn't exist. + const windowAlreadyExists = this.windows_[windowNum] !== null; + if (!windowAlreadyExists) { + const window = new shaka.cea.Cea708Window(windowNum); + window.setStartTime(pts); + this.windows_[windowNum] = window; + } + + // 6 Bytes follow, with the following form: + // b1 = |0|0|V|R|C|PRIOR| , b2 = |P|VERT_ANCHOR| , b3 = |HOR_ANCHOR| + // b4 = |ANC_ID|ROW_CNT| , b5 = |0|0|COL_COUNT| , b6 = |0|0|WNSTY|PNSTY| + // Semantics of these bytes at https://en.wikipedia.org/wiki/CEA-708#DefineWindow07_(0x98-0x9F,_+_6_bytes) + const b1 = dtvccPacket.readByte().value; + const b2 = dtvccPacket.readByte().value; + const b3 = dtvccPacket.readByte().value; + const b4 = dtvccPacket.readByte().value; + const b5 = dtvccPacket.readByte().value; + const b6 = dtvccPacket.readByte().value; + + // As per 8.4.7 of CEA-708-E, row locks and column locks are to be ignored. + // So this decoder will ignore these values. + + const visible = (b1 & 0x20) > 0; + const verticalAnchor = b2 & 0x7f; + const relativeToggle = (b2 & 0x80) > 0; + const horAnchor = b3; + const rowCount = (b4 & 0x0f) + 1; // Spec says to add 1. + const anchorId = (b4 & 0xf0) >> 4; + const colCount = (b5 & 0x3f) + 1; // Spec says to add 1. + + // If pen style = 0 AND window previously existed, keep its pen style. + // Otherwise, change the pen style (For now, just reset to the default pen). + // TODO add support for predefined pen styles and fonts. + const penStyle = b6 & 0x07; + if (!windowAlreadyExists || penStyle !== 0) { + this.windows_[windowNum].resetPen(); + } + + this.windows_[windowNum].defineWindow(visible, verticalAnchor, + horAnchor, anchorId, relativeToggle, rowCount, colCount); + + // Set the current window to the newly defined window. + this.currentWindow_ = this.windows_[windowNum]; + } + + /** + * Maps 64 possible CEA-708 colors to 8 CSS colors. + * @param {!number} red value from 0-3 + * @param {!number} green value from 0-3 + * @param {!number} blue value from 0-3 + * @return {!string} + * @private + */ + rgbColorToHex_(red, green, blue) { + // Rather than supporting 64 colors, this decoder supports 8 colors and + // gets the closest color, as per 9.19 of CEA-708-E. This is because some + // colors on television such as white, are often sent with lower intensity + // and often appear dull/greyish on the browser, making them hard to read. + + // As per CEA-708-E 9.19, these mappings will map 64 colors to 8 colors. + const colorMapping = {0: 0, 1: 0, 2: 1, 3: 1}; + red = colorMapping[red]; + green = colorMapping[green]; + blue = colorMapping[blue]; + + const colorCode = (red << 2) | (green << 1) | blue; + return shaka.cea.Cea708Service.Colors[colorCode]; + } +}; + +/** + * @private @const {!number} + */ +shaka.cea.Cea708Service.ASCII_BACKSPACE = 0x08; + +/** + * @private @const {!number} + */ +shaka.cea.Cea708Service.ASCII_FORM_FEED = 0x0c; + +/** + * @private @const {!number} + */ +shaka.cea.Cea708Service.ASCII_CARRIAGE_RETURN = 0x0d; + +/** + * @private @const {!number} + */ +shaka.cea.Cea708Service.ASCII_HOR_CARRIAGE_RETURN = 0x0e; + +/** + * For extended control codes in block_data on CEA-708, byte 1 is 0x10. + * @private @const {!number} + */ +shaka.cea.Cea708Service.EXT_CEA708_CTRL_CODE_BYTE1 = 0x10; + +/** + * Holds characters mapping for bytes that are G2 control codes. + * @private @const {!Map} + */ +shaka.cea.Cea708Service.G2Charset = new Map([ + [0x20, ' '], [0x21, '\xa0'], [0x25, '…'], [0x2a, 'Š'], [0x2c, 'Œ'], + [0x30, '█'], [0x31, '‘'], [0x32, '’'], [0x33, '“'], [0x34, '”'], + [0x35, '•'], [0x39, '™'], [0x3a, 'š'], [0x3c, 'œ'], [0x3d, '℠'], + [0x3f, 'Ÿ'], [0x76, '⅛'], [0x77, '⅜'], [0x78, '⅝'], [0x79, '⅞'], + [0x7a, '│'], [0x7b, '┐'], [0x7c, '└'], [0x7d, '─'], [0x7e, '┘'], [0x7f, '┌'], +]); + +/** + * An array of 8 colors that 64 colors can be quantized to. Order here matters. + * @private @const {!Array} + */ +shaka.cea.Cea708Service.Colors = [ + 'black', 'blue', 'green', 'cyan', + 'red', 'magenta', 'yellow', 'white', +]; + +/** + * CEA-708 closed captions byte. + * @typedef {{ + * pts: number, + * type: number, + * value: number, + * order: number + * }} + * + * @property {!number} pts + * Presentation timestamp (in second) at which this packet was received. + * @property {!number} type + * Type of the byte. Either 2 or 3, DTVCC Packet Data or a DTVCC Packet Start. + * @property {!number} value The byte containing data relevant to the packet. + * @property {!number} order + * A number indicating the order this packet was received in a sequence + * of packets. Used to break ties in a stable sorting algorithm + */ +shaka.cea.Cea708Service.Cea708Byte; diff --git a/lib/cea/cea708_window.js b/lib/cea/cea708_window.js new file mode 100644 index 00000000000..faa2c26c375 --- /dev/null +++ b/lib/cea/cea708_window.js @@ -0,0 +1,423 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.cea.Cea708Window'); + +goog.require('shaka.cea.CeaUtils'); +goog.require('shaka.cea.CeaUtils.StyledChar'); +goog.require('shaka.cea.ICaptionDecoder'); + + +/** + * CEA-708 Window. Each CEA-708 service owns 8 of these. + */ +shaka.cea.Cea708Window = class { + /** + * @param {!number} windowNum + */ + constructor(windowNum) { + /** + * A number from 0 - 7 indicating the window number in the + * service that owns this window. + * @private {!number} + */ + this.windowNum_ = windowNum; + + /** + * Indicates whether this window is visible. + * @private {!boolean} + */ + this.visible_ = false; + + /** + * Indicates whether the horizontal and vertical anchors coordinates specify + * a percentage of the screen, or physical coordinates on the screen. + * @private {!boolean} + */ + this.relativeToggle_ = false; + + /** + * Horizontal anchor. Loosely corresponds to a WebVTT viewport X anchor. + * @private {!number} + */ + this.horizontalAnchor_ = 0; + + /** + * Vertical anchor. Loosely corresponds to a WebVTT viewport Y anchor. + * @private {!number} + */ + this.verticalAnchor_ = 0; + + /** + * If valid, ranges from 0 to 8, specifying one of 9 locations on window: + * 0________1________2 + * | | | + * 3________4________5 + * | | | + * 6________7________8 + * Diagram is valid as per CEA-708-E section 8.4.4. + * Each of these locations corresponds to a WebVTT region's "region anchor". + * @private {!number} + */ + this.anchorId_ = 0; + + /** + * Indicates the number of rows in this window's buffer/memory. + * @private {!number} + */ + this.rowCount_ = 0; + + /** + * Indicates the number of columns in this window's buffer/memory. + * @private {!number} + */ + this.colCount_ = 0; + + /** + * Center by default. + * @private {!shaka.cea.Cea708Window.TextJustification} + */ + this.justification_ = shaka.cea.Cea708Window.TextJustification.CENTER; + + /** + * An array of rows of styled characters, representing the + * current text and styling of text in this window. + * @private {!Array>} + */ + this.memory_ = []; + + /** + * @private {!number} + */ + this.startTime_ = 0; + + /** + * Row that the current pen is pointing at. + * @private {!number} + */ + this.row_ = 0; + + /** + * Column that the current pen is pointing at. + * @private {!number} + */ + this.col_ = 0; + + /** + * Indicates whether the current pen position is italicized. + * @private {!boolean} + */ + this.italics_ = false; + + /** + * Indicates whether the current pen position is underlined. + * @private {!boolean} + */ + this.underline_ = false; + + /** + * Indicates the text color at the current pen position. + * @private {!string} + */ + this.textColor_ = shaka.cea.CeaUtils.DEFAULT_TXT_COLOR; + + /** + * Indicates the background color at the current pen position. + * @private {!string} + */ + this.backgroundColor_ = shaka.cea.CeaUtils.DEFAULT_BG_COLOR; + + this.resetMemory(); + + // TODO Support window positioning by mapping them to Regions. + // https://dvcs.w3.org/hg/text-tracks/raw-file/default/608toVTT/608toVTT.html#positioning-in-cea-708 + shaka.util.Functional.ignored(this.verticalAnchor_, this.relativeToggle_, + this.horizontalAnchor_, this.anchorId_, this.windowNum_); + } + + /** + * @param {!boolean} visible + * @param {!number} verticalAnchor + * @param {!number} horizontalAnchor + * @param {!number} anchorId + * @param {!boolean} relativeToggle + * @param {!number} rowCount + * @param {!number} colCount + */ + defineWindow(visible, verticalAnchor, horizontalAnchor, anchorId, + relativeToggle, rowCount, colCount) { + this.visible_ = visible; + this.verticalAnchor_ = verticalAnchor; + this.horizontalAnchor_ = horizontalAnchor; + this.anchorId_ = anchorId; + this.relativeToggle_ = relativeToggle; + this.rowCount_ = rowCount; + this.colCount_ = colCount; + } + + /** + * Resets the memory buffer. + */ + resetMemory() { + this.memory_ = []; + for (let i = 0; i < shaka.cea.Cea708Window.MAX_ROWS; i++) { + this.memory_.push(this.createNewRow_()); + } + } + + /** + * Allocates and returns a new row. + * @return {!Array} + * @private + */ + createNewRow_() { + const row = []; + for (let j = 0; j < shaka.cea.Cea708Window.MAX_COLS; j++) { + row.push(null); + } + return row; + } + + /** + * Sets the unicode value for a char at the current pen location. + * @param {!string} char + */ + setCharacter(char) { + // Check if the pen is out of bounds. + if (!this.isPenInBounds_()) { + return; + } + + const cea708Char = new shaka.cea.CeaUtils.StyledChar( + char, this.underline_, this.italics_, + this.backgroundColor_, this.textColor_); + this.memory_[this.row_][this.col_] = cea708Char; + + // Increment column + this.col_ ++; + } + + /** + * Erases a character from the buffer and moves the pen back. + */ + backspace() { + if (!this.isPenInBounds_()) { + return; + } + + // Check if a backspace can be done. + if (this.col_ <= 0 && this.row_ <= 0) { + return; + } + + if (this.col_ <= 0) { + // Move pen back a row. + this.col_ = this.colCount_ - 1; + this.row_--; + } else { + // Move pen back a column. + this.col_--; + } + + // Erase the character occupied at that position. + this.memory_[this.row_][this.col_] = null; + } + + /** + * @private + */ + isPenInBounds_() { + const inRowBounds = this.row_ < this.rowCount_ && this.row_ >= 0; + const inColBounds = this.col_ < this.colCount_ && this.col_ >= 0; + return inRowBounds && inColBounds; + } + + /** + * @return {!boolean} + */ + isVisible() { + return this.visible_; + } + + /** + * Moves up rows in the buffer. + * @param {!number} count + * @private + */ + moveUpRows_(count) { + let dst = 0; // Row each row should be moved to. + + // Move existing rows up by . + for (let i = count; i < shaka.cea.Cea708Window.MAX_ROWS; i++, dst++) { + this.memory_[dst] = this.memory_[i]; + } + + // Create new rows at the bottom. + for (let i = 0; i < count; i++, dst++) { + this.memory_[dst] = this.createNewRow_(); + } + } + + /** + * Handles CR. Increments row - if last row, "roll up" all rows by one. + */ + carriageReturn() { + if (this.row_ + 1 >= this.rowCount_) { + this.moveUpRows_(1); + this.col_ = 0; + return; + } + + this.row_++; + this.col_ = 0; + } + + /** + * Handles HCR. Moves the pen to the beginning of the cur. row and clears it. + */ + horizontalCarriageReturn() { + this.memory_[this.row_] = this.createNewRow_(); + this.col_ = 0; + } + + /** + * @param {!number} endTime + * @param {!number} serviceNumber Number of the service emitting this caption. + * @return {?shaka.cea.ICaptionDecoder.ClosedCaption} + */ + forceEmit(endTime, serviceNumber) { + const stream = `svc${serviceNumber}`; + const TextJustification = shaka.cea.Cea708Window.TextJustification; + const topLevelCue = new shaka.text.Cue( + this.startTime_, endTime, /* payload= */ ''); + + if (this.justification_ === TextJustification.LEFT) { + // LEFT justified. + topLevelCue.textAlign = shaka.text.Cue.textAlign.LEFT; + } else if (this.justification_ === TextJustification.RIGHT) { + // RIGHT justified. + topLevelCue.textAlign = shaka.text.Cue.textAlign.RIGHT; + } else { + // CENTER justified. Both FULL and CENTER are handled as CENTER justified. + topLevelCue.textAlign = shaka.text.Cue.textAlign.CENTER; + } + + const caption = shaka.cea.CeaUtils.getParsedCaption( + topLevelCue, stream, this.memory_, this.startTime_, endTime); + if (caption) { + // If a caption is being emitted, then the next caption's start time + // should be no less than this caption's end time. + this.setStartTime(endTime); + } + return caption; + } + + /** + * @param {!number} row + * @param {!number} col + */ + setPenLocation(row, col) { + this.row_ = row; + this.col_ = col; + } + + /** + * @param {!string} backgroundColor + */ + setPenBackgroundColor(backgroundColor) { + this.backgroundColor_ = backgroundColor; + } + + /** + * @param {!string} textColor + */ + setPenTextColor(textColor) { + this.textColor_ = textColor; + } + + /** + * @param {!boolean} underline + */ + setPenUnderline(underline) { + this.underline_ = underline; + } + + /** + * @param {!boolean} italics + */ + setPenItalics(italics) { + this.italics_ = italics; + } + + resetPen() { + this.row_ = 0; + this.col_ = 0; + this.underline_ = false; + this.italics_ = false; + this.textColor_ = shaka.cea.CeaUtils.DEFAULT_TXT_COLOR; + this.backgroundColor_ = shaka.cea.CeaUtils.DEFAULT_BG_COLOR; + } + + /** + * @param {!shaka.cea.Cea708Window.TextJustification} justification + */ + setJustification(justification) { + this.justification_ = justification; + } + + /** + * Sets the window to visible. + */ + display() { + this.visible_ = true; + } + + /** + * Sets the window to invisible. + */ + hide() { + this.visible_ = false; + } + + /** + * Toggles the visibility of the window. + */ + toggle() { + this.visible_ = !this.visible_; + } + + /** + * Sets the start time for the cue to be emitted. + * @param {!number} pts + */ + setStartTime(pts) { + this.startTime_ = pts; + } +}; + +/** + * Caption type. + * @const @enum {!number} + */ +shaka.cea.Cea708Window.TextJustification = { + LEFT: 0, + RIGHT: 1, + CENTER: 2, + FULL: 3, +}; + +/** + * Can be indexed 0-31 for 4:3 format, and 0-41 for 16:9 formats. + * Thus the absolute maximum is 42 columns for the 16:9 format. + * @private @const {!number} + */ +shaka.cea.Cea708Window.MAX_COLS = 42; + +/** + * Maximum of 15 rows that can be indexed from 0 to 14. + * @private @const {!number} + */ +shaka.cea.Cea708Window.MAX_ROWS = 15; diff --git a/lib/cea/cea_decoder.js b/lib/cea/cea_decoder.js index 116e2f0846f..be003289e1f 100644 --- a/lib/cea/cea_decoder.js +++ b/lib/cea/cea_decoder.js @@ -6,8 +6,9 @@ goog.provide('shaka.cea.CeaDecoder'); +goog.require('shaka.cea.Cea708Service'); goog.require('shaka.cea.Cea608DataChannel'); - +goog.require('shaka.cea.DtvccPacketBuilder'); /** * CEA-X08 captions decoder. Currently only CEA-608 supported. @@ -16,13 +17,25 @@ goog.require('shaka.cea.Cea608DataChannel'); shaka.cea.CeaDecoder = class { constructor() { /** - * An array of closed captions packets extracted for decoding. + * An array of CEA-608 closed caption data extracted for decoding. * @private {!Array} */ - this.ccPacketArray_ = []; + this.cea608DataArray_ = []; /** - * Number of bad frames decoded in a row. + * An array of CEA-708 closed caption data extracted for decoding. + * @private {!Array} + */ + this.cea708DataArray_ = []; + + /** + * A DTVCC Packet builder for CEA-708 data. + * @private {!shaka.cea.DtvccPacketBuilder} + */ + this.dtvccPacketBuilder_ = new shaka.cea.DtvccPacketBuilder(); + + /** + * Number of consecutive bad frames decoded on CEA-608. * @private {!number} */ this.badFrames_ = 0; @@ -50,6 +63,13 @@ shaka.cea.CeaDecoder = class { */ this.currentField2Channel_ = 0; + /** + * Map of service number to CEA-708 services, initially empty. Since there + * can be up to 63 services, they are created dynamically only when needed. + * @private {!Map} + */ + this.serviceNumberToService_ = new Map(); + this.reset(); } @@ -59,15 +79,15 @@ shaka.cea.CeaDecoder = class { */ clear() { this.badFrames_ = 0; - this.clearExtractedPackets_(); + this.cea608DataArray_ = []; + this.cea708DataArray_ = []; + this.dtvccPacketBuilder_.clear(); this.reset(); - } - /** - * @private - */ - clearExtractedPackets_() { - this.ccPacketArray_ = []; + // Clear all the CEA-708 services. + for (const service of this.serviceNumberToService_.values()) { + service.clear(); + } } /** @@ -129,14 +149,36 @@ shaka.cea.CeaDecoder = class { if (ccValid) { const ccType = cc & 0x03; - const ccPacket = { - pts, - type: ccType, - ccData1, - ccData2, - order: this.ccPacketArray_.length, - }; - this.ccPacketArray_.push(ccPacket); + // Send the packet to the appropriate data array (CEA-608 or CEA-708). + if (ccType === shaka.cea.CeaDecoder.NTSC_CC_FIELD_1 || + ccType === shaka.cea.CeaDecoder.NTSC_CC_FIELD_2) { + // CEA-608 NTSC (Line 21) Data. + this.cea608DataArray_.push({ + pts, + type: ccType, + ccData1, + ccData2, + order: this.cea608DataArray_.length, + }); + } else { + // CEA-708 DTVCC Data. + this.cea708DataArray_.push({ + pts, + type: ccType, + value: ccData1, + order: this.cea708DataArray_.length, + }); + + // The second byte should always be labelled as DTVCC packet data. + // Even if this pair was a DTVCC packet start, only the first byte + // contains header info, and the second byte is just packet data. + this.cea708DataArray_.push({ + pts, + type: shaka.cea.DtvccPacketBuilder.DTVCC_PACKET_DATA, + value: ccData2, + order: this.cea708DataArray_.length, + }); + } } } } @@ -150,33 +192,38 @@ shaka.cea.CeaDecoder = class { const parsedClosedCaptions = []; // In some versions of Chrome, and other browsers, the default sorting - // algorithm isn't stable. This sort breaks ties based on receive order. - this.ccPacketArray_.sort( - /** - * Stable sorting function. - * @param {!shaka.cea.Cea608DataChannel.Cea608Packet} ccPacket1 - * @param {!shaka.cea.Cea608DataChannel.Cea608Packet} ccPacket2 - * @return {!number} - */ - (ccPacket1, ccPacket2) => { - const diff = ccPacket1.pts - ccPacket2.pts; - const isEqual = diff === 0; - return isEqual ? ccPacket1.order - ccPacket2.order : diff; - }); - - for (const ccPacket of this.ccPacketArray_) { - // Only consider packets that are NTSC line 21 (CEA-608). - // Types 2 and 3 contain DVTCC data, for a future CEA-708 decoder. - if (ccPacket.type === shaka.cea.CeaDecoder.NTSC_CC_FIELD_1 || - ccPacket.type === shaka.cea.CeaDecoder.NTSC_CC_FIELD_2) { - const parsedClosedCaption = this.decodeCea608_(ccPacket); - if (parsedClosedCaption) { - parsedClosedCaptions.push(parsedClosedCaption); - } + // algorithm isn't stable. This comparator sorts on presentation + // timestamp, and breaks ties on receive order (position in array). + const stableComparator = + (p1, p2) => (p1.pts - p2.pts) || (p1.order - p2.order); + + this.cea608DataArray_.sort(stableComparator); + this.cea708DataArray_.sort(stableComparator); + + // CEA-608 packets are just byte pairs. Decode all of them. + for (const cea608Packet of this.cea608DataArray_) { + const parsedClosedCaption = this.decodeCea608_(cea608Packet); + if (parsedClosedCaption) { + parsedClosedCaptions.push(parsedClosedCaption); } } - this.clearExtractedPackets_(); + // CEA-708 packets are DTVCC packets composed of many byte pairs. Add all + // byte pairs to the packet builder, and process + clear any ready packets. + for (const cea708Byte of this.cea708DataArray_) { + this.dtvccPacketBuilder_.addByte(cea708Byte); + } + const dtvccPackets = this.dtvccPacketBuilder_.getBuiltPackets(); + for (const dtvccPacket of dtvccPackets) { + const closedCaptions = this.decodeCea708_(dtvccPacket); + parsedClosedCaptions.push(...closedCaptions); + } + + // Clear all processed data. + this.dtvccPacketBuilder_.clearBuiltPackets(); + this.cea608DataArray_ = []; + this.cea708DataArray_ = []; + return parsedClosedCaptions; } @@ -245,6 +292,65 @@ shaka.cea.CeaDecoder = class { return parsedClosedCaption; } + /** + * Decodes a CEA-708 DTVCC packet based on ANSI/CTA-708-E. + * @param {shaka.cea.DtvccPacket} dtvccPacket + * @return {!Array} + * @private + */ + decodeCea708_(dtvccPacket) { + const parsedClosedCaptions = []; + try { + while (dtvccPacket.hasMoreData()) { + // Process a service block. + const serviceBlockHeader = dtvccPacket.readByte().value; + + // First 3 bits are service number, next 5 are block size, + // representing the number of bytes coming in this block + // (discluding a possible extended service block header byte) + let serviceNumber = (serviceBlockHeader & 0xe0) >> 5; + const blockSize = serviceBlockHeader & 0x1f; + + if (serviceNumber === /* 0b111 */ 0x07 && blockSize != 0) { + // 2 bits null padding, 6 bits extended service number + const extendedServiceBlockHeader = dtvccPacket.readByte().value; + serviceNumber = extendedServiceBlockHeader & 0x3f; + } + + // As per CEA-708-E, service number 0 is invalid, and should be ignored. + if (serviceNumber != 0) { + // If the service doesn't already exist, create it. + if (!this.serviceNumberToService_.has(serviceNumber)) { + const service = new shaka.cea.Cea708Service(serviceNumber); + this.serviceNumberToService_.set(serviceNumber, service); + } + const service = this.serviceNumberToService_.get(serviceNumber); + + // Process all control codes. + const startPos = dtvccPacket.getPosition(); + + // Execute this loop `blockSize` times, to decode the control codes. + while (dtvccPacket.getPosition() - startPos < blockSize) { + const closedCaption = service.handleCea708ControlCode(dtvccPacket); + if (closedCaption) { + parsedClosedCaptions.push(closedCaption); + } + } // position < end of block + } // serviceNumber != 0 + } // hasMoreData + } catch (error) { + if (error instanceof shaka.util.Error && + error.code === shaka.util.Error.Code.BUFFER_READ_OUT_OF_BOUNDS) { + shaka.log.warnOnce('CEA708_INVALID_DATA', + 'Buffer read out of bounds / invalid CEA-708 Data.'); + } else { + // This is an unexpected error, and should be rethrown. + throw error; + } + } + return parsedClosedCaptions; + } + /** * Checks if a byte has odd parity (Odd number of 1s in binary). * @param {!number} byte diff --git a/lib/cea/cea_utils.js b/lib/cea/cea_utils.js new file mode 100644 index 00000000000..b178d3e1239 --- /dev/null +++ b/lib/cea/cea_utils.js @@ -0,0 +1,265 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.cea.CeaUtils'); +goog.provide('shaka.cea.CeaUtils.StyledChar'); + +goog.require('shaka.cea.ICaptionDecoder'); +goog.require('shaka.text.Cue'); + + +shaka.cea.CeaUtils = class { + /** + * Emits a closed caption based on the state of the buffer. + * @param {!shaka.text.Cue} topLevelCue + * @param {!string} stream + * @param {!Array>} memory + * @param {!number} startTime Start time of the cue. + * @param {!number} endTime End time of the cue. + * @return {?shaka.cea.ICaptionDecoder.ClosedCaption} + */ + static getParsedCaption(topLevelCue, stream, memory, startTime, endTime) { + if (startTime >= endTime) { + return null; + } + + // Find the first and last row that contains characters. + let firstNonEmptyRow = -1; + let lastNonEmptyRow = -1; + + for (let i = 0; i < memory.length; i++) { + if (memory[i].some((e) => e != null && e.getChar().trim() != '')) { + firstNonEmptyRow = i; + break; + } + } + + for (let i = memory.length - 1; i >= 0; i--) { + if (memory[i].some((e) => e != null && e.getChar().trim() != '')) { + lastNonEmptyRow = i; + break; + } + } + + // Exit early if no non-empty row was found. + if (firstNonEmptyRow === -1 || lastNonEmptyRow === -1) { + return null; + } + + // Keeps track of the current styles for a cue being emitted. + let currentUnderline = false; + let currentItalics = false; + let currentTextColor = shaka.cea.CeaUtils.DEFAULT_TXT_COLOR; + let currentBackgroundColor = shaka.cea.CeaUtils.DEFAULT_BG_COLOR; + + // Create first cue that will be nested in top level cue. Default styles. + let currentCue = shaka.cea.CeaUtils.createStyledCue( + startTime, endTime, currentUnderline, currentItalics, + currentTextColor, currentBackgroundColor); + + // Logic: Reduce rows into a single top level cue containing nested cues. + // Each nested cue corresponds either a style change or a line break. + + for (let i = firstNonEmptyRow; i <= lastNonEmptyRow; i++) { + // Find the first and last non-empty characters in this row. We do this so + // no styles creep in before/after the first and last non-empty chars. + const row = memory[i]; + let firstNonEmptyCol = -1; + let lastNonEmptyCol = -1; + + for (let j = 0; j < row.length; j++) { + if (row[j] != null && row[j].getChar().trim() !== '') { + firstNonEmptyCol = j; + break; + } + } + + for (let j = row.length - 1; j >= 0; j--) { + if (row[j] != null && row[j].getChar().trim() !== '') { + lastNonEmptyCol = j; + break; + } + } + + // If no non-empty char. was found in this row, it must be a linebreak. + if (firstNonEmptyCol === -1 || lastNonEmptyCol === -1) { + const linebreakCue = shaka.cea.CeaUtils + .createLineBreakCue(startTime, endTime); + topLevelCue.nestedCues.push(linebreakCue); + continue; + } + + for (let j = firstNonEmptyCol; j <= lastNonEmptyCol; j++) { + const styledChar = row[j]; + + // A null between non-empty cells in a row is handled as a space. + if (!styledChar) { + currentCue.payload += ' '; + continue; + } + const underline = styledChar.isUnderlined(); + const italics = styledChar.isItalicized(); + const textColor = styledChar.getTextColor(); + const backgroundColor = styledChar.getBackgroundColor(); + + // If any style properties have changed, we need to open a new cue. + if (underline != currentUnderline || italics != currentItalics || + textColor != currentTextColor || + backgroundColor != currentBackgroundColor) { + // Push the currently built cue and start a new cue, with new styles. + if (currentCue.payload) { + topLevelCue.nestedCues.push(currentCue); + } + currentCue = shaka.cea.CeaUtils.createStyledCue( + startTime, endTime, underline, + italics, textColor, backgroundColor); + + currentUnderline = underline; + currentItalics = italics; + currentTextColor = textColor; + currentBackgroundColor = backgroundColor; + } + + currentCue.payload += styledChar.getChar(); + } + if (currentCue.payload) { + topLevelCue.nestedCues.push(currentCue); + } + + // Add a linebreak since the row just ended. + if (i !== lastNonEmptyRow) { + const linebreakCue = shaka.cea.CeaUtils + .createLineBreakCue(startTime, endTime); + topLevelCue.nestedCues.push(linebreakCue); + } + + // Create a new cue. + currentCue = shaka.cea.CeaUtils.createStyledCue( + startTime, endTime, currentUnderline, currentItalics, + currentTextColor, currentBackgroundColor); + } + + if (topLevelCue.nestedCues.length) { + return { + cue: topLevelCue, + stream, + }; + } + + return null; + } + + /** + * @param {!number} startTime + * @param {!number} endTime + * @param {!boolean} underline + * @param {!boolean} italics + * @param {!string} txtColor + * @param {!string} bgColor + * @return {!shaka.text.Cue} + */ + static createStyledCue(startTime, endTime, underline, + italics, txtColor, bgColor) { + const cue = new shaka.text.Cue(startTime, endTime, /* payload= */ ''); + if (underline) { + cue.textDecoration.push(shaka.text.Cue.textDecoration.UNDERLINE); + } + if (italics) { + cue.fontStyle = shaka.text.Cue.fontStyle.ITALIC; + } + cue.color = txtColor; + cue.backgroundColor = bgColor; + return cue; + } + + /** + * @param {!number} startTime + * @param {!number} endTime + * @return {!shaka.text.Cue} + */ + static createLineBreakCue(startTime, endTime) { + const linebreakCue = new shaka.text.Cue( + startTime, endTime, /* payload= */ ''); + linebreakCue.spacer = true; + return linebreakCue; + } +}; + +shaka.cea.CeaUtils.StyledChar = class { + constructor(character, underline, italics, backgroundColor, textColor) { + /** + * @private {!string} + */ + this.character_ = character; + + /** + * @private {!boolean} + */ + this.underline_ = underline; + + /** + * @private {!boolean} + */ + this.italics_ = italics; + + /** + * @private {!string} + */ + this.backgroundColor_ = backgroundColor; + + /** + * @private {!string} + */ + this.textColor_ = textColor; + } + + /** + * @return {!string} + */ + getChar() { + return this.character_; + } + + /** + * @return {!boolean} + */ + isUnderlined() { + return this.underline_; + } + + /** + * @return {!boolean} + */ + isItalicized() { + return this.italics_; + } + + /** + * @return {!string} + */ + getBackgroundColor() { + return this.backgroundColor_; + } + + /** + * @return {!string} + */ + getTextColor() { + return this.textColor_; + } +}; + +/** + * Default background color for text. + * @const {!string} + */ +shaka.cea.CeaUtils.DEFAULT_BG_COLOR = 'black'; + +/** + * Default text color. + * @const {!string} + */ +shaka.cea.CeaUtils.DEFAULT_TXT_COLOR = 'white'; diff --git a/lib/cea/dtvcc_packet_builder.js b/lib/cea/dtvcc_packet_builder.js new file mode 100644 index 00000000000..705ec5fb24d --- /dev/null +++ b/lib/cea/dtvcc_packet_builder.js @@ -0,0 +1,167 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.cea.DtvccPacketBuilder'); +goog.provide('shaka.cea.DtvccPacket'); + + +// CEA-708 DTVCC Packet Builder. +// Builds packets based on Figure 5 CCP State Table in 5.2 of CEA-708-E. +// Initially, there is no packet. When a DTVCC_PACKET_START payload is received, +// a packet begins construction. The packet is considered "built" once all bytes +// indicated in the header are read, and ignored if a new packet starts building +// before the current packet is finished being built. +shaka.cea.DtvccPacketBuilder = class { + constructor() { + /** + * An array containing built DTVCC packets that are ready to be processed. + * @private {!Array} + */ + this.builtPackets_ = []; + + /** + * Stores the packet data for the current packet being processed, if any. + * @private {?Array} + */ + this.currentPacketBeingBuilt_ = null; + + /** + * Keeps track of the number of bytes left to add in the current packet. + * @private {!number} + */ + this.bytesLeftToAddInCurrentPacket_ = 0; + } + + /** + * @param {!shaka.cea.Cea708Service.Cea708Byte} cea708Byte + */ + addByte(cea708Byte) { + if (cea708Byte.type === shaka.cea.DtvccPacketBuilder.DTVCC_PACKET_START) { + // If there was a packet being built that finished, it would have + // already been added to the built packets when it finished. So if + // there's an open packet at this point, it must be unfinished. As + // per the spec, we don't deal with unfinished packets. So we ignore them. + + // A new packet should be opened. + const packetSize = cea708Byte.value & 0x3f; + + // As per spec, number of packet data bytes to follow is packetSize*2-1. + this.bytesLeftToAddInCurrentPacket_ = packetSize * 2 - 1; + this.currentPacketBeingBuilt_ = []; + return; + } + + if (!this.currentPacketBeingBuilt_) { + // There is no packet open. Then an incoming byte should not + // have come in at all. Ignore it. + return; + } + + if (this.bytesLeftToAddInCurrentPacket_ > 0) { + this.currentPacketBeingBuilt_.push(cea708Byte); + this.bytesLeftToAddInCurrentPacket_--; + } + + if (this.bytesLeftToAddInCurrentPacket_ === 0) { + // Current packet is complete and ready for processing. + const packet = new shaka.cea.DtvccPacket(this.currentPacketBeingBuilt_); + this.builtPackets_.push(packet); + this.currentPacketBeingBuilt_ = null; + this.bytesLeftToAddInCurrentPacket_ = 0; + } + } + + /** + * @return {!Array} + */ + getBuiltPackets() { + return this.builtPackets_; + } + + clearBuiltPackets() { + this.builtPackets_ = []; + } + + clear() { + this.builtPackets_ = []; + this.currentPacketBeingBuilt_ = []; + this.bytesLeftToAddInCurrentPacket_ = 0; + } +}; + + +shaka.cea.DtvccPacket = class { + /** + * @param {!Array} packetData + */ + constructor(packetData) { + /** + * Keeps track of the position to read the next byte from in the packet. + * @private {!number} + */ + this.pos_ = 0; + + /** + * Bytes that represent the data in the DTVCC packet. + * @private {!Array} + */ + this.packetData_ = packetData; + } + + /** + * @return {!boolean} + */ + hasMoreData() { + return this.pos_ < this.packetData_.length; + } + + /** + * @return {!number} + */ + getPosition() { + return this.pos_; + } + + /** + * Reads a byte from the packet. TODO CONSIDER RENAMING THIS TO BLOCK + * @return {!shaka.cea.Cea708Service.Cea708Byte} + * @throws {!shaka.util.Error} + */ + readByte() { + if (!this.hasMoreData()) { + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.TEXT, + shaka.util.Error.Code.BUFFER_READ_OUT_OF_BOUNDS); + } + return this.packetData_[this.pos_++]; + } + + /** + * Skips the provided number of blocks in the buffer. + * @param {!number} numBlocks + * @throws {!shaka.util.Error} + */ + skip(numBlocks) { + if (this.pos_ + numBlocks > this.packetData_.length) { + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.TEXT, + shaka.util.Error.Code.BUFFER_READ_OUT_OF_BOUNDS); + } + this.pos_ += numBlocks; + } +}; + +/** + * @const {!number} + */ +shaka.cea.DtvccPacketBuilder.DTVCC_PACKET_DATA = 2; + +/** + * @const {!number} + */ +shaka.cea.DtvccPacketBuilder.DTVCC_PACKET_START = 3; diff --git a/lib/dash/dash_parser.js b/lib/dash/dash_parser.js index 413a4c0f5ad..a76525a0842 100644 --- a/lib/dash/dash_parser.js +++ b/lib/dash/dash_parser.js @@ -768,22 +768,27 @@ shaka.dash.DashParser = class { for (const prop of accessibilities) { const schemeId = prop.getAttribute('schemeIdUri'); const value = prop.getAttribute('value'); - if (schemeId == 'urn:scte:dash:cc:cea-608:2015' || - schemeId == 'urn:scte:dash:cc:cea-708:2015') { + if (schemeId == 'urn:scte:dash:cc:cea-608:2015' ) { let channelId = 1; if (value != null) { - for (const captionStr of value.split(';')) { + const channelAssignments = value.split(';'); + for (const captionStr of channelAssignments) { let channel; let language; // Some closed caption descriptions have channel number and - // language, like "CC1=eng" or "1=lang:eng", others may only have - // the language, like "eng". + // language ("CC1=eng") others may only have language ("eng,spa"). if (!captionStr.includes('=')) { - // Since only odd numbers are used as channel numbers, like CC1, - // CC3, CC5, etc, when the channel number is not provided, use an - // odd number as the key. https://en.wikipedia.org/wiki/EIA-608 - channel = 'CC' + channelId; - channelId += 2; + // When the channel assignemnts are not explicitly provided and + // there are only 2 values provided, it is highly likely that the + // assignments are CC1 and CC3 (most commonly used CC streams). + // Otherwise, cycle through all channels arbitrarily (CC1 - CC4) + // in order of provided langs. + channel = `CC${channelId}`; + if (channelAssignments.length == 2) { + channelId += 2; + } else { + channelId ++; + } language = captionStr; } else { const channelAndLanguage = captionStr.split('='); @@ -791,11 +796,9 @@ shaka.dash.DashParser = class { // If the channel info only has channel number(like '1'), add 'CC' // as prefix so that it can be a full channel id (like 'CC1'). channel = channelAndLanguage[0].startsWith('CC') ? - channelAndLanguage[0] : 'CC' + channelAndLanguage[0]; - // The language info can be different formats, like 'eng', - // 'lang:eng', or 'lang:eng,war:1,er:1'. Extract the language info - // and convert it to 2-letter language code format. - language = channelAndLanguage[1].split(',')[0].split(':').pop(); + channelAndLanguage[0] : `CC${channelAndLanguage[0]}`; + + language = channelAndLanguage[1]; // 3 letters (ISO 639-2) } closedCaptions.set(channel, LanguageUtils.normalize(language)); } @@ -804,6 +807,36 @@ shaka.dash.DashParser = class { // 'CC1' as channel id and 'und' as language info. closedCaptions.set('CC1', 'und'); } + } else if (schemeId == 'urn:scte:dash:cc:cea-708:2015') { + let serviceNumber = 1; + if (value != null) { + for (const captionStr of value.split(';')) { + let service; + let language; + // Similar to CEA-608, it is possible that service # assignments + // are not explicitly provided e.g. "eng;deu;swe" In this case, + // we just cycle through the services for each language one by one. + if (!captionStr.includes('=')) { + service = `svc${serviceNumber}`; + serviceNumber ++; + language = captionStr; + } else { + // Otherwise, CEA-708 caption values take the form " + // 1=lang:eng;2=lang:deu" i.e. serviceNumber=lang:threelettercode. + const serviceAndLanguage = captionStr.split('='); + service = `svc${serviceAndLanguage[0]}`; + + // The language info can be different formats, lang:eng', + // or 'lang:eng,war:1,er:1'. Extract the language info. + language = serviceAndLanguage[1].split(',')[0].split(':').pop(); + } + closedCaptions.set(service, LanguageUtils.normalize(language)); + } + } else { + // If service and language information has not been provided, assign + // 'svc1' as service number and 'und' as language info. + closedCaptions.set('svc1', 'und'); + } } else if (schemeId == 'urn:mpeg:dash:role:2011') { // See DASH IOP 3.9.2 Table 4. if (value != null) { diff --git a/lib/media/closed_caption_parser.js b/lib/media/closed_caption_parser.js index 4ac5afe156d..b5fa738252f 100644 --- a/lib/media/closed_caption_parser.js +++ b/lib/media/closed_caption_parser.js @@ -7,6 +7,8 @@ goog.provide('shaka.media.IClosedCaptionParser'); goog.provide('shaka.media.ClosedCaptionParser'); +goog.require('shaka.cea.CeaDecoder'); +goog.require('shaka.cea.Mp4CeaParser'); goog.require('shaka.util.BufferUtils'); diff --git a/lib/media/streaming_engine.js b/lib/media/streaming_engine.js index 7478fce848e..fe7a35dba01 100644 --- a/lib/media/streaming_engine.js +++ b/lib/media/streaming_engine.js @@ -1600,9 +1600,12 @@ shaka.media.StreamingEngine = class { */ static isEmbeddedText_(mediaState) { const MimeUtils = shaka.util.MimeUtils; + const CEA608_MIME = MimeUtils.CEA608_CLOSED_CAPTION_MIMETYPE; + const CEA708_MIME = MimeUtils.CEA708_CLOSED_CAPTION_MIMETYPE; return mediaState && mediaState.type == shaka.util.ManifestParserUtils.ContentType.TEXT && - mediaState.stream.mimeType == MimeUtils.CLOSED_CAPTION_MIMETYPE; + (mediaState.stream.mimeType == CEA608_MIME || + mediaState.stream.mimeType == CEA708_MIME); } diff --git a/lib/player.js b/lib/player.js index 5546b5532ea..07c18422cf6 100644 --- a/lib/player.js +++ b/lib/player.js @@ -4004,13 +4004,15 @@ shaka.Player = class extends shaka.util.FakeEventTarget { */ makeTextStreamsForClosedCaptions_(manifest) { const ContentType = shaka.util.ManifestParserUtils.ContentType; - const MimeUtils = shaka.util.MimeUtils; const TextStreamKind = shaka.util.ManifestParserUtils.TextStreamKind; + const CEA608_MIME = shaka.util.MimeUtils.CEA608_CLOSED_CAPTION_MIMETYPE; + const CEA708_MIME = shaka.util.MimeUtils.CEA708_CLOSED_CAPTION_MIMETYPE; // A set, to make sure we don't create two text streams for the same video. const closedCaptionsSet = new Set(); for (const textStream of manifest.textStreams) { - if (textStream.mimeType == MimeUtils.CLOSED_CAPTION_MIMETYPE) { + if (textStream.mimeType == CEA608_MIME || + textStream.mimeType == CEA708_MIME) { // This function might be called on a manifest update, so don't make a // new text stream for closed caption streams we have seen before. closedCaptionsSet.add(textStream.originalId); @@ -4021,6 +4023,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { if (video && video.closedCaptions) { for (const id of video.closedCaptions.keys()) { if (!closedCaptionsSet.has(id)) { + const mimeType = id.startsWith('CC') ? CEA608_MIME : CEA708_MIME; + // Add an empty segmentIndex, for the benefit of the period combiner // in our builtin DASH parser. const segmentIndex = new shaka.media.MetaSegmentIndex(); @@ -4029,7 +4033,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { originalId: id, // The CC ID string, like 'CC1', 'CC3', etc. createSegmentIndex: () => Promise.resolve(), segmentIndex, - mimeType: MimeUtils.CLOSED_CAPTION_MIMETYPE, + mimeType, codecs: '', kind: TextStreamKind.CLOSED_CAPTION, encrypted: false, diff --git a/lib/text/text_engine.js b/lib/text/text_engine.js index 65fec5b73e9..f682f3d705a 100644 --- a/lib/text/text_engine.js +++ b/lib/text/text_engine.js @@ -86,8 +86,9 @@ shaka.text.TextEngine = class { // An actual parser is available. return true; } - if (mimeType == shaka.util.MimeUtils.CLOSED_CAPTION_MIMETYPE) { - // Will be handled internally by our Closed Caption parser. + if (mimeType == shaka.util.MimeUtils.CEA608_CLOSED_CAPTION_MIMETYPE || + mimeType == shaka.util.MimeUtils.CEA708_CLOSED_CAPTION_MIMETYPE ) { + // Closed captions. return true; } return false; @@ -122,7 +123,8 @@ shaka.text.TextEngine = class { initParser(mimeType) { // No parser for CEA, which is extracted from video and side-loaded // into TextEngine and TextDisplayer. - if (mimeType == shaka.util.MimeUtils.CLOSED_CAPTION_MIMETYPE) { + if (mimeType == shaka.util.MimeUtils.CEA608_CLOSED_CAPTION_MIMETYPE || + mimeType == shaka.util.MimeUtils.CEA708_CLOSED_CAPTION_MIMETYPE) { return; } diff --git a/lib/util/mime_utils.js b/lib/util/mime_utils.js index bdaccb70440..102c643886f 100644 --- a/lib/util/mime_utils.js +++ b/lib/util/mime_utils.js @@ -108,8 +108,13 @@ shaka.util.MimeUtils.EXTENDED_MIME_PARAMETERS_ = new Map() /** - * A mimetype created for CEA closed captions. + * A mimetype created for CEA-608 closed captions. * @const {string} */ -shaka.util.MimeUtils.CLOSED_CAPTION_MIMETYPE = 'application/cea-608'; +shaka.util.MimeUtils.CEA608_CLOSED_CAPTION_MIMETYPE = 'application/cea-608'; +/** + * A mimetype created for CEA-708 closed captions. + * @const {string} + */ +shaka.util.MimeUtils.CEA708_CLOSED_CAPTION_MIMETYPE = 'application/cea-708'; diff --git a/lib/util/stream_utils.js b/lib/util/stream_utils.js index 3d764dd9044..e66a4299817 100644 --- a/lib/util/stream_utils.js +++ b/lib/util/stream_utils.js @@ -459,7 +459,7 @@ shaka.util.StreamUtils = class { */ static html5TextTrackToTrack(textTrack) { const CLOSED_CAPTION_MIMETYPE = - shaka.util.MimeUtils.CLOSED_CAPTION_MIMETYPE; + shaka.util.MimeUtils.CEA608_CLOSED_CAPTION_MIMETYPE; const StreamUtils = shaka.util.StreamUtils; /** @type {shaka.extern.Track} */ diff --git a/shaka-player.uncompiled.js b/shaka-player.uncompiled.js index dfcf7457a7d..9f36119929e 100644 --- a/shaka-player.uncompiled.js +++ b/shaka-player.uncompiled.js @@ -15,10 +15,6 @@ goog.require('shaka.abr.SimpleAbrManager'); goog.require('shaka.ads.AdManager'); goog.require('shaka.cast.CastProxy'); goog.require('shaka.cast.CastReceiver'); -goog.require('shaka.cea.CeaDecoder'); -goog.require('shaka.cea.Cea608DataChannel'); -goog.require('shaka.cea.Cea608Memory'); -goog.require('shaka.cea.Mp4CeaParser'); goog.require('shaka.dash.DashParser'); goog.require('shaka.hls.HlsParser'); goog.require('shaka.log'); diff --git a/test/cea/cea608_memory_unit.js b/test/cea/cea608_memory_unit.js index 4a56e8838e0..de71638e4fe 100644 --- a/test/cea/cea608_memory_unit.js +++ b/test/cea/cea608_memory_unit.js @@ -117,12 +117,12 @@ describe('Cea608Memory', () => { CeaUtils.createStyledCue(startTime, endTime, expectedText, /* underline= */ true, /* italics= */ true, /* textColor= */ 'red', - /* backgroundColor= */ shaka.cea.Cea608Memory.DEFAULT_BG_COLOR), + /* backgroundColor= */ shaka.cea.CeaUtils.DEFAULT_BG_COLOR), CeaUtils.createStyledCue(startTime, endTime, expectedText, /* underline= */ false, /* italics= */ false, /* textColor= */ 'red', - /* backgroundColor= */ shaka.cea.Cea608Memory.DEFAULT_BG_COLOR), + /* backgroundColor= */ shaka.cea.CeaUtils.DEFAULT_BG_COLOR), ]; const expectedCaption = { diff --git a/test/cea/cea708_service_unit.js b/test/cea/cea708_service_unit.js new file mode 100644 index 00000000000..cbcaa9c646a --- /dev/null +++ b/test/cea/cea708_service_unit.js @@ -0,0 +1,674 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +describe('Cea708Service', () => { + const CeaUtils = shaka.test.CeaUtils; + + /** @type {!shaka.cea.Cea708Service} */ + let service; + + /** + * Hide window (2 bytes), with a bitmap provided to indicate all windows. + * @type {!Array} + */ + const hideWindow = [0x8a, 0xff]; + + /** + * Define window (7 bytes), defines window #0 to be a visible window + * with 32 rows and 32 columns. (We specify 31 for each since decoder adds 1). + * @type {!Array} + */ + const defineWindow = [ + 0x98, 0x38, 0x00, 0x00, 0x1f, 0x1f, 0x00, + ]; + + /** @type {!number} */ + const startTime = 1; + + /** @type {!number} */ + const endTime = 2; + + /** + * We arbitrarily pick service 1 for all of these tests. + * @type {!number} + */ + const serviceNumber = 1; + + /** @type {!string} */ + const stream = `svc${serviceNumber}`; + + /** + * Takes in a array of bytes and a presentation timestamp (in seconds), + * and converts it into a CEA-708 DTVCC Packet. + * @param {!Array} bytes + * @param {!number} pts + */ + const createCea708PacketFromBytes = (bytes, pts) => { + const cea708Bytes = bytes.map((code, i) => { + return { + pts, + type: shaka.cea.DtvccPacketBuilder.DTVCC_PACKET_DATA, + value: code, + order: i, + }; + }); + return new shaka.cea.DtvccPacket(cea708Bytes); + }; + + /** + * Takes in a CEA-708 service and array of 708 packets with control codes, + * and returns all the captions inside of them, using the service to decode. + * @param {!shaka.cea.Cea708Service} service + * @param {...!shaka.cea.DtvccPacket} packets + */ + const getCaptionsFromPackets = (service, ...packets) => { + const captions = []; + for (const packet of packets) { + while (packet.hasMoreData()) { + const caption = service.handleCea708ControlCode(packet); + if (caption) { + captions.push(caption); + } + } + } + return captions; + }; + + beforeEach(() => { + service = new shaka.cea.Cea708Service(serviceNumber); + }); + + it('decodes regular unstyled caption text', () => { + const controlCodes = [ + ...defineWindow, + // Series of G0 control codes that add text. + 0x74, 0x65, 0x73, 0x74, // t, e, s, t + ]; + + const packet1 = createCea708PacketFromBytes(controlCodes, startTime); + const packet2 = createCea708PacketFromBytes(hideWindow, endTime); + + const text = 'test'; + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text), + ]; + + const expectedCaptions = [ + { + stream, + cue: topLevelCue, + }, + ]; + + const captions = getCaptionsFromPackets(service, packet1, packet2); + expect(captions).toEqual(expectedCaptions); + }); + + it('setPenLocation sets the pen location correctly', () => { + const controlCodes = [ + ...defineWindow, + // Series of G0 control codes that add text. + 0x74, 0x65, 0x73, 0x74, // t, e, s, t + + // SetPenLocation command to move the pen to (2, 0) + 0x92, 0x02, 0x00, + + // Series of G0 control codes that add text. + 0x74, 0x65, 0x73, 0x74, // t, e, s, t + ]; + + const packet1 = createCea708PacketFromBytes(controlCodes, startTime); + const packet2 = createCea708PacketFromBytes(hideWindow, endTime); + + // After decoding, the buffer should look like this (omitting null cells). + // [0]: test + // [1]: + // [2]: test + const text = 'test'; + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text), + ]; + + const expectedCaptions = [ + { + stream, + cue: topLevelCue, + }, + ]; + + const captions = getCaptionsFromPackets(service, packet1, packet2); + expect(captions).toEqual(expectedCaptions); + }); + + it('setPenAttributes sets underline and italics correctly', () => { + const controlCodes = [ + ...defineWindow, + // Series of G0 control codes that add text. + 0x74, 0x65, 0x73, 0x74, // t, e, s, t + + // setPenAttributes. First byte is a "don't care", since this + // decoder ignores it. First 2 bits of second byte are italics + // and underline toggles. Turn on italics + underline. + 0x90, 0x00, 0xc0, + + // Series of G0 control codes that add text. + 0x74, 0x65, 0x73, 0x74, // t, e, s, t + + // setPenAttributes. Turn off italics + underline. + 0x90, 0x00, 0x00, + + // Series of G0 control codes that add text. + 0x74, 0x65, 0x73, 0x74, // t, e, s, t + ]; + + const packet1 = createCea708PacketFromBytes(controlCodes, startTime); + const packet2 = createCea708PacketFromBytes(hideWindow, endTime); + + // Three nested cues, where the middle one should be underlined+italicized. + const text = 'test'; + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text), + CeaUtils.createStyledCue( + startTime, endTime, text, + /* underline= */ true, /* italics= */ true, + /* textColor= */ shaka.cea.CeaUtils.DEFAULT_TXT_COLOR, + /* backgroundColor= */ shaka.cea.CeaUtils.DEFAULT_BG_COLOR), + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text), + ]; + + const expectedCaptions = [ + { + stream, + cue: topLevelCue, + }, + ]; + + const captions = getCaptionsFromPackets(service, packet1, packet2); + expect(captions).toEqual(expectedCaptions); + }); + + it('setPenColor sets foreground and background color correctly', () => { + const controlCodes = [ + ...defineWindow, + // Series of G0 control codes that add text. + 0x74, 0x65, 0x73, 0x74, // t, e, s, t + + // setPenColor (4 bytes). Last 6 bits of byte 2 are R,G,B for foreground. + // Last 6 bits of byte 3 are R,G,B for background. This decoder ignores + // byte 4 which is edge color, so it's a "don't care". + 0x91, 0x30, 0x33, 0x00, // Red foreground, magenta background. + + // Series of G0 control codes that add text. + 0x63, 0x6f, 0x6c, 0x6f, 0x72, // c, o, l, o, r + ]; + + const packet1 = createCea708PacketFromBytes(controlCodes, startTime); + const packet2 = createCea708PacketFromBytes(hideWindow, endTime); + + // Two nested cues, the second one should have colors. + const text1 = 'test'; + const text2 = 'color'; + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text1), + CeaUtils.createStyledCue( + startTime, endTime, text2, + /* underline= */ false, /* italics= */ false, + /* textColor= */ 'red', /* backgroundColor= */ 'magenta'), + ]; + + const expectedCaptions = [ + { + stream, + cue: topLevelCue, + }, + ]; + + const captions = getCaptionsFromPackets(service, packet1, packet2); + expect(captions).toEqual(expectedCaptions); + }); + + it('handles special characters from the G0, G1, G2, and G3 groups', () => { + const controlCodes = [ + ...defineWindow, + // Series of G0 text control code + 0x7f, // A musical note, the only exception the G0 table has to ASCII. + + // setPenLocation (1, 0) to go to next row. + 0x92, 0x01, 0x00, + + // Series of G1 control codes that add text. + 0xa9, 0xb6, 0xf7, // ©, ¶, ÷ + + // setPenLocation (2, 0) to go to next row. + 0x92, 0x02, 0x00, + + // Series of G2 control codes that add text. + 0x1079, 0x107b, 0x1039, // ⅞, ┐, ™ + + // setPenLocation (3, 0) to go to next row. + 0x92, 0x03, 0x00, + + // G3 control code. + 0x10a0, // As of CEA-708-E, there is only 1 char in G3, on 0xa0. + ]; + + const packet1 = createCea708PacketFromBytes(controlCodes, startTime); + const packet2 = createCea708PacketFromBytes(hideWindow, endTime); + + const text1 = '♪'; + const text2 = '©¶÷'; + const text3 = '⅞┐™'; + const text4 = '[CC]'; + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text1), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text2), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text3), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text4), + ]; + + const expectedCaptions = [ + { + stream, + cue: topLevelCue, + }, + ]; + + const captions = getCaptionsFromPackets(service, packet1, packet2); + expect(captions).toEqual(expectedCaptions); + }); + + it('adds an underline for unsupported chars from the G2/G3 groups', () => { + const controlCodes = [ + ...defineWindow, + // Series of G2 control codes that add text. + 0x1036, 0x103c, 0x1070, // unsupported, œ, unsupported + + // setPenLocation (1, 0) to go to next row. + 0x92, 0x01, 0x00, + + // Series of G3 control codes that add text. + 0x10a0, 0x10a1, 0x10db, // [CC], unsupported, unsupported + ]; + + const packet1 = createCea708PacketFromBytes(controlCodes, startTime); + const packet2 = createCea708PacketFromBytes(hideWindow, endTime); + + // Some of the characters are unsupported as of CEA-708-E, so they should + // be replaced by an underline. + const text1 = '_œ_'; + const text2 = '[CC]__'; + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text1), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text2), + ]; + + const expectedCaptions = [ + { + stream, + cue: topLevelCue, + }, + ]; + const captions = getCaptionsFromPackets(service, packet1, packet2); + expect(captions).toEqual(expectedCaptions); + }); + + it('handles the reset command correctly', () => { + const controlCodes = [ + ...defineWindow, + // Series of G0 control codes that add text. + 0x74, 0x65, 0x73, 0x74, // t, e, s, t + ]; + + const resetControlCode = [0x8f]; + + const packet1 = createCea708PacketFromBytes(controlCodes, startTime); + const packet2 = createCea708PacketFromBytes(resetControlCode, endTime); + + // The text in the current window should have been emitted, and then clear + // should have been called. + const text = 'test'; + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text), + ]; + + const expectedCaptions = [ + { + stream, + cue: topLevelCue, + }, + ]; + + spyOn(service, 'clear').and.callThrough(); + const captions = getCaptionsFromPackets(service, packet1, packet2); + expect(captions).toEqual(expectedCaptions); + expect(service.clear).toHaveBeenCalledTimes(1); + }); + + it('handles the setWindowAttributes command correctly', () => { + const controlCodes = [ + ...defineWindow, + // Series of G0 control codes that add text. + 0x74, 0x65, 0x73, 0x74, // t, e, s, t + + // Currently, setWindowAttributes is only used to justify text, + // as specified by the last 2 bits of the fourth byte. The + // other bytes after the first byte are "don't care". + 0x97, 0x00, 0x00, 0x01, 0x00, // Justify right + ]; + + const packet1 = createCea708PacketFromBytes(controlCodes, startTime); + const packet2 = createCea708PacketFromBytes(hideWindow, endTime); + + // Right-justified text is expected. + const text = 'test'; + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.textAlign = shaka.text.Cue.textAlign.RIGHT; + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text), + ]; + + const expectedCaptions = [ + { + stream, + cue: topLevelCue, + }, + ]; + + const captions = getCaptionsFromPackets(service, packet1, packet2); + expect(captions).toEqual(expectedCaptions); + }); + + it('handles the carriage return command correctly', () => { + const controlCodes = [ + ...defineWindow, + // Series of G0 control codes that add text. + 0x74, 0x65, // t, e, + + // Carriage return. + 0x0d, + + // Series of G0 control codes that add text. + 0x73, 0x74, // s, t + ]; + + const packet1 = createCea708PacketFromBytes(controlCodes, startTime); + const packet2 = createCea708PacketFromBytes(hideWindow, endTime); + + const text1 = 'te'; + const text2 = 'st'; + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text1), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text2), + ]; + + const expectedCaptions = [ + { + stream, + cue: topLevelCue, + }, + ]; + + const captions = getCaptionsFromPackets(service, packet1, packet2); + expect(captions).toEqual(expectedCaptions); + }); + + it('handles the horizontal carriage return command correctly', () => { + const controlCodes = [ + ...defineWindow, + // Series of G0 control codes that add text. + 0x74, 0x65, // t, e, + + // setPenLocation (1, 0) to go to next row. + 0x92, 0x01, 0x00, + + // Series of G0 control codes that add text. + 0x6d, 0x70, // m, p + + // Horizontal Carriage return. + 0x0e, + + // Series of G0 control codes that add text. + 0x73, 0x74, // s, t + ]; + + const packet1 = createCea708PacketFromBytes(controlCodes, startTime); + const packet2 = createCea708PacketFromBytes(hideWindow, endTime); + + // HCR wipes the row and moves the pen to the row start. + const text1 = 'te'; + const text2 = 'st'; + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text1), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text2), + ]; + + const expectedCaptions = [ + { + stream, + cue: topLevelCue, + }, + ]; + + const captions = getCaptionsFromPackets(service, packet1, packet2); + expect(captions).toEqual(expectedCaptions); + }); + + it('handles the ASCII backspace command correctly', () => { + const controlCodes = [ + ...defineWindow, + // Series of G0 control codes that add text. + 0x74, 0x65, 0x73, 0x74, // t, e, s, t + + // Backspace. + 0x08, + ]; + + const packet1 = createCea708PacketFromBytes(controlCodes, startTime); + const packet2 = createCea708PacketFromBytes(hideWindow, endTime); + + // Backspace should have erased the last 't' in 'test'. + const text = 'tes'; + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text), + ]; + + const expectedCaptions = [ + { + stream, + cue: topLevelCue, + }, + ]; + + const captions = getCaptionsFromPackets(service, packet1, packet2); + expect(captions).toEqual(expectedCaptions); + }); + + it('handles the ASCII form-feed command correctly', () => { + const controlCodes = [ + ...defineWindow, + // Series of G0 control codes that add text. + 0x61, 0x62, // a, b, + + // setPenLocation (1, 0) to go to next row. + 0x92, 0x01, 0x00, + + // Series of G0 control codes that add text. + 0x62, 0x61, // b, a + + // Form-feed. + 0x0c, + + // Series of G0 control codes that add text. + 0x74, 0x65, 0x73, 0x74, // t, e, s, t + ]; + + const packet1 = createCea708PacketFromBytes(controlCodes, startTime); + const packet2 = createCea708PacketFromBytes(hideWindow, endTime); + + // The form feed control code would have wiped the entire window + // including new lines, and the text after is just 'test'. + const text = 'test'; + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text), + ]; + + const expectedCaptions = [ + { + stream, + cue: topLevelCue, + }, + ]; + + const captions = getCaptionsFromPackets(service, packet1, packet2); + + expect(captions).toEqual(expectedCaptions); + }); + + it('handles C2 and C3 no-op control codes correctly', () => { + // As of CEA-708, the C2 and C3 control code group has no operations. + // However, the bytes are reserved for future modifications to the spec, + // and so the correct # of bytes should be skipped if they are seen. + const packets = [ + // C2 control code data. + [0x1008, 0x00], // C2 Packet 1. + [0x1010, 0x00, 0x00], // C2 Packet 2. + [0x1018, 0x00, 0x00, 0x00], // C2 Packet 3. + + // C3 control code data. + [0x1080, 0x00, 0x00, 0x00, 0x00], // C3 packet 1. + [0x1088, 0x00, 0x00, 0x00, 0x00, 0x00], // C3 packet 2. + ]; + const expectedSkips = [1, 2, 3, 4, 5]; // As per the CEA-708-E spec. + + for (let i = 0; i < packets.length; i++) { + const packet = createCea708PacketFromBytes(packets[i], /* pts= */ 1); + spyOn(packet, 'skip'); + getCaptionsFromPackets(service, packet); + expect(packet.skip).toHaveBeenCalledWith(expectedSkips[i]); + } + }); + + describe('handles commands that change the display of windows', () => { + const time1 = 1; + const time2 = 2; + const time3 = 4; + const time4 = 5; + const textControlCodes = [ + // Series of G0 control codes that add text. + 0x74, 0x65, 0x73, 0x74, // t, e, s, t + ]; + + // These commands affect ALL windows, per the 0xff bitmap. + const toggleWindow = [0x8b, 0xff]; + const displayWindow = [0x89, 0xff]; + const deleteWindow = [0x8c, 0xff]; + const clearWindow = [0x88, 0xff]; + + it('handles display, toggle, and delete commands on windows', () => { + // Define a visible window, add some text, and toggle it off, + // which should force the window to emit the caption, 'test'. + const packet1 = createCea708PacketFromBytes(defineWindow, time1); + const packet2 = createCea708PacketFromBytes(textControlCodes, time1); + const packet3 = createCea708PacketFromBytes(toggleWindow, time2); + + // Window is now hidden. Turn it back on at time 3, and append + // more text to it. + const packet4 = createCea708PacketFromBytes(displayWindow, time3); + const packet5 = createCea708PacketFromBytes(textControlCodes, time3); + + // Window is now being displayed. Delete all the windows. + // This should force the displayed window to emit the caption, 'testtest'. + const packet6 = createCea708PacketFromBytes(deleteWindow, time4); + + const text1 = 'test'; + const text2 = 'testtest'; + const topLevelCue1 = new shaka.text.Cue( + /* startTime= */ time1, /* endTime= */ time2, ''); + topLevelCue1.nestedCues = [ + CeaUtils.createDefaultCue( + /* startTime= */ time1, /* endTime= */ time2, /* payload= */ text1), + ]; + + const topLevelCue2 = new shaka.text.Cue( + /* startTime= */ time3, /* endTime= */ time4, ''); + topLevelCue2.nestedCues = [ + CeaUtils.createDefaultCue( + /* startTime= */ time3, /* endTime= */ time4, /* payload= */ text2), + ]; + + const expectedCaptions = [ + { + stream, + cue: topLevelCue1, + }, + + { + stream, + cue: topLevelCue2, + }, + ]; + + const captions = getCaptionsFromPackets( + service, packet1, packet2, packet3, packet4, packet5, packet6); + expect(captions).toEqual(expectedCaptions); + }); + + it('handles the clear command on a window', () => { + // Define a visible window, add text to it, and then clear it. + // This should emit a caption, since a visible window is being cleared. + const packet1 = createCea708PacketFromBytes(defineWindow, time1); + const packet2 = createCea708PacketFromBytes(textControlCodes, time1); + const packet3 = createCea708PacketFromBytes(clearWindow, time2); + + // Display the window again, and then hide it. Although a visible window + // that turns off usually emits, this should NOT emit a caption, since + // the window contains nothing in it after the clear. + const packet4 = createCea708PacketFromBytes(displayWindow, time3); + const packet5 = createCea708PacketFromBytes(textControlCodes, time3); + const packet6 = createCea708PacketFromBytes(hideWindow, time1); + + // Only one cue should have been emitted as per the explanation above. + const text = 'test'; + const topLevelCue = new shaka.text.Cue( + /* startTime= */ time1, /* endTime= */ time2, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue( + /* startTime= */ time1, /* endTime= */ time2, /* payload= */ text), + ]; + + const expectedCaptions = [ + { + stream, + cue: topLevelCue, + }, + ]; + + const captions = getCaptionsFromPackets(service, packet1, packet2, + packet3, packet4, packet5, packet6); + expect(captions).toEqual(expectedCaptions); + }); + }); +}); diff --git a/test/cea/cea708_window_unit.js b/test/cea/cea708_window_unit.js new file mode 100644 index 00000000000..8bed04b5d43 --- /dev/null +++ b/test/cea/cea708_window_unit.js @@ -0,0 +1,373 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +describe('Cea708Window', () => { + const CeaUtils = shaka.test.CeaUtils; + + /** @type {!shaka.cea.Cea708Window} */ + let window; + + /** @type {!number} */ + const serviceNumber = 1; // We will arbitrarily pick service 1 for all tests. + + /** @type {!string} */ + const stream = `svc${serviceNumber}`; + + /** @type {!number} */ + const rowCount = 10; + + /** @type {!number} */ + const colCount = 32; + + /** @type {!number} */ + const startTime = 1; + + /** @type {!number} */ + const endTime = 2; + + beforeEach(() => { + window = new shaka.cea.Cea708Window(/* windowNum= */ 0); + window.defineWindow( + /* visible= */ true, /* verticalAnchor= */ 0, + /* horAnchor= */ 0, /* anchorId= */ 0, /* relativeToggle= */ false, + rowCount, colCount); + window.setStartTime(startTime); + }); + + it('adds and emits a series of characters from the buffer', () => { + const text = 'test word'; + for (const c of text) { + window.setCharacter(c); + } + + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, text), + ]; + + const caption = window.forceEmit(endTime, serviceNumber); + const expectedCaption = { + stream, + cue: topLevelCue, + }; + + expect(caption).toEqual(expectedCaption); + }); + + describe('handles carriage returns', () => { + it('handles a regular carriage return', () => { + const text1 = 'test'; + const text2 = 'word'; + for (const c of text1) { + window.setCharacter(c); + } + window.carriageReturn(); + for (const c of text2) { + window.setCharacter(c); + } + + const caption = window.forceEmit(endTime, serviceNumber); + + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, text1), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createDefaultCue(startTime, endTime, text2), + ]; + + const expectedCaption = { + stream, + cue: topLevelCue, + }; + + expect(caption).toEqual(expectedCaption); + }); + + it('handles a carriage return on the last row of the buffer', () => { + const text1 = 'test'; + const text2 = 'word'; + + // Set the pen lcoation to the very last row in the buffer. + window.setPenLocation(/* row= */ rowCount-1, /* col= */ 0); + + for (const c of text1) { + window.setCharacter(c); + } + window.carriageReturn(); + for (const c of text2) { + window.setCharacter(c); + } + + const caption = window.forceEmit(endTime, serviceNumber); + + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, text1), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createDefaultCue(startTime, endTime, text2), + ]; + + const expectedCaption = { + stream, + cue: topLevelCue, + }; + + expect(caption).toEqual(expectedCaption); + }); + + it('handles a horizontal carriage return', () => { + const text = 'test'; + + for (const c of text) { + window.setCharacter(c); + } + window.horizontalCarriageReturn(); + + const caption = window.forceEmit(endTime, serviceNumber); + + // Nothing should have emitted, a horizontal carriage return wipes the row + // and sets the column position to the beginning of the row. + expect(caption).toBe(null); + }); + }); + + it('handles pen styling including colors, underlines, italics', () => { + const text1 = 'style1'; + const text2 = 'style2'; + const text3 = 'style3'; + const textColor1 = 'red'; + const textColor2 = 'yellow'; + const backgroundColor1 = 'blue'; + const backgroundColor2 = 'magenta'; + // Set the pen to an underlined, italicized red color. + window.setPenItalics(true); + window.setPenUnderline(true); + window.setPenTextColor('red'); + for (const c of text1) { + window.setCharacter(c); + } + + // Remove the underline and italics, and set the background color to blue. + window.setPenItalics(false); + window.setPenUnderline(false); + window.setPenBackgroundColor('blue'); + for (const c of text2) { + window.setCharacter(c); + } + + // Turn underline on again, make the text yellow and background magenta. + window.setPenUnderline(true); + window.setPenTextColor('yellow'); + window.setPenBackgroundColor('magenta'); + for (const c of text3) { + window.setCharacter(c); + } + + // These three stylings should correspond to three nested cues. + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createStyledCue(startTime, endTime, text1, /* underline= */ true, + /* italics= */ true, textColor1, + shaka.cea.CeaUtils.DEFAULT_BG_COLOR), + CeaUtils.createStyledCue(startTime, endTime, text2, + /* underline= */ false, /* italics= */ false, + textColor1, backgroundColor1), + CeaUtils.createStyledCue(startTime, endTime, text3, /* underline= */ true, + /* italics= */ false, textColor2, backgroundColor2), + ]; + + const caption = window.forceEmit(endTime, serviceNumber); + const expectedCaption = { + stream, + cue: topLevelCue, + }; + + expect(caption).toEqual(expectedCaption); + }); + + describe('handles justification of cues', () => { + const text = 'test'; + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + it('justifies the text left', () => { + for (const c of text) { + window.setCharacter(c); + } + + // Left-justified. + window.setJustification(shaka.cea.Cea708Window.TextJustification.LEFT); + topLevelCue.textAlign = shaka.text.Cue.textAlign.LEFT; + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, text), + ]; + const expectedCaption = { + stream, + cue: topLevelCue, + }; + + const caption = window.forceEmit(endTime, serviceNumber); + expect(caption).toEqual(expectedCaption); + }); + + it('justifies the text right', () => { + for (const c of text) { + window.setCharacter(c); + } + + // Right-justified. + window.setJustification(shaka.cea.Cea708Window.TextJustification.RIGHT); + topLevelCue.textAlign = shaka.text.Cue.textAlign.RIGHT; + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, text), + ]; + const expectedCaption = { + stream, + cue: topLevelCue, + }; + + const caption = window.forceEmit(endTime, serviceNumber); + expect(caption).toEqual(expectedCaption); + }); + + it('default justification should be centered', () => { + for (const c of text) { + window.setCharacter(c); + } + topLevelCue.textAlign = shaka.text.Cue.textAlign.CENTER; + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, text), + ]; + const expectedCaption = { + stream, + cue: topLevelCue, + }; + + const caption = window.forceEmit(endTime, serviceNumber); + expect(caption).toEqual(expectedCaption); + }); + }); + + it('resets the pen correctly', () => { + const text1 = 'abcd'; + const text2 = 'efgh'; + + // Set some styles on the pen and add the first text to window. + window.setPenUnderline(true); + window.setPenBackgroundColor('blue'); + for (const c of text1) { + window.setCharacter(c); + } + + // Reset the pen and add the second text to window. + window.resetPen(); + for (const c of text2) { + window.setCharacter(c); + } + + // The second text should have overwritten the first text, + // and all the styles should have been cleared. + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, text2), + ]; + const caption = window.forceEmit(endTime, serviceNumber); + const expectedCaption = { + stream, + cue: topLevelCue, + }; + expect(caption).toEqual(expectedCaption); + }); + + it('handles the backspace command to backspace text correctly', () => { + const text = 'testt'; + const backspacedText = 'test'; + + for (const c of text) { + window.setCharacter(c); + } + window.backspace(); + + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, backspacedText), + ]; + const caption = window.forceEmit(endTime, serviceNumber); + const expectedCaption = { + stream, + cue: topLevelCue, + }; + expect(caption).toEqual(expectedCaption); + }); + + it('correctly sets pen location', () => { + const text1 = 'test'; + const text2 = 'word'; + const text3 = 'on new line'; + for (const c of text1) { + window.setCharacter(c); + } + + window.setPenLocation(/* row= */ 0, /* col= */ 6); + for (const c of text2) { + window.setCharacter(c); + } + + window.setPenLocation(/* row= */ 3, /* col= */ 0); + for (const c of text3) { + window.setCharacter(c); + } + + // There should be two spaces between the words on the first row, + // and then the last row with text should appear 3 linebreaks later. + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, text1+' '+text2), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createDefaultCue(startTime, endTime, text3), + ]; + const caption = window.forceEmit(endTime, serviceNumber); + const expectedCaption = { + stream, + cue: topLevelCue, + }; + expect(caption).toEqual(expectedCaption); + }); + + it('cuts off text that exceeds the column size on a given row', () => { + const text = '0123456789012345678901234567890123'; // this text is 34 chars. + const trimmedText = text.substr(0, 32); + for (const c of text) { + window.setCharacter(c); + } + + // Since column size is 32, the buffer should have only taken the first + // 32 chars, and omitted the two extra ones at the end. + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, trimmedText), + ]; + const caption = window.forceEmit(endTime, serviceNumber); + const expectedCaption = { + stream, + cue: topLevelCue, + }; + expect(caption).toEqual(expectedCaption); + }); + + + it('correctly handles display(), hide(), and toggle() commands', () => { + window.display(); // The window should be visible. + expect(window.isVisible()).toBe(true); + + window.hide(); // The window should be hidden. + expect(window.isVisible()).toBe(false); + + window.toggle(); // The window was hidden, but is now toggled to visible. + expect(window.isVisible()).toBe(true); + }); +}); + diff --git a/test/cea/cea_decoder_unit.js b/test/cea/cea_decoder_unit.js index 8e68d814879..78f0e66d4a6 100644 --- a/test/cea/cea_decoder_unit.js +++ b/test/cea/cea_decoder_unit.js @@ -8,7 +8,7 @@ describe('CeaDecoder', () => { const CeaUtils = shaka.test.CeaUtils; /** @type {!string} */ - const DEFAULT_BG_COLOR = shaka.cea.Cea608Memory.DEFAULT_BG_COLOR; + const DEFAULT_BG_COLOR = shaka.cea.CeaUtils.DEFAULT_BG_COLOR; /** * Initialization bytes for CC packet. @@ -25,469 +25,551 @@ describe('CeaDecoder', () => { /** @type {!shaka.cea.CeaDecoder} */ const decoder = new shaka.cea.CeaDecoder(); - const edmCodeByte2 = 0x2c; // Erase displayed memory byte 2. + describe('decodes CEA-608', () => { + const edmCodeByte2 = 0x2c; // Erase displayed memory byte 2. - // Blank padding control code between two control codes that are the same. - const blankPaddingControlCode = new Uint8Array([0x97, 0x23]); + // Blank padding control code between two control codes that are the same. + const blankPaddingControlCode = new Uint8Array([0x97, 0x23]); - // Erases displayed memory on every captioning mode. - const eraseDisplayedMemory = new Uint8Array([ - ...atscCaptionInitBytes, 0xc4, /* padding= */ 0xff, - 0xfc, 0x94, edmCodeByte2, // EDM on CC1 - 0xfc, 0x1c, edmCodeByte2, // EDM on CC2 - 0xfd, 0x15, edmCodeByte2, // EDM on CC3 - 0xfd, 0x9d, edmCodeByte2, // EDM on CC4 - ]); - - beforeEach(() => { - decoder.clear(); - }); - - it('green and underlined popon caption data on CC3', () => { - const controlCount = 0x08; - const captionData = 0xc0 | controlCount; - const greenTextCC3Packet = new Uint8Array([ - ...atscCaptionInitBytes, captionData, /* padding= */ 0xff, - 0xfd, 0x15, 0x20, // Pop-on mode (RCL control code) - 0xfd, 0x13, 0xe3, // PAC to underline and color text green on last row. - 0xfd, 0x67, 0xf2, // g, r - 0xfd, 0xe5, 0xe5, // e, e - 0xfd, 0x6e, 0x20, // n, space - 0xfd, 0xf4, 0xe5, // t, e - 0xfd, 0xf8, 0xf4, // x, t - 0xfd, 0x15, 0x2f, // EOC + // Erases displayed memory on every captioning mode. + const eraseDisplayedMemory = new Uint8Array([ + ...atscCaptionInitBytes, 0xc4, /* padding= */ 0xff, + 0xfc, 0x94, edmCodeByte2, // EDM on CC1 + 0xfc, 0x1c, edmCodeByte2, // EDM on CC2 + 0xfd, 0x15, edmCodeByte2, // EDM on CC3 + 0xfd, 0x9d, edmCodeByte2, // EDM on CC4 ]); - const startTimeCaption1 = 1; - const startTimeCaption2 = 2; - const expectedText = 'green text'; - - const topLevelCue = new shaka.text.Cue( - startTimeCaption1, startTimeCaption2, ''); - topLevelCue.nestedCues = [ - CeaUtils.createStyledCue( - startTimeCaption1, startTimeCaption2, expectedText, - /* underline= */ true, /* italics= */ false, - /* textColor= */ 'green', /* backgroundColor= */ 'black'), - ]; - - const expectedCaptions = [ - { - stream: 'CC3', + beforeEach(() => { + decoder.clear(); + }); + + it('green and underlined popon caption data on CC3', () => { + const controlCount = 0x08; + const captionData = 0xc0 | controlCount; + const greenTextCC3Packet = new Uint8Array([ + ...atscCaptionInitBytes, captionData, /* padding= */ 0xff, + 0xfd, 0x15, 0x20, // Pop-on mode (RCL control code) + 0xfd, 0x13, 0xe3, // PAC to underline and color text green on last row. + 0xfd, 0x67, 0xf2, // g, r + 0xfd, 0xe5, 0xe5, // e, e + 0xfd, 0x6e, 0x20, // n, space + 0xfd, 0xf4, 0xe5, // t, e + 0xfd, 0xf8, 0xf4, // x, t + 0xfd, 0x15, 0x2f, // EOC + ]); + + const startTimeCaption1 = 1; + const startTimeCaption2 = 2; + const expectedText = 'green text'; + + const topLevelCue = new shaka.text.Cue( + startTimeCaption1, startTimeCaption2, ''); + topLevelCue.nestedCues = [ + CeaUtils.createStyledCue( + startTimeCaption1, startTimeCaption2, expectedText, + /* underline= */ true, /* italics= */ false, + /* textColor= */ 'green', /* backgroundColor= */ 'black'), + ]; + + const expectedCaptions = [ + { + stream: 'CC3', + cue: topLevelCue, + }, + ]; + + decoder.extract(greenTextCC3Packet, startTimeCaption1); + decoder.extract(eraseDisplayedMemory, startTimeCaption2); + const captions = decoder.decode(); + + expect(captions).toEqual(expectedCaptions); + }); + + it('popon captions that change color and underline midrow on CC2', () => { + const controlCount = 0x08; + const captionData = 0xc0 | controlCount; + const midrowStyleChangeCC2Packet = new Uint8Array([ + ...atscCaptionInitBytes, captionData, /* padding= */ 0xff, + 0xfc, 0x1c, 0x20, // Pop-on mode (RCL control code). + 0xfc, 0xad, 0xad, // -, - + 0xfc, 0x19, 0x29, // Red + underline midrow style control code. + 0xfc, 0xf2, 0xe5, // r, e + 0xfc, 0x64, 0x80, // d, invalid + 0xfc, 0x19, 0x20, // Midrow style control code to clear styles. + 0xfc, 0xad, 0xad, // -, - + 0xfc, 0x1c, 0x2f, // EOC + ]); + + const startTimeCaption1 = 1; + const startTimeCaption2 = 2; + const expectedText1 = '-- '; + const expectedText2 = 'red'; + const expectedText3 = ' --'; + + // Since there are three style changes, there should be three nested cues. + const topLevelCue = new shaka.text.Cue( + startTimeCaption1, startTimeCaption2, ''); + + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue( + startTimeCaption1, startTimeCaption2, expectedText1), + + CeaUtils.createStyledCue( + startTimeCaption1, startTimeCaption2, expectedText2, + /* underline= */ true, /* italics= */ false, + /* textColor= */ 'red', /* backgroundColor= */ DEFAULT_BG_COLOR), + + CeaUtils.createDefaultCue( + startTimeCaption1, startTimeCaption2, expectedText3), + ]; + + const expectedCaptions = [ + { + stream: 'CC2', + cue: topLevelCue, + }, + ]; + + decoder.extract(midrowStyleChangeCC2Packet, startTimeCaption1); + decoder.extract(eraseDisplayedMemory, startTimeCaption2); + const captions = decoder.decode(); + + expect(captions).toEqual(expectedCaptions); + }); + + it('italicized popon captions on a yellow background on CC2', () => { + const controlCount = 0x08; + const captionData = 0xc0 | controlCount; + const midrowStyleChangeCC2Packet = new Uint8Array([ + ...atscCaptionInitBytes, captionData, /* padding= */ 0xff, + 0xfc, 0x1c, 0x20, // Pop-on mode (RCL control code). + 0xfc, 0x19, 0x6e, // White Italics PAC. + 0xfc, 0x98, 0x2a, // Background attribute yellow. + 0xfc, 0xf4, 0xe5, // t, e + 0xfc, 0x73, 0xf4, // s, t + 0xfc, 0x19, 0x20, // Midrow style control code to clear styles. + 0xfc, 0x98, 0x20, // Background attribute to clear background. + 0xfc, 0x1c, 0x2f, // EOC + ]); + + const startTimeCaption1 = 1; + const startTimeCaption2 = 2; + const expectedText = 'test'; + + // A single nested cue containing yellow, italicized text. + const topLevelCue = new shaka.text.Cue(startTimeCaption1, + startTimeCaption2, ''); + topLevelCue.nestedCues = [ + CeaUtils.createStyledCue( + startTimeCaption1, startTimeCaption2, expectedText, + /* underline= */ false, /* italics= */ true, + /* textColor= */ 'white', /* backgroundColor= */ 'yellow'), + ]; + + const expectedCaptions = [{ + stream: 'CC2', cue: topLevelCue, - }, - ]; - - decoder.extract(greenTextCC3Packet, startTimeCaption1); - decoder.extract(eraseDisplayedMemory, startTimeCaption2); - const captions = decoder.decode(); - - expect(captions).toEqual(expectedCaptions); - }); - - it('popon captions that change color and underline midrow on CC2', () => { - const controlCount = 0x08; - const captionData = 0xc0 | controlCount; - const midrowStyleChangeCC2Packet = new Uint8Array([ - ...atscCaptionInitBytes, captionData, /* padding= */ 0xff, - 0xfc, 0x1c, 0x20, // Pop-on mode (RCL control code). - 0xfc, 0xad, 0xad, // -, - - 0xfc, 0x19, 0x29, // Red + underline midrow style control code. - 0xfc, 0xf2, 0xe5, // r, e - 0xfc, 0x64, 0x80, // d, invalid - 0xfc, 0x19, 0x20, // Midrow style control code to clear styles. - 0xfc, 0xad, 0xad, // -, - - 0xfc, 0x1c, 0x2f, // EOC - ]); - - const startTimeCaption1 = 1; - const startTimeCaption2 = 2; - const expectedText1 = '-- '; - const expectedText2 = 'red'; - const expectedText3 = ' --'; - - // Since there are three style changes, there should be three nested cues. - const topLevelCue = new shaka.text.Cue( - startTimeCaption1, startTimeCaption2, ''); - - topLevelCue.nestedCues = [ - CeaUtils.createDefaultCue( - startTimeCaption1, startTimeCaption2, expectedText1), - - CeaUtils.createStyledCue( - startTimeCaption1, startTimeCaption2, expectedText2, - /* underline= */ true, /* italics= */ false, - /* textColor= */ 'red', /* backgroundColor= */ DEFAULT_BG_COLOR), - - CeaUtils.createDefaultCue( - startTimeCaption1, startTimeCaption2, expectedText3), - ]; - - const expectedCaptions = [ - { + }]; + + decoder.extract(midrowStyleChangeCC2Packet, startTimeCaption1); + decoder.extract(eraseDisplayedMemory, startTimeCaption2); + const captions = decoder.decode(); + + expect(captions).toEqual(expectedCaptions); + }); + + it('popon captions with special characters on CC2', () => { + const controlCount = 0x07; + const captionData = 0xc0 | controlCount; + const midrowStyleChangeCC2Packet = new Uint8Array([ + ...atscCaptionInitBytes, captionData, /* padding= */ 0xff, + 0xfc, 0x1c, 0x20, // Pop-on mode (RCL control code). + 0xfc, 0x19, 0x37, // Special North American character (♪) + 0xfc, 0x20, 0x80, // SP, invalid. SP will be replaced by extended char. + 0xfc, 0x1a, 0x25, // Extended Spanish/Misc character (ü) + 0xfc, 0x20, 0x80, // SP, invalid. + 0xfc, 0x9b, 0xb9, // Extended German/Danish character (å) + 0xfc, 0x1c, 0x2f, // EOC + ]); + + const startTimeCaption1 = 1; + const startTimeCaption2 = 2; + const expectedText = '♪üå'; + + const topLevelCue = new shaka.text.Cue(startTimeCaption1, + startTimeCaption2, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue( + startTimeCaption1, startTimeCaption2, expectedText), + ]; + + const expectedCaptions = [{ stream: 'CC2', cue: topLevelCue, - }, - ]; - - decoder.extract(midrowStyleChangeCC2Packet, startTimeCaption1); - decoder.extract(eraseDisplayedMemory, startTimeCaption2); - const captions = decoder.decode(); - - expect(captions).toEqual(expectedCaptions); - }); - - it('italicized popon captions on a yellow background on CC2', () => { - const controlCount = 0x08; - const captionData = 0xc0 | controlCount; - const midrowStyleChangeCC2Packet = new Uint8Array([ - ...atscCaptionInitBytes, captionData, /* padding= */ 0xff, - 0xfc, 0x1c, 0x20, // Pop-on mode (RCL control code). - 0xfc, 0x19, 0x6e, // White Italics PAC. - 0xfc, 0x98, 0x2a, // Background attribute yellow. - 0xfc, 0xf4, 0xe5, // t, e - 0xfc, 0x73, 0xf4, // s, t - 0xfc, 0x19, 0x20, // Midrow style control code to clear styles. - 0xfc, 0x98, 0x20, // Background attribute to clear background. - 0xfc, 0x1c, 0x2f, // EOC - ]); - - const startTimeCaption1 = 1; - const startTimeCaption2 = 2; - const expectedText = 'test'; - - // 2 nested cues, one contains styled text, one contains a midrow space. - const topLevelCue = new shaka.text.Cue(startTimeCaption1, - startTimeCaption2, ''); - topLevelCue.nestedCues = [ - CeaUtils.createStyledCue( - startTimeCaption1, startTimeCaption2, expectedText, - /* underline= */ false, /* italics= */ true, - /* textColor= */ 'white', /* backgroundColor= */ 'yellow'), - - CeaUtils.createStyledCue( - startTimeCaption1, startTimeCaption2, /* payload= */ ' ', - /* underline= */ false, /* italics= */ false, - /* textColor= */ 'white', /* backgroundColor= */ 'yellow'), - ]; - - const expectedCaptions = [{ - stream: 'CC2', - cue: topLevelCue, - }]; - - decoder.extract(midrowStyleChangeCC2Packet, startTimeCaption1); - decoder.extract(eraseDisplayedMemory, startTimeCaption2); - const captions = decoder.decode(); - - expect(captions).toEqual(expectedCaptions); - }); - - it('popon captions with special characters on CC2', () => { - const controlCount = 0x07; - const captionData = 0xc0 | controlCount; - const midrowStyleChangeCC2Packet = new Uint8Array([ - ...atscCaptionInitBytes, captionData, /* padding= */ 0xff, - 0xfc, 0x1c, 0x20, // Pop-on mode (RCL control code). - 0xfc, 0x19, 0x37, // Special North American character (♪) - 0xfc, 0x20, 0x80, // SP, invalid. SP will be replaced by extended char. - 0xfc, 0x1a, 0x25, // Extended Spanish/Misc character (ü) - 0xfc, 0x20, 0x80, // SP, invalid. - 0xfc, 0x9b, 0xb9, // Extended German/Danish character (å) - 0xfc, 0x1c, 0x2f, // EOC - ]); - - const startTimeCaption1 = 1; - const startTimeCaption2 = 2; - const expectedText = '♪üå'; - - const topLevelCue = new shaka.text.Cue(startTimeCaption1, - startTimeCaption2, ''); - topLevelCue.nestedCues = [ - CeaUtils.createDefaultCue( - startTimeCaption1, startTimeCaption2, expectedText), - ]; - - const expectedCaptions = [{ - stream: 'CC2', - cue: topLevelCue, - }]; - - decoder.extract(midrowStyleChangeCC2Packet, startTimeCaption1); - decoder.extract(eraseDisplayedMemory, startTimeCaption2); - const captions = decoder.decode(); - expect(captions).toEqual(expectedCaptions); - }); - - it('painton captions on CC1', () => { - const controlCount = 0x03; - const captionData = 0xc0 | controlCount; - const paintonCaptionCC1Packet = new Uint8Array([ - ...atscCaptionInitBytes, captionData, /* padding= */ 0xff, - 0xfc, 0x94, 0x29, // Paint-on mode (RDC control code). - 0xfc, 0xf4, 0xe5, // t, e - 0xfc, 0x73, 0xf4, // s, t - ]); - - const startTimeCaption1 = 1; - const startTimeCaption2 = 2; - const expectedText = 'test'; - - const topLevelCue = new shaka.text.Cue(startTimeCaption1, - startTimeCaption2, ''); - topLevelCue.nestedCues = [ - CeaUtils.createDefaultCue( - startTimeCaption1, startTimeCaption2, expectedText), - ]; - - const expectedCaptions = [{ - stream: 'CC1', - cue: topLevelCue, - }]; - - decoder.extract(paintonCaptionCC1Packet, startTimeCaption1); - decoder.extract(eraseDisplayedMemory, startTimeCaption2); - const captions = decoder.decode(); - - expect(captions).toEqual(expectedCaptions); - }); - - it('rollup captions (2 lines) on CC1', () => { - const controlCount1 = 0x03; - const controlCount2 = 0x02; - const stream = 'CC1'; - const time1 = 1; - const time2 = 2; - const time3 = 3; - const time4 = 4; - const time5 = 5; - - // Carriage return on CC1 - const carriageReturnControlCode = new Uint8Array([0x94, 0xad]); - const packets = [ - new Uint8Array([ - ...atscCaptionInitBytes, 0xc0 | controlCount1, /* padding= */ 0xff, - 0xfc, 0x94, 0x25, // Roll-up 2 rows control code. - 0xfc, ...carriageReturnControlCode, - 0xfc, ...blankPaddingControlCode, - ]), - new Uint8Array([ - ...atscCaptionInitBytes, 0xc0 | controlCount1, /* padding= */ 0xff, - 0xfc, 0x31, 0xae, // 1, . - 0xfc, ...carriageReturnControlCode, - 0xfc, ...blankPaddingControlCode, - ]), - new Uint8Array([ - ...atscCaptionInitBytes, 0xc0 | controlCount1, /* padding= */ 0xff, - 0xfc, 0x32, 0xae, // 2, . - 0xfc, ...carriageReturnControlCode, - 0xfc, ...blankPaddingControlCode, - ]), - new Uint8Array([ - ...atscCaptionInitBytes, 0xc0 | controlCount1, /* padding= */ 0xff, - 0xfc, 0xb3, 0xae, // 3, . - 0xfc, ...carriageReturnControlCode, - 0xfc, ...blankPaddingControlCode, - ]), - new Uint8Array([ - ...atscCaptionInitBytes, 0xc0 | controlCount2, /* padding= */ 0xff, - 0xfc, 0x34, 0xae, // 4, . - 0xfc, 0x94, 0x2f, // EOC - ]), - ]; - - for (let i = 0; i < packets.length; i++) { - decoder.extract(packets[i], i+1); - } - decoder.extract(eraseDisplayedMemory, 6); - - // Top level cue corresponding to the first closed caption. - const topLevelCue1 = new shaka.text.Cue( - /* startTime= */ time1, /* endTime= */ time2, ''); - topLevelCue1.nestedCues = [ - CeaUtils.createDefaultCue( - /* startTime= */ time1, /* endTime= */ time2, /* payload= */ '1.'), - ]; - - // Top level cue corresponding to the second closed caption. - const topLevelCue2 = new shaka.text.Cue( - /* startTime= */ time2, /* endTime= */ time3, ''); - topLevelCue2.nestedCues = [ - CeaUtils.createDefaultCue( - /* startTime= */ time2, /* endTime= */ time3, /* payload= */ '1.'), - - CeaUtils.createLineBreakCue( - /* startTime= */ time2, /* endTime= */ time3), - - CeaUtils.createDefaultCue( - /* startTime= */ time2, /* endTime= */ time3, /* payload= */ '2.'), - ]; - - // Top level cue corresponding to the third closed caption. - const topLevelCue3 = new shaka.text.Cue( - /* startTime= */ time3, /* endTime= */ time4, ''); - topLevelCue3.nestedCues = [ - CeaUtils.createDefaultCue( - /* startTime= */ time3, /* endTime= */ time4, /* payload= */ '2.'), - - CeaUtils.createLineBreakCue( - /* startTime= */ time3, /* endTime= */ time4), - - CeaUtils.createDefaultCue( - /* startTime= */ time3, /* endTime= */ time4, /* payload= */ '3.'), - ]; - - // Top level cue corresponding to the fourth closed caption. - const topLevelCue4 = new shaka.text.Cue( - /* startTime= */ time4, /* endTime= */ time5, ''); - topLevelCue4.nestedCues = [ - CeaUtils.createDefaultCue( - /* startTime= */ time4, /* endTime= */ time5, /* payload= */ '3.'), - - CeaUtils.createLineBreakCue( - /* startTime= */ time4, /* endTime= */ time5), - - CeaUtils.createDefaultCue( - /* startTime= */ time4, /* endTime= */ time5, /* payload= */ '4.'), - ]; - - const expectedCaptions = [ - { - stream, - cue: topLevelCue1, - }, - { - stream, - cue: topLevelCue2, - }, - { - stream, - cue: topLevelCue3, - }, - { - stream, - cue: topLevelCue4, - }, - ]; - - const captions = decoder.decode(); - - expect(captions).toEqual(expectedCaptions); - }); - - it('PAC shifts entire 2-line rollup window to a new row on CC1', () => { - const controlCount1 = 0x03; - const controlCount2 = 0x02; - const stream = 'CC1'; - - // Carriage return on CC1 - const carriageReturnControlCode = new Uint8Array([0x94, 0xad]); - const packets = [ - new Uint8Array([ - ...atscCaptionInitBytes, 0xc0 | controlCount1, /* padding= */ 0xff, - 0xfc, 0x94, 0x25, // Roll-up 2 rows control code. - 0xfc, ...carriageReturnControlCode, - 0xfc, 0x97, 0x23, // Blank padding control code - ]), - new Uint8Array([ - ...atscCaptionInitBytes, 0xc0 | controlCount1, /* padding= */ 0xff, - 0xfc, 0x31, 0xae, // 1, . - 0xfc, ...carriageReturnControlCode, - 0xfc, 0x97, 0x23, // Blank padding control code - ]), - new Uint8Array([ - ...atscCaptionInitBytes, 0xc0 | controlCount2, /* padding= */ 0xff, - 0xfc, 0x32, 0xae, // 2, . - 0xfc, 0x92, 0xe0, // PAC control code to move to row 4. - ]), - ]; - - for (let i = 0; i < packets.length; i++) { - decoder.extract(packets[i], i+1); - } - decoder.extract(eraseDisplayedMemory, 3); - - // Top level cue corresponding to the first closed caption. - const topLevelCue1 = new shaka.text.Cue(/* startTime= */ 1, - /* endTime= */ 2, ''); - topLevelCue1.nestedCues = [ - CeaUtils.createDefaultCue( - /* startTime= */ 1, /* endTime= */ 2, /* payload= */ '1.'), - ]; - - // Top level cue corresponding to the second closed caption. - const topLevelCue2 = new shaka.text.Cue(/* startTime= */ 2, - /* endTime= */ 3, ''); - topLevelCue2.nestedCues = [ - CeaUtils.createDefaultCue( - /* startTime= */ 2, /* endTime= */ 3, /* payload= */ '1.'), - - CeaUtils.createLineBreakCue(/* startTime= */ 2, /* endTime= */ 3), - - CeaUtils.createDefaultCue( - /* startTime= */ 2, /* endTime= */ 3, /* payload= */ '2.'), - ]; - - const expectedCaptions = [ - { - stream, - cue: topLevelCue1, - }, - { - stream, - cue: topLevelCue2, - }, - ]; - - const captions = decoder.decode(); - - expect(captions).toEqual(expectedCaptions); - }); + }]; + + decoder.extract(midrowStyleChangeCC2Packet, startTimeCaption1); + decoder.extract(eraseDisplayedMemory, startTimeCaption2); + const captions = decoder.decode(); + expect(captions).toEqual(expectedCaptions); + }); + + it('painton captions on CC1', () => { + const controlCount = 0x03; + const captionData = 0xc0 | controlCount; + const paintonCaptionCC1Packet = new Uint8Array([ + ...atscCaptionInitBytes, captionData, /* padding= */ 0xff, + 0xfc, 0x94, 0x29, // Paint-on mode (RDC control code). + 0xfc, 0xf4, 0xe5, // t, e + 0xfc, 0x73, 0xf4, // s, t + ]); + + const startTimeCaption1 = 1; + const startTimeCaption2 = 2; + const expectedText = 'test'; + + const topLevelCue = new shaka.text.Cue(startTimeCaption1, + startTimeCaption2, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue( + startTimeCaption1, startTimeCaption2, expectedText), + ]; + + const expectedCaptions = [{ + stream: 'CC1', + cue: topLevelCue, + }]; + + decoder.extract(paintonCaptionCC1Packet, startTimeCaption1); + decoder.extract(eraseDisplayedMemory, startTimeCaption2); + const captions = decoder.decode(); + + expect(captions).toEqual(expectedCaptions); + }); + + it('rollup captions (2 lines) on CC1', () => { + const controlCount1 = 0x03; + const controlCount2 = 0x02; + const stream = 'CC1'; + const time1 = 1; + const time2 = 2; + const time3 = 3; + const time4 = 4; + const time5 = 5; + + // Carriage return on CC1 + const carriageReturnControlCode = new Uint8Array([0x94, 0xad]); + const packets = [ + new Uint8Array([ + ...atscCaptionInitBytes, 0xc0 | controlCount1, /* padding= */ 0xff, + 0xfc, 0x94, 0x25, // Roll-up 2 rows control code. + 0xfc, ...carriageReturnControlCode, + 0xfc, ...blankPaddingControlCode, + ]), + new Uint8Array([ + ...atscCaptionInitBytes, 0xc0 | controlCount1, /* padding= */ 0xff, + 0xfc, 0x31, 0xae, // 1, . + 0xfc, ...carriageReturnControlCode, + 0xfc, ...blankPaddingControlCode, + ]), + new Uint8Array([ + ...atscCaptionInitBytes, 0xc0 | controlCount1, /* padding= */ 0xff, + 0xfc, 0x32, 0xae, // 2, . + 0xfc, ...carriageReturnControlCode, + 0xfc, ...blankPaddingControlCode, + ]), + new Uint8Array([ + ...atscCaptionInitBytes, 0xc0 | controlCount1, /* padding= */ 0xff, + 0xfc, 0xb3, 0xae, // 3, . + 0xfc, ...carriageReturnControlCode, + 0xfc, ...blankPaddingControlCode, + ]), + new Uint8Array([ + ...atscCaptionInitBytes, 0xc0 | controlCount2, /* padding= */ 0xff, + 0xfc, 0x34, 0xae, // 4, . + 0xfc, 0x94, 0x2f, // EOC + ]), + ]; + + for (let i = 0; i < packets.length; i++) { + decoder.extract(packets[i], i+1); + } + decoder.extract(eraseDisplayedMemory, 6); + + // Top level cue corresponding to the first closed caption. + const topLevelCue1 = new shaka.text.Cue( + /* startTime= */ time1, /* endTime= */ time2, ''); + topLevelCue1.nestedCues = [ + CeaUtils.createDefaultCue( + /* startTime= */ time1, /* endTime= */ time2, /* payload= */ '1.'), + ]; + + // Top level cue corresponding to the second closed caption. + const topLevelCue2 = new shaka.text.Cue( + /* startTime= */ time2, /* endTime= */ time3, ''); + topLevelCue2.nestedCues = [ + CeaUtils.createDefaultCue( + /* startTime= */ time2, /* endTime= */ time3, /* payload= */ '1.'), + + CeaUtils.createLineBreakCue( + /* startTime= */ time2, /* endTime= */ time3), + + CeaUtils.createDefaultCue( + /* startTime= */ time2, /* endTime= */ time3, /* payload= */ '2.'), + ]; + + // Top level cue corresponding to the third closed caption. + const topLevelCue3 = new shaka.text.Cue( + /* startTime= */ time3, /* endTime= */ time4, ''); + topLevelCue3.nestedCues = [ + CeaUtils.createDefaultCue( + /* startTime= */ time3, /* endTime= */ time4, /* payload= */ '2.'), + + CeaUtils.createLineBreakCue( + /* startTime= */ time3, /* endTime= */ time4), + + CeaUtils.createDefaultCue( + /* startTime= */ time3, /* endTime= */ time4, /* payload= */ '3.'), + ]; + + // Top level cue corresponding to the fourth closed caption. + const topLevelCue4 = new shaka.text.Cue( + /* startTime= */ time4, /* endTime= */ time5, ''); + topLevelCue4.nestedCues = [ + CeaUtils.createDefaultCue( + /* startTime= */ time4, /* endTime= */ time5, /* payload= */ '3.'), + + CeaUtils.createLineBreakCue( + /* startTime= */ time4, /* endTime= */ time5), + + CeaUtils.createDefaultCue( + /* startTime= */ time4, /* endTime= */ time5, /* payload= */ '4.'), + ]; + + const expectedCaptions = [ + { + stream, + cue: topLevelCue1, + }, + { + stream, + cue: topLevelCue2, + }, + { + stream, + cue: topLevelCue3, + }, + { + stream, + cue: topLevelCue4, + }, + ]; + + const captions = decoder.decode(); + + expect(captions).toEqual(expectedCaptions); + }); + + it('PAC shifts entire 2-line rollup window to a new row on CC1', () => { + const controlCount1 = 0x03; + const controlCount2 = 0x02; + const stream = 'CC1'; + + // Carriage return on CC1 + const carriageReturnControlCode = new Uint8Array([0x94, 0xad]); + const packets = [ + new Uint8Array([ + ...atscCaptionInitBytes, 0xc0 | controlCount1, /* padding= */ 0xff, + 0xfc, 0x94, 0x25, // Roll-up 2 rows control code. + 0xfc, ...carriageReturnControlCode, + 0xfc, 0x97, 0x23, // Blank padding control code + ]), + new Uint8Array([ + ...atscCaptionInitBytes, 0xc0 | controlCount1, /* padding= */ 0xff, + 0xfc, 0x31, 0xae, // 1, . + 0xfc, ...carriageReturnControlCode, + 0xfc, 0x97, 0x23, // Blank padding control code + ]), + new Uint8Array([ + ...atscCaptionInitBytes, 0xc0 | controlCount2, /* padding= */ 0xff, + 0xfc, 0x32, 0xae, // 2, . + 0xfc, 0x92, 0xe0, // PAC control code to move to row 4. + ]), + ]; + + for (let i = 0; i < packets.length; i++) { + decoder.extract(packets[i], i+1); + } + decoder.extract(eraseDisplayedMemory, 3); + + // Top level cue corresponding to the first closed caption. + const topLevelCue1 = new shaka.text.Cue(/* startTime= */ 1, + /* endTime= */ 2, ''); + topLevelCue1.nestedCues = [ + CeaUtils.createDefaultCue( + /* startTime= */ 1, /* endTime= */ 2, /* payload= */ '1.'), + ]; + + // Top level cue corresponding to the second closed caption. + const topLevelCue2 = new shaka.text.Cue(/* startTime= */ 2, + /* endTime= */ 3, ''); + topLevelCue2.nestedCues = [ + CeaUtils.createDefaultCue( + /* startTime= */ 2, /* endTime= */ 3, /* payload= */ '1.'), + + CeaUtils.createLineBreakCue(/* startTime= */ 2, /* endTime= */ 3), + + CeaUtils.createDefaultCue( + /* startTime= */ 2, /* endTime= */ 3, /* payload= */ '2.'), + ]; + + const expectedCaptions = [ + { + stream, + cue: topLevelCue1, + }, + { + stream, + cue: topLevelCue2, + }, + ]; + + const captions = decoder.decode(); + + expect(captions).toEqual(expectedCaptions); + }); + + it('does not emit text sent while in CEA-608 Text Mode', () => { + const controlCount = 0x03; + const captionData = 0xc0 | controlCount; + const textModePacket = new Uint8Array([ + ...atscCaptionInitBytes, captionData, /* padding= */ 0xff, + 0xfc, 0x94, 0x2a, // Text mode (Text restart control code). + 0xfc, 0xf4, 0xe5, // t, e + 0xfc, 0x73, 0xf4, // s, t + ]); + + const startTimeCaption1 = 1; + const startTimeCaption2 = 2; + + decoder.extract(textModePacket, startTimeCaption1); + decoder.extract(eraseDisplayedMemory, startTimeCaption2); + + const captions = decoder.decode(); + expect(captions).toEqual([]); + }); + + it('resets the decoder on >=45 consecutive bad frames', () => { + // CEA-608-B C.21 says to reset the decoder after 45 invalid frames. + const controlCount = 0x0f; + const captionData = 0xc0 | controlCount; + const badFrames = []; + const badFrameCount = 15; + for (let i = 0; i { - const controlCount = 0x03; - const captionData = 0xc0 | controlCount; - const textModePacket = new Uint8Array([ - ...atscCaptionInitBytes, captionData, /* padding= */ 0xff, - 0xfc, 0x94, 0x2a, // Text mode (Text restart control code). - 0xfc, 0xf4, 0xe5, // t, e - 0xfc, 0x73, 0xf4, // s, t - ]); + const badFramesBuffer = new Uint8Array([ + ...atscCaptionInitBytes, captionData, /* padding= */ 0xff, + ...new Uint8Array(badFrames), + ]); - const startTimeCaption1 = 1; - const startTimeCaption2 = 2; + // 3*15 = 45 total bad frames extracted. + for (let i = 0; i < 3; i++) { + decoder.extract(badFramesBuffer, i+1); + } - decoder.extract(textModePacket, startTimeCaption1); - decoder.extract(eraseDisplayedMemory, startTimeCaption2); + spyOn(decoder, 'reset').and.callThrough(); + decoder.decode(); - const captions = decoder.decode(); - expect(captions).toEqual([]); + expect(decoder.reset).toHaveBeenCalledTimes(1); + }); }); - it('resets the decoder on >=45 consecutive bad frames', () => { - // CEA-608-B C.21 says to reset the decoder after 45 invalid frames. - const controlCount = 0x0f; - const captionData = 0xc0 | controlCount; - const badFrames = []; - const badFrameCount = 15; - for (let i = 0; i { + // Hide window (2 bytes), with a bitmap provided to indicate all windows. + const hideWindow = new Uint8Array([ + ...atscCaptionInitBytes, 0xc2, /* padding= */ 0xff, + 0xff, 0x02, 0x22, // Service #1, and 2 bytes will follow. + 0xfe, 0x8a, 0xff, ]); - // 3*15 = 45 total bad frames extracted. - for (let i = 0; i < 3; i++) { - decoder.extract(badFramesBuffer, i+1); - } - - spyOn(decoder, 'reset').and.callThrough(); - decoder.decode(); - - expect(decoder.reset).toHaveBeenCalledTimes(1); + it('well-formed caption packet that contains valid control codes', () => { + const startTime = 1; + const endTime = 2; + const bytePairCount = 0x07; + const captionData = 0xc0 | bytePairCount; + const serviceNumber = 1; + const cea708Packet = new Uint8Array([ + ...atscCaptionInitBytes, captionData, /* padding= */ 0xff, + // Byte 1 (0x07) is a DTVCC_PACKET_START that states 7 * 2 - 1 bytes + // will follow. Byte 2 is a service block header that selects service # + // and states that there are 12 bytes that will follow in the block. + 0xff, 0x07, (serviceNumber << 5) | 12, + + // Define window (7 bytes). Visible window #0 with 10 rows, 10 columns. + 0xfe, 0x98, 0x38, + 0xfe, 0x00, 0x00, + 0xfe, 0x0a, 0x0a, + 0xfe, 0x00, + + // Series of G0 control codes that add text + 0x74, // t + 0xfe, 0x65, 0x73, // e, s + 0xfe, 0x74, 0x00, // t, padding + ]); + + decoder.extract(cea708Packet, startTime); + decoder.extract(hideWindow, endTime); + + const text = 'test'; + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, /* payload= */ text), + ]; + + const expectedCaptions = [ + { + stream: 'svc1', + cue: topLevelCue, + }, + ]; + + const captions = decoder.decode(); + expect(captions).toEqual(expectedCaptions); + }); + + it('service block contains a corrupted header', () => { + const startTime = 1; + const endTime = 2; + const bytePairCount = 0x02; + const captionData = 0xc0 | bytePairCount; + const serviceNumber = 1; + const cea708Packet = new Uint8Array([ + ...atscCaptionInitBytes, captionData, /* padding= */ 0xff, + // Byte 1 (0x01) is DTVCC_PACKET_START that states 1 * 2 - 1 bytes + // will follow. Byte 2 is a service block header that selects service # + // and states that there are 12 bytes that will follow in the block. + 0xff, 0x01, (serviceNumber << 5) | 12, + 0xfe, 0x00, 0x00, + ]); + + // The data corrupted, since the service block header claimed 12 bytes + // would follow, but only two bytes followed. + decoder.extract(cea708Packet, startTime); + decoder.extract(hideWindow, endTime); + + // Then we should have warned of the invalid data and stopped processing + // the block without interrupting playback. + spyOn(shaka.log, 'warnOnce').and.callThrough(); + + const captions = decoder.decode(); + expect(shaka.log.warnOnce).toHaveBeenCalledWith('CEA708_INVALID_DATA', + 'Buffer read out of bounds / invalid CEA-708 Data.'); + expect(shaka.log.warnOnce).toHaveBeenCalledTimes(1); + expect(captions).toEqual([]); + }); }); }); diff --git a/test/cea/dtvcc_packet_builder_unit.js b/test/cea/dtvcc_packet_builder_unit.js new file mode 100644 index 00000000000..de129747743 --- /dev/null +++ b/test/cea/dtvcc_packet_builder_unit.js @@ -0,0 +1,158 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +describe('DtvccPacketBuilder', () => { + /** @type {!shaka.cea.DtvccPacketBuilder} */ + let dtvccPacketBuilder; + + /** @type {!number} */ + const DTVCC_PACKET_START = shaka.cea.DtvccPacketBuilder.DTVCC_PACKET_START; + + /** @type {!number} */ + const DTVCC_PACKET_DATA = shaka.cea.DtvccPacketBuilder.DTVCC_PACKET_DATA; + + beforeEach(() => { + dtvccPacketBuilder = new shaka.cea.DtvccPacketBuilder(); + }); + + it('parses and returns a full packet correctly', () => { + // Last 6 bits of the first byte in a DTVCC_PACKET_START is + // packetSize. The number of data bytes that follow should be + // packetSize * 2 - 1, as per the spec. + const dtvccStartByte = 0x01; + + // Add the byte for DTVCC_PACKET_START. + dtvccPacketBuilder.addByte({ + pts: 0, + type: DTVCC_PACKET_START, + value: dtvccStartByte, + order: 0, + }); + + const cea708PacketDataBytes = [ + { + pts: 0, + type: DTVCC_PACKET_DATA, + value: 0, + order: 0, + }, + ]; + + for (const byte of cea708PacketDataBytes) { + dtvccPacketBuilder.addByte(byte); + } + + const expectedPackets = [new shaka.cea.DtvccPacket(cea708PacketDataBytes)]; + const parsedPackets = dtvccPacketBuilder.getBuiltPackets(); + expect(parsedPackets).toEqual(expectedPackets); + }); + + it('does not return a half-processed packet', () => { + const dtvccStartByte = 0x02; // 2 * 2 - 1 = 3 data bytes should follow. + + // Add the byte for DTVCC_PACKET_START. + dtvccPacketBuilder.addByte({ + pts: 0, + type: DTVCC_PACKET_START, + value: dtvccStartByte, + order: 0, + }); + + // Add a DTVCC_PACKET_DATA byte. + dtvccPacketBuilder.addByte({ + pts: 0, + type: DTVCC_PACKET_START, + value: dtvccStartByte, + order: 0, + }); + + // Add another DTVCC_PACKET_START byte before all the data bytes + // were received for the first packet. + dtvccPacketBuilder.addByte({ + pts: 0, + type: DTVCC_PACKET_START, + value: dtvccStartByte, + order: 0, + }); + + // Expect no packets to be returned, since no packets completed processing. + const parsedPackets = dtvccPacketBuilder.getBuiltPackets(); + expect(parsedPackets).toEqual([]); + }); + + it('clears the packet builder', () => { + const dtvccStartByte = 0x01; // 1 * 2 - 1 = 1 data bytes should follow. + + // Add the byte for DTVCC_PACKET_START. + dtvccPacketBuilder.addByte({ + pts: 0, + type: DTVCC_PACKET_START, + value: dtvccStartByte, + order: 0, + }); + + // Clear the packet builder. + dtvccPacketBuilder.clear(); + + // Add a DTVCC_PACKET_DATA byte. + dtvccPacketBuilder.addByte({ + pts: 0, + type: DTVCC_PACKET_START, + value: dtvccStartByte, + order: 0, + }); + + // Even though the right number of data bytes were given according to the + // start packet, the packet builder was cleared in between. So no packets + // should have been returned. + const parsedPackets = dtvccPacketBuilder.getBuiltPackets(); + expect(parsedPackets).toEqual([]); + }); + + it('ignores DTVCC_PACKET_DATA sent without a DTVCC_PACKET_START', () => { + // These next two bytes are DTVCC_PACKET_DATA, but no DTVCC_PACKET_START + // was sent yet. So these bytes should have been ignored by the builder. + dtvccPacketBuilder.addByte({ + pts: 0, + type: DTVCC_PACKET_DATA, + value: 0, + order: 0, + }); + dtvccPacketBuilder.addByte({ + pts: 0, + type: DTVCC_PACKET_DATA, + value: 0, + order: 0, + }); + + const dtvccStartByte = 0x01; // 1 * 2 - 1 = 1 data bytes should follow. + + // Add the byte for DTVCC_PACKET_START. + dtvccPacketBuilder.addByte({ + pts: 0, + type: DTVCC_PACKET_START, + value: dtvccStartByte, + order: 0, + }); + + const cea708PacketDataBytes = [ + { + pts: 0, + type: DTVCC_PACKET_DATA, + value: 0, + order: 0, + }, + ]; + + for (const byte of cea708PacketDataBytes) { + dtvccPacketBuilder.addByte(byte); + } + + const expectedPackets = [new shaka.cea.DtvccPacket(cea708PacketDataBytes)]; + const parsedPackets = dtvccPacketBuilder.getBuiltPackets(); + expect(parsedPackets).toEqual(expectedPackets); + }); +}); diff --git a/test/cea/dtvcc_packet_unit.js b/test/cea/dtvcc_packet_unit.js new file mode 100644 index 00000000000..67b0ccc6ac1 --- /dev/null +++ b/test/cea/dtvcc_packet_unit.js @@ -0,0 +1,60 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +describe('DtvccPacket', () => { + /** @type {!shaka.cea.DtvccPacket} */ + let dtvccPacket; + + it('reads all data from a packet', () => { + const dataBytes = [{ + pts: 0, + type: shaka.cea.DtvccPacketBuilder.DTVCC_PACKET_DATA, + value: 0, + order: 0, + }, + { + pts: 0, + type: shaka.cea.DtvccPacketBuilder.DTVCC_PACKET_DATA, + value: 0x1, + order: 0, + }]; + + dtvccPacket = new shaka.cea.DtvccPacket(dataBytes); + let i = 0; + while (dtvccPacket.hasMoreData()) { + const data = dtvccPacket.readByte(); + expect(data).toBe(dataBytes[i]); + i++; + } + expect(dtvccPacket.getPosition()).toBe(2); + }); + + it('should skip data from a packet correctly', () => { + const dataBytes = [{ + pts: 0, + type: shaka.cea.DtvccPacketBuilder.DTVCC_PACKET_DATA, + value: 0, + order: 0, + }]; + dtvccPacket = new shaka.cea.DtvccPacket(dataBytes); + dtvccPacket.skip(1); + expect(dtvccPacket.getPosition()).toBe(1); + }); + + describe('should throw a buffer read out of bounds error', () => { + const error = shaka.test.Util.jasmineError(new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.TEXT, + shaka.util.Error.Code.BUFFER_READ_OUT_OF_BOUNDS)); + it('on unbuffered skips', () => { + expect(() => dtvccPacket.skip(1)).toThrow(error); + }); + + it('on unbuffered reads', () => { + expect(() => dtvccPacket.readByte()).toThrow(error); + }); + }); +}); + diff --git a/test/dash/dash_parser_manifest_unit.js b/test/dash/dash_parser_manifest_unit.js index a5c20d96201..f2c51ff0060 100644 --- a/test/dash/dash_parser_manifest_unit.js +++ b/test/dash/dash_parser_manifest_unit.js @@ -333,7 +333,7 @@ describe('DashParser Manifest', () => { /* appendWindowEnd= */ 30)); }); - it('correctly parses closed captions with channels and languages', + it('correctly parses mixed captions with channels, services, and languages', async () => { const source = [ '', @@ -345,16 +345,9 @@ describe('DashParser Manifest', () => { ' ', ' ', ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', @@ -367,10 +360,8 @@ describe('DashParser Manifest', () => { /** @type {shaka.extern.Manifest} */ const manifest = await parser.start('dummy://foo', playerInterface); - // First Representation should be dropped. const stream1 = manifest.variants[0].video; const stream2 = manifest.variants[1].video; - const stream3 = manifest.variants[2].video; const expectedClosedCaptions1 = new Map( [['CC1', shaka.util.LanguageUtils.normalize('eng')], @@ -378,17 +369,67 @@ describe('DashParser Manifest', () => { ); const expectedClosedCaptions2 = new Map( - [['CC1', shaka.util.LanguageUtils.normalize('fr')], - ['CC3', shaka.util.LanguageUtils.normalize('zh')]] - ); - - const expectedClosedCaptions3 = new Map( - [['CC1', shaka.util.LanguageUtils.normalize('ru')], - ['CC3', shaka.util.LanguageUtils.normalize('hu')]] + [['svc1', shaka.util.LanguageUtils.normalize('bos')], + ['svc3', shaka.util.LanguageUtils.normalize('cze')]] ); expect(stream1.closedCaptions).toEqual(expectedClosedCaptions1); expect(stream2.closedCaptions).toEqual(expectedClosedCaptions2); - expect(stream3.closedCaptions).toEqual(expectedClosedCaptions3); + }); + + it('correctly parses CEA-708 caption tags with service numbers and languages', + async () => { + const source = [ + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + + fakeNetEngine.setResponseText('dummy://foo', source); + + /** @type {shaka.extern.Manifest} */ + const manifest = await parser.start('dummy://foo', playerInterface); + const stream = manifest.variants[0].video; + const expectedClosedCaptions = new Map( + [['svc1', shaka.util.LanguageUtils.normalize('eng')], + ['svc3', shaka.util.LanguageUtils.normalize('swe')]] + ); + expect(stream.closedCaptions).toEqual(expectedClosedCaptions); + }); + + it('correctly parses CEA-708 caption tags without service #s and languages', + async () => { + const source = [ + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + + fakeNetEngine.setResponseText('dummy://foo', source); + + /** @type {shaka.extern.Manifest} */ + const manifest = await parser.start('dummy://foo', playerInterface); + const stream = manifest.variants[0].video; + const expectedClosedCaptions = new Map( + [['svc1', shaka.util.LanguageUtils.normalize('eng')], + ['svc2', shaka.util.LanguageUtils.normalize('swe')]] + ); + expect(stream.closedCaptions).toEqual(expectedClosedCaptions); }); it('Detects E-AC3 JOC content by SupplementalProperty', async () => { @@ -415,34 +456,55 @@ describe('DashParser Manifest', () => { expect(stream.mimeType).toBe('audio/eac3-joc'); }); - it('correctly parses closed captions without channel numbers', async () => { - const source = [ - '', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - '', - ].join('\n'); + it('correctly parses CEA-608 closed caption tags without channel numbers', + async () => { + const source = [ + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); - fakeNetEngine.setResponseText('dummy://foo', source); + fakeNetEngine.setResponseText('dummy://foo', source); - /** @type {shaka.extern.Manifest} */ - const manifest = await parser.start('dummy://foo', playerInterface); - const stream = manifest.variants[0].video; - const expectedClosedCaptions = new Map( - [['CC1', shaka.util.LanguageUtils.normalize('eng')], - ['CC3', shaka.util.LanguageUtils.normalize('swe')]] - ); - expect(stream.closedCaptions).toEqual(expectedClosedCaptions); - }); + /** @type {shaka.extern.Manifest} */ + const manifest = await parser.start('dummy://foo', playerInterface); + const stream1 = manifest.variants[0].video; + const stream2 = manifest.variants[1].video; + + const expectedClosedCaptions1 = new Map( + [['CC1', shaka.util.LanguageUtils.normalize('eng')], + ['CC3', shaka.util.LanguageUtils.normalize('swe')]] + ); + + const expectedClosedCaptions2 = new Map( + [ + ['CC1', shaka.util.LanguageUtils.normalize('eng')], + ['CC2', shaka.util.LanguageUtils.normalize('swe')], + ['CC3', shaka.util.LanguageUtils.normalize('fre')], + ['CC4', shaka.util.LanguageUtils.normalize('pol')], + ] + ); + + expect(stream1.closedCaptions).toEqual(expectedClosedCaptions1); + expect(stream2.closedCaptions).toEqual(expectedClosedCaptions2); + }); - it('correctly parses closed captions with no channel and language info', + it('correctly parses CEA-608 caption tags with no channel and language info', async () => { const source = [ '', diff --git a/test/media/streaming_engine_unit.js b/test/media/streaming_engine_unit.js index 5bfce6dda3f..9e883795568 100644 --- a/test/media/streaming_engine_unit.js +++ b/test/media/streaming_engine_unit.js @@ -2813,7 +2813,7 @@ describe('StreamingEngine', () => { stream.useSegmentTemplate('video-120-%d.mp4', 10); }); manifest.addTextStream(3, (stream) => { - stream.mimeType = shaka.util.MimeUtils.CLOSED_CAPTION_MIMETYPE; + stream.mimeType = shaka.util.MimeUtils.CEA608_CLOSED_CAPTION_MIMETYPE; }); }); diff --git a/test/test/util/cea_utils.js b/test/test/util/cea_utils.js index 8ff66ea64e9..a7c689b8631 100644 --- a/test/test/util/cea_utils.js +++ b/test/test/util/cea_utils.js @@ -18,8 +18,8 @@ shaka.test.CeaUtils = class { */ static createDefaultCue(startTime, endTime, payload) { const cue = new shaka.text.Cue(startTime, endTime, payload); - cue.color = shaka.cea.Cea608Memory.DEFAULT_TXT_COLOR; - cue.backgroundColor = shaka.cea.Cea608Memory.DEFAULT_BG_COLOR; + cue.color = shaka.cea.CeaUtils.DEFAULT_TXT_COLOR; + cue.backgroundColor = shaka.cea.CeaUtils.DEFAULT_BG_COLOR; return cue; } diff --git a/test/text/text_engine_unit.js b/test/text/text_engine_unit.js index e45f0167627..3fcf26f00b4 100644 --- a/test/text/text_engine_unit.js +++ b/test/text/text_engine_unit.js @@ -60,9 +60,11 @@ describe('TextEngine', () => { it('reports support when it\'s closed captions', () => { - const closedCaptionsType = - shaka.util.MimeUtils.CLOSED_CAPTION_MIMETYPE; - expect(TextEngine.isTypeSupported(closedCaptionsType)).toBe(true); + // Both CEA-608 and CEA-708 is supported by our closed caption parser. + expect(TextEngine.isTypeSupported( + shaka.util.MimeUtils.CEA608_CLOSED_CAPTION_MIMETYPE)).toBe(true); + expect(TextEngine.isTypeSupported( + shaka.util.MimeUtils.CEA708_CLOSED_CAPTION_MIMETYPE)).toBe(true); }); }); diff --git a/ui/text_selection.js b/ui/text_selection.js index a1ba1fd4386..e463f69d82c 100644 --- a/ui/text_selection.js +++ b/ui/text_selection.js @@ -167,12 +167,12 @@ shaka.ui.TextSelection = class extends shaka.ui.SettingsMenu { * @private */ async onTextTrackSelected_(track) { - // setTextTrackVisibility should be called after selectTextLanguage. - // selectTextLanguage sets a text stream, and setTextTrackVisiblity(true) + // setTextTrackVisibility should be called after selectTextTrack. + // selectTextTrack sets a text stream, and setTextTrackVisiblity(true) // will set a text stream if it isn't already set. Consequently, reversing // the order of these calls makes two languages display simultaneously // if captions are turned off -> on in a different language. - this.player.selectTextLanguage(track.language, track.roles[0]); + this.player.selectTextTrack(track); await this.player.setTextTrackVisibility(true); }