Skip to content

Commit eebb030

Browse files
authored
Merge pull request #16 from diffusionstudio/konstantin/fix/load-partial-video
implemented and tested partial video loading
2 parents 4ef2c45 + 1909961 commit eebb030

File tree

8 files changed

+183
-74
lines changed

8 files changed

+183
-74
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@diffusionstudio/core",
33
"private": false,
4-
"version": "1.0.0-rc.6",
4+
"version": "1.0.0-rc.7",
55
"type": "module",
66
"description": "Build bleeding edge video processing applications",
77
"files": [

src/clips/utils/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import type { MimeType } from '../../types';
1515
* @param mimeType The mimetype to check
1616
* @returns A valid mimetype
1717
*/
18-
export function parseMimeType(mimeType: string): MimeType {
19-
if (!Object.keys(SUPPORTED_MIME_TYPES.MIXED).includes(mimeType)) {
18+
export function parseMimeType(mimeType?: string | null): MimeType {
19+
if (!Object.keys(SUPPORTED_MIME_TYPES.MIXED).includes(mimeType ?? '')) {
2020
throw new errors.ValidationError({
2121
message: `${mimeType} is not an accepted mime type`,
2222
code: 'invalid_mimetype',

src/clips/video/video.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export class VideoClip extends VisualMixin(MediaClip<VideoClipProps>) {
5151
this.element.controls = false;
5252
this.element.playsInline = true;
5353
this.element.style.display = 'hidden';
54+
this.element.crossOrigin = "anonymous";
5455

5556
(this.textrues.html5.source as any).autoPlay = false;
5657
(this.textrues.html5.source as any).loop = false;
@@ -140,7 +141,7 @@ export class VideoClip extends VisualMixin(MediaClip<VideoClipProps>) {
140141

141142
public async seek(time: Timestamp): Promise<void> {
142143
if (this.track?.composition?.rendering) {
143-
const buffer = this.decodeVideo();
144+
const buffer = await this.decodeVideo();
144145
return new Promise<void>((resolve) => {
145146
buffer.onenqueue = () => resolve();
146147
});
@@ -149,7 +150,7 @@ export class VideoClip extends VisualMixin(MediaClip<VideoClipProps>) {
149150
return super.seek(time);
150151
}
151152

152-
private decodeVideo() {
153+
private async decodeVideo() {
153154
this.buffer = new FrameBuffer();
154155
this.worker = new DecodeWorker();
155156

@@ -165,7 +166,7 @@ export class VideoClip extends VisualMixin(MediaClip<VideoClipProps>) {
165166

166167
this.worker.postMessage({
167168
type: 'init',
168-
file: this.source.file!,
169+
file: await this.source.getFile(),
169170
range: this.demuxRange,
170171
fps: this.track?.composition?.fps ?? FPS_DEFAULT,
171172
} satisfies InitMessageData);

src/sources/html.ts

Lines changed: 19 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77

88
import { Source } from './source';
99
import { documentToSvgImageUrl } from './html.utils';
10-
import { parseMimeType } from '../clips';
11-
import { IOError } from '../errors';
1210

1311
import type { ClipType } from '../clips';
1412

@@ -57,47 +55,26 @@ export class HtmlSource extends Source {
5755
return this.objectURL;
5856
}
5957

60-
public async from(input: File | string, init?: RequestInit | undefined): Promise<this> {
61-
try {
62-
this.state = 'LOADING';
63-
64-
if (input instanceof File) {
65-
this.name = input.name;
66-
this.mimeType = parseMimeType(input.type);
67-
this.external = false;
68-
this.file = input;
69-
} else {
70-
// case input is a request url
71-
const res = await fetch(input, init);
72-
73-
if (!res?.ok) throw new IOError({
74-
code: 'unexpectedIOError',
75-
message: 'An unexpected error occurred while fetching the file',
76-
});
77-
78-
const blob = await res.blob();
79-
this.name = input.toString().split('/').at(-1) ?? '';
80-
this.external = true;
81-
this.file = new File([blob], this.name, { type: blob.type });
82-
this.externalURL = input;
83-
this.mimeType = parseMimeType(blob.type);
84-
}
85-
86-
this.iframe.setAttribute('src', URL.createObjectURL(this.file));
87-
88-
await new Promise<void>((resolve, reject) => {
89-
this.iframe.onload = () => resolve();
90-
this.iframe.onerror = (e) => reject(e);
91-
});
92-
93-
this.state = 'READY';
94-
this.trigger('load', undefined);
95-
} catch (e) {
96-
this.state = 'ERROR';
97-
throw e;
98-
}
58+
protected async loadUrl(url: string | URL | Request, init?: RequestInit) {
59+
await super.loadUrl(url, init);
60+
61+
this.iframe.setAttribute('src', URL.createObjectURL(this.file!));
62+
63+
await new Promise<void>((resolve, reject) => {
64+
this.iframe.onload = () => resolve();
65+
this.iframe.onerror = (e) => reject(e);
66+
});
67+
}
68+
69+
protected async loadFile(file: File) {
70+
await super.loadFile(file);
71+
72+
this.iframe.setAttribute('src', URL.createObjectURL(this.file!));
9973

100-
return this;
74+
await new Promise<void>((resolve, reject) => {
75+
this.iframe.onload = () => resolve();
76+
this.iframe.onerror = (e) => reject(e);
77+
});
10178
}
10279

10380
/**

src/sources/source.ts

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ import { Timestamp } from '../models';
1515
import type { MimeType } from '../types';
1616
import type { ClipType } from '../clips';
1717

18-
type Url = string | URL | Request;
19-
2018
type Events = {
2119
load: undefined;
2220
update: undefined;
@@ -32,7 +30,7 @@ export class Source extends EventEmitterMixin<Events, typeof Serializer>(Seriali
3230
* Locally accessible blob address to the data
3331
*/
3432
@serializable()
35-
public objectURL: string | undefined;
33+
public objectURL?: string;
3634

3735
/**
3836
* Defines the default duration
@@ -111,36 +109,44 @@ export class Source extends EventEmitterMixin<Events, typeof Serializer>(Seriali
111109
return this.file;
112110
}
113111

114-
public async from(input: File | Url, init?: RequestInit | undefined): Promise<this> {
112+
protected async loadFile(file: File) {
113+
this.name = file.name;
114+
this.mimeType = parseMimeType(file.type);
115+
this.external = false;
116+
this.file = file;
117+
}
118+
119+
protected async loadUrl(url: string | URL | Request, init?: RequestInit) {
120+
const res = await fetch(url, init);
121+
122+
if (!res?.ok) throw new IOError({
123+
code: 'unexpectedIOError',
124+
message: 'An unexpected error occurred while fetching the file',
125+
});
126+
127+
const blob = await res.blob();
128+
this.name = url.toString().split('/').at(-1) ?? '';
129+
this.external = true;
130+
this.file = new File([blob], this.name, { type: blob.type });
131+
this.externalURL = url;
132+
this.mimeType = parseMimeType(blob.type);
133+
}
134+
135+
public async from(input: File | string | URL | Request, init?: RequestInit): Promise<this> {
115136
try {
116137
this.state = 'LOADING';
117138

118139
if (input instanceof File) {
119-
this.name = input.name;
120-
this.mimeType = parseMimeType(input.type);
121-
this.external = false;
122-
this.file = input;
140+
await this.loadFile(input);
123141
} else {
124-
// case input is a request url
125-
const res = await fetch(input, init);
126-
127-
if (!res?.ok) throw new IOError({
128-
code: 'unexpectedIOError',
129-
message: 'An unexpected error occurred while fetching the file',
130-
});
131-
132-
const blob = await res.blob();
133-
this.name = input.toString().split('/').at(-1) ?? '';
134-
this.external = true;
135-
this.file = new File([blob], this.name, { type: blob.type });
136-
this.externalURL = input;
137-
this.mimeType = parseMimeType(blob.type);
142+
await this.loadUrl(input, init);
138143
}
139144

140145
this.state = 'READY';
141146
this.trigger('load', undefined);
142147
} catch (e) {
143148
this.state == 'ERROR';
149+
this.trigger('error', new Error(String(e)));
144150
throw e;
145151
}
146152

@@ -173,7 +179,7 @@ export class Source extends EventEmitterMixin<Events, typeof Serializer>(Seriali
173179
/**
174180
* Downloads the file
175181
*/
176-
public async export(): Promise<void> {
182+
public async download(): Promise<void> {
177183
const file = await this.getFile();
178184

179185
downloadObject(file, this.name);
@@ -192,7 +198,7 @@ export class Source extends EventEmitterMixin<Events, typeof Serializer>(Seriali
192198
*/
193199
public static async from<T extends Source>(
194200
this: new () => T,
195-
input: File | Url,
201+
input: File | string | URL | Request,
196202
init?: RequestInit | undefined,
197203
source = new this(),
198204
): Promise<T> {

src/sources/video.spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Copyright (c) 2024 The Diffusion Studio Authors
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla
5+
* Public License, v. 2.0 that can be found in the LICENSE file.
6+
*/
7+
8+
import { setFetchMockReturnValue } from '../../vitest.mocks';
9+
import { describe, expect, it, vi } from 'vitest';
10+
import { VideoSource } from './video';
11+
import { sleep } from '../utils';
12+
13+
describe('The Video Source Object', () => {
14+
it('should be createable from a http address', async () => {
15+
const resetFetch = setFetchMockReturnValue({
16+
ok: true,
17+
blob: async () => {
18+
await sleep(1);
19+
return new Blob([], { type: 'video/mp4' });
20+
},
21+
headers: {
22+
get(_: string) {
23+
return 'video/mp4'
24+
}
25+
} as any
26+
});
27+
28+
const source = new VideoSource();
29+
const loadFn = vi.fn();
30+
source.on('load', loadFn);
31+
32+
await source.from('https://external.url');
33+
34+
expect(source.name).toBe('external.url');
35+
expect(source.mimeType).toBe('video/mp4');
36+
expect(source.external).toBe(true);
37+
expect(source.file).toBeUndefined();
38+
expect(source.externalURL).toBe('https://external.url');
39+
expect(source.objectURL).toBe('https://external.url');
40+
expect(loadFn).toHaveBeenCalledTimes(1);
41+
42+
// file is being loaded in the background
43+
await sleep(1);
44+
expect(source.file).toBeInstanceOf(File);
45+
expect(loadFn).toHaveBeenCalledTimes(2);
46+
47+
resetFetch();
48+
});
49+
50+
it('should get a file after the asset has been fetched', async () => {
51+
const resetFetch = setFetchMockReturnValue({
52+
ok: true,
53+
blob: async () => {
54+
await sleep(1);
55+
return new Blob([], { type: 'video/mp4' });
56+
},
57+
headers: {
58+
get(_: string) {
59+
return 'video/mp4'
60+
}
61+
} as any
62+
});
63+
64+
const source = new VideoSource();
65+
await source.from('https://external.mp4');
66+
67+
const file = await source.getFile();
68+
69+
expect(file).toBeInstanceOf(File);
70+
expect(file.name).toBe('external.mp4');
71+
72+
resetFetch();
73+
});
74+
});

src/sources/video.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,46 @@
66
*/
77

88
import { AudioSource } from './';
9+
import { parseMimeType } from '../clips';
10+
import { IOError, ValidationError } from '../errors';
11+
912
import type { ClipType } from '../clips';
1013

1114
export class VideoSource extends AudioSource {
1215
public readonly type: ClipType = 'video';
16+
private downloadInProgress = true;
17+
18+
protected async loadUrl(url: string | URL | Request, init?: RequestInit | undefined) {
19+
const res = await fetch(url, init);
20+
21+
if (!res?.ok) throw new IOError({
22+
code: 'unexpectedIOError',
23+
message: 'An unexpected error occurred while fetching the file',
24+
});
25+
26+
this.name = url.toString().split('/').at(-1) ?? '';
27+
this.external = true;
28+
this.externalURL = url;
29+
this.objectURL = String(url);
30+
this.mimeType = parseMimeType(res.headers.get('Content-type'));
31+
32+
this.getBlob(res);
33+
}
34+
35+
public async getFile(): Promise<File> {
36+
if (!this.file && this.downloadInProgress) {
37+
await new Promise(this.resolve('load'));
38+
}
39+
40+
if (!this.file) {
41+
throw new ValidationError({
42+
code: 'fileNotAccessible',
43+
message: "The desired file cannot be accessed",
44+
});
45+
}
46+
47+
return this.file;
48+
}
1349

1450
public async thumbnail(): Promise<HTMLVideoElement> {
1551
const video = document.createElement('video');
@@ -35,4 +71,19 @@ export class VideoSource extends AudioSource {
3571
video.src = await this.createObjectURL();
3672
return video;
3773
}
74+
75+
private async getBlob(response: Response) {
76+
try {
77+
this.downloadInProgress = true;
78+
const blob = await response.blob();
79+
80+
this.file = new File([blob], this.name, { type: blob.type });
81+
this.trigger('load', undefined);
82+
} catch (e) {
83+
this.state == 'ERROR';
84+
this.trigger('error', new Error(String(e)));
85+
} finally {
86+
this.downloadInProgress = false;
87+
}
88+
}
3889
}

0 commit comments

Comments
 (0)