mirror of
https://github.com/video-dev/hls.js.git
synced 2026-06-16 13:34:44 +00:00
Add "mjpg" image I-Frame support (#7862)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -321,6 +321,8 @@ function getAliasesForDist(format, features) {
|
||||
aliases = {
|
||||
...aliases,
|
||||
'./controller/iframe-controller': `./${emptyFile}`,
|
||||
'./iframe-stream-controller': `./${emptyFile}`,
|
||||
'./image-iframe-stream-controller': `./${emptyFile}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+83
@@ -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
@@ -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
|
||||
|
||||
@@ -782,7 +782,7 @@ class AudioStreamController
|
||||
|
||||
protected checkFragmentChanged() {
|
||||
const previousFrag = this.fragPlaying;
|
||||
const fragChanged = super.checkFragmentChanged();
|
||||
const fragChanged = this.checkFragPlaying();
|
||||
if (!fragChanged) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user