Spaces:
Runtime error
Runtime error
add fullscreen button and aspect ratio selector
Browse files- frontend/src/lib/components/AspectRatioSelect.svelte +27 -0
- frontend/src/lib/components/ImagePlayer.svelte +30 -6
- frontend/src/lib/components/MediaListSwitcher.svelte +14 -7
- frontend/src/lib/components/VideoInput.svelte +14 -15
- frontend/src/lib/icons/aspect.svelte +10 -0
- frontend/src/lib/icons/expand.svelte +10 -0
- frontend/src/lib/mediaStream.ts +27 -5
- frontend/src/lib/utils.ts +43 -0
frontend/src/lib/components/AspectRatioSelect.svelte
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script lang="ts">
|
2 |
+
import { createEventDispatcher } from 'svelte';
|
3 |
+
|
4 |
+
let options: string[] = ['1:1', '16:9', '4:3', '3:2', '3:4', '9:16'];
|
5 |
+
export let aspectRatio: number = 1;
|
6 |
+
const dispatchEvent = createEventDispatcher();
|
7 |
+
|
8 |
+
function onChange(e: Event) {
|
9 |
+
const target = e.target as HTMLSelectElement;
|
10 |
+
const value = target.value;
|
11 |
+
const [width, height] = value.split(':').map((v) => parseInt(v));
|
12 |
+
aspectRatio = width / height;
|
13 |
+
dispatchEvent('change', aspectRatio);
|
14 |
+
}
|
15 |
+
</script>
|
16 |
+
|
17 |
+
<div class="relative">
|
18 |
+
<select
|
19 |
+
on:change={onChange}
|
20 |
+
title="Aspect Ratio"
|
21 |
+
class="border-1 block cursor-pointer rounded-md border-gray-800 border-opacity-50 bg-slate-100 bg-opacity-30 p-1 font-medium text-white"
|
22 |
+
>
|
23 |
+
{#each options as option, i}
|
24 |
+
<option value={option}>{option}</option>
|
25 |
+
{/each}
|
26 |
+
</select>
|
27 |
+
</div>
|
frontend/src/lib/components/ImagePlayer.svelte
CHANGED
@@ -4,11 +4,14 @@
|
|
4 |
|
5 |
import Button from '$lib/components/Button.svelte';
|
6 |
import Floppy from '$lib/icons/floppy.svelte';
|
7 |
-
import
|
|
|
8 |
|
9 |
$: isLCMRunning = $lcmLiveStatus !== LCMLiveStatus.DISCONNECTED;
|
10 |
$: console.log('isLCMRunning', isLCMRunning);
|
11 |
let imageEl: HTMLImageElement;
|
|
|
|
|
12 |
async function takeSnapshot() {
|
13 |
if (isLCMRunning) {
|
14 |
await snapImage(imageEl, {
|
@@ -19,6 +22,18 @@
|
|
19 |
});
|
20 |
}
|
21 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
</script>
|
23 |
|
24 |
<div
|
@@ -26,12 +41,21 @@
|
|
26 |
>
|
27 |
<!-- svelte-ignore a11y-missing-attribute -->
|
28 |
{#if isLCMRunning}
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
|
|
|
|
34 |
<div class="absolute bottom-1 right-1">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
35 |
<Button
|
36 |
on:click={takeSnapshot}
|
37 |
disabled={!isLCMRunning}
|
|
|
4 |
|
5 |
import Button from '$lib/components/Button.svelte';
|
6 |
import Floppy from '$lib/icons/floppy.svelte';
|
7 |
+
import Expand from '$lib/icons/expand.svelte';
|
8 |
+
import { snapImage, expandWindow } from '$lib/utils';
|
9 |
|
10 |
$: isLCMRunning = $lcmLiveStatus !== LCMLiveStatus.DISCONNECTED;
|
11 |
$: console.log('isLCMRunning', isLCMRunning);
|
12 |
let imageEl: HTMLImageElement;
|
13 |
+
let expandedWindow: Window;
|
14 |
+
let isExpanded = false;
|
15 |
async function takeSnapshot() {
|
16 |
if (isLCMRunning) {
|
17 |
await snapImage(imageEl, {
|
|
|
22 |
});
|
23 |
}
|
24 |
}
|
25 |
+
async function toggleFullscreen() {
|
26 |
+
if (isLCMRunning && !isExpanded) {
|
27 |
+
expandedWindow = expandWindow('/api/stream/' + $streamId);
|
28 |
+
expandedWindow.addEventListener('beforeunload', () => {
|
29 |
+
isExpanded = false;
|
30 |
+
});
|
31 |
+
isExpanded = true;
|
32 |
+
} else {
|
33 |
+
expandedWindow?.close();
|
34 |
+
isExpanded = false;
|
35 |
+
}
|
36 |
+
}
|
37 |
</script>
|
38 |
|
39 |
<div
|
|
|
41 |
>
|
42 |
<!-- svelte-ignore a11y-missing-attribute -->
|
43 |
{#if isLCMRunning}
|
44 |
+
{#if !isExpanded}
|
45 |
+
<img
|
46 |
+
bind:this={imageEl}
|
47 |
+
class="aspect-square w-full rounded-lg"
|
48 |
+
src={'/api/stream/' + $streamId}
|
49 |
+
/>
|
50 |
+
{/if}
|
51 |
<div class="absolute bottom-1 right-1">
|
52 |
+
<Button
|
53 |
+
on:click={toggleFullscreen}
|
54 |
+
title={'Expand Fullscreen'}
|
55 |
+
classList={'text-sm ml-auto text-white p-1 shadow-lg rounded-lg opacity-50'}
|
56 |
+
>
|
57 |
+
<Expand classList={''} />
|
58 |
+
</Button>
|
59 |
<Button
|
60 |
on:click={takeSnapshot}
|
61 |
disabled={!isLCMRunning}
|
frontend/src/lib/components/MediaListSwitcher.svelte
CHANGED
@@ -1,21 +1,28 @@
|
|
1 |
<script lang="ts">
|
2 |
import { mediaDevices, mediaStreamActions } from '$lib/mediaStream';
|
3 |
import Screen from '$lib/icons/screen.svelte';
|
|
|
4 |
import { onMount } from 'svelte';
|
5 |
|
6 |
let deviceId: string = '';
|
|
|
|
|
|
|
|
|
|
|
7 |
$: {
|
8 |
-
console.log(
|
9 |
}
|
10 |
$: {
|
11 |
-
console.log(
|
12 |
}
|
13 |
-
onMount(() => {
|
14 |
-
deviceId = $mediaDevices[0].deviceId;
|
15 |
-
});
|
16 |
</script>
|
17 |
|
18 |
-
<div class="flex items-center justify-center text-xs">
|
|
|
|
|
|
|
|
|
19 |
<button
|
20 |
title="Share your screen"
|
21 |
class="border-1 my-1 flex cursor-pointer gap-1 rounded-md border-gray-500 border-opacity-50 bg-slate-100 bg-opacity-30 p-1 font-medium text-white"
|
@@ -28,7 +35,7 @@
|
|
28 |
{#if $mediaDevices}
|
29 |
<select
|
30 |
bind:value={deviceId}
|
31 |
-
on:change={() => mediaStreamActions.switchCamera(deviceId)}
|
32 |
id="devices-list"
|
33 |
class="border-1 block cursor-pointer rounded-md border-gray-800 border-opacity-50 bg-slate-100 bg-opacity-30 p-1 font-medium text-white"
|
34 |
>
|
|
|
1 |
<script lang="ts">
|
2 |
import { mediaDevices, mediaStreamActions } from '$lib/mediaStream';
|
3 |
import Screen from '$lib/icons/screen.svelte';
|
4 |
+
import AspectRatioSelect from './AspectRatioSelect.svelte';
|
5 |
import { onMount } from 'svelte';
|
6 |
|
7 |
let deviceId: string = '';
|
8 |
+
let aspectRatio: number = 1;
|
9 |
+
|
10 |
+
onMount(() => {
|
11 |
+
deviceId = $mediaDevices[0].deviceId;
|
12 |
+
});
|
13 |
$: {
|
14 |
+
console.log(deviceId);
|
15 |
}
|
16 |
$: {
|
17 |
+
console.log(aspectRatio);
|
18 |
}
|
|
|
|
|
|
|
19 |
</script>
|
20 |
|
21 |
+
<div class="flex items-center justify-center text-xs backdrop-blur-sm backdrop-grayscale">
|
22 |
+
<AspectRatioSelect
|
23 |
+
bind:aspectRatio
|
24 |
+
on:change={() => mediaStreamActions.switchCamera(deviceId, aspectRatio)}
|
25 |
+
/>
|
26 |
<button
|
27 |
title="Share your screen"
|
28 |
class="border-1 my-1 flex cursor-pointer gap-1 rounded-md border-gray-500 border-opacity-50 bg-slate-100 bg-opacity-30 p-1 font-medium text-white"
|
|
|
35 |
{#if $mediaDevices}
|
36 |
<select
|
37 |
bind:value={deviceId}
|
38 |
+
on:change={() => mediaStreamActions.switchCamera(deviceId, aspectRatio)}
|
39 |
id="devices-list"
|
40 |
class="border-1 block cursor-pointer rounded-md border-gray-800 border-opacity-50 bg-slate-100 bg-opacity-30 p-1 font-medium text-white"
|
41 |
>
|
frontend/src/lib/components/VideoInput.svelte
CHANGED
@@ -10,6 +10,7 @@
|
|
10 |
mediaDevices
|
11 |
} from '$lib/mediaStream';
|
12 |
import MediaListSwitcher from './MediaListSwitcher.svelte';
|
|
|
13 |
export let width = 512;
|
14 |
export let height = 512;
|
15 |
const size = { width, height };
|
@@ -32,6 +33,7 @@
|
|
32 |
$: {
|
33 |
console.log(selectedDevice);
|
34 |
}
|
|
|
35 |
onDestroy(() => {
|
36 |
if (videoFrameCallbackId) videoEl.cancelVideoFrameCallback(videoFrameCallbackId);
|
37 |
});
|
@@ -47,18 +49,15 @@
|
|
47 |
}
|
48 |
const videoWidth = videoEl.videoWidth;
|
49 |
const videoHeight = videoEl.videoHeight;
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
y0 = (videoHeight - videoWidth) / 2;
|
60 |
-
}
|
61 |
-
ctx.drawImage(videoEl, x0, y0, width0, height0, 0, 0, size.width, size.height);
|
62 |
const blob = await new Promise<Blob>((resolve) => {
|
63 |
canvasEl.toBlob(
|
64 |
(blob) => {
|
@@ -78,14 +77,14 @@
|
|
78 |
</script>
|
79 |
|
80 |
<div class="relative mx-auto max-w-lg overflow-hidden rounded-lg border border-slate-300">
|
81 |
-
<div class="relative z-10 aspect-square w-full object-cover">
|
82 |
{#if $mediaDevices.length > 0}
|
83 |
-
<div class="absolute bottom-0 right-0 z-10">
|
84 |
<MediaListSwitcher />
|
85 |
</div>
|
86 |
{/if}
|
87 |
<video
|
88 |
-
class="pointer-events-none aspect-square w-full object-
|
89 |
bind:this={videoEl}
|
90 |
on:loadeddata={() => {
|
91 |
videoIsReady = true;
|
|
|
10 |
mediaDevices
|
11 |
} from '$lib/mediaStream';
|
12 |
import MediaListSwitcher from './MediaListSwitcher.svelte';
|
13 |
+
|
14 |
export let width = 512;
|
15 |
export let height = 512;
|
16 |
const size = { width, height };
|
|
|
33 |
$: {
|
34 |
console.log(selectedDevice);
|
35 |
}
|
36 |
+
|
37 |
onDestroy(() => {
|
38 |
if (videoFrameCallbackId) videoEl.cancelVideoFrameCallback(videoFrameCallbackId);
|
39 |
});
|
|
|
49 |
}
|
50 |
const videoWidth = videoEl.videoWidth;
|
51 |
const videoHeight = videoEl.videoHeight;
|
52 |
+
// scale down video to fit canvas, size.width, size.height
|
53 |
+
const scale = Math.min(size.width / videoWidth, size.height / videoHeight);
|
54 |
+
const width0 = videoWidth * scale;
|
55 |
+
const height0 = videoHeight * scale;
|
56 |
+
const x0 = (size.width - width0) / 2;
|
57 |
+
const y0 = (size.height - height0) / 2;
|
58 |
+
ctx.clearRect(0, 0, size.width, size.height);
|
59 |
+
ctx.drawImage(videoEl, x0, y0, width0, height0);
|
60 |
+
|
|
|
|
|
|
|
61 |
const blob = await new Promise<Blob>((resolve) => {
|
62 |
canvasEl.toBlob(
|
63 |
(blob) => {
|
|
|
77 |
</script>
|
78 |
|
79 |
<div class="relative mx-auto max-w-lg overflow-hidden rounded-lg border border-slate-300">
|
80 |
+
<div class="relative z-10 flex aspect-square w-full items-center justify-center object-cover">
|
81 |
{#if $mediaDevices.length > 0}
|
82 |
+
<div class="absolute bottom-0 right-0 z-10 w-full bg-slate-400 bg-opacity-40">
|
83 |
<MediaListSwitcher />
|
84 |
</div>
|
85 |
{/if}
|
86 |
<video
|
87 |
+
class="pointer-events-none aspect-square w-full justify-center object-contain"
|
88 |
bind:this={videoEl}
|
89 |
on:loadeddata={() => {
|
90 |
videoIsReady = true;
|
frontend/src/lib/icons/aspect.svelte
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script lang="ts">
|
2 |
+
export let classList: string = '';
|
3 |
+
</script>
|
4 |
+
|
5 |
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" height="16px" class={classList}>
|
6 |
+
<path
|
7 |
+
fill="currentColor"
|
8 |
+
d="M32 32C14.3 32 0 46.3 0 64v96c0 17.7 14.3 32 32 32s32-14.3 32-32V96h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H32zM64 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7 14.3 32 32 32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H64V352zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32h64v64c0 17.7 14.3 32 32 32s32-14.3 32-32V64c0-17.7-14.3-32-32-32H320zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v64H320c-17.7 0-32 14.3-32 32s14.3 32 32 32h96c17.7 0 32-14.3 32-32V352z"
|
9 |
+
/>
|
10 |
+
</svg>
|
frontend/src/lib/icons/expand.svelte
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script lang="ts">
|
2 |
+
export let classList: string = '';
|
3 |
+
</script>
|
4 |
+
|
5 |
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="1em" class={classList}>
|
6 |
+
<path
|
7 |
+
fill="currentColor"
|
8 |
+
d="M.3 89.5C.1 91.6 0 93.8 0 96V224 416c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64V224 96c0-35.3-28.7-64-64-64H64c-2.2 0-4.4 .1-6.5 .3c-9.2 .9-17.8 3.8-25.5 8.2C21.8 46.5 13.4 55.1 7.7 65.5c-3.9 7.3-6.5 15.4-7.4 24zM48 224H464l0 192c0 8.8-7.2 16-16 16L64 432c-8.8 0-16-7.2-16-16l0-192z"
|
9 |
+
/>
|
10 |
+
</svg>
|
frontend/src/lib/mediaStream.ts
CHANGED
@@ -1,5 +1,6 @@
|
|
1 |
-
import { writable, type Writable, get } from 'svelte/store';
|
2 |
|
|
|
3 |
export enum MediaStreamStatusEnum {
|
4 |
INIT = "init",
|
5 |
CONNECTED = "connected",
|
@@ -23,11 +24,17 @@ export const mediaStreamActions = {
|
|
23 |
console.error(err);
|
24 |
});
|
25 |
},
|
26 |
-
async start(mediaDevicedID?: string) {
|
27 |
const constraints = {
|
28 |
audio: false,
|
29 |
video: {
|
30 |
-
width:
|
|
|
|
|
|
|
|
|
|
|
|
|
31 |
}
|
32 |
};
|
33 |
|
@@ -36,6 +43,7 @@ export const mediaStreamActions = {
|
|
36 |
.then((stream) => {
|
37 |
mediaStreamStatus.set(MediaStreamStatusEnum.CONNECTED);
|
38 |
mediaStream.set(stream);
|
|
|
39 |
})
|
40 |
.catch((err) => {
|
41 |
console.error(`${err.name}: ${err.message}`);
|
@@ -65,19 +73,33 @@ export const mediaStreamActions = {
|
|
65 |
console.log(JSON.stringify(videoTrack.getConstraints(), null, 2));
|
66 |
mediaStreamStatus.set(MediaStreamStatusEnum.CONNECTED);
|
67 |
mediaStream.set(captureStream)
|
|
|
|
|
|
|
|
|
68 |
} catch (err) {
|
69 |
console.error(err);
|
70 |
}
|
71 |
|
72 |
},
|
73 |
-
async switchCamera(mediaDevicedID: string) {
|
|
|
74 |
if (get(mediaStreamStatus) !== MediaStreamStatusEnum.CONNECTED) {
|
75 |
return;
|
76 |
}
|
77 |
const constraints = {
|
78 |
audio: false,
|
79 |
-
video: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
80 |
};
|
|
|
81 |
await navigator.mediaDevices
|
82 |
.getUserMedia(constraints)
|
83 |
.then((stream) => {
|
|
|
1 |
+
import { writable, type Writable, type Readable, get, derived } from 'svelte/store';
|
2 |
|
3 |
+
const BASE_HEIGHT = 720;
|
4 |
export enum MediaStreamStatusEnum {
|
5 |
INIT = "init",
|
6 |
CONNECTED = "connected",
|
|
|
24 |
console.error(err);
|
25 |
});
|
26 |
},
|
27 |
+
async start(mediaDevicedID?: string, aspectRatio: number = 1) {
|
28 |
const constraints = {
|
29 |
audio: false,
|
30 |
video: {
|
31 |
+
width: {
|
32 |
+
ideal: BASE_HEIGHT * aspectRatio,
|
33 |
+
},
|
34 |
+
height: {
|
35 |
+
ideal: BASE_HEIGHT,
|
36 |
+
},
|
37 |
+
deviceId: mediaDevicedID
|
38 |
}
|
39 |
};
|
40 |
|
|
|
43 |
.then((stream) => {
|
44 |
mediaStreamStatus.set(MediaStreamStatusEnum.CONNECTED);
|
45 |
mediaStream.set(stream);
|
46 |
+
|
47 |
})
|
48 |
.catch((err) => {
|
49 |
console.error(`${err.name}: ${err.message}`);
|
|
|
73 |
console.log(JSON.stringify(videoTrack.getConstraints(), null, 2));
|
74 |
mediaStreamStatus.set(MediaStreamStatusEnum.CONNECTED);
|
75 |
mediaStream.set(captureStream)
|
76 |
+
|
77 |
+
const capabilities = videoTrack.getCapabilities();
|
78 |
+
const aspectRatio = capabilities.aspectRatio;
|
79 |
+
console.log('Aspect Ratio Constraints:', aspectRatio);
|
80 |
} catch (err) {
|
81 |
console.error(err);
|
82 |
}
|
83 |
|
84 |
},
|
85 |
+
async switchCamera(mediaDevicedID: string, aspectRatio: number) {
|
86 |
+
console.log("Switching camera");
|
87 |
if (get(mediaStreamStatus) !== MediaStreamStatusEnum.CONNECTED) {
|
88 |
return;
|
89 |
}
|
90 |
const constraints = {
|
91 |
audio: false,
|
92 |
+
video: {
|
93 |
+
width: {
|
94 |
+
ideal: BASE_HEIGHT * aspectRatio,
|
95 |
+
},
|
96 |
+
height: {
|
97 |
+
ideal: BASE_HEIGHT,
|
98 |
+
},
|
99 |
+
deviceId: mediaDevicedID
|
100 |
+
}
|
101 |
};
|
102 |
+
console.log("Switching camera", constraints);
|
103 |
await navigator.mediaDevices
|
104 |
.getUserMedia(constraints)
|
105 |
.then((stream) => {
|
frontend/src/lib/utils.ts
CHANGED
@@ -36,3 +36,46 @@ export function snapImage(imageEl: HTMLImageElement, info: IImageInfo) {
|
|
36 |
console.log(err);
|
37 |
}
|
38 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
36 |
console.log(err);
|
37 |
}
|
38 |
}
|
39 |
+
|
40 |
+
export function expandWindow(steramURL: string): Window {
|
41 |
+
const html = `
|
42 |
+
<html>
|
43 |
+
<head>
|
44 |
+
<title>Real-Time Latent Consistency Model</title>
|
45 |
+
<style>
|
46 |
+
body {
|
47 |
+
margin: 0;
|
48 |
+
padding: 0;
|
49 |
+
background-color: black;
|
50 |
+
}
|
51 |
+
</style>
|
52 |
+
</head>
|
53 |
+
<body>
|
54 |
+
<script>
|
55 |
+
let isFullscreen = false;
|
56 |
+
window.onkeydown = function(event) {
|
57 |
+
switch (event.code) {
|
58 |
+
case "Escape":
|
59 |
+
window.close();
|
60 |
+
break;
|
61 |
+
case "Enter":
|
62 |
+
if (isFullscreen) {
|
63 |
+
document.exitFullscreen();
|
64 |
+
isFullscreen = false;
|
65 |
+
} else {
|
66 |
+
document.documentElement.requestFullscreen();
|
67 |
+
isFullscreen = true;
|
68 |
+
}
|
69 |
+
break;
|
70 |
+
}
|
71 |
+
}
|
72 |
+
</script>
|
73 |
+
|
74 |
+
<img src="${steramURL}" style="width: 100%; height: 100%; object-fit: contain;" />
|
75 |
+
</body>
|
76 |
+
</html>
|
77 |
+
`;
|
78 |
+
const newWindow = window.open("", "_blank", "width=1024,height=1024,scrollbars=0,resizable=1,toolbar=0,menubar=0,location=0,directories=0,status=0") as Window;
|
79 |
+
newWindow.document.write(html);
|
80 |
+
return newWindow;
|
81 |
+
}
|