Skip to content

Commit

Permalink
Support post-processing to produce arbitrary output lines for segment…
Browse files Browse the repository at this point in the history
…/variant (#151)
  • Loading branch information
kuu authored Apr 21, 2024
1 parent 6b038fb commit 6dd73bc
Show file tree
Hide file tree
Showing 5 changed files with 268 additions and 10 deletions.
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 26 additions & 8 deletions stringify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
Segment,
SessionData,
SpliceInfo,
Variant
Variant,
PostProcess,
} from './types';

const ALLOW_REDUNDANCY = [
Expand Down Expand Up @@ -57,6 +58,15 @@ class LineArray extends Array<string> {
}
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) {
Expand All @@ -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);
}
}
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -272,14 +286,18 @@ 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') {
unclosedCueIn = true;
} 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');
Expand Down Expand Up @@ -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);
Expand All @@ -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'));
Expand Down
226 changes: 226 additions & 0 deletions test/spec/stringify.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
<b>#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</b>
#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] = `<b>${lines[start]}`;
} else if (start > 0) {
lines[start - 1] = `${lines[start - 1]}</b>`;
}
}
}
};
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));
});
5 changes: 5 additions & 0 deletions types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

0 comments on commit 6dd73bc

Please sign in to comment.