From 6dd73bc68cc390bd33a70795d4560a2bf21b139b Mon Sep 17 00:00:00 2001 From: Kuu Miyazaki Date: Sun, 21 Apr 2024 15:07:48 +0900 Subject: [PATCH] Support post-processing to produce arbitrary output lines for segment/variant (#151) --- README.md | 10 +- package.json | 3 +- stringify.ts | 34 ++++-- test/spec/stringify.spec.js | 226 ++++++++++++++++++++++++++++++++++++ types.ts | 5 + 5 files changed, 268 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 0aef632..78668f6 100644 --- a/README.md +++ b/README.md @@ -61,13 +61,21 @@ Converts a text playlist into a structured JS object #### return value An instance of either `MasterPlaylist` or `MediaPlaylist` (See **Data format** below.) -### `HLS.stringify(obj)` +### `HLS.stringify(obj, processors)` Converts a JS object into a plain text playlist #### params | Name | Type | Required | Default | Description | | ------- | ------ | -------- | ------- | ------------- | | obj | `MasterPlaylist` or `MediaPlaylist` (See **Data format** below.) | Yes | N/A | An object returned by `HLS.parse()` or a manually created object | +| postProcess | PostProcess | No | undefined | A function to be called for each segment or variant to manipulate the output. | + +##### `PostProcess` +| Property | Type | Required | Default | Description | +| ---------------- | ------------- | -------- | ------- | ------------- | +| `segmentProcessor` | (lines: string[], start: number, end: number, segment: Segment, i: number) => undefined | No | undefined | A function to manipulate the segment output. | +| `variantProcessor` | (lines: string[], start: number, end: number, variant: Variant, i: number) => undefined | No | undefined | A function to manipulate the variant output. | + #### return value A text data that conforms to [the HLS playlist spec](https://tools.ietf.org/html/draft-pantos-http-live-streaming-23#section-4.1) diff --git a/package.json b/package.json index ccc8d84..84692e3 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "type-check": "tsc --noEmit", "audit": "npm audit --audit-level high", "build": "rm -fR ./dist; tsc ; webpack --mode development ; webpack --mode production", - "test": "npm run lint && npm run build && npm run audit && ava --verbose" + "test": "npm run lint && npm run build && npm run audit && ava --verbose", + "test-offline": "npm run lint && npm run build && ava --verbose" }, "repository": { "type": "git", diff --git a/stringify.ts b/stringify.ts index ba61bef..10b3a5d 100644 --- a/stringify.ts +++ b/stringify.ts @@ -10,7 +10,8 @@ import { Segment, SessionData, SpliceInfo, - Variant + Variant, + PostProcess, } from './types'; const ALLOW_REDUNDANCY = [ @@ -57,6 +58,15 @@ class LineArray extends Array { } return this.length; } + + override join(separator: string | undefined = ','): string { + for (let i = this.length - 1; i >= 0; i--) { + if (!this[i]) { + this.splice(i, 1); + } + } + return super.join(separator); + } } function buildDecimalFloatingNumber(num: number, fixed?: number) { @@ -77,15 +87,19 @@ function getNumberOfDecimalPlaces(num: number) { return str.length - index - 1; } -function buildMasterPlaylist(lines: LineArray, playlist: MasterPlaylist) { +function buildMasterPlaylist(lines: LineArray, playlist: MasterPlaylist, postProcess: PostProcess | undefined) { for (const sessionData of playlist.sessionDataList) { lines.push(buildSessionData(sessionData)); } for (const sessionKey of playlist.sessionKeyList) { lines.push(buildKey(sessionKey, true)); } - for (const variant of playlist.variants) { + for (const [i, variant] of playlist.variants.entries()) { + const base = lines.length; buildVariant(lines, variant); + if (postProcess?.variantProcessor) { + postProcess.variantProcessor(lines, base, lines.length - 1, variant, i); + } } } @@ -231,7 +245,7 @@ function buildRendition(rendition: Rendition) { return `#EXT-X-MEDIA:${attrs.join(',')}`; } -function buildMediaPlaylist(lines: LineArray, playlist: MediaPlaylist) { +function buildMediaPlaylist(lines: LineArray, playlist: MediaPlaylist, postProcess: PostProcess | undefined) { let lastKey = ''; let lastMap = ''; let unclosedCueIn = false; @@ -272,7 +286,8 @@ function buildMediaPlaylist(lines: LineArray, playlist: MediaPlaylist) { if (playlist.skip > 0) { lines.push(`#EXT-X-SKIP:SKIPPED-SEGMENTS=${playlist.skip}`); } - for (const segment of playlist.segments) { + for (const [i, segment] of playlist.segments.entries()) { + const base = lines.length; let markerType = ''; [lastKey, lastMap, markerType] = buildSegment(lines, segment, lastKey, lastMap, playlist.version); if (markerType === 'OUT') { @@ -280,6 +295,9 @@ function buildMediaPlaylist(lines: LineArray, playlist: MediaPlaylist) { } else if (markerType === 'IN' && unclosedCueIn) { unclosedCueIn = false; } + if (postProcess?.segmentProcessor) { + postProcess.segmentProcessor(lines, base, lines.length - 1, segment, i); + } } if (playlist.playlistType === 'VOD' && unclosedCueIn) { lines.push('#EXT-X-CUE-IN'); @@ -449,7 +467,7 @@ function buildParts(lines: LineArray, parts: PartialSegment[]) { return hint; } -function stringify(playlist: MasterPlaylist | MediaPlaylist): string { +function stringify(playlist: MasterPlaylist | MediaPlaylist, postProcess: PostProcess | undefined): string { utils.PARAMCHECK(playlist); utils.ASSERT('Not a playlist', playlist.type === 'playlist'); const lines = new LineArray(playlist.uri); @@ -464,9 +482,9 @@ function stringify(playlist: MasterPlaylist | MediaPlaylist): string { lines.push(`#EXT-X-START:TIME-OFFSET=${buildDecimalFloatingNumber(playlist.start.offset)}${playlist.start.precise ? ',PRECISE=YES' : ''}`); } if (playlist.isMasterPlaylist) { - buildMasterPlaylist(lines, playlist as MasterPlaylist); + buildMasterPlaylist(lines, playlist as MasterPlaylist, postProcess); } else { - buildMediaPlaylist(lines, playlist as MediaPlaylist); + buildMediaPlaylist(lines, playlist as MediaPlaylist, postProcess); } // console.log('<<<'); // console.log(lines.join('\n')); diff --git a/test/spec/stringify.spec.js b/test/spec/stringify.spec.js index 30cf8d8..23b45c1 100644 --- a/test/spec/stringify.spec.js +++ b/test/spec/stringify.spec.js @@ -11,3 +11,229 @@ for (const {name, m3u8, object} of fixtures) { t.is(result, utils.stripCommentsAndEmptyLines(m3u8)); }); } + +test('stringify.postProcess.segment.add', t => { + const obj = HLS.parse(` + #EXTM3U + #EXT-X-VERSION:3 + #EXT-X-TARGETDURATION:6 + #EXTINF:6.006, + http://media.example.com/01.ts + #EXTINF:6.006, + http://media.example.com/02.ts + #EXTINF:6.006, + http://ads.example.com/ad-01.ts + #EXTINF:6.006, + http://ads.example.com/ad-02.ts + #EXTINF:6.006, + http://media.example.com/03.ts + #EXTINF:3.003, + http://media.example.com/04.ts + `); + const expected = ` + #EXTM3U + #EXT-X-VERSION:3 + #EXT-X-TARGETDURATION:6 + #EXT-X-MY-PROGRAM-DATE-TIME:2014-03-05T11:14:00.000Z + #EXTINF:6.006, + http://media.example.com/01.ts + #EXT-X-MY-PROGRAM-DATE-TIME:2014-03-05T11:14:06.006Z + #EXTINF:6.006, + http://media.example.com/02.ts + #EXT-X-MY-PROGRAM-DATE-TIME:2014-03-05T11:14:12.012Z + #EXTINF:6.006, + http://ads.example.com/ad-01.ts + #EXT-X-MY-PROGRAM-DATE-TIME:2014-03-05T11:14:18.018Z + #EXTINF:6.006, + http://ads.example.com/ad-02.ts + #EXT-X-MY-PROGRAM-DATE-TIME:2014-03-05T11:14:24.024Z + #EXTINF:6.006, + http://media.example.com/03.ts + #EXT-X-MY-PROGRAM-DATE-TIME:2014-03-05T11:14:30.030Z + #EXTINF:3.003, + http://media.example.com/04.ts + `; + let time = new Date('2014-03-05T11:14:00.000Z').getTime(); + const segmentProcessor = (lines, start, end, segment) => { + let hasPdt = false; + for (let i = start; i <= end; i++) { + if (lines[i].startsWith('#EXT-X-PROGRAM-DATE-TIME')) { + hasPdt = true; + break; + } + } + if (!hasPdt) { + lines.splice(start, 0, `#EXT-X-MY-PROGRAM-DATE-TIME:${new Date(Math.round(time)).toISOString()}`); + } + time += segment.duration * 1000; + }; + const result = HLS.stringify(obj, {segmentProcessor}); + t.is(result, utils.stripCommentsAndEmptyLines(expected)); +}); + +test('stringify.postProcess.segment.delete', t => { + const obj = HLS.parse(` + #EXTM3U + #EXT-X-VERSION:3 + #EXT-X-TARGETDURATION:6 + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:00.000Z + #EXTINF:6.006, + http://media.example.com/01.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:06.006Z + #EXTINF:6.006, + http://media.example.com/02.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:12.012Z + #EXT-X-DATERANGE:ID="splice-6FFFFFF0",START-DATE="2014-03-05T11:15:00.000Z",PLANNED-DURATION=59.993,SCTE35-OUT=0xFC002F0000000000FF000014056FFFFFF000E011622DCAFF000052636200000000000A0008029896F50000008700000000 + #EXTINF:6.006, + http://ads.example.com/ad-01.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:18.018Z + #EXTINF:6.006, + http://ads.example.com/ad-02.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:24.024Z + #EXT-X-DATERANGE:ID="splice-6FFFFFF0",DURATION=59.993,SCTE35-IN=0xFC002A0000000000FF00000F056FFFFFF000401162802E6100000000000A0008029896F50000008700000000 + #EXTINF:6.006, + http://media.example.com/03.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:30.030Z + #EXTINF:3.003, + http://media.example.com/04.ts + `); + const expected = ` + #EXTM3U + #EXT-X-VERSION:3 + #EXT-X-TARGETDURATION:6 + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:00.000Z + #EXTINF:6.006, + http://media.example.com/01.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:06.006Z + #EXTINF:6.006, + http://media.example.com/02.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:12.012Z + #EXTINF:6.006, + http://ads.example.com/ad-01.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:18.018Z + #EXTINF:6.006, + http://ads.example.com/ad-02.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:24.024Z + #EXTINF:6.006, + http://media.example.com/03.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:30.030Z + #EXTINF:3.003, + http://media.example.com/04.ts + `; + const segmentProcessor = (lines, start, end) => { + for (let i = start; i <= end; i++) { + const line = lines[i]; + if (line.startsWith('#EXT-X-DATERANGE')) { + lines[i] = ''; + } + } + }; + const result = HLS.stringify(obj, {segmentProcessor}); + t.is(result, utils.stripCommentsAndEmptyLines(expected)); +}); + +test('stringify.postProcess.segment.update', t => { + const obj = HLS.parse(` + #EXTM3U + #EXT-X-VERSION:3 + #EXT-X-TARGETDURATION:6 + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:00.000Z + #EXTINF:6.006, + http://media.example.com/01.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:06.006Z + #EXTINF:6.006, + http://media.example.com/02.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:12.012Z + #EXT-X-DATERANGE:ID="splice-6FFFFFF0",START-DATE="2014-03-05T11:15:00.000Z",PLANNED-DURATION=59.993,SCTE35-OUT=0xFC002F0000000000FF000014056FFFFFF000E011622DCAFF000052636200000000000A0008029896F50000008700000000 + #EXTINF:6.006, + http://ads.example.com/ad-01.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:18.018Z + #EXTINF:6.006, + http://ads.example.com/ad-02.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:24.024Z + #EXT-X-DATERANGE:ID="splice-6FFFFFF0",DURATION=59.993,SCTE35-IN=0xFC002A0000000000FF00000F056FFFFFF000401162802E6100000000000A0008029896F50000008700000000 + #EXTINF:6.006, + http://media.example.com/03.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:30.030Z + #EXTINF:3.003, + http://media.example.com/04.ts + `); + const expected = ` + #EXTM3U + #EXT-X-VERSION:3 + #EXT-X-TARGETDURATION:6 + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:00.000Z + #EXTINF:6.006, + http://media.example.com/01.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:06.006Z + #EXTINF:6.006, + http://media.example.com/02.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:12.012Z + #EXT-X-DATERANGE:ID="splice-6FFFFFF0",START-DATE="2014-03-05T11:15:00.000Z",PLANNED-DURATION=59.993,SCTE35-OUT=0xFC002F0000000000FF000014056FFFFFF000E011622DCAFF000052636200000000000A0008029896F50000008700000000 + #EXTINF:6.006, + http://ads.example.com/ad-01.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:18.018Z + #EXTINF:6.006, + http://ads.example.com/ad-02.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:24.024Z + #EXT-X-DATERANGE:ID="splice-6FFFFFF0",DURATION=59.993,SCTE35-IN=0xFC002A0000000000FF00000F056FFFFFF000401162802E6100000000000A0008029896F50000008700000000 + #EXTINF:6.006, + http://media.example.com/03.ts + #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:14:30.030Z + #EXTINF:3.003, + http://media.example.com/04.ts + `; + const segmentProcessor = (lines, start, end) => { + for (let i = start; i <= end; i++) { + const line = lines[i]; + if (line.startsWith('#EXT-X-DATERANGE')) { + if (line.includes('PLANNED-DURATION')) { + lines[start] = `${lines[start]}`; + } else if (start > 0) { + lines[start - 1] = `${lines[start - 1]}`; + } + } + } + }; + const result = HLS.stringify(obj, {segmentProcessor}); + t.is(result, utils.stripCommentsAndEmptyLines(expected)); +}); + +test('stringify.postProcess.variant.update', t => { + const obj = HLS.parse(` + #EXTM3U + #EXT-X-STREAM-INF:BANDWIDTH=1280000 + http://example.com/low.m3u8 + #EXT-X-STREAM-INF:BANDWIDTH=2560000 + http://example.com/mid.m3u8 + #EXT-X-STREAM-INF:BANDWIDTH=7680000 + http://example.com/hi.m3u8 +`); + const expected = ` + #EXTM3U + #EXT-X-STREAM-INF:BANDWIDTH=1280000,MY-RESOLUTION=1280x720 + http://example.com/low.m3u8 + #EXT-X-STREAM-INF:BANDWIDTH=2560000,MY-RESOLUTION=1920x1080 + http://example.com/mid.m3u8 + #EXT-X-STREAM-INF:BANDWIDTH=7680000,MY-RESOLUTION=3840x2160 + http://example.com/hi.m3u8 +`; + const variantProcessor = (lines, start, end, {bandwidth}) => { + for (let i = start; i <= end; i++) { + const line = lines[i]; + if (line.startsWith('#EXT-X-STREAM-INF')) { + let resolution = '640x360'; + if (bandwidth >= 1000000 && bandwidth < 2000000) { + resolution = '1280x720'; + } else if (bandwidth >= 2000000 && bandwidth < 3000000) { + resolution = '1920x1080'; + } else if (bandwidth >= 3000000) { + resolution = '3840x2160'; + } + lines[i] = `${line},MY-RESOLUTION=${resolution}`; + } + } + }; + const result = HLS.stringify(obj, {variantProcessor}); + t.is(result, utils.stripCommentsAndEmptyLines(expected)); +}); diff --git a/types.ts b/types.ts index 8c7ce9b..6c278a1 100644 --- a/types.ts +++ b/types.ts @@ -518,3 +518,8 @@ export type TagParam = | [ Date, null ]; export type UserAttribute = number | string | Uint8Array; + +export type PostProcess = { + segmentProcessor: ((lines: string[], start: number, end: number, segment: Segment, i: number) => undefined) | undefined; + variantProcessor: ((lines: string[], start: number, end: number, variant: Variant, i: number) => undefined) | undefined; +};