Skip to content

Commit 84347d8

Browse files
authored
Merge pull request #18 from ClipABit/search-frontend-refactor-2
Update Frontend for Search
2 parents 8eb87f9 + 33e4e64 commit 84347d8

File tree

1 file changed

+215
-122
lines changed

1 file changed

+215
-122
lines changed

frontend/web/app.py

Lines changed: 215 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -4,156 +4,249 @@
44
import 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

Comments
 (0)