|
<script lang="ts"> |
|
import { createEventDispatcher, onMount } from "svelte"; |
|
import { Camera, Circle, Square, DropdownArrow } from "@gradio/icons"; |
|
import type { I18nFormatter } from "@gradio/utils"; |
|
import type { FileData } from "@gradio/client"; |
|
import { prepare_files, upload } from "@gradio/client"; |
|
|
|
let video_source: HTMLVideoElement; |
|
let canvas: HTMLCanvasElement; |
|
export let streaming = false; |
|
export let pending = false; |
|
export let root = ""; |
|
|
|
export let mode: "image" | "video" = "image"; |
|
export let mirror_webcam: boolean; |
|
export let include_audio: boolean; |
|
export let i18n: I18nFormatter; |
|
|
|
const dispatch = createEventDispatcher<{ |
|
stream: undefined; |
|
capture: FileData | Blob | null; |
|
error: string; |
|
start_recording: undefined; |
|
stop_recording: undefined; |
|
}>(); |
|
|
|
onMount(() => (canvas = document.createElement("canvas"))); |
|
const size = { |
|
width: { ideal: 1920 }, |
|
height: { ideal: 1440 } |
|
}; |
|
async function access_webcam(device_id?: string): Promise<void> { |
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { |
|
dispatch("error", i18n("image.no_webcam_support")); |
|
return; |
|
} |
|
try { |
|
stream = await navigator.mediaDevices.getUserMedia({ |
|
video: device_id ? { deviceId: { exact: device_id }, ...size } : size, |
|
audio: include_audio |
|
}); |
|
video_source.srcObject = stream; |
|
video_source.muted = true; |
|
video_source.play(); |
|
} catch (err) { |
|
if (err instanceof DOMException && err.name == "NotAllowedError") { |
|
dispatch("error", i18n("image.allow_webcam_access")); |
|
} else { |
|
throw err; |
|
} |
|
} |
|
} |
|
|
|
function take_picture(): void { |
|
var context = canvas.getContext("2d")!; |
|
|
|
if (video_source.videoWidth && video_source.videoHeight) { |
|
canvas.width = video_source.videoWidth; |
|
canvas.height = video_source.videoHeight; |
|
context.drawImage( |
|
video_source, |
|
0, |
|
0, |
|
video_source.videoWidth, |
|
video_source.videoHeight |
|
); |
|
|
|
if (mirror_webcam) { |
|
context.scale(-1, 1); |
|
context.drawImage(video_source, -video_source.videoWidth, 0); |
|
} |
|
|
|
canvas.toBlob( |
|
(blob) => { |
|
dispatch(streaming ? "stream" : "capture", blob); |
|
}, |
|
"image/png", |
|
0.8 |
|
); |
|
} |
|
} |
|
|
|
let recording = false; |
|
let recorded_blobs: BlobPart[] = []; |
|
let stream: MediaStream; |
|
let mimeType: string; |
|
let media_recorder: MediaRecorder; |
|
|
|
function take_recording(): void { |
|
if (recording) { |
|
media_recorder.stop(); |
|
let video_blob = new Blob(recorded_blobs, { type: mimeType }); |
|
let ReaderObj = new FileReader(); |
|
ReaderObj.onload = async function (e): Promise<void> { |
|
if (e.target) { |
|
let _video_blob = new File( |
|
[video_blob], |
|
"sample." + mimeType.substring(6) |
|
); |
|
const val = await prepare_files([_video_blob]); |
|
let value = ( |
|
(await upload(val, root))?.filter(Boolean) as FileData[] |
|
)[0]; |
|
dispatch("capture", value); |
|
dispatch("stop_recording"); |
|
} |
|
}; |
|
ReaderObj.readAsDataURL(video_blob); |
|
} else { |
|
dispatch("start_recording"); |
|
recorded_blobs = []; |
|
let validMimeTypes = ["video/webm", "video/mp4"]; |
|
for (let validMimeType of validMimeTypes) { |
|
if (MediaRecorder.isTypeSupported(validMimeType)) { |
|
mimeType = validMimeType; |
|
break; |
|
} |
|
} |
|
if (mimeType === null) { |
|
console.error("No supported MediaRecorder mimeType"); |
|
return; |
|
} |
|
media_recorder = new MediaRecorder(stream, { |
|
mimeType: mimeType |
|
}); |
|
media_recorder.addEventListener("dataavailable", function (e) { |
|
recorded_blobs.push(e.data); |
|
}); |
|
media_recorder.start(200); |
|
} |
|
recording = !recording; |
|
} |
|
|
|
access_webcam(); |
|
|
|
if (streaming && mode === "image") { |
|
window.setInterval(() => { |
|
if (video_source && !pending) { |
|
take_picture(); |
|
} |
|
}, 500); |
|
} |
|
|
|
async function select_source(): Promise<void> { |
|
const devices = await navigator.mediaDevices.enumerateDevices(); |
|
video_sources = devices.filter((device) => device.kind === "videoinput"); |
|
options_open = true; |
|
} |
|
|
|
let video_sources: MediaDeviceInfo[] = []; |
|
async function selectVideoSource(device_id: string): Promise<void> { |
|
await access_webcam(device_id); |
|
options_open = false; |
|
} |
|
|
|
let options_open = false; |
|
|
|
export function click_outside(node: Node, cb: any): any { |
|
const handle_click = (event: MouseEvent): void => { |
|
if ( |
|
node && |
|
!node.contains(event.target as Node) && |
|
!event.defaultPrevented |
|
) { |
|
cb(event); |
|
} |
|
}; |
|
|
|
document.addEventListener("click", handle_click, true); |
|
|
|
return { |
|
destroy() { |
|
document.removeEventListener("click", handle_click, true); |
|
} |
|
}; |
|
} |
|
|
|
function handle_click_outside(event: MouseEvent): void { |
|
event.preventDefault(); |
|
event.stopPropagation(); |
|
options_open = false; |
|
} |
|
</script> |
|
|
|
<div class="wrap"> |
|
|
|
|
|
<video bind:this={video_source} class:flip={mirror_webcam} /> |
|
{#if !streaming} |
|
<div class="button-wrap"> |
|
<button |
|
on:click={mode === "image" ? take_picture : take_recording} |
|
aria-label={mode === "image" ? "capture photo" : "start recording"} |
|
> |
|
{#if mode === "video"} |
|
{#if recording} |
|
<div class="icon red" title="stop recording"> |
|
<Square /> |
|
</div> |
|
{:else} |
|
<div class="icon red" title="start recording"> |
|
<Circle /> |
|
</div> |
|
{/if} |
|
{:else} |
|
<div class="icon" title="capture photo"> |
|
<Camera /> |
|
</div> |
|
{/if} |
|
</button> |
|
|
|
{#if !recording} |
|
<button |
|
on:click={select_source} |
|
aria-label={mode === "image" ? "capture photo" : "start recording"} |
|
> |
|
<div class="icon" title="select video source"> |
|
<DropdownArrow /> |
|
</div> |
|
</button> |
|
{/if} |
|
</div> |
|
{#if options_open} |
|
<select |
|
class="select-wrap" |
|
aria-label="select source" |
|
use:click_outside={handle_click_outside} |
|
> |
|
<button |
|
class="inset-icon" |
|
on:click|stopPropagation={() => (options_open = false)} |
|
> |
|
<DropdownArrow /> |
|
</button> |
|
{#if video_sources.length === 0} |
|
<option value="">{i18n("common.no_devices")}</option> |
|
{:else} |
|
{#each video_sources as source} |
|
<option on:click={() => selectVideoSource(source.deviceId)}> |
|
{source.label} |
|
</option> |
|
{/each} |
|
{/if} |
|
</select> |
|
{/if} |
|
{/if} |
|
</div> |
|
|
|
<style> |
|
.wrap { |
|
position: relative; |
|
width: var(--size-full); |
|
height: var(--size-full); |
|
} |
|
|
|
video { |
|
width: var(--size-full); |
|
height: var(--size-full); |
|
object-fit: cover; |
|
} |
|
|
|
.button-wrap { |
|
position: absolute; |
|
background-color: var(--block-background-fill); |
|
border: 1px solid var(--border-color-primary); |
|
border-radius: var(--radius-xl); |
|
padding: var(--size-1-5); |
|
display: flex; |
|
bottom: var(--size-2); |
|
left: 50%; |
|
transform: translate(-50%, 0); |
|
box-shadow: var(--shadow-drop-lg); |
|
border-radius: var(--radius-xl); |
|
line-height: var(--size-3); |
|
color: var(--button-secondary-text-color); |
|
} |
|
|
|
@media (--screen-md) { |
|
button { |
|
bottom: var(--size-4); |
|
} |
|
} |
|
|
|
@media (--screen-xl) { |
|
button { |
|
bottom: var(--size-8); |
|
} |
|
} |
|
|
|
.icon { |
|
opacity: 0.8; |
|
width: 18px; |
|
height: 18px; |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
} |
|
|
|
.red { |
|
fill: red; |
|
stroke: red; |
|
} |
|
|
|
.flip { |
|
transform: scaleX(-1); |
|
} |
|
|
|
.select-wrap { |
|
-webkit-appearance: none; |
|
-moz-appearance: none; |
|
appearance: none; |
|
color: var(--button-secondary-text-color); |
|
background-color: transparent; |
|
width: 95%; |
|
font-size: var(--text-md); |
|
position: absolute; |
|
bottom: var(--size-2); |
|
background-color: var(--block-background-fill); |
|
box-shadow: var(--shadow-drop-lg); |
|
border-radius: var(--radius-xl); |
|
z-index: var(--layer-top); |
|
border: 1px solid var(--border-color-primary); |
|
text-align: left; |
|
line-height: var(--size-4); |
|
white-space: nowrap; |
|
text-overflow: ellipsis; |
|
left: 50%; |
|
transform: translate(-50%, 0); |
|
max-width: var(--size-52); |
|
} |
|
|
|
.select-wrap > option { |
|
padding: 0.25rem 0.5rem; |
|
border-bottom: 1px solid var(--border-color-accent); |
|
padding-right: var(--size-8); |
|
text-overflow: ellipsis; |
|
overflow: hidden; |
|
} |
|
|
|
.select-wrap > option:hover { |
|
background-color: var(--color-accent); |
|
} |
|
|
|
.select-wrap > option:last-child { |
|
border: none; |
|
} |
|
|
|
.inset-icon { |
|
position: absolute; |
|
top: 5px; |
|
right: -6.5px; |
|
width: var(--size-10); |
|
height: var(--size-5); |
|
opacity: 0.8; |
|
} |
|
</style> |
|
|