@flighthq/video 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/videoFormat.d.ts +5 -1
- package/dist/videoFormat.d.ts.map +1 -1
- package/dist/videoFormat.js +44 -1
- package/dist/videoFormat.js.map +1 -1
- package/dist/videoResource.d.ts +7 -0
- package/dist/videoResource.d.ts.map +1 -1
- package/dist/videoResource.js +40 -0
- package/dist/videoResource.js.map +1 -1
- package/dist/videoResourceFrom.d.ts +5 -3
- package/dist/videoResourceFrom.d.ts.map +1 -1
- package/dist/videoResourceFrom.js +47 -11
- package/dist/videoResourceFrom.js.map +1 -1
- package/package.json +7 -2
- package/src/videoFormat.test.ts +97 -11
- package/src/videoResource.test.ts +103 -1
- package/src/videoResourceFrom.test.ts +114 -6
package/dist/videoFormat.d.ts
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import type { VideoResourceUrl } from '@flighthq/types';
|
|
2
|
+
export declare function canPlayVideoType(mimeType: string): boolean;
|
|
3
|
+
export declare function detectVideoMimeType(data: ArrayBuffer | Uint8Array): string | null;
|
|
4
|
+
export declare function inferVideoMimeType(url: string): string | null;
|
|
5
|
+
export declare function selectVideoResourceUrl(sources: Readonly<VideoResourceUrl[]>): VideoResourceUrl | null;
|
|
2
6
|
//# sourceMappingURL=videoFormat.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"videoFormat.d.ts","sourceRoot":"","sources":["../src/videoFormat.ts"],"names":[],"mappings":"AAAA,wBAAgB,
|
|
1
|
+
{"version":3,"file":"videoFormat.d.ts","sourceRoot":"","sources":["../src/videoFormat.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAKxD,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAG1D;AAKD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,WAAW,GAAG,UAAU,GAAG,MAAM,GAAG,IAAI,CAcjF;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAwB7D;AAKD,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,QAAQ,CAAC,gBAAgB,EAAE,CAAC,GAAG,gBAAgB,GAAG,IAAI,CAKrG"}
|
package/dist/videoFormat.js
CHANGED
|
@@ -1,4 +1,29 @@
|
|
|
1
|
-
|
|
1
|
+
// True when the current environment claims it can play the given MIME type (a non-empty canPlayType
|
|
2
|
+
// result — 'maybe' or 'probably'). Returns false in headless environments (jsdom) where no codecs
|
|
3
|
+
// are registered. This is the codec-probe primitive that source selection is built on.
|
|
4
|
+
export function canPlayVideoType(mimeType) {
|
|
5
|
+
const probe = document.createElement('video');
|
|
6
|
+
return probe.canPlayType(mimeType) !== '';
|
|
7
|
+
}
|
|
8
|
+
// Sniffs a container MIME type from the leading bytes of encoded video, or null when unrecognised.
|
|
9
|
+
// Mirrors detectImageMimeType. mkv and webm share the Matroska/EBML signature (the DocType that
|
|
10
|
+
// separates them lives deeper in the stream), so both report as 'video/webm', the web-canonical one.
|
|
11
|
+
export function detectVideoMimeType(data) {
|
|
12
|
+
const b = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
13
|
+
if (b.byteLength < 4)
|
|
14
|
+
return null;
|
|
15
|
+
// ISO Base Media (mp4/m4v): a 'ftyp' box tag at bytes 4-7 (66 74 79 70).
|
|
16
|
+
if (b.byteLength >= 8 && b[4] === 0x66 && b[5] === 0x74 && b[6] === 0x79 && b[7] === 0x70)
|
|
17
|
+
return 'video/mp4';
|
|
18
|
+
// Matroska/WebM (EBML header): 1A 45 DF A3.
|
|
19
|
+
if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3)
|
|
20
|
+
return 'video/webm';
|
|
21
|
+
// Ogg: 'OggS' (4F 67 67 53).
|
|
22
|
+
if (b[0] === 0x4f && b[1] === 0x67 && b[2] === 0x67 && b[3] === 0x53)
|
|
23
|
+
return 'video/ogg';
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
export function inferVideoMimeType(url) {
|
|
2
27
|
const ext = url.split('?')[0].split('.').pop()?.toLowerCase();
|
|
3
28
|
switch (ext) {
|
|
4
29
|
case 'mp4':
|
|
@@ -6,13 +31,31 @@ export function inferVideoType(url) {
|
|
|
6
31
|
return 'video/mp4';
|
|
7
32
|
case 'webm':
|
|
8
33
|
return 'video/webm';
|
|
34
|
+
case 'mkv':
|
|
35
|
+
return 'video/x-matroska';
|
|
9
36
|
case 'ogv':
|
|
10
37
|
case 'ogg':
|
|
11
38
|
return 'video/ogg';
|
|
12
39
|
case 'mov':
|
|
13
40
|
return 'video/quicktime';
|
|
41
|
+
case '3gp':
|
|
42
|
+
return 'video/3gpp';
|
|
43
|
+
case 'm3u8':
|
|
44
|
+
return 'application/vnd.apple.mpegurl';
|
|
45
|
+
case 'mpd':
|
|
46
|
+
return 'application/dash+xml';
|
|
14
47
|
default:
|
|
15
48
|
return null;
|
|
16
49
|
}
|
|
17
50
|
}
|
|
51
|
+
// Picks the first source the environment can play, resolving each source's MIME type from its
|
|
52
|
+
// explicit `type` or, failing that, its URL extension. Returns null when none is playable — the
|
|
53
|
+
// source-negotiation primitive behind loadVideoResourceFromUrls.
|
|
54
|
+
export function selectVideoResourceUrl(sources) {
|
|
55
|
+
for (const source of sources) {
|
|
56
|
+
if (canPlayVideoType(source.type ?? inferVideoMimeType(source.url) ?? ''))
|
|
57
|
+
return source;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
18
61
|
//# sourceMappingURL=videoFormat.js.map
|
package/dist/videoFormat.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"videoFormat.js","sourceRoot":"","sources":["../src/videoFormat.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"videoFormat.js","sourceRoot":"","sources":["../src/videoFormat.ts"],"names":[],"mappings":"AAEA,oGAAoG;AACpG,kGAAkG;AAClG,uFAAuF;AACvF,MAAM,UAAU,gBAAgB,CAAC,QAAgB;IAC/C,MAAM,KAAK,GAAG,QAAQ,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;IAC9C,OAAO,KAAK,CAAC,WAAW,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAC;AAC5C,CAAC;AAED,mGAAmG;AACnG,gGAAgG;AAChG,qGAAqG;AACrG,MAAM,UAAU,mBAAmB,CAAC,IAA8B;IAChE,MAAM,CAAC,GAAG,IAAI,YAAY,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,UAAU,CAAC,IAAI,CAAC,CAAC;IACnE,IAAI,CAAC,CAAC,UAAU,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IAElC,yEAAyE;IACzE,IAAI,CAAC,CAAC,UAAU,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI;QAAE,OAAO,WAAW,CAAC;IAE9G,4CAA4C;IAC5C,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI;QAAE,OAAO,YAAY,CAAC;IAE1F,6BAA6B;IAC7B,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI;QAAE,OAAO,WAAW,CAAC;IAEzF,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,kBAAkB,CAAC,GAAW;IAC5C,MAAM,GAAG,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,WAAW,EAAE,CAAC;IAC9D,QAAQ,GAAG,EAAE,CAAC;QACZ,KAAK,KAAK,CAAC;QACX,KAAK,KAAK;YACR,OAAO,WAAW,CAAC;QACrB,KAAK,MAAM;YACT,OAAO,YAAY,CAAC;QACtB,KAAK,KAAK;YACR,OAAO,kBAAkB,CAAC;QAC5B,KAAK,KAAK,CAAC;QACX,KAAK,KAAK;YACR,OAAO,WAAW,CAAC;QACrB,KAAK,KAAK;YACR,OAAO,iBAAiB,CAAC;QAC3B,KAAK,KAAK;YACR,OAAO,YAAY,CAAC;QACtB,KAAK,MAAM;YACT,OAAO,+BAA+B,CAAC;QACzC,KAAK,KAAK;YACR,OAAO,sBAAsB,CAAC;QAChC;YACE,OAAO,IAAI,CAAC;IAChB,CAAC;AACH,CAAC;AAED,8FAA8F;AAC9F,gGAAgG;AAChG,iEAAiE;AACjE,MAAM,UAAU,sBAAsB,CAAC,OAAqC;IAC1E,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,gBAAgB,CAAC,MAAM,CAAC,IAAI,IAAI,kBAAkB,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;YAAE,OAAO,MAAM,CAAC;IAC3F,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
|
package/dist/videoResource.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
import type { VideoResource } from '@flighthq/types';
|
|
2
2
|
export declare function createVideoResource(element?: HTMLVideoElement): VideoResource;
|
|
3
|
+
export declare function disposeVideoResource(resource: VideoResource): void;
|
|
4
|
+
export declare function getVideoResourceDuration(resource: Readonly<VideoResource>): number;
|
|
5
|
+
export declare function getVideoResourceHeight(resource: Readonly<VideoResource>): number;
|
|
6
|
+
export declare function getVideoResourceWidth(resource: Readonly<VideoResource>): number;
|
|
7
|
+
export declare function hasVideoResourceElement(resource: Readonly<VideoResource>): boolean;
|
|
8
|
+
export declare function isVideoResourceEmpty(resource: Readonly<VideoResource>): boolean;
|
|
9
|
+
export declare function isVideoResourceReady(resource: Readonly<VideoResource>): boolean;
|
|
3
10
|
//# sourceMappingURL=videoResource.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"videoResource.d.ts","sourceRoot":"","sources":["../src/videoResource.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"videoResource.d.ts","sourceRoot":"","sources":["../src/videoResource.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAKrD,wBAAgB,mBAAmB,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,aAAa,CAE7E;AAKD,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,aAAa,GAAG,IAAI,CAOlE;AAID,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,MAAM,CAElF;AAED,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,MAAM,CAEhF;AAED,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,MAAM,CAE/E;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,OAAO,CAElF;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,OAAO,CAG/E;AAID,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,OAAO,CAG/E"}
|
package/dist/videoResource.js
CHANGED
|
@@ -1,4 +1,44 @@
|
|
|
1
|
+
// No cloneVideoResource: a VideoResource is a thin carrier over a single HTMLVideoElement, and an
|
|
2
|
+
// element cannot be duplicated (each carries its own decoder and playback position). Wrap the same
|
|
3
|
+
// element in a second createVideoResource call if two carriers over one stream are truly wanted.
|
|
1
4
|
export function createVideoResource(element) {
|
|
2
5
|
return { element: element ?? null };
|
|
3
6
|
}
|
|
7
|
+
// Releases the decoder the element holds: clearing the src and calling load() detaches the media
|
|
8
|
+
// resource so the browser can free its buffers, then the element reference is dropped for GC. Unlike
|
|
9
|
+
// destroy*, there is no non-GC handle to free here — the element is plain GC-managed memory.
|
|
10
|
+
export function disposeVideoResource(resource) {
|
|
11
|
+
const element = resource.element;
|
|
12
|
+
if (element !== null) {
|
|
13
|
+
element.removeAttribute('src');
|
|
14
|
+
element.load();
|
|
15
|
+
}
|
|
16
|
+
resource.element = null;
|
|
17
|
+
}
|
|
18
|
+
// Duration in seconds of the loaded media, or 0 when no element is attached. May be NaN before
|
|
19
|
+
// metadata has loaded and Infinity for open-ended live streams — both come straight from the element.
|
|
20
|
+
export function getVideoResourceDuration(resource) {
|
|
21
|
+
return resource.element !== null ? resource.element.duration : 0;
|
|
22
|
+
}
|
|
23
|
+
export function getVideoResourceHeight(resource) {
|
|
24
|
+
return resource.element !== null ? resource.element.videoHeight : 0;
|
|
25
|
+
}
|
|
26
|
+
export function getVideoResourceWidth(resource) {
|
|
27
|
+
return resource.element !== null ? resource.element.videoWidth : 0;
|
|
28
|
+
}
|
|
29
|
+
export function hasVideoResourceElement(resource) {
|
|
30
|
+
return resource.element !== null;
|
|
31
|
+
}
|
|
32
|
+
export function isVideoResourceEmpty(resource) {
|
|
33
|
+
const element = resource.element;
|
|
34
|
+
return element === null || element.videoWidth <= 0 || element.videoHeight <= 0;
|
|
35
|
+
}
|
|
36
|
+
// True once the element has decoded at least the current frame (readyState >= HAVE_CURRENT_DATA), so
|
|
37
|
+
// its videoWidth/videoHeight are known and a frame is available to sample or upload to a texture.
|
|
38
|
+
export function isVideoResourceReady(resource) {
|
|
39
|
+
const element = resource.element;
|
|
40
|
+
return element !== null && element.readyState >= HAVE_CURRENT_DATA;
|
|
41
|
+
}
|
|
42
|
+
// HTMLMediaElement.HAVE_CURRENT_DATA — data for the current playback position is available.
|
|
43
|
+
const HAVE_CURRENT_DATA = 2;
|
|
4
44
|
//# sourceMappingURL=videoResource.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"videoResource.js","sourceRoot":"","sources":["../src/videoResource.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,mBAAmB,CAAC,OAA0B;IAC5D,OAAO,EAAE,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,CAAC;AACtC,CAAC"}
|
|
1
|
+
{"version":3,"file":"videoResource.js","sourceRoot":"","sources":["../src/videoResource.ts"],"names":[],"mappings":"AAEA,kGAAkG;AAClG,mGAAmG;AACnG,iGAAiG;AACjG,MAAM,UAAU,mBAAmB,CAAC,OAA0B;IAC5D,OAAO,EAAE,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,CAAC;AACtC,CAAC;AAED,iGAAiG;AACjG,qGAAqG;AACrG,6FAA6F;AAC7F,MAAM,UAAU,oBAAoB,CAAC,QAAuB;IAC1D,MAAM,OAAO,GAAG,QAAQ,CAAC,OAAO,CAAC;IACjC,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;QACrB,OAAO,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;QAC/B,OAAO,CAAC,IAAI,EAAE,CAAC;IACjB,CAAC;IACD,QAAQ,CAAC,OAAO,GAAG,IAAI,CAAC;AAC1B,CAAC;AAED,+FAA+F;AAC/F,sGAAsG;AACtG,MAAM,UAAU,wBAAwB,CAAC,QAAiC;IACxE,OAAO,QAAQ,CAAC,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;AACnE,CAAC;AAED,MAAM,UAAU,sBAAsB,CAAC,QAAiC;IACtE,OAAO,QAAQ,CAAC,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC;AACtE,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,QAAiC;IACrE,OAAO,QAAQ,CAAC,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;AACrE,CAAC;AAED,MAAM,UAAU,uBAAuB,CAAC,QAAiC;IACvE,OAAO,QAAQ,CAAC,OAAO,KAAK,IAAI,CAAC;AACnC,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,QAAiC;IACpE,MAAM,OAAO,GAAG,QAAQ,CAAC,OAAO,CAAC;IACjC,OAAO,OAAO,KAAK,IAAI,IAAI,OAAO,CAAC,UAAU,IAAI,CAAC,IAAI,OAAO,CAAC,WAAW,IAAI,CAAC,CAAC;AACjF,CAAC;AAED,qGAAqG;AACrG,kGAAkG;AAClG,MAAM,UAAU,oBAAoB,CAAC,QAAiC;IACpE,MAAM,OAAO,GAAG,QAAQ,CAAC,OAAO,CAAC;IACjC,OAAO,OAAO,KAAK,IAAI,IAAI,OAAO,CAAC,UAAU,IAAI,iBAAiB,CAAC;AACrE,CAAC;AAED,4FAA4F;AAC5F,MAAM,iBAAiB,GAAG,CAAC,CAAC"}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { VideoResource, VideoResourceUrl } from '@flighthq/types';
|
|
2
|
-
export declare function
|
|
3
|
-
export declare function
|
|
1
|
+
import type { VideoResource, VideoResourceLoadOptions, VideoResourceUrl } from '@flighthq/types';
|
|
2
|
+
export declare function createVideoResourceFromMediaStream(stream: MediaStream): VideoResource;
|
|
3
|
+
export declare function loadVideoResourceFromBlob(blob: Blob, options?: Readonly<VideoResourceLoadOptions>, signal?: AbortSignal): Promise<VideoResource>;
|
|
4
|
+
export declare function loadVideoResourceFromUrl(url: string, options?: Readonly<VideoResourceLoadOptions>, signal?: AbortSignal): Promise<VideoResource>;
|
|
5
|
+
export declare function loadVideoResourceFromUrls(sources: Readonly<VideoResourceUrl[]>, options?: Readonly<VideoResourceLoadOptions>, signal?: AbortSignal): Promise<VideoResource>;
|
|
4
6
|
//# sourceMappingURL=videoResourceFrom.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"videoResourceFrom.d.ts","sourceRoot":"","sources":["../src/videoResourceFrom.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"videoResourceFrom.d.ts","sourceRoot":"","sources":["../src/videoResourceFrom.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,wBAAwB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAOjG,wBAAgB,kCAAkC,CAAC,MAAM,EAAE,WAAW,GAAG,aAAa,CAIrF;AAID,wBAAsB,yBAAyB,CAC7C,IAAI,EAAE,IAAI,EACV,OAAO,CAAC,EAAE,QAAQ,CAAC,wBAAwB,CAAC,EAC5C,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAOxB;AAED,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,QAAQ,CAAC,wBAAwB,CAAC,EAC5C,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAuCxB;AAED,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,QAAQ,CAAC,gBAAgB,EAAE,CAAC,EACrC,OAAO,CAAC,EAAE,QAAQ,CAAC,wBAAwB,CAAC,EAC5C,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAIxB"}
|
|
@@ -1,12 +1,38 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { selectVideoResourceUrl } from './videoFormat';
|
|
2
2
|
import { createVideoResource } from './videoResource';
|
|
3
|
-
|
|
3
|
+
// Wraps a live MediaStream (camera, screen capture, canvas.captureStream) as a video resource by
|
|
4
|
+
// assigning it to element.srcObject. Pure DOM, no load — the stream feeds frames as they arrive.
|
|
5
|
+
export function createVideoResourceFromMediaStream(stream) {
|
|
6
|
+
const element = document.createElement('video');
|
|
7
|
+
element.srcObject = stream;
|
|
8
|
+
return createVideoResource(element);
|
|
9
|
+
}
|
|
10
|
+
// Loads from a Blob by wrapping it in an object URL. This function owns that URL and revokes it once
|
|
11
|
+
// the load settles (success or failure), so the caller never has to.
|
|
12
|
+
export async function loadVideoResourceFromBlob(blob, options, signal) {
|
|
13
|
+
const url = URL.createObjectURL(blob);
|
|
14
|
+
try {
|
|
15
|
+
return await loadVideoResourceFromUrl(url, options, signal);
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
URL.revokeObjectURL(url);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function loadVideoResourceFromUrl(url, options, signal) {
|
|
4
22
|
if (signal?.aborted)
|
|
5
23
|
return Promise.reject(signal.reason);
|
|
6
24
|
return new Promise((resolve, reject) => {
|
|
7
25
|
const element = document.createElement('video');
|
|
8
|
-
element.preload = 'auto';
|
|
9
|
-
|
|
26
|
+
element.preload = (options?.preload ?? 'auto');
|
|
27
|
+
// crossOrigin must be set before assigning src so the fetched frames stay untainted for GPU upload.
|
|
28
|
+
if (options?.crossOrigin !== undefined)
|
|
29
|
+
element.crossOrigin = options.crossOrigin;
|
|
30
|
+
if (options?.muted !== undefined)
|
|
31
|
+
element.muted = options.muted;
|
|
32
|
+
if (options?.playsInline !== undefined)
|
|
33
|
+
element.playsInline = options.playsInline;
|
|
34
|
+
const readyEvent = readinessEventName(options?.readiness);
|
|
35
|
+
const onReady = () => {
|
|
10
36
|
cleanup();
|
|
11
37
|
resolve(createVideoResource(element));
|
|
12
38
|
};
|
|
@@ -20,23 +46,33 @@ export function loadVideoResourceFromUrl(url, signal) {
|
|
|
20
46
|
reject(signal.reason);
|
|
21
47
|
};
|
|
22
48
|
const cleanup = () => {
|
|
23
|
-
element.removeEventListener(
|
|
49
|
+
element.removeEventListener(readyEvent, onReady);
|
|
24
50
|
element.removeEventListener('error', onError);
|
|
25
51
|
if (signal !== undefined)
|
|
26
52
|
signal.removeEventListener('abort', onAbort);
|
|
27
53
|
};
|
|
28
|
-
element.addEventListener(
|
|
54
|
+
element.addEventListener(readyEvent, onReady, { once: true });
|
|
29
55
|
element.addEventListener('error', onError, { once: true });
|
|
30
56
|
if (signal !== undefined)
|
|
31
57
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
32
58
|
element.src = url;
|
|
33
59
|
});
|
|
34
60
|
}
|
|
35
|
-
export function loadVideoResourceFromUrls(sources, signal) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
if (selected === undefined)
|
|
61
|
+
export function loadVideoResourceFromUrls(sources, options, signal) {
|
|
62
|
+
const selected = selectVideoResourceUrl(sources);
|
|
63
|
+
if (selected === null)
|
|
39
64
|
return Promise.resolve(createVideoResource());
|
|
40
|
-
return loadVideoResourceFromUrl(selected.url, signal);
|
|
65
|
+
return loadVideoResourceFromUrl(selected.url, options, signal);
|
|
66
|
+
}
|
|
67
|
+
// Maps a readiness mode to the media event that resolves the load; defaults to 'canplay'.
|
|
68
|
+
function readinessEventName(readiness) {
|
|
69
|
+
switch (readiness) {
|
|
70
|
+
case 'metadata':
|
|
71
|
+
return 'loadedmetadata';
|
|
72
|
+
case 'canplaythrough':
|
|
73
|
+
return 'canplaythrough';
|
|
74
|
+
default:
|
|
75
|
+
return 'canplay';
|
|
76
|
+
}
|
|
41
77
|
}
|
|
42
78
|
//# sourceMappingURL=videoResourceFrom.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"videoResourceFrom.js","sourceRoot":"","sources":["../src/videoResourceFrom.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"videoResourceFrom.js","sourceRoot":"","sources":["../src/videoResourceFrom.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flighthq/video",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "https://github.com/flighthq/flight.git",
|
|
7
|
+
"directory": "packages/video"
|
|
8
|
+
},
|
|
4
9
|
"type": "module",
|
|
5
10
|
"main": "dist/index.js",
|
|
6
11
|
"types": "dist/index.d.ts",
|
|
@@ -27,7 +32,7 @@
|
|
|
27
32
|
"clean:dist": "tsx ../../scripts/clean-package-dist.ts"
|
|
28
33
|
},
|
|
29
34
|
"dependencies": {
|
|
30
|
-
"@flighthq/types": "0.
|
|
35
|
+
"@flighthq/types": "0.2.0"
|
|
31
36
|
},
|
|
32
37
|
"devDependencies": {
|
|
33
38
|
"typescript": "^5.3.0"
|
package/src/videoFormat.test.ts
CHANGED
|
@@ -1,39 +1,125 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { canPlayVideoType, detectVideoMimeType, inferVideoMimeType, selectVideoResourceUrl } from './videoFormat';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
afterEach(() => {
|
|
4
|
+
vi.restoreAllMocks();
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
describe('canPlayVideoType', () => {
|
|
8
|
+
it('is false in jsdom where no codecs are registered', () => {
|
|
9
|
+
expect(canPlayVideoType('video/mp4')).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('is true when the element reports it can play the type', () => {
|
|
13
|
+
vi.spyOn(HTMLVideoElement.prototype, 'canPlayType').mockReturnValue('probably');
|
|
14
|
+
expect(canPlayVideoType('video/mp4')).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('detectVideoMimeType', () => {
|
|
19
|
+
it('returns null for a buffer that is too small', () => {
|
|
20
|
+
expect(detectVideoMimeType(new ArrayBuffer(2))).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns null for an unrecognised header', () => {
|
|
24
|
+
expect(detectVideoMimeType(new Uint8Array([0x00, 0x01, 0x02, 0x03]))).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('detects an mp4 ftyp box', () => {
|
|
28
|
+
const b = new Uint8Array([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]);
|
|
29
|
+
expect(detectVideoMimeType(b)).toBe('video/mp4');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('detects a Matroska/WebM EBML header', () => {
|
|
33
|
+
expect(detectVideoMimeType(new Uint8Array([0x1a, 0x45, 0xdf, 0xa3]))).toBe('video/webm');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('detects an Ogg stream', () => {
|
|
37
|
+
expect(detectVideoMimeType(new Uint8Array([0x4f, 0x67, 0x67, 0x53]))).toBe('video/ogg');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('accepts an ArrayBuffer as well as a Uint8Array', () => {
|
|
41
|
+
const buf = new ArrayBuffer(8);
|
|
42
|
+
new Uint8Array(buf).set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]);
|
|
43
|
+
expect(detectVideoMimeType(buf)).toBe('video/mp4');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('inferVideoMimeType', () => {
|
|
4
48
|
it('returns "video/mp4" for .mp4 files', () => {
|
|
5
|
-
expect(
|
|
49
|
+
expect(inferVideoMimeType('clip.mp4')).toBe('video/mp4');
|
|
6
50
|
});
|
|
7
51
|
|
|
8
52
|
it('returns "video/mp4" for .m4v files', () => {
|
|
9
|
-
expect(
|
|
53
|
+
expect(inferVideoMimeType('clip.m4v')).toBe('video/mp4');
|
|
10
54
|
});
|
|
11
55
|
|
|
12
56
|
it('returns "video/webm" for .webm files', () => {
|
|
13
|
-
expect(
|
|
57
|
+
expect(inferVideoMimeType('clip.webm')).toBe('video/webm');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns "video/x-matroska" for .mkv files', () => {
|
|
61
|
+
expect(inferVideoMimeType('clip.mkv')).toBe('video/x-matroska');
|
|
14
62
|
});
|
|
15
63
|
|
|
16
64
|
it('returns "video/ogg" for .ogv files', () => {
|
|
17
|
-
expect(
|
|
65
|
+
expect(inferVideoMimeType('clip.ogv')).toBe('video/ogg');
|
|
18
66
|
});
|
|
19
67
|
|
|
20
68
|
it('returns "video/ogg" for .ogg files', () => {
|
|
21
|
-
expect(
|
|
69
|
+
expect(inferVideoMimeType('clip.ogg')).toBe('video/ogg');
|
|
22
70
|
});
|
|
23
71
|
|
|
24
72
|
it('returns "video/quicktime" for .mov files', () => {
|
|
25
|
-
expect(
|
|
73
|
+
expect(inferVideoMimeType('clip.mov')).toBe('video/quicktime');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns "video/3gpp" for .3gp files', () => {
|
|
77
|
+
expect(inferVideoMimeType('clip.3gp')).toBe('video/3gpp');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns the HLS playlist type for .m3u8 files', () => {
|
|
81
|
+
expect(inferVideoMimeType('stream.m3u8')).toBe('application/vnd.apple.mpegurl');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns the DASH manifest type for .mpd files', () => {
|
|
85
|
+
expect(inferVideoMimeType('stream.mpd')).toBe('application/dash+xml');
|
|
26
86
|
});
|
|
27
87
|
|
|
28
88
|
it('returns null for unrecognized extensions', () => {
|
|
29
|
-
expect(
|
|
89
|
+
expect(inferVideoMimeType('clip.avi')).toBeNull();
|
|
30
90
|
});
|
|
31
91
|
|
|
32
92
|
it('strips query parameters before matching', () => {
|
|
33
|
-
expect(
|
|
93
|
+
expect(inferVideoMimeType('clip.mp4?t=0')).toBe('video/mp4');
|
|
34
94
|
});
|
|
35
95
|
|
|
36
96
|
it('returns null for a URL with no extension', () => {
|
|
37
|
-
expect(
|
|
97
|
+
expect(inferVideoMimeType('video')).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('selectVideoResourceUrl', () => {
|
|
102
|
+
it('returns null when no source is playable', () => {
|
|
103
|
+
expect(selectVideoResourceUrl([{ url: 'clip.mp4' }])).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('returns null for an empty source list', () => {
|
|
107
|
+
expect(selectVideoResourceUrl([])).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('picks the first source whose inferred type is playable', () => {
|
|
111
|
+
vi.spyOn(HTMLVideoElement.prototype, 'canPlayType').mockImplementation((type) =>
|
|
112
|
+
type === 'video/webm' ? 'probably' : '',
|
|
113
|
+
);
|
|
114
|
+
const selected = selectVideoResourceUrl([{ url: 'clip.mp4' }, { url: 'clip.webm' }]);
|
|
115
|
+
expect(selected?.url).toBe('clip.webm');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('honours an explicit type over the URL extension', () => {
|
|
119
|
+
vi.spyOn(HTMLVideoElement.prototype, 'canPlayType').mockImplementation((type) =>
|
|
120
|
+
type === 'video/mp4' ? 'maybe' : '',
|
|
121
|
+
);
|
|
122
|
+
const selected = selectVideoResourceUrl([{ url: 'stream', type: 'video/mp4' }]);
|
|
123
|
+
expect(selected?.url).toBe('stream');
|
|
38
124
|
});
|
|
39
125
|
});
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createVideoResource,
|
|
3
|
+
disposeVideoResource,
|
|
4
|
+
getVideoResourceDuration,
|
|
5
|
+
getVideoResourceHeight,
|
|
6
|
+
getVideoResourceWidth,
|
|
7
|
+
hasVideoResourceElement,
|
|
8
|
+
isVideoResourceEmpty,
|
|
9
|
+
isVideoResourceReady,
|
|
10
|
+
} from './videoResource';
|
|
2
11
|
|
|
3
12
|
describe('createVideoResource', () => {
|
|
4
13
|
it('returns a resource with null element when called with no arguments', () => {
|
|
@@ -12,3 +21,96 @@ describe('createVideoResource', () => {
|
|
|
12
21
|
expect(resource.element).toBe(element);
|
|
13
22
|
});
|
|
14
23
|
});
|
|
24
|
+
|
|
25
|
+
describe('disposeVideoResource', () => {
|
|
26
|
+
it('clears the src, reloads to release the decoder, and drops the element', () => {
|
|
27
|
+
const removeAttribute = vi.fn();
|
|
28
|
+
const load = vi.fn();
|
|
29
|
+
const element = { removeAttribute, load } as unknown as HTMLVideoElement;
|
|
30
|
+
const resource = createVideoResource(element);
|
|
31
|
+
|
|
32
|
+
disposeVideoResource(resource);
|
|
33
|
+
|
|
34
|
+
expect(removeAttribute).toHaveBeenCalledWith('src');
|
|
35
|
+
expect(load).toHaveBeenCalledOnce();
|
|
36
|
+
expect(resource.element).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('is a no-op on an already element-less resource', () => {
|
|
40
|
+
const resource = createVideoResource();
|
|
41
|
+
disposeVideoResource(resource);
|
|
42
|
+
expect(resource.element).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('getVideoResourceDuration', () => {
|
|
47
|
+
it('returns 0 when there is no element', () => {
|
|
48
|
+
expect(getVideoResourceDuration(createVideoResource())).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('reads duration from the element', () => {
|
|
52
|
+
const element = { duration: 12.5 } as HTMLVideoElement;
|
|
53
|
+
expect(getVideoResourceDuration(createVideoResource(element))).toBe(12.5);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('getVideoResourceHeight', () => {
|
|
58
|
+
it('returns 0 when there is no element', () => {
|
|
59
|
+
expect(getVideoResourceHeight(createVideoResource())).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('reads videoHeight from the element', () => {
|
|
63
|
+
const element = { videoHeight: 480 } as HTMLVideoElement;
|
|
64
|
+
expect(getVideoResourceHeight(createVideoResource(element))).toBe(480);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('getVideoResourceWidth', () => {
|
|
69
|
+
it('returns 0 when there is no element', () => {
|
|
70
|
+
expect(getVideoResourceWidth(createVideoResource())).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('reads videoWidth from the element', () => {
|
|
74
|
+
const element = { videoWidth: 640 } as HTMLVideoElement;
|
|
75
|
+
expect(getVideoResourceWidth(createVideoResource(element))).toBe(640);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('hasVideoResourceElement', () => {
|
|
80
|
+
it('is false without an element and true with one', () => {
|
|
81
|
+
expect(hasVideoResourceElement(createVideoResource())).toBe(false);
|
|
82
|
+
expect(hasVideoResourceElement(createVideoResource(document.createElement('video')))).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('isVideoResourceEmpty', () => {
|
|
87
|
+
it('is true when there is no element', () => {
|
|
88
|
+
expect(isVideoResourceEmpty(createVideoResource())).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('is true when the element has no decoded dimensions', () => {
|
|
92
|
+
const element = { videoWidth: 0, videoHeight: 0 } as HTMLVideoElement;
|
|
93
|
+
expect(isVideoResourceEmpty(createVideoResource(element))).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('is false once the element reports dimensions', () => {
|
|
97
|
+
const element = { videoWidth: 640, videoHeight: 480 } as HTMLVideoElement;
|
|
98
|
+
expect(isVideoResourceEmpty(createVideoResource(element))).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('isVideoResourceReady', () => {
|
|
103
|
+
it('is false when there is no element', () => {
|
|
104
|
+
expect(isVideoResourceReady(createVideoResource())).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('is false while readyState is below HAVE_CURRENT_DATA', () => {
|
|
108
|
+
const element = { readyState: 1 } as HTMLVideoElement;
|
|
109
|
+
expect(isVideoResourceReady(createVideoResource(element))).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('is true once readyState reaches HAVE_CURRENT_DATA', () => {
|
|
113
|
+
const element = { readyState: 2 } as HTMLVideoElement;
|
|
114
|
+
expect(isVideoResourceReady(createVideoResource(element))).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -1,4 +1,59 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createVideoResourceFromMediaStream,
|
|
3
|
+
loadVideoResourceFromBlob,
|
|
4
|
+
loadVideoResourceFromUrl,
|
|
5
|
+
loadVideoResourceFromUrls,
|
|
6
|
+
} from './videoResourceFrom';
|
|
7
|
+
|
|
8
|
+
// Capture every <video> the loaders create so tests can drive its media events synchronously.
|
|
9
|
+
let created: HTMLVideoElement[];
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
created = [];
|
|
13
|
+
const original = document.createElement.bind(document);
|
|
14
|
+
vi.spyOn(document, 'createElement').mockImplementation(((tag: string, options?: ElementCreationOptions) => {
|
|
15
|
+
const element = original(tag, options);
|
|
16
|
+
if (tag === 'video') created.push(element as HTMLVideoElement);
|
|
17
|
+
return element;
|
|
18
|
+
}) as typeof document.createElement);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function lastVideo(): HTMLVideoElement {
|
|
26
|
+
return created[created.length - 1];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('createVideoResourceFromMediaStream', () => {
|
|
30
|
+
it('wraps a MediaStream by assigning it to srcObject', () => {
|
|
31
|
+
const stream = {} as MediaStream;
|
|
32
|
+
const resource = createVideoResourceFromMediaStream(stream);
|
|
33
|
+
expect(resource.element).not.toBeNull();
|
|
34
|
+
expect(resource.element!.srcObject).toBe(stream);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('loadVideoResourceFromBlob', () => {
|
|
39
|
+
it('revokes the object URL after a successful load', async () => {
|
|
40
|
+
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
|
41
|
+
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL');
|
|
42
|
+
const promise = loadVideoResourceFromBlob(new Blob([], { type: 'video/mp4' }));
|
|
43
|
+
lastVideo().dispatchEvent(new Event('canplay'));
|
|
44
|
+
await promise;
|
|
45
|
+
expect(revokeSpy).toHaveBeenCalledWith('blob:mock');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('revokes the object URL even when the load fails', async () => {
|
|
49
|
+
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
|
50
|
+
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL');
|
|
51
|
+
const promise = loadVideoResourceFromBlob(new Blob([], { type: 'video/mp4' }));
|
|
52
|
+
lastVideo().dispatchEvent(new Event('error'));
|
|
53
|
+
await expect(promise).rejects.toThrow('Failed to load video');
|
|
54
|
+
expect(revokeSpy).toHaveBeenCalledWith('blob:mock');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
2
57
|
|
|
3
58
|
describe('loadVideoResourceFromUrl', () => {
|
|
4
59
|
it('returns a Promise', () => {
|
|
@@ -7,16 +62,60 @@ describe('loadVideoResourceFromUrl', () => {
|
|
|
7
62
|
expect(result).toBeInstanceOf(Promise);
|
|
8
63
|
});
|
|
9
64
|
|
|
65
|
+
it('defaults preload to auto and resolves on canplay when options are omitted', async () => {
|
|
66
|
+
const promise = loadVideoResourceFromUrl('test.mp4');
|
|
67
|
+
const element = lastVideo();
|
|
68
|
+
expect(element.preload).toBe('auto');
|
|
69
|
+
element.dispatchEvent(new Event('canplay'));
|
|
70
|
+
const resource = await promise;
|
|
71
|
+
expect(resource.element).toBe(element);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('applies crossOrigin, muted, and preload from options', async () => {
|
|
75
|
+
const promise = loadVideoResourceFromUrl('test.mp4', {
|
|
76
|
+
crossOrigin: 'anonymous',
|
|
77
|
+
muted: true,
|
|
78
|
+
preload: 'metadata',
|
|
79
|
+
});
|
|
80
|
+
const element = lastVideo();
|
|
81
|
+
expect(element.crossOrigin).toBe('anonymous');
|
|
82
|
+
expect(element.muted).toBe(true);
|
|
83
|
+
expect(element.preload).toBe('metadata');
|
|
84
|
+
element.dispatchEvent(new Event('canplay'));
|
|
85
|
+
await promise;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('resolves on loadedmetadata when readiness is "metadata"', async () => {
|
|
89
|
+
const promise = loadVideoResourceFromUrl('test.mp4', { readiness: 'metadata' });
|
|
90
|
+
const element = lastVideo();
|
|
91
|
+
element.dispatchEvent(new Event('loadedmetadata'));
|
|
92
|
+
const resource = await promise;
|
|
93
|
+
expect(resource.element).toBe(element);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('resolves on canplaythrough when readiness is "canplaythrough"', async () => {
|
|
97
|
+
const promise = loadVideoResourceFromUrl('test.mp4', { readiness: 'canplaythrough' });
|
|
98
|
+
const element = lastVideo();
|
|
99
|
+
element.dispatchEvent(new Event('canplaythrough'));
|
|
100
|
+
const resource = await promise;
|
|
101
|
+
expect(resource.element).toBe(element);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('rejects when the element emits an error', async () => {
|
|
105
|
+
const promise = loadVideoResourceFromUrl('bad.mp4');
|
|
106
|
+
lastVideo().dispatchEvent(new Event('error'));
|
|
107
|
+
await expect(promise).rejects.toThrow('Failed to load video: bad.mp4');
|
|
108
|
+
});
|
|
109
|
+
|
|
10
110
|
it('rejects immediately when the signal is already aborted', async () => {
|
|
11
111
|
const controller = new AbortController();
|
|
12
|
-
|
|
13
|
-
controller.
|
|
14
|
-
await expect(loadVideoResourceFromUrl('test.mp4', controller.signal)).rejects.toThrow('pre-aborted');
|
|
112
|
+
controller.abort(new Error('pre-aborted'));
|
|
113
|
+
await expect(loadVideoResourceFromUrl('test.mp4', undefined, controller.signal)).rejects.toThrow('pre-aborted');
|
|
15
114
|
});
|
|
16
115
|
|
|
17
116
|
it('rejects when the signal is aborted after the call', async () => {
|
|
18
117
|
const controller = new AbortController();
|
|
19
|
-
const promise = loadVideoResourceFromUrl('test.mp4', controller.signal);
|
|
118
|
+
const promise = loadVideoResourceFromUrl('test.mp4', undefined, controller.signal);
|
|
20
119
|
controller.abort(new Error('cancelled'));
|
|
21
120
|
await expect(promise).rejects.toThrow('cancelled');
|
|
22
121
|
});
|
|
@@ -28,8 +127,17 @@ describe('loadVideoResourceFromUrls', () => {
|
|
|
28
127
|
expect(resource.element).toBeNull();
|
|
29
128
|
});
|
|
30
129
|
|
|
31
|
-
it('resolves to a null-element resource when no source
|
|
130
|
+
it('resolves to a null-element resource when no source is playable in jsdom', async () => {
|
|
32
131
|
const resource = await loadVideoResourceFromUrls([{ url: 'test.mp4' }]);
|
|
33
132
|
expect(resource.element).toBeNull();
|
|
34
133
|
});
|
|
134
|
+
|
|
135
|
+
it('loads the first playable source', async () => {
|
|
136
|
+
vi.spyOn(HTMLVideoElement.prototype, 'canPlayType').mockReturnValue('probably');
|
|
137
|
+
const promise = loadVideoResourceFromUrls([{ url: 'clip.mp4' }]);
|
|
138
|
+
const element = lastVideo();
|
|
139
|
+
element.dispatchEvent(new Event('canplay'));
|
|
140
|
+
const resource = await promise;
|
|
141
|
+
expect(resource.element).toBe(element);
|
|
142
|
+
});
|
|
35
143
|
});
|