Joffrey Thomas commited on
Commit
6709b20
·
1 Parent(s): 63378c2

Multiple sources

Browse files
src/App.tsx CHANGED
@@ -2,20 +2,33 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
2
  import LoadingScreen from "./components/LoadingScreen";
3
  import CaptioningView from "./components/CaptioningView";
4
  import WelcomeScreen from "./components/WelcomeScreen";
5
- import WebcamPermissionDialog from "./components/WebcamPermissionDialog";
6
- import type { AppState } from "./types";
7
 
8
  export default function App() {
9
- const [appState, setAppState] = useState<AppState>("requesting-permission");
10
- const [webcamStream, setWebcamStream] = useState<MediaStream | null>(null);
11
  const [isVideoReady, setIsVideoReady] = useState(false);
12
  const videoRef = useRef<HTMLVideoElement | null>(null);
13
 
14
- const handlePermissionGranted = useCallback((stream: MediaStream) => {
15
- setWebcamStream(stream);
16
  setAppState("welcome");
17
  }, []);
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  const handleStart = useCallback(() => {
20
  setAppState("loading");
21
  }, []);
@@ -33,8 +46,20 @@ export default function App() {
33
  }, []);
34
 
35
  const setupVideo = useCallback(
36
- (video: HTMLVideoElement, stream: MediaStream) => {
37
- video.srcObject = stream;
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  const handleCanPlay = () => {
40
  setIsVideoReady(true);
@@ -43,28 +68,34 @@ export default function App() {
43
 
44
  video.addEventListener("canplay", handleCanPlay, { once: true });
45
 
 
 
 
 
 
 
 
 
 
 
46
  return () => {
47
  video.removeEventListener("canplay", handleCanPlay);
48
  };
49
  },
50
- [playVideo],
51
  );
52
 
53
  useEffect(() => {
54
- if (webcamStream && videoRef.current) {
55
  const video = videoRef.current;
56
-
57
- video.srcObject = null;
58
- video.load();
59
-
60
- const cleanup = setupVideo(video, webcamStream);
61
  return cleanup;
62
  }
63
- }, [webcamStream, setupVideo]);
64
 
65
  const videoBlurState = useMemo(() => {
66
  switch (appState) {
67
- case "requesting-permission":
68
  return "blur(20px) brightness(0.2) saturate(0.5)";
69
  case "welcome":
70
  return "blur(12px) brightness(0.3) saturate(0.7)";
@@ -77,11 +108,25 @@ export default function App() {
77
  }
78
  }, [appState]);
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  return (
81
  <div className="App relative h-screen overflow-hidden bg-[#FFFAEB]">
82
  <div className="absolute inset-0 bg-[#FFFAEB]" />
83
 
84
- {webcamStream && (
85
  <video
86
  ref={videoRef}
87
  autoPlay
@@ -95,21 +140,33 @@ export default function App() {
95
  />
96
  )}
97
 
98
- {appState !== "captioning" && (
99
  <div className="absolute inset-0 bg-[#FFFAEB]/90 backdrop-blur-sm" />
100
  )}
101
 
102
- {appState === "requesting-permission" && (
103
- <WebcamPermissionDialog onPermissionGranted={handlePermissionGranted} />
104
  )}
105
 
106
- {appState === "welcome" && <WelcomeScreen onStart={handleStart} />}
 
 
 
 
 
 
107
 
108
  {appState === "loading" && (
109
  <LoadingScreen onComplete={handleLoadingComplete} />
110
  )}
111
 
112
- {appState === "captioning" && <CaptioningView videoRef={videoRef} />}
 
 
 
 
 
 
113
  </div>
114
  );
115
  }
 
2
  import LoadingScreen from "./components/LoadingScreen";
3
  import CaptioningView from "./components/CaptioningView";
4
  import WelcomeScreen from "./components/WelcomeScreen";
5
+ import SourceSelector from "./components/SourceSelector";
6
+ import type { AppState, VideoSource } from "./types";
7
 
8
  export default function App() {
9
+ const [appState, setAppState] = useState<AppState>("source-selection");
10
+ const [videoSource, setVideoSource] = useState<VideoSource | null>(null);
11
  const [isVideoReady, setIsVideoReady] = useState(false);
12
  const videoRef = useRef<HTMLVideoElement | null>(null);
13
 
14
+ const handleSourceSelected = useCallback((source: VideoSource) => {
15
+ setVideoSource(source);
16
  setAppState("welcome");
17
  }, []);
18
 
19
+ const handleBackToSourceSelection = useCallback(() => {
20
+ // Clean up current source
21
+ if (videoSource?.stream) {
22
+ videoSource.stream.getTracks().forEach(track => track.stop());
23
+ }
24
+ if (videoSource?.url && videoSource.type === "upload") {
25
+ URL.revokeObjectURL(videoSource.url);
26
+ }
27
+ setVideoSource(null);
28
+ setIsVideoReady(false);
29
+ setAppState("source-selection");
30
+ }, [videoSource]);
31
+
32
  const handleStart = useCallback(() => {
33
  setAppState("loading");
34
  }, []);
 
46
  }, []);
47
 
48
  const setupVideo = useCallback(
49
+ (video: HTMLVideoElement, source: VideoSource) => {
50
+ // Reset video element
51
+ video.srcObject = null;
52
+ video.src = "";
53
+ video.load();
54
+
55
+ if (source.stream) {
56
+ // Stream-based sources (webcam, screen share)
57
+ video.srcObject = source.stream;
58
+ } else if (source.url) {
59
+ // URL-based sources (upload, examples)
60
+ video.src = source.url;
61
+ video.loop = true; // Loop for uploaded/example videos
62
+ }
63
 
64
  const handleCanPlay = () => {
65
  setIsVideoReady(true);
 
68
 
69
  video.addEventListener("canplay", handleCanPlay, { once: true });
70
 
71
+ // Handle stream ended (e.g., user stops screen sharing)
72
+ if (source.stream) {
73
+ const track = source.stream.getVideoTracks()[0];
74
+ if (track) {
75
+ track.onended = () => {
76
+ handleBackToSourceSelection();
77
+ };
78
+ }
79
+ }
80
+
81
  return () => {
82
  video.removeEventListener("canplay", handleCanPlay);
83
  };
84
  },
85
+ [playVideo, handleBackToSourceSelection],
86
  );
87
 
88
  useEffect(() => {
89
+ if (videoSource && videoRef.current) {
90
  const video = videoRef.current;
91
+ const cleanup = setupVideo(video, videoSource);
 
 
 
 
92
  return cleanup;
93
  }
94
+ }, [videoSource, setupVideo]);
95
 
96
  const videoBlurState = useMemo(() => {
97
  switch (appState) {
98
+ case "source-selection":
99
  return "blur(20px) brightness(0.2) saturate(0.5)";
100
  case "welcome":
101
  return "blur(12px) brightness(0.3) saturate(0.7)";
 
108
  }
109
  }, [appState]);
110
 
111
+ const getSourceLabel = () => {
112
+ if (!videoSource) return null;
113
+ switch (videoSource.type) {
114
+ case "webcam":
115
+ return "Webcam";
116
+ case "screen":
117
+ return "Screen Share";
118
+ case "upload":
119
+ return videoSource.name || "Uploaded Video";
120
+ case "example":
121
+ return videoSource.name || "Example Video";
122
+ }
123
+ };
124
+
125
  return (
126
  <div className="App relative h-screen overflow-hidden bg-[#FFFAEB]">
127
  <div className="absolute inset-0 bg-[#FFFAEB]" />
128
 
129
+ {videoSource && (
130
  <video
131
  ref={videoRef}
132
  autoPlay
 
140
  />
141
  )}
142
 
143
+ {appState !== "captioning" && appState !== "source-selection" && (
144
  <div className="absolute inset-0 bg-[#FFFAEB]/90 backdrop-blur-sm" />
145
  )}
146
 
147
+ {appState === "source-selection" && (
148
+ <SourceSelector onSourceSelected={handleSourceSelected} />
149
  )}
150
 
151
+ {appState === "welcome" && (
152
+ <WelcomeScreen
153
+ onStart={handleStart}
154
+ onBack={handleBackToSourceSelection}
155
+ sourceLabel={getSourceLabel() ?? undefined}
156
+ />
157
+ )}
158
 
159
  {appState === "loading" && (
160
  <LoadingScreen onComplete={handleLoadingComplete} />
161
  )}
162
 
163
+ {appState === "captioning" && (
164
+ <CaptioningView
165
+ videoRef={videoRef}
166
+ videoSource={videoSource}
167
+ onChangeSource={handleBackToSourceSelection}
168
+ />
169
+ )}
170
  </div>
171
  );
172
  }
src/components/CaptioningView.tsx CHANGED
@@ -3,10 +3,44 @@ import WebcamCapture from "./WebcamCapture";
3
  import PromptInput from "./PromptInput";
4
  import LiveCaption, { type HistoryEntry } from "./LiveCaption";
5
  import { useVLMContext } from "../context/useVLMContext";
6
- import { PROMPTS, TIMING } from "../constants";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  interface CaptioningViewProps {
9
  videoRef: React.RefObject<HTMLVideoElement | null>;
 
 
10
  }
11
 
12
  function useCaptioningLoop(
@@ -94,7 +128,7 @@ function useCaptioningLoop(
94
  }, [isRunning, isLoaded, runInference, promptRef, videoRef]);
95
  }
96
 
97
- export default function CaptioningView({ videoRef }: CaptioningViewProps) {
98
  const { imageSize, setImageSize } = useVLMContext();
99
  const [caption, setCaption] = useState<string>("");
100
  const [isLoopRunning, setIsLoopRunning] = useState<boolean>(true);
@@ -168,6 +202,12 @@ export default function CaptioningView({ videoRef }: CaptioningViewProps) {
168
  if (error) setError(null);
169
  }, [error]);
170
 
 
 
 
 
 
 
171
  return (
172
  <div className="absolute inset-0 text-white">
173
  <div className="relative w-full h-full">
@@ -179,6 +219,36 @@ export default function CaptioningView({ videoRef }: CaptioningViewProps) {
179
  onImageSizeChange={setImageSize}
180
  />
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  {/* Prompt Input - Bottom Left */}
183
  <div className="absolute bottom-5 left-5 z-30 w-[540px]">
184
  <PromptInput onPromptChange={handlePromptChange} />
 
3
  import PromptInput from "./PromptInput";
4
  import LiveCaption, { type HistoryEntry } from "./LiveCaption";
5
  import { useVLMContext } from "../context/useVLMContext";
6
+ import { PROMPTS, TIMING, THEME } from "../constants";
7
+ import type { VideoSource } from "../types";
8
+
9
+ const SOURCE_ICONS: Record<string, React.ReactNode> = {
10
+ webcam: (
11
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
12
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
13
+ </svg>
14
+ ),
15
+ screen: (
16
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
17
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" />
18
+ </svg>
19
+ ),
20
+ upload: (
21
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
22
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
23
+ </svg>
24
+ ),
25
+ example: (
26
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
27
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
28
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.91 11.672a.375.375 0 010 .656l-5.603 3.113a.375.375 0 01-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112z" />
29
+ </svg>
30
+ ),
31
+ };
32
+
33
+ const SOURCE_LABELS: Record<string, string> = {
34
+ webcam: "Webcam",
35
+ screen: "Screen",
36
+ upload: "Upload",
37
+ example: "Example",
38
+ };
39
 
40
  interface CaptioningViewProps {
41
  videoRef: React.RefObject<HTMLVideoElement | null>;
42
+ videoSource?: VideoSource | null;
43
+ onChangeSource?: () => void;
44
  }
45
 
46
  function useCaptioningLoop(
 
128
  }, [isRunning, isLoaded, runInference, promptRef, videoRef]);
129
  }
130
 
131
+ export default function CaptioningView({ videoRef, videoSource, onChangeSource }: CaptioningViewProps) {
132
  const { imageSize, setImageSize } = useVLMContext();
133
  const [caption, setCaption] = useState<string>("");
134
  const [isLoopRunning, setIsLoopRunning] = useState<boolean>(true);
 
202
  if (error) setError(null);
203
  }, [error]);
204
 
205
+ const getSourceLabel = () => {
206
+ if (!videoSource) return "Video";
207
+ if (videoSource.name) return videoSource.name;
208
+ return SOURCE_LABELS[videoSource.type] || "Video";
209
+ };
210
+
211
  return (
212
  <div className="absolute inset-0 text-white">
213
  <div className="relative w-full h-full">
 
219
  onImageSizeChange={setImageSize}
220
  />
221
 
222
+ {/* Source Indicator - Top Left */}
223
+ {videoSource && onChangeSource && (
224
+ <div className="absolute top-5 left-5 z-30">
225
+ <button
226
+ onClick={onChangeSource}
227
+ className="group flex items-center gap-3 px-4 py-2.5 bg-black/60 backdrop-blur-md border border-white/10 hover:border-white/30 transition-all hover:bg-black/70"
228
+ title="Change video source"
229
+ >
230
+ <div
231
+ className="flex items-center justify-center"
232
+ style={{ color: THEME.mistralOrange }}
233
+ >
234
+ {SOURCE_ICONS[videoSource.type]}
235
+ </div>
236
+ <span className="text-sm font-medium text-white/90 max-w-[200px] truncate">
237
+ {getSourceLabel()}
238
+ </span>
239
+ <svg
240
+ className="w-4 h-4 text-white/50 group-hover:text-white/80 transition-colors"
241
+ fill="none"
242
+ viewBox="0 0 24 24"
243
+ stroke="currentColor"
244
+ strokeWidth={2}
245
+ >
246
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
247
+ </svg>
248
+ </button>
249
+ </div>
250
+ )}
251
+
252
  {/* Prompt Input - Bottom Left */}
253
  <div className="absolute bottom-5 left-5 z-30 w-[540px]">
254
  <PromptInput onPromptChange={handlePromptChange} />
src/components/SourceSelector.tsx ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+ import { THEME, EXAMPLE_VIDEOS } from "../constants";
3
+ import type { VideoSourceType, VideoSource } from "../types";
4
+
5
+ type ExampleVideo = (typeof EXAMPLE_VIDEOS)[number];
6
+
7
+ interface SourceSelectorProps {
8
+ onSourceSelected: (source: VideoSource) => void;
9
+ onBack?: () => void;
10
+ }
11
+
12
+ interface SourceCardProps {
13
+ icon: React.ReactNode;
14
+ title: string;
15
+ description: string;
16
+ onClick: () => void;
17
+ isActive?: boolean;
18
+ isLoading?: boolean;
19
+ badge?: string;
20
+ }
21
+
22
+ function SourceCard({ icon, title, description, onClick, isActive, isLoading, badge }: SourceCardProps) {
23
+ return (
24
+ <button
25
+ onClick={onClick}
26
+ disabled={isLoading}
27
+ className={`
28
+ group relative w-full text-left p-6 border-2 transition-all duration-300
29
+ hover:border-[var(--mistral-orange)] hover:shadow-lg hover:-translate-y-1
30
+ ${isActive ? "border-[var(--mistral-orange)] bg-[var(--mistral-orange)]/5" : "border-[var(--beige-dark)]"}
31
+ ${isLoading ? "opacity-50 cursor-wait" : "cursor-pointer"}
32
+ `}
33
+ style={{
34
+ "--mistral-orange": THEME.mistralOrange,
35
+ "--beige-dark": THEME.beigeDark,
36
+ } as React.CSSProperties}
37
+ >
38
+ {badge && (
39
+ <span
40
+ className="absolute -top-2 -right-2 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white"
41
+ style={{ backgroundColor: THEME.mistralOrange }}
42
+ >
43
+ {badge}
44
+ </span>
45
+ )}
46
+
47
+ <div className="flex items-start gap-4">
48
+ <div
49
+ className={`
50
+ w-14 h-14 flex items-center justify-center shrink-0
51
+ transition-all duration-300 group-hover:scale-110
52
+ ${isActive ? "text-white" : "text-[var(--mistral-orange)]"}
53
+ `}
54
+ style={{
55
+ backgroundColor: isActive ? THEME.mistralOrange : `${THEME.mistralOrange}15`,
56
+ }}
57
+ >
58
+ {isLoading ? (
59
+ <svg className="w-6 h-6 animate-spin" viewBox="0 0 24 24" fill="none">
60
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
61
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
62
+ </svg>
63
+ ) : (
64
+ icon
65
+ )}
66
+ </div>
67
+
68
+ <div className="flex-1 min-w-0">
69
+ <h3
70
+ className="font-semibold text-lg mb-1 flex items-center gap-2"
71
+ style={{ color: THEME.textBlack }}
72
+ >
73
+ {title}
74
+ <svg
75
+ className="w-4 h-4 opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300"
76
+ style={{ color: THEME.mistralOrange }}
77
+ fill="none"
78
+ viewBox="0 0 24 24"
79
+ stroke="currentColor"
80
+ strokeWidth={2}
81
+ >
82
+ <path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
83
+ </svg>
84
+ </h3>
85
+ <p className="text-gray-500 text-sm leading-relaxed">{description}</p>
86
+ </div>
87
+ </div>
88
+ </button>
89
+ );
90
+ }
91
+
92
+ function ExampleVideoCard({ video, onClick, isSelected }: { video: ExampleVideo; onClick: () => void; isSelected: boolean }) {
93
+ return (
94
+ <button
95
+ onClick={onClick}
96
+ className={`
97
+ group relative overflow-hidden border-2 transition-all duration-300
98
+ hover:border-[var(--mistral-orange)] hover:shadow-lg
99
+ ${isSelected ? "border-[var(--mistral-orange)] ring-2 ring-[var(--mistral-orange)]/20" : "border-[var(--beige-dark)]"}
100
+ `}
101
+ style={{
102
+ "--mistral-orange": THEME.mistralOrange,
103
+ "--beige-dark": THEME.beigeDark,
104
+ } as React.CSSProperties}
105
+ >
106
+ <div className="aspect-video relative overflow-hidden bg-gray-100">
107
+ <img
108
+ src={video.thumbnail}
109
+ alt={video.name}
110
+ className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
111
+ />
112
+ <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
113
+
114
+ {isSelected && (
115
+ <div
116
+ className="absolute top-2 right-2 w-6 h-6 flex items-center justify-center text-white"
117
+ style={{ backgroundColor: THEME.mistralOrange }}
118
+ >
119
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
120
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
121
+ </svg>
122
+ </div>
123
+ )}
124
+
125
+ <div className="absolute bottom-0 left-0 right-0 p-3">
126
+ <h4 className="font-semibold text-white text-sm">{video.name}</h4>
127
+ <p className="text-white/70 text-xs">{video.description}</p>
128
+ </div>
129
+ </div>
130
+ </button>
131
+ );
132
+ }
133
+
134
+ export default function SourceSelector({ onSourceSelected, onBack }: SourceSelectorProps) {
135
+ const [mounted, setMounted] = useState(false);
136
+ const [activeSource, setActiveSource] = useState<VideoSourceType | null>(null);
137
+ const [isLoading, setIsLoading] = useState(false);
138
+ const [error, setError] = useState<string | null>(null);
139
+ const [selectedExample, setSelectedExample] = useState<string | null>(null);
140
+ const [showExamples, setShowExamples] = useState(false);
141
+ const fileInputRef = useRef<HTMLInputElement>(null);
142
+
143
+ useEffect(() => {
144
+ setMounted(true);
145
+ }, []);
146
+
147
+ const handleWebcam = useCallback(async () => {
148
+ setActiveSource("webcam");
149
+ setIsLoading(true);
150
+ setError(null);
151
+
152
+ try {
153
+ const stream = await navigator.mediaDevices.getUserMedia({
154
+ video: {
155
+ width: { ideal: 1920, max: 1920 },
156
+ height: { ideal: 1080, max: 1080 },
157
+ facingMode: "user",
158
+ },
159
+ });
160
+ onSourceSelected({ type: "webcam", stream });
161
+ } catch (err) {
162
+ setError(err instanceof Error ? err.message : "Failed to access webcam");
163
+ setActiveSource(null);
164
+ } finally {
165
+ setIsLoading(false);
166
+ }
167
+ }, [onSourceSelected]);
168
+
169
+ const handleScreenShare = useCallback(async () => {
170
+ setActiveSource("screen");
171
+ setIsLoading(true);
172
+ setError(null);
173
+
174
+ try {
175
+ const stream = await navigator.mediaDevices.getDisplayMedia({
176
+ video: {
177
+ width: { ideal: 1920 },
178
+ height: { ideal: 1080 },
179
+ },
180
+ });
181
+
182
+ // Handle stream end (user stops sharing)
183
+ stream.getVideoTracks()[0].onended = () => {
184
+ // This will be handled by the parent component
185
+ };
186
+
187
+ onSourceSelected({ type: "screen", stream });
188
+ } catch (err) {
189
+ if ((err as Error).name !== "AbortError") {
190
+ setError(err instanceof Error ? err.message : "Failed to start screen sharing");
191
+ }
192
+ setActiveSource(null);
193
+ } finally {
194
+ setIsLoading(false);
195
+ }
196
+ }, [onSourceSelected]);
197
+
198
+ const handleFileUpload = useCallback(() => {
199
+ fileInputRef.current?.click();
200
+ }, []);
201
+
202
+ const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
203
+ const file = e.target.files?.[0];
204
+ if (file) {
205
+ setActiveSource("upload");
206
+ const url = URL.createObjectURL(file);
207
+ onSourceSelected({ type: "upload", url, name: file.name });
208
+ }
209
+ }, [onSourceSelected]);
210
+
211
+ const handleExampleSelect = useCallback((video: ExampleVideo) => {
212
+ setSelectedExample(video.id);
213
+ setActiveSource("example");
214
+ onSourceSelected({ type: "example", url: video.url, name: video.name });
215
+ }, [onSourceSelected]);
216
+
217
+ const toggleExamples = useCallback(() => {
218
+ setShowExamples(prev => !prev);
219
+ }, []);
220
+
221
+ return (
222
+ <div
223
+ className="absolute inset-0 flex items-center justify-center p-6 overflow-y-auto"
224
+ style={{
225
+ backgroundColor: THEME.beigeLight,
226
+ backgroundImage: `
227
+ linear-gradient(${THEME.beigeDark} 1px, transparent 1px),
228
+ linear-gradient(90deg, ${THEME.beigeDark} 1px, transparent 1px)
229
+ `,
230
+ backgroundSize: "40px 40px",
231
+ color: THEME.textBlack,
232
+ }}
233
+ >
234
+ <input
235
+ ref={fileInputRef}
236
+ type="file"
237
+ accept="video/*"
238
+ onChange={handleFileChange}
239
+ className="hidden"
240
+ />
241
+
242
+ <div
243
+ className={`
244
+ relative max-w-3xl w-full backdrop-blur-sm p-10 border shadow-2xl
245
+ transition-all duration-700
246
+ ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}
247
+ `}
248
+ style={{
249
+ backgroundColor: `${THEME.beigeLight}F2`,
250
+ borderColor: THEME.beigeDark,
251
+ }}
252
+ >
253
+ {/* Header Bar */}
254
+ <div
255
+ className="absolute top-0 left-0 right-0 h-1"
256
+ style={{ backgroundColor: THEME.mistralOrange }}
257
+ />
258
+
259
+ {/* Back Button */}
260
+ {onBack && (
261
+ <button
262
+ onClick={onBack}
263
+ className="absolute top-6 left-6 p-2 text-gray-400 hover:text-gray-600 transition-colors"
264
+ >
265
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
266
+ <path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
267
+ </svg>
268
+ </button>
269
+ )}
270
+
271
+ {/* Header */}
272
+ <div className="text-center mb-10">
273
+ <h2
274
+ className="text-3xl font-bold tracking-tight mb-3"
275
+ style={{ color: THEME.textBlack }}
276
+ >
277
+ Choose Video Source
278
+ </h2>
279
+ <p className="text-gray-500 text-lg">
280
+ Select how you want to provide video for analysis
281
+ </p>
282
+ </div>
283
+
284
+ {/* Error Message */}
285
+ {error && (
286
+ <div
287
+ className="mb-6 p-4 border-l-4 bg-red-50"
288
+ style={{ borderColor: THEME.errorRed }}
289
+ >
290
+ <div className="flex items-center gap-2">
291
+ <svg
292
+ className="w-5 h-5"
293
+ style={{ color: THEME.errorRed }}
294
+ fill="none"
295
+ viewBox="0 0 24 24"
296
+ stroke="currentColor"
297
+ >
298
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
299
+ </svg>
300
+ <span className="text-sm font-medium" style={{ color: THEME.errorRed }}>
301
+ {error}
302
+ </span>
303
+ </div>
304
+ </div>
305
+ )}
306
+
307
+ {/* Source Options Grid */}
308
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
309
+ <SourceCard
310
+ icon={
311
+ <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
312
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
313
+ </svg>
314
+ }
315
+ title="Webcam"
316
+ description="Use your camera for real-time video analysis"
317
+ onClick={handleWebcam}
318
+ isActive={activeSource === "webcam"}
319
+ isLoading={isLoading && activeSource === "webcam"}
320
+ badge="Live"
321
+ />
322
+
323
+ <SourceCard
324
+ icon={
325
+ <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
326
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" />
327
+ </svg>
328
+ }
329
+ title="Screen Share"
330
+ description="Capture your screen, window, or browser tab"
331
+ onClick={handleScreenShare}
332
+ isActive={activeSource === "screen"}
333
+ isLoading={isLoading && activeSource === "screen"}
334
+ />
335
+
336
+ <SourceCard
337
+ icon={
338
+ <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
339
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
340
+ </svg>
341
+ }
342
+ title="Upload Video"
343
+ description="Select a video file from your device"
344
+ onClick={handleFileUpload}
345
+ isActive={activeSource === "upload"}
346
+ />
347
+
348
+ <SourceCard
349
+ icon={
350
+ <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
351
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
352
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.91 11.672a.375.375 0 010 .656l-5.603 3.113a.375.375 0 01-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112z" />
353
+ </svg>
354
+ }
355
+ title="Examples"
356
+ description="Try with pre-loaded sample videos"
357
+ onClick={toggleExamples}
358
+ isActive={showExamples || activeSource === "example"}
359
+ />
360
+ </div>
361
+
362
+ {/* Example Videos Section */}
363
+ {showExamples && (
364
+ <div
365
+ className="border-t pt-6 animate-enter"
366
+ style={{ borderColor: THEME.beigeDark }}
367
+ >
368
+ <h3 className="text-sm font-bold uppercase tracking-wider text-gray-500 mb-4 flex items-center gap-2">
369
+ <span
370
+ className="w-2 h-2"
371
+ style={{ backgroundColor: THEME.mistralOrange }}
372
+ />
373
+ Sample Videos
374
+ </h3>
375
+ <div className="grid grid-cols-3 gap-3">
376
+ {EXAMPLE_VIDEOS.map((video) => (
377
+ <ExampleVideoCard
378
+ key={video.id}
379
+ video={video}
380
+ onClick={() => handleExampleSelect(video)}
381
+ isSelected={selectedExample === video.id}
382
+ />
383
+ ))}
384
+ </div>
385
+ </div>
386
+ )}
387
+
388
+ {/* Footer */}
389
+ <div
390
+ className="mt-8 pt-6 border-t text-center"
391
+ style={{ borderColor: THEME.beigeDark }}
392
+ >
393
+ <p className="text-xs text-gray-400 font-mono uppercase tracking-wider">
394
+ All processing happens locally in your browser
395
+ </p>
396
+ </div>
397
+ </div>
398
+ </div>
399
+ );
400
+ }
401
+
src/components/WelcomeScreen.tsx CHANGED
@@ -5,9 +5,11 @@ import HfIcon from "./HfIcon";
5
 
6
  interface WelcomeScreenProps {
7
  onStart: () => void;
 
 
8
  }
9
 
10
- export default function WelcomeScreen({ onStart }: WelcomeScreenProps) {
11
  const [mounted, setMounted] = useState(false);
12
 
13
  useEffect(() => {
@@ -35,10 +37,25 @@ export default function WelcomeScreen({ onStart }: WelcomeScreenProps) {
35
  borderColor: THEME.beigeDark,
36
  }}
37
  >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  {/* 1. Top Right Status Indicator */}
39
  <div className="absolute top-6 right-6 group cursor-help z-10">
40
  <span className="absolute right-full mr-4 top-1/2 -translate-y-1/2 whitespace-nowrap text-xs font-mono uppercase tracking-widest text-gray-500 opacity-0 group-hover:opacity-100 transition-all duration-300 translate-x-2 group-hover:translate-x-0 pointer-events-none">
41
- System Ready
42
  </span>
43
  <div className="relative flex h-3 w-3">
44
  <span
 
5
 
6
  interface WelcomeScreenProps {
7
  onStart: () => void;
8
+ onBack?: () => void;
9
+ sourceLabel?: string;
10
  }
11
 
12
+ export default function WelcomeScreen({ onStart, onBack, sourceLabel }: WelcomeScreenProps) {
13
  const [mounted, setMounted] = useState(false);
14
 
15
  useEffect(() => {
 
37
  borderColor: THEME.beigeDark,
38
  }}
39
  >
40
+ {/* Back Button */}
41
+ {onBack && (
42
+ <button
43
+ onClick={onBack}
44
+ className="absolute top-6 left-6 p-2 text-gray-400 hover:text-gray-600 transition-colors z-10 flex items-center gap-2 group"
45
+ >
46
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
47
+ <path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
48
+ </svg>
49
+ <span className="text-xs font-mono uppercase tracking-wider opacity-0 group-hover:opacity-100 transition-opacity">
50
+ Change Source
51
+ </span>
52
+ </button>
53
+ )}
54
+
55
  {/* 1. Top Right Status Indicator */}
56
  <div className="absolute top-6 right-6 group cursor-help z-10">
57
  <span className="absolute right-full mr-4 top-1/2 -translate-y-1/2 whitespace-nowrap text-xs font-mono uppercase tracking-widest text-gray-500 opacity-0 group-hover:opacity-100 transition-all duration-300 translate-x-2 group-hover:translate-x-0 pointer-events-none">
58
+ {sourceLabel || "System Ready"}
59
  </span>
60
  <div className="relative flex h-3 w-3">
61
  <span
src/constants/index.ts CHANGED
@@ -52,3 +52,27 @@ export const PROMPTS = {
52
  fallbackCaption: "Waiting for first caption...",
53
  processingMessage: "Starting analysis...",
54
  } as const;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  fallbackCaption: "Waiting for first caption...",
53
  processingMessage: "Starting analysis...",
54
  } as const;
55
+
56
+ export const EXAMPLE_VIDEOS = [
57
+ {
58
+ id: "cooking",
59
+ name: "Cooking Demo",
60
+ description: "Chef preparing a dish",
61
+ url: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
62
+ thumbnail: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerBlazes.jpg",
63
+ },
64
+ {
65
+ id: "nature",
66
+ name: "Nature Scene",
67
+ description: "Beautiful landscape",
68
+ url: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4",
69
+ thumbnail: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerEscapes.jpg",
70
+ },
71
+ {
72
+ id: "city",
73
+ name: "City Life",
74
+ description: "Urban environment",
75
+ url: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4",
76
+ thumbnail: "https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerJoyrides.jpg",
77
+ },
78
+ ] as const;
src/types/index.ts CHANGED
@@ -1,9 +1,27 @@
1
  export type AppState =
 
2
  | "requesting-permission"
3
  | "welcome"
4
  | "loading"
5
  | "captioning";
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  export interface WebcamPermissionError {
8
  type: "general" | "https" | "not-supported" | "permission";
9
  message: string;
 
1
  export type AppState =
2
+ | "source-selection"
3
  | "requesting-permission"
4
  | "welcome"
5
  | "loading"
6
  | "captioning";
7
 
8
+ export type VideoSourceType = "webcam" | "upload" | "screen" | "example";
9
+
10
+ export interface VideoSource {
11
+ type: VideoSourceType;
12
+ stream?: MediaStream;
13
+ url?: string;
14
+ name?: string;
15
+ }
16
+
17
+ export interface ExampleVideo {
18
+ id: string;
19
+ name: string;
20
+ description: string;
21
+ url: string;
22
+ thumbnail: string;
23
+ }
24
+
25
  export interface WebcamPermissionError {
26
  type: "general" | "https" | "not-supported" | "permission";
27
  message: string;