Skip to content

Commit 67859c9

Browse files
authored
feat(HLS): Add I-Frame playlist support (#7230)
1 parent e522921 commit 67859c9

File tree

7 files changed

+160
-24
lines changed

7 files changed

+160
-24
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ HLS features supported:
140140
- SAMPLE-AES and SAMPLE-AES-CTR (identity) support on browsers with ClearKey support
141141
- Key rotation
142142
- Raw AAC, MP3, AC-3 and EC-3 (without an MP4 container)
143-
- I-frame-only playlists with mjpg codec for thumbnails
143+
- I-frame-only playlists (for trick play and thumbnails)
144144
- #EXT-X-IMAGE-STREAM-INF for thumbnails
145145
- Interstitials
146146
- Container change during the playback (eg: MP4 to TS, or AAC to TS)

demo/common/assets.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1385,6 +1385,7 @@ shakaAssets.testAssets = [
13851385
.addFeature(shakaAssets.Feature.HLS)
13861386
.addFeature(shakaAssets.Feature.MP4)
13871387
.addFeature(shakaAssets.Feature.ULTRA_HIGH_DEFINITION)
1388+
.addFeature(shakaAssets.Feature.TRICK_MODE)
13881389
.addFeature(shakaAssets.Feature.OFFLINE)
13891390
.addFeature(shakaAssets.Feature.THUMBNAILS),
13901391
new ShakaDemoAssetInfo(
@@ -1461,6 +1462,7 @@ shakaAssets.testAssets = [
14611462
.addFeature(shakaAssets.Feature.HLS)
14621463
.addFeature(shakaAssets.Feature.HIGH_DEFINITION)
14631464
.addFeature(shakaAssets.Feature.MP4)
1465+
.addFeature(shakaAssets.Feature.TRICK_MODE)
14641466
.addFeature(shakaAssets.Feature.OFFLINE)
14651467
.addFeature(shakaAssets.Feature.LCEVC)
14661468
.setExtraConfig({
@@ -1479,6 +1481,7 @@ shakaAssets.testAssets = [
14791481
.addFeature(shakaAssets.Feature.HLS)
14801482
.addFeature(shakaAssets.Feature.HIGH_DEFINITION)
14811483
.addFeature(shakaAssets.Feature.MP2TS)
1484+
.addFeature(shakaAssets.Feature.TRICK_MODE)
14821485
.addFeature(shakaAssets.Feature.OFFLINE)
14831486
.addFeature(shakaAssets.Feature.LCEVC)
14841487
.setExtraConfig({

lib/dash/dash_parser.js

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ goog.require('shaka.util.ObjectUtils');
3535
goog.require('shaka.util.OperationManager');
3636
goog.require('shaka.util.PeriodCombiner');
3737
goog.require('shaka.util.PlayerConfiguration');
38+
goog.require('shaka.util.StreamUtils');
3839
goog.require('shaka.util.StringUtils');
3940
goog.require('shaka.util.Timer');
4041
goog.require('shaka.util.TXml');
@@ -1596,22 +1597,8 @@ shaka.dash.DashParser = class {
15961597
for (const normalSet of normalAdaptationSets) {
15971598
if (targetIds.includes(normalSet.id)) {
15981599
for (const stream of normalSet.streams) {
1599-
const validStreams = trickModeSet.streams.filter((trickStream) =>
1600-
shaka.util.MimeUtils.getNormalizedCodec(stream.codecs) ==
1601-
shaka.util.MimeUtils.getNormalizedCodec(trickStream.codecs))
1602-
.sort((a, b) => {
1603-
return a.bandwidth - b.bandwidth;
1604-
});
1605-
stream.trickModeVideo = validStreams[0];
1606-
if (validStreams.length <= 1) {
1607-
continue;
1608-
}
1609-
const sameResolutionStream = validStreams.find((trickStream) =>
1610-
stream.width == trickStream.width &&
1611-
stream.height == trickStream.height);
1612-
if (sameResolutionStream) {
1613-
stream.trickModeVideo = sameResolutionStream;
1614-
}
1600+
shaka.util.StreamUtils.setBetterIFrameStream(
1601+
stream, trickModeSet.streams);
16151602
}
16161603
}
16171604
}

lib/hls/hls_parser.js

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ goog.require('shaka.util.Timer');
4343
goog.require('shaka.util.TsParser');
4444
goog.require('shaka.util.TXml');
4545
goog.require('shaka.util.Platform');
46+
goog.require('shaka.util.StreamUtils');
4647
goog.require('shaka.util.Uint8ArrayUtils');
4748
goog.requireType('shaka.hls.Segment');
4849

@@ -938,9 +939,10 @@ shaka.hls.HlsParser = class {
938939
this.parseCodecs_(variantTags);
939940

940941
this.parseClosedCaptions_(mediaTags);
942+
const iFrameStreams = this.parseIFrames_(iFrameTags);
941943
variants = await this.createVariantsForTags_(
942944
variantTags, sessionKeyTags, mediaTags, getUris,
943-
this.globalVariables_);
945+
this.globalVariables_, iFrameStreams);
944946
textStreams = this.parseTexts_(mediaTags);
945947
imageStreams = await this.parseImages_(imageTags, iFrameTags);
946948
}
@@ -1458,7 +1460,8 @@ shaka.hls.HlsParser = class {
14581460
}
14591461
try {
14601462
const streamInfo = this.createStreamInfoFromIframeTag_(tag);
1461-
if (streamInfo.stream.codecs !== 'mjpg') {
1463+
const ContentType = shaka.util.ManifestParserUtils.ContentType;
1464+
if (streamInfo.stream.type !== ContentType.IMAGE) {
14621465
return null;
14631466
}
14641467
return streamInfo.stream;
@@ -1503,19 +1506,40 @@ shaka.hls.HlsParser = class {
15031506
}
15041507
}
15051508

1509+
/**
1510+
* @param {!Array.<!shaka.hls.Tag>} iFrameTags from the playlist.
1511+
* @return {!Array.<!shaka.extern.Stream>}
1512+
* @private
1513+
*/
1514+
parseIFrames_(iFrameTags) {
1515+
// Create iFrame stream for each iFrame tag.
1516+
const iFrameStreams = iFrameTags.map((tag) => {
1517+
const streamInfo = this.createStreamInfoFromIframeTag_(tag);
1518+
const ContentType = shaka.util.ManifestParserUtils.ContentType;
1519+
if (streamInfo.stream.type !== ContentType.VIDEO) {
1520+
return null;
1521+
}
1522+
return streamInfo.stream;
1523+
});
1524+
1525+
// Filter mjpg iFrames
1526+
return iFrameStreams.filter((s) => s);
1527+
}
1528+
15061529
/**
15071530
* @param {!Array.<!shaka.hls.Tag>} tags Variant tags from the playlist.
15081531
* @param {!Array.<!shaka.hls.Tag>} sessionKeyTags EXT-X-SESSION-KEY tags
15091532
* from the playlist.
15101533
* @param {!Array.<!shaka.hls.Tag>} mediaTags EXT-X-MEDIA tags from the
15111534
* playlist.
15121535
* @param {function():!Array.<string>} getUris
1513-
* @param {?Map.<string, string>=} variables
1536+
* @param {?Map.<string, string>} variables
1537+
* @param {!Array.<!shaka.extern.Stream>} iFrameStreams
15141538
* @return {!Promise.<!Array.<!shaka.extern.Variant>>}
15151539
* @private
15161540
*/
15171541
async createVariantsForTags_(tags, sessionKeyTags, mediaTags, getUris,
1518-
variables) {
1542+
variables, iFrameStreams) {
15191543
// EXT-X-SESSION-KEY processing
15201544
const drmInfos = [];
15211545
const keyIds = new Set();
@@ -1625,7 +1649,8 @@ shaka.hls.HlsParser = class {
16251649
videoRange,
16261650
videoLayout,
16271651
drmInfos,
1628-
keyIds));
1652+
keyIds,
1653+
iFrameStreams));
16291654
}
16301655
return allVariants.filter((variant) => variant != null);
16311656
}
@@ -1960,12 +1985,13 @@ shaka.hls.HlsParser = class {
19601985
* @param {?string} videoLayout
19611986
* @param {!Array.<shaka.extern.DrmInfo>} drmInfos
19621987
* @param {!Set.<string>} keyIds
1988+
* @param {!Array.<!shaka.extern.Stream>} iFrameStreams
19631989
* @return {!Array.<!shaka.extern.Variant>}
19641990
* @private
19651991
*/
19661992
createVariants_(
19671993
audioInfos, videoInfos, bandwidth, width, height, frameRate, videoRange,
1968-
videoLayout, drmInfos, keyIds) {
1994+
videoLayout, drmInfos, keyIds, iFrameStreams) {
19691995
const ContentType = shaka.util.ManifestParserUtils.ContentType;
19701996
const DrmUtils = shaka.util.DrmUtils;
19711997

@@ -2000,6 +2026,8 @@ shaka.hls.HlsParser = class {
20002026
if (videoStream) {
20012027
videoStream.drmInfos = drmInfos;
20022028
videoStream.keyIds = keyIds;
2029+
shaka.util.StreamUtils.setBetterIFrameStream(
2030+
videoStream, iFrameStreams);
20032031
}
20042032
if (videoStream && !audioStream) {
20052033
videoStream.bandwidth = bandwidth;
@@ -2267,11 +2295,15 @@ shaka.hls.HlsParser = class {
22672295
goog.asserts.assert(tag.name == 'EXT-X-I-FRAME-STREAM-INF',
22682296
'Should only be called on iframe tags!');
22692297
/** @type {string} */
2270-
const type = shaka.util.ManifestParserUtils.ContentType.IMAGE;
2298+
let type = shaka.util.ManifestParserUtils.ContentType.VIDEO;
22712299

22722300
const verbatimIFramePlaylistUri = tag.getRequiredAttrValue('URI');
22732301
const codecs = tag.getAttributeValue('CODECS') || '';
22742302

2303+
if (codecs == 'mjpg') {
2304+
type = shaka.util.ManifestParserUtils.ContentType.IMAGE;
2305+
}
2306+
22752307
// Check if the stream has already been created as part of another Variant
22762308
// and return it if it has.
22772309
if (this.uriToStreamInfosMap_.has(verbatimIFramePlaylistUri)) {

lib/util/stream_utils.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1884,6 +1884,37 @@ shaka.util.StreamUtils = class {
18841884
}
18851885

18861886

1887+
/**
1888+
* Set the best iframe stream to the original stream.
1889+
*
1890+
* @param {!shaka.extern.Stream} stream
1891+
* @param {!Array.<!shaka.extern.Stream>} iFrameStreams
1892+
*/
1893+
static setBetterIFrameStream(stream, iFrameStreams) {
1894+
if (!iFrameStreams.length) {
1895+
return;
1896+
}
1897+
const validStreams = iFrameStreams.filter((iFrameStream) =>
1898+
shaka.util.MimeUtils.getNormalizedCodec(stream.codecs) ==
1899+
shaka.util.MimeUtils.getNormalizedCodec(iFrameStream.codecs))
1900+
.sort((a, b) => {
1901+
if (!a.bandwidth || !b.bandwidth || a.bandwidth == b.bandwidth) {
1902+
return (a.width || 0) - (b.width || 0);
1903+
}
1904+
return a.bandwidth - b.bandwidth;
1905+
});
1906+
stream.trickModeVideo = validStreams[0];
1907+
if (validStreams.length > 1) {
1908+
const sameResolutionStream = validStreams.find((iFrameStream) =>
1909+
stream.width == iFrameStream.width &&
1910+
stream.height == iFrameStream.height);
1911+
if (sameResolutionStream) {
1912+
stream.trickModeVideo = sameResolutionStream;
1913+
}
1914+
}
1915+
}
1916+
1917+
18871918
/**
18881919
* Returns a string of a variant, with the attribute values of its audio
18891920
* and/or video streams for log printing.

roadmap.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ v5.0 - 2024 Q4
2222

2323
v4.11 - 2024 Q3
2424
- HLS: EXT-X-START support
25+
- HLS: EXT-X-I-FRAME-STREAM-INF support
2526
- Basic support of VAST and VMAP without IMA (playback without tracking)
2627
- DASH: DVB Fonts
2728
- TTML: IMSC1 (CMAF) image subtitle

test/hls/hls_parser_unit.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2136,6 +2136,88 @@ describe('HlsParser', () => {
21362136
expect(thirdThumbnailReference).not.toBe(null);
21372137
});
21382138

2139+
it('supports EXT-X-I-FRAME-STREAM-INF for trick play', async () => {
2140+
const master = [
2141+
'#EXTM3U\n',
2142+
'#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="sub1",LANGUAGE="eng",',
2143+
'URI="text"\n',
2144+
'#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud1",LANGUAGE="eng",',
2145+
'CHANNELS="2",URI="audio"\n',
2146+
'#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1,mp4a",',
2147+
'RESOLUTION=960x540,FRAME-RATE=60,AUDIO="aud1",SUBTITLES="sub1"\n',
2148+
'video\n',
2149+
'#EXT-X-I-FRAME-STREAM-INF:RESOLUTION=960x540,CODECS="avc1",',
2150+
'URI="iframe"\n',
2151+
'#EXT-X-I-FRAME-STREAM-INF:RESOLUTION=240×135,CODECS="avc1",',
2152+
'URI="iframeAvc"\n',
2153+
].join('');
2154+
2155+
const video = [
2156+
'#EXTM3U\n',
2157+
'#EXT-X-PLAYLIST-TYPE:VOD\n',
2158+
'#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n',
2159+
'#EXTINF:5,\n',
2160+
'main.mp4\n',
2161+
'#EXTINF:5,\n',
2162+
'main.mp4\n',
2163+
'#EXTINF:5,\n',
2164+
'main.mp4\n',
2165+
].join('');
2166+
2167+
const audio = [
2168+
'#EXTM3U\n',
2169+
'#EXT-X-PLAYLIST-TYPE:VOD\n',
2170+
'#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n',
2171+
'#EXTINF:5,\n',
2172+
'main.mp4\n',
2173+
'#EXTINF:5,\n',
2174+
'main.mp4\n',
2175+
'#EXTINF:5,\n',
2176+
'main.mp4\n',
2177+
].join('');
2178+
2179+
const text = [
2180+
'#EXTM3U\n',
2181+
'#EXT-X-PLAYLIST-TYPE:VOD\n',
2182+
'#EXTINF:5,\n',
2183+
'#EXT-X-BYTERANGE:121090@616\n',
2184+
'main.vtt',
2185+
].join('');
2186+
2187+
const iframe = [
2188+
'#EXTM3U\n',
2189+
'#EXT-X-PLAYLIST-TYPE:VOD\n',
2190+
'#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n',
2191+
'#EXTINF:5,\n',
2192+
'main.mp4\n',
2193+
'#EXTINF:5,\n',
2194+
'main.mp4\n',
2195+
'#EXTINF:5,\n',
2196+
'main.mp4\n',
2197+
].join('');
2198+
2199+
fakeNetEngine
2200+
.setResponseText('test:/master', master)
2201+
.setResponseText('test:/audio', audio)
2202+
.setResponseText('test:/video', video)
2203+
.setResponseText('test:/text', text)
2204+
.setResponseText('test:/iframe', iframe)
2205+
.setResponseText('test:/main.vtt', vttText)
2206+
.setResponseValue('test:/init.mp4', initSegmentData)
2207+
.setResponseValue('test:/main.mp4', segmentData);
2208+
2209+
const actual = await parser.start('test:/master', playerInterface);
2210+
await loadAllStreamsFor(actual);
2211+
2212+
expect(actual.textStreams.length).toBe(1);
2213+
expect(actual.variants.length).toBe(1);
2214+
2215+
const trickModeVideo = actual.variants[0].video.trickModeVideo;
2216+
expect(trickModeVideo).toBeDefined();
2217+
expect(trickModeVideo.width).toBe(960);
2218+
expect(trickModeVideo.height).toBe(540);
2219+
});
2220+
21392221
it('parse EXT-X-GAP', async () => {
21402222
const master = [
21412223
'#EXTM3U\n',

0 commit comments

Comments
 (0)