@gradio/video 0.1.0-beta.7 → 0.1.0-beta.9
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/CHANGELOG.md +23 -1
- package/Index.svelte +40 -8
- package/README.md +38 -6
- package/Video.test.ts +72 -89
- package/index.ts +7 -0
- package/package.json +13 -11
- package/shared/InteractiveVideo.svelte +80 -21
- package/shared/Player.svelte +28 -1
- package/shared/Video.svelte +56 -0
- package/shared/VideoControls.svelte +214 -0
- package/shared/VideoPreview.svelte +16 -12
- package/shared/VideoTimeline.svelte +270 -0
- package/shared/utils.ts +107 -2
- package/shared/StaticVideo.svelte +0 -70
@@ -0,0 +1,270 @@
|
|
1
|
+
<script lang="ts">
|
2
|
+
import { onMount, onDestroy } from "svelte";
|
3
|
+
|
4
|
+
export let videoElement: HTMLVideoElement;
|
5
|
+
export let trimmedDuration: number | null;
|
6
|
+
export let dragStart: number;
|
7
|
+
export let dragEnd: number;
|
8
|
+
export let loadingTimeline: boolean;
|
9
|
+
|
10
|
+
let thumbnails: string[] = [];
|
11
|
+
let numberOfThumbnails = 10;
|
12
|
+
let intervalId: number | NodeJS.Timer;
|
13
|
+
let videoDuration: number;
|
14
|
+
|
15
|
+
let leftHandlePosition = 0;
|
16
|
+
let rightHandlePosition = 100;
|
17
|
+
|
18
|
+
let dragging: string | null = null;
|
19
|
+
|
20
|
+
const startDragging = (side: string | null): void => {
|
21
|
+
dragging = side;
|
22
|
+
};
|
23
|
+
|
24
|
+
$: loadingTimeline = thumbnails.length !== numberOfThumbnails;
|
25
|
+
|
26
|
+
const stopDragging = (): void => {
|
27
|
+
dragging = null;
|
28
|
+
};
|
29
|
+
|
30
|
+
const drag = (event: { clientX: number }, distance?: number): void => {
|
31
|
+
if (dragging) {
|
32
|
+
const timeline = document.getElementById("timeline");
|
33
|
+
|
34
|
+
if (!timeline) return;
|
35
|
+
|
36
|
+
const rect = timeline.getBoundingClientRect();
|
37
|
+
let newPercentage = ((event.clientX - rect.left) / rect.width) * 100;
|
38
|
+
|
39
|
+
if (distance) {
|
40
|
+
// Move handle based on arrow key press
|
41
|
+
newPercentage =
|
42
|
+
dragging === "left"
|
43
|
+
? leftHandlePosition + distance
|
44
|
+
: rightHandlePosition + distance;
|
45
|
+
} else {
|
46
|
+
// Move handle based on mouse drag
|
47
|
+
newPercentage = ((event.clientX - rect.left) / rect.width) * 100;
|
48
|
+
}
|
49
|
+
|
50
|
+
newPercentage = Math.max(0, Math.min(newPercentage, 100)); // Keep within 0 and 100
|
51
|
+
|
52
|
+
if (dragging === "left") {
|
53
|
+
leftHandlePosition = Math.min(newPercentage, rightHandlePosition);
|
54
|
+
|
55
|
+
// Calculate the new time and set it for the videoElement
|
56
|
+
const newTimeLeft = (leftHandlePosition / 100) * videoDuration;
|
57
|
+
videoElement.currentTime = newTimeLeft;
|
58
|
+
|
59
|
+
dragStart = newTimeLeft;
|
60
|
+
} else if (dragging === "right") {
|
61
|
+
rightHandlePosition = Math.max(newPercentage, leftHandlePosition);
|
62
|
+
|
63
|
+
const newTimeRight = (rightHandlePosition / 100) * videoDuration;
|
64
|
+
videoElement.currentTime = newTimeRight;
|
65
|
+
|
66
|
+
dragEnd = newTimeRight;
|
67
|
+
}
|
68
|
+
|
69
|
+
const startTime = (leftHandlePosition / 100) * videoDuration;
|
70
|
+
const endTime = (rightHandlePosition / 100) * videoDuration;
|
71
|
+
trimmedDuration = endTime - startTime;
|
72
|
+
|
73
|
+
leftHandlePosition = leftHandlePosition;
|
74
|
+
rightHandlePosition = rightHandlePosition;
|
75
|
+
}
|
76
|
+
};
|
77
|
+
|
78
|
+
const moveHandle = (e: KeyboardEvent): void => {
|
79
|
+
if (dragging) {
|
80
|
+
// Calculate the movement distance as a percentage of the video duration
|
81
|
+
const distance = (1 / videoDuration) * 100;
|
82
|
+
|
83
|
+
if (e.key === "ArrowLeft") {
|
84
|
+
drag({ clientX: 0 }, -distance);
|
85
|
+
} else if (e.key === "ArrowRight") {
|
86
|
+
drag({ clientX: 0 }, distance);
|
87
|
+
}
|
88
|
+
}
|
89
|
+
};
|
90
|
+
|
91
|
+
const generateThumbnail = (): void => {
|
92
|
+
const canvas = document.createElement("canvas");
|
93
|
+
const ctx = canvas.getContext("2d");
|
94
|
+
if (!ctx) return;
|
95
|
+
|
96
|
+
canvas.width = videoElement.videoWidth;
|
97
|
+
canvas.height = videoElement.videoHeight;
|
98
|
+
|
99
|
+
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
|
100
|
+
|
101
|
+
const thumbnail: string = canvas.toDataURL("image/jpeg", 0.7);
|
102
|
+
thumbnails = [...thumbnails, thumbnail];
|
103
|
+
};
|
104
|
+
|
105
|
+
onMount(() => {
|
106
|
+
const loadMetadata = (): void => {
|
107
|
+
videoDuration = videoElement.duration;
|
108
|
+
|
109
|
+
const interval = videoDuration / numberOfThumbnails;
|
110
|
+
let captures = 0;
|
111
|
+
|
112
|
+
const onSeeked = (): void => {
|
113
|
+
generateThumbnail();
|
114
|
+
captures++;
|
115
|
+
|
116
|
+
if (captures < numberOfThumbnails) {
|
117
|
+
videoElement.currentTime += interval;
|
118
|
+
} else {
|
119
|
+
videoElement.removeEventListener("seeked", onSeeked);
|
120
|
+
}
|
121
|
+
};
|
122
|
+
|
123
|
+
videoElement.addEventListener("seeked", onSeeked);
|
124
|
+
videoElement.currentTime = 0;
|
125
|
+
};
|
126
|
+
|
127
|
+
if (videoElement.readyState >= 1) {
|
128
|
+
loadMetadata();
|
129
|
+
} else {
|
130
|
+
videoElement.addEventListener("loadedmetadata", loadMetadata);
|
131
|
+
}
|
132
|
+
});
|
133
|
+
|
134
|
+
onDestroy(() => {
|
135
|
+
window.removeEventListener("mousemove", drag);
|
136
|
+
window.removeEventListener("mouseup", stopDragging);
|
137
|
+
window.removeEventListener("keydown", moveHandle);
|
138
|
+
|
139
|
+
if (intervalId) {
|
140
|
+
clearInterval(intervalId);
|
141
|
+
}
|
142
|
+
});
|
143
|
+
|
144
|
+
onMount(() => {
|
145
|
+
window.addEventListener("mousemove", drag);
|
146
|
+
window.addEventListener("mouseup", stopDragging);
|
147
|
+
window.addEventListener("keydown", moveHandle);
|
148
|
+
});
|
149
|
+
</script>
|
150
|
+
|
151
|
+
<div class="container">
|
152
|
+
{#if loadingTimeline}
|
153
|
+
<div class="load-wrap">
|
154
|
+
<span aria-label="loading timeline" class="loader" />
|
155
|
+
</div>
|
156
|
+
{:else}
|
157
|
+
<div id="timeline" class="thumbnail-wrapper">
|
158
|
+
<button
|
159
|
+
class="handle left"
|
160
|
+
on:mousedown={() => startDragging("left")}
|
161
|
+
on:blur={stopDragging}
|
162
|
+
on:keydown={(e) => {
|
163
|
+
if (e.key === "ArrowLeft" || e.key == "ArrowRight") {
|
164
|
+
startDragging("left");
|
165
|
+
}
|
166
|
+
}}
|
167
|
+
style="left: {leftHandlePosition}%;"
|
168
|
+
/>
|
169
|
+
|
170
|
+
<div
|
171
|
+
class="opaque-layer"
|
172
|
+
style="left: {leftHandlePosition}%; right: {100 - rightHandlePosition}%"
|
173
|
+
/>
|
174
|
+
|
175
|
+
{#each thumbnails as thumbnail, i (i)}
|
176
|
+
<img src={thumbnail} alt={`frame-${i}`} draggable="false" />
|
177
|
+
{/each}
|
178
|
+
<button
|
179
|
+
class="handle right"
|
180
|
+
on:mousedown={() => startDragging("right")}
|
181
|
+
on:blur={stopDragging}
|
182
|
+
on:keydown={(e) => {
|
183
|
+
if (e.key === "ArrowLeft" || e.key == "ArrowRight") {
|
184
|
+
startDragging("right");
|
185
|
+
}
|
186
|
+
}}
|
187
|
+
style="left: {rightHandlePosition}%;"
|
188
|
+
/>
|
189
|
+
</div>
|
190
|
+
{/if}
|
191
|
+
</div>
|
192
|
+
|
193
|
+
<style>
|
194
|
+
.load-wrap {
|
195
|
+
display: flex;
|
196
|
+
justify-content: center;
|
197
|
+
align-items: center;
|
198
|
+
height: 100%;
|
199
|
+
}
|
200
|
+
.loader {
|
201
|
+
display: flex;
|
202
|
+
position: relative;
|
203
|
+
background-color: var(--border-color-accent-subdued);
|
204
|
+
animation: shadowPulse 2s linear infinite;
|
205
|
+
box-shadow: -24px 0 var(--border-color-accent-subdued),
|
206
|
+
24px 0 var(--border-color-accent-subdued);
|
207
|
+
margin: var(--spacing-md);
|
208
|
+
border-radius: 50%;
|
209
|
+
width: 10px;
|
210
|
+
height: 10px;
|
211
|
+
scale: 0.5;
|
212
|
+
}
|
213
|
+
|
214
|
+
@keyframes shadowPulse {
|
215
|
+
33% {
|
216
|
+
box-shadow: -24px 0 var(--border-color-accent-subdued), 24px 0 #fff;
|
217
|
+
background: #fff;
|
218
|
+
}
|
219
|
+
66% {
|
220
|
+
box-shadow: -24px 0 #fff, 24px 0 #fff;
|
221
|
+
background: var(--border-color-accent-subdued);
|
222
|
+
}
|
223
|
+
100% {
|
224
|
+
box-shadow: -24px 0 #fff, 24px 0 var(--border-color-accent-subdued);
|
225
|
+
background: #fff;
|
226
|
+
}
|
227
|
+
}
|
228
|
+
|
229
|
+
.container {
|
230
|
+
display: flex;
|
231
|
+
flex-direction: column;
|
232
|
+
align-items: center;
|
233
|
+
justify-content: center;
|
234
|
+
margin: var(--spacing-lg) var(--spacing-lg) 0 var(--spacing-lg);
|
235
|
+
}
|
236
|
+
|
237
|
+
#timeline {
|
238
|
+
display: flex;
|
239
|
+
height: var(--size-10);
|
240
|
+
flex: 1;
|
241
|
+
position: relative;
|
242
|
+
}
|
243
|
+
|
244
|
+
img {
|
245
|
+
flex: 1 1 auto;
|
246
|
+
min-width: 0;
|
247
|
+
object-fit: cover;
|
248
|
+
height: var(--size-12);
|
249
|
+
border: 1px solid var(--block-border-color);
|
250
|
+
user-select: none;
|
251
|
+
z-index: 1;
|
252
|
+
}
|
253
|
+
|
254
|
+
.handle {
|
255
|
+
width: 3px;
|
256
|
+
background-color: var(--color-accent);
|
257
|
+
cursor: ew-resize;
|
258
|
+
height: var(--size-12);
|
259
|
+
z-index: 3;
|
260
|
+
position: absolute;
|
261
|
+
}
|
262
|
+
|
263
|
+
.opaque-layer {
|
264
|
+
background-color: rgba(230, 103, 40, 0.25);
|
265
|
+
border: 1px solid var(--color-accent);
|
266
|
+
height: var(--size-12);
|
267
|
+
position: absolute;
|
268
|
+
z-index: 2;
|
269
|
+
}
|
270
|
+
</style>
|
package/shared/utils.ts
CHANGED
@@ -1,4 +1,6 @@
|
|
1
|
-
import
|
1
|
+
import { toBlobURL } from "@ffmpeg/util";
|
2
|
+
import { FFmpeg } from "@ffmpeg/ffmpeg";
|
3
|
+
import { lookup } from "mrmime";
|
2
4
|
|
3
5
|
export const prettyBytes = (bytes: number): string => {
|
4
6
|
let units = ["B", "KB", "MB", "GB", "PB"];
|
@@ -22,7 +24,7 @@ export const playable = (): boolean => {
|
|
22
24
|
export function loaded(
|
23
25
|
node: HTMLVideoElement,
|
24
26
|
{ autoplay }: { autoplay: boolean }
|
25
|
-
):
|
27
|
+
): any {
|
26
28
|
async function handle_playback(): Promise<void> {
|
27
29
|
if (!autoplay) return;
|
28
30
|
await node.play();
|
@@ -36,3 +38,106 @@ export function loaded(
|
|
36
38
|
}
|
37
39
|
};
|
38
40
|
}
|
41
|
+
|
42
|
+
export default async function loadFfmpeg(): Promise<FFmpeg> {
|
43
|
+
const ffmpeg = new FFmpeg();
|
44
|
+
const baseURL = "https://unpkg.com/@ffmpeg/core@0.12.4/dist/esm";
|
45
|
+
|
46
|
+
await ffmpeg.load({
|
47
|
+
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
|
48
|
+
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm")
|
49
|
+
});
|
50
|
+
|
51
|
+
return ffmpeg;
|
52
|
+
}
|
53
|
+
|
54
|
+
export function blob_to_data_url(blob: Blob): Promise<string> {
|
55
|
+
return new Promise((fulfill, reject) => {
|
56
|
+
let reader = new FileReader();
|
57
|
+
reader.onerror = reject;
|
58
|
+
reader.onload = () => fulfill(reader.result as string);
|
59
|
+
reader.readAsDataURL(blob);
|
60
|
+
});
|
61
|
+
}
|
62
|
+
|
63
|
+
export async function trimVideo(
|
64
|
+
ffmpeg: FFmpeg,
|
65
|
+
startTime: number,
|
66
|
+
endTime: number,
|
67
|
+
videoElement: HTMLVideoElement
|
68
|
+
): Promise<any> {
|
69
|
+
try {
|
70
|
+
const videoUrl = videoElement.src;
|
71
|
+
const mimeType = lookup(videoElement.src) || "video/mp4";
|
72
|
+
const blobUrl = await toBlobURL(videoUrl, mimeType);
|
73
|
+
const response = await fetch(blobUrl);
|
74
|
+
const vidBlob = await response.blob();
|
75
|
+
const type = getVideoExtensionFromMimeType(mimeType) || "mp4";
|
76
|
+
const inputName = `input.${type}`;
|
77
|
+
const outputName = `output.${type}`;
|
78
|
+
|
79
|
+
await ffmpeg.writeFile(
|
80
|
+
inputName,
|
81
|
+
new Uint8Array(await vidBlob.arrayBuffer())
|
82
|
+
);
|
83
|
+
|
84
|
+
let command = [
|
85
|
+
"-i",
|
86
|
+
inputName,
|
87
|
+
"-ss",
|
88
|
+
startTime.toString(),
|
89
|
+
"-to",
|
90
|
+
endTime.toString(),
|
91
|
+
"-c:a",
|
92
|
+
"copy",
|
93
|
+
outputName
|
94
|
+
];
|
95
|
+
|
96
|
+
await ffmpeg.exec(command);
|
97
|
+
const outputData = await ffmpeg.readFile(outputName);
|
98
|
+
const outputBlob = new Blob([outputData], {
|
99
|
+
type: `video/${type}`
|
100
|
+
});
|
101
|
+
|
102
|
+
return outputBlob;
|
103
|
+
} catch (error) {
|
104
|
+
console.error("Error initializing FFmpeg:", error);
|
105
|
+
}
|
106
|
+
}
|
107
|
+
|
108
|
+
const getVideoExtensionFromMimeType = (mimeType: string): string | null => {
|
109
|
+
const videoMimeToExtensionMap: { [key: string]: string } = {
|
110
|
+
"video/mp4": "mp4",
|
111
|
+
"video/webm": "webm",
|
112
|
+
"video/ogg": "ogv",
|
113
|
+
"video/quicktime": "mov",
|
114
|
+
"video/x-msvideo": "avi",
|
115
|
+
"video/x-matroska": "mkv",
|
116
|
+
"video/mpeg": "mpeg",
|
117
|
+
"video/3gpp": "3gp",
|
118
|
+
"video/3gpp2": "3g2",
|
119
|
+
"video/h261": "h261",
|
120
|
+
"video/h263": "h263",
|
121
|
+
"video/h264": "h264",
|
122
|
+
"video/jpeg": "jpgv",
|
123
|
+
"video/jpm": "jpm",
|
124
|
+
"video/mj2": "mj2",
|
125
|
+
"video/mpv": "mpv",
|
126
|
+
"video/vnd.ms-playready.media.pyv": "pyv",
|
127
|
+
"video/vnd.uvvu.mp4": "uvu",
|
128
|
+
"video/vnd.vivo": "viv",
|
129
|
+
"video/x-f4v": "f4v",
|
130
|
+
"video/x-fli": "fli",
|
131
|
+
"video/x-flv": "flv",
|
132
|
+
"video/x-m4v": "m4v",
|
133
|
+
"video/x-ms-asf": "asf",
|
134
|
+
"video/x-ms-wm": "wm",
|
135
|
+
"video/x-ms-wmv": "wmv",
|
136
|
+
"video/x-ms-wmx": "wmx",
|
137
|
+
"video/x-ms-wvx": "wvx",
|
138
|
+
"video/x-sgi-movie": "movie",
|
139
|
+
"video/x-smv": "smv"
|
140
|
+
};
|
141
|
+
|
142
|
+
return videoMimeToExtensionMap[mimeType] || null;
|
143
|
+
};
|
@@ -1,70 +0,0 @@
|
|
1
|
-
<script lang="ts">
|
2
|
-
import type { HTMLVideoAttributes } from "svelte/elements";
|
3
|
-
import { createEventDispatcher } from "svelte";
|
4
|
-
import { loaded } from "./utils";
|
5
|
-
|
6
|
-
import { resolve_wasm_src } from "@gradio/wasm/svelte";
|
7
|
-
|
8
|
-
export let src: HTMLVideoAttributes["src"] = undefined;
|
9
|
-
|
10
|
-
export let muted: HTMLVideoAttributes["muted"] = undefined;
|
11
|
-
export let playsinline: HTMLVideoAttributes["playsinline"] = undefined;
|
12
|
-
export let preload: HTMLVideoAttributes["preload"] = undefined;
|
13
|
-
export let autoplay: HTMLVideoAttributes["autoplay"] = undefined;
|
14
|
-
export let controls: HTMLVideoAttributes["controls"] = undefined;
|
15
|
-
|
16
|
-
export let currentTime: number | undefined = undefined;
|
17
|
-
export let duration: number | undefined = undefined;
|
18
|
-
export let paused: boolean | undefined = undefined;
|
19
|
-
|
20
|
-
export let node: HTMLVideoElement | undefined = undefined;
|
21
|
-
|
22
|
-
const dispatch = createEventDispatcher();
|
23
|
-
</script>
|
24
|
-
|
25
|
-
{#await resolve_wasm_src(src) then resolved_src}
|
26
|
-
<!--
|
27
|
-
The spread operator with `$$props` or `$$restProps` can't be used here
|
28
|
-
to pass props from the parent component to the <video> element
|
29
|
-
because of its unexpected behavior: https://github.com/sveltejs/svelte/issues/7404
|
30
|
-
For example, if we add {...$$props} or {...$$restProps}, the boolean props aside it like `controls` will be compiled as string "true" or "false" on the actual DOM.
|
31
|
-
Then, even when `controls` is false, the compiled DOM would be `<video controls="false">` which is equivalent to `<video controls>` since the string "false" is even truthy.
|
32
|
-
-->
|
33
|
-
<video
|
34
|
-
src={resolved_src}
|
35
|
-
{muted}
|
36
|
-
{playsinline}
|
37
|
-
{preload}
|
38
|
-
{autoplay}
|
39
|
-
{controls}
|
40
|
-
on:loadeddata={dispatch.bind(null, "loadeddata")}
|
41
|
-
on:click={dispatch.bind(null, "click")}
|
42
|
-
on:play={dispatch.bind(null, "play")}
|
43
|
-
on:pause={dispatch.bind(null, "pause")}
|
44
|
-
on:ended={dispatch.bind(null, "ended")}
|
45
|
-
on:mouseover={dispatch.bind(null, "mouseover")}
|
46
|
-
on:mouseout={dispatch.bind(null, "mouseout")}
|
47
|
-
on:focus={dispatch.bind(null, "focus")}
|
48
|
-
on:blur={dispatch.bind(null, "blur")}
|
49
|
-
bind:currentTime
|
50
|
-
bind:duration
|
51
|
-
bind:paused
|
52
|
-
bind:this={node}
|
53
|
-
use:loaded={{ autoplay: autoplay ?? false }}
|
54
|
-
data-testid={$$props["data-testid"]}
|
55
|
-
>
|
56
|
-
<slot />
|
57
|
-
</video>
|
58
|
-
{:catch error}
|
59
|
-
<p style="color: red;">{error.message}</p>
|
60
|
-
{/await}
|
61
|
-
|
62
|
-
<style>
|
63
|
-
video {
|
64
|
-
position: inherit;
|
65
|
-
background-color: black;
|
66
|
-
width: var(--size-full);
|
67
|
-
height: var(--size-full);
|
68
|
-
object-fit: contain;
|
69
|
-
}
|
70
|
-
</style>
|