Add "mjpg" image I-Frame support (#7862)

This commit is contained in:
Rob Walch
2026-05-21 10:59:32 -07:00
committed by GitHub
parent e435886151
commit 8dd7373bf3
19 changed files with 1355 additions and 253 deletions
+50 -9
View File
@@ -461,7 +461,7 @@ export class BaseStreamController extends TaskLoop implements NetworkComponentAP
okToFlushForwardBuffer: boolean;
};
// (undocumented)
protected checkFragmentChanged(): boolean;
protected checkFragPlaying(): boolean;
// (undocumented)
protected checkLiveUpdate(details: LevelDetails): void;
// (undocumented)
@@ -565,7 +565,7 @@ export class BaseStreamController extends TaskLoop implements NetworkComponentAP
// (undocumented)
protected _loadInitSegment(fragment: Fragment, level: Level): void;
// (undocumented)
mapToInitFragWhenRequired(frag: Fragment | null): typeof frag;
protected mapToInitFragWhenRequired<T extends Fragment | null>(frag: T): T | Fragment;
// (undocumented)
protected media: HTMLMediaElement | null;
// (undocumented)
@@ -1021,6 +1021,8 @@ export interface CodecsParsed {
// (undocumented)
audioCodec?: string;
// (undocumented)
imageCodec?: string;
// (undocumented)
textCodec?: string;
// (undocumented)
unknownCodecs?: string[];
@@ -1335,6 +1337,13 @@ export class ErrorController extends Logger implements NetworkComponentAPI {
stopLoad(): void;
}
// Warning: (ae-missing-release-tag) "ErrorControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type ErrorControllerConfig = {
errorPenaltyExpireMs: number;
};
// Warning: (ae-missing-release-tag) "ErrorData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -2189,6 +2198,7 @@ class Hls implements HlsEventEmitter {
set capLevelToPlayerSize(shouldStartCapping: boolean);
readonly config: HlsConfig;
createIFramePlayer(configOverride?: Partial<HlsConfig>): HlsIFramesOnly | null;
createImageIFramePlayer(configOverride?: Partial<HlsConfig>): HlsImageIFramesOnly | null;
get currentLevel(): number;
// Warning: (ae-setter-with-docs) The doc comment for the property "currentLevel" must appear on the getter, not the setter.
set currentLevel(newLevel: number);
@@ -2399,7 +2409,6 @@ export interface HlsChunkPerformanceTiming extends HlsPerformanceTiming {
executeStart: number;
}
// Warning: (ae-forgotten-export) The symbol "ErrorControllerConfig" needs to be exported by the entry point hls.d.ts
// Warning: (ae-missing-release-tag) "HlsConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -2444,14 +2453,14 @@ export type HlsConfig = {
useMediaCapabilities: boolean;
streamController: typeof StreamController;
abrController: typeof AbrController;
bufferController: typeof BufferController;
capLevelController: typeof CapLevelController;
bufferController?: typeof BufferController;
capLevelController?: typeof CapLevelController;
errorController: typeof ErrorController;
fpsController?: typeof FPSController;
progressive: boolean;
lowLatencyMode: boolean;
primarySessionId?: string;
} & ABRControllerConfig & BufferControllerConfig & CapLevelControllerConfig & EMEControllerConfig & ErrorControllerConfig & FPSControllerConfig & GapControllerConfig & LevelControllerConfig & MP4RemuxerConfig & StreamControllerConfig & SelectionPreferences & LatencyControllerConfig & MetadataControllerConfig & TimelineControllerConfig & TSDemuxerConfig & HlsLoadPolicies & PlaylistControllerConfig & FragmentLoaderConfig & PlaylistLoaderConfig;
} & ABRControllerConfig & BufferControllerConfig & CapLevelControllerConfig & EMEControllerConfig & ErrorControllerConfig & FPSControllerConfig & GapControllerConfig & LevelControllerConfig & MP4RemuxerConfig & IFrameControllerConfig & StreamControllerConfig & SelectionPreferences & LatencyControllerConfig & MetadataControllerConfig & TimelineControllerConfig & TSDemuxerConfig & HlsLoadPolicies & PlaylistControllerConfig & FragmentLoaderConfig & PlaylistLoaderConfig;
// Warning: (ae-missing-release-tag) "HlsEventEmitter" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -2476,13 +2485,23 @@ export interface HlsEventEmitter {
// Warning: (ae-missing-release-tag) "HlsIFramesOnly" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface HlsIFramesOnly extends Hls {
// Warning: (ae-forgotten-export) The symbol "LoadMediaAtOptions" needs to be exported by the entry point hls.d.ts
//
export interface HlsIFramesOnly extends Omit<Hls, 'createIFramePlayer' | 'createImageIFramePlayer' | 'iframeVariants' | 'swapAudioCodec' | 'setAudioOption' | 'allAudioTracks' | 'audioTracks' | 'audioTrack' | 'nextAudioTrack' | 'setSubtitleOption' | 'allSubtitleTracks' | 'subtitleTracks' | 'subtitleTrack' | 'subtitleDisplay'> {
// (undocumented)
loadMediaAt(time: number, options?: Partial<LoadMediaAtOptions>): void;
}
// Warning: (ae-missing-release-tag) "HlsImageIFramesOnly" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface HlsImageIFramesOnly extends Omit<HlsIFramesOnly, 'attachMedia' | 'detachMedia' | 'transferMedia' | 'recoverMediaError' | 'media'> {
// (undocumented)
attachImage(image: HTMLImageElement): void;
// (undocumented)
detachImage(): void;
// (undocumented)
loadMediaAt(time: number): void;
}
// Warning: (ae-missing-release-tag) "HlsListeners" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
@@ -2724,8 +2743,17 @@ export class IFrameController extends Logger {
constructor(hls: Hls, HlsPlayerClass: typeof Hls);
// (undocumented)
createIFramePlayer(configOverride?: Partial<HlsConfig> | undefined): HlsIFramesOnly | null;
// (undocumented)
createImageIFramePlayer(configOverride?: Partial<HlsConfig> | undefined): HlsImageIFramesOnly | null;
}
// Warning: (ae-missing-release-tag) "IFrameControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type IFrameControllerConfig = {
iframeCacheLimit: number;
};
// Warning: (ae-missing-release-tag) "ILogFunction" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -3349,6 +3377,8 @@ export class Level {
// (undocumented)
readonly iframes?: boolean;
// (undocumented)
readonly imageCodec?: string;
// (undocumented)
loaded?: {
bytes: number;
duration: number;
@@ -3991,6 +4021,13 @@ export interface LoaderStats {
total: number;
}
// Warning: (ae-missing-release-tag) "LoadMediaAtOptions" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type LoadMediaAtOptions = {
seekOnAppend: boolean;
};
// Warning: (ae-missing-release-tag) "LoadPolicy" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -4835,6 +4872,8 @@ export class StreamController extends BaseStreamController implements NetworkCom
// Warning: (ae-setter-with-docs) The doc comment for the property "backtrackFragment" must appear on the getter, not the setter.
protected set backtrackFragment(value: Fragment | undefined);
// (undocumented)
protected _bufferInitSegment(currentLevel: Level, tracks: TrackSet, frag: Fragment, chunkMeta: ChunkMetadata): void;
// (undocumented)
protected checkFragmentChanged(): boolean;
protected get couldBacktrack(): boolean;
// Warning: (ae-setter-with-docs) The doc comment for the property "couldBacktrack" must appear on the getter, not the setter.
@@ -4857,6 +4896,8 @@ export class StreamController extends BaseStreamController implements NetworkCom
// (undocumented)
protected _handleFragmentLoadProgress(data: FragLoadedData): void;
// (undocumented)
protected _handleTransmuxComplete(transmuxResult: TransmuxerResult): void;
// (undocumented)
get hasEnoughToStart(): boolean;
// (undocumented)
immediateLevelSwitch(): void;
+2
View File
@@ -321,6 +321,8 @@ function getAliasesForDist(format, features) {
aliases = {
...aliases,
'./controller/iframe-controller': `./${emptyFile}`,
'./iframe-stream-controller': `./${emptyFile}`,
'./image-iframe-stream-controller': `./${emptyFile}`,
};
}
+83
View File
@@ -146,6 +146,7 @@ See [API Reference](https://hlsjs-dev.video-dev.org/api-docs/) for a complete li
- [`interstitialLiveLookAhead`](#interstitiallivelookahead)
- [`assetPlayerId`](#assetplayerid)
- [`primarySessionId`](#primarysessionid)
- [`iframeCacheLimit`](#iframecachelimit)
- [`useMediaCapabilities`](#usemediacapabilities)
- [Video Binding/Unbinding API](#video-bindingunbinding-api)
- [`hls.attachMedia(HTMLMediaElement | MediaAttachingData)`](#hlsattachmediahtmlmediaelement--mediaattachingdata)
@@ -195,6 +196,9 @@ See [API Reference](https://hlsjs-dev.video-dev.org/api-docs/) for a complete li
- [`hls.iframeVariants`](#hlsiframevariants)
- [`hls.createIFramePlayer()`](#hlscreateiframeplayer)
- [Example usage](#example-usage)
- [`hls.createImageIFramePlayer()`](#hlscreateimageiframeplayer)
- [Example usage](#example-usage-1)
- [Handling image data without an HTMLImageElement](#handling-image-data-without-an-htmlimageelement)
- [Live stream API](#live-stream-api)
- [`hls.liveSyncPosition`](#hlslivesyncposition)
- [`hls.latency`](#hlslatency)
@@ -1926,6 +1930,14 @@ An optional identifier string used to identify the primary HLS session when work
This is primarily used internally by HLS.js when creating interstitial asset players, but can be set manually if you need to coordinate multiple HLS instances.
### `iframeCacheLimit`
(default: `2097152` (2 MB))
The maximum total byte size of cached image I-Frame data retained by the `ImageIFrameStreamController`. When the cache exceeds this limit, the oldest entries are evicted and their `Fragment.data` is cleared to free memory.
This option only applies to image I-Frame player instances created via `hls.createImageIFramePlayer()`. Video I-Frame data (from `hls.createIFramePlayer()`) is managed by the MSE video SourceBuffer and is not affected by this setting.
### `useMediaCapabilities`
(default value is `true` except in the light build or custom builds with setting the **USE_MEDIA_CAPABILITIES** flag to `false`.)
@@ -2235,6 +2247,77 @@ function preloadIFrame(time) {
}
```
| Method | Description |
| ------------------------------------ | ----------------------------------------------------------------- |
| `hls.createIFramePlayer(config?)` | Creates an I-Frame player, or `null` if no I-Frame variants exist |
| `player.attachMedia(video)` | Attach an `HTMLVideoElement` for frame rendering |
| `player.detachMedia()` | Detach the video element |
| `player.loadMediaAt(time, options?)` | Load, append and then seek to the I-Frame at the given time |
| `player.stopLoad()` | Cancel active loading of segment data |
| `player.destroy()` | Clean up resources |
### `hls.createImageIFramePlayer()`
`createImageIFramePlayer` returns a new `HlsImageIFramesOnly` instance that uses the current instance's `iframeVariants` filtered to only those with an image codec (e.g. `CODECS="mjpg"`) as its `levels`. Returns `null` when no image I-Frame variants are available or before levels have loaded. This method accepts an optional config overrides argument.
Image I-Frame instances load JPEG keyframes from fMP4 I-Frame segments without requiring MSE or a `<video>` element. This is useful for thumbnail previews or trick-play, especially on devices limited to a single video decoder.
#### Example usage
```ts
const mainVideo = document.getElementById('video');
const thumbnailImg = document.getElementById('thumbnail') as HTMLImageElement;
const hls = new Hls();
let imagePlayer: HlsImageIFramesOnly | null = null;
hls.loadSource('http://example.com/primary.m3u8');
hls.attachMedia(mainVideo);
hls.once(Events.MANIFEST_PARSED, () => {
imagePlayer = hls.createImageIFramePlayer();
if (imagePlayer) {
imagePlayer.attachImage(thumbnailImg);
imagePlayer.on(Events.FRAG_BUFFERED, (name, { frag }) => {
/* JPEG frame rendered to image element */
});
imagePlayer.on(Events.ERROR, (name, { error }) => {
/* handle error */
});
}
});
function showThumbnailAtTime(time: number) {
imagePlayer?.loadMediaAt(time);
}
```
| Method | Description |
| -------------------------------------- | -------------------------------------------------------------------- |
| `hls.createImageIFramePlayer(config?)` | Creates an image I-Frame player, or `null` if no MJPG variants exist |
| `player.attachImage(img)` | Attach an `HTMLImageElement` for frame rendering |
| `player.detachImage()` | Detach the image element |
| `player.loadMediaAt(time)` | Load and display the I-Frame at the given time |
| `player.stopLoad()` | Cancel active loading of segment data |
| `player.destroy()` | Clean up resources |
#### Handling image data without an HTMLImageElement
Developers can process image data directly instead of attaching an `HTMLImageElement`. Omit the `attachImage()` call and listen for `FRAG_PARSED` — the `Fragment.data` property on the event's `frag` contains the raw JPEG bytes (a `Uint8Array` extracted from the fMP4 mdat box):
```ts
const imagePlayer = hls.createImageIFramePlayer();
if (imagePlayer) {
imagePlayer.on(Hls.Events.FRAG_PARSED, (name, { frag }) => {
const jpegBytes: Uint8Array = frag.data;
// Process JPEG data (e.g. draw to canvas, cache and replace frag.data, etc.)
});
imagePlayer.loadMediaAt(30);
}
```
Note: `FRAG_BUFFERED` only fires when an `HTMLImageElement` is attached (emitted after `image.onload`). Without an attached image, use `FRAG_PARSED` which fires once the segment is demuxed and `Fragment.data` is populated.
## Live stream API
### `hls.liveSyncPosition`
+8 -2
View File
@@ -175,6 +175,10 @@ export type MP4RemuxerConfig = {
maxAudioFramesDrift: number;
};
export type IFrameControllerConfig = {
iframeCacheLimit: number;
};
export interface PlaylistLoaderConstructor {
new (confg: HlsConfig): Loader<PlaylistLoaderContext>;
}
@@ -367,8 +371,8 @@ export type HlsConfig = {
streamController: typeof StreamController;
abrController: typeof AbrController;
bufferController: typeof BufferController;
capLevelController: typeof CapLevelController;
bufferController?: typeof BufferController;
capLevelController?: typeof CapLevelController;
errorController: typeof ErrorController;
fpsController?: typeof FPSController;
progressive: boolean;
@@ -383,6 +387,7 @@ export type HlsConfig = {
GapControllerConfig &
LevelControllerConfig &
MP4RemuxerConfig &
IFrameControllerConfig &
StreamControllerConfig &
SelectionPreferences &
LatencyControllerConfig &
@@ -500,6 +505,7 @@ export const hlsDefaultConfig: HlsConfig = {
enableInterstitialPlayback: __USE_INTERSTITIALS__,
interstitialAppendInPlace: true,
interstitialLiveLookAhead: 10,
iframeCacheLimit: 2 * 1024 * 1024,
useMediaCapabilities: __USE_MEDIA_CAPABILITIES__,
preserveManualLevelOnError: false,
errorPenaltyExpireMs: 0, // used by error-controller and abr-controller
+1 -1
View File
@@ -782,7 +782,7 @@ class AudioStreamController
protected checkFragmentChanged() {
const previousFrag = this.fragPlaying;
const fragChanged = super.checkFragmentChanged();
const fragChanged = this.checkFragPlaying();
if (!fragChanged) {
return false;
}
+4 -2
View File
@@ -1656,7 +1656,9 @@ export default class BaseStreamController
return frag;
}
mapToInitFragWhenRequired(frag: Fragment | null): typeof frag {
protected mapToInitFragWhenRequired<T extends Fragment | null>(
frag: T,
): T | Fragment {
// If an initSegment is present, it must be buffered first
if (frag?.initSegment && !frag.initSegment.data && !this.bitrateTest) {
return frag.initSegment;
@@ -2589,7 +2591,7 @@ export default class BaseStreamController
this.nextLoadPosition = this.playhead;
}
protected checkFragmentChanged(): boolean {
protected checkFragPlaying(): boolean {
const video = this.media;
let fragPlayingCurrent: MediaFragment | null = null;
if (video && video.readyState > 1 && video.seeking === false) {
+183 -209
View File
@@ -1,43 +1,71 @@
import { State } from './base-stream-controller';
import {
IFrameStreamController,
type LoadMediaAtOptions,
} from './iframe-stream-controller';
import { ImageIFrameStreamController } from './image-iframe-stream-controller';
import { Events } from '../events';
import { type LoaderStats, PlaylistLevelType } from '../types/loader';
import { BufferHelper } from '../utils/buffer-helper';
import { Logger } from '../utils/logger';
import { getVideoPreference } from '../utils/rendition-helper';
import type { HlsConfig } from '../config';
import type Hls from '../hls';
import type StreamController from './stream-controller';
import type { Fragment, MediaFragment, Part } from '../loader/fragment';
import type { LevelDetails } from '../loader/level-details';
import type {
FragBufferedData,
InitPTSFoundData,
LevelsUpdatedData,
ManifestLoadedData,
} from '../types/events';
import type { Level, VariableMap } from '../types/level';
import type { Level, LevelParsed, VariableMap } from '../types/level';
import type { TimestampOffset } from '../utils/timescale-conversion';
const PRELOAD_IFRAME_PLAYLISTS_AFTER_BUFFER_SECONDS = 10;
const IMAGE_IFRAME_ATTACH_ERROR =
'Image I-Frame player does not accept HTMLMediaElements';
type Constructor<T = object, A extends any[] = any[], Static = {}> = (new (
...a: A
) => T) &
Static;
export type LoadMediaAtOptions = { seekOnAppend: boolean };
const loadMediaAtOptionsDefault: LoadMediaAtOptions = {
seekOnAppend: true,
};
export interface HlsIFramesOnly extends Hls {
export interface HlsIFramesOnly extends Omit<
Hls,
| 'createIFramePlayer'
| 'createImageIFramePlayer'
| 'iframeVariants'
| 'swapAudioCodec'
| 'setAudioOption'
| 'allAudioTracks'
| 'audioTracks'
| 'audioTrack'
| 'nextAudioTrack'
| 'setSubtitleOption'
| 'allSubtitleTracks'
| 'subtitleTracks'
| 'subtitleTrack'
| 'subtitleDisplay'
> {
loadMediaAt(time: number, options?: Partial<LoadMediaAtOptions>): void;
}
interface IFrameStreamController extends StreamController {
initDetails?: LevelDetails | null;
setInitPts(initPTS: TimestampOffset[]): void;
loadMediaAt(time: number, options: LoadMediaAtOptions): void;
export interface HlsImageIFramesOnly extends Omit<
HlsIFramesOnly,
| 'attachMedia'
| 'detachMedia'
| 'transferMedia'
| 'recoverMediaError'
| 'media'
> {
loadMediaAt(time: number): void;
attachImage(image: HTMLImageElement): void;
detachImage(): void;
}
let HlsIFramesOnlyClass: ReturnType<typeof createHlsIFramesOnly>;
let HlsImageIFramesOnlyClass: ReturnType<typeof createHlsImageIFramesOnly>;
export class IFrameController extends Logger {
private hls: Hls | undefined;
@@ -48,12 +76,13 @@ export class IFrameController extends Logger {
private initPTS: TimestampOffset[] = [];
private iframeInstances: HlsIFramesOnly[];
private iframeInstances: (HlsIFramesOnly | HlsImageIFramesOnly)[];
private instanceCounter: number = 0;
constructor(hls: Hls, HlsPlayerClass: typeof Hls) {
super('iframes', hls.logger);
HlsIFramesOnlyClass ||= createHlsIFramesOnly(HlsPlayerClass);
HlsImageIFramesOnlyClass ||= createHlsImageIFramesOnly(HlsIFramesOnlyClass);
this.hls = hls;
this.iframeInstances = [];
this.registerListeners();
@@ -66,6 +95,7 @@ export class IFrameController extends Logger {
hls.on(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
hls.on(Events.LEVELS_UPDATED, this.onLevelsUpdated, this);
hls.on(Events.INIT_PTS_FOUND, this.onInitPtsFound, this);
hls.on(Events.FRAG_BUFFERED, this.onFragBuffered, this);
hls.on(Events.DESTROYING, this.onDestroying, this);
}
}
@@ -77,6 +107,7 @@ export class IFrameController extends Logger {
hls.off(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
hls.off(Events.LEVELS_UPDATED, this.onLevelsUpdated, this);
hls.off(Events.INIT_PTS_FOUND, this.onInitPtsFound, this);
hls.off(Events.FRAG_BUFFERED, this.onFragBuffered, this);
hls.off(Events.DESTROYING, this.onDestroying, this);
}
}
@@ -125,15 +156,59 @@ export class IFrameController extends Logger {
}
}
private onFragBuffered(event: Events.FRAG_BUFFERED, data: FragBufferedData) {
const hls = this.hls;
if (!hls) {
return;
}
if (
hls.hasEnoughToStart &&
(hls.mainForwardBufferInfo?.len || 0) >
PRELOAD_IFRAME_PLAYLISTS_AFTER_BUFFER_SECONDS
) {
this.iframeInstances.forEach((instance) => {
if (!instance.loadingEnabled) {
instance.startLoad(this.hls?.media?.currentTime || 0);
}
});
}
}
public createIFramePlayer(
configOverride?: Partial<HlsConfig> | undefined,
): HlsIFramesOnly | null {
return this.createInstance(
HlsIFramesOnlyClass,
this.hls?.iframeVariants,
configOverride,
);
}
public createImageIFramePlayer(
configOverride?: Partial<HlsConfig> | undefined,
): HlsImageIFramesOnly | null {
const imageIframeVariants = this.hls?.iframeVariants.filter(
(parsed) => parsed.imageCodec,
);
return this.createInstance(
HlsImageIFramesOnlyClass,
imageIframeVariants,
configOverride,
'image-',
);
}
private createInstance<H extends HlsIFramesOnly | HlsImageIFramesOnly>(
HlsIframeClass: Constructor<H>,
iframeVariants: LevelParsed[] | undefined,
configOverride: Partial<HlsConfig> | undefined,
loggerLabelPrefix?: string,
): H | null {
const { hls } = this;
if (!hls) {
return null;
}
const {
iframeVariants,
url,
userConfig,
latestLevelDetails,
@@ -146,7 +221,7 @@ export class IFrameController extends Logger {
if (!iframeVariants?.length || !stats || !url) {
return null;
}
const loggerId = `iframe-player-${this.instanceCounter++}`;
const loggerId = `${loggerLabelPrefix || ''}iframe-player-${this.instanceCounter++}`;
const levels = hls.levels as (Level | undefined)[];
const activeLevel = loadLevelObj || levels[loadLevel];
const videoPreference = getVideoPreference(
@@ -155,16 +230,46 @@ export class IFrameController extends Logger {
);
const pathwayId = activeLevel?.pathwayId || '.';
const iframeInstance = new HlsIFramesOnlyClass(
userConfig,
{
loggerId,
primarySessionId: sessionId,
videoPreference,
abrEwmaDefaultEstimate: bandwidthEstimate,
streamController: hls.config.streamController,
},
configOverride,
// I-Frame player config specialized behavior for I-frame only streaming
const playerConfig: Partial<HlsConfig> = {
...userConfig,
loggerId,
primarySessionId: sessionId,
videoPreference,
abrEwmaDefaultEstimate: bandwidthEstimate,
// StreamController will be replaced in constructor (HlsIFramesOnlyClass | HlsImageIFramesOnlyClass)
streamController: undefined,
// Disable features not essential to streaming video I-Frames
audioStreamController: undefined,
audioTrackController: undefined,
subtitleStreamController: undefined,
subtitleTrackController: undefined,
timelineController: undefined,
id3TrackController: undefined,
fpsController: undefined,
gapController: undefined,
iframeController: undefined,
cmcd: undefined,
// FIXME: Interstitials must not be loaded independently of parent player. Schedule should come from parent. (disabled for now)
interstitialsController: undefined,
// Only load and unload as needed
backBufferLength: Infinity,
// Adapt to attached HTMLVideoElement dimension
capLevelToPlayerSize: true,
// Streamline loading
enableWorker: false,
autoStartLoad: false,
startFragPrefetch: false,
testBandwidth: false,
progressive: false,
...configOverride,
};
// Instance of HlsIFramesOnlyClass | HlsImageIFramesOnlyClass
const iframeInstance = new HlsIframeClass(
playerConfig,
url,
this.initPTS,
latestLevelDetails,
@@ -194,58 +299,16 @@ export class IFrameController extends Logger {
}
}
interface HlsIFramesOnlyAlias extends HlsIFramesOnly {}
function createHlsIFramesOnly(Base: Constructor<Hls>) {
return class HlsIFramesOnly extends Base implements HlsIFramesOnlyAlias {
return class HlsIFramesOnly extends Base {
constructor(
userConfig: Partial<HlsConfig>,
parentConfig: Partial<HlsConfig> & {
streamController: typeof StreamController;
},
configOverride: Partial<HlsConfig> | undefined,
playerConfig: Partial<HlsConfig>,
url: string,
initPTS: TimestampOffset[],
latestLevelDetails: LevelDetails | null,
) {
const playerConfig: Partial<HlsConfig> = {
...userConfig,
...parentConfig,
streamController: createIFrameStreamController(
parentConfig.streamController,
),
// Disable features not essential to streaming video I-Frames
audioStreamController: undefined,
audioTrackController: undefined,
subtitleStreamController: undefined,
subtitleTrackController: undefined,
timelineController: undefined,
id3TrackController: undefined,
fpsController: undefined,
gapController: undefined,
iframeController: undefined,
cmcd: undefined,
// FIXME: Interstitials must not be loaded independently of parent platyer. Schedule should come from parent. (disabled for now)
interstitialsController: undefined,
// Only load and unload as needed
// maxMaxBufferLength: 8,
backBufferLength: Infinity,
// Adapt to attached HTMLVideoElement dimension
capLevelToPlayerSize: true,
// Streamline loading
enableWorker: false,
autoStartLoad: false,
startFragPrefetch: false,
testBandwidth: false,
progressive: false,
// startOnSegmentBoundary: true,
...configOverride,
};
// Video I-Frame stream-controller load segments before seeking to them to render frames in desired order
playerConfig.streamController ||= IFrameStreamController;
super(playerConfig);
// Hls.url matches the parent player session source url.
@@ -258,7 +321,7 @@ function createHlsIFramesOnly(Base: Constructor<Hls>) {
latestLevelDetails;
}
loadSource(url: string) {}
loadSource() {}
loadMediaAt(
time: number,
@@ -282,148 +345,59 @@ function createHlsIFramesOnly(Base: Constructor<Hls>) {
};
}
interface IFrameStreamControllerAlias extends IFrameStreamController {}
function createHlsImageIFramesOnly(Base: typeof HlsIFramesOnlyClass) {
return class HlsImageIFramesOnly extends Base {
constructor(
playerConfig: Partial<HlsConfig>,
url: string,
initPTS: TimestampOffset[],
latestLevelDetails: LevelDetails | null,
) {
// Image I-Frame stream-controller does not use media element
playerConfig.cmcdController = undefined;
playerConfig.emeController = undefined;
function createIFrameStreamController(Base: Constructor<StreamController>) {
return class IFrameStreamController
extends Base
implements IFrameStreamControllerAlias
{
private currentOp?: [time: number, options: LoadMediaAtOptions];
private nextOp?: [time: number, options: LoadMediaAtOptions];
private gotNext: boolean = false;
initDetails?: LevelDetails | null;
playerConfig.streamController ||= ImageIFrameStreamController;
setInitPts(initPTS: TimestampOffset[]) {
this.initPTS = initPTS.slice();
// Nice to have, need to replace `media` and attach/detach constraints
playerConfig.capLevelController = undefined;
// Omit BufferController since MSE is not involved in image processing
playerConfig.bufferController = undefined;
super(playerConfig, url, initPTS, latestLevelDetails);
}
loadMediaAt(time: number, options: LoadMediaAtOptions) {
const { seekOnAppend } = options;
const adjustedTime = time + this.timelineOffset;
this.nextLoadPosition = this.lastCurrentTime = adjustedTime;
this.startPosition = time;
switch (this.state) {
case State.STOPPED:
case State.ENDED:
case State.ERROR:
this.state = State.IDLE;
attachImage(image: HTMLImageElement) {
(this.streamController as ImageIFrameStreamController).image = image;
}
detachImage() {
(this.streamController as ImageIFrameStreamController).image = undefined;
}
loadSource() {}
attachMedia() {
throw new Error(IMAGE_IFRAME_ATTACH_ERROR);
}
detachMedia() {}
recoverMediaError() {}
transferMedia() {
return null;
}
loadMediaAt(time: number) {
if (time < 0) {
return null;
}
if (this.state === State.IDLE) {
this.hls.resumeBuffering();
this.tick();
this.currentOp = [adjustedTime, options];
} else {
const fragCurrent = this.fragCurrent;
if (
!fragCurrent ||
(time >= fragCurrent.start && time < fragCurrent.end)
) {
this.nextOp = [adjustedTime, options];
}
if (!this.loadingEnabled) {
this.startLoad(time);
}
const media = this.media;
if (seekOnAppend && media) {
const seeking = this.seekTo(adjustedTime);
if (seeking) {
this.currentOp = [adjustedTime, options];
this.nextOp = undefined;
}
}
}
private seekTo(time: number): boolean {
const media = this.media;
if (media) {
if (time > media.duration) {
time = media.duration - 0.01;
}
const bufferInfo = BufferHelper.bufferInfo(media, time, 0);
const hasEnough = bufferInfo.len > 0 && this.getBufferedAt(time);
if (hasEnough) {
media.currentTime = time;
if (this.state === State.IDLE) {
this.tick();
}
return true;
}
}
return false;
}
private getBufferedAt(time: number): MediaFragment | null {
return this.fragmentTracker.getBufferedFrag(time, PlaylistLevelType.MAIN);
}
// overrides
protected fragBufferedComplete(frag: Fragment, part: Part | null) {
super.fragBufferedComplete(frag, part);
const { currentOp, nextOp } = this;
this.currentOp = this.nextOp = undefined;
this.state = State.STOPPED;
if (currentOp?.[1].seekOnAppend) {
this.seekTo(currentOp[0]);
if (!nextOp && !this.gotNext) {
// repeat op to get next segment (Chrome may require two HEVC frame appends, or one with EoS, before rendering)
this.gotNext = true;
this.loadMediaAt.apply(this, currentOp);
}
}
if (nextOp) {
this.loadMediaAt.apply(this, nextOp);
}
}
get playhead(): number {
return this.nextLoadPosition;
}
startLoad() {
if (!this.startFragRequested) {
const hlsIFrames = this.hls;
hlsIFrames.nextLoadLevel =
hlsIFrames.startLevel === -1 ? 0 : hlsIFrames.firstAutoLevel;
}
}
// public getLevelDetails
protected seekToStartPos() {}
protected setStartPosition() {}
protected onMediaSeeking = () => {
this.gotNext = false;
};
protected alignPlaylists(
details: LevelDetails,
previousDetails: LevelDetails | undefined,
switchDetails: LevelDetails | undefined,
): number {
const initDetails = this.initDetails;
this.initDetails = null;
return super.alignPlaylists(
details,
previousDetails,
switchDetails || initDetails || undefined,
);
}
getMainFwdBufferInfo() {
const t = this.playhead;
const bufferedFragAtPos = this.getBufferedAt(t);
if (bufferedFragAtPos) {
const len = bufferedFragAtPos.duration;
return { len, start: t, end: t + len, bufferedIndex: -1 };
}
return { len: 0, start: t, end: t, bufferedIndex: -1 };
}
protected _streamEnded() {
const t = this.playhead;
const bufferedFragAtPos = this.fragmentTracker.getBufferedFrag(
t,
PlaylistLevelType.MAIN,
);
return !!bufferedFragAtPos?.endList;
(this.streamController as ImageIFrameStreamController).loadMediaAt(time);
}
};
}
+151
View File
@@ -0,0 +1,151 @@
import { State } from './base-stream-controller';
import StreamController from './stream-controller';
import { PlaylistLevelType } from '../types/loader';
import { BufferHelper } from '../utils/buffer-helper';
import type { Fragment, MediaFragment, Part } from '../loader/fragment';
import type { LevelDetails } from '../loader/level-details';
import type { TimestampOffset } from '../utils/timescale-conversion';
export type LoadMediaAtOptions = { seekOnAppend: boolean };
export class IFrameStreamController extends StreamController {
private currentOp?: [time: number, options: LoadMediaAtOptions];
private nextOp?: [time: number, options: LoadMediaAtOptions];
private gotNext: boolean = false;
initDetails?: LevelDetails | null;
setInitPts(initPTS: TimestampOffset[]) {
this.initPTS = initPTS.slice();
}
loadMediaAt(time: number, options: LoadMediaAtOptions) {
if (!this.hls) {
return;
}
const { seekOnAppend } = options;
const adjustedTime = time + this.timelineOffset;
this.nextLoadPosition = this.lastCurrentTime = adjustedTime;
this.startPosition = time;
switch (this.state) {
case State.STOPPED:
case State.ENDED:
case State.ERROR:
this.state = State.IDLE;
}
if (this.state === State.IDLE) {
this.hls.resumeBuffering();
this.tick();
this.currentOp = [adjustedTime, options];
} else {
const fragCurrent = this.fragCurrent;
if (
!fragCurrent ||
(time >= fragCurrent.start && time < fragCurrent.end)
) {
this.nextOp = [adjustedTime, options];
}
}
const media = this.media;
if (seekOnAppend && media) {
const seeking = this.seekTo(adjustedTime);
if (seeking) {
this.currentOp = [adjustedTime, options];
this.nextOp = undefined;
}
}
}
private seekTo(time: number): boolean {
const media = this.media;
if (media) {
if (time > media.duration) {
time = media.duration - 0.01;
}
const bufferInfo = BufferHelper.bufferInfo(media, time, 0);
const hasEnough = bufferInfo.len > 0 && this.getBufferedAt(time);
if (hasEnough) {
media.currentTime = time;
if (this.state === State.IDLE) {
this.tick();
}
return true;
}
}
return false;
}
protected getBufferedAt(time: number): MediaFragment | null {
return this.fragmentTracker.getBufferedFrag(time, PlaylistLevelType.MAIN);
}
// overrides
protected fragBufferedComplete(frag: Fragment, part: Part | null) {
super.fragBufferedComplete(frag, part);
const { currentOp, nextOp } = this;
this.currentOp = this.nextOp = undefined;
this.state = State.STOPPED;
if (currentOp?.[1].seekOnAppend) {
this.seekTo(currentOp[0]);
if (!nextOp && !this.gotNext) {
// repeat op to get next segment (Chrome may require two HEVC frame appends, or one with EoS, before rendering)
this.gotNext = true;
this.loadMediaAt.apply(this, currentOp);
}
}
if (nextOp) {
this.loadMediaAt.apply(this, nextOp);
}
}
get playhead(): number {
return this.nextLoadPosition;
}
startLoad() {
if (!this.startFragRequested) {
const hlsIFrames = this.hls;
hlsIFrames.nextLoadLevel =
hlsIFrames.startLevel === -1 ? 0 : hlsIFrames.firstAutoLevel;
}
}
// public getLevelDetails
protected seekToStartPos() {}
protected setStartPosition() {}
protected onMediaSeeking = () => {
this.gotNext = false;
};
protected alignPlaylists(
details: LevelDetails,
previousDetails: LevelDetails | undefined,
switchDetails: LevelDetails | undefined,
): number {
const initDetails = this.initDetails;
this.initDetails = null;
return super.alignPlaylists(
details,
previousDetails,
switchDetails || initDetails || undefined,
);
}
getMainFwdBufferInfo() {
const t = this.playhead;
const bufferedFragAtPos = this.getBufferedAt(t);
if (bufferedFragAtPos) {
const len = bufferedFragAtPos.duration;
return { len, start: t, end: t + len, bufferedIndex: -1 };
}
return { len: 0, start: t, end: t, bufferedIndex: -1 };
}
protected _streamEnded() {
const t = this.playhead;
const bufferedFragAtPos = this.fragmentTracker.getBufferedFrag(
t,
PlaylistLevelType.MAIN,
);
return !!bufferedFragAtPos?.endList;
}
}
@@ -0,0 +1,235 @@
import { State } from './base-stream-controller';
import { IFrameStreamController } from './iframe-stream-controller';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import {
type Fragment,
mediaFragmentsAreEqual,
type Part,
} from '../loader/fragment';
import { findBox } from '../utils/mp4-tools';
import type { MediaFragment } from '../loader/fragment';
import type { RemuxedTrack } from '../types/remuxer';
import type { ChunkMetadata } from '../types/transmuxer';
export class ImageIFrameStreamController extends IFrameStreamController {
private _img?: HTMLImageElement;
private queued?: [time: number];
private cached: MediaFragment[] = [];
private cachedSize = 0;
get image(): HTMLImageElement | undefined {
return this._img;
}
set image(value: HTMLImageElement | undefined) {
const src = this._img?.src;
if (src) {
self.URL.revokeObjectURL(src);
}
this._img = value;
}
loadMediaAt(time: number) {
if (!this.hls) {
return;
}
const adjustedTime = time + this.timelineOffset;
this.nextLoadPosition = this.lastCurrentTime = adjustedTime;
this.startPosition = time;
switch (this.state) {
case State.STOPPED:
case State.ENDED:
case State.ERROR:
this.state = State.IDLE;
}
if (this.state === State.IDLE) {
this.hls.resumeBuffering();
this.media ||= {} as any;
this.tick();
// If segment is already loaded, state will remain IDLE. Update image and/or emit buffered events.
if (this.state === State.IDLE) {
const frag = this.getBufferedAt(time);
if (frag) {
this.fragCurrent = frag;
this.updateImage(frag, null);
}
}
} else {
const fragCurrent = this.fragCurrent;
if (
!fragCurrent ||
(time >= fragCurrent.start && time < fragCurrent.end)
) {
this.queued = [adjustedTime];
}
}
}
private cacheSet(
frag: MediaFragment,
imageBytesView: Uint8Array<ArrayBuffer>,
) {
if (!this.hls) {
return;
}
frag.data = imageBytesView;
const cache = this.cached;
cache.push(frag);
this.cachedSize += imageBytesView.buffer.byteLength;
const iframeCacheLimit = this.hls.config.iframeCacheLimit;
while (this.cachedSize > iframeCacheLimit && cache.length > 1) {
const evicted = cache.shift()!;
if (evicted.data) {
this.cachedSize -= evicted.data.byteLength;
evicted.data = undefined;
} else {
// Fragment was removed. Re-evaluate size.
this.cached = cache.filter((frag) => !!frag.data);
this.cachedSize = cache.reduce(
(acc, { data }) => (data ? data.buffer.byteLength : 0),
0,
);
break;
}
}
}
private updateImage(
frag: MediaFragment,
part: Part | null,
chunkMeta?: ChunkMetadata,
) {
const imageBytesView = frag.data;
if (!imageBytesView) {
this.fragmentTracker.removeFragment(frag);
}
const image = this._img;
if (image && imageBytesView) {
const now = self.performance.now();
const stats = part ? part.stats : frag.stats;
stats.buffering.start = frag.stats.buffering.start = now;
image.onload = () => {
if (!this.hls) {
return;
}
const level = this.hls.levels[frag.level];
if (level?.fragmentError) {
level.fragmentError = 0;
frag.stats.retry = 0;
}
const now = self.performance.now();
const stats = part ? part.stats : frag.stats;
stats.buffering.end = frag.stats.buffering.end = now;
this.hls.trigger(Events.FRAG_BUFFERED, {
frag,
part,
stats,
id: frag.type,
});
};
image.onerror = () => {
this.handleError('Image decode error', frag, part, chunkMeta);
};
const src = image.src;
if (src) {
self.URL.revokeObjectURL(src);
}
const blob = new Blob([imageBytesView], {
type: 'image/jpeg',
});
image.src = self.URL.createObjectURL(blob);
} else {
this.state = State.STOPPED;
this.processQueued();
}
}
private processQueued() {
const queued = this.queued;
this.queued = undefined;
if (queued) {
this.loadMediaAt.apply(this, queued);
}
}
private handleError(
message: string,
frag: Fragment,
part?: Part | null,
chunkMeta?: ChunkMetadata,
) {
if (!this.hls) {
return;
}
const error = new Error(message);
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_PARSING_ERROR,
chunkMeta,
frag,
part,
fatal: false,
error,
err: error,
});
}
// overrides
protected onHandlerDestroying() {
this.cached.length = 0;
this.cachedSize = 0;
super.onHandlerDestroying();
}
protected _bufferInitSegment() {}
protected bufferFragmentData(
data: RemuxedTrack,
frag: Fragment,
part: Part | null,
chunkMeta: ChunkMetadata,
) {
super.bufferFragmentData(data, frag, part, chunkMeta, true);
const results = findBox(new Uint8Array(data.data1), [
'mdat',
]) as Uint8Array<ArrayBuffer>[];
if (results.length === 0) {
this.handleError('Could not find I-Frame mdat', frag, part, chunkMeta);
return;
}
const fragment = frag as MediaFragment;
this.cacheSet(fragment, results[0]);
this.updateImage(fragment, part, chunkMeta);
}
protected fragBufferedComplete(frag: Fragment, part: Part | null) {
this.fragmentTracker.fragBuffered(frag as MediaFragment, true);
super.fragBufferedComplete(frag, part);
this.processQueued();
}
protected checkFragPlaying(): boolean {
const currentTime = this.fragPrevious?.start;
if (currentTime !== undefined) {
const fragPlayingCurrent = this.getAppendedFrag(currentTime);
if (
fragPlayingCurrent &&
!mediaFragmentsAreEqual(fragPlayingCurrent, this.fragPlaying)
) {
this.fragPlaying = fragPlayingCurrent;
return true;
}
}
return false;
}
getMainFwdBufferInfo() {
return {
len: 0,
start: this.playhead,
end: this.playhead,
bufferedIndex: -1,
};
}
}
+10 -1
View File
@@ -120,6 +120,7 @@ export default class LevelController extends BasePlaylistController {
let resolutionFound = false;
let videoCodecFound = false;
let audioCodecFound = false;
let imageCodecFound = false;
data.levels.forEach((levelParsed: LevelParsed) => {
const attributes = levelParsed.attrs;
@@ -127,7 +128,7 @@ export default class LevelController extends BasePlaylistController {
levelParsed,
preferManagedMediaSource,
);
const { audioCodec, videoCodec, width, height } = levelParsed;
const { audioCodec, videoCodec, imageCodec, width, height } = levelParsed;
if (!supported) {
this.log(`Some or all CODECS not supported "${attributes.CODECS}"`);
return;
@@ -135,6 +136,7 @@ export default class LevelController extends BasePlaylistController {
resolutionFound ||= !!(width && height);
videoCodecFound ||= !!videoCodec;
audioCodecFound ||= !!audioCodec;
imageCodecFound ||= !!imageCodec;
const {
CODECS,
@@ -175,6 +177,7 @@ export default class LevelController extends BasePlaylistController {
resolutionFound,
videoCodecFound,
audioCodecFound,
imageCodecFound,
);
}
@@ -203,6 +206,7 @@ export default class LevelController extends BasePlaylistController {
resolutionFound: boolean,
videoCodecFound: boolean,
audioCodecFound: boolean,
imageCodecFound: boolean,
) {
let audioTracks: MediaPlaylist[] = [];
let subtitleTracks: MediaPlaylist[] = [];
@@ -218,6 +222,11 @@ export default class LevelController extends BasePlaylistController {
);
}
// image codec handling requires image-only iframe player fed only image iframe variants
if (videoCodecFound && imageCodecFound) {
levels = levels.filter((lvl) => !lvl.imageCodec);
}
if (levels.length === 0) {
// Dispatch error after MANIFEST_LOADED is done propagating
// eslint-disable-next-line @typescript-eslint/no-floating-promises
+4 -6
View File
@@ -375,9 +375,7 @@ export default class StreamController
return;
}
if (frag.initSegment && !frag.initSegment.data && !this.bitrateTest) {
frag = frag.initSegment;
}
frag = this.mapToInitFragWhenRequired(frag);
this.loadFragment(frag, levelInfo, targetBufferTime);
}
@@ -439,7 +437,7 @@ export default class StreamController
protected checkFragmentChanged(): boolean {
const previousFrag = this.fragPlaying;
const fragChanged = super.checkFragmentChanged();
const fragChanged = this.checkFragPlaying();
if (!fragChanged) {
return false;
}
@@ -1197,7 +1195,7 @@ export default class StreamController
});
}
private _handleTransmuxComplete(transmuxResult: TransmuxerResult) {
protected _handleTransmuxComplete(transmuxResult: TransmuxerResult) {
const id = this.playlistType;
const { hls } = this;
const { remuxResult, chunkMeta } = transmuxResult;
@@ -1401,7 +1399,7 @@ export default class StreamController
);
}
private _bufferInitSegment(
protected _bufferInitSegment(
currentLevel: Level,
tracks: TrackSet,
frag: Fragment,
+49 -17
View File
@@ -34,6 +34,7 @@ import type FPSController from './controller/fps-controller';
import type GapController from './controller/gap-controller';
import type {
HlsIFramesOnly,
HlsImageIFramesOnly,
IFrameController,
} from './controller/iframe-controller';
import type InterstitialsController from './controller/interstitials-controller';
@@ -101,8 +102,8 @@ export default class Hls implements HlsEventEmitter {
private _autoLevelCapping: number = -1;
private _maxHdcpLevel: HdcpLevel = null;
private abrController: AbrComponentAPI;
private bufferController: BufferController;
private capLevelController: CapLevelController;
private bufferController?: BufferController;
private capLevelController?: CapLevelController;
private latencyController?: LatencyController;
private levelController: LevelController;
private audioStreamController?: AudioStreamController;
@@ -222,13 +223,12 @@ export default class Hls implements HlsEventEmitter {
const interstitialsController = _InterstitialsController
? (this.interstitialsController = new _InterstitialsController(this, Hls))
: null;
const bufferController = (this.bufferController = new _BufferController(
this,
fragmentTracker,
));
const capLevelController = (this.capLevelController =
new _CapLevelController(this));
const bufferController = _BufferController
? (this.bufferController = new _BufferController(this, fragmentTracker))
: null;
const capLevelController = _CapLevelController
? (this.capLevelController = new _CapLevelController(this))
: null;
const fpsController = _FpsController ? new _FpsController(this) : null;
const playListLoader = new PlaylistLoader(this);
@@ -258,7 +258,9 @@ export default class Hls implements HlsEventEmitter {
: undefined);
// Cap level controller uses streamController to flush the buffer
capLevelController.setStreamController(streamController);
if (capLevelController) {
capLevelController.setStreamController(streamController);
}
const networkControllers: NetworkComponentAPI[] = [
playListLoader,
@@ -273,11 +275,16 @@ export default class Hls implements HlsEventEmitter {
}
this.networkControllers = networkControllers;
const coreComponents: ComponentAPI[] = [abrController, bufferController];
const coreComponents: ComponentAPI[] = [abrController];
if (bufferController) {
coreComponents.push(bufferController);
}
if (gapController) {
coreComponents.push(gapController);
}
coreComponents.push(capLevelController);
if (capLevelController) {
coreComponents.push(capLevelController);
}
if (fpsController) {
// fpsController uses streamController to switch when frames are being dropped
fpsController.setStreamController(streamController);
@@ -512,7 +519,7 @@ export default class Hls implements HlsEventEmitter {
*/
transferMedia(): AttachMediaSourceData | null {
this._media = null;
const transferMedia = this.bufferController.transferMedia();
const transferMedia = this.bufferController?.transferMedia() || null;
const data = { transferMedia };
this.trigger(Events.MEDIA_DETACHING, data);
this.trigger(Events.MEDIA_DETACHED, data);
@@ -539,7 +546,8 @@ export default class Hls implements HlsEventEmitter {
if (
media &&
loadedSource &&
(loadedSource !== loadingSource || this.bufferController.hasSourceTypes())
(loadedSource !== loadingSource ||
this.bufferController?.hasSourceTypes())
) {
// Remove and re-create MediaSource
this.detachMedia();
@@ -864,13 +872,17 @@ export default class Hls implements HlsEventEmitter {
* Enables or disables level capping. If disabled after previously enabled, `nextLevelSwitch` will be immediately called.
*/
set capLevelToPlayerSize(shouldStartCapping: boolean) {
const capLevelController = this.capLevelController;
const newCapLevelToPlayerSize = !!shouldStartCapping;
if (newCapLevelToPlayerSize !== this.config.capLevelToPlayerSize) {
if (
capLevelController &&
newCapLevelToPlayerSize !== this.config.capLevelToPlayerSize
) {
if (newCapLevelToPlayerSize) {
this.capLevelController.startCapping(); // If capping occurs, nextLevelSwitch will happen based on size.
capLevelController.startCapping(); // If capping occurs, nextLevelSwitch will happen based on size.
} else {
this.capLevelController.stopCapping();
capLevelController.stopCapping();
this.autoLevelCapping = -1;
this.streamController.nextLevelSwitch(); // Now we're uncapped, get the next level asap.
}
@@ -1304,6 +1316,22 @@ export default class Hls implements HlsEventEmitter {
return null;
}
/**
* Returns an new image iframe focused Hls (HlsImageIFramesOnly) instance based on `iframeVariants` found in the
* current asset, or null when no image variants are available. An image iframe instance uses iframe variants with
* image codec values in CODECS ("mjpg") as its `levels`.
* Use HlsIFramesOnly.loadMediaAt(time) to load image IFrames.
* Attach an HTMLImageElement with HlsImageIFramesOnly.attachImage(image), or process image data on FRAG_LOADED.
*/
createImageIFramePlayer(
configOverride?: Partial<HlsConfig>,
): HlsImageIFramesOnly | null {
if (__USE_IFRAMES__ && this._url && this.iframeController) {
return this.iframeController.createImageIFramePlayer(configOverride);
}
return null;
}
/**
* returns mediaCapabilities.decodingInfo for a variant/rendition
*/
@@ -1356,6 +1384,7 @@ export type {
GapController,
IFrameController,
HlsIFramesOnly,
HlsImageIFramesOnly,
InterstitialsController,
LatencyController,
StreamController,
@@ -1386,11 +1415,13 @@ export type {
DRMSystemConfiguration,
DRMSystemsConfiguration,
DRMSystemOptions,
ErrorControllerConfig,
FPSControllerConfig,
FragmentLoaderConfig,
FragmentLoaderConstructor,
GapControllerConfig,
HlsLoadPolicies,
IFrameControllerConfig,
LevelControllerConfig,
LoaderConfig,
LoadPolicy,
@@ -1429,6 +1460,7 @@ export type {
IErrorAction,
} from './controller/error-controller';
export type { ID3TrackController } from './controller/id3-track-controller';
export type { LoadMediaAtOptions } from './controller/iframe-stream-controller';
export type {
HlsAssetPlayer,
HlsAssetPlayerConfig,
+1 -1
View File
@@ -974,7 +974,7 @@ function setCodecs(
level: CodecsParsed,
) {
let codecs = (codecsAttributeValue || '').split(/[ ,]+/).filter((c) => c);
['video', 'audio', 'text'].forEach((type: CodecType) => {
['video', 'audio', 'text', 'image'].forEach((type: CodecType) => {
const filtered = codecs.filter((codec) => isCodecType(codec, type));
if (filtered.length) {
// Comma separated list of all codecs for type
+2
View File
@@ -553,6 +553,8 @@ function getParsedTrackCodec(
logger.warn(`Unhandled audio codec "${parsedCodec}" in mp4 MAP`);
return parsedCodec || 'mp4a';
} else if (parsedCodec === 'mjpg') {
return parsedCodec;
}
// Provide defaults based on codec type
// This allows for some playback of some fmp4 playlists without CODECS defined in manifest
+5
View File
@@ -20,6 +20,7 @@ export interface CodecsParsed {
audioCodec?: string;
videoCodec?: string;
textCodec?: string;
imageCodec?: string;
unknownCodecs?: string[];
}
@@ -120,6 +121,7 @@ export class Level {
public readonly supplemental: CodecsParsed | undefined;
public readonly videoCodec: string | undefined;
public readonly width: number;
public readonly imageCodec?: string;
public readonly iframes?: boolean;
public details?: LevelDetails;
public fragmentError: number = 0;
@@ -150,6 +152,9 @@ export class Level {
this._avgBitrate = data.attrs.decimalInteger('AVERAGE-BANDWIDTH');
this.audioCodec = data.audioCodec;
this.videoCodec = data.videoCodec;
if ('imageCodec' in data) {
this.imageCodec = data.imageCodec;
}
this.codecSet = [data.videoCodec, data.audioCodec]
.filter((c) => !!c)
.map((s: string) => s.substring(0, 4))
+4 -1
View File
@@ -78,9 +78,12 @@ export const sampleEntryCodesISO = {
stpp: 1,
wvtt: 1,
},
image: {
mjpg: 1,
},
} as const;
export type CodecType = 'audio' | 'video';
export type CodecType = 'audio' | 'video' | 'text' | 'image';
export function isCodecType(codec: string, type: CodecType): boolean {
const typeCodes = sampleEntryCodesISO[type];
@@ -458,7 +458,7 @@ describe('AudioStreamController', function () {
it('should return false when super.checkFragmentChanged returns false', function () {
sandbox
.stub(BaseStreamController.prototype as any, 'checkFragmentChanged')
.stub(BaseStreamController.prototype as any, 'checkFragPlaying')
.returns(false);
expect((audioStreamController as any).checkFragmentChanged()).to.be.false;
});
@@ -472,7 +472,7 @@ describe('AudioStreamController', function () {
(audioStreamController as any).switchingTrack = mockSwitchingTrack;
sandbox
.stub(BaseStreamController.prototype as any, 'checkFragmentChanged')
.stub(BaseStreamController.prototype as any, 'checkFragPlaying')
.callsFake(function (this: any) {
this.fragPlaying = mockFrag;
return true;
@@ -499,7 +499,7 @@ describe('AudioStreamController', function () {
(audioStreamController as any).switchingTrack = mockSwitchingTrack;
sandbox
.stub(BaseStreamController.prototype as any, 'checkFragmentChanged')
.stub(BaseStreamController.prototype as any, 'checkFragPlaying')
.callsFake(function (this: any) {
this.fragPlaying = mockFrag;
return true;
+144 -1
View File
@@ -1,15 +1,19 @@
import { config as chaiConfig, expect, use } from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import { ImageIFrameStreamController } from '../../../src/controller/image-iframe-stream-controller';
import { Events } from '../../../src/events';
import Hls from '../../../src/hls';
import { Fragment } from '../../../src/loader/fragment';
import { LoadStats } from '../../../src/loader/load-stats';
import { PlaylistLevelType } from '../../../src/types/loader';
import type { HlsConfig } from '../../../src/config';
import type {
HlsIFramesOnly,
HlsImageIFramesOnly,
IFrameController,
} from '../../../src/controller/iframe-controller';
import type { MediaFragment } from '../../../src/loader/fragment';
import type PlaylistLoader from '../../../src/loader/playlist-loader';
import type {
ComponentAPI,
@@ -62,6 +66,16 @@ https://b.com/video/avc1/1/media.m3u8
#EXT-X-I-FRAME-STREAM-INF:PATHWAY-ID="..",BANDWIDTH=481062,CODECS="avc1.64002A",RESOLUTION=1920x1080,URI="https://b.com/video/avc1/1/media.m3u8"
`;
const playlistWithImageIFrameVariants = `#EXTM3U
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",LANGUAGE="en",NAME="English",AUTOSELECT=YES,DEFAULT=YES,CHANNELS="2",URI="audio/en/mp4a.40.2/media.m3u8"
#EXT-X-STREAM-INF:AUDIO="audio",AVERAGE-BANDWIDTH=6383725,BANDWIDTH=7495785,CODECS="avc1.64002A,mp4a.40.2",RESOLUTION=1920x1080
video/avc1/1/media.m3u8
#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=309502,BANDWIDTH=481062,CODECS="avc1.64002A",RESOLUTION=1920x1080,URI="video/avc1/1/iframes.m3u8"
#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=65770,BANDWIDTH=110693,CODECS="avc1.64002A",RESOLUTION=960x540,URI="video/avc1/3/iframes.m3u8"
#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=50000,BANDWIDTH=50000,CODECS="mjpg",RESOLUTION=960x540,URI="video/mjpg/iframes.m3u8"
#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=30000,BANDWIDTH=30000,CODECS="mjpg",RESOLUTION=480x270,URI="video/mjpg/2/iframes.m3u8"
`;
describe('IFrameController', function () {
const sandbox = sinon.createSandbox();
let hls: HlsTestable;
@@ -121,7 +135,9 @@ describe('IFrameController', function () {
it('configures IFramePlayer instances to only handle iframe video variants', function () {
const iframePlayer = loadedIFramePlayer(playlistWithIFrameVariants);
expect(iframePlayer.levels).to.have.to.have.lengthOf(3);
expect(iframePlayer.iframeVariants).to.have.lengthOf(0);
expect(iframePlayer)
.to.have.property('iframeVariants')
.which.has.lengthOf(0);
});
it('destroys child IFramePlayer instances when destroyed', function () {
@@ -186,4 +202,131 @@ describe('IFrameController', function () {
expect(iframeStreamController.initPTS).to.not.equal(timestamps);
expect(iframeStreamController.initPTS).to.deep.equal(timestamps);
});
it('createImageIFramePlayer returns null when no image iframe variants exist', function () {
loadManifest(playlistWithIFrameVariants);
expect(hls.iframeVariants).to.have.lengthOf(3);
const imagePlayer = iframeController.createImageIFramePlayer();
expect(imagePlayer).to.be.null;
});
it('createImageIFramePlayer returns instance when mjpg variants exist', function () {
loadManifest(playlistWithImageIFrameVariants);
const imagePlayer = iframeController.createImageIFramePlayer();
expect(imagePlayer).to.be.an.instanceOf(Hls);
});
it('createImageIFramePlayer filters to only image codec variants', function () {
loadManifest(playlistWithImageIFrameVariants);
expect(hls.iframeVariants).to.have.lengthOf(4);
const imagePlayer = iframeController.createImageIFramePlayer();
expect(imagePlayer).to.be.an.instanceOf(Hls);
if (imagePlayer) {
expect(imagePlayer.levels).to.have.lengthOf(2);
imagePlayer.levels.forEach((level) => {
expect(level).to.have.property('imageCodec', 'mjpg');
});
}
});
it('Image IFrame player throws on attachMedia', function () {
loadManifest(playlistWithImageIFrameVariants);
const imagePlayer =
iframeController.createImageIFramePlayer() as HlsImageIFramesOnly;
expect(imagePlayer).to.be.an.instanceOf(Hls);
expect(() => (imagePlayer as any).attachMedia({} as any)).to.throw(
'Image I-Frame player does not accept HTMLMediaElements',
);
});
it('Image IFrame player is destroyed with parent', function () {
loadManifest(playlistWithImageIFrameVariants);
const imagePlayer = iframeController.createImageIFramePlayer();
expect(imagePlayer).to.be.an.instanceOf(Hls);
if (imagePlayer) {
expect(imagePlayer.url).to.eql('main.m3u8');
hls.destroy();
expect(imagePlayer.url).to.eql(null);
}
});
});
describe('ImageIFrameStreamController', function () {
function createFrag(sn: number, dataSize: number): MediaFragment {
const frag = new Fragment(PlaylistLevelType.MAIN, '') as MediaFragment;
frag.sn = sn;
frag.data = new Uint8Array(dataSize);
return frag;
}
describe('cacheSet', function () {
let controller: any;
let cacheSet: (frag: MediaFragment, data: Uint8Array) => void;
beforeEach(function () {
controller = {
hls: { config: { iframeCacheLimit: 1024 } },
cached: [] as MediaFragment[],
cachedSize: 0,
};
cacheSet = (ImageIFrameStreamController.prototype as any).cacheSet.bind(
controller,
);
});
it('adds fragment data to cache and tracks size', function () {
const frag = createFrag(0, 100);
cacheSet(frag, frag.data!);
expect(controller.cached).to.have.lengthOf(1);
expect(controller.cachedSize).to.equal(100);
expect(frag.data).to.not.be.undefined;
});
it('evicts oldest entries when cache exceeds iframeCacheLimit', function () {
controller.hls.config.iframeCacheLimit = 300;
const frag1 = createFrag(4, 150);
const frag2 = createFrag(5, 100);
const frag3 = createFrag(6, 201);
cacheSet(frag1, frag1.data!);
cacheSet(frag2, frag2.data!);
expect(controller.cached).to.have.lengthOf(2);
expect(controller.cachedSize).to.equal(250);
// Adding frag3 pushes total to 451, exceeding 300 limit
cacheSet(frag3, frag3.data!);
// frag1 (150) and frag2 (100) evicted => total = 201
expect(controller.cached).to.have.lengthOf(1);
expect(controller.cached[0].sn).to.equal(6);
expect(controller.cachedSize).to.equal(201);
expect(frag1.data).to.be.undefined;
});
it('keeps at least one entry even if it exceeds the limit', function () {
controller.hls.config.iframeCacheLimit = 50;
const frag = createFrag(0, 100);
cacheSet(frag, frag.data!);
expect(controller.cached).to.have.lengthOf(1);
expect(controller.cachedSize).to.equal(100);
expect(frag.data).to.not.be.undefined;
});
it('recalculates size when evicted fragment data was already cleared', function () {
controller.hls.config.iframeCacheLimit = 250;
const frag1 = createFrag(1, 100);
const frag2 = createFrag(2, 100);
const frag3 = createFrag(3, 200);
cacheSet(frag1, frag1.data!);
cacheSet(frag2, frag2.data!);
// Externally clear frag1.data (simulates fragment tracker removal)
frag1.data = undefined;
// Adding frag3 pushes cachedSize to 400, exceeding 250
// Evicting frag1 finds data already cleared, triggers recalculation and break
cacheSet(frag3, frag3.data!);
expect(controller.cached).to.have.lengthOf(2);
expect(controller.cached[0].sn).to.equal(2);
expect(controller.cached[1].sn).to.equal(3);
});
});
});
@@ -0,0 +1,416 @@
import { expect } from 'chai';
import sinon from 'sinon';
import { State } from '../../../src/controller/base-stream-controller';
import { Events } from '../../../src/events';
import Hls from '../../../src/hls';
import { LoadStats } from '../../../src/loader/load-stats';
import { ChunkMetadata } from '../../../src/types/transmuxer';
import { findBox } from '../../../src/utils/mp4-tools';
import type {
HlsImageIFramesOnly,
IFrameController,
} from '../../../src/controller/iframe-controller';
import type { ImageIFrameStreamController } from '../../../src/controller/image-iframe-stream-controller';
import type PlaylistLoader from '../../../src/loader/playlist-loader';
import type {
ComponentAPI,
NetworkComponentAPI,
} from '../../../src/types/component-api';
import type { ErrorData } from '../../../src/types/events';
import type { RemuxedTrack } from '../../../src/types/remuxer';
type HlsTestable = Omit<Hls, 'networkControllers' | 'coreComponents'> & {
coreComponents: ComponentAPI[];
networkControllers: NetworkComponentAPI[];
iframeController: IFrameController;
};
type PlaylistLoaderTestable = Omit<PlaylistLoader, 'handleMasterPlaylist'> & {
handleMasterPlaylist: (
response: { data: string; url: string },
stats: LoadStats,
) => void;
};
type ImageIFrameStreamControllerTestable = Omit<
ImageIFrameStreamController,
| 'state'
| 'fragCurrent'
| 'fragmentTracker'
| 'hls'
| '_bufferInitSegment'
| 'bufferFragmentData'
| 'media'
> & {
state: string;
fragCurrent: any;
fragmentTracker: any;
hls: any;
media: any;
_bufferInitSegment(): void;
bufferFragmentData(
data: RemuxedTrack,
frag: any,
part: any,
chunkMeta: ChunkMetadata,
): void;
};
const silentLogger = {
trace: () => null,
debug: () => null,
log: () => null,
warn: () => null,
info: () => null,
error: () => null,
};
const playlistWithImageIFrameVariants = `#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=7495785,CODECS="avc1.64002A,mp4a.40.2",RESOLUTION=1920x1080
video/avc1/1/media.m3u8
#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=50000,CODECS="mjpg",RESOLUTION=960x540,URI="video/mjpg/iframes.m3u8"
`;
function buildMdatBox(
payload: Uint8Array<ArrayBuffer>,
): Uint8Array<ArrayBuffer> {
const size = 8 + payload.length;
const box = new Uint8Array(size);
new DataView(box.buffer).setUint32(0, size);
box.set([0x6d, 0x64, 0x61, 0x74], 4); // "mdat"
box.set(payload, 8);
return box;
}
function createImagePlayer(
hls: HlsTestable,
): [HlsImageIFramesOnly, ImageIFrameStreamControllerTestable] {
const playlistLoader = hls.networkControllers[0] as PlaylistLoaderTestable;
const iframeController = hls.iframeController;
const url = 'main.m3u8';
(iframeController as any).clearAsset();
(hls as any)._url = url;
playlistLoader.handleMasterPlaylist(
{ data: playlistWithImageIFrameVariants, url },
new LoadStats(),
);
const imagePlayer = iframeController.createImageIFramePlayer();
if (!imagePlayer) {
throw new Error('createImageIFramePlayer returned null');
}
const controller = (imagePlayer as any)
.streamController as ImageIFrameStreamControllerTestable;
return [imagePlayer, controller];
}
describe('ImageIFrameStreamController', function () {
const sandbox = sinon.createSandbox();
let hls: HlsTestable;
beforeEach(function () {
hls = new Hls({
autoStartLoad: false,
debug: silentLogger,
}) as unknown as HlsTestable;
});
afterEach(function () {
hls.destroy();
sandbox.restore();
});
it('getMainFwdBufferInfo returns zero-length buffer', function () {
const [, controller] = createImagePlayer(hls);
const info = controller.getMainFwdBufferInfo();
expect(info).to.have.property('len', 0);
});
it('loadMediaAt transitions from STOPPED to IDLE', function () {
const [imagePlayer] = createImagePlayer(hls);
const controller = (imagePlayer as any)
.streamController as ImageIFrameStreamControllerTestable;
expect(controller.state).to.equal(State.STOPPED);
imagePlayer.loadMediaAt(5);
// State should have transitioned (may end up IDLE or further depending on tick)
expect(controller.state).to.equal(State.IDLE);
});
it('loadMediaAt with negative time is ignored', function () {
const [imagePlayer] = createImagePlayer(hls);
const controller = (imagePlayer as any)
.streamController as ImageIFrameStreamControllerTestable;
expect(controller.state).to.equal(State.STOPPED);
imagePlayer.loadMediaAt(-1);
expect(controller.state).to.equal(State.STOPPED);
});
it('image setter revokes previous Blob URL', function () {
const [, controller] = createImagePlayer(hls);
const revokeStub = sandbox.stub(self.URL, 'revokeObjectURL');
const img = new Image();
controller.image = img;
expect(revokeStub).to.not.have.been.called;
// Set src to simulate a Blob URL
img.src = 'blob:http://localhost/fake-url';
const img2 = new Image();
controller.image = img2;
expect(revokeStub).to.have.been.calledOnce;
expect(revokeStub).to.have.been.calledWith(
'blob:http://localhost/fake-url',
);
controller.image = undefined;
});
it('bufferFragmentData extracts JPEG bytes from mdat box', function () {
const [, controller] = createImagePlayer(hls);
const jpegPayload = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0xff, 0xd9]);
const mdatBox = buildMdatBox(jpegPayload);
const frag: any = {
sn: 0,
level: 0,
type: 'main',
start: 0,
duration: 2,
stats: {
buffering: {},
retry: 0,
},
};
const data: RemuxedTrack = {
data1: mdatBox,
data2: undefined as any,
startPTS: 0,
endPTS: 2,
startDTS: 0,
endDTS: 2,
type: 'video',
hasAudio: false,
hasVideo: true,
nb: 1,
dropped: 0,
independent: true,
firstKeyFrame: 0,
firstKeyFramePTS: 0,
};
// Set state to PARSING so bufferFragmentData processes data
controller.state = State.PARSING;
controller.fragCurrent = frag;
// Stub fragmentTracker.fragBuffered to prevent errors in fragBufferedComplete
controller.fragmentTracker = {
getState: () => null,
fragBuffered: sandbox.stub(),
getBufferedFrag: () => null,
removeFragment: sandbox.stub(),
removeFragmentsInRange: sandbox.stub(),
isEndListAppended: () => false,
};
// Provide a stub for image so updateImage processes the data
const img = new Image();
controller.image = img;
sandbox.stub(self.URL, 'createObjectURL').returns('blob:fake');
sandbox.stub(self.URL, 'revokeObjectURL');
controller.bufferFragmentData(
data,
frag,
null,
new ChunkMetadata(0, 0, 0, 0, -1, false, 2),
);
// Verify JPEG bytes were extracted and stored on fragment
expect(frag.data).to.be.an.instanceOf(Uint8Array);
// Verify the extracted bytes match the mdat payload by using findBox
const extracted = findBox(mdatBox, ['mdat']);
expect(extracted).to.have.lengthOf(1);
expect(Array.from(frag.data)).to.deep.equal(Array.from(extracted[0]));
});
it('bufferFragmentData triggers error when no mdat box is found', function () {
const [imagePlayer, controller] = createImagePlayer(hls);
// Data that is not a valid mdat box
const noMdatData = new Uint8Array([0, 0, 0, 8, 0x66, 0x72, 0x65, 0x65]); // "free" box
const frag: any = {
sn: 0,
level: 0,
type: 'main',
start: 0,
duration: 2,
stats: { buffering: {} },
};
const data: RemuxedTrack = {
data1: noMdatData,
data2: undefined,
startPTS: 0,
endPTS: 2,
startDTS: 0,
endDTS: 2,
type: 'video',
hasAudio: false,
hasVideo: true,
nb: 1,
dropped: 0,
independent: true,
firstKeyFrame: 0,
firstKeyFramePTS: 0,
};
controller.state = State.PARSING;
controller.fragCurrent = frag;
controller.fragmentTracker = {
getState: () => null,
fragBuffered: sandbox.stub(),
getBufferedFrag: () => null,
removeFragment: sandbox.stub(),
removeFragmentsInRange: sandbox.stub(),
isEndListAppended: () => false,
};
const triggerSpy = sandbox.spy(imagePlayer, 'trigger');
controller.bufferFragmentData(
data,
frag,
null,
new ChunkMetadata(0, 0, 0, 0, -1, false, 2),
);
expect(triggerSpy).to.have.been.calledWith(Events.ERROR);
const errorCall = triggerSpy
.getCalls()
.filter((call) => call.args[0] === Events.ERROR);
expect(errorCall).to.have.lengthOf(1);
const errorEventData = errorCall[0].args[1] as ErrorData;
expect(errorEventData).to.have.property('error');
if (errorEventData) {
expect(errorEventData.error.message).to.equal(
'Could not find I-Frame mdat',
);
}
});
it('updateImage sets image.src when image element is attached', function () {
const [, controller] = createImagePlayer(hls);
const jpegPayload = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]);
const mdatBox = buildMdatBox(jpegPayload);
const frag: any = {
sn: 0,
level: 0,
type: 'main',
start: 0,
duration: 2,
stats: { buffering: {}, retry: 0 },
};
const data: RemuxedTrack = {
data1: mdatBox,
data2: undefined,
startPTS: 0,
endPTS: 2,
startDTS: 0,
endDTS: 2,
type: 'video',
hasAudio: false,
hasVideo: true,
nb: 1,
dropped: 0,
independent: true,
firstKeyFrame: 0,
firstKeyFramePTS: 0,
};
controller.state = State.PARSING;
controller.fragCurrent = frag;
controller.fragmentTracker = {
getState: () => null,
fragBuffered: sandbox.stub(),
getBufferedFrag: () => null,
removeFragment: sandbox.stub(),
removeFragmentsInRange: sandbox.stub(),
isEndListAppended: () => false,
};
const img = new Image();
controller.image = img;
const createURLStub = sandbox
.stub(self.URL, 'createObjectURL')
.returns('blob:http://localhost/jpeg-url');
sandbox.stub(self.URL, 'revokeObjectURL');
controller.bufferFragmentData(
data,
frag,
null,
new ChunkMetadata(0, 0, 0, 0, -1, false, 2),
);
expect(createURLStub).to.have.been.calledOnce;
expect(img.src).to.equal('blob:http://localhost/jpeg-url');
});
it('updateImage transitions to STOPPED when no image element is attached', function () {
const [, controller] = createImagePlayer(hls);
const jpegPayload = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]);
const mdatBox = buildMdatBox(jpegPayload);
const frag: any = {
sn: 0,
level: 0,
type: 'main',
start: 0,
duration: 2,
stats: { buffering: {} },
};
const data: RemuxedTrack = {
data1: mdatBox,
data2: undefined,
startPTS: 0,
endPTS: 2,
startDTS: 0,
endDTS: 2,
type: 'video',
hasAudio: false,
hasVideo: true,
nb: 1,
dropped: 0,
independent: true,
firstKeyFrame: 0,
firstKeyFramePTS: 0,
};
controller.state = State.PARSING;
controller.fragCurrent = frag;
controller.fragmentTracker = {
getState: () => null,
fragBuffered: sandbox.stub(),
getBufferedFrag: () => null,
removeFragment: sandbox.stub(),
removeFragmentsInRange: sandbox.stub(),
isEndListAppended: () => false,
};
// No image attached
controller.image = undefined;
controller.bufferFragmentData(
data,
frag,
null,
new ChunkMetadata(0, 0, 0, 0, -1, false, 2),
);
expect(controller.state).to.equal(State.STOPPED);
});
});