@flighthq/video 0.3.0-next.906.07cea63 → 0.3.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/videoResource.d.ts +1 -1
- package/dist/videoResource.d.ts.map +1 -1
- package/dist/videoResource.js +15 -4
- package/dist/videoResource.js.map +1 -1
- package/dist/videoResourceFrom.d.ts.map +1 -1
- package/dist/videoResourceFrom.js +21 -6
- package/dist/videoResourceFrom.js.map +1 -1
- package/package.json +4 -2
- package/src/videoResource.test.ts +54 -0
- package/src/videoResourceFrom.test.ts +56 -4
package/dist/videoResource.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { VideoResource } from '@flighthq/types/contract';
|
|
2
|
-
export declare function createVideoResource(element?: HTMLVideoElement): VideoResource;
|
|
2
|
+
export declare function createVideoResource(element?: HTMLVideoElement, objectUrl?: string): VideoResource;
|
|
3
3
|
export declare function disposeVideoResource(resource: VideoResource): void;
|
|
4
4
|
export declare function getVideoResourceDuration(resource: Readonly<VideoResource>): number;
|
|
5
5
|
export declare function getVideoResourceHeight(resource: Readonly<VideoResource>): number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"videoResource.d.ts","sourceRoot":"","sources":["../src/videoResource.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"videoResource.d.ts","sourceRoot":"","sources":["../src/videoResource.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAO9D,wBAAgB,mBAAmB,CAAC,OAAO,CAAC,EAAE,gBAAgB,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,aAAa,CAEjG;AAQD,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,aAAa,GAAG,IAAI,CAalE;AAID,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,MAAM,CAGlF;AAED,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,MAAM,CAGhF;AAED,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,MAAM,CAG/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,18 +1,29 @@
|
|
|
1
1
|
// No cloneVideoResource: a VideoResource is a thin carrier over a single HTMLVideoElement, and an
|
|
2
2
|
// element cannot be duplicated (each carries its own decoder and playback position). Wrap the same
|
|
3
3
|
// element in a second createVideoResource call if two carriers over one stream are truly wanted.
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
// `objectUrl` transfers ownership of a blob object URL to the resource, so disposal revokes it. Pass
|
|
5
|
+
// it only for a URL this resource should own — a caller-managed URL stays the caller's to revoke.
|
|
6
|
+
export function createVideoResource(element, objectUrl) {
|
|
7
|
+
return { element: element ?? null, objectUrl: objectUrl ?? null };
|
|
6
8
|
}
|
|
7
9
|
// 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.
|
|
9
|
-
//
|
|
10
|
+
// resource so the browser can free its buffers, then the element reference is dropped for GC. Also
|
|
11
|
+
// revokes an owned object URL, whose blob-store entry is what keeps the underlying Blob reachable —
|
|
12
|
+
// the revoke belongs here, at end of life, because the element fetches from that URL for as long as it
|
|
13
|
+
// plays. Both are dispose*, not destroy*: each releases a reference so memory becomes GC-eligible,
|
|
14
|
+
// rather than freeing a non-GC handle.
|
|
10
15
|
export function disposeVideoResource(resource) {
|
|
11
16
|
const element = resource.element;
|
|
12
17
|
if (element !== null) {
|
|
13
18
|
element.removeAttribute('src');
|
|
14
19
|
element.load();
|
|
15
20
|
}
|
|
21
|
+
// Revoked after the element has let go of the src, so the media resource is never detached from a
|
|
22
|
+
// URL the element is still reading through.
|
|
23
|
+
if (resource.objectUrl !== null) {
|
|
24
|
+
URL.revokeObjectURL(resource.objectUrl);
|
|
25
|
+
resource.objectUrl = null;
|
|
26
|
+
}
|
|
16
27
|
resource.element = null;
|
|
17
28
|
}
|
|
18
29
|
// Duration in seconds of the loaded media, or 0 when no element is attached. May be NaN before
|
|
@@ -1 +1 @@
|
|
|
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;
|
|
1
|
+
{"version":3,"file":"videoResource.js","sourceRoot":"","sources":["../src/videoResource.ts"],"names":[],"mappings":"AAEA,kGAAkG;AAClG,mGAAmG;AACnG,iGAAiG;AACjG,qGAAqG;AACrG,kGAAkG;AAClG,MAAM,UAAU,mBAAmB,CAAC,OAA0B,EAAE,SAAkB;IAChF,OAAO,EAAE,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,SAAS,EAAE,SAAS,IAAI,IAAI,EAAE,CAAC;AACpE,CAAC;AAED,iGAAiG;AACjG,mGAAmG;AACnG,oGAAoG;AACpG,uGAAuG;AACvG,mGAAmG;AACnG,uCAAuC;AACvC,MAAM,UAAU,oBAAoB,CAAC,QAAuB;IAC1D,MAAM,OAAO,GAAG,QAAQ,CAAC,OAAkC,CAAC;IAC5D,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;QACrB,OAAO,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;QAC/B,OAAO,CAAC,IAAI,EAAE,CAAC;IACjB,CAAC;IACD,kGAAkG;IAClG,4CAA4C;IAC5C,IAAI,QAAQ,CAAC,SAAS,KAAK,IAAI,EAAE,CAAC;QAChC,GAAG,CAAC,eAAe,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;QACxC,QAAQ,CAAC,SAAS,GAAG,IAAI,CAAC;IAC5B,CAAC;IACD,QAAQ,CAAC,OAAO,GAAG,IAAI,CAAC;AAC1B,CAAC;AAED,+FAA+F;AAC/F,sGAAsG;AACtG,MAAM,UAAU,wBAAwB,CAAC,QAAiC;IACxE,MAAM,OAAO,GAAG,QAAQ,CAAC,OAAkC,CAAC;IAC5D,OAAO,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;AACjD,CAAC;AAED,MAAM,UAAU,sBAAsB,CAAC,QAAiC;IACtE,MAAM,OAAO,GAAG,QAAQ,CAAC,OAAkC,CAAC;IAC5D,OAAO,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC;AACpD,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,QAAiC;IACrE,MAAM,OAAO,GAAG,QAAQ,CAAC,OAAkC,CAAC;IAC5D,OAAO,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;AACnD,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,OAAkC,CAAC;IAC5D,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,OAAkC,CAAC;IAC5D,OAAO,OAAO,KAAK,IAAI,IAAI,OAAO,CAAC,UAAU,IAAI,iBAAiB,CAAC;AACrE,CAAC;AAED,4FAA4F;AAC5F,MAAM,iBAAiB,GAAG,CAAC,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
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,0BAA0B,CAAC;AAO1G,wBAAgB,kCAAkC,CAAC,MAAM,EAAE,WAAW,GAAG,aAAa,CAIrF;
|
|
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,0BAA0B,CAAC;AAO1G,wBAAgB,kCAAkC,CAAC,MAAM,EAAE,WAAW,GAAG,aAAa,CAIrF;AAUD,wBAAsB,yBAAyB,CAC7C,IAAI,EAAE,IAAI,EACV,OAAO,CAAC,EAAE,QAAQ,CAAC,wBAAwB,CAAC,EAC5C,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAWxB;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,CA4CxB;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,5 +1,5 @@
|
|
|
1
1
|
import { selectVideoResourceUrl } from './videoFormat';
|
|
2
|
-
import { createVideoResource } from './videoResource';
|
|
2
|
+
import { createVideoResource, disposeVideoResource } from './videoResource';
|
|
3
3
|
// Wraps a live MediaStream (camera, screen capture, canvas.captureStream) as a video resource by
|
|
4
4
|
// assigning it to element.srcObject. Pure DOM, no load — the stream feeds frames as they arrive.
|
|
5
5
|
export function createVideoResourceFromMediaStream(stream) {
|
|
@@ -7,16 +7,26 @@ export function createVideoResourceFromMediaStream(stream) {
|
|
|
7
7
|
element.srcObject = stream;
|
|
8
8
|
return createVideoResource(element);
|
|
9
9
|
}
|
|
10
|
-
// Loads from a Blob by wrapping it in an object URL
|
|
11
|
-
//
|
|
10
|
+
// Loads from a Blob by wrapping it in an object URL, which the returned resource then owns: pass it to
|
|
11
|
+
// disposeVideoResource to revoke it. The revoke deliberately does not happen when the load settles —
|
|
12
|
+
// settling only means the readiness event fired, and the element keeps fetching from the URL while it
|
|
13
|
+
// plays and re-fetches on seek, so revoking there breaks playback of a video that just reported ready
|
|
14
|
+
// (most starkly under `readiness: 'metadata'`, where only the container header has been read).
|
|
15
|
+
//
|
|
16
|
+
// A failed load is the one case this function still revokes, because it returns no resource for the
|
|
17
|
+
// caller to dispose, so nothing else could ever release the URL.
|
|
12
18
|
export async function loadVideoResourceFromBlob(blob, options, signal) {
|
|
13
19
|
const url = URL.createObjectURL(blob);
|
|
20
|
+
let resource;
|
|
14
21
|
try {
|
|
15
|
-
|
|
22
|
+
resource = await loadVideoResourceFromUrl(url, options, signal);
|
|
16
23
|
}
|
|
17
|
-
|
|
24
|
+
catch (error) {
|
|
18
25
|
URL.revokeObjectURL(url);
|
|
26
|
+
throw error;
|
|
19
27
|
}
|
|
28
|
+
resource.objectUrl = url;
|
|
29
|
+
return resource;
|
|
20
30
|
}
|
|
21
31
|
export function loadVideoResourceFromUrl(url, options, signal) {
|
|
22
32
|
if (signal?.aborted)
|
|
@@ -36,13 +46,18 @@ export function loadVideoResourceFromUrl(url, options, signal) {
|
|
|
36
46
|
cleanup();
|
|
37
47
|
resolve(createVideoResource(element));
|
|
38
48
|
};
|
|
49
|
+
// Both rejection paths abandon an element this function created, so both owe it the same decoder
|
|
50
|
+
// release — routed through disposeVideoResource so the sequence has exactly one home. Assigning
|
|
51
|
+
// element.src = '' is not that sequence: an empty src resolves against the document base URL, so
|
|
52
|
+
// the element goes on to fetch the *page* as media, and the decoder is never detached.
|
|
39
53
|
const onError = () => {
|
|
40
54
|
cleanup();
|
|
55
|
+
disposeVideoResource(createVideoResource(element));
|
|
41
56
|
reject(new Error(`Failed to load video: ${url}`));
|
|
42
57
|
};
|
|
43
58
|
const onAbort = () => {
|
|
44
59
|
cleanup();
|
|
45
|
-
element
|
|
60
|
+
disposeVideoResource(createVideoResource(element));
|
|
46
61
|
reject(signal.reason);
|
|
47
62
|
};
|
|
48
63
|
const cleanup = () => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"videoResourceFrom.js","sourceRoot":"","sources":["../src/videoResourceFrom.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,sBAAsB,EAAE,MAAM,eAAe,CAAC;AACvD,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"videoResourceFrom.js","sourceRoot":"","sources":["../src/videoResourceFrom.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flighthq/video",
|
|
3
|
-
"version": "0.3.0
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"author": "Joshua Granick and other contributors",
|
|
5
|
+
"license": "MIT",
|
|
4
6
|
"repository": {
|
|
5
7
|
"type": "git",
|
|
6
8
|
"url": "https://github.com/flighthq/flight.git",
|
|
@@ -36,7 +38,7 @@
|
|
|
36
38
|
"clean:dist": "tsx ../../scripts/clean-package-dist.ts"
|
|
37
39
|
},
|
|
38
40
|
"dependencies": {
|
|
39
|
-
"@flighthq/types": "0.3.0
|
|
41
|
+
"@flighthq/types": "0.3.0"
|
|
40
42
|
},
|
|
41
43
|
"devDependencies": {
|
|
42
44
|
"typescript": "^5.3.0"
|
|
@@ -9,6 +9,12 @@ import {
|
|
|
9
9
|
isVideoResourceReady,
|
|
10
10
|
} from './videoResource';
|
|
11
11
|
|
|
12
|
+
// vi.spyOn hands back the *existing* spy when a method is already spied, so without this the URL
|
|
13
|
+
// spies below share one call history and every count assertion reads the previous test's calls too.
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
12
18
|
describe('createVideoResource', () => {
|
|
13
19
|
it('returns a resource with null element when called with no arguments', () => {
|
|
14
20
|
const resource = createVideoResource();
|
|
@@ -41,6 +47,54 @@ describe('disposeVideoResource', () => {
|
|
|
41
47
|
disposeVideoResource(resource);
|
|
42
48
|
expect(resource.element).toBeNull();
|
|
43
49
|
});
|
|
50
|
+
|
|
51
|
+
it('revokes an owned object URL and clears it', () => {
|
|
52
|
+
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
|
53
|
+
const element = { removeAttribute: vi.fn(), load: vi.fn() } as unknown as HTMLVideoElement;
|
|
54
|
+
const resource = createVideoResource(element, 'blob:owned');
|
|
55
|
+
|
|
56
|
+
disposeVideoResource(resource);
|
|
57
|
+
|
|
58
|
+
expect(revokeSpy).toHaveBeenCalledWith('blob:owned');
|
|
59
|
+
expect(resource.objectUrl).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Revoking is what makes the Blob GC-eligible, so it must survive a second disposal without
|
|
63
|
+
// double-revoking a URL the resource no longer owns.
|
|
64
|
+
it('does not revoke again when disposed twice', () => {
|
|
65
|
+
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
|
66
|
+
const element = { removeAttribute: vi.fn(), load: vi.fn() } as unknown as HTMLVideoElement;
|
|
67
|
+
const resource = createVideoResource(element, 'blob:owned');
|
|
68
|
+
|
|
69
|
+
disposeVideoResource(resource);
|
|
70
|
+
disposeVideoResource(resource);
|
|
71
|
+
|
|
72
|
+
expect(revokeSpy).toHaveBeenCalledTimes(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// A resource built over a URL the caller manages must not have it revoked out from under them.
|
|
76
|
+
it('revokes nothing when the resource owns no object URL', () => {
|
|
77
|
+
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
|
78
|
+
const element = { removeAttribute: vi.fn(), load: vi.fn() } as unknown as HTMLVideoElement;
|
|
79
|
+
|
|
80
|
+
disposeVideoResource(createVideoResource(element));
|
|
81
|
+
|
|
82
|
+
expect(revokeSpy).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// The element must let go of the src before the URL behind it is revoked, not after.
|
|
86
|
+
it('revokes the object URL only after the element has released its src', () => {
|
|
87
|
+
const order: string[] = [];
|
|
88
|
+
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => void order.push('revoke'));
|
|
89
|
+
const element = {
|
|
90
|
+
removeAttribute: vi.fn(() => void order.push('removeAttribute')),
|
|
91
|
+
load: vi.fn(() => void order.push('load')),
|
|
92
|
+
} as unknown as HTMLVideoElement;
|
|
93
|
+
|
|
94
|
+
disposeVideoResource(createVideoResource(element, 'blob:owned'));
|
|
95
|
+
|
|
96
|
+
expect(order).toEqual(['removeAttribute', 'load', 'revoke']);
|
|
97
|
+
});
|
|
44
98
|
});
|
|
45
99
|
|
|
46
100
|
describe('getVideoResourceDuration', () => {
|
|
@@ -36,16 +36,34 @@ describe('createVideoResourceFromMediaStream', () => {
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
describe('loadVideoResourceFromBlob', () => {
|
|
39
|
-
|
|
39
|
+
// The load settling is not the end of the URL's life — the element fetches through it for as long as
|
|
40
|
+
// it plays. So the invariant is that the resource comes back still playable, holding a live URL it
|
|
41
|
+
// now owns; an earlier version revoked here and returned a resource whose source was already gone.
|
|
42
|
+
it('returns a resource still holding its live object URL after the load settles', async () => {
|
|
40
43
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
|
41
44
|
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL');
|
|
42
45
|
const promise = loadVideoResourceFromBlob(new Blob([], { type: 'video/mp4' }));
|
|
43
46
|
lastVideo().dispatchEvent(new Event('canplay'));
|
|
44
|
-
await promise;
|
|
45
|
-
expect(
|
|
47
|
+
const resource = await promise;
|
|
48
|
+
expect(resource.objectUrl).toBe('blob:mock');
|
|
49
|
+
expect(revokeSpy).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Readiness 'metadata' is the starkest case: only the container header has been read, so every byte
|
|
53
|
+
// of media data is still to come through the URL.
|
|
54
|
+
it('keeps the object URL live when the load settles at metadata readiness', async () => {
|
|
55
|
+
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
|
56
|
+
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL');
|
|
57
|
+
const promise = loadVideoResourceFromBlob(new Blob([], { type: 'video/mp4' }), { readiness: 'metadata' });
|
|
58
|
+
lastVideo().dispatchEvent(new Event('loadedmetadata'));
|
|
59
|
+
const resource = await promise;
|
|
60
|
+
expect(resource.objectUrl).toBe('blob:mock');
|
|
61
|
+
expect(revokeSpy).not.toHaveBeenCalled();
|
|
46
62
|
});
|
|
47
63
|
|
|
48
|
-
|
|
64
|
+
// The failure path is the one place this function must still revoke: it returns no resource, so
|
|
65
|
+
// nothing else can ever own the URL.
|
|
66
|
+
it('revokes the object URL when the load fails, since no resource is returned to own it', async () => {
|
|
49
67
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
|
50
68
|
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL');
|
|
51
69
|
const promise = loadVideoResourceFromBlob(new Blob([], { type: 'video/mp4' }));
|
|
@@ -53,6 +71,16 @@ describe('loadVideoResourceFromBlob', () => {
|
|
|
53
71
|
await expect(promise).rejects.toThrow('Failed to load video');
|
|
54
72
|
expect(revokeSpy).toHaveBeenCalledWith('blob:mock');
|
|
55
73
|
});
|
|
74
|
+
|
|
75
|
+
it('revokes the object URL when the load is aborted, since no resource is returned to own it', async () => {
|
|
76
|
+
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
|
77
|
+
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL');
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const promise = loadVideoResourceFromBlob(new Blob([], { type: 'video/mp4' }), undefined, controller.signal);
|
|
80
|
+
controller.abort(new Error('cancelled'));
|
|
81
|
+
await expect(promise).rejects.toThrow('cancelled');
|
|
82
|
+
expect(revokeSpy).toHaveBeenCalledWith('blob:mock');
|
|
83
|
+
});
|
|
56
84
|
});
|
|
57
85
|
|
|
58
86
|
describe('loadVideoResourceFromUrl', () => {
|
|
@@ -119,6 +147,30 @@ describe('loadVideoResourceFromUrl', () => {
|
|
|
119
147
|
controller.abort(new Error('cancelled'));
|
|
120
148
|
await expect(promise).rejects.toThrow('cancelled');
|
|
121
149
|
});
|
|
150
|
+
|
|
151
|
+
// The two rejection paths are the only ones that abandon an element the loader created; on success
|
|
152
|
+
// the caller takes ownership. Both are covered because a release sequence that is right on one
|
|
153
|
+
// abandonment path and absent on the other still leaks a decoder.
|
|
154
|
+
it('detaches the src and reloads the element it abandons when the load fails', async () => {
|
|
155
|
+
const promise = loadVideoResourceFromUrl('bad.mp4');
|
|
156
|
+
const element = lastVideo();
|
|
157
|
+
const loadSpy = vi.spyOn(element, 'load').mockImplementation(() => {});
|
|
158
|
+
element.dispatchEvent(new Event('error'));
|
|
159
|
+
await expect(promise).rejects.toThrow('Failed to load video');
|
|
160
|
+
expect(element.hasAttribute('src')).toBe(false);
|
|
161
|
+
expect(loadSpy).toHaveBeenCalledTimes(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('detaches the src and reloads the element it abandons when the signal is aborted', async () => {
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
const promise = loadVideoResourceFromUrl('clip.mp4', undefined, controller.signal);
|
|
167
|
+
const element = lastVideo();
|
|
168
|
+
const loadSpy = vi.spyOn(element, 'load').mockImplementation(() => {});
|
|
169
|
+
controller.abort(new Error('cancelled'));
|
|
170
|
+
await expect(promise).rejects.toThrow('cancelled');
|
|
171
|
+
expect(element.hasAttribute('src')).toBe(false);
|
|
172
|
+
expect(loadSpy).toHaveBeenCalledTimes(1);
|
|
173
|
+
});
|
|
122
174
|
});
|
|
123
175
|
|
|
124
176
|
describe('loadVideoResourceFromUrls', () => {
|