<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"> <!-- svelte-ignore a11y-media-has-caption --> <!-- need to suppress for video streaming https://github.com/sveltejs/svelte/issues/5967 --> <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>