Spaces:
Running
Running
added screen recording and upload options
Browse files- .gitignore +15 -0
- package-lock.json +0 -0
- package.json +2 -2
- src/App.tsx +103 -27
- src/components/WelcomeScreen.tsx +66 -17
.gitignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
|
| 4 |
+
# Python
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.pyc
|
| 7 |
+
|
| 8 |
+
# OS files
|
| 9 |
+
.DS_Store
|
| 10 |
+
|
| 11 |
+
# Large binaries
|
| 12 |
+
*.onnx
|
| 13 |
+
*.bin
|
| 14 |
+
*.pt
|
| 15 |
+
*.mp4
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
CHANGED
|
@@ -19,8 +19,8 @@
|
|
| 19 |
},
|
| 20 |
"devDependencies": {
|
| 21 |
"@eslint/js": "^9.29.0",
|
| 22 |
-
"@types/react": "^19.1.
|
| 23 |
-
"@types/react-dom": "^19.1.
|
| 24 |
"@vitejs/plugin-react": "^4.5.2",
|
| 25 |
"@webgpu/types": "^0.1.63",
|
| 26 |
"eslint": "^9.29.0",
|
|
|
|
| 19 |
},
|
| 20 |
"devDependencies": {
|
| 21 |
"@eslint/js": "^9.29.0",
|
| 22 |
+
"@types/react": "^19.1.12",
|
| 23 |
+
"@types/react-dom": "^19.1.9",
|
| 24 |
"@vitejs/plugin-react": "^4.5.2",
|
| 25 |
"@webgpu/types": "^0.1.63",
|
| 26 |
"eslint": "^9.29.0",
|
src/App.tsx
CHANGED
|
@@ -6,13 +6,21 @@ import WebcamPermissionDialog from "./components/WebcamPermissionDialog";
|
|
| 6 |
import type { AppState } from "./types";
|
| 7 |
|
| 8 |
export default function App() {
|
| 9 |
-
const [appState, setAppState] = useState<AppState>("
|
| 10 |
-
const [
|
|
|
|
| 11 |
const [isVideoReady, setIsVideoReady] = useState(false);
|
| 12 |
const videoRef = useRef<HTMLVideoElement | null>(null);
|
| 13 |
|
| 14 |
const handlePermissionGranted = useCallback((stream: MediaStream) => {
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
setAppState("welcome");
|
| 17 |
}, []);
|
| 18 |
|
|
@@ -33,8 +41,17 @@ export default function App() {
|
|
| 33 |
}, []);
|
| 34 |
|
| 35 |
const setupVideo = useCallback(
|
| 36 |
-
(video: HTMLVideoElement,
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
const handleCanPlay = () => {
|
| 40 |
setIsVideoReady(true);
|
|
@@ -51,16 +68,68 @@ export default function App() {
|
|
| 51 |
);
|
| 52 |
|
| 53 |
useEffect(() => {
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
}
|
| 63 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
const videoBlurState = useMemo(() => {
|
| 66 |
switch (appState) {
|
|
@@ -81,25 +150,32 @@ export default function App() {
|
|
| 81 |
<div className="App relative h-screen overflow-hidden">
|
| 82 |
<div className="absolute inset-0 bg-gray-900" />
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
)}
|
| 97 |
|
| 98 |
{appState !== "captioning" && <div className="absolute inset-0 bg-gray-900/80 backdrop-blur-sm" />}
|
| 99 |
|
| 100 |
{appState === "requesting-permission" && <WebcamPermissionDialog onPermissionGranted={handlePermissionGranted} />}
|
| 101 |
|
| 102 |
-
{appState === "welcome" &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
{appState === "loading" && <LoadingScreen onComplete={handleLoadingComplete} />}
|
| 105 |
|
|
|
|
| 6 |
import type { AppState } from "./types";
|
| 7 |
|
| 8 |
export default function App() {
|
| 9 |
+
const [appState, setAppState] = useState<AppState>("welcome");
|
| 10 |
+
const [mediaStream, setMediaStream] = useState<MediaStream | null>(null);
|
| 11 |
+
const [fileUrl, setFileUrl] = useState<string | null>(null);
|
| 12 |
const [isVideoReady, setIsVideoReady] = useState(false);
|
| 13 |
const videoRef = useRef<HTMLVideoElement | null>(null);
|
| 14 |
|
| 15 |
const handlePermissionGranted = useCallback((stream: MediaStream) => {
|
| 16 |
+
// Clean up any previous source
|
| 17 |
+
setFileUrl((prev: string | null) => {
|
| 18 |
+
if (prev) URL.revokeObjectURL(prev);
|
| 19 |
+
return null;
|
| 20 |
+
});
|
| 21 |
+
stopStreamTracks(mediaStream);
|
| 22 |
+
|
| 23 |
+
setMediaStream(stream);
|
| 24 |
setAppState("welcome");
|
| 25 |
}, []);
|
| 26 |
|
|
|
|
| 41 |
}, []);
|
| 42 |
|
| 43 |
const setupVideo = useCallback(
|
| 44 |
+
(video: HTMLVideoElement, source: { stream?: MediaStream; url?: string | null }) => {
|
| 45 |
+
// Reset current src/srcObject first
|
| 46 |
+
video.pause();
|
| 47 |
+
video.src = "";
|
| 48 |
+
(video as HTMLVideoElement & { srcObject: MediaStream | null }).srcObject = null;
|
| 49 |
+
|
| 50 |
+
if (source.stream) {
|
| 51 |
+
(video as HTMLVideoElement & { srcObject: MediaStream | null }).srcObject = source.stream;
|
| 52 |
+
} else if (source.url) {
|
| 53 |
+
video.src = source.url;
|
| 54 |
+
}
|
| 55 |
|
| 56 |
const handleCanPlay = () => {
|
| 57 |
setIsVideoReady(true);
|
|
|
|
| 68 |
);
|
| 69 |
|
| 70 |
useEffect(() => {
|
| 71 |
+
const video = videoRef.current;
|
| 72 |
+
if (!video) return;
|
| 73 |
+
|
| 74 |
+
const cleanup = setupVideo(video, { stream: mediaStream ?? undefined, url: fileUrl });
|
| 75 |
+
return cleanup;
|
| 76 |
+
}, [mediaStream, fileUrl, setupVideo]);
|
| 77 |
+
|
| 78 |
+
const stopStreamTracks = (stream: MediaStream | null) => {
|
| 79 |
+
stream?.getTracks().forEach((t) => {
|
| 80 |
+
try {
|
| 81 |
+
t.stop();
|
| 82 |
+
} catch (e) {
|
| 83 |
+
// ignore
|
| 84 |
+
}
|
| 85 |
+
});
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
const handleSelectCamera = useCallback(() => {
|
| 89 |
+
// Move to permission flow; component will request permission
|
| 90 |
+
setAppState("requesting-permission");
|
| 91 |
+
}, []);
|
| 92 |
|
| 93 |
+
const handleSelectDisplay = useCallback(async () => {
|
| 94 |
+
try {
|
| 95 |
+
// Clean up previous
|
| 96 |
+
stopStreamTracks(mediaStream);
|
| 97 |
+
setMediaStream(null);
|
| 98 |
+
setFileUrl((prev: string | null) => {
|
| 99 |
+
if (prev) URL.revokeObjectURL(prev);
|
| 100 |
+
return null;
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
if (!navigator.mediaDevices?.getDisplayMedia) {
|
| 104 |
+
throw new Error("Screen capture not supported in this browser");
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
const stream = await navigator.mediaDevices.getDisplayMedia({
|
| 108 |
+
video: true,
|
| 109 |
+
audio: false,
|
| 110 |
+
});
|
| 111 |
+
setMediaStream(stream);
|
| 112 |
+
// Ensure we are on welcome where Start is visible
|
| 113 |
+
setAppState("welcome");
|
| 114 |
+
} catch (e) {
|
| 115 |
+
console.error("Failed to start display capture:", e);
|
| 116 |
+
// Stay on welcome; user can retry
|
| 117 |
}
|
| 118 |
+
}, [mediaStream]);
|
| 119 |
+
|
| 120 |
+
const handleSelectFile = useCallback((file: File) => {
|
| 121 |
+
// Clean up previous sources
|
| 122 |
+
stopStreamTracks(mediaStream);
|
| 123 |
+
setMediaStream(null);
|
| 124 |
+
setFileUrl((prev: string | null) => {
|
| 125 |
+
if (prev) URL.revokeObjectURL(prev);
|
| 126 |
+
return null;
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
const url = URL.createObjectURL(file);
|
| 130 |
+
setFileUrl(url);
|
| 131 |
+
setAppState("welcome");
|
| 132 |
+
}, [mediaStream]);
|
| 133 |
|
| 134 |
const videoBlurState = useMemo(() => {
|
| 135 |
switch (appState) {
|
|
|
|
| 150 |
<div className="App relative h-screen overflow-hidden">
|
| 151 |
<div className="absolute inset-0 bg-gray-900" />
|
| 152 |
|
| 153 |
+
<video
|
| 154 |
+
ref={videoRef}
|
| 155 |
+
autoPlay
|
| 156 |
+
muted
|
| 157 |
+
playsInline
|
| 158 |
+
loop={Boolean(fileUrl)}
|
| 159 |
+
className="absolute inset-0 w-full h-full object-cover transition-all duration-1000 ease-out"
|
| 160 |
+
style={{
|
| 161 |
+
filter: videoBlurState,
|
| 162 |
+
opacity: isVideoReady ? 1 : 0,
|
| 163 |
+
}}
|
| 164 |
+
/>
|
|
|
|
| 165 |
|
| 166 |
{appState !== "captioning" && <div className="absolute inset-0 bg-gray-900/80 backdrop-blur-sm" />}
|
| 167 |
|
| 168 |
{appState === "requesting-permission" && <WebcamPermissionDialog onPermissionGranted={handlePermissionGranted} />}
|
| 169 |
|
| 170 |
+
{appState === "welcome" && (
|
| 171 |
+
<WelcomeScreen
|
| 172 |
+
onStart={handleStart}
|
| 173 |
+
onSelectCamera={handleSelectCamera}
|
| 174 |
+
onSelectDisplay={handleSelectDisplay}
|
| 175 |
+
onSelectFile={handleSelectFile}
|
| 176 |
+
isSourceReady={Boolean(mediaStream) || Boolean(fileUrl)}
|
| 177 |
+
/>
|
| 178 |
+
)}
|
| 179 |
|
| 180 |
{appState === "loading" && <LoadingScreen onComplete={handleLoadingComplete} />}
|
| 181 |
|
src/components/WelcomeScreen.tsx
CHANGED
|
@@ -2,12 +2,24 @@ import HfIcon from "./HfIcon";
|
|
| 2 |
import GlassContainer from "./GlassContainer";
|
| 3 |
import GlassButton from "./GlassButton";
|
| 4 |
import { GLASS_EFFECTS } from "../constants";
|
|
|
|
| 5 |
|
| 6 |
interface WelcomeScreenProps {
|
| 7 |
onStart: () => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
}
|
| 9 |
|
| 10 |
-
export default function WelcomeScreen({ onStart }: WelcomeScreenProps) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
return (
|
| 12 |
<div className="absolute inset-0 text-white flex items-center justify-center p-8">
|
| 13 |
<div className="max-w-2xl w-full space-y-8">
|
|
@@ -33,18 +45,64 @@ export default function WelcomeScreen({ onStart }: WelcomeScreenProps) {
|
|
| 33 |
</div>
|
| 34 |
</GlassContainer>
|
| 35 |
|
| 36 |
-
{/*
|
| 37 |
<GlassContainer
|
| 38 |
bgColor={GLASS_EFFECTS.COLORS.SUCCESS_BG}
|
| 39 |
className="rounded-2xl shadow-2xl hover:scale-105 transition-transform duration-200"
|
| 40 |
-
role="
|
| 41 |
-
aria-label="
|
| 42 |
>
|
| 43 |
-
<div className="p-4">
|
| 44 |
-
<div className="flex items-center justify-center
|
| 45 |
-
<
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
</div>
|
| 49 |
</GlassContainer>
|
| 50 |
|
|
@@ -104,16 +162,7 @@ export default function WelcomeScreen({ onStart }: WelcomeScreenProps) {
|
|
| 104 |
</div>
|
| 105 |
</GlassContainer>
|
| 106 |
|
| 107 |
-
{/* Start Button */}
|
| 108 |
<div className="flex flex-col items-center space-y-4">
|
| 109 |
-
<GlassButton
|
| 110 |
-
onClick={onStart}
|
| 111 |
-
className="px-8 py-4 rounded-2xl"
|
| 112 |
-
aria-label="Start live captioning with AI model"
|
| 113 |
-
>
|
| 114 |
-
<span className="font-semibold text-lg">Start Live Captioning</span>
|
| 115 |
-
</GlassButton>
|
| 116 |
-
|
| 117 |
<p className="text-sm text-gray-400 opacity-80">AI model will load when you click start</p>
|
| 118 |
</div>
|
| 119 |
</div>
|
|
|
|
| 2 |
import GlassContainer from "./GlassContainer";
|
| 3 |
import GlassButton from "./GlassButton";
|
| 4 |
import { GLASS_EFFECTS } from "../constants";
|
| 5 |
+
import type React from "react";
|
| 6 |
|
| 7 |
interface WelcomeScreenProps {
|
| 8 |
onStart: () => void;
|
| 9 |
+
onSelectCamera?: () => void;
|
| 10 |
+
onSelectDisplay?: () => void;
|
| 11 |
+
onSelectFile?: (file: File) => void;
|
| 12 |
+
isSourceReady?: boolean;
|
| 13 |
}
|
| 14 |
|
| 15 |
+
export default function WelcomeScreen({ onStart, onSelectCamera, onSelectDisplay, onSelectFile, isSourceReady }: WelcomeScreenProps) {
|
| 16 |
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 17 |
+
const file = e.target.files?.[0];
|
| 18 |
+
if (file && onSelectFile) onSelectFile(file);
|
| 19 |
+
// Reset input so selecting the same file again re-triggers change
|
| 20 |
+
e.currentTarget.value = "";
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
return (
|
| 24 |
<div className="absolute inset-0 text-white flex items-center justify-center p-8">
|
| 25 |
<div className="max-w-2xl w-full space-y-8">
|
|
|
|
| 45 |
</div>
|
| 46 |
</GlassContainer>
|
| 47 |
|
| 48 |
+
{/* Source Selection Card */}
|
| 49 |
<GlassContainer
|
| 50 |
bgColor={GLASS_EFFECTS.COLORS.SUCCESS_BG}
|
| 51 |
className="rounded-2xl shadow-2xl hover:scale-105 transition-transform duration-200"
|
| 52 |
+
role="region"
|
| 53 |
+
aria-label="Video source selection"
|
| 54 |
>
|
| 55 |
+
<div className="p-6 flex flex-col items-center gap-4">
|
| 56 |
+
<div className="flex flex-wrap items-center justify-center gap-3">
|
| 57 |
+
<GlassButton
|
| 58 |
+
onClick={(e) => {
|
| 59 |
+
e.preventDefault();
|
| 60 |
+
onSelectCamera?.();
|
| 61 |
+
}}
|
| 62 |
+
className="px-4 py-3 rounded-2xl"
|
| 63 |
+
aria-label="Use Camera"
|
| 64 |
+
>
|
| 65 |
+
Use Camera
|
| 66 |
+
</GlassButton>
|
| 67 |
+
<GlassButton
|
| 68 |
+
onClick={(e) => {
|
| 69 |
+
e.preventDefault();
|
| 70 |
+
onSelectDisplay?.();
|
| 71 |
+
}}
|
| 72 |
+
className="px-4 py-3 rounded-2xl"
|
| 73 |
+
aria-label="Share Tab or Screen"
|
| 74 |
+
>
|
| 75 |
+
Share Tab/Screen
|
| 76 |
+
</GlassButton>
|
| 77 |
+
<div>
|
| 78 |
+
<input
|
| 79 |
+
id="video-file-input"
|
| 80 |
+
type="file"
|
| 81 |
+
accept="video/*"
|
| 82 |
+
onChange={handleFileChange}
|
| 83 |
+
className="hidden"
|
| 84 |
+
/>
|
| 85 |
+
<GlassButton
|
| 86 |
+
onClick={(e) => {
|
| 87 |
+
e.preventDefault();
|
| 88 |
+
document.getElementById("video-file-input")?.click();
|
| 89 |
+
}}
|
| 90 |
+
className="px-4 py-3 rounded-2xl"
|
| 91 |
+
aria-label="Upload Video"
|
| 92 |
+
>
|
| 93 |
+
Upload Video
|
| 94 |
+
</GlassButton>
|
| 95 |
+
</div>
|
| 96 |
</div>
|
| 97 |
+
|
| 98 |
+
<GlassButton
|
| 99 |
+
onClick={onStart}
|
| 100 |
+
className="px-8 py-4 rounded-2xl"
|
| 101 |
+
aria-label="Start live captioning with AI model"
|
| 102 |
+
disabled={!isSourceReady}
|
| 103 |
+
>
|
| 104 |
+
<span className="font-semibold text-lg">Start Live Captioning</span>
|
| 105 |
+
</GlassButton>
|
| 106 |
</div>
|
| 107 |
</GlassContainer>
|
| 108 |
|
|
|
|
| 162 |
</div>
|
| 163 |
</GlassContainer>
|
| 164 |
|
|
|
|
| 165 |
<div className="flex flex-col items-center space-y-4">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
<p className="text-sm text-gray-400 opacity-80">AI model will load when you click start</p>
|
| 167 |
</div>
|
| 168 |
</div>
|