@gradio/image 0.3.0-beta.7 → 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/CHANGELOG.md +37 -1
- package/Image.stories.svelte +17 -5
- package/Image.test.ts +15 -10
- package/Index.svelte +34 -29
- package/README.md +47 -5
- package/package.json +9 -7
- package/shared/{ModifySketch.svelte → ClearImage.svelte} +1 -16
- package/shared/ImagePreview.svelte +8 -12
- package/shared/ImageUploader.svelte +224 -0
- package/shared/Webcam.svelte +173 -30
- package/shared/Cropper.svelte +0 -33
- package/shared/ImageEditor.svelte +0 -456
- package/shared/Sketch.svelte +0 -624
- package/shared/SketchSettings.svelte +0 -77
@@ -0,0 +1,224 @@
|
|
1
|
+
<script lang="ts">
|
2
|
+
import { createEventDispatcher, tick } from "svelte";
|
3
|
+
import { BlockLabel } from "@gradio/atoms";
|
4
|
+
import { Image } from "@gradio/icons";
|
5
|
+
import type { SelectData, I18nFormatter } from "@gradio/utils";
|
6
|
+
import { get_coordinates_of_clicked_image } from "./utils";
|
7
|
+
import {
|
8
|
+
Webcam as WebcamIcon,
|
9
|
+
ImagePaste,
|
10
|
+
Upload as UploadIcon
|
11
|
+
} from "@gradio/icons";
|
12
|
+
import Webcam from "./Webcam.svelte";
|
13
|
+
import { Toolbar, IconButton } from "@gradio/atoms";
|
14
|
+
|
15
|
+
import { Upload } from "@gradio/upload";
|
16
|
+
import { type FileData, normalise_file } from "@gradio/client";
|
17
|
+
import ClearImage from "./ClearImage.svelte";
|
18
|
+
|
19
|
+
export let value: null | FileData;
|
20
|
+
export let label: string | undefined = undefined;
|
21
|
+
export let show_label: boolean;
|
22
|
+
|
23
|
+
export let sources: ("clipboard" | "webcam" | "upload")[] = [
|
24
|
+
"upload",
|
25
|
+
"clipboard",
|
26
|
+
"webcam"
|
27
|
+
];
|
28
|
+
export let streaming = false;
|
29
|
+
export let pending = false;
|
30
|
+
export let mirror_webcam: boolean;
|
31
|
+
export let selectable = false;
|
32
|
+
export let root: string;
|
33
|
+
export let i18n: I18nFormatter;
|
34
|
+
|
35
|
+
let upload: Upload;
|
36
|
+
export let active_tool: "webcam" | null = null;
|
37
|
+
|
38
|
+
function handle_upload({ detail }: CustomEvent<FileData>): void {
|
39
|
+
value = normalise_file(detail, root, null);
|
40
|
+
}
|
41
|
+
|
42
|
+
async function handle_save(img_blob: Blob | any): Promise<void> {
|
43
|
+
pending = true;
|
44
|
+
const f = await upload.load_files([new File([img_blob], `webcam.png`)]);
|
45
|
+
|
46
|
+
value = f?.[0] || null;
|
47
|
+
if (!streaming) active_tool = null;
|
48
|
+
|
49
|
+
await tick();
|
50
|
+
|
51
|
+
dispatch(streaming ? "stream" : "change");
|
52
|
+
pending = false;
|
53
|
+
}
|
54
|
+
|
55
|
+
$: value && !value.url && (value = normalise_file(value, root, null));
|
56
|
+
|
57
|
+
const dispatch = createEventDispatcher<{
|
58
|
+
change?: never;
|
59
|
+
stream?: never;
|
60
|
+
clear?: never;
|
61
|
+
drag: boolean;
|
62
|
+
upload?: never;
|
63
|
+
select: SelectData;
|
64
|
+
}>();
|
65
|
+
|
66
|
+
let dragging = false;
|
67
|
+
|
68
|
+
$: dispatch("drag", dragging);
|
69
|
+
|
70
|
+
function handle_click(evt: MouseEvent): void {
|
71
|
+
let coordinates = get_coordinates_of_clicked_image(evt);
|
72
|
+
if (coordinates) {
|
73
|
+
dispatch("select", { index: coordinates, value: null });
|
74
|
+
}
|
75
|
+
}
|
76
|
+
|
77
|
+
const sources_meta = {
|
78
|
+
upload: {
|
79
|
+
icon: UploadIcon,
|
80
|
+
label: i18n("Upload"),
|
81
|
+
order: 0
|
82
|
+
},
|
83
|
+
webcam: {
|
84
|
+
icon: WebcamIcon,
|
85
|
+
label: i18n("Webcam"),
|
86
|
+
order: 1
|
87
|
+
},
|
88
|
+
clipboard: {
|
89
|
+
icon: ImagePaste,
|
90
|
+
label: i18n("Paste"),
|
91
|
+
order: 2
|
92
|
+
}
|
93
|
+
};
|
94
|
+
|
95
|
+
$: sources_list = sources.sort(
|
96
|
+
(a, b) => sources_meta[a].order - sources_meta[b].order
|
97
|
+
);
|
98
|
+
|
99
|
+
$: {
|
100
|
+
if (sources.length === 1 && sources[0] === "webcam") {
|
101
|
+
active_tool = "webcam";
|
102
|
+
}
|
103
|
+
}
|
104
|
+
|
105
|
+
async function handle_toolbar(
|
106
|
+
source: (typeof sources)[number]
|
107
|
+
): Promise<void> {
|
108
|
+
switch (source) {
|
109
|
+
case "clipboard":
|
110
|
+
navigator.clipboard.read().then(async (items) => {
|
111
|
+
for (let i = 0; i < items.length; i++) {
|
112
|
+
const type = items[i].types.find((t) => t.startsWith("image/"));
|
113
|
+
if (type) {
|
114
|
+
items[i].getType(type).then(async (blob) => {
|
115
|
+
const f = await upload.load_files([
|
116
|
+
new File([blob], `clipboard.${type.replace("image/", "")}`)
|
117
|
+
]);
|
118
|
+
f;
|
119
|
+
value = f?.[0] || null;
|
120
|
+
});
|
121
|
+
break;
|
122
|
+
}
|
123
|
+
}
|
124
|
+
});
|
125
|
+
break;
|
126
|
+
case "webcam":
|
127
|
+
active_tool = "webcam";
|
128
|
+
break;
|
129
|
+
case "upload":
|
130
|
+
upload.open_file_upload();
|
131
|
+
break;
|
132
|
+
default:
|
133
|
+
break;
|
134
|
+
}
|
135
|
+
}
|
136
|
+
</script>
|
137
|
+
|
138
|
+
<BlockLabel {show_label} Icon={Image} label={label || "Image"} />
|
139
|
+
|
140
|
+
<div data-testid="image" class="image-container">
|
141
|
+
{#if value?.url}
|
142
|
+
<ClearImage on:remove_image={() => (value = null)} />
|
143
|
+
{/if}
|
144
|
+
<div class="upload-container">
|
145
|
+
<Upload
|
146
|
+
hidden={value !== null || active_tool === "webcam"}
|
147
|
+
bind:this={upload}
|
148
|
+
bind:dragging
|
149
|
+
filetype="image/*"
|
150
|
+
on:load={handle_upload}
|
151
|
+
on:error
|
152
|
+
{root}
|
153
|
+
disable_click={!sources.includes("upload")}
|
154
|
+
>
|
155
|
+
{#if value === null && !active_tool}
|
156
|
+
<slot />
|
157
|
+
{/if}
|
158
|
+
</Upload>
|
159
|
+
{#if active_tool === "webcam"}
|
160
|
+
<Webcam
|
161
|
+
on:capture={(e) => handle_save(e.detail)}
|
162
|
+
on:stream={(e) => handle_save(e.detail)}
|
163
|
+
on:error
|
164
|
+
on:drag
|
165
|
+
on:upload={(e) => handle_save(e.detail)}
|
166
|
+
{mirror_webcam}
|
167
|
+
{streaming}
|
168
|
+
mode="image"
|
169
|
+
include_audio={false}
|
170
|
+
{i18n}
|
171
|
+
/>
|
172
|
+
{:else if value !== null && !streaming}
|
173
|
+
<!-- svelte-ignore a11y-click-events-have-key-events-->
|
174
|
+
<!-- svelte-ignore a11y-no-noninteractive-element-interactions-->
|
175
|
+
<img
|
176
|
+
src={value.url}
|
177
|
+
alt={value.alt_text}
|
178
|
+
on:click={handle_click}
|
179
|
+
class:selectable
|
180
|
+
/>
|
181
|
+
{/if}
|
182
|
+
</div>
|
183
|
+
{#if sources.length > 1 || sources.includes("clipboard")}
|
184
|
+
<Toolbar show_border={!value?.url}>
|
185
|
+
{#each sources_list as source}
|
186
|
+
<IconButton
|
187
|
+
on:click={() => handle_toolbar(source)}
|
188
|
+
Icon={sources_meta[source].icon}
|
189
|
+
size="large"
|
190
|
+
padded={false}
|
191
|
+
/>
|
192
|
+
{/each}
|
193
|
+
</Toolbar>
|
194
|
+
{/if}
|
195
|
+
</div>
|
196
|
+
|
197
|
+
<style>
|
198
|
+
/* .image-container {
|
199
|
+
height: auto;
|
200
|
+
} */
|
201
|
+
img {
|
202
|
+
width: var(--size-full);
|
203
|
+
height: var(--size-full);
|
204
|
+
}
|
205
|
+
|
206
|
+
.upload-container {
|
207
|
+
height: 100%;
|
208
|
+
flex-shrink: 1;
|
209
|
+
max-height: 100%;
|
210
|
+
}
|
211
|
+
|
212
|
+
.image-container {
|
213
|
+
display: flex;
|
214
|
+
height: 100%;
|
215
|
+
flex-direction: column;
|
216
|
+
justify-content: center;
|
217
|
+
align-items: center;
|
218
|
+
max-height: 100%;
|
219
|
+
}
|
220
|
+
|
221
|
+
.selectable {
|
222
|
+
cursor: crosshair;
|
223
|
+
}
|
224
|
+
</style>
|
package/shared/Webcam.svelte
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
<script lang="ts">
|
2
|
-
import { createEventDispatcher, onMount } from "svelte";
|
3
|
-
import { Camera, Circle, Square } from "@gradio/icons";
|
4
|
-
import type { I18nFormatter } from "
|
2
|
+
import { createEventDispatcher, onMount, tick } from "svelte";
|
3
|
+
import { Camera, Circle, Square, DropdownArrow } from "@gradio/icons";
|
4
|
+
import type { I18nFormatter } from "@gradio/utils";
|
5
5
|
|
6
6
|
let video_source: HTMLVideoElement;
|
7
7
|
let canvas: HTMLCanvasElement;
|
@@ -22,7 +22,7 @@
|
|
22
22
|
is_example?: boolean;
|
23
23
|
is_file: boolean;
|
24
24
|
}
|
25
|
-
|
|
25
|
+
| Blob;
|
26
26
|
error: string;
|
27
27
|
start_recording: undefined;
|
28
28
|
stop_recording: undefined;
|
@@ -30,11 +30,15 @@
|
|
30
30
|
|
31
31
|
onMount(() => (canvas = document.createElement("canvas")));
|
32
32
|
|
33
|
-
async function access_webcam(): Promise<void> {
|
33
|
+
async function access_webcam(device_id?: string): Promise<void> {
|
34
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
35
|
+
dispatch("error", i18n("image.no_webcam_support"));
|
36
|
+
return;
|
37
|
+
}
|
34
38
|
try {
|
35
39
|
stream = await navigator.mediaDevices.getUserMedia({
|
36
|
-
video: true,
|
37
|
-
audio: include_audio
|
40
|
+
video: device_id ? { deviceId: { exact: device_id } } : true,
|
41
|
+
audio: include_audio
|
38
42
|
});
|
39
43
|
video_source.srcObject = stream;
|
40
44
|
video_source.muted = true;
|
@@ -62,8 +66,18 @@
|
|
62
66
|
video_source.videoHeight
|
63
67
|
);
|
64
68
|
|
65
|
-
|
66
|
-
|
69
|
+
if (mirror_webcam) {
|
70
|
+
context.scale(-1, 1);
|
71
|
+
context.drawImage(video_source, -video_source.videoWidth, 0);
|
72
|
+
}
|
73
|
+
|
74
|
+
canvas.toBlob(
|
75
|
+
(blob) => {
|
76
|
+
dispatch(streaming ? "stream" : "capture", blob);
|
77
|
+
},
|
78
|
+
"image/png",
|
79
|
+
0.8
|
80
|
+
);
|
67
81
|
}
|
68
82
|
}
|
69
83
|
|
@@ -105,7 +119,7 @@
|
|
105
119
|
return;
|
106
120
|
}
|
107
121
|
media_recorder = new MediaRecorder(stream, {
|
108
|
-
mimeType: mimeType
|
122
|
+
mimeType: mimeType
|
109
123
|
});
|
110
124
|
media_recorder.addEventListener("dataavailable", function (e) {
|
111
125
|
recorded_blobs.push(e.data);
|
@@ -124,6 +138,46 @@
|
|
124
138
|
}
|
125
139
|
}, 500);
|
126
140
|
}
|
141
|
+
|
142
|
+
async function select_source(): Promise<void> {
|
143
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
144
|
+
video_sources = devices.filter((device) => device.kind === "videoinput");
|
145
|
+
options_open = true;
|
146
|
+
}
|
147
|
+
|
148
|
+
let video_sources: MediaDeviceInfo[] = [];
|
149
|
+
async function selectVideoSource(device_id: string): Promise<void> {
|
150
|
+
await access_webcam(device_id);
|
151
|
+
options_open = false;
|
152
|
+
}
|
153
|
+
|
154
|
+
let options_open = false;
|
155
|
+
|
156
|
+
export function click_outside(node: Node, cb: any): any {
|
157
|
+
const handle_click = (event: MouseEvent): void => {
|
158
|
+
if (
|
159
|
+
node &&
|
160
|
+
!node.contains(event.target as Node) &&
|
161
|
+
!event.defaultPrevented
|
162
|
+
) {
|
163
|
+
cb(event);
|
164
|
+
}
|
165
|
+
};
|
166
|
+
|
167
|
+
document.addEventListener("click", handle_click, true);
|
168
|
+
|
169
|
+
return {
|
170
|
+
destroy() {
|
171
|
+
document.removeEventListener("click", handle_click, true);
|
172
|
+
}
|
173
|
+
};
|
174
|
+
}
|
175
|
+
|
176
|
+
function handle_click_outside(event: MouseEvent): void {
|
177
|
+
event.preventDefault();
|
178
|
+
event.stopPropagation();
|
179
|
+
options_open = false;
|
180
|
+
}
|
127
181
|
</script>
|
128
182
|
|
129
183
|
<div class="wrap">
|
@@ -131,26 +185,59 @@
|
|
131
185
|
<!-- need to suppress for video streaming https://github.com/sveltejs/svelte/issues/5967 -->
|
132
186
|
<video bind:this={video_source} class:flip={mirror_webcam} />
|
133
187
|
{#if !streaming}
|
134
|
-
<button
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
{#if
|
140
|
-
|
141
|
-
<
|
142
|
-
|
188
|
+
<div class:capture={!recording} class="button-wrap">
|
189
|
+
<button
|
190
|
+
on:click={mode === "image" ? take_picture : take_recording}
|
191
|
+
aria-label={mode === "image" ? "capture photo" : "start recording"}
|
192
|
+
>
|
193
|
+
{#if mode === "video"}
|
194
|
+
{#if recording}
|
195
|
+
<div class="icon" title="stop recording">
|
196
|
+
<Square />
|
197
|
+
</div>
|
198
|
+
{:else}
|
199
|
+
<div class="icon" title="start recording">
|
200
|
+
<Circle />
|
201
|
+
</div>
|
202
|
+
{/if}
|
143
203
|
{:else}
|
144
|
-
<div class="icon" title="
|
145
|
-
<
|
204
|
+
<div class="icon" title="capture photo">
|
205
|
+
<Camera />
|
146
206
|
</div>
|
147
207
|
{/if}
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
208
|
+
</button>
|
209
|
+
|
210
|
+
{#if !recording}
|
211
|
+
<button
|
212
|
+
on:click={select_source}
|
213
|
+
aria-label={mode === "image" ? "capture photo" : "start recording"}
|
214
|
+
>
|
215
|
+
<div class="icon" title="select video source">
|
216
|
+
<DropdownArrow />
|
217
|
+
</div>
|
218
|
+
|
219
|
+
{#if options_open}
|
220
|
+
<div class="select-wrap" use:click_outside={handle_click_outside}>
|
221
|
+
<!-- svelte-ignore a11y-click-events-have-key-events-->
|
222
|
+
<!-- svelte-ignore a11y-no-static-element-interactions-->
|
223
|
+
<span
|
224
|
+
class="inset-icon"
|
225
|
+
on:click|stopPropagation={() => (options_open = false)}
|
226
|
+
>
|
227
|
+
<DropdownArrow />
|
228
|
+
</span>
|
229
|
+
{#each video_sources as source}
|
230
|
+
<!-- svelte-ignore a11y-click-events-have-key-events-->
|
231
|
+
<!-- svelte-ignore a11y-no-static-element-interactions-->
|
232
|
+
<div on:click={() => selectVideoSource(source.deviceId)}>
|
233
|
+
{source.label}
|
234
|
+
</div>
|
235
|
+
{/each}
|
236
|
+
</div>
|
237
|
+
{/if}
|
238
|
+
</button>
|
152
239
|
{/if}
|
153
|
-
</
|
240
|
+
</div>
|
154
241
|
{/if}
|
155
242
|
</div>
|
156
243
|
|
@@ -167,7 +254,7 @@
|
|
167
254
|
height: var(--size-full);
|
168
255
|
}
|
169
256
|
|
170
|
-
button {
|
257
|
+
.button-wrap {
|
171
258
|
display: flex;
|
172
259
|
position: absolute;
|
173
260
|
right: 0px;
|
@@ -180,7 +267,15 @@
|
|
180
267
|
border-radius: var(--radius-xl);
|
181
268
|
background-color: rgba(0, 0, 0, 0.9);
|
182
269
|
width: var(--size-10);
|
183
|
-
height: var(--size-
|
270
|
+
height: var(--size-8);
|
271
|
+
padding: var(--size-2-5);
|
272
|
+
padding-right: var(--size-1);
|
273
|
+
z-index: var(--layer-3);
|
274
|
+
}
|
275
|
+
|
276
|
+
.capture {
|
277
|
+
width: var(--size-14);
|
278
|
+
transform: translateX(var(--size-2-5));
|
184
279
|
}
|
185
280
|
|
186
281
|
@media (--screen-md) {
|
@@ -197,12 +292,60 @@
|
|
197
292
|
|
198
293
|
.icon {
|
199
294
|
opacity: 0.8;
|
200
|
-
width:
|
201
|
-
height:
|
295
|
+
width: 100%;
|
296
|
+
height: 100%;
|
202
297
|
color: white;
|
298
|
+
display: flex;
|
299
|
+
justify-content: space-between;
|
300
|
+
align-items: center;
|
203
301
|
}
|
204
302
|
|
205
303
|
.flip {
|
206
304
|
transform: scaleX(-1);
|
207
305
|
}
|
306
|
+
|
307
|
+
.select-wrap {
|
308
|
+
-webkit-appearance: none;
|
309
|
+
-moz-appearance: none;
|
310
|
+
appearance: none;
|
311
|
+
background-color: transparent;
|
312
|
+
border: none;
|
313
|
+
width: auto;
|
314
|
+
font-size: 1rem;
|
315
|
+
/* padding: 0.5rem; */
|
316
|
+
width: max-content;
|
317
|
+
position: absolute;
|
318
|
+
top: 0;
|
319
|
+
right: 0;
|
320
|
+
background-color: var(--block-background-fill);
|
321
|
+
box-shadow: var(--shadow-drop-lg);
|
322
|
+
border-radius: var(--radius-xl);
|
323
|
+
z-index: var(--layer-top);
|
324
|
+
border: 1px solid var(--border-color-accent);
|
325
|
+
text-align: left;
|
326
|
+
overflow: hidden;
|
327
|
+
}
|
328
|
+
|
329
|
+
.select-wrap > div {
|
330
|
+
padding: 0.25rem 0.5rem;
|
331
|
+
border-bottom: 1px solid var(--border-color-accent);
|
332
|
+
padding-right: var(--size-8);
|
333
|
+
}
|
334
|
+
|
335
|
+
.select-wrap > div:hover {
|
336
|
+
background-color: var(--color-accent);
|
337
|
+
}
|
338
|
+
|
339
|
+
.select-wrap > div:last-child {
|
340
|
+
border: none;
|
341
|
+
}
|
342
|
+
|
343
|
+
.inset-icon {
|
344
|
+
position: absolute;
|
345
|
+
top: 5px;
|
346
|
+
right: -6.5px;
|
347
|
+
width: var(--size-10);
|
348
|
+
height: var(--size-5);
|
349
|
+
opacity: 0.8;
|
350
|
+
}
|
208
351
|
</style>
|
package/shared/Cropper.svelte
DELETED
@@ -1,33 +0,0 @@
|
|
1
|
-
<svelte:options accessors={true} />
|
2
|
-
|
3
|
-
<script lang="ts">
|
4
|
-
import Cropper from "cropperjs";
|
5
|
-
import { onMount, createEventDispatcher } from "svelte";
|
6
|
-
|
7
|
-
export let image: string;
|
8
|
-
let el: HTMLImageElement;
|
9
|
-
|
10
|
-
const dispatch = createEventDispatcher();
|
11
|
-
let cropper: Cropper;
|
12
|
-
|
13
|
-
export function destroy(): void {
|
14
|
-
cropper.destroy();
|
15
|
-
}
|
16
|
-
|
17
|
-
export function create(): void {
|
18
|
-
if (cropper) {
|
19
|
-
destroy();
|
20
|
-
}
|
21
|
-
cropper = new Cropper(el, {
|
22
|
-
autoCropArea: 1,
|
23
|
-
cropend(): void {
|
24
|
-
const image_data = cropper.getCroppedCanvas().toDataURL();
|
25
|
-
dispatch("crop", image_data);
|
26
|
-
}
|
27
|
-
});
|
28
|
-
|
29
|
-
dispatch("crop", image);
|
30
|
-
}
|
31
|
-
</script>
|
32
|
-
|
33
|
-
<img src={image} bind:this={el} alt="" />
|