Skip to content
This repository was archived by the owner on Apr 18, 2024. It is now read-only.

Commit b00b0a3

Browse files
authored
fix: LSDV-4801: Better file type playable detection for video (#1259)
* fix: LSDV-4801: Better file type playable detection for video * adding unit test for VirtualVideo filetype detection * fixing cache invalidation of urls, to not operate on signed urls as it will invalidate the signature * fix preview loading * fix audio resolving in tests
1 parent 427266e commit b00b0a3

File tree

8 files changed

+489
-29
lines changed

8 files changed

+489
-29
lines changed

Diff for: e2e/tests/helpers.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ const waitForAudio = async () => {
176176

177177
await Promise.all(
178178
[...audios].map(audio => {
179-
if (audio.readyState === 4) return true;
179+
if (audio.readyState === 4) return Promise.resolve(true);
180180
return new Promise(resolve => {
181181
audio.addEventListener('durationchange', () => {
182182
resolve(true);

Diff for: jest.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ module.exports = {
55
'<rootDir>/src',
66
],
77
'preset': 'ts-jest',
8+
'setupFilesAfterEnv': ['./jest.setup.js'],
89
'testEnvironment': 'jsdom',
910
'verbose': false,
1011
'collectCoverageFrom': [

Diff for: jest.setup.js

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
require('jest-fetch-mock').enableMocks();
2+
3+
// Mock HTMLMediaElement data and methods not implemented by jsdom.
4+
window.HTMLMediaElement.prototype._mock = {
5+
paused: true,
6+
duration: NaN,
7+
_loaded: false,
8+
// Emulates the media file loading
9+
_load: function mediaInit(media) {
10+
media.dispatchEvent(new Event('loadedmetadata'));
11+
media.dispatchEvent(new Event('loadeddata'));
12+
media.dispatchEvent(new Event('canplaythrough'));
13+
},
14+
// Reset to the initial state
15+
_resetMock: function resetMock(media) {
16+
media._mock = Object.assign(
17+
{},
18+
window.HTMLMediaElement.prototype._mock,
19+
);
20+
},
21+
_supportsTypes: [
22+
'video/mp4', 'video/webm', 'video/ogg',
23+
'audio/mp3', 'audio/webm', 'audio/ogg', 'audio/wav',
24+
],
25+
};
26+
27+
// Get "paused" value, it is automatically set to true / false when we play / pause the media.
28+
Object.defineProperty(window.HTMLMediaElement.prototype, 'paused', {
29+
get() {
30+
return this._mock.paused;
31+
},
32+
});
33+
34+
// Get and set media duration
35+
Object.defineProperty(window.HTMLMediaElement.prototype, 'duration', {
36+
get() {
37+
return this._mock.duration;
38+
},
39+
set(value) {
40+
// Reset the mock state to initial (paused) when we set the duration.
41+
this._mock._resetMock(this);
42+
this._mock.duration = value;
43+
},
44+
});
45+
46+
// Load the media file
47+
window.HTMLMediaElement.prototype.load = function loadMock() {
48+
if (!this._mock._loaded) {
49+
// emulate the media file load and metadata initialization
50+
this._mock._load(this);
51+
}
52+
this.dispatchEvent(new Event('load'));
53+
};
54+
55+
// Start the playback.
56+
window.HTMLMediaElement.prototype.play = function playMock() {
57+
if (!this._mock._loaded) {
58+
// emulate the media file load and metadata initialization
59+
this._mock._load(this);
60+
}
61+
this._mock.paused = false;
62+
this.dispatchEvent(new Event('play'));
63+
};
64+
65+
// Pause the playback
66+
window.HTMLMediaElement.prototype.pause = function pauseMock() {
67+
this._mock.paused = true;
68+
this.dispatchEvent(new Event('pause'));
69+
};
70+
71+
// Can play the media file
72+
window.HTMLMediaElement.prototype.canPlayType = function canPlayTypeMock(type) {
73+
return this._mock._supportsTypes.includes(type) ? 'maybe' : '';
74+
};

Diff for: package.json

+2
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@
112112
"@babel/runtime": "7.18.6",
113113
"@heartexlabs/eslint-plugin-frontend": "https://github.com/heartexlabs/eslint-plugin-frontend.git",
114114
"@svgr/webpack": "^5.5.0",
115+
"@testing-library/react": "12.1.2",
115116
"@types/chroma-js": "^2.1.3",
116117
"@types/enzyme": "^3.10.12",
117118
"@types/jest": "^29.2.3",
@@ -149,6 +150,7 @@
149150
"insert-after": "^0.1.4",
150151
"jest": "^29.3.1",
151152
"jest-environment-jsdom": "^29.3.1",
153+
"jest-fetch-mock": "^3.0.3",
152154
"jsdoc-to-markdown": "^8",
153155
"keymaster": "*",
154156
"konva": "^8.1.3",

Diff for: src/components/VideoCanvas/VirtualVideo.tsx

+56-13
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,58 @@ type VirtualVideoProps = DetailedHTMLProps<VideoHTMLAttributes<HTMLVideoElement>
88

99
const DEBUG_MODE = false;
1010

11-
const canPlayUrl = async (url: string) => {
11+
// Just a mapping of file types to mime types, so we can check if the browser can play the file
12+
// before having to fall back to using a fetch request.
13+
const mimeTypeMapping = {
14+
// Supported
15+
'mp4': 'video/mp4',
16+
'mp4v': 'video/mp4',
17+
'mpg4': 'video/mp4',
18+
19+
'ogg': 'video/ogg',
20+
'ogv': 'video/ogg',
21+
'ogm': 'video/ogg',
22+
'ogx': 'video/ogg',
23+
24+
// Partially supported
25+
'webm': 'video/webm',
26+
27+
// Unsupported
28+
'avi': 'video/avi',
29+
'mov': 'video/quicktime',
30+
'qt': 'video/quicktime',
31+
};
32+
33+
const isBinary = (mimeType: string|null|undefined) => {
34+
if (!mimeType) {
35+
return false;
36+
}
37+
38+
return mimeType.includes('octet-stream');
39+
};
40+
41+
export const canPlayUrl = async (url: string) => {
1242
const video = document.createElement('video');
1343

14-
const fileMeta = await fetch(url, {
15-
method: 'GET',
16-
headers: {
17-
'Range': 'bytes=0-0',
18-
},
19-
});
44+
const pathName = new URL(url, /^https?/.exec(url) ? undefined : window.location.href).pathname;
2045

21-
const fileType = fileMeta.headers.get('content-type');
22-
const supported = !!fileType && video.canPlayType(fileType) !== '';
46+
const fileType = (pathName.split('.').pop() ?? '') as keyof typeof mimeTypeMapping;
47+
48+
let fileMimeType: string|null|undefined = mimeTypeMapping[fileType];
49+
50+
if (!fileMimeType) {
51+
const fileMeta = await fetch(url, {
52+
method: 'GET',
53+
headers: {
54+
'Range': 'bytes=0-0',
55+
},
56+
});
57+
58+
fileMimeType = fileMeta.headers.get('content-type');
59+
}
60+
61+
// If the file is binary, we can't check if the browser can play it, so we just assume it can.
62+
const supported = isBinary(fileMimeType) || (!!fileMimeType && video.canPlayType(fileMimeType) !== '');
2363
const modalExists = document.querySelector('.ant-modal');
2464

2565
if (!supported && !modalExists) InfoModal.error('There has been an error rendering your video, please check the format is supported');
@@ -140,11 +180,14 @@ export const VirtualVideo = forwardRef<HTMLVideoElement, VirtualVideoProps>((pro
140180
useEffect(() => {
141181
createVideoElement();
142182
attachEventListeners();
143-
canPlayType(props.src ?? '');
144-
attachSource();
145-
attachRef(video.current);
183+
canPlayType(props.src ?? '').then((canPlay) => {
184+
if (canPlay) {
185+
attachSource();
186+
attachRef(video.current);
146187

147-
document.body.append(video.current!);
188+
document.body.append(video.current!);
189+
}
190+
});
148191
}, []);
149192

150193
// Handle video cleanup
+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
2+
import { render } from '@testing-library/react';
3+
import fetchMock from 'jest-fetch-mock';
4+
import * as VirtualVideo from '../VirtualVideo';
5+
6+
7+
describe('VirtualVideo', () => {
8+
it('should call canPlayUrl and return false if no url specified', async () => {
9+
const canPlayType = jest.fn();
10+
11+
render(<VirtualVideo.VirtualVideo canPlayType={canPlayType} />);
12+
13+
await new Promise(resolve => setTimeout(resolve, 10));
14+
15+
expect(canPlayType).toBeCalledWith(false);
16+
});
17+
18+
it('should call canPlayUrl and return true if valid url specified', async () => {
19+
const canPlayType = jest.fn();
20+
21+
render(<VirtualVideo.VirtualVideo src="https://app.heartex.ai/static/samples/opossum_snow.mp4" canPlayType={canPlayType} />);
22+
23+
await new Promise(resolve => setTimeout(resolve, 10));
24+
25+
expect(canPlayType).toBeCalledWith(true);
26+
});
27+
28+
it('should call canPlayUrl and return true if valid relative url specified', async () => {
29+
const canPlayType = jest.fn();
30+
31+
render(<VirtualVideo.VirtualVideo src="/files/opossum_intro.webm" canPlayType={canPlayType} />);
32+
33+
await new Promise(resolve => setTimeout(resolve, 10));
34+
35+
expect(canPlayType).toBeCalledWith(true);
36+
});
37+
38+
it('should call canPlayUrl and return true if valid url specified, even if content-type is binary/octet-stream', async () => {
39+
const canPlayType = jest.fn();
40+
41+
// return binary/octet-stream for all requests, mimicking the situation where
42+
// the server doesn't set the content-type header and defaults to binary/octet-stream
43+
fetchMock.mockResponseOnce('', {
44+
headers: {
45+
'content-type': 'binary/octet-stream',
46+
},
47+
});
48+
49+
render(<VirtualVideo.VirtualVideo src="https://app.heartex.ai/static/samples/opossum_snow.mp4" canPlayType={canPlayType} />);
50+
51+
await new Promise(resolve => setTimeout(resolve, 10));
52+
53+
expect(canPlayType).toBeCalledWith(true);
54+
});
55+
56+
it('should call canPlayUrl and return true if valid file is specified, and content-type is binary/octet-stream but no file extension', async () => {
57+
const canPlayType = jest.fn();
58+
59+
// return binary/octet-stream for all requests, mimicking the situation where
60+
// the server doesn't set the content-type header and defaults to binary/octet-stream
61+
fetchMock.mockResponseOnce('', {
62+
headers: {
63+
'content-type': 'binary/octet-stream',
64+
},
65+
});
66+
67+
render(<VirtualVideo.VirtualVideo src="https://app.heartex.ai/static/samples/opossum_snow" canPlayType={canPlayType} />);
68+
69+
await new Promise(resolve => setTimeout(resolve, 10));
70+
71+
expect(canPlayType).toBeCalledWith(true);
72+
});
73+
74+
it('should call canPlayUrl and return false if invalid url specified', async () => {
75+
const canPlayType = jest.fn();
76+
77+
render(<VirtualVideo.VirtualVideo src="https://app.heartex.ai/static/samples/opossum_snow.avi" canPlayType={canPlayType} />);
78+
79+
await new Promise(resolve => setTimeout(resolve, 10));
80+
81+
expect(canPlayType).toBeCalledWith(false);
82+
});
83+
84+
it('should call canPlayUrl and return false if invalid url specified, even if content-type is binary/octet-stream', async () => {
85+
const canPlayType = jest.fn();
86+
87+
// return binary/octet-stream for all requests, mimicking the situation where
88+
// the server doesn't set the content-type header and defaults to binary/octet-stream
89+
fetchMock.mockResponseOnce('', {
90+
headers: {
91+
'content-type': 'binary/octet-stream',
92+
},
93+
});
94+
95+
render(<VirtualVideo.VirtualVideo src="https://app.heartex.ai/static/samples/opossum_snow.avi" canPlayType={canPlayType} />);
96+
97+
await new Promise(resolve => setTimeout(resolve, 10));
98+
99+
expect(canPlayType).toBeCalledWith(false);
100+
});
101+
});

Diff for: src/lib/AudioUltra/Media/MediaLoader.ts

+14-4
Original file line numberDiff line numberDiff line change
@@ -188,10 +188,20 @@ export class MediaLoader extends Destructable {
188188
// Handle relative urls, by converting them to absolute so any query params can be preserved
189189
const newUrl = new URL(url, /^https?/.exec(url) ? undefined : window.location.href);
190190

191-
// Arbitrary setting of query param to stop caching from reusing any media requests which may have less headers
192-
// cached than this request. This is to prevent a CORS error when the headers are different between partial
193-
// content and full content requests.
194-
newUrl.searchParams.set('lsref', '1');
191+
const signedUrlParams = [
192+
'X-Goog-Signature', // Google Cloud Storage
193+
'X-Amz-Signature', // S3|Minio|DigitalOcean|Backblaze
194+
'sig', // Azure
195+
];
196+
197+
// If the url is signed, we need to preserve the query params otherwise the signature will be invalid
198+
if (!signedUrlParams.some(p => newUrl.searchParams.has(p))) {
199+
// Arbitrary setting of query param to stop caching from reusing any media requests which may have less headers
200+
// cached than this request. This is to prevent a CORS error when the headers are different between partial
201+
// content and full content requests.
202+
newUrl.searchParams.set('lsref', '1');
203+
}
204+
195205

196206
xhr.open('GET', newUrl.toString(), true);
197207
xhr.send();

0 commit comments

Comments
 (0)