Spaces:
Running
Running
| """EmoSphere -- HuggingFace Spaces Demo (Streamlit). | |
| Trial-gated multimodal emotion recognition demo with fuzzy fusion. | |
| Run: streamlit run app.py | |
| Flow: | |
| 1. Landing page with email registration | |
| 2. 6-digit code verification | |
| 3. Demo: Upload video OR camera+mic quick capture | |
| 4. Full multimodal analysis (face, voice, speech, posture) | |
| 5. Session report with emotion timeline | |
| 6. Trial-ended screen with contact info | |
| """ | |
| from __future__ import annotations | |
| import time | |
| import io | |
| from datetime import datetime | |
| from collections import deque | |
| from pathlib import Path | |
| import numpy as np | |
| import streamlit as st | |
| try: | |
| import cv2 | |
| HAS_CV2 = True | |
| except ImportError: | |
| HAS_CV2 = False | |
| try: | |
| from PIL import Image | |
| HAS_PIL = True | |
| except ImportError: | |
| HAS_PIL = False | |
| import base64 | |
| import streamlit.components.v1 as components_lib | |
| import os as _os | |
| # Custom webcam component (no WebRTC needed) | |
| _WEBCAM_DIR = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "webcam_component") | |
| _webcam_component_func = None | |
| if _os.path.isdir(_WEBCAM_DIR): | |
| _webcam_component_func = components_lib.declare_component("webcam_capture", path=_WEBCAM_DIR) | |
| def webcam_capture(key="webcam"): | |
| """Custom webcam component — captures video frames + audio, sends to Python.""" | |
| if _webcam_component_func is None: | |
| st.error("Webcam component not found.") | |
| return None | |
| return _webcam_component_func(key=key, default=None) | |
| from models import EmotionLabel, EMOTION_LABELS, CulturalRegion, EMOTION_EMOJI | |
| from face_detector import FaceEmotionDetector | |
| from text_detector import TextEmotionDetector | |
| from posture_detector import PostureEmotionDetector | |
| from auth import ( | |
| validate_email, | |
| is_email_used, | |
| mark_email_used, | |
| generate_code, | |
| send_code_email, | |
| smtp_configured, | |
| get_remaining_seconds, | |
| is_trial_expired, | |
| TRIAL_DURATION_SECONDS, | |
| VIDEO_MAX_DURATION_SECONDS, | |
| CAMERA_MAX_DURATION_SECONDS, | |
| ) | |
| # ===================================================================== | |
| # Page Config | |
| # ===================================================================== | |
| st.set_page_config( | |
| page_title="EmoSphere", | |
| page_icon="\U0001f300", | |
| layout="wide", | |
| initial_sidebar_state="collapsed", | |
| ) | |
| # ===================================================================== | |
| # Brand Constants | |
| # ===================================================================== | |
| EMOTION_COLORS = { | |
| EmotionLabel.JOY: "#FFD700", | |
| EmotionLabel.SADNESS: "#4A90D9", | |
| EmotionLabel.SURPRISE: "#FF8C00", | |
| EmotionLabel.FEAR: "#8B5CF6", | |
| EmotionLabel.DISGUST: "#10B981", | |
| EmotionLabel.NEUTRAL: "#94A3B8", | |
| EmotionLabel.LOVE: "#F472B6", | |
| EmotionLabel.CALM: "#67E8F9", | |
| } | |
| CULTURAL_OPTIONS = { | |
| "universal": "Universal", | |
| "western": "Western", | |
| "east_asian": "East Asian", | |
| "south_asian": "South Asian", | |
| "middle_eastern": "Middle Eastern", | |
| "african": "African", | |
| "latin_american": "Latin American", | |
| } | |
| # ===================================================================== | |
| # Global CSS | |
| # ===================================================================== | |
| GLOBAL_CSS = """ | |
| <style> | |
| .stApp { | |
| background: linear-gradient(180deg, #0A0A1A 0%, #0D0D2B 50%, #0F0F2E 100%); | |
| color: #B0BCD0; | |
| } | |
| section[data-testid="stSidebar"] { | |
| background: #0F0F2E; | |
| border-right: 1px solid rgba(255,255,255,0.08); | |
| } | |
| h1 { | |
| background: linear-gradient(90deg, #E948A0, #9B6FCE, #00D4FF); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| font-weight: 800; | |
| letter-spacing: 2px; | |
| } | |
| h2, h3 { color: #00D4FF !important; } | |
| .glass-card { | |
| background: linear-gradient(135deg, rgba(22,22,64,0.8), rgba(30,30,82,0.6)); | |
| border: 1px solid rgba(255,255,255,0.1); | |
| border-radius: 16px; | |
| padding: 20px; | |
| margin: 10px 0; | |
| backdrop-filter: blur(10px); | |
| } | |
| .emotion-bar { | |
| background: rgba(255,255,255,0.05); | |
| border-radius: 8px; | |
| height: 28px; | |
| margin: 4px 0; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| .emotion-fill { | |
| height: 100%; | |
| border-radius: 8px; | |
| display: flex; | |
| align-items: center; | |
| padding-left: 10px; | |
| font-weight: 600; | |
| font-size: 13px; | |
| color: white; | |
| transition: width 0.5s ease; | |
| } | |
| div[data-testid="stMetric"] { | |
| background: rgba(22,22,64,0.6); | |
| border: 1px solid rgba(255,255,255,0.08); | |
| border-radius: 12px; | |
| padding: 12px; | |
| } | |
| div[data-testid="stMetric"] label { color: #6B7B9D !important; } | |
| div[data-testid="stMetric"] div[data-testid="stMetricValue"] { color: #00D4FF !important; } | |
| .stButton > button, | |
| .stFormSubmitButton > button, | |
| button[kind="primary"], | |
| button[kind="primaryFormSubmit"], | |
| .stButton > button[kind="primary"], | |
| .stFormSubmitButton > button[kind="primaryFormSubmit"], | |
| div[data-testid="stFormSubmitButton"] > button, | |
| div[data-testid="stButton"] > button { | |
| background: linear-gradient(90deg, #9B6FCE, #6C63FF) !important; | |
| background-color: #9B6FCE !important; | |
| color: white !important; | |
| border: none !important; | |
| border-radius: 12px !important; | |
| font-weight: 700 !important; | |
| letter-spacing: 0.5px !important; | |
| padding: 0.5rem 2rem !important; | |
| } | |
| .stButton > button:hover, | |
| .stFormSubmitButton > button:hover, | |
| button[kind="primary"]:hover, | |
| button[kind="primaryFormSubmit"]:hover, | |
| div[data-testid="stFormSubmitButton"] > button:hover, | |
| div[data-testid="stButton"] > button:hover { | |
| background: linear-gradient(90deg, #7C3AED, #5B4FCE) !important; | |
| background-color: #7C3AED !important; | |
| } | |
| .stTabs [data-baseweb="tab"] { color: #6B7B9D; font-weight: 600; } | |
| .stTabs [aria-selected="true"] { | |
| color: #00D4FF !important; | |
| border-bottom-color: #00D4FF !important; | |
| } | |
| .stTextArea textarea, .stTextInput input { | |
| background: rgba(22,22,64,0.6) !important; | |
| border: 1px solid rgba(255,255,255,0.1) !important; | |
| color: white !important; | |
| border-radius: 12px !important; | |
| } | |
| .stSelectbox > div > div { | |
| background: rgba(22,22,64,0.6) !important; | |
| border: 1px solid rgba(255,255,255,0.1) !important; | |
| color: white !important; | |
| } | |
| #MainMenu { visibility: hidden; } | |
| footer { visibility: hidden; } | |
| @keyframes float { | |
| 0% { transform: translateY(0px); } | |
| 50% { transform: translateY(-20px); } | |
| 100% { transform: translateY(0px); } | |
| } | |
| .floating-logo { | |
| animation: float 6s ease-in-out infinite; | |
| display: block; | |
| margin: 0 auto; | |
| } | |
| .auth-container { | |
| max-width: 500px; | |
| margin: 60px auto; | |
| text-align: center; | |
| } | |
| .countdown-bar { | |
| position: fixed; | |
| top: 0; left: 0; right: 0; | |
| height: 4px; | |
| background: rgba(255,255,255,0.1); | |
| z-index: 9999; | |
| } | |
| .countdown-fill { | |
| height: 100%; | |
| background: linear-gradient(90deg, #00D4FF, #E948A0); | |
| transition: width 1s linear; | |
| } | |
| .countdown-text { | |
| position: fixed; | |
| top: 8px; | |
| right: 16px; | |
| font-size: 13px; | |
| font-weight: 700; | |
| color: #00D4FF; | |
| z-index: 9999; | |
| background: rgba(10,10,26,0.8); | |
| padding: 4px 12px; | |
| border-radius: 8px; | |
| border: 1px solid rgba(0,212,255,0.3); | |
| } | |
| .trial-ended { | |
| max-width: 560px; | |
| margin: 80px auto; | |
| text-align: center; | |
| } | |
| </style> | |
| <script> | |
| // Auto-focus iframe so clicks work immediately | |
| window.focus(); | |
| document.addEventListener('DOMContentLoaded', function() { window.focus(); }); | |
| // Also focus on any mouse movement over the page | |
| document.addEventListener('mouseover', function() { window.focus(); }, {once: true}); | |
| </script> | |
| """ | |
| st.markdown(GLOBAL_CSS, unsafe_allow_html=True) | |
| # ===================================================================== | |
| # Session State Initialization | |
| # ===================================================================== | |
| # Secret bypass: ?bypass=cait2026 skips auth | |
| _qp = st.query_params | |
| if "auth_stage" not in st.session_state: | |
| if _qp.get("bypass") == "cait2026": | |
| st.session_state.auth_stage = "demo" | |
| st.session_state.demo_start_time = time.time() + 99999 | |
| else: | |
| st.session_state.auth_stage = "email" # email | code | demo | ended | |
| if "auth_email" not in st.session_state: | |
| st.session_state.auth_email = "" | |
| if "auth_code" not in st.session_state: | |
| st.session_state.auth_code = "" | |
| if "auth_code_shown" not in st.session_state: | |
| st.session_state.auth_code_shown = False | |
| if "auth_email_pending" not in st.session_state: | |
| st.session_state.auth_email_pending = False | |
| if "demo_start_time" not in st.session_state: | |
| st.session_state.demo_start_time = None | |
| if "history" not in st.session_state: | |
| st.session_state.history = deque(maxlen=50) | |
| if "show_report" not in st.session_state: | |
| st.session_state.show_report = False | |
| if "video_processing" not in st.session_state: | |
| st.session_state.video_processing = False | |
| # ── Background email send (runs after rerun so UI stays responsive) ── | |
| if st.session_state.get("auth_email_pending") and st.session_state.auth_stage == "code": | |
| st.session_state.auth_email_pending = False | |
| _email = st.session_state.auth_email | |
| _code = st.session_state.auth_code | |
| print(f"[Auth] Attempting to send code to {_email}...") | |
| try: | |
| sent, _msg = send_code_email(_email, _code) | |
| print(f"[Auth] send_code_email returned: sent={sent}, msg={_msg}") | |
| if sent: | |
| st.session_state.auth_email_sent = True | |
| else: | |
| st.session_state.auth_email_error = _msg | |
| except Exception as _exc: | |
| print(f"[Auth] send_code_email exception: {_exc}") | |
| st.session_state.auth_email_error = str(_exc) | |
| # ===================================================================== | |
| # Model Loader (cached) | |
| # ===================================================================== | |
| def load_face_detector(): | |
| det = FaceEmotionDetector() | |
| det.load() | |
| return det | |
| def load_text_detector(): | |
| det = TextEmotionDetector() | |
| det.load() | |
| return det | |
| def load_posture_detector(): | |
| det = PostureEmotionDetector() | |
| det.load() | |
| return det | |
| def load_live_processor(): | |
| from live_processor import LiveSessionProcessor | |
| proc = LiveSessionProcessor() | |
| proc.initialize() | |
| return proc | |
| # ===================================================================== | |
| # Helper: Render Functions | |
| # ===================================================================== | |
| def render_emotion_bars(scores): | |
| sorted_emotions = sorted(scores.items(), key=lambda x: -x[1]) | |
| html_parts = [] | |
| for label, score in sorted_emotions: | |
| color = EMOTION_COLORS.get(label, "#94A3B8") | |
| emoji = EMOTION_EMOJI.get(label, "") | |
| pct = max(score * 100, 2) | |
| html_parts.append( | |
| '<div class="emotion-bar">' | |
| '<div class="emotion-fill" style="width: {}%; background: {};">' | |
| '{} {} - {:.1f}%' | |
| '</div></div>'.format(pct, color, emoji, label.value, score * 100) | |
| ) | |
| st.markdown("".join(html_parts), unsafe_allow_html=True) | |
| def render_emotion_bubble(label, score): | |
| color = EMOTION_COLORS.get(label, "#94A3B8") | |
| emoji = EMOTION_EMOJI.get(label, "") | |
| st.markdown( | |
| '<div style="text-align: center; padding: 20px;">' | |
| '<div style="display: inline-flex; width: 140px; height: 140px; border-radius: 50%;' | |
| ' background: radial-gradient(circle, {}30, {}10);' | |
| ' border: 2px solid {}80; align-items: center; justify-content: center;' | |
| ' flex-direction: column; margin: 0 auto; box-shadow: 0 0 30px {}40;">' | |
| '<span style="font-size: 48px; line-height: 1.2;">{}</span>' | |
| '<span style="font-size: 22px; font-weight: 700; color: {};">{:.0f}%</span>' | |
| '</div>' | |
| '<div style="margin-top: 10px; font-size: 16px; font-weight: 700; color: {};' | |
| ' letter-spacing: 2px; text-transform: uppercase;">{}</div>' | |
| '</div>'.format( | |
| color, color, color, color, emoji, color, score * 100, color, label.value | |
| ), | |
| unsafe_allow_html=True, | |
| ) | |
| def render_countdown_bar(remaining): | |
| pct = (remaining / TRIAL_DURATION_SECONDS) * 100 | |
| if remaining > 15: | |
| color = "#00D4FF" | |
| elif remaining > 5: | |
| color = "#FF4444" | |
| else: | |
| color = "#FF0000" | |
| mins = int(remaining) // 60 | |
| secs = int(remaining) % 60 | |
| st.markdown( | |
| '<div class="countdown-bar">' | |
| '<div class="countdown-fill" style="width: {}%; background: linear-gradient(90deg, {}, #E948A0);"></div>' | |
| '</div>' | |
| '<div class="countdown-text" style="color: {};">' | |
| 'Trial: {}:{:02d}' | |
| '</div>'.format(pct, color, color, mins, secs), | |
| unsafe_allow_html=True, | |
| ) | |
| # ===================================================================== | |
| # AUTH GATE: Stage 1 -- Email Input | |
| # ===================================================================== | |
| def _get_logo_b64(): | |
| """Load logo as base64 for HTML embedding.""" | |
| import base64 | |
| logo_path = Path(__file__).parent / "logo.png" | |
| if logo_path.exists(): | |
| return base64.b64encode(logo_path.read_bytes()).decode() | |
| return None | |
| def show_landing_page(): | |
| logo_b64 = _get_logo_b64() | |
| if logo_b64: | |
| st.markdown( | |
| f'<div style="text-align:center; padding: 20px 0;">' | |
| f'<img src="data:image/png;base64,{logo_b64}" class="floating-logo" ' | |
| f'style="width: 300px; height: 300px; object-fit: contain;"/>' | |
| f'</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown( | |
| '<p style="text-align:center; color:#6B7B9D; font-size:16px; max-width:500px; margin:0 auto 32px;">' | |
| 'Multimodal & cultural-aware emotion recognition — AI detection of emotions from face, text, posture and voice.' | |
| '</p>', | |
| unsafe_allow_html=True, | |
| ) | |
| col_l, col_c, col_r = st.columns([1, 2, 1]) | |
| with col_c: | |
| st.markdown( | |
| '<div class="glass-card" style="text-align: center;">' | |
| '<h3 style="margin-top: 0;">Request Demo Access</h3>' | |
| '<p style="color: #6B7B9D; font-size: 13px;">' | |
| 'Enter your email to receive a one-time access code.<br/>' | |
| 'Each email grants a single 60-second demo session.' | |
| '</p></div>', | |
| unsafe_allow_html=True, | |
| ) | |
| with st.form("email_form", clear_on_submit=False): | |
| email = st.text_input( | |
| "Email address", | |
| placeholder="you@example.com", | |
| key="email_input", | |
| ) | |
| submitted = st.form_submit_button("Send Access Code", use_container_width=True, type="primary") | |
| if submitted: | |
| if not email or not email.strip(): | |
| st.error("Please enter an email address.") | |
| elif not validate_email(email): | |
| st.error("Please enter a valid email address.") | |
| elif is_email_used(email): | |
| st.error( | |
| "This email has already been used for a demo session. " | |
| "Contact info@caitcore.com for full access." | |
| ) | |
| else: | |
| code = generate_code() | |
| st.session_state.auth_email = email.strip().lower() | |
| st.session_state.auth_code = code | |
| st.session_state.auth_stage = "code" | |
| st.session_state.auth_email_pending = True | |
| st.rerun() | |
| st.markdown( | |
| '<div style="text-align: center; margin-top: 40px; color: #6B7B9D; font-size: 12px;">' | |
| '<p>EmoSphere v1.0 by ' | |
| '<a href="mailto:info@caitcore.com" style="color: #00D4FF;">CAIT</a></p>' | |
| '<p>No surveillance • No medical screening • No data storage</p>' | |
| '</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| # ===================================================================== | |
| # AUTH GATE: Stage 2 -- Code Verification | |
| # ===================================================================== | |
| def show_code_verification(): | |
| logo_b64 = _get_logo_b64() | |
| if logo_b64: | |
| st.markdown( | |
| f'<div style="text-align:center; padding: 10px 0;">' | |
| f'<img src="data:image/png;base64,{logo_b64}" class="floating-logo" ' | |
| f'style="width: 200px; height: 200px; object-fit: contain;"/>' | |
| f'</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| col_l, col_c, col_r = st.columns([1, 2, 1]) | |
| with col_c: | |
| st.markdown( | |
| '<div class="glass-card" style="text-align: center;">' | |
| '<h3 style="margin-top: 0;">Enter Access Code</h3>' | |
| '<p style="color: #6B7B9D; font-size: 13px;">' | |
| 'A 6-digit code was sent to ' | |
| '<strong style="color: #00D4FF;">{}</strong>' | |
| '</p></div>'.format(st.session_state.auth_email), | |
| unsafe_allow_html=True, | |
| ) | |
| with st.form("code_form", clear_on_submit=False): | |
| code_input = st.text_input( | |
| "6-digit code", | |
| placeholder="123456", | |
| max_chars=6, | |
| key="code_input", | |
| ) | |
| verified = st.form_submit_button("Verify & Start Demo", use_container_width=True, type="primary") | |
| if verified: | |
| if code_input.strip() == st.session_state.auth_code: | |
| mark_email_used(st.session_state.auth_email) | |
| st.session_state.demo_start_time = time.time() | |
| st.session_state.auth_stage = "demo" | |
| st.rerun() | |
| else: | |
| st.error("Invalid code. Please try again.") | |
| if st.button("Back", use_container_width=True): | |
| st.session_state.auth_stage = "email" | |
| st.session_state.auth_code = "" | |
| st.rerun() | |
| # ===================================================================== | |
| # AUTH GATE: Stage 4 -- Trial Ended | |
| # ===================================================================== | |
| def show_trial_ended(): | |
| st.markdown( | |
| '<div class="trial-ended">' | |
| '<div style="font-size: 64px; margin-bottom: 16px;">🌀</div>' | |
| '</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown( | |
| '<div style="text-align:center;">' | |
| '<img src="https://caitcore.com/images/emosphere-logo.png" style="width:120px; height:120px; border-radius:16px; margin-bottom:16px;" />' | |
| '</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| col_l, col_c, col_r = st.columns([1, 2, 1]) | |
| with col_c: | |
| st.markdown( | |
| '<div class="glass-card" style="text-align: center; padding: 40px;">' | |
| '<div style="font-size: 48px; margin-bottom: 16px;">⏰</div>' | |
| '<h2 style="margin-top: 0;">Trial Complete</h2>' | |
| '<p style="color: #B0BCD0; font-size: 15px; margin: 16px 0;">' | |
| 'Your 60-second demo session has ended.<br/>' | |
| 'Thank you for trying EmoSphere.' | |
| '</p>' | |
| '<div style="margin: 24px 0; padding: 20px; background: rgba(0,212,255,0.08);' | |
| ' border-radius: 12px; border: 1px solid rgba(0,212,255,0.2);">' | |
| '<p style="color: #00D4FF; font-weight: 700; font-size: 16px; margin: 0 0 8px;">' | |
| 'Want full access?' | |
| '</p>' | |
| '<p style="color: #B0BCD0; font-size: 14px; margin: 0;">' | |
| 'Contact us for enterprise licensing, API access, and custom integrations.' | |
| '</p>' | |
| '<a href="mailto:info@caitcore.com"' | |
| ' style="display: inline-block; margin-top: 16px; padding: 10px 28px;' | |
| ' background: linear-gradient(90deg, #E948A0, #9B6FCE);' | |
| ' color: white; text-decoration: none; border-radius: 12px;' | |
| ' font-weight: 700; font-size: 14px;">' | |
| 'Contact info@caitcore.com' | |
| '</a>' | |
| '</div>' | |
| '<p style="color: #6B7B9D; font-size: 12px; margin-top: 20px;">' | |
| 'EmoSphere v1.0 • Multimodal Emotion AI for Psychotherapy' | |
| '</p>' | |
| '</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| # ===================================================================== | |
| # AUTH GATE: Stage 3 -- Demo (time-limited) | |
| # ===================================================================== | |
| def show_demo(): | |
| """Show the demo, guarded by a 60-second timer.""" | |
| start = st.session_state.demo_start_time | |
| # Allow report viewing even after trial expires | |
| has_report = st.session_state.get("show_report", False) | |
| if start is None: | |
| st.session_state.auth_stage = "ended" | |
| st.rerun() | |
| return | |
| if is_trial_expired(start) and not has_report: | |
| st.session_state.auth_stage = "ended" | |
| st.rerun() | |
| return | |
| remaining = get_remaining_seconds(start) | |
| if not has_report: | |
| render_countdown_bar(remaining) | |
| # Load the live processor | |
| processor = load_live_processor() | |
| with st.sidebar: | |
| st.markdown("### EmoSphere Demo") | |
| mins = int(remaining) // 60 | |
| secs = int(remaining) % 60 | |
| st.markdown("**Time remaining:** {}:{:02d}".format(mins, secs)) | |
| st.divider() | |
| st.markdown("### How it works") | |
| st.markdown( | |
| "1. Click **START** on the video stream\n" | |
| "2. All 4 modalities analyzed in real-time\n" | |
| "3. Fused with **Mamdani fuzzy logic**\n" | |
| "4. Click **Stop & View Report** when done" | |
| ) | |
| st.divider() | |
| st.markdown("### Modalities") | |
| st.markdown( | |
| "🧑 **Face** — ViT expression \n" | |
| "🎙 **Voice** — Wav2Vec2 prosody \n" | |
| "💬 **Speech** — DistilRoBERTa NLP \n" | |
| "🧍 **Posture/Gesture** — MediaPipe pose + hands" | |
| ) | |
| # Header | |
| st.markdown( | |
| '<div style="text-align:center;">' | |
| '<img src="https://caitcore.com/images/emosphere-logo.png" ' | |
| 'style="width:80px; height:80px; border-radius:12px; margin-bottom:8px;" />' | |
| '<h2 style="margin:0;">EmoSphere — Live Emotion Analysis</h2>' | |
| '<p style="color:#6B7B9D; font-size:14px; margin-top:4px;">' | |
| 'Multimodal AI emotion detection with fuzzy fusion ' | |
| '— face, voice, speech & posture</p>' | |
| '</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| # Show report if ready | |
| if st.session_state.get("show_report"): | |
| from session_report import render_session_report | |
| render_session_report(processor) | |
| if st.button("Done — End Trial", use_container_width=True, type="primary"): | |
| st.session_state.show_report = False | |
| st.session_state.auth_stage = "ended" | |
| st.rerun() | |
| return | |
| # Show video processing screen | |
| if st.session_state.get("video_processing"): | |
| _show_video_processing(processor, start) | |
| return | |
| # ── Primary: Live Streaming ────────────────────────────────────── | |
| _show_live_session(processor, remaining, start) | |
| # ── Secondary: Video Upload ────────────────────────────────────── | |
| st.divider() | |
| st.markdown( | |
| '<div style="text-align:center;">' | |
| '<h3 style="margin: 0;">Or Upload a Video</h3>' | |
| '<p style="color: #6B7B9D; font-size: 13px;">Upload a short video (MP4, max 60s) for full multimodal analysis.</p>' | |
| '</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| uploaded_video = st.file_uploader( | |
| "Choose video file", | |
| type=["mp4", "webm", "avi", "mov", "mkv"], | |
| key="video_upload", | |
| label_visibility="collapsed", | |
| ) | |
| if uploaded_video is not None: | |
| st.video(uploaded_video) | |
| if st.button("🔍 Analyze Video", type="primary", use_container_width=True): | |
| video_bytes = uploaded_video.read() | |
| st.session_state.video_bytes = video_bytes | |
| st.session_state.video_processing = True | |
| st.rerun() | |
| # Check trial expiry (but allow report viewing) | |
| if is_trial_expired(start) and not st.session_state.get("show_report", False): | |
| if processor.is_active: | |
| processor.stop_session() | |
| # Auto-generate report instead of going to "ended" | |
| st.session_state.show_report = True | |
| st.rerun() | |
| else: | |
| st.session_state.auth_stage = "ended" | |
| st.rerun() | |
| def _show_live_session(processor, remaining, start): | |
| """Live session using custom webcam component (no WebRTC needed).""" | |
| # Video + Results side by side | |
| col_video, col_results = st.columns([1.6, 1]) | |
| with col_video: | |
| # Custom webcam component with built-in START/STOP + timer | |
| component_value = webcam_capture(key="webcam_live") | |
| # Handle component messages | |
| if component_value and isinstance(component_value, dict): | |
| msg_type = component_value.get("type") | |
| if msg_type == "started": | |
| if not processor.is_active: | |
| processor.start_session() | |
| st.session_state["session_started"] = True | |
| elif msg_type == "frame": | |
| if not processor.is_active: | |
| processor.start_session() | |
| st.session_state["session_started"] = True | |
| # Decode base64 JPEG and process | |
| data_url = component_value.get("data", "") | |
| if "," in data_url: | |
| try: | |
| img_b64 = data_url.split(",", 1)[1] | |
| img_bytes = base64.b64decode(img_b64) | |
| processor.process_image(img_bytes) | |
| except Exception as e: | |
| print(f"[App] Frame decode error: {e}") | |
| elif msg_type == "audio": | |
| data_url = component_value.get("data", "") | |
| if "," in data_url: | |
| try: | |
| audio_b64 = data_url.split(",", 1)[1] | |
| audio_bytes = base64.b64decode(audio_b64) | |
| whisper_lang = st.session_state.get("whisper_language", None) | |
| processor.process_audio_bytes(audio_bytes, language=whisper_lang) | |
| except Exception as e: | |
| print(f"[App] Audio decode error: {e}") | |
| elif msg_type == "stopped": | |
| if processor.is_active: | |
| processor.stop_session() | |
| st.session_state["session_started"] = False | |
| st.session_state.show_report = True | |
| st.rerun() | |
| elif msg_type == "error": | |
| st.error("Camera/mic error: " + component_value.get("message", "unknown")) | |
| with col_results: | |
| if processor.is_active: | |
| _render_live_results(processor) | |
| else: | |
| st.markdown( | |
| '<div class="glass-card" style="text-align: center; padding: 40px;">' | |
| '<span style="font-size: 48px;">🎥</span>' | |
| '<h3 style="margin: 12px 0 8px; color: #B0BCD0 !important;">Ready to Stream</h3>' | |
| '<p style="color: #6B7B9D; margin: 0; font-size: 13px;">' | |
| 'Click <strong>START SESSION</strong> on the left to begin ' | |
| 'your 60-second live emotion analysis.</p>' | |
| '<div style="margin-top: 16px; padding: 12px; background: rgba(0,212,255,0.06); ' | |
| 'border-radius: 8px; border: 1px solid rgba(0,212,255,0.15);">' | |
| '<p style="color: #00D4FF; font-size: 12px; margin: 0;">' | |
| '🧑 Face • 🎙 Voice • 💬 Speech • 🧍 Posture/Gesture<br/>' | |
| 'All fused with fuzzy logic in real-time.</p>' | |
| '</div>' | |
| '</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| # Language selector under Ready to Stream | |
| st.markdown( | |
| '<p style="font-weight:600; color:#B0BCD0; margin-top:16px; margin-bottom:4px; font-size:14px;">' | |
| 'Select your speech language:</p>', | |
| unsafe_allow_html=True, | |
| ) | |
| lang_options = { | |
| "English": "en", | |
| "Greek": "el", | |
| "Spanish": "es", | |
| "French": "fr", | |
| "German": "de", | |
| "Italian": "it", | |
| "Portuguese": "pt", | |
| "Dutch": "nl", | |
| "Russian": "ru", | |
| "Chinese": "zh", | |
| "Japanese": "ja", | |
| "Korean": "ko", | |
| "Arabic": "ar", | |
| "Turkish": "tr", | |
| "Polish": "pl", | |
| "Swedish": "sv", | |
| "Romanian": "ro", | |
| "Bulgarian": "bg", | |
| "Serbian": "sr", | |
| "Croatian": "hr", | |
| } | |
| selected_lang = st.selectbox( | |
| "Speech language", | |
| options=list(lang_options.keys()), | |
| index=0, | |
| key="speech_language", | |
| label_visibility="collapsed", | |
| ) | |
| st.session_state["whisper_language"] = lang_options[selected_lang] | |
| # Stop button — below the columns, always visible during session | |
| if processor.is_active or st.session_state.get("session_started", False): | |
| st.markdown("") # spacer | |
| if st.button("⏹ Stop Session & View Report", type="primary", use_container_width=True, key="stop_session_btn"): | |
| if processor.is_active: | |
| processor.stop_session() | |
| st.session_state["session_started"] = False | |
| st.session_state.show_report = True | |
| st.rerun() | |
| def _render_live_results(processor): | |
| """Auto-updating display of live emotion results. Refreshes every 2s.""" | |
| fused = processor.get_latest_fused() | |
| face = processor.get_latest_face() | |
| voice = processor.get_latest_voice() | |
| text = processor.get_latest_text() | |
| posture = processor.get_latest_posture() | |
| stats = processor.get_stats() | |
| transcript = processor.get_transcript() | |
| topics = processor.get_topics() | |
| if fused is None: | |
| st.markdown( | |
| '<div class="glass-card" style="text-align: center; padding: 20px;">' | |
| '<span style="font-size: 36px;">🔮</span>' | |
| '<p style="color: #6B7B9D; margin-top: 8px;">' | |
| 'Analyzing... Speak, move, or express yourself.</p>' | |
| '</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| return | |
| # Dominant emotion bubble | |
| render_emotion_bubble(fused.dominant, fused.dominant_score) | |
| # Fused emotion bars | |
| fused_scores = {s.label: s.score for s in fused.scores} | |
| render_emotion_bars(fused_scores) | |
| # Modality signals | |
| st.markdown("#### Modality Signals") | |
| mod_data = [ | |
| ("🧑 Face", face), | |
| ("🎙 Voice", voice), | |
| ("💬 Speech", text), | |
| ("🧍 Posture", posture), | |
| ] | |
| mod_colors = ["#E948A0", "#FFD700", "#00D4FF", "#10B981"] | |
| for (mod_label, mod_result), color in zip(mod_data, mod_colors): | |
| if mod_result is not None: | |
| dom = mod_result.dominant | |
| emoji = EMOTION_EMOJI.get(dom, "") | |
| conf = mod_result.confidence * 100 | |
| st.markdown( | |
| '<div style="display: flex; align-items: center; margin: 4px 0; font-size: 13px;">' | |
| '<span style="width: 100px; flex-shrink: 0;">{}</span>' | |
| '<span style="font-size: 16px; margin-right: 6px;">{}</span>' | |
| '<span style="color: {}; font-weight: 600; width: 70px;">{}</span>' | |
| '<div style="flex: 1; background: rgba(255,255,255,0.05); border-radius: 4px; height: 8px; overflow: hidden;">' | |
| '<div style="width: {:.0f}%; height: 100%; background: {}; border-radius: 4px;"></div>' | |
| '</div>' | |
| '<span style="color: #6B7B9D; margin-left: 8px; font-size: 11px;">{:.0f}%</span>' | |
| '</div>'.format(mod_label, emoji, color, dom.value, conf, color, conf), | |
| unsafe_allow_html=True, | |
| ) | |
| else: | |
| st.markdown( | |
| '<div style="display: flex; align-items: center; margin: 4px 0; font-size: 13px;">' | |
| '<span style="width: 100px; flex-shrink: 0;">{}</span>' | |
| '<span style="color: #6B7B9D; font-style: italic;">waiting...</span>' | |
| '</div>'.format(mod_label), | |
| unsafe_allow_html=True, | |
| ) | |
| # Live transcript | |
| if transcript: | |
| st.markdown("#### Live Transcript") | |
| recent = transcript[-5:] | |
| html_parts = ['<div class="glass-card" style="max-height: 180px; overflow-y: auto; padding: 10px;">'] | |
| for seg in recent: | |
| emoji = EMOTION_EMOJI.get(seg.emotion, "") if seg.emotion else "" | |
| mins = int(seg.timestamp) // 60 | |
| secs = int(seg.timestamp) % 60 | |
| html_parts.append( | |
| '<div style="padding: 4px 0; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 13px;">' | |
| '<span style="color: #6B7B9D;">{}:{:02d}</span> ' | |
| '<span>{}</span> ' | |
| '<span style="color: #B0BCD0;">{}</span>' | |
| '</div>'.format(mins, secs, emoji, seg.text) | |
| ) | |
| html_parts.append('</div>') | |
| st.markdown("".join(html_parts), unsafe_allow_html=True) | |
| # Topics | |
| if topics: | |
| topic_html = " ".join( | |
| '<span style="display: inline-block; background: rgba(0,212,255,0.12); ' | |
| 'border: 1px solid rgba(0,212,255,0.25); border-radius: 16px; ' | |
| 'padding: 2px 10px; margin: 2px; font-size: 11px; color: #00D4FF;">{}</span>'.format( | |
| t.replace("_", " ").title() | |
| ) | |
| for t in topics | |
| ) | |
| st.markdown( | |
| '<div style="margin-top: 8px;">' | |
| '<span style="color: #6B7B9D; font-size: 12px; font-weight: 600;">Topics: </span>' | |
| '{}</div>'.format(topic_html), | |
| unsafe_allow_html=True, | |
| ) | |
| # Stats | |
| st.markdown( | |
| '<div style="color: #6B7B9D; font-size: 11px; margin-top: 8px; text-align: right;">' | |
| 'Frames: {} • Audio: {} • Transcript: {}' | |
| '</div>'.format( | |
| stats.get("video_frames", 0), | |
| stats.get("audio_chunks", 0), | |
| stats.get("transcript_segments", 0), | |
| ), | |
| unsafe_allow_html=True, | |
| ) | |
| def _show_video_processing(processor, start): | |
| """Process an uploaded video and show results.""" | |
| video_bytes = st.session_state.get("video_bytes") | |
| if not video_bytes: | |
| st.session_state.video_processing = False | |
| st.rerun() | |
| return | |
| st.markdown( | |
| '<div class="glass-card" style="text-align: center; padding: 24px;">' | |
| '<span style="font-size: 42px;">⚙️</span>' | |
| '<h3 style="margin: 8px 0;">Analyzing Video...</h3>' | |
| '<p style="color: #6B7B9D; font-size: 13px;">' | |
| 'Processing all 4 modalities (face, voice, speech, posture) with fuzzy fusion.</p>' | |
| '</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| progress_bar = st.progress(0, text="Initializing...") | |
| def update_progress(pct): | |
| progress_bar.progress(min(int(pct * 100), 100), text=f"Processing... {int(pct * 100)}%") | |
| summary = processor.process_video_file(video_bytes, progress_callback=update_progress) | |
| progress_bar.progress(100, text="Complete!") | |
| # Clean up | |
| st.session_state.video_processing = False | |
| st.session_state.pop("video_bytes", None) | |
| if summary is not None: | |
| st.session_state.show_report = True | |
| st.rerun() | |
| else: | |
| st.error("Video processing failed. Please try a different video format (MP4 recommended).") | |
| if st.button("Back", use_container_width=True): | |
| st.rerun() | |
| def _schedule_rerun(remaining): | |
| """Schedule automatic page reload to update countdown and enforce expiry.""" | |
| if remaining <= 0: | |
| return | |
| interval = min(5.0, remaining) | |
| try: | |
| import streamlit.components.v1 as components | |
| js_code = ( | |
| '<script>' | |
| 'setTimeout(function() { window.location.reload(); }, ' | |
| + str(int(interval * 1000)) | |
| + ');</script>' | |
| ) | |
| components.html(js_code, height=0) | |
| except Exception: | |
| pass | |
| # ===================================================================== | |
| # Main Router | |
| # ===================================================================== | |
| def main(): | |
| stage = st.session_state.auth_stage | |
| if stage == "demo" and st.session_state.demo_start_time: | |
| if is_trial_expired(st.session_state.demo_start_time): | |
| st.session_state.auth_stage = "ended" | |
| stage = "ended" | |
| if stage == "email": | |
| show_landing_page() | |
| elif stage == "code": | |
| show_code_verification() | |
| elif stage == "demo": | |
| show_demo() | |
| elif stage == "ended": | |
| show_trial_ended() | |
| else: | |
| show_landing_page() | |
| if __name__ == "__main__": | |
| main() | |