diff --git a/README.md b/README.md index 81ad866..1052c2e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# WebVTT parser and segmenter +# WebVTT compiler parser and segmenter -Parse WebVTT files, segments and generates HLS playlists for them. +Compiles, parses WebVTT files, segments and generates HLS playlists for them. ## Usage @@ -23,7 +23,7 @@ Foo Bar ``` -We can parse, segment and create HLS playlists: +We can parse, segment and create HLS playlists, and compile back to WebVTT format: ```javascript const webvtt = require('node-webvtt'); @@ -32,6 +32,7 @@ const segmentDuration = 10; // default to 10 const startOffset = 0; // Starting MPEG TS offset to be used in timestamp map, default 900000 const parsed = webvtt.parse(input); +const compile = webvtt.compile(input); const segmented = webvtt.parse(input, segmentDuration); const playlist = webvtt.hls.hlsSegmentPlaylist(input, segmentDuration); const segments = webvtt.hls.hlsSegment(input, segmentDuration, startOffset); @@ -88,6 +89,13 @@ For the above example we'd get: } ``` +### Compiling + +Compiles JSON from the above format back into a WebVTT string. + +If the object is missing any attributes, the compiler will throw a `CompilerError` exception. So +for safety, calls to `compile` should be in `try catch`. + ### Segmenting Segments a subtitle according to how it should be segmented for HLS subtitles. diff --git a/index.js b/index.js index 29a7444..29a6603 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,8 @@ 'use strict'; const parse = require('./lib/parser').parse; +const compile = require('./lib/compiler').compile; const segment = require('./lib/segmenter').segment; const hls = require('./lib/hls'); -module.exports = { parse, segment, hls }; +module.exports = { parse, compile, segment, hls }; diff --git a/lib/compiler.js b/lib/compiler.js new file mode 100644 index 0000000..fdb5241 --- /dev/null +++ b/lib/compiler.js @@ -0,0 +1,148 @@ +'use strict'; + +/** + * See spec: https://www.w3.org/TR/webvtt1/#file-structure + */ + +function CompilerError (message, error) { + this.message = message; + this.error = error; +} + +CompilerError.prototype = Object.create(Error.prototype); + +function compile (input) { + + if (!input) { + throw new CompilerError('Input must be non-null'); + } + + if (typeof input !== 'object') { + throw new CompilerError('Input must be an object'); + } + + if (input.isArray) { + throw new CompilerError('Input cannot be array'); + } + + if (!input.valid) { + throw new CompilerError('Input must be valid'); + } + + + let output = 'WEBVTT\n'; + + input.cues.forEach((cue) => { + output += '\n'; + output += compileCue(cue); + output += '\n'; + }); + + return output; +} + +/** + * Compile a single cue block. + * + * @param {array} cue Array of content for the cue + * + * @returns {object} cue Cue object with start, end, text and styles. + * Null if it's a note + */ +function compileCue (cue) { + // TODO: check for malformed JSON + if (typeof cue !== 'object') { + throw new CompilerError('Cue malformed: not of type object'); + } + + if (typeof cue.identifier !== 'string' && + typeof cue.identifier !== 'number' && + cue.identifier !== null) { + throw new CompilerError(`Cue malformed: identifier value is not a string. + ${JSON.stringify(cue)}`); + } + + if (isNaN(cue.start)) { + throw new CompilerError(`Cue malformed: null start value. + ${JSON.stringify(cue)}`); + } + + if (isNaN(cue.end)) { + throw new CompilerError(`Cue malformed: null end value. + ${JSON.stringify(cue)}`); + } + + if (cue.start >= cue.end) { + throw new CompilerError(`Cue malformed: start timestamp greater than end + ${JSON.stringify(cue)}`); + } + + if (typeof cue.text !== 'string') { + throw new CompilerError(`Cue malformed: null text value. + ${JSON.stringify(cue)}`); + } + + if (typeof cue.styles !== 'string') { + throw new CompilerError(`Cue malformed: null styles value. + ${JSON.stringify(cue)}`); + } + + let output = ''; + + if (cue.identifier.length > 0) { + output += `${cue.identifier}\n`; + } + + const startTimestamp = convertTimestamp(cue.start); + const endTimestamp = convertTimestamp(cue.end); + + output += `${startTimestamp} --> ${endTimestamp}`; + output += cue.styles ? ` ${cue.styles}` : ''; + output += `\n${cue.text}`; + + return output; +} + +function convertTimestamp (time) { + if (isNaN(time)) { + throw new CompilerError('Timestamp: not type number'); + } + + const hours = pad(calculateHours(time), 2); + const minutes = pad(calculateMinutes(time), 2); + const seconds = pad(calculateSeconds(time), 2); + const milliseconds = pad(calculateMs(time), 3); + return `${hours}:${minutes}:${seconds}.${milliseconds}`; +} + +function pad (num, zeroes) { + if (isNaN(zeroes)) { + throw new CompilerError('Pad: length is not type number'); + } + + let output = `${num}`; + + while (output.length < zeroes) { + output = `0${ output }`; + } + + return output; +} + +function calculateHours (time) { + return Math.floor(time / 60 / 60); +} + +function calculateMinutes (time) { + return (Math.floor(time / 60) % 60); +} + +function calculateSeconds (time) { + return Math.floor((time) % 60); +} + +function calculateMs (time) { + return Math.round((time % 1) * 1000); +} + +module.exports = { CompilerError, compile}; diff --git a/package-lock.json b/package-lock.json index 77b5910..514ed3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1095,7 +1095,7 @@ }, "through": { "version": "2.3.8", - "resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "dev": true }, diff --git a/package.json b/package.json index a5a376d..c63489e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "node-webvtt", "version": "1.1.0", - "description": "WebVTT parser and segmenter with HLS support", + "description": "WebVTT parser, compiler, and segmenter with HLS support", "main": "index.js", "scripts": { "eslint": "eslint *.js **/*.js", diff --git a/test/compiler.test.js b/test/compiler.test.js new file mode 100644 index 0000000..11946f1 --- /dev/null +++ b/test/compiler.test.js @@ -0,0 +1,309 @@ +'use strict'; + +const chai = require('chai'); +chai.should(); + +const compiler = require('../lib/compiler'); +const compilerError = compiler.CompilerError; +const compile = compiler.compile; + +const parser = require('../lib/parser'); +const parse = parser.parse; + +describe('WebVTT compiler', () => { + + it('should not compile null', () => { + (() => { compile(null); }) + .should.throw(compilerError, /null/); + }); + + it('should not compile undefined', () => { + (() => { compile(); }) + .should.throw(compilerError, /Input/); + }); + + it('should not compile string', () => { + (() => { compile(''); }) + .should.throw(compilerError, /Input/); + }); + + it('should not compile array', () => { + (() => { compile([]); }) + .should.throw(compilerError, /Input/); + }); + + it('should compile object', () => { + (() => { + compile({ + cues: [{ + end: 1, + identifier: '', + start: 0, + styles: '', + text: 'Hello world!' + }], valid: true + }); + }) + .should.not.throw(compilerError, /valid/); + }); + + it('should not compile invalid cue', () => { + (() => { + compile({ + cues: [{ + end: 1, + identifier: '', + start: 0, + styles: '', + text: 'Hello world!' + }], valid: false + }); + }) + .should.throw(compilerError, /valid/); + }); + + it('should compile string identifier', () => { + (() => { + compile({ + cues: [{ + end: 1, + identifier: 'chance', + start: 0, + styles: '', + text: 'Hello world!' + }], valid: true + }); + }) + .should.not.throw(compilerError, /identifier value/); + }); + + it('should compile empty identifier', () => { + (() => { + compile({ + cues: [{ + end: 1, + identifier: '', + start: 0, + styles: '', + text: 'Hello world!' + }], valid: true + }); + }) + .should.not.throw(compilerError, /identifier value/); + }); + + it('should compile null identifier', () => { + (() => { + compile({ + cues: [{ + end: 1, + identifier: null, + start: 0, + styles: '', + text: 'Hello world!' + }], valid: true + }); + }) + .should.not.throw(compilerError, /identifier value/); + }); + + it('should compile numeric identifier', () => { + (() => { + compile({ + cues: [{ + end: 1, + identifier: 1, + start: 0, + styles: '', + text: 'Hello world!' + }], valid: true + }); + }) + .should.not.throw(compilerError, /identifier value/); + }); + + it('should not compile object cue', () => { + (() => { + compile({ + cues: [{ + end: 1, + identifier: {}, + start: 0, + styles: '', + text: 'Hello world!' + }], valid: true + }); + }) + .should.throw(compilerError, /identifier value/); + }); + + it('should compile cues with numeric start', () => { + (() => { + compile({ + cues: [{ + end: 1, + identifier: '', + start: '0', + styles: '', + text: 'Hello world!' + }], valid: true + }); + }) + .should.not.throw(compilerError, /Cue malformed/); + }); + + it('should compile cues with numeric end', () => { + (() => { + compile({ + cues: [{ + end: '1', + identifier: '', + start: 0, + styles: '', + text: 'Hello world!' + }], valid: true + }); + }) + .should.not.throw(compilerError, /Cue malformed/); + }); + + it('should not compile cues with non-numeric end', () => { + (() => { + compile({ + cues: [{ + end: '1a', + identifier: '', + start: 0, + styles: '', + text: 'Hello world!' + }], valid: true + }); + }) + .should.throw(compilerError, /Cue malformed/); + }); + + it('should not compile equal start and end times', () => { + (() => { + compile({ + cues: [{ + end: 1, + identifier: '', + start: 1, + styles: '', + text: 'Hello world!' + }], valid: true + }); + }) + .should.throw(compilerError, /Cue malformed/); + }); + + it('should not compile non-string text', () => { + (() => { + compile({ + cues: [{ + end: 1, + identifier: {}, + start: 0, + styles: '', + text: 1 + }], valid: true + }); + }) + .should.throw(compilerError, /Cue malformed/); + }); + + it('should not compile non-string styles', () => { + (() => { + compile({ + cues: [{ + end: 1, + identifier: {}, + start: 0, + styles: null, + text: '' + }], valid: true + }); + }) + .should.throw(compilerError, /Cue malformed/); + }); + + it('should compile properly', () => { + + const input = { + cues: [{ + end: 140, + identifier: '1', + start: 135.001, + styles: '', + text: 'Ta en kopp varmt te.\nDet är inte varmt.' + }, { + end: 145, + identifier: '2', + start: 140, + styles: '', + text: 'Har en kopp te.\nDet smakar som te.' + }, { + end: 150, + identifier: '3', + start: 145, + styles: '', + text: 'Ta en kopp' + }], + valid: true + }; + const output = `WEBVTT + +1 +00:02:15.001 --> 00:02:20.000 +Ta en kopp varmt te. +Det är inte varmt. + +2 +00:02:20.000 --> 00:02:25.000 +Har en kopp te. +Det smakar som te. + +3 +00:02:25.000 --> 00:02:30.000 +Ta en kopp +`; + + compile(input).should.equal(output); + }); + + it('should compile string start and end times', () => { + (() => { + compile({ + cues: [{ + end: '1', + identifier: '', + start: '0', + styles: '', + text: 'Hello world!' + }], valid: false + }); + }) + .should.not.throw(compilerError, /Timestamp/); + }); + + it('should be reversible', () => { + + const input = `WEBVTT + +1 +00:02:15.001 --> 00:02:20.000 +Ta en kopp varmt te. +Det är inte varmt. + +2 +00:02:20.000 --> 00:02:25.000 +Har en kopp te. +Det smakar som te. + +3 +00:02:25.000 --> 00:02:30.000 +Ta en kopp +`; + compile(parse(input)).should.equal(input); + }); +});