'use strict'; var segments = require('data-files!segments'); var window = require('global/window'), captionStream, m2ts = require('../lib/m2ts'), mp4 = require('../lib/mp4'), QUnit = require('qunit'), seiNalUnitGenerator = require('./utils/sei-nal-unit-generator'), makeSeiFromCaptionPacket = seiNalUnitGenerator.makeSeiFromCaptionPacket, makeSeiFromMultipleCaptionPackets = seiNalUnitGenerator.makeSeiFromMultipleCaptionPackets, characters = seiNalUnitGenerator.characters, packetHeader708 = seiNalUnitGenerator.packetHeader708, displayWindows708 = seiNalUnitGenerator.displayWindows708, cc708PinkUnderscore = require('./utils/cc708-pink-underscore'), cc708Korean = require('./utils/cc708-korean'), sintelCaptions = segments['sintel-captions.ts'](), mixed608708Captions = require('./utils/mixed-608-708-captions.js'), multiChannel608Captions = segments['multi-channel-608-captions.ts'](); QUnit.module('Caption Stream', { beforeEach: function() { captionStream = new m2ts.CaptionStream(); } }); QUnit.test('parses SEIs messages larger than 255 bytes', function(assert) { var packets = [], data; captionStream.ccStreams_[0].push = function(packet) { packets.push(packet); }; // set data channel 1 active for field 1 captionStream.activeCea608Channel_[0] = 0; data = new Uint8Array(268); data[0] = 0x04; // payload_type === user_data_registered_itu_t_t35 data[1] = 0xff; // payload_size data[2] = 0x0d; // payload_size data[3] = 181; // itu_t_t35_country_code data[4] = 0x00; data[5] = 0x31; // itu_t_t35_provider_code data[6] = 0x47; data[7] = 0x41; data[8] = 0x39; data[9] = 0x34; // user_identifier, "GA94" data[10] = 0x03; // user_data_type_code, 0x03 is cc_data data[11] = 0xc1; // process_cc_data, cc_count data[12] = 0xff; // reserved data[13] = 0xfc; // cc_valid, cc_type (608, field 1) data[14] = 0xff; // cc_data_1 with parity bit set data[15] = 0x0e; // cc_data_2 without parity bit set data[16] = 0xff; // marker_bits captionStream.push({ nalUnitType: 'sei_rbsp', escapedRBSP: data }); captionStream.flush(); assert.equal(packets.length, 1, 'parsed a caption'); }); QUnit.test('parses SEIs containing multiple messages', function(assert) { var packets = [], data; captionStream.ccStreams_[0].push = function(packet) { packets.push(packet); }; // set data channel 1 active for field 1 captionStream.activeCea608Channel_[0] = 0; data = new Uint8Array(22); data[0] = 0x01; // payload_type !== user_data_registered_itu_t_t35 data[1] = 0x04; // payload_size data[6] = 0x04; // payload_type === user_data_registered_itu_t_t35 data[7] = 0x0d; // payload_size data[8] = 181; // itu_t_t35_country_code data[9] = 0x00; data[10] = 0x31; // itu_t_t35_provider_code data[11] = 0x47; data[12] = 0x41; data[13] = 0x39; data[14] = 0x34; // user_identifier, "GA94" data[15] = 0x03; // user_data_type_code, 0x03 is cc_data data[16] = 0xc1; // process_cc_data, cc_count data[17] = 0xff; // reserved data[18] = 0xfc; // cc_valid, cc_type (608, field 1) data[19] = 0xff; // cc_data_1 with parity bit set data[20] = 0x0e; // cc_data_2 without parity bit set data[21] = 0xff; // marker_bits captionStream.push({ nalUnitType: 'sei_rbsp', escapedRBSP: data }); captionStream.flush(); assert.equal(packets.length, 1, 'parsed a caption'); }); QUnit.test('parses SEIs containing multiple messages of type user_data_registered_itu_t_t35', function(assert) { var packets = [], data; captionStream.ccStreams_[0].push = function(packet) { packets.push(packet); }; // set data channel 1 active for field 1 captionStream.activeCea608Channel_[0] = 0; data = new Uint8Array(33); data[0] = 0x01; // payload_type !== user_data_registered_itu_t_t35 data[1] = 0x04; // payload_size // https://www.etsi.org/deliver/etsi_ts/101100_101199/101154/01.11.01_60/ts_101154v011101p.pdf#page=117 data[6] = 0x04; // payload_type === user_data_registered_itu_t_t35 data[7] = 0x09; // payload_size data[8] = 181; // itu_t_t35_country_code data[9] = 0x00; data[10] = 0x31; // itu_t_t35_provider_code data[11] = 0x44; data[12] = 0x54; data[13] = 0x47; data[14] = 0x31; // user_identifier, "DTG1" data[15] = 0x11; // zero_bit (b7), active_format_flag (b6), reserved (b5-b0) data[16] = 0xF0; // reserved (b7-b4), active_format (b3-b0) data[17] = 0x04; // payload_type === user_data_registered_itu_t_t35 data[18] = 0x0d; // payload_size data[19] = 181; // itu_t_t35_country_code data[20] = 0x00; data[21] = 0x31; // itu_t_t35_provider_code data[22] = 0x47; data[23] = 0x41; data[24] = 0x39; data[25] = 0x34; // user_identifier, "GA94" data[26] = 0x03; // user_data_type_code, 0x03 is cc_data data[27] = 0xc1; // process_cc_data, cc_count data[28] = 0xff; // reserved data[29] = 0xfc; // cc_valid, cc_type (608, field 1) data[30] = 0xff; // cc_data_1 with parity bit set data[31] = 0x0e; // cc_data_2 without parity bit set data[32] = 0xff; // marker_bits captionStream.push({ nalUnitType: 'sei_rbsp', escapedRBSP: data }); captionStream.flush(); assert.equal(packets.length, 1, 'ignored DTG1 payload, parsed a GA94 caption'); }); QUnit.test('does not throw error if only invalid payloads', function(assert) { var packets = [], data; captionStream.ccStreams_[0].push = function(packet) { packets.push(packet); }; // set data channel 1 active for field 1 captionStream.activeCea608Channel_[0] = 0; data = new Uint8Array(33); data[0] = 0x01; // payload_type !== user_data_registered_itu_t_t35 data[1] = 0x04; // payload_size // https://www.etsi.org/deliver/etsi_ts/101100_101199/101154/01.11.01_60/ts_101154v011101p.pdf#page=117 data[6] = 0x04; // payload_type === user_data_registered_itu_t_t35 data[7] = 0x09; // payload_size data[8] = 181; // itu_t_t35_country_code data[9] = 0x00; data[10] = 0x31; // itu_t_t35_provider_code data[11] = 0x44; data[12] = 0x54; data[13] = 0x47; data[14] = 0x31; // user_identifier, "DTG1" data[15] = 0x11; // zero_bit (b7), active_format_flag (b6), reserved (b5-b0) data[16] = 0xF0; // reserved (b7-b4), active_format (b3-b0) data[17] = 0x04; // payload_type === user_data_registered_itu_t_t35 data[18] = 0x0d; // payload_size data[19] = 181; // itu_t_t35_country_code data[20] = 0x00; data[21] = 0x31; // itu_t_t35_provider_code data[22] = 0x47; data[23] = 0x41; data[24] = 0x39; captionStream.push({ nalUnitType: 'sei_rbsp', escapedRBSP: data }); captionStream.flush(); assert.equal(packets.length, 0, 'ignored DTG1 payload'); }); QUnit.test('ignores SEIs that do not have type user_data_registered_itu_t_t35', function(assert) { var captions = []; captionStream.on('data', function(caption) { captions.push(caption); }); captionStream.push({ nalUnitType: 'sei_rbsp', escapedRBSP: new Uint8Array([ 0x05 // payload_type !== user_data_registered_itu_t_t35 ]) }); assert.equal(captions.length, 0, 'ignored the unknown payload type'); }); QUnit.test('parses a minimal example of caption data', function(assert) { var packets = []; captionStream.ccStreams_[0].push = function(packet) { packets.push(packet); }; // set data channel 1 active for field 1 captionStream.activeCea608Channel_[0] = 0; captionStream.push({ nalUnitType: 'sei_rbsp', escapedRBSP: new Uint8Array([ 0x04, // payload_type === user_data_registered_itu_t_t35 0x0d, // payload_size 181, // itu_t_t35_country_code 0x00, 0x31, // itu_t_t35_provider_code 0x47, 0x41, 0x39, 0x34, // user_identifier, "GA94" 0x03, // user_data_type_code, 0x03 is cc_data // 110 00001 0xc1, // process_cc_data, cc_count 0xff, // reserved // 1111 1100 0xfc, // cc_valid, cc_type (608, field 1) 0xff, // cc_data_1 with parity bit set 0x0e, // cc_data_2 without parity bit set 0xff // marker_bits ]) }); captionStream.flush(); assert.equal(packets.length, 1, 'parsed a caption packet'); }); QUnit.test('can be parsed from a segment', function(assert) { var transmuxer = new mp4.Transmuxer(), captions = []; // Setting the BMDT to ensure that captions and id3 tags are not // time-shifted by this value when they are output and instead are // zero-based transmuxer.setBaseMediaDecodeTime(100000); transmuxer.on('data', function(data) { if (data.captions) { captions = captions.concat(data.captions); } }); transmuxer.push(sintelCaptions); transmuxer.flush(); assert.equal(captions.length, 2, 'parsed two captions'); assert.equal(captions[0].content[0].text.indexOf('ASUKA'), 0, 'parsed the start of the first caption'); assert.ok(captions[0].content[0].text.indexOf('Japanese') > 0, 'parsed the end of the first caption'); assert.equal(captions[0].startTime, 1, 'parsed the start time'); assert.equal(captions[0].endTime, 4, 'parsed the end time'); }); QUnit.test('dispatches caption track information', function(assert) { var transmuxer = new mp4.Transmuxer(), captions = [], captionStreams = {}; // Setting the BMDT to ensure that captions and id3 tags are not // time-shifted by this value when they are output and instead are // zero-based transmuxer.setBaseMediaDecodeTime(100000); transmuxer.on('data', function(data) { if (data.captions) { captions = captions.concat(data.captions); for (var trackId in data.captionStreams) { captionStreams[trackId] = true; } } }); transmuxer.push(multiChannel608Captions); transmuxer.flush(); assert.deepEqual(captionStreams, {CC1: true, CC3: true}, 'found captions in CC1 and CC3'); assert.equal(captions.length, 4, 'parsed eight captions'); assert.equal(captions[0].content[0].text, 'être une période de questions', 'parsed the text of the first caption in CC3'); assert.equal(captions[1].content[0].text, 'PERIOD, FOLKS.', 'parsed the text of the first caption in CC1'); }); QUnit.test('sorting is fun', function(assert) { var packets, captions, seiNals; packets = [ // Send another command so that the second EOC isn't ignored { pts: 10 * 1000, ccData: 0x1420, type: 0 }, // RCL, resume caption loading { pts: 1000, ccData: 0x1420, type: 0 }, // 'test string #1' { pts: 1000, ccData: characters('te'), type: 0 }, { pts: 1000, ccData: characters('st'), type: 0 }, { pts: 1000, ccData: characters(' s'), type: 0 }, // 'test string #2' { pts: 10 * 1000, ccData: characters('te'), type: 0 }, { pts: 10 * 1000, ccData: characters('st'), type: 0 }, { pts: 10 * 1000, ccData: characters(' s'), type: 0 }, // 'test string #1' continued { pts: 1000, ccData: characters('tr'), type: 0 }, { pts: 1000, ccData: characters('in'), type: 0 }, { pts: 1000, ccData: characters('g '), type: 0 }, { pts: 1000, ccData: characters('#1'), type: 0 }, // 'test string #2' continued { pts: 10 * 1000, ccData: characters('tr'), type: 0 }, { pts: 10 * 1000, ccData: characters('in'), type: 0 }, { pts: 10 * 1000, ccData: characters('g '), type: 0 }, { pts: 10 * 1000, ccData: characters('#2'), type: 0 }, // EOC, End of Caption. End display { pts: 10 * 1000, ccData: 0x142f, type: 0 }, // EOC, End of Caption. Finished transmitting, begin display { pts: 1000, ccData: 0x142f, type: 0 }, // Send another command so that the second EOC isn't ignored { pts: 20 * 1000, ccData: 0x1420, type: 0 }, // EOC, End of Caption. End display { pts: 20 * 1000, ccData: 0x142f, type: 0 } ]; captions = []; seiNals = packets.map(makeSeiFromCaptionPacket); captionStream.on('data', function(caption) { captions.push(caption); }); seiNals.forEach(captionStream.push, captionStream); captionStream.flush(); assert.equal(captions.length, 2, 'detected two captions'); assert.equal(captions[0].content[0].text, 'test string #1', 'parsed caption 1'); assert.equal(captions[1].content[0].text, 'test string #2', 'parsed caption 2'); }); QUnit.test('drops duplicate segments', function(assert) { var packets, captions, seiNals; packets = [ { pts: 1000, dts: 1000, captions: [ {ccData: 0x1420, type: 0 }, // RCL (resume caption loading) {ccData: 0x1420, type: 0 }, // RCL, duplicate as per spec {ccData: characters('te'), type: 0 }, {ccData: characters('st'), type: 0 } ] }, { pts: 2000, dts: 2000, captions: [ {ccData: characters(' s'), type: 0 }, {ccData: characters('tr'), type: 0 }, {ccData: characters('in'), type: 0 } ] }, { pts: 3000, dts: 3000, captions: [ {ccData: characters('g '), type: 0 }, {ccData: characters('da'), type: 0 }, {ccData: characters('ta'), type: 0 } ] }, { pts: 2000, dts: 2000, captions: [ {ccData: characters(' s'), type: 0 }, {ccData: characters('tr'), type: 0 }, {ccData: characters('in'), type: 0 } ] }, { pts: 3000, dts: 3000, captions: [ {ccData: characters('g '), type: 0 }, {ccData: characters('da'), type: 0 }, {ccData: characters('ta'), type: 0 } ] }, { pts: 4000, dts: 4000, captions: [ {ccData: 0x142f, type: 0 }, // EOC (end of caption), mark display start {ccData: 0x142f, type: 0 }, // EOC, duplicate as per spec {ccData: 0x142f, type: 0 }, // EOC, mark display end and flush {ccData: 0x142f, type: 0 } // EOC, duplicate as per spec ] } ]; captions = []; seiNals = packets.map(makeSeiFromMultipleCaptionPackets); captionStream.on('data', function(caption) { captions.push(caption); }); seiNals.forEach(captionStream.push, captionStream); captionStream.flush(); assert.equal(captions.length, 1, 'detected one caption'); assert.equal(captions[0].content[0].text, 'test string data', 'parsed caption properly'); }); QUnit.test('drops duplicate segments with multi-segment DTS values', function(assert) { var packets, captions, seiNals; packets = [ { pts: 1000, dts: 1000, captions: [ {ccData: 0x1420, type: 0 }, // RCL (resume caption loading) {ccData: 0x1420, type: 0 }, // RCL, duplicate as per spec {ccData: characters('te'), type: 0 } ] }, { pts: 2000, dts: 2000, captions: [ {ccData: characters('st'), type: 0 }, {ccData: characters(' s'), type: 0 } ] }, { pts: 2000, dts: 2000, captions: [ {ccData: characters('tr'), type: 0 }, {ccData: characters('in'), type: 0 } ] }, { pts: 3000, dts: 3000, captions: [ {ccData: characters('g '), type: 0 }, {ccData: characters('da'), type: 0 }, {ccData: characters('ta'), type: 0 } ] }, { pts: 2000, dts: 2000, captions: [ {ccData: characters(' s'), type: 0 }, {ccData: characters('tr'), type: 0 }, {ccData: characters('in'), type: 0 } ] }, { pts: 3000, dts: 3000, captions: [ {ccData: characters('g '), type: 0 }, {ccData: characters('da'), type: 0 }, {ccData: characters('ta'), type: 0 } ] }, { pts: 3000, dts: 3000, captions: [ {ccData: characters(' s'), type: 0 }, {ccData: characters('tu'), type: 0 }, {ccData: characters('ff'), type: 0 } ] }, { pts: 4000, dts: 4000, captions: [ {ccData: 0x142f, type: 0 }, // EOC (end of caption) // EOC not duplicated for robustness testing {ccData: 0x1420, type: 0 } // RCL (resume caption loading) ] }, { pts: 5000, dts: 5000, captions: [ {ccData: 0x1420, type: 0 }, // RCL, duplicated as per spec {ccData: characters(' a'), type: 0 }, {ccData: characters('nd'), type: 0 } ] }, { pts: 6000, dts: 6000, captions: [ {ccData: characters(' e'), type: 0 }, {ccData: characters('ve'), type: 0 } ] }, { pts: 6000, dts: 6000, captions: [ {ccData: characters('n '), type: 0 }, {ccData: characters('mo'), type: 0 } ] }, { pts: 6000, dts: 6000, captions: [ {ccData: characters('re'), type: 0 }, {ccData: characters(' t'), type: 0 } ] }, { pts: 5000, dts: 5000, captions: [ {ccData: 0x1420, type: 0 }, // RCL, duplicated as per spec {ccData: characters(' a'), type: 0 }, {ccData: characters('nd'), type: 0 } ] }, { pts: 6000, dts: 6000, captions: [ {ccData: characters(' e'), type: 0 }, {ccData: characters('ve'), type: 0 } ] }, { pts: 6000, dts: 6000, captions: [ {ccData: characters('n '), type: 0 }, {ccData: characters('mo'), type: 0 } ] }, { pts: 6000, dts: 6000, captions: [ {ccData: characters('re'), type: 0 }, {ccData: characters(' t'), type: 0 } ] }, { pts: 6000, dts: 6000, captions: [ {ccData: characters('ex'), type: 0 }, {ccData: characters('t '), type: 0 } ] }, { pts: 6000, dts: 6000, captions: [ {ccData: characters('da'), type: 0 }, {ccData: characters('ta'), type: 0 } ] }, { pts: 7000, dts: 7000, captions: [ {ccData: characters(' h'), type: 0 }, {ccData: characters('er'), type: 0 } ] }, { pts: 8000, dts: 8000, captions: [ {ccData: characters('e!'), type: 0 }, {ccData: 0x142f, type: 0 }, // EOC (end of caption), mark display start {ccData: 0x142f, type: 0 }, // EOC, duplicated as per spec {ccData: 0x142f, type: 0 } // EOC, mark display end and flush // EOC not duplicated for robustness testing ] } ]; captions = []; seiNals = packets.map(makeSeiFromMultipleCaptionPackets); captionStream.on('data', function(caption) { captions.push(caption); }); seiNals.forEach(captionStream.push, captionStream); captionStream.flush(); assert.equal(captions.length, 2, 'detected two captions'); assert.equal(captions[0].content[0].text, 'test string data stuff', 'parsed caption properly'); assert.equal(captions[1].content[0].text, 'and even more text data here!', 'parsed caption properly'); }); QUnit.test('doesn\'t ignore older segments if reset', function(assert) { var firstPackets, secondPackets, captions, seiNals1, seiNals2; firstPackets = [ { pts: 11000, dts: 11000, captions: [ {ccData: 0x1420, type: 0 }, // RCL (resume caption loading) {ccData: 0x1420, type: 0 }, // RCL, duplicated as per spec {ccData: characters('te'), type: 0 } ] }, { pts: 12000, dts: 12000, captions: [ {ccData: characters('st'), type: 0 }, {ccData: characters(' s'), type: 0 } ] }, { pts: 12000, dts: 12000, captions: [ {ccData: characters('tr'), type: 0 }, {ccData: characters('in'), type: 0 } ] }, { pts: 13000, dts: 13000, captions: [ {ccData: characters('g '), type: 0 }, {ccData: characters('da'), type: 0 }, {ccData: characters('ta'), type: 0 } ] } ]; secondPackets = [ { pts: 1000, dts: 1000, captions: [ {ccData: 0x1420, type: 0 }, // RCL (resume caption loading) {ccData: 0x1420, type: 0 }, // RCL, duplicated as per spec {ccData: characters('af'), type: 0 } ] }, { pts: 2000, dts: 2000, captions: [ {ccData: characters('te'), type: 0 }, {ccData: characters('r '), type: 0 }, {ccData: characters('re'), type: 0 } ] }, { pts: 3000, dts: 3000, captions: [ {ccData: characters('se'), type: 0 }, {ccData: characters('t '), type: 0 }, {ccData: characters('da'), type: 0 } ] }, { pts: 3000, dts: 3000, captions: [ {ccData: characters('ta'), type: 0 }, {ccData: characters('!!'), type: 0 } ] }, { pts: 4000, dts: 4000, captions: [ {ccData: 0x142f, type: 0 }, // EOC (end of caption), mark display start {ccData: 0x142f, type: 0 }, // EOC, duplicated as per spec {ccData: 0x142f, type: 0 } // EOC, mark display end and flush // EOC not duplicated for robustness testing ] } ]; captions = []; seiNals1 = firstPackets.map(makeSeiFromMultipleCaptionPackets); seiNals2 = secondPackets.map(makeSeiFromMultipleCaptionPackets); captionStream.on('data', function(caption) { captions.push(caption); }); seiNals1.forEach(captionStream.push, captionStream); captionStream.flush(); assert.equal(captionStream.latestDts_, 13000, 'DTS is tracked correctly'); captionStream.reset(); assert.equal(captionStream.latestDts_, null, 'DTS tracking was reset'); seiNals2.forEach(captionStream.push, captionStream); captionStream.flush(); assert.equal(captionStream.latestDts_, 4000, 'DTS is tracked correctly'); assert.equal(captions.length, 1, 'detected one caption'); assert.equal(captions[0].content[0].text, 'after reset data!!', 'parsed caption properly'); }); QUnit.test('extracts all theoretical caption channels', function(assert) { var captions = []; captionStream.ccStreams_.forEach(function(cc) { cc.on('data', function(caption) { captions.push(caption); }); }); // RU2 = roll-up, 2 rows // CR = carriage return var packets = [ { pts: 1000, type: 0, ccData: 0x1425 }, // RU2 (sets CC1) { pts: 2000, type: 0, ccData: characters('1a') }, // CC1 { pts: 3000, type: 0, ccData: 0x1c25 }, // RU2 (sets CC2) { pts: 4000, type: 1, ccData: 0x1525 }, // RU2 (sets CC3) { pts: 5000, type: 1, ccData: characters('3a') }, // CC3 // this next one tests if active channel is tracked per-field // instead of globally { pts: 6000, type: 0, ccData: characters('2a') }, // CC2 { pts: 7000, type: 1, ccData: 0x1d25 }, // RU2 (sets CC4) { pts: 8000, type: 1, ccData: characters('4a') }, // CC4 { pts: 9000, type: 1, ccData: characters('4b') }, // CC4 { pts: 10000, type: 0, ccData: 0x142d }, // CR (sets + flushes CC1) { pts: 11000, type: 0, ccData: 0x1c2d }, // CR (sets + flushes CC2) { pts: 12000, type: 0, ccData: 0x1425 }, // RU2 (sets CC1) { pts: 13000, type: 0, ccData: characters('1b') }, // CC1 { pts: 14000, type: 0, ccData: characters('1c') }, // CC1 { pts: 15000, type: 0, ccData: 0x142d }, // CR (sets + flushes CC1) { pts: 16000, type: 1, ccData: 0x152d }, // CR (sets + flushes CC3) { pts: 17000, type: 1, ccData: 0x1d2d }, // CR (sets + flushes CC4) { pts: 18000, type: 0, ccData: 0x1c25 }, // RU2 (sets CC2) { pts: 19000, type: 0, ccData: characters('2b') }, // CC2 { pts: 20000, type: 0, ccData: 0x1c2d } // CR (sets + flushes CC2) ]; var seiNals = packets.map(makeSeiFromCaptionPacket); seiNals.forEach(captionStream.push, captionStream); captionStream.flush(); assert.equal(captions.length, 6, 'got all captions'); assert.equal(captions[0].content[0].text, '1a', 'cc1 first row'); assert.equal(captions[1].content[0].text, '2a', 'cc2 first row'); assert.equal(captions[2].content[0].text, '1a', 'cc1 first row'); assert.equal(captions[2].content[1].text, '1b1c', 'cc1 second row'); assert.equal(captions[3].content[0].text, '3a', 'cc3 first row'); assert.equal(captions[4].content[0].text, '4a4b', 'cc4 first row'); assert.equal(captions[5].content[0].text, '2a', 'cc2 first row'); assert.equal(captions[5].content[1].text, '2b', 'cc2 second row'); }); QUnit.test('drops data until first command that sets activeChannel for a field', function(assert) { var captions = []; captionStream.ccStreams_.forEach(function(cc) { cc.on('data', function(caption) { captions.push(caption); }); }); var packets = [ // test that packets in same field and same data channel are dropped // before a control code that sets the data channel { pts: 0 * 1000, ccData: characters('no'), type: 0 }, { pts: 0 * 1000, ccData: characters('t '), type: 0 }, { pts: 0 * 1000, ccData: characters('th'), type: 0 }, { pts: 0 * 1000, ccData: characters('is'), type: 0 }, // EOC (end of caption), sets CC1 { pts: 1 * 1000, ccData: 0x142f, type: 0 }, // RCL (resume caption loading) { pts: 1 * 1000, ccData: 0x1420, type: 0 }, // EOC, if data wasn't dropped this would dispatch a caption { pts: 2 * 1000, ccData: 0x142f, type: 0 }, // RCL { pts: 3 * 1000, ccData: 0x1420, type: 0 }, { pts: 4 * 1000, ccData: characters('fi'), type: 0 }, { pts: 4 * 1000, ccData: characters('el'), type: 0 }, { pts: 4 * 1000, ccData: characters('d0'), type: 0 }, // EOC, mark display start { pts: 5 * 1000, ccData: 0x142f, type: 0 }, // EOC, duplicated as per spec { pts: 5 * 1000, ccData: 0x142f, type: 0 }, // EOC, mark display end and flush { pts: 6 * 1000, ccData: 0x142f, type: 0 }, // EOC not duplicated cuz not necessary // now switch to field 1 and test that packets in the same field // but DIFFERENT data channel are dropped { pts: 7 * 1000, ccData: characters('or'), type: 1 }, { pts: 7 * 1000, ccData: characters(' t'), type: 1 }, { pts: 7 * 1000, ccData: characters('hi'), type: 1 }, { pts: 7 * 1000, ccData: characters('s.'), type: 1 }, // EOC (end of caption, sets CC4) { pts: 8 * 1000, ccData: 0x1d2f, type: 1 }, // RCL (resume caption loading) { pts: 8 * 1000, ccData: 0x1d20, type: 1 }, // EOC, if data wasn't dropped this would dispatch a caption { pts: 9 * 1000, ccData: 0x1d2f, type: 1 }, // RCL { pts: 10 * 1000, ccData: 0x1d20, type: 1 }, { pts: 11 * 1000, ccData: characters('fi'), type: 1 }, { pts: 11 * 1000, ccData: characters('el'), type: 1 }, { pts: 11 * 1000, ccData: characters('d1'), type: 1 }, // EOC, mark display start { pts: 12 * 1000, ccData: 0x1d2f, type: 1 }, // EOC, duplicated as per spec { pts: 12 * 1000, ccData: 0x1d2f, type: 1 }, // EOC, mark display end and flush { pts: 13 * 1000, ccData: 0x1d2f, type: 1 } // EOC not duplicated cuz not necessary ]; var seiNals = packets.map(makeSeiFromCaptionPacket); seiNals.forEach(captionStream.push, captionStream); captionStream.flush(); assert.equal(captions.length, 2, 'received 2 captions'); assert.equal(captions[0].content[0].text, 'field0', 'received only confirmed field0 data'); assert.equal(captions[0].stream, 'CC1', 'caption went to right channel'); assert.equal(captions[1].content[0].text, 'field1', 'received only confirmed field1 data'); assert.equal(captions[1].stream, 'CC4', 'caption went to right channel'); }); QUnit.test('clears buffer and drops data until first command that sets activeChannel after reset', function(assert) { var firstPackets, secondPackets, captions, seiNals1, seiNals2; captions = []; firstPackets = [ // RCL (resume caption loading), CC1 { pts: 1 * 1000, ccData: 0x1420, type: 0 }, { pts: 2 * 1000, ccData: characters('fi'), type: 0 }, { pts: 2 * 1000, ccData: characters('el'), type: 0 }, { pts: 2 * 1000, ccData: characters('d0'), type: 0 }, // EOC (end of caption), swap text to displayed memory { pts: 3 * 1000, ccData: 0x142f, type: 0 }, { pts: 4 * 1000, ccData: characters('fi'), type: 0 }, { pts: 4 * 1000, ccData: characters('el'), type: 0 }, { pts: 4 * 1000, ccData: characters('d0'), type: 0 }, // RCL (resume caption loading), CC4 { pts: 5 * 1000, ccData: 0x1d20, type: 1 }, { pts: 6 * 1000, ccData: characters('fi'), type: 1 }, { pts: 6 * 1000, ccData: characters('el'), type: 1 }, { pts: 6 * 1000, ccData: characters('d1'), type: 1 }, // EOC (end of caption), swap text to displayed memory { pts: 7 * 1000, ccData: 0x1d2f, type: 1 }, { pts: 8 * 1000, ccData: characters('fi'), type: 1 }, { pts: 8 * 1000, ccData: characters('el'), type: 1 }, { pts: 8 * 1000, ccData: characters('d1'), type: 1 } ]; secondPackets = [ // following packets are dropped { pts: 9 * 1000, ccData: characters('no'), type: 0 }, { pts: 9 * 1000, ccData: characters('t '), type: 0 }, { pts: 9 * 1000, ccData: characters('th'), type: 0 }, { pts: 9 * 1000, ccData: characters('is'), type: 0 }, { pts: 10 * 1000, ccData: characters('or'), type: 1 }, { pts: 10 * 1000, ccData: characters(' t'), type: 1 }, { pts: 10 * 1000, ccData: characters('hi'), type: 1 }, { pts: 10 * 1000, ccData: characters('s.'), type: 1 }, // EOC (end of caption), sets CC1 { pts: 11 * 1000, ccData: 0x142f, type: 0 }, // RCL (resume caption loading), CC1 { pts: 11 * 1000, ccData: 0x1420, type: 0 }, // EOC, sets CC4 { pts: 12 * 1000, ccData: 0x1d2f, type: 1 }, // RCL, CC4 { pts: 12 * 1000, ccData: 0x1d20, type: 1 }, // EOC, CC1, would dispatch caption if packets weren't ignored { pts: 13 * 1000, ccData: 0x142f, type: 0 }, // RCL, CC1 { pts: 13 * 1000, ccData: 0x1420, type: 0 }, // EOC, CC4, would dispatch caption if packets weren't ignored { pts: 14 * 1000, ccData: 0x1d2f, type: 1 }, // RCL, CC4 { pts: 14 * 1000, ccData: 0x1d20, type: 1 }, { pts: 18 * 1000, ccData: characters('bu'), type: 0 }, { pts: 18 * 1000, ccData: characters('t '), type: 0 }, { pts: 18 * 1000, ccData: characters('th'), type: 0 }, { pts: 18 * 1000, ccData: characters('is'), type: 0 }, { pts: 19 * 1000, ccData: characters('an'), type: 1 }, { pts: 19 * 1000, ccData: characters('d '), type: 1 }, { pts: 19 * 1000, ccData: characters('th'), type: 1 }, { pts: 19 * 1000, ccData: characters('is'), type: 1 }, // EOC (end of caption), CC1, mark caption 1 start { pts: 20 * 1000, ccData: 0x142f, type: 0 }, // EOC, CC1, duplicated as per spec { pts: 20 * 1000, ccData: 0x142f, type: 0 }, // EOC, CC1, mark caption 1 end and dispatch { pts: 21 * 1000, ccData: 0x142f, type: 0 }, // No duplicate EOC cuz not necessary // EOC, CC4, mark caption 2 start { pts: 22 * 1000, ccData: 0x1d2f, type: 1 }, // EOC, CC4, duplicated as per spec { pts: 22 * 1000, ccData: 0x1d2f, type: 1 }, // EOC, CC4, mark caption 2 end and dispatch { pts: 23 * 1000, ccData: 0x1d2f, type: 1 } // No duplicate EOC cuz not necessary ]; seiNals1 = firstPackets.map(makeSeiFromCaptionPacket); seiNals2 = secondPackets.map(makeSeiFromCaptionPacket); captionStream.on('data', function(caption) { captions.push(caption); }); seiNals1.forEach(captionStream.push, captionStream); captionStream.flush(); assert.equal(captionStream.ccStreams_[0].nonDisplayed_[14].text, 'field0', 'there is data in non-displayed memory for field 0 before reset'); assert.equal(captionStream.ccStreams_[3].nonDisplayed_[14].text, 'field1', 'there is data in non-displayed memory for field 1 before reset'); assert.equal(captionStream.ccStreams_[0].displayed_[14].text, 'field0', 'there is data in displayed memory for field 0 before reset'); assert.equal(captionStream.ccStreams_[3].displayed_[14].text, 'field1', 'there is data in displayed memory for field 1 before reset'); captionStream.reset(); assert.equal(captionStream.ccStreams_[0].nonDisplayed_[14].text, '', 'there is no data in non-displayed memory for field 0 after reset'); assert.equal(captionStream.ccStreams_[3].nonDisplayed_[14].text, '', 'there is no data in non-displayed memory for field 1 after reset'); assert.equal(captionStream.ccStreams_[0].displayed_[14].text, '', 'there is no data in displayed memory for field 0 after reset'); assert.equal(captionStream.ccStreams_[3].displayed_[14].text, '', 'there is no data in displayed memory for field 1 after reset'); seiNals2.forEach(captionStream.push, captionStream); captionStream.flush(); assert.equal(captions.length, 2, 'detected two captions'); assert.equal(captions[0].content[0].text, 'but this', 'parsed caption properly'); assert.equal(captions[0].stream, 'CC1', 'caption went to right channel'); assert.equal(captions[1].content[0].text, 'and this', 'parsed caption properly'); assert.equal(captions[1].stream, 'CC4', 'caption went to right channel'); }); QUnit.test("don't mess up 608 captions when 708 are present", function(assert) { var captions = []; captionStream.ccStreams_.forEach(function(cc) { cc.on('data', function(caption) { captions.push(caption); }); }); var seiNals = mixed608708Captions.map(makeSeiFromCaptionPacket); seiNals.forEach(captionStream.push, captionStream); captionStream.flush(); assert.equal(captions.length, 3, 'parsed three captions'); // first caption stream assert.equal(captions[0].content[0].text, 'BUT IT\'S NOT SUFFERING', 'first stream: parsed first content text correctly'); assert.equal(captions[0].content[1].text, 'RIGHW.', 'first stream: parsed second content text correctly'); // there is also bad data in the captions, but the null ascii character is removed // second caption stream assert.equal(captions[1].content[0].text, 'IT\'S NOT A THREAT TO ANYBODY.', 'second stream: parsed content text correctly'); // third stream assert.equal(captions[2].content[0].text, 'WE TRY NOT TO PUT AN ANIMAL DOWN', 'third stream: parsed first content text correctly'); assert.equal(captions[2].content[1].text, 'IF WE DON\'T HAVE TO.', 'third stream: parsed second content text correctly'); }); QUnit.test("both 608 and 708 captions are available by default", function(assert) { var cc608 = []; var cc708 = []; captionStream.on('data', function(caption) { if (caption.stream === 'CC1') { cc608.push(caption); } else { cc708.push(caption); } }); var seiNals = mixed608708Captions.map(makeSeiFromCaptionPacket); seiNals.forEach(captionStream.push, captionStream); captionStream.flush(); assert.equal(cc608.length, 3, 'parsed three 608 cues'); assert.equal(cc708.length, 3, 'parsed three 708 cues'); }); QUnit.test("708 parsing can be turned off", function(assert) { captionStream.reset(); captionStream = new m2ts.CaptionStream({ parse708captions: false }); var cc608 = []; var cc708 = []; captionStream.on('data', function(caption) { if (caption.stream === 'CC1') { cc608.push(caption); } else { cc708.push(caption); } }); var seiNals = mixed608708Captions.map(makeSeiFromCaptionPacket); seiNals.forEach(captionStream.push, captionStream); captionStream.flush(); assert.equal(cc608.length, 3, 'parsed three 608 cues'); assert.equal(cc708.length, 0, 'did not parse any 708 cues'); }); QUnit.test('ignores XDS and Text packets', function(assert) { var captions = []; captionStream.on('data', function(caption) { captions.push(caption); }); [ // RCL, resume caption loading, CC3 { pts: 1000, ccData: 0x1520, type: 1 }, { pts: 2000, ccData: characters('hi'), type: 1 }, // EOC, End of Caption { pts: 3000, ccData: 0x152f, type: 1 }, // Send another command so that the second EOC isn't ignored { pts: 3000, ccData: 0x152f, type: 1 }, // EOC, End of Caption { pts: 4000, ccData: 0x152f, type: 1 }, // ENM, Erase Non-Displayed Memory { pts: 4000, ccData: 0x152e, type: 1 }, // RCL, resume caption loading, CC1 { pts: 5000, ccData: 0x1420, type: 0 } ].map(makeSeiFromCaptionPacket).forEach(captionStream.push, captionStream); captionStream.flush(); assert.equal(captionStream.activeCea608Channel_[0], 0, 'field 1: CC1 is active'); assert.equal(captionStream.activeCea608Channel_[1], 0, 'field 2: CC3 is active'); [ // TR, text restart, CC1 { pts: 5000, ccData: 0x142a, type: 0 }, { pts: 6000, ccData: characters('tx'), type: 0 } ].map(makeSeiFromCaptionPacket).forEach(captionStream.push, captionStream); captionStream.flush(); assert.equal(captionStream.activeCea608Channel_[0], null, 'field 1: disabled'); assert.equal(captionStream.activeCea608Channel_[1], 0, 'field 2: CC3 is active'); [ // EOC, End of Caption { pts: 7000, ccData: 0x142f, type: 0 }, // Send another command so that the second EOC isn't ignored { pts: 7000, ccData: 0x142f, type: 0 }, // EOC, End of Caption { pts: 8000, ccData: 0x142f, type: 0 }, // RCL, resume caption loading, CC3 { pts: 9000, ccData: 0x1520, type: 1 }, // XDS start, "current" class, program identification number type { pts: 10000, ccData: 0x0101, type: 1 }, { pts: 11000, ccData: characters('oh'), type: 1 }, // XDS end { pts: 12000, ccData: 0x0f00, type: 1 } ].map(makeSeiFromCaptionPacket).forEach(captionStream.push, captionStream); captionStream.flush(); assert.equal(captionStream.activeCea608Channel_[0], 0, 'field 1: CC1 is active'); assert.equal(captionStream.activeCea608Channel_[1], null, 'field 2: disabled'); [ // EOC, End of Caption { pts: 13000, ccData: 0x152f, type: 1 }, // Send another command so that the second EOC isn't ignored { pts: 13000, ccData: 0x152f, type: 1 } ].map(makeSeiFromCaptionPacket).forEach(captionStream.push, captionStream); captionStream.flush(); assert.equal(captions.length, 1, 'only parsed real caption'); assert.equal(captions[0].content[0].text, 'hi', 'caption is correct'); }); // Full character translation tests are below for Cea608Stream, they just only // test support for CC1. See those tests and the source code for more about the // mechanics of special and extended characters. QUnit.test('special and extended character codes work regardless of field and data channel', function(assert) { var packets, seiNals, captions = []; packets = [ // RU2 (roll-up, 2 rows), CC2 { ccData: 0x1c25, type: 0 }, // ® { ccData: 0x1930, type: 0 }, // CR (carriage return), CC2, flush caption { ccData: 0x1c2d, type: 0 }, // RU2, CC3 { ccData: 0x1525, type: 1 }, // " { ccData: 0x2200, type: 1 }, // « { ccData: 0x123e, type: 1 }, // CR, CC3, flush caption { ccData: 0x152d, type: 1 }, // RU2, CC4 { ccData: 0x1d25, type: 1 }, // " { ccData: 0x2200, type: 1 }, // » { ccData: 0x1a3f, type: 1 }, // CR, CC4, flush caption { ccData: 0x1d2d, type: 1 } ]; captionStream.on('data', function(caption) { captions.push(caption); }); seiNals = packets.map(makeSeiFromCaptionPacket); seiNals.forEach(captionStream.push, captionStream); captionStream.flush(); assert.deepEqual(captions[0].content[0].text, String.fromCharCode(0xae), 'CC2 special character correct'); assert.deepEqual(captions[1].content[0].text, String.fromCharCode(0xab), 'CC3 extended character correct'); assert.deepEqual(captions[2].content[0].text, String.fromCharCode(0xbb), 'CC4 extended character correct'); }); QUnit.test('number of roll up rows takes precedence over base row command', function(assert) { var captions = []; var packets = [ // RU2 (roll-up, 2 rows), CC1 { type: 0, ccData: 0x1425 }, // RU2, CC1 { type: 0, ccData: 0x1425 }, // PAC: row 1 (sets base row to row 1) { type: 0, ccData: 0x1170 }, // PAC: row 1 { type: 0, ccData: 0x1170 }, // - { type: 0, ccData: 0x2d00 }, // CR { type: 0, ccData: 0x14ad }, // CR { type: 0, ccData: 0x14ad }, // RU3 (roll-up, 3 rows), CC1 { type: 0, ccData: 0x1426 }, // RU3, CC1 { type: 0, ccData: 0x1426 }, // PAC, row 11 { type: 0, ccData: 0x13d0 }, // PAC, row 11 { type: 0, ccData: 0x13d0 }, // so { type: 0, ccData: 0x736f }, // CR { type: 0, ccData: 0x14ad }, // CR { type: 0, ccData: 0x14ad } ]; var seis; captionStream.on('data', function(caption) { captions.push(caption); }); seis = packets.map(makeSeiFromCaptionPacket); seis.forEach(captionStream.push, captionStream); captionStream.flush(); assert.deepEqual(captions[0].content[0].text, '-', 'RU2 caption is correct'); assert.deepEqual(captions[1].content[0].text, '-', 'first RU3 caption is correct'); assert.deepEqual(captions[1].content[1].text, 'so', 'second RU3 caption is correct'); packets = [ // switching from row 11 to 0 // PAC: row 0 (sets base row to row 0) { type: 0, ccData: 0x1140 }, // PAC: row 0 { type: 0, ccData: 0x1140 }, // CR { type: 0, ccData: 0x14ad }, // CR { type: 0, ccData: 0x14ad } ]; seis = packets.map(makeSeiFromCaptionPacket); seis.forEach(captionStream.push, captionStream); captionStream.flush(); assert.deepEqual(captions[2].content[0].text, '-', 'first RU3 caption is correct'); assert.deepEqual(captions[2].content[1].text, 'so', 'second RU3 caption is correct'); }); var cea608Stream; QUnit.module('CEA 608 Stream', { beforeEach: function() { cea608Stream = new m2ts.Cea608Stream(); } }); QUnit.skip('filters null data', function(assert) { assert.ok(false, 'not implemented'); }); QUnit.skip('removes parity bits', function(assert) { assert.ok(false, 'not implemented'); }); QUnit.test('converts non-ASCII character codes to ASCII', function(assert) { var packets, captions; packets = [ // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // ASCII exceptions { ccData: 0x2a5c, type: 0 }, { ccData: 0x5e5f, type: 0 }, { ccData: 0x607b, type: 0 }, { ccData: 0x7c7d, type: 0 }, { ccData: 0x7e7f, type: 0 }, // EOC, End of Caption { pts: 1000, ccData: 0x142f, type: 0 }, // Send another command so that the second EOC isn't ignored { ccData: 0x1420, type: 0 }, // EOC, End of Caption, clear the display { pts: 10 * 1000, ccData: 0x142f, type: 0 } ]; captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions[0].content[0].text, String.fromCharCode(0xe1, 0xe9, 0xed, 0xf3, 0xfa, 0xe7, 0xf7, 0xd1, 0xf1, 0x2588), 'translated non-standard characters'); }); QUnit.test('properly handles special character codes', function(assert) { var packets, captions; packets = [ // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // Special characters as defined by CEA-608 // see the CHARACTER_TRANSLATION hash in lib/m2ts/caption-stream for the // mapping table { ccData: 0x1130, type: 0 }, { ccData: 0x1131, type: 0 }, { ccData: 0x1132, type: 0 }, { ccData: 0x1133, type: 0 }, { ccData: 0x1134, type: 0 }, { ccData: 0x1135, type: 0 }, { ccData: 0x1136, type: 0 }, { ccData: 0x1137, type: 0 }, { ccData: 0x1138, type: 0 }, { ccData: 0x1139, type: 0 }, { ccData: 0x113a, type: 0 }, { ccData: 0x113b, type: 0 }, { ccData: 0x113c, type: 0 }, { ccData: 0x113d, type: 0 }, { ccData: 0x113e, type: 0 }, { ccData: 0x113f, type: 0 }, // EOC, End of Caption { pts: 1000, ccData: 0x142f, type: 0 }, // Send another command so that the second EOC isn't ignored { ccData: 0x1420, type: 0 }, // EOC, End of Caption, CC1, clear the display { pts: 10 * 1000, ccData: 0x142f, type: 0 } ]; captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions[0].content[0].text, String.fromCharCode(0xae, 0xb0, 0xbd, 0xbf, 0x2122, 0xa2, 0xa3, 0x266a, 0xe0, 0xa0, 0xe8, 0xe2, 0xea, 0xee, 0xf4, 0xfb), 'translated special characters'); }); QUnit.test('properly handles extended character codes', function(assert) { var packets, captions; packets = [ // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // Extended characters are defined in CEA-608 as a standard character, // which is followed by an extended character, and the standard character // gets deleted. // see the CHARACTER_TRANSLATION hash in lib/m2ts/caption-stream for the // mapping table { ccData: 0x2200, type: 0 }, { ccData: 0x123e, type: 0 }, { ccData: 0x4c41, type: 0 }, { ccData: 0x1230, type: 0 }, { ccData: 0x2d4c, type: 0 }, { ccData: 0x4100, type: 0 }, { ccData: 0x1338, type: 0 }, { ccData: 0x204c, type: 0 }, { ccData: 0x417d, type: 0 }, { ccData: 0x4400, type: 0 }, { ccData: 0x1137, type: 0 }, { ccData: 0x2200, type: 0 }, { ccData: 0x123f, type: 0 }, // EOC, End of Caption { pts: 1000, ccData: 0x142f, type: 0 }, // Send another command so that the second EOC isn't ignored { ccData: 0x1420, type: 0 }, // EOC, End of Caption, clear the display { pts: 10 * 1000, ccData: 0x142f, type: 0 } ]; captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions[0].content[0].text, '«LÀ-LÅ LAÑD♪»', 'translated special characters'); }); QUnit.test('pop-on mode', function(assert) { var packets, captions; packets = [ // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // 'hi' { ccData: characters('hi'), type: 0 }, // EOC, End of Caption. Finished transmitting, begin display { pts: 1000, ccData: 0x142f, type: 0 }, // Send another command so that the second EOC isn't ignored { ccData: 0x1420, type: 0 }, // EOC, End of Caption. End display { pts: 10 * 1000, ccData: 0x142f, type: 0 } ]; captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'detected a caption'); assert.deepEqual(captions[0], { startPts: 1000, endPts: 10 * 1000, content: [{ line: 15, position: 10, text: 'hi' }], stream: 'CC1' }, 'parsed the caption'); }); QUnit.test('ignores null characters', function(assert) { var packets, captions; packets = [ // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // 'mu' { ccData: characters('mu'), type: 0 }, // null characters { ccData: 0x0000, type: 0 }, // ' x' { ccData: characters(' x'), type: 0 }, // EOC, End of Caption. Finished transmitting, begin display { pts: 1000, ccData: 0x142f, type: 0 }, // Send another command so that the second EOC isn't ignored { ccData: 0x1420, type: 0 }, // EOC, End of Caption. End display { pts: 10 * 1000, ccData: 0x142f, type: 0 } ]; captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'detected a caption'); assert.deepEqual(captions[0], { startPts: 1000, endPts: 10 * 1000, content: [{ line: 15, position: 10, text: 'mu x' }], stream: 'CC1' }, 'ignored null characters'); }); QUnit.test('recognizes the Erase Displayed Memory command', function(assert) { var packets, captions; packets = [ // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // '01' { ccData: characters('01'), type: 0 }, // EOC, End of Caption. Finished transmitting, display '01' { pts: 1 * 1000, ccData: 0x142f, type: 0 }, // EDM, Erase Displayed Memory { pts: 1.5 * 1000, ccData: 0x142c, type: 0 }, // '23' { ccData: characters('23'), type: 0 }, // EOC, End of Caption. Display '23' { pts: 2 * 1000, ccData: 0x142f, type: 0 }, // '34' { ccData: characters('34'), type: 0 }, // EOC, End of Caption. Display '34' { pts: 3 * 1000, ccData: 0x142f, type: 0 }, // Send another command so that the second EOC isn't ignored { ccData: 0x1420, type: 0}, // EOC, End of Caption { pts: 4 * 1000, ccData: 0x142f, type: 0 } ]; captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 3, 'detected three captions'); assert.deepEqual(captions[0], { startPts: 1 * 1000, endPts: 1.5 * 1000, content: [{ line: 15, position: 10, text: '01' }], stream: 'CC1' }, 'parsed the first caption'); assert.deepEqual(captions[1], { startPts: 2 * 1000, endPts: 3 * 1000, content: [{ line: 15, position: 10, text: '23' }], stream: 'CC1' }, 'parsed the second caption'); assert.deepEqual(captions[2], { startPts: 3 * 1000, endPts: 4 * 1000, content: [{ line: 15, position: 10, text: '34' }], stream: 'CC1' }, 'parsed the third caption'); }); QUnit.test('correct content text is added to non-displayed memory for pop-on mode', function(assert) { var captions = [], packets; cea608Stream.on('data', function(caption) { captions.push(caption); }); packets = [ // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // '01' { ccData: characters('01'), type: 0 }, // backspace { ccData: 0x1421, type: 0 }, { ccData: characters('23'), type: 0 }, // PAC: row 13, no indent { pts: 1 * 1000, ccData: 0x1370, type: 0 }, { pts: 1 * 1000, ccData: characters('32'), type: 0 }, // backspace { pts: 2 * 1000, ccData: 0x1421, type: 0 }, { pts: 3 * 1000, ccData: characters('10'), type: 0 }, // EOC, End of Caption { pts: 4 * 1000, ccData: 0x142f, type: 0 }, // Send another command so that the second EOC isn't ignored { ccData: 0x1420, type: 0 }, // EOC, End of Caption, flush caption { pts: 5 * 1000, ccData: 0x142f, type: 0 } ]; packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'detected a caption'); assert.equal(captions[0].content[0].text, '310', 'first content text'); assert.equal(captions[0].content[1].text, '023', 'second content text'); }); QUnit.test('backspaces on cleared memory are no-ops', function(assert) { var captions = [], packets; cea608Stream.on('data', function(caption) { captions.push(caption); }); packets = [ // RCL, resume caption loading { ccData: 0x1420 }, // backspace { ccData: 0x1421 }, // EOC, End of Caption. Finished transmitting, display '01' { pts: 1 * 1000, ccData: 0x142f } ]; packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 0, 'no captions detected'); }); QUnit.test('recognizes the Erase Non-Displayed Memory command', function(assert) { var packets, captions; packets = [ // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // '01' { ccData: characters('01'), type: 0 }, // ENM, Erase Non-Displayed Memory { ccData: 0x142e, type: 0 }, { ccData: characters('23'), type: 0 }, // EOC, End of Caption. Finished transmitting, display '23' { pts: 1 * 1000, ccData: 0x142f, type: 0 }, // Send another command so that the second EOC isn't ignored { ccData: 0x1420, type: 0 }, // EOC, End of Caption { pts: 2 * 1000, ccData: 0x142f, type: 0 } ]; captions = []; packets.forEach(cea608Stream.push, cea608Stream); cea608Stream.on('data', function(caption) { captions.push(caption); }); packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'detected one caption'); assert.deepEqual(captions[0], { startPts: 1 * 1000, endPts: 2 * 1000, content: [{ line: 15, position: 10, text: '23' }], stream: 'CC1' }, 'cleared the non-displayed memory'); }); QUnit.test('ignores unrecognized commands', function(assert) { var packets, captions; packets = [ // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // a row-9 magenta command, which is not supported { ccData: 0x1f4c, type: 0 }, // '01' { ccData: characters('01'), type: 0 }, // EOC, End of Caption { pts: 1 * 1000, ccData: 0x142f, type: 0 }, // Send another command so that the second EOC isn't ignored { ccData: 0x1420, type: 0 }, // EOC, End of Caption { pts: 2 * 1000, ccData: 0x142f, type: 0 } ]; captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions[0].content[0].text, '01', 'skipped the unrecognized commands'); }); QUnit.skip('applies preamble address codes', function(assert) { assert.ok(false, 'not implemented'); }); QUnit.skip('applies mid-row colors', function(assert) { assert.ok(false, 'not implemented'); }); QUnit.test('applies mid-row underline', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); var packets = [ // RU2 (roll-up, 2 rows) { ccData: 0x1425, type: 0 }, { ccData: characters('no'), type: 0 }, // mid-row, white underline { ccData: 0x1121, type: 0 }, { ccData: characters('ye'), type: 0 }, { ccData: characters('s.'), type: 0 }, // CR (carriage return), dispatches caption { ccData: 0x142d, type: 0 } ]; packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions[0].content[0].text, 'no yes.', 'properly closed by CR'); assert.deepEqual(cea608Stream.formatting_, [], 'formatting is empty'); }); QUnit.test('applies mid-row italics', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); var packets = [ // RU2 (roll-up, 2 rows) { ccData: 0x1425, type: 0 }, { ccData: characters('no'), type: 0 }, // mid-row, italics { ccData: 0x112e, type: 0 }, { ccData: characters('ye'), type: 0 }, { ccData: characters('s.'), type: 0 }, // CR (carriage return), dispatches caption { ccData: 0x142d, type: 0 } ]; packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions[0].content[0].text, 'no yes.', 'properly closed by CR'); assert.deepEqual(cea608Stream.formatting_, [], 'formatting is empty'); }); QUnit.test('applies mid-row italics underline', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); var packets = [ // RU2 (roll-up, 2 rows) { ccData: 0x1425, type: 0 }, { ccData: characters('no'), type: 0 }, // mid-row, italics underline { ccData: 0x112f, type: 0 }, { ccData: characters('ye'), type: 0 }, { ccData: characters('s.'), type: 0 }, // CR (carriage return), dispatches caption { ccData: 0x142d, type: 0 } ]; packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions[0].content[0].text, 'no yes.', 'properly closed by CR'); assert.deepEqual(cea608Stream.formatting_, [], 'formatting is empty'); }); // NOTE: With the exception of white italics PACs (the following two test // cases), PACs only have their underline attribute extracted and used QUnit.test('applies PAC underline', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); var packets = [ // RU2 (roll-up, 2 rows) { ccData: 0x1425, type: 0 }, // PAC: row 15, white underline { ccData: 0x1461, type: 0 }, { ccData: characters('ye'), type: 0 }, { ccData: characters('s.'), type: 0 }, // CR (carriage return), dispatches caption { ccData: 0x142d, type: 0 } ]; packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions[0].content[0].text, 'yes.', 'properly closed by CR'); assert.deepEqual(cea608Stream.formatting_, [], 'formatting is empty'); }); QUnit.test('applies PAC white italics', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); var packets = [ // RU2 (roll-up, 2 rows) { ccData: 0x1425, type: 0 }, // PAC: row 15, white italics { ccData: 0x146e, type: 0 }, { ccData: characters('ye'), type: 0 }, { ccData: characters('s.'), type: 0 }, // CR (carriage return), dispatches caption { ccData: 0x142d, type: 0 } ]; packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions[0].content[0].text, 'yes.', 'properly closed by CR'); assert.deepEqual(cea608Stream.formatting_, [], 'formatting is empty'); }); QUnit.test('applies PAC white italics underline', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); var packets = [ // RU2 (roll-up, 2 rows) { ccData: 0x1425, type: 0 }, // PAC: row 15, white italics underline { ccData: 0x146f, type: 0 }, { ccData: characters('ye'), type: 0 }, { ccData: characters('s.'), type: 0 }, // CR (carriage return), dispatches caption { ccData: 0x142d, type: 0 } ]; packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions[0].content[0].text, 'yes.', 'properly closed by CR'); assert.deepEqual(cea608Stream.formatting_, [], 'formatting is empty'); }); QUnit.test('includes all caption text at PAC row change', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); var packets = [ // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // PAC: row 14, white italics underlime { ccData: 0x144f, type: 0 }, { ccData: characters('ye'), type: 0 }, { ccData: characters('s.'), type: 0 }, // PAC: row 15, indent 0 { ccData: 0x1470, type: 0 }, { ccData: characters('no'), type: 0 }, // EOC, End of Caption { pts: 1 * 1000, ccData: 0x142f, type: 0 }, // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // EOC, End of Caption { pts: 2 * 1000, ccData: 0x142f, type: 0 } ]; packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions[0].content[0].text, 'yes.', 'first content text'); assert.equal(captions[0].content[1].text, 'no', 'second content text'); assert.deepEqual(cea608Stream.formatting_, [], 'formatting is empty'); }); QUnit.test('closes formatting at EOC', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); var packets = [ // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // PAC: row 15, white italics underline { ccData: 0x146f, type: 0 }, { ccData: characters('ye'), type: 0 }, { ccData: characters('s.'), type: 0 }, // EOC, End of Caption { pts: 1 * 1000, ccData: 0x142f, type: 0 }, // Send another command so that the second EOC isn't ignored { ccData: 0x1420, type: 0 }, // EOC, End of Caption { pts: 2 * 1000, ccData: 0x142f, type: 0 } ]; packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions[0].content[0].text, 'yes.', 'properly closed by EOC'); assert.deepEqual(cea608Stream.formatting_, [], 'formatting is empty'); }); QUnit.test('closes formatting at negating mid-row code', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); var packets = [ // RU2 (roll-up, 2 rows) { ccData: 0x1425, type: 0 }, { ccData: characters('no'), type: 0 }, // mid-row: italics underline { ccData: 0x112f, type: 0 }, { ccData: characters('ye'), type: 0 }, { ccData: characters('s.'), type: 0 }, // mid-row: white { ccData: 0x1120, type: 0 }, { ccData: characters('no'), type: 0 } ]; packets.forEach(cea608Stream.push, cea608Stream); cea608Stream.flushDisplayed(); assert.equal(captions[0].content[0].text, 'no yes. no', 'properly closed by negating mid-row code'); assert.deepEqual(cea608Stream.formatting_, [], 'formatting is empty'); }); QUnit.test('roll-up display mode', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RU2, roll-up captions 2 rows { ccData: 0x1425, type: 0}, // '01' { pts: 1 * 1000, ccData: characters('01'), type: 0 }, // CR, carriage return { pts: 3 * 1000, ccData: 0x142d, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'detected one caption'); assert.deepEqual(captions[0], { startPts: 0 * 1000, endPts: 3 * 1000, content: [{ line: 15, position: 10, text: '01' }], stream: 'CC1' }, 'parsed the caption'); captions = []; [ // RU4, roll-up captions 4 rows { ccdata: 0x1427, type: 0 }, // '23' { pts: 4 * 1000, ccData: characters('23'), type: 0, stream: 'CC1' }, // CR { pts: 5 * 1000, ccData: 0x142d, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'detected another caption'); assert.deepEqual(captions[0], { startPts: 3 * 1000, endPts: 5 * 1000, content: [ { line: 14, position: 10, text: '01' }, { line: 15, position: 10, text: '23' } ], stream: 'CC1' }, 'parsed the new caption and kept the caption up after the new caption'); }); QUnit.test('roll-up displays multiple rows simultaneously', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RU2, roll-up captions 2 rows { ccData: 0x1425, type: 0 }, // '01' { pts: 0 * 1000, ccData: characters('01'), type: 0, stream: 'CC1' }, // CR, carriage return { pts: 1 * 1000, ccData: 0x142d, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'detected a caption'); assert.deepEqual(captions[0], { startPts: 0 * 1000, endPts: 1 * 1000, content: [{ line: 15, position: 10, text: '01' }], stream: 'CC1' }, 'created a caption for the first period'); captions = []; [ // '23' { pts: 2 * 1000, ccData: characters('23'), type: 0, stream: 'CC1' }, // CR, carriage return { pts: 3 * 1000, ccData: 0x142d, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'detected another caption'); assert.deepEqual(captions[0], { startPts: 1 * 1000, endPts: 3 * 1000, content: [ { line: 14, position: 10, text: '01' }, { line: 15, position: 10, text: '23' } ], stream: 'CC1' }, 'created the top and bottom rows after the shift up'); captions = []; [ // '45' { pts: 4 * 1000, ccData: characters('45'), type: 0, stream: 'CC1' }, // CR, carriage return { pts: 5 * 1000, ccData: 0x142d, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'detected third caption'); assert.deepEqual(captions[0], { startPts: 3 * 1000, endPts: 5 * 1000, "content": [ { line: 14, position: 10, text: '23' }, { line: 15, position: 10, text: '45' } ], stream: 'CC1' }, 'created the top and bottom rows after the shift up'); }); QUnit.test('the roll-up count can be changed on-the-fly', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RU2, roll-up captions 2 rows { ccData: 0x1425, type: 0 }, // '01' { pts: 0 * 1000, ccData: characters('01'), type: 0, stream: 'CC1' }, // CR, carriage return { pts: 1 * 1000, ccData: 0x142d, type: 0 } ].forEach(cea608Stream.push, cea608Stream); captions = []; [ // RU3, roll-up captions 3 rows { ccData: 0x1426, type: 0 }, // CR, carriage return { pts: 2 * 1000, ccData: 0x142d, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'still displaying a caption'); captions = []; [ // RU4, roll-up captions 4 rows { ccData: 0x1427, type: 0 }, // CR, carriage return { pts: 3 * 1000, ccData: 0x142d, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'still displaying a caption'); captions = []; // RU3, roll-up captions 3 rows cea608Stream.push({ ccdata: 0x1426, type: 0 }); assert.equal(captions.length, 0, 'cleared the caption'); }); QUnit.test('switching to roll-up from pop-on wipes memories and flushes captions', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RCL (resume caption loading) { pts: 0 * 1000, ccData: 0x1420, type: 0 }, { pts: 0 * 1000, ccData: characters('hi'), type: 0 }, // EOC (end of caption), mark 1st caption start { pts: 1 * 1000, ccData: 0x142f, type: 0 }, // RCL, resume caption loading { pts: 1 * 1000, ccData: 0x1420, type: 0 }, { pts: 2 * 1000, ccData: characters('oh'), type: 0 }, // EOC, mark 2nd caption start and flush 1st caption { pts: 2 * 1000, ccData: 0x142f, type: 0 }, // RU2 (roll-up, 2 rows), flush 2nd caption { pts: 3 * 1000, ccData: 0x1425, type: 0 } ].forEach(cea608Stream.push, cea608Stream); var displayed = cea608Stream.displayed_.reduce(function(acc, val) { acc += val.text; return acc; }, ''); var nonDisplayed = cea608Stream.nonDisplayed_.reduce(function(acc, val) { acc += val.text; return acc; }, ''); assert.equal(captions.length, 2, 'both captions flushed'); assert.equal(displayed, '', 'displayed memory is wiped'); assert.equal(nonDisplayed, '', 'non-displayed memory is wiped'); assert.deepEqual(captions[0], { startPts: 1000, endPts: 2000, content: [{ line: 15, position: 10, text: 'hi', }], stream: 'CC1' }, 'first caption correct'); assert.deepEqual(captions[1], { startPts: 2000, endPts: 3000, content: [{ line: 15, position: 10, text: 'oh', }], stream: 'CC1' }, 'second caption correct'); }); QUnit.test('switching to roll-up from paint-on wipes memories and flushes captions', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RDC (resume direct captioning) { pts: 0 * 1000, ccData: 0x1429, type: 0 }, { pts: 0 * 1000, ccData: characters('hi'), type: 0 }, // RU2 (roll-up, 2 rows), flush displayed caption { pts: 1 * 1000, ccData: 0x1425, type: 0 } ].forEach(cea608Stream.push, cea608Stream); var displayed = cea608Stream.displayed_.reduce(function(acc, val) { acc += val.text; return acc; }, ''); var nonDisplayed = cea608Stream.nonDisplayed_.reduce(function(acc, val) { acc += val.text; return acc; }, ''); assert.equal(captions.length, 1, 'flushed caption'); assert.equal(displayed, '', 'displayed memory is wiped'); assert.equal(nonDisplayed, '', 'non-displayed memory is wiped'); assert.deepEqual(captions[0], { startPts: 0, endPts: 1000, content: [{ line: 15, position: 10, text: 'hi', }], stream: 'CC1' }, 'caption correct'); }); // NOTE: This should change to not wiping the display when caption // positioning is properly implemented QUnit.test('switching to paint-on from pop-on flushes display', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RCL (resume caption loading) { pts: 0 * 1000, ccData: 0x1420, type: 0 }, // PAC: row 14, indent 0 { pts: 0 * 1000, ccData: 0x1450, type: 0 }, { pts: 0 * 1000, ccData: characters('hi'), type: 0 }, // EOC (end of caption), mark caption start { pts: 1 * 1000, ccData: 0x142f, type: 0 }, // RCL { pts: 1 * 1000, ccData: 0x1420, type: 0 }, // RDC (resume direct captioning), flush caption { pts: 2 * 1000, ccData: 0x1429, type: 0 }, // PAC: row 14, indent 0 { pts: 2 * 1000, ccData: 0x1450, type: 0 }, // TO1 (tab offset 1 column) { pts: 2 * 1000, ccData: 0x1721, type: 0 }, { pts: 3 * 1000, ccData: characters('io'), type: 0 }, // EDM (erase displayed memory), flush paint-on caption { pts: 4 * 1000, ccData: 0x142c, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 2, 'detected 2 captions'); assert.equal(captions[0].content[0].text, 'hi', 'pop-on caption received'); assert.equal(captions[0].startPts, 1000, 'proper start pts'); assert.equal(captions[0].endPts, 2000, 'proper end pts'); assert.equal(captions[1].content[0].text, 'io', 'paint-on caption received'); assert.equal(captions[1].startPts, 2000, 'proper start pts'); assert.equal(captions[1].endPts, 4000, 'proper end pts'); }); QUnit.test('backspaces are reflected in the generated captions', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RU2, roll-up captions 2 rows { ccData: 0x1425, type: 0 }, // '01' { pts: 0 * 1000, ccData: characters('01'), type: 0 }, // backspace { ccData: 0x1421, type: 0 }, { pts: 1 * 1000, ccData: characters('23'), type: 0 }, // CR, carriage return { pts: 1 * 1000, ccData: 0x142d, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'detected a caption'); assert.equal(captions[0].content[0].text, '023', 'applied the backspace'); }); QUnit.test('backspaces can remove a caption entirely', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RU2, roll-up captions 2 rows { ccData: 0x1425, type: 0 }, // '01' { pts: 0 * 1000, ccData: characters('01'), type: 0 }, // backspace { ccData: 0x1421, type: 0 }, // Send another command so that the backspace isn't // ignored as a duplicate command { ccData: 0x1425, type: 0 }, // backspace { ccData: 0x1421, type: 0 }, // CR, carriage return { pts: 1 * 1000, ccData: 0x142d, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 0, 'no caption emitted'); }); QUnit.test('a second identical control code immediately following the first is ignored', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RU2, roll-up captions 2 rows { ccData: 0x1425, type: 0 }, // '01' { pts: 0 * 1000, ccData: characters('01'), type: 0 }, // '02' { pts: 1 * 1000, ccData: characters('02'), type: 0 }, // backspace { ccData: 0x1421, type: 0 }, // backspace { ccData: 0x1421, type: 0 }, // duplicate is ignored // backspace { ccData: 0x1421, type: 0 }, // CR, carriage return { pts: 2 * 1000, ccData: 0x142d, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'caption emitted'); assert.equal(captions[0].content[0].text, '01', 'only two backspaces processed'); }); QUnit.test('a second identical control code separated by only padding from the first is ignored', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RU2, roll-up captions 2 rows { ccData: 0x1425, type: 0 }, // '01' { pts: 0 * 1000, ccData: characters('01'), type: 0 }, // '02' { pts: 1 * 1000, ccData: characters('02'), type: 0 }, // backspace { ccData: 0x1421, type: 0 }, // padding { ccData: 0x0000, type: 0 }, { ccData: 0x0000, type: 0 }, { ccData: 0x0000, type: 0 }, // backspace { pts: 2 * 1000, ccData: 0x1421, type: 0 }, // duplicate is ignored // CR, carriage return { pts: 3 * 1000, ccData: 0x142d, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'caption emitted'); assert.equal(captions[0].content[0].text, '010', 'only one backspace processed'); }); QUnit.test('preamble address codes on same row are NOT converted into spaces', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RU2, roll-up captions 2 rows { ccData: 0x1425, type: 0 }, // '01' { pts: 0 * 1000, ccData: characters('01'), type: 0 }, // PAC: row 15, indent 0 { ccData: 0x1470, type: 0 }, // '02' { pts: 1 * 1000, ccData: characters('02'), type: 0 }, // CR, carriage return { pts: 2 * 1000, ccData: 0x142d, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'caption emitted'); assert.equal(captions[0].content[0].text, '0102', 'PACs were NOT converted to space'); }); QUnit.test('generates correct content with PACs in pop-on mode', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // ENM, erase non-displayed memory { ccData: 0x142e, type: 0 }, // PAC: row 12, indent 0 { ccData: 0x1350, type: 0 }, // text: TEST { ccData: 0x5445, type: 0 }, { ccData: 0x5354, type: 0 }, // PAC: row 14, indent 0 { ccData: 0x1450, type: 0 }, // text: STRING { ccData: 0x5354, type: 0 }, { ccData: 0x5249, type: 0 }, { ccData: 0x4e47, type: 0 }, // PAC: row 15, indent 0 { ccData: 0x1470, type: 0 }, // text: DATA { ccData: 0x4441, type: 0 }, { ccData: 0x5441, type: 0 }, // EOC, end of caption { pts: 1 * 1000, ccData: 0x142f, type: 0 }, // EOC, duplicated as per spec { pts: 1 * 1000, ccData: 0x142f, type: 0 }, // EOC, dispatch caption { pts: 2 * 1000, ccData: 0x142f, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'caption emitted'); assert.equal(captions[0].content[0].text, 'TEST', 'first content text'); assert.equal(captions[0].content[1].text, 'STRING', 'second content text'); assert.equal(captions[0].content[2].text, 'DATA', 'third content text'); }); QUnit.test('extracts real-world cc1 and cc3 channels', function(assert) { var cea608Stream1 = cea608Stream; var cea608Stream3 = new m2ts.Cea608Stream(1, 0); var captions = []; cea608Stream1.on('data', function(caption) { captions.push(caption); }); cea608Stream3.on('data', function(caption) { captions.push(caption); }); var packets = [ { pts: 425316, type: 0, ccData: 5158 }, // RU3 { pts: 431322, type: 0, ccData: 5165 }, // CR { pts: 440331, type: 0, ccData: 4944 }, // position 11,0 { pts: 443334, type: 0, ccData: 20549 }, // PE { pts: 449340, type: 0, ccData: 21065 }, // RI { pts: 449340, type: 0, ccData: 0 }, // padding { pts: 452343, type: 0, ccData: 20292 }, // OD { pts: 458349, type: 0, ccData: 11264 }, // , { pts: 458349, type: 0, ccData: 0 }, // padding { pts: 461352, type: 0, ccData: 0 }, // padding { pts: 467358, type: 0, ccData: 8192 }, // (space) { pts: 467358, type: 0, ccData: 17920 }, // F { pts: 470361, type: 0, ccData: 0 }, // padding { pts: 476367, type: 0, ccData: 0 }, // padding { pts: 476367, type: 0, ccData: 20300 }, // OL { pts: 479370, type: 0, ccData: 19283 }, // KS { pts: 485376, type: 0, ccData: 0 }, // padding { pts: 485376, type: 0, ccData: 11776 }, // . { pts: 674565, type: 0, ccData: 5158 }, // RU3 { pts: 677568, type: 0, ccData: 5165 }, // CR { pts: 371262, type: 1, ccData: 5414 }, // RU3 { pts: 377268, type: 1, ccData: 0 }, // padding { pts: 377268, type: 1, ccData: 4944 }, // position 11,0 { pts: 380271, type: 1, ccData: 0 }, // padding { pts: 386277, type: 1, ccData: 4412 }, // ê { pts: 386277, type: 1, ccData: 0 }, // padding { pts: 389280, type: 1, ccData: 29810 }, // tr { pts: 395286, type: 1, ccData: 25888 }, // e(space) { pts: 395286, type: 1, ccData: 30062 }, // un { pts: 398289, type: 1, ccData: 25888 }, // e(space) { pts: 404295, type: 1, ccData: 28764 }, // pé { pts: 404295, type: 1, ccData: 29289 }, // ri { pts: 407298, type: 1, ccData: 28516 }, // od { pts: 413304, type: 1, ccData: 25856 }, // e { pts: 413304, type: 1, ccData: 0 }, // padding { pts: 443334, type: 1, ccData: 8292 }, // (space)d { pts: 449340, type: 1, ccData: 25888 }, // e(space) { pts: 449340, type: 1, ccData: 29045 }, // qu { pts: 452343, type: 1, ccData: 25971 }, // es { pts: 458349, type: 1, ccData: 29801 }, // ti { pts: 458349, type: 1, ccData: 28526 }, // on { pts: 461352, type: 1, ccData: 29440 }, // s { pts: 467358, type: 1, ccData: 5421 }, // CR { pts: 467358, type: 1, ccData: 0 }, // padding { pts: 470361, type: 1, ccData: 5414 }, // RU3 { pts: 476367, type: 1, ccData: 0 } // padding ]; packets.forEach(function(packet) { cea608Stream1.push(packet); cea608Stream3.push(packet); }); var cc1 = {stream: 'CC1', content: [{ text: 'PERIOD, FOLKS.'}] }; var cc3 = {stream: 'CC3', content: [{ text: 'être une période de questions' }] }; assert.equal(captions.length, 2, 'caption emitted'); assert.equal(captions[0].stream, cc1.stream, 'cc1 stream detected'); assert.equal(captions[0].content[0].text, cc1.content[0].text, 'cc1 stream extracted successfully'); assert.equal(captions[1].stream, cc3.stream, 'cc3 stream detected'); assert.equal(captions[1].content[0].text, cc3.content[0].text, 'cc3 stream extracted successfully'); }); QUnit.test('backspaces stop at the beginning of the line', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RU2, roll-up captions 2 rows { ccData: 0x1425, type: 0 }, // '01' { pts: 0 * 1000, ccData: characters('01'), type: 0 }, // backspace { ccData: 0x1421, type: 0 }, // Send another command so that the backspace isn't // ignored as a duplicate command { ccData: 0x1425, type: 0 }, // backspace { ccData: 0x1421, type: 0 }, // Send another command so that the backspace isn't // ignored as a duplicate command { ccData: 0x1425, type: 0 }, // backspace { ccData: 0x1421, type: 0 }, // CR, carriage return { pts: 1 * 1000, ccData: 0x142d, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 0, 'no caption emitted'); }); QUnit.test('reset works', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RU2, roll-up captions 2 rows { pts: 0, ccData: 0x1425, type: 0 }, // mid-row: white underline { pts: 0, ccData: 0x1121, type: 0 }, { pts: 0, ccData: characters('01'), type: 0 } ].forEach(cea608Stream.push, cea608Stream); var buffer = cea608Stream.displayed_.map(function(row) { return row.text.trim(); }).join('\n') .replace(/^\n+|\n+$/g, ''); assert.equal(buffer, '01', 'buffer is as expected'); cea608Stream.reset(); buffer = cea608Stream.displayed_ .map(function(row) { return row.text.trim(); }) .join('\n') .replace(/^\n+|\n+$/g, ''); assert.equal(buffer, '', 'displayed buffer reset successfully'); assert.equal(cea608Stream.lastControlCode_, null, 'last control code reset successfully'); assert.deepEqual(cea608Stream.formatting_, [], 'formatting was reset'); }); QUnit.test('paint-on mode', function(assert) { var packets, captions; packets = [ // RDC, resume direct captioning, begin display { pts: 1000, ccData: 0x1429, type: 0 }, { pts: 2000, ccData: characters('hi'), type: 0 }, // EDM, erase displayed memory. Finish display, flush caption { pts: 3000, ccData: 0x142c, type: 0 } ]; captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'detected a caption'); assert.deepEqual(captions[0], { startPts: 1000, endPts: 3000, content: [{ line: 15, position: 10, text: 'hi', }], stream: 'CC1' }, 'parsed the caption'); }); QUnit.test('generates correct text from PACs in paint-on mode', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RDC, resume direct captioning { pts: 1000, ccData: 0x1429, type: 0 }, // PAC: row 12, indent 0 { pts: 1000, ccData: 0x1350, type: 0 }, // text: TEST { pts: 2000, ccData: 0x5445, type: 0 }, { pts: 2000, ccData: 0x5354, type: 0 }, // PAC: row 14, indent 0 { pts: 3000, ccData: 0x1450, type: 0 }, // text: STRING { pts: 3000, ccData: 0x5354, type: 0 }, { pts: 4000, ccData: 0x5249, type: 0 }, { pts: 4000, ccData: 0x4e47, type: 0 }, // PAC: row 15, indent 0 { pts: 5000, ccData: 0x1470, type: 0 }, // text: DATA { pts: 5000, ccData: 0x4441, type: 0 }, { pts: 6000, ccData: 0x5441, type: 0 }, // EDM, erase displayed memory. Finish display, flush caption { pts: 6000, ccData: 0x142c, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'caption emitted'); assert.equal(captions[0].content[0].text, 'TEST', 'first content text'); assert.equal(captions[0].content[1].text, 'STRING', 'second content text'); assert.equal(captions[0].content[2].text, 'DATA', 'third content text'); }); QUnit.test('multiple caption texts are generated (paint-on)', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RDC, resume direct captioning { ccData: 0x1429, type: 0 }, // '01', default row 15 { pts: 0 * 1000, ccData: characters('01'), type: 0 }, // backspace { pts: 0 * 1000, ccData: 0x1421, type: 0 }, { pts: 1 * 1000, ccData: characters('23'), type: 0 }, // PAC: row 13, indent 0 { pts: 2 * 1000, ccData: 0x1370, type: 0 }, { pts: 2 * 1000, ccData: characters('32'), type: 0 }, // backspace { pts: 3 * 1000, ccData: 0x1421, type: 0 }, { pts: 4 * 1000, ccData: characters('10'), type: 0 }, // EDM, erase displayed memory, flush caption { pts: 5 * 1000, ccData: 0x142c, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 1, 'detected a caption'); assert.equal(captions[0].content[0].text, '310', 'first caption text'); assert.equal(captions[0].content[1].text, '023', 'second caption text'); }); QUnit.test('PAC indent code increases the position', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); var packets = [ // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // PAC indent code representing 4 indentations. { ccData: 5240, type: 0 }, { ccData: characters('te'), type: 0 }, { ccData: characters('st'), type: 0 }, // EOC, End of Caption { pts: 1 * 1000, ccData: 0x142f, type: 0 }, // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // EOC, End of Caption { pts: 2 * 1000, ccData: 0x142f, type: 0 } ]; packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions[0].content[0].text, 'test', 'content text'); assert.equal(captions[0].content[0].line, 15, 'positions the caption to the bottom of the screen'); assert.equal(captions[0].content[0].position, 50, 'positions the caption to the right of the screen'); }); QUnit.test('PAC offset code increases the position', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); var packets = [ // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // PAC: row 1, indent 0 { pts: 6750, ccData: 0x1150, type: 0 }, // TO2 (tab offset 2 columns) { pts: 6755, ccData: 0x1722, type: 0 }, { ccData: characters('te'), type: 0 }, { ccData: characters('st'), type: 0 }, // EOC, End of Caption { pts: 1 * 1000, ccData: 0x142f, type: 0 }, // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // EOC, End of Caption { pts: 2 * 1000, ccData: 0x142f, type: 0 } ]; packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions[0].content[0].text, 'test', 'content text'); assert.equal(captions[0].content[0].line, 1, 'positions the caption to the bottom of the screen'); // Two tab offset columns adds 5 to the position (2 * 2.5) assert.equal(captions[0].content[0].position, 15, 'positions the caption to the right'); }); QUnit.test('PAC row command ensures we have the correct line property for captions', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); var packets = [ // RU2 (roll-up, 2 rows) { pts: 6675, ccData: 0x1425, type: 0 }, // CR (carriange return), flush nothing { pts: 6675, ccData: 0x142d, type: 0 }, // PAC: row 2, indent 0 // This should ensure the captions are at the top of the screen. { pts: 6675, ccData: 0x1170, type: 0 }, // text: YEAR. { pts: 6676, ccData: 0x5945, type: 0 }, { pts: 6676, ccData: 0x4152, type: 0 }, { pts: 6676, ccData: 0x2e00, type: 0 }, // RU2 (roll-up, 2 rows) { pts: 6677, ccData: 0x1425, type: 0 }, // CR (carriange return), flush 1 row { pts: 6677, ccData: 0x142d, type: 0 }, // EDM (erase displayed memory), flush 2 displayed roll-up rows { pts: 6697, ccData: 0x142c, type: 0 }, // RDC (resume direct captioning), wipes memories, flushes nothing { pts: 6749, ccData: 0x1429, type: 0 }, // PAC: row 1, indent 0 { pts: 6750, ccData: 0x1150, type: 0 }, // EOC, End of Caption { pts: 1 * 1000, ccData: 0x142f, type: 0 }, // RCL, resume caption loading { ccData: 0x1420, type: 0 }, // EOC, End of Caption { pts: 2 * 1000, ccData: 0x142f, type: 0 } ]; // First caption stream is at the second most bottom row. packets.forEach(cea608Stream.push, cea608Stream); assert.equal(captions[0].content[0].text, 'YEAR.', 'content text'); assert.equal(captions[0].content[0].line, 2, 'positions the caption in the second most bottom row'); assert.equal(captions[0].content[0].position, 10, 'position of the caption'); // Second caption stream is at the most bottom row. assert.equal(captions[1].content[0].text, 'YEAR.', 'content text'); assert.equal(captions[1].content[0].line, 1, 'positions the caption in the most bottom row'); assert.equal(captions[1].content[0].position, 10, 'position of the caption'); }); QUnit.test('mix of all modes (extract from CNN)', function(assert) { var captions = []; cea608Stream.on('data', function(caption) { captions.push(caption); }); [ // RU2 (roll-up, 2 rows) { pts: 6675, ccData: 0x1425, type: 0 }, // CR (carriange return), flush nothing { pts: 6675, ccData: 0x142d, type: 0 }, // PAC: row 2, indent 0 { pts: 6675, ccData: 0x1170, type: 0 }, // text: YEAR. { pts: 6676, ccData: 0x5945, type: 0 }, { pts: 6676, ccData: 0x4152, type: 0 }, { pts: 6676, ccData: 0x2e00, type: 0 }, // RU2 (roll-up, 2 rows) { pts: 6677, ccData: 0x1425, type: 0 }, // CR (carriange return), flush 1 row { pts: 6677, ccData: 0x142d, type: 0 }, // PAC: row 2, indent 0 { pts: 6677, ccData: 0x1170, type: 0 }, // text: GO TO CNNHEROS.COM. { pts: 6677, ccData: 0x474f, type: 0 }, { pts: 6678, ccData: 0x2054, type: 0 }, { pts: 6678, ccData: 0x4f00, type: 0 }, { pts: 6678, ccData: 0x2043, type: 0 }, { pts: 6679, ccData: 0x4e4e, type: 0 }, { pts: 6679, ccData: 0x4845, type: 0 }, { pts: 6679, ccData: 0x524f, type: 0 }, { pts: 6680, ccData: 0x532e, type: 0 }, { pts: 6680, ccData: 0x434f, type: 0 }, { pts: 6680, ccData: 0x4d2e, type: 0 }, // EDM (erase displayed memory), flush 2 displayed roll-up rows { pts: 6697, ccData: 0x142c, type: 0 }, // RDC (resume direct captioning), wipes memories, flushes nothing { pts: 6749, ccData: 0x1429, type: 0 }, // PAC: row 1, indent 0 { pts: 6750, ccData: 0x1150, type: 0 }, // text: Did your Senator or Congressman { pts: 6750, ccData: 0x4469, type: 0 }, { pts: 6750, ccData: 0x6420, type: 0 }, { pts: 6750, ccData: 0x796f, type: 0 }, { pts: 6751, ccData: 0x7572, type: 0 }, { pts: 6751, ccData: 0x2053, type: 0 }, { pts: 6751, ccData: 0x656e, type: 0 }, { pts: 6752, ccData: 0x6174, type: 0 }, { pts: 6752, ccData: 0x6f72, type: 0 }, { pts: 6752, ccData: 0x206f, type: 0 }, { pts: 6753, ccData: 0x7220, type: 0 }, { pts: 6753, ccData: 0x436f, type: 0 }, { pts: 6753, ccData: 0x6e67, type: 0 }, { pts: 6753, ccData: 0x7265, type: 0 }, { pts: 6754, ccData: 0x7373, type: 0 }, { pts: 6754, ccData: 0x6d61, type: 0 }, { pts: 6754, ccData: 0x6e00, type: 0 }, // PAC: row 2, indent 0 { pts: 6755, ccData: 0x1170, type: 0 }, // TO2 (tab offset 2 columns) { pts: 6755, ccData: 0x1722, type: 0 }, // text: get elected by talking tough { pts: 6755, ccData: 0x6765, type: 0 }, { pts: 6756, ccData: 0x7420, type: 0 }, { pts: 6756, ccData: 0x656c, type: 0 }, { pts: 6756, ccData: 0x6563, type: 0 }, { pts: 6756, ccData: 0x7465, type: 0 }, { pts: 6757, ccData: 0x6420, type: 0 }, { pts: 6757, ccData: 0x6279, type: 0 }, { pts: 6757, ccData: 0x2074, type: 0 }, { pts: 6758, ccData: 0x616c, type: 0 }, { pts: 6758, ccData: 0x6b69, type: 0 }, { pts: 6758, ccData: 0x6e67, type: 0 }, { pts: 6759, ccData: 0x2074, type: 0 }, { pts: 6759, ccData: 0x6f75, type: 0 }, { pts: 6759, ccData: 0x6768, type: 0 }, // RCL (resume caption loading) { pts: 6759, ccData: 0x1420, type: 0 }, // PAC: row 1, indent 4 { pts: 6760, ccData: 0x1152, type: 0 }, // TO1 (tab offset 1 column) { pts: 6760, ccData: 0x1721, type: 0 }, // text: on the national debt? { pts: 6760, ccData: 0x6f6e, type: 0 }, { pts: 6761, ccData: 0x2074, type: 0 }, { pts: 6761, ccData: 0x6865, type: 0 }, { pts: 6761, ccData: 0x206e, type: 0 }, { pts: 6762, ccData: 0x6174, type: 0 }, { pts: 6762, ccData: 0x696f, type: 0 }, { pts: 6762, ccData: 0x6e61, type: 0 }, { pts: 6762, ccData: 0x6c20, type: 0 }, { pts: 6763, ccData: 0x6465, type: 0 }, { pts: 6763, ccData: 0x6274, type: 0 }, { pts: 6763, ccData: 0x3f00, type: 0 }, // RCL (resume caption loading) { pts: 6781, ccData: 0x1420, type: 0 }, // EDM (erase displayed memory), flush paint-on caption { pts: 6781, ccData: 0x142c, type: 0 }, // EOC (end of caption), mark pop-on caption 1 start { pts: 6782, ccData: 0x142f, type: 0 }, // RCL (resume caption loading) { pts: 6782, ccData: 0x1420, type: 0 }, // PAC: row 1, indent 4 { pts: 6782, ccData: 0x1152, type: 0 }, // TO2 (tab offset 2 columns) { pts: 6783, ccData: 0x1722, type: 0 }, // text: Will they stay true { pts: 6783, ccData: 0x5769, type: 0 }, { pts: 6783, ccData: 0x6c6c, type: 0 }, { pts: 6783, ccData: 0x2074, type: 0 }, { pts: 6784, ccData: 0x6865, type: 0 }, { pts: 6784, ccData: 0x7920, type: 0 }, { pts: 6784, ccData: 0x7374, type: 0 }, { pts: 6785, ccData: 0x6179, type: 0 }, { pts: 6785, ccData: 0x2074, type: 0 }, { pts: 6785, ccData: 0x7275, type: 0 }, { pts: 6786, ccData: 0x6500, type: 0 }, // PAC: row 2, indent 8 { pts: 6786, ccData: 0x1174, type: 0 }, // text: to their words? { pts: 6786, ccData: 0x746f, type: 0 }, { pts: 6786, ccData: 0x2074, type: 0 }, { pts: 6787, ccData: 0x6865, type: 0 }, { pts: 6787, ccData: 0x6972, type: 0 }, { pts: 6787, ccData: 0x2077, type: 0 }, { pts: 6788, ccData: 0x6f72, type: 0 }, { pts: 6788, ccData: 0x6473, type: 0 }, { pts: 6788, ccData: 0x3f00, type: 0 }, // RCL (resume caption loading) { pts: 6797, ccData: 0x1420, type: 0 }, // EDM (erase displayed memory), mark pop-on caption 1 end and flush { pts: 6797, ccData: 0x142c, type: 0 }, // EOC (end of caption), mark pop-on caption 2 start, flush nothing { pts: 6798, ccData: 0x142f, type: 0 }, // RCL { pts: 6799, ccData: 0x1420, type: 0 }, // EOC, mark pop-on caption 2 end and flush { pts: 6838, ccData: 0x142f, type: 0 }, // RU2 (roll-up, 2 rows), wipes memories { pts: 6841, ccData: 0x1425, type: 0 }, // CR (carriage return), flush nothing { pts: 6841, ccData: 0x142d, type: 0 }, // PAC: row 2, indent 0 { pts: 6841, ccData: 0x1170, type: 0 }, // text: NO MORE SPECULATION, NO MORE { pts: 6841, ccData: 0x3e3e, type: 0 }, { pts: 6841, ccData: 0x3e00, type: 0 }, { pts: 6842, ccData: 0x204e, type: 0 }, { pts: 6842, ccData: 0x4f00, type: 0 }, { pts: 6842, ccData: 0x204d, type: 0 }, { pts: 6842, ccData: 0x4f52, type: 0 }, { pts: 6842, ccData: 0x4500, type: 0 }, { pts: 6842, ccData: 0x2000, type: 0 }, { pts: 6842, ccData: 0x5350, type: 0 }, { pts: 6843, ccData: 0x4543, type: 0 }, { pts: 6843, ccData: 0x554c, type: 0 }, { pts: 6843, ccData: 0x4154, type: 0 }, { pts: 6843, ccData: 0x494f, type: 0 }, { pts: 6843, ccData: 0x4e2c, type: 0 }, { pts: 6843, ccData: 0x204e, type: 0 }, { pts: 6843, ccData: 0x4f00, type: 0 }, { pts: 6843, ccData: 0x204d, type: 0 }, { pts: 6844, ccData: 0x4f52, type: 0 }, { pts: 6844, ccData: 0x4500, type: 0 }, // RU2 (roll-up, two rows) { pts: 6844, ccData: 0x1425, type: 0 }, // CR (carriage return), flush 1 roll-up row { pts: 6844, ccData: 0x142d, type: 0 }, // PAC: row 2, indent 0 { pts: 6844, ccData: 0x1170, type: 0 }, // text: RUMORS OR GUESSING GAMES. { pts: 6844, ccData: 0x5255, type: 0 }, { pts: 6844, ccData: 0x4d4f, type: 0 }, { pts: 6844, ccData: 0x5253, type: 0 }, { pts: 6844, ccData: 0x204f, type: 0 }, { pts: 6845, ccData: 0x5200, type: 0 }, { pts: 6845, ccData: 0x2047, type: 0 }, { pts: 6845, ccData: 0x5545, type: 0 }, { pts: 6845, ccData: 0x5353, type: 0 }, { pts: 6845, ccData: 0x494e, type: 0 }, { pts: 6845, ccData: 0x4700, type: 0 }, { pts: 6845, ccData: 0x2047, type: 0 }, { pts: 6845, ccData: 0x414d, type: 0 }, { pts: 6845, ccData: 0x4553, type: 0 }, { pts: 6845, ccData: 0x2e00, type: 0 }, // RU2 (roll-up, 2 rows) { pts: 6846, ccData: 0x1425, type: 0 }, // CR (carriage return), flush 2 roll-up rows { pts: 6846, ccData: 0x142d, type: 0 } ].forEach(cea608Stream.push, cea608Stream); assert.equal(captions.length, 7, 'detected 7 captions of varying types'); assert.deepEqual(captions[0], { content: [{ line: 2, position: 10, text: 'YEAR.', }], startPts: 6675, endPts: 6677, stream: 'CC1' }, 'parsed the 1st roll-up caption'); assert.deepEqual(captions[1], { content: [ { line: 1, position: 10, text: 'YEAR.', }, { line: 2, position: 10, text: 'GO TO CNNHEROS.COM.', } ], startPts: 6677, endPts: 6697, stream: 'CC1' }, 'parsed the 2nd roll-up caption'); assert.deepEqual(captions[2], { content: [ { line: 1, position: 10, text: 'Did your Senator or Congressman', }, { line: 2, position: 10, text: 'get elected by talking tough', } ], startPts: 6749, endPts: 6781, stream: 'CC1' }, 'parsed the paint-on caption'); assert.deepEqual(captions[3], { content: [{ line: 1, position: 22.5, text: 'on the national debt?', }], startPts: 6782, endPts: 6797, stream: 'CC1' }, 'parsed the 1st pop-on caption'); assert.deepEqual(captions[4], { content: [ { line: 1, position: 25, text: 'Will they stay true', }, { line: 2, position: 30, text: 'to their words?', } ], startPts: 6798, endPts: 6838, stream: 'CC1' }, 'parsed the 2nd pop-on caption'); assert.deepEqual(captions[5], { content: [{ line: 2, position: 10, text: '>>> NO MORE SPECULATION, NO MORE', }], startPts: 6841, endPts: 6844, stream: 'CC1' }, 'parsed the 3rd roll-up caption'); assert.deepEqual(captions[6], { content: [ { line: 1, position: 10, text: '>>> NO MORE SPECULATION, NO MORE', }, { line: 2, position: 10, text: 'RUMORS OR GUESSING GAMES.', } ], startPts: 6844, endPts: 6846, stream: 'CC1' }, 'parsed the 4th roll-up caption'); }); QUnit.test('Cea608Stream will trigger log on malformed captions', function(assert) { var result; var logs = []; cea608Stream.on('log', function(log) { logs.push(log); }) // this will force an exception to happen in flushDisplayed cea608Stream.displayed_[0] = undefined; try { cea608Stream.flushDisplayed(); result = true; } catch (e) { result = false; } assert.ok( result, 'the function does not throw an exception' ); assert.deepEqual( logs, [ {level: 'warn', message: 'Skipping a malformed 608 caption at index 0.'} ], 'logs were triggered' ); }); var cea708Stream; QUnit.module('CEA 708 Stream', { beforeEach: function() { cea708Stream = new m2ts.Cea708Stream(); } }); QUnit.test('Filters encoding values out of captionServices option block', function(assert) { var expectedServiceEncodings = { SERVICE1: 'euc-kr', SERVICE2: 'utf-8', }; cea708Stream = new m2ts.Cea708Stream({ captionServices: { SERVICE1: { language: 'kr', label: 'Korean', encoding: 'euc-kr' }, SERVICE2: { language: 'en', label: 'English', encoding: 'utf-8' } } }); assert.deepEqual(cea708Stream.serviceEncodings, expectedServiceEncodings, 'filtered encodings correctly'); }); QUnit.test('parses 708 captions', function(assert) { var captions = []; cea708Stream.on('data', function(caption) { captions.push(caption); }); cc708PinkUnderscore.forEach(cea708Stream.push, cea708Stream); assert.equal(captions.length, 235, 'parsed 235 captions'); assert.deepEqual(captions[0], { startPts: 6723335478, endPts: 6723626769, text: '\"Pinkalicious_and_Peterrific\"\nis_made_possible_in_part_by:', stream: 'cc708_1' }, 'parsed first caption correctly'); assert.deepEqual(captions[1], { startPts: 6723740883, endPts: 6723945087, text: 'GIRL:\nRead_me_the_tale\nof_a_faraway_land.', stream: 'cc708_1' }, 'parsed second caption correctly'); assert.deepEqual(captions[2], { startPts: 6723948090, endPts: 6724200342, text: 'Tell_me_of_planets\nwith_oceans_of_sand.', stream: 'cc708_1' }, 'parsed third caption correctly'); assert.deepEqual(captions[33], { startPts: 6732617751, endPts: 6732876009, text: '♪_It\'s_a_Pinkalicious_feeling_♪', stream: 'cc708_1' }, 'parsed caption 33 correctly with music note'); assert.deepEqual(captions[38], { startPts: 6734218350, endPts: 6734425557, text: 'PINKALICIOUS:\n\"Dream_Salon.\"', stream: 'cc708_1' }, 'parsed caption 38 correctly'); assert.deepEqual(captions[234], { startPts: 6778809897, endPts: 6779104191, text: 'I_guess_I\'ll_just_have\nto_duck_a_little_bit.', stream: 'cc708_1' }, 'parsed caption 234 correctly'); }); QUnit.test('Decodes multibyte characters if valid encoding option is provided and TextDecoder is supported', function(assert) { var captions = []; cea708Stream = new m2ts.Cea708Stream({ captionServices: { SERVICE1: { encoding: 'euc-kr' } } }); cea708Stream.on('data', function(caption) { captions.push(caption); }); cc708Korean.forEach(cea708Stream.push, cea708Stream); cea708Stream.flushDisplayed(4721138662, cea708Stream.services[1]); assert.equal(captions.length, 1, 'parsed single caption correctly'); if (window.TextDecoder) { assert.ok(cea708Stream.services[1].textDecoder_, 'TextDecoder created when supported'); assert.equal( captions[0].text, '니가 ', 'parsed multibyte characters correctly' ); } else { assert.notOk(cea708Stream.services[1].textDecoder_, 'TextDecoder not created when unsupported'); } }); QUnit.test('Decodes multi-byte characters as unicode if no valid encoding option is provided', function(assert) { var captions = []; cea708Stream = new m2ts.Cea708Stream({ captionServices: { SERVICE1: {} } }); cea708Stream.on('data', function(caption) { captions.push(caption); }); cc708Korean.forEach(cea708Stream.push, cea708Stream); cea708Stream.flushDisplayed(4721138662, cea708Stream.services[1]); assert.equal(captions.length, 1, 'parsed single caption correctly'); assert.notOk(cea708Stream.services[1].textDecoder_, 'TextDecoder was not created'); assert.equal( captions[0].text, '듏낡 ', 'parsed multibyte characters correctly' ); }); QUnit.test('Creates TextDecoder only if valid encoding value is provided', function(assert) { var secondCea708Stream; cea708Stream = new m2ts.Cea708Stream({ captionServices: { SERVICE1: { encoding: 'euc-kr' } } }); cc708Korean.forEach(cea708Stream.push, cea708Stream); cea708Stream.flushDisplayed(4721138662, cea708Stream.services[1]); if (window.TextDecoder) { assert.ok(cea708Stream.services[1].textDecoder_, 'TextDecoder created successfully when encoding is valid'); } secondCea708Stream = new m2ts.Cea708Stream({ captionServices: { SERVICE1: { encoding: 'invalid' } } }); cc708Korean.forEach(secondCea708Stream.push, secondCea708Stream); secondCea708Stream.flushDisplayed(4721138662, secondCea708Stream.services[1]); assert.notOk(secondCea708Stream.services[1].textDecoder_, 'TextDecoder not created when encoding is invalid'); }); QUnit.test('reset command', function(assert) { var captions = []; cea708Stream.on('data', function(caption) { captions.push(caption); }); [ { type: 3, pts: 153315036, ccData: 0x8322 }, { type: 2, pts: 153315036, ccData: 0x4820 }, { type: 2, pts: 153315036, ccData: 0x0000 }, { type: 3, pts: 153318039, ccData: 0xc322 }, { type: 2, pts: 153318039, ccData: 0x4953 }, { type: 2, pts: 153318039, ccData: 0x0000 }, { type: 3, pts: 153387108, ccData: 0x0628 }, { type: 2, pts: 153387108, ccData: 0x0d90 }, { type: 2, pts: 153387108, ccData: 0x0503 }, { type: 2, pts: 153387108, ccData: 0x912a }, { type: 2, pts: 153387108, ccData: 0x002a }, { type: 2, pts: 153387108, ccData: 0x0000 }, { type: 3, pts: 153405126, ccData: 0x4da2 }, { type: 2, pts: 153405126, ccData: 0x8c0f }, { type: 2, pts: 153405126, ccData: 0x628c }, { type: 2, pts: 153405126, ccData: 0x0f31 }, { type: 2, pts: 153405126, ccData: 0x983b }, { type: 2, pts: 153405126, ccData: 0x912a }, { type: 2, pts: 153405126, ccData: 0x8f00 }, { type: 2, pts: 153405126, ccData: 0x611f }, { type: 2, pts: 153405126, ccData: 0x002a }, { type: 2, pts: 153405126, ccData: 0x1090 }, { type: 2, pts: 153405126, ccData: 0x0503 }, { type: 3, pts: 153408129, ccData: 0x8a31 }, { type: 2, pts: 153408129, ccData: 0x9201 }, { type: 2, pts: 153408129, ccData: 0x983b }, // RST (Reset command) { type: 2, pts: 153408129, ccData: 0x8f00 }, { type: 2, pts: 153408129, ccData: 0x0000 }, { type: 2, pts: 153408129, ccData: 0x611f }, { type: 2, pts: 153408129, ccData: 0x1090 }, { type: 2, pts: 153408129, ccData: 0x0000 }, { type: 2, pts: 153408129, ccData: 0x0503 }, { type: 2, pts: 153408129, ccData: 0x912a }, { type: 2, pts: 153408129, ccData: 0x0000 }, { type: 2, pts: 153408129, ccData: 0x9201 }, { type: 3, pts: 153414135, ccData: 0xc322 }, { type: 2, pts: 153414135, ccData: 0x434f }, { type: 2, pts: 153414135, ccData: 0x0000 } ].forEach(cea708Stream.push, cea708Stream); assert.equal(captions.length, 1, 'parsed 1 caption'); assert.deepEqual(captions[0], { startPts: 153315036, endPts: 153408129, text: '*\n;', stream: 'cc708_1' }, 'parsed the caption correctly'); }); QUnit.test('windowing', function(assert) { var captions = []; cea708Stream.on('data', function(caption) { captions.push(caption); }); [ { type: 3, pts: 1000, ccData: packetHeader708(0, 3, 1, 4) }, { type: 2, pts: 1000, ccData: 0x8000 }, // CW0 { type: 2, pts: 1000, ccData: characters('w0') }, { type: 3, pts: 1000, ccData: packetHeader708(1, 3, 1, 4) }, { type: 2, pts: 1000, ccData: 0x8100 }, // CW1 { type: 2, pts: 1000, ccData: characters('w1') }, { type: 3, pts: 1000, ccData: packetHeader708(2, 3, 1, 4) }, { type: 2, pts: 1000, ccData: 0x8200 }, // CW2 { type: 2, pts: 1000, ccData: characters('w2') }, { type: 3, pts: 1000, ccData: packetHeader708(0, 3, 1, 4) }, { type: 2, pts: 1000, ccData: 0x8300 }, // CW3 { type: 2, pts: 1000, ccData: characters('w3') }, { type: 3, pts: 1000, ccData: packetHeader708(1, 3, 1, 4) }, { type: 2, pts: 1000, ccData: 0x8400 }, // CW4 { type: 2, pts: 1000, ccData: characters('w4') }, { type: 3, pts: 1000, ccData: packetHeader708(2, 3, 1, 4) }, { type: 2, pts: 1000, ccData: 0x8500 }, // CW5 { type: 2, pts: 1000, ccData: characters('w5') }, { type: 3, pts: 1000, ccData: packetHeader708(0, 3, 1, 4) }, { type: 2, pts: 1000, ccData: 0x8600 }, // CW6 { type: 2, pts: 1000, ccData: characters('w6') }, { type: 3, pts: 1000, ccData: packetHeader708(1, 3, 1, 4) }, { type: 2, pts: 1000, ccData: 0x8700 }, // CW7 { type: 2, pts: 1000, ccData: characters('w7') }, { type: 3, pts: 2000, ccData: packetHeader708(2, 3, 1, 4) }, { type: 2, pts: 2000, ccData: 0x8aff }, // HDW (Hide all) { type: 2, pts: 2000, ccData: displayWindows708([0]) }, { type: 3, pts: 3000, ccData: packetHeader708(0, 3, 1, 4) }, { type: 2, pts: 3000, ccData: 0x8aff }, // HDW (Hide all) { type: 2, pts: 3000, ccData: displayWindows708([1]) }, { type: 3, pts: 4000, ccData: packetHeader708(1, 3, 1, 4) }, { type: 2, pts: 4000, ccData: 0x8aff }, // HDW (Hide all) { type: 2, pts: 4000, ccData: displayWindows708([2, 3]) }, { type: 3, pts: 5000, ccData: packetHeader708(2, 3, 1, 4) }, { type: 2, pts: 5000, ccData: 0x8aff }, // HDW (Hide all) { type: 2, pts: 5000, ccData: displayWindows708([3, 4]) }, { type: 3, pts: 6000, ccData: packetHeader708(0, 3, 1, 4) }, { type: 2, pts: 6000, ccData: 0x8aff }, // HDW (Hide all) { type: 2, pts: 6000, ccData: displayWindows708([5, 6, 7]) }, { type: 3, pts: 7000, ccData: packetHeader708(1, 2, 1, 2) }, { type: 2, pts: 7000, ccData: 0x8aff }, // HDW (Hide all) // Indicate end of last packet { type: 3, pts: 8000, ccData: packetHeader708(2, 1, 1, 0) } ].forEach(cea708Stream.push, cea708Stream); assert.equal(captions.length, 5, 'parsed 5 captions'); assert.deepEqual(captions[0], { startPts: 2000, endPts: 3000, text: 'w0', stream: 'cc708_1' }, 'parsed caption 0 correctly'); assert.deepEqual(captions[1], { startPts: 3000, endPts: 4000, text: 'w1', stream: 'cc708_1' }, 'parsed caption 1 correctly'); assert.deepEqual(captions[2], { startPts: 4000, endPts: 5000, text: 'w2\n\nw3', stream: 'cc708_1' }, 'parsed caption 2 correctly'); assert.deepEqual(captions[3], { startPts: 5000, endPts: 6000, text: 'w3\n\nw4', stream: 'cc708_1' }, 'parsed caption 3 correctly'); assert.deepEqual(captions[4], { startPts: 6000, endPts: 7000, text: 'w5\n\nw6\n\nw7', stream: 'cc708_1' }, 'parsed caption 4 correctly'); }); QUnit.test('backspace', function(assert) { var captions = []; cea708Stream.on('data', function(caption) { captions.push(caption); }); [ { type: 3, pts: 1000, ccData: packetHeader708(0, 7, 1, 12) }, { type: 2, pts: 1000, ccData: 0x8000 }, // CW0 { type: 2, pts: 1000, ccData: characters('ty') }, { type: 2, pts: 1000, ccData: characters('op') }, { type: 2, pts: 1000, ccData: 0x0808 }, // BS BS: Backspace twice { type: 2, pts: 1000, ccData: characters('po') }, { type: 2, pts: 1000, ccData: displayWindows708([0]) }, { type: 3, pts: 2000, ccData: packetHeader708(1, 2, 1, 2) }, { type: 2, pts: 2000, ccData: 0x8aff }, // Indicate end of last packet { type: 3, pts: 3000, ccData: packetHeader708(2, 1, 1, 0) } ].forEach(cea708Stream.push, cea708Stream); assert.equal(captions.length, 1, 'parsed 1 caption'); assert.equal(captions[0].text, 'typo', 'parsed caption with backspaces correctly'); }); QUnit.test('extended character set', function(assert) { var captions = []; cea708Stream.on('data', function(caption) { captions.push(caption); }); [ { type: 3, pts: 1000, ccData: packetHeader708(0, 7, 1, 12) }, { type: 2, pts: 1000, ccData: displayWindows708([0]) }, { type: 2, pts: 1000, ccData: 0x8000 }, // CW0 { type: 2, pts: 1000, ccData: 0x103f }, // Ÿ { type: 2, pts: 1000, ccData: 0x1035 }, // • { type: 2, pts: 1000, ccData: 0x103f }, // Ÿ { type: 2, pts: 1000, ccData: 0x0020 }, { type: 3, pts: 1000, ccData: packetHeader708(1, 7, 1, 12) }, { type: 2, pts: 1000, ccData: 0x103c }, // œ { type: 2, pts: 1000, ccData: 0x102a }, // Š { type: 2, pts: 1000, ccData: 0x1025 }, // … { type: 2, pts: 1000, ccData: 0x102a }, // Š { type: 2, pts: 1000, ccData: 0x103c }, // œ { type: 2, pts: 1000, ccData: 0x0020 }, { type: 3, pts: 1000, ccData: packetHeader708(2, 5, 1, 8) }, { type: 2, pts: 1000, ccData: 0x1033 }, // “ { type: 2, pts: 1000, ccData: 0x103d }, // ℠ { type: 2, pts: 1000, ccData: 0x1034 }, // ” { type: 2, pts: 1000, ccData: 0x1039 }, // ™ { type: 3, pts: 2000, ccData: packetHeader708(0, 2, 1, 2) }, { type: 2, pts: 2000, ccData: 0x8aff }, // Indicate end of last packet { type: 3, pts: 3000, ccData: packetHeader708(1, 1, 1, 0) } ].forEach(cea708Stream.push, cea708Stream); assert.equal(captions.length, 1, 'parsed 1 caption'); assert.equal(captions[0].text, 'Ÿ•Ÿ œŠ…Šœ “℠”™', 'parsed extended characters correctly'); }); QUnit.test('roll up', function(assert) { var captions = []; cea708Stream.on('data', function(caption) { captions.push(caption); }); [ // Define window with two virtual rows (rowCount = 1) { type: 3, pts: 1000, ccData: packetHeader708(0, 4, 1, 6) }, { type: 2, pts: 1000, ccData: 0x983b }, { type: 2, pts: 1000, ccData: 0x8f00 }, { type: 2, pts: 1000, ccData: 0x611f }, { type: 3, pts: 1000, ccData: packetHeader708(1, 3, 1, 4) }, { type: 2, pts: 1000, ccData: characters('L1') }, { type: 2, pts: 1000, ccData: 0x0d00 }, // CR { type: 3, pts: 2000, ccData: packetHeader708(2, 3, 1, 4) }, { type: 2, pts: 2000, ccData: characters('L2') }, { type: 2, pts: 2000, ccData: 0x0d00 }, // CR { type: 3, pts: 3000, ccData: packetHeader708(0, 3, 1, 4) }, { type: 2, pts: 3000, ccData: characters('L3') }, { type: 2, pts: 3000, ccData: 0x0d00 }, // CR { type: 3, pts: 4000, ccData: packetHeader708(1, 3, 1, 4) }, { type: 2, pts: 4000, ccData: characters('L4') }, { type: 2, pts: 4000, ccData: 0x0d00 }, // CR { type: 3, pts: 5000, ccData: packetHeader708(2, 2, 1, 2) }, { type: 2, pts: 5000, ccData: 0x8aff }, // Indicate end of last packet { type: 3, pts: 6000, ccData: packetHeader708(0, 1, 1, 0) } ].forEach(cea708Stream.push, cea708Stream); assert.equal(captions.length, 3, 'parsed 3 captions'); assert.equal(captions[0].text, 'L1\nL2', 'parsed caption 1 correctly'); assert.equal(captions[1].text, 'L2\nL3', 'parsed caption 2 correctly'); assert.equal(captions[2].text, 'L3\nL4', 'parsed caption 3 correctly'); });