44import streamlit as st
55
66
7- st .set_page_config (page_title = "ClipABit - Video Upload" , layout = "centered" )
7+ # Page configuration
8+ st .set_page_config (
9+ page_title = "ClipABit - Semantic Video Search" ,
10+ layout = "wide" ,
11+ initial_sidebar_state = "collapsed"
12+ )
813
9- st .title ("ClipABit — Video Uploader" )
10- st .caption ("Upload a video file and send it to your Modal backend for processing." )
14+ # Initialize session state
15+ if 'search_results' not in st .session_state :
16+ st .session_state .search_results = None
17+ if 'show_upload' not in st .session_state :
18+ st .session_state .show_upload = False
1119
12- api_url = st .text_input (
13- "Backend API URL" ,
14- value = "https://clipabit01--clipabit-server-upload-dev.modal.run" ,
15- help = "Full URL of the Modal backend endpoint that accepts multipart file uploads." ,
16- )
20+ # API endpoints
21+ SEARCH_API_URL = "https://clipabit01--clipabit-server-search-dev.modal.run"
22+ UPLOAD_API_URL = "https://clipabit01--clipabit-server-upload-dev.modal.run"
23+ STATUS_API_URL = "https://clipabit01--clipabit-server-status-dev.modal.run"
24+ GET_FRAME_API_URL = "https://clipabit01--clipabit-server-get-frame-dev.modal.run"
25+
26+
27+ def search_videos (query : str ):
28+ """Send search query to backend."""
29+ try :
30+ resp = requests .get (SEARCH_API_URL , params = {"query" : query }, timeout = 30 )
31+ if resp .status_code == 200 :
32+ return resp .json ()
33+ else :
34+ return {"error" : f"Search failed with status { resp .status_code } " }
35+ except requests .RequestException as e :
36+ return {"error" : str (e )}
1737
1838
19- def upload_file_to_backend (api_url : str , file_bytes : bytes , filename : str , content_type : str | None = None ):
39+ def upload_file_to_backend (file_bytes : bytes , filename : str , content_type : str | None = None ):
2040 """Upload file to backend via multipart form-data."""
2141 files = {"file" : (filename , io .BytesIO (file_bytes ), content_type or "application/octet-stream" )}
22- resp = requests .post (api_url , files = files , timeout = 300 )
42+ resp = requests .post (UPLOAD_API_URL , files = files , timeout = 300 )
2343 return resp
2444
2545
26- def poll_job_status (status_url : str , job_id : str , max_wait : int = 120 , poll_interval : int = 2 ):
46+ def poll_job_status (job_id : str , max_wait : int = 120 , status_placeholder = None ):
2747 """Poll job status until complete or timeout."""
2848 start_time = time .time ()
29-
49+ poll_count = 0
50+
3051 while time .time () - start_time < max_wait :
52+ poll_count += 1
53+ elapsed = int (time .time () - start_time )
54+
3155 try :
32- resp = requests .get (f"{ status_url } ?job_id={ job_id } " , timeout = 10 )
56+ resp = requests .get (f"{ STATUS_API_URL } ?job_id={ job_id } " , timeout = 10 )
3357 if resp .status_code == 200 :
3458 data = resp .json ()
3559 status = data .get ("status" , "unknown" )
36-
37- # Job completed or failed
60+
61+ # Update status display
62+ if status_placeholder :
63+ if status == "processing" :
64+ status_placeholder .info (f"⏳ Processing... ({ elapsed } s elapsed, poll #{ poll_count } )" )
65+ elif status == "completed" :
66+ status_placeholder .success (f"✓ Processing complete! (took { elapsed } s)" )
67+ elif status == "failed" :
68+ status_placeholder .error (f"✗ Processing failed after { elapsed } s" )
69+
3870 if status in ["completed" , "failed" ]:
3971 return data
40-
41- time .sleep (poll_interval )
72+ else :
73+ if status_placeholder :
74+ status_placeholder .warning (f"⚠ Checking status... ({ elapsed } s elapsed)" )
75+
76+ time .sleep (2 )
4277 except requests .RequestException :
43- time .sleep (poll_interval )
44-
45- # Timeout
78+ if status_placeholder :
79+ status_placeholder .warning (f"⚠ Connection issue, retrying... ({ elapsed } s elapsed)" )
80+ time .sleep (2 )
81+
4682 return {"job_id" : job_id , "status" : "timeout" , "message" : "Job polling timed out" }
4783
4884
49- # File uploader
50- uploaded = st .file_uploader ("Choose a video file" , type = ["mp4" , "mov" , "avi" , "mkv" , "webm" ])
51-
52- if uploaded is not None :
53- # Read bytes once so we can both preview and upload
54- uploaded_bytes = uploaded .read ()
55-
56- # Show video preview
57- try :
58- st .video (io .BytesIO (uploaded_bytes ))
59- except Exception :
60- st .info ("Preview not available for this format — proceeding to upload." )
85+ # Upload dialog
86+ @st .dialog ("Upload Video" )
87+ def upload_dialog ():
88+ st .write ("Upload a video to add it to the searchable database." )
6189
62- # File metadata
63- st .write (f"**Filename:** { uploaded .name } " )
64- st .write (f"**Size:** { len (uploaded_bytes ):,} bytes ({ len (uploaded_bytes ) / 1024 / 1024 :.2f} MB)" )
90+ uploaded = st .file_uploader ("Choose a video file" , type = ["mp4" , "mov" , "avi" , "mkv" , "webm" ])
6591
66- # Upload button
67- if st .button ("Upload to Backend" , type = "primary" ):
68- if not api_url :
69- st .error ("Please set the backend API URL first." )
70- else :
71- with st .spinner ("Uploading video to backend..." ):
92+ if uploaded is not None :
93+ uploaded_bytes = uploaded .read ()
94+
95+ # Show video preview
96+ try :
97+ st .video (io .BytesIO (uploaded_bytes ))
98+ except Exception :
99+ st .info ("Preview not available for this format." )
100+
101+ # File metadata
102+ st .write (f"**Filename:** { uploaded .name } " )
103+ st .write (f"**Size:** { len (uploaded_bytes ):,} bytes ({ len (uploaded_bytes ) / 1024 / 1024 :.2f} MB)" )
104+
105+ col1 , col2 = st .columns ([1 , 1 ])
106+
107+ with col1 :
108+ if st .button ("Upload" , type = "primary" , use_container_width = True ):
72109 try :
73- resp = upload_file_to_backend (api_url , uploaded_bytes , uploaded .name , uploaded .type )
74-
75- # Handle response
110+ # Upload phase
111+ with st .spinner ("📤 Uploading video to server..." ):
112+ resp = upload_file_to_backend (uploaded_bytes , uploaded .name , uploaded .type )
113+
76114 if resp .status_code == 200 :
77- try :
78- upload_data = resp .json ()
79-
80- # Check if job was spawned
81- if upload_data .get ("status" ) == "processing" :
82- job_id = upload_data .get ("job_id" )
83- st .info (f"Video uploaded successfully. Job ID: { job_id } " )
84-
85- # Derive status URL from upload URL
86- status_url = api_url .replace ("-upload-" , "-status-" )
87-
88- # Poll for results
89- with st .spinner ("Processing video... This may take a minute." ):
90- data = poll_job_status (status_url , job_id , max_wait = 120 , poll_interval = 2 )
91- else :
92- # Legacy: direct response (if not using spawn)
93- data = upload_data
94-
95- # Display results
96- if data .get ("status" ) == "completed" :
97- st .success ("Processing complete!" )
98- elif data .get ("status" ) == "failed" :
99- st .error (f"Processing failed: { data .get ('error' , 'Unknown error' )} " )
100- elif data .get ("status" ) == "timeout" :
101- st .warning ("Processing timed out. Job may still be running." )
115+ data = resp .json ()
116+ if data .get ("status" ) == "processing" :
117+ job_id = data .get ("job_id" )
118+ st .success (f"✓ Video uploaded! Job ID: `{ job_id } `" )
119+
120+ st .markdown ("---" )
121+ st .subheader ("Processing Status" )
122+ st .caption ("This may take a few minutes depending on video length..." )
123+
124+ # Create placeholder for live status updates
125+ status_placeholder = st .empty ()
126+ status_placeholder .info ("⏳ Starting video processing..." )
127+
128+ # Poll for completion with live updates
129+ result = poll_job_status (job_id , max_wait = 120 , status_placeholder = status_placeholder )
130+
131+ st .markdown ("---" )
132+
133+ if result .get ("status" ) == "completed" :
134+ st .success ("🎉 Video processing complete!" )
135+
136+ # Display results
137+ st .subheader ("Processing Results" )
138+ col_a , col_b , col_c = st .columns (3 )
139+ with col_a :
140+ st .metric ("Video Chunks" , result .get ("chunks" , 0 ))
141+ with col_b :
142+ st .metric ("Frames Embedded" , result .get ("total_frames" , 0 ))
143+ with col_c :
144+ st .metric ("Memory Used" , f"{ result .get ('total_memory_mb' , 0 ):.1f} MB" )
145+
146+ st .info ("✨ Video frames are now searchable! Close this dialog and try searching for content." )
147+
148+ with st .expander ("📊 View detailed processing info" ):
149+ st .json (result )
150+
151+ elif result .get ("status" ) == "failed" :
152+ st .error (f"❌ Processing failed: { result .get ('error' , 'Unknown error' )} " )
153+ with st .expander ("View error details" ):
154+ st .json (result )
155+ elif result .get ("status" ) == "timeout" :
156+ st .warning ("⏰ Processing timed out (120s limit reached). The job may still be running in the background." )
157+ st .info (f"You can manually check status at: { STATUS_API_URL } ?job_id={ job_id } " )
102158 else :
103- st .info (f"Status: { data .get ('status' , 'unknown' )} " )
104-
105- # Display processing summary
106- if isinstance (data , dict ):
107- st .subheader ("Processing Summary" )
108-
109- col1 , col2 , col3 , col4 = st .columns (4 )
110- with col1 :
111- st .metric ("Status" , data .get ("status" , "unknown" ))
112- with col2 :
113- st .metric ("Chunks" , data .get ("chunks" , 0 ))
114- with col3 :
115- st .metric ("Total Frames" , data .get ("total_frames" , 0 ))
116- with col4 :
117- st .metric ("Memory" , f"{ data .get ('total_memory_mb' , 0 ):.1f} MB" )
118-
119- # Show job ID
120- st .code (f"Job ID: { data .get ('job_id' , 'N/A' )} " , language = None )
121-
122- # Show raw JSON in expander
123- with st .expander ("View raw response" ):
124- st .json (data )
125-
126- # Display chunk details if available
127- if "chunk_details" in data and data ["chunk_details" ]:
128- st .subheader ("Chunk Details" )
129- for i , chunk in enumerate (data ["chunk_details" ], 1 ):
130- with st .expander (f"Chunk { i } : { chunk .get ('chunk_id' , 'unknown' )} " ):
131- meta = chunk .get ('metadata' , {})
132- time_range = meta .get ('timestamp_range' , [0 , 0 ])
133-
134- st .write (f"**Time Range:** { time_range [0 ]:.1f} s - { time_range [1 ]:.1f} s" )
135- st .write (f"**Duration:** { meta .get ('duration' , 0 ):.1f} s" )
136- st .write (f"**Frames:** { meta .get ('frame_count' , 0 )} at { meta .get ('sampling_fps' , 0 ):.2f} fps" )
137- st .write (f"**Memory:** { chunk .get ('memory_mb' , 0 ):.2f} MB" )
138- st .write (f"**Complexity:** { meta .get ('complexity_score' , 0 ):.3f} " )
139- else :
140- st .json (data )
141- except ValueError :
142- st .text (resp .text )
159+ st .warning (f"⚠ Unknown status: { result .get ('status' , 'unknown' )} " )
160+ st .json (result )
161+ else :
162+ st .error ("❌ Upload failed - unexpected response" )
163+ st .json (data )
143164 else :
144165 st .error (f"Upload failed with status { resp .status_code } " )
145- st .text (resp .text )
146-
147166 except requests .RequestException as e :
148167 st .error (f"Upload failed: { e } " )
149-
150-
151- st .markdown ("---" )
152- st .markdown (
153- """
154- **Notes:**
155- - The backend must accept a multipart form file field named `file`
156- - Make sure your Modal backend is running (`modal serve main.py`)
157- - Update the API URL above to match your Modal deployment URL
158- """
159- )
168+
169+ with col2 :
170+ if st .button ("Cancel" , use_container_width = True ):
171+ st .rerun ()
172+
173+
174+ # Main UI
175+ col_title , col_stats = st .columns ([3 , 1 ])
176+ with col_title :
177+ st .title ("🎬 ClipABit" )
178+ st .subheader ("Semantic Video Search" )
179+ with col_stats :
180+ # Show database stats if we have search results
181+ if st .session_state .search_results and 'stats' in st .session_state .search_results :
182+ stats = st .session_state .search_results ['stats' ]
183+ st .metric ("Vectors in DB" , f"{ stats .get ('namespace_vectors' , 0 ):,} " )
184+
185+ # Header with search and upload button
186+ col1 , col2 = st .columns ([5 , 1 ])
187+
188+ with col1 :
189+ search_query = st .text_input (
190+ "Search for video content" ,
191+ placeholder = "e.g., 'a woman walking on a train platform'" ,
192+ label_visibility = "collapsed"
193+ )
194+
195+ with col2 :
196+ if st .button ("📤 Upload" , use_container_width = True ):
197+ upload_dialog ()
198+
199+ # Search button
200+ if st .button ("🔍 Search" , type = "primary" , use_container_width = False ):
201+ if search_query :
202+ with st .spinner ("Searching..." ):
203+ results = search_videos (search_query )
204+ st .session_state .search_results = results
205+ else :
206+ st .warning ("Please enter a search query" )
207+
208+ # Display results
209+ if st .session_state .search_results :
210+ st .markdown ("---" )
211+
212+ # Header with toggle
213+ col_header , col_toggle = st .columns ([3 , 1 ])
214+ with col_header :
215+ st .subheader ("Search Results" )
216+ with col_toggle :
217+ show_frames = st .checkbox ("Show frames" , value = True , help = "Fetch and display frame images (for testing/debugging)" )
218+
219+ results = st .session_state .search_results
220+
221+ if "error" in results :
222+ st .error (f"Error: { results ['error' ]} " )
223+ else :
224+ # Display query echo
225+ if "query" in results :
226+ st .info (f"Query: { results ['query' ]} " )
227+
228+ # Display results
229+ search_results = results .get ('results' , [])
230+ if search_results :
231+ for i , result in enumerate (search_results , 1 ):
232+ metadata = result .get ('metadata' , {})
233+ score = result .get ('score' , 0 )
234+ result_id = result .get ('id' , '' )
235+ with st .container ():
236+ st .markdown (f"### Result { i } - Score: { score :.3f} " )
237+ st .write (f"- Chunk ID: `{ metadata .get ('chunk_id' , 'N/A' )} `" )
238+ st .write (f"- Video ID: `{ metadata .get ('video_id' , 'N/A' )} `" )
239+ st .write (f"- Duration: **{ metadata .get ('duration' , 0 ):.2f} s**" )
240+ st .write (f"- Start Time: { metadata .get ('start_time_s' , 0 ):.2f} s" )
241+ st .write (f"- End Time: { metadata .get ('end_time_s' , 0 ):.2f} s" )
242+ st .write (f"- Frame Count: { metadata .get ('frame_count' , 0 )} " )
243+ st .write (f"- Complexity Score: { metadata .get ('complexity_score' , metadata .get ('complexity' , 0 )):.3f} " )
244+ st .write (f"- Filename: { metadata .get ('file_filename' , 'N/A' )} " )
245+ st .write (f"- File Type: { metadata .get ('file_type' , 'N/A' )} " )
246+ st .write (f"- Processed At: { metadata .get ('processed_at' , 'N/A' )} " )
247+ st .markdown ("---" )
248+ else :
249+ st .info ("No results found" )
250+ with st .expander ("View raw JSON response" ):
251+ st .json (results )
252+ st .caption ("ClipABit - Powered by CLIP embeddings and semantic search" )
0 commit comments