Spaces:
Sleeping
Sleeping
| import os | |
| import re | |
| import tempfile | |
| import time | |
| import traceback | |
| import gradio as gr | |
| import httpx | |
| import yt_dlp | |
| try: | |
| import spaces | |
| except ImportError: | |
| class spaces: | |
| def GPU(duration=60): | |
| def decorator(fn): | |
| return fn | |
| return decorator | |
| PROXY_BASE = os.environ.get("PROXY_BASE", "").rstrip("/") | |
| PROXY_TOKEN = os.environ.get("PROXY_TOKEN", "") | |
| from transcribe import transcribe_audio, unload_model as unload_whisper | |
| from lecture_processor import summarize_lecture, generate_quiz | |
| # LANGUAGES = { | |
| # "Auto-detect": None, | |
| # "English": "en", | |
| # "Korean": "ko", | |
| # "Japanese": "ja", | |
| # "Chinese": "zh", | |
| # "Spanish": "es", | |
| # "French": "fr", | |
| # "German": "de", | |
| # "Italian": "it", | |
| # "Portuguese": "pt", | |
| # "Russian": "ru", | |
| # "Arabic": "ar", | |
| # "Hindi": "hi", | |
| # } | |
| def get_youtube_video_id(url: str) -> str | None: | |
| """Extract video ID from various YouTube URL formats.""" | |
| patterns = [ | |
| r"(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/)([a-zA-Z0-9_-]{11})", | |
| ] | |
| for pattern in patterns: | |
| match = re.search(pattern, url) | |
| if match: | |
| return match.group(1) | |
| return None | |
| def make_embed_html(video_id: str) -> str: | |
| return f'<iframe width="100%" height="400" src="https://www.youtube.com/embed/{video_id}" frameborder="0" allowfullscreen></iframe>' | |
| def download_youtube_audio(url: str) -> str: | |
| """Download audio from YouTube URL, returns path to wav file.""" | |
| tmp_dir = tempfile.mkdtemp() | |
| output_path = f"{tmp_dir}/audio.wav" | |
| ydl_opts = { | |
| "format": "bestaudio/best", | |
| "postprocessors": [{ | |
| "key": "FFmpegExtractAudio", | |
| "preferredcodec": "wav", | |
| }], | |
| "outtmpl": f"{tmp_dir}/audio", | |
| "quiet": True, | |
| "no_warnings": True, | |
| } | |
| with yt_dlp.YoutubeDL(ydl_opts) as ydl: | |
| ydl.download([url]) | |
| return output_path | |
| LANGUAGES = { | |
| "English": "en", | |
| } | |
| def make_status_html(step: int = 0, timing: str = "", error: str = "") -> str: | |
| """Step progress indicator. Steps: 0=idle, 1=download, 2=transcribe, 3=summarize, 4=quiz, 5=done.""" | |
| if error: | |
| return f'<div class="status-bar error">{error}</div>' | |
| if step == 0: | |
| return "" | |
| labels = ["Download", "Transcribe", "Summarize", "Quiz"] | |
| items = [] | |
| for i, label in enumerate(labels): | |
| s = i + 1 | |
| if s < step or step == 5: | |
| cls, icon = "done", "✓" | |
| elif s == step: | |
| cls, icon = "active", "↻" | |
| else: | |
| cls, icon = "pending", str(s) | |
| items.append( | |
| f'<div class="step {cls}"><span class="num">{icon}</span>{label}</div>' | |
| ) | |
| connector = '<div class="conn"></div>' | |
| steps_html = connector.join(items) | |
| timing_html = f'<div class="timing">{timing}</div>' if timing else "" | |
| return f'<div class="status-bar"><div class="steps">{steps_html}</div>{timing_html}</div>' | |
| def _run_pipeline(audio_path: str, language: str): | |
| """Pipeline that yields (transcript, summary, quiz, step, timing) progressively.""" | |
| lang_code = LANGUAGES.get(language) | |
| timings = {} | |
| gr.Info("Transcribing audio with WhisperX...") | |
| try: | |
| t0 = time.time() | |
| raw_text = transcribe_audio(audio_path, language=lang_code) | |
| timings["Transcription"] = time.time() - t0 | |
| except Exception as e: | |
| yield f"[Transcription error] {e}", "", "", 0, "" | |
| return | |
| if not raw_text: | |
| yield "(no speech detected)", "", "", 0, "" | |
| return | |
| timing_str = " | ".join(f"{k}: {v:.1f}s" for k, v in timings.items()) | |
| yield raw_text, "", "", 3, timing_str | |
| unload_whisper() | |
| gr.Info("Generating summary with Gemma...") | |
| try: | |
| t0 = time.time() | |
| summary = summarize_lecture(raw_text) | |
| timings["Summarization"] = time.time() - t0 | |
| except Exception as e: | |
| print(f"[ERROR] Summarization failed: {e}") | |
| traceback.print_exc() | |
| summary = f"[Summarization error] {e}" | |
| timing_str = " | ".join(f"{k}: {v:.1f}s" for k, v in timings.items()) | |
| yield raw_text, summary, "", 4, timing_str | |
| gr.Info("Generating quiz with Gemma...") | |
| try: | |
| t0 = time.time() | |
| quiz = generate_quiz(raw_text) | |
| timings["Quiz Generation"] = time.time() - t0 | |
| except Exception as e: | |
| print(f"[ERROR] Quiz generation failed: {e}") | |
| traceback.print_exc() | |
| quiz = f"[Quiz generation error] {e}" | |
| timing_str = " | ".join(f"{k}: {v:.1f}s" for k, v in timings.items()) | |
| total = sum(timings.values()) | |
| timing_str += f" | Total: {total:.1f}s" | |
| yield raw_text, summary, quiz, 5, timing_str | |
| def fetch_audio_from_proxy(url: str) -> str: | |
| """Request audio extraction from proxy, save to tmp file, return path.""" | |
| headers = {"x-proxy-token": PROXY_TOKEN} if PROXY_TOKEN else {} | |
| with httpx.stream( | |
| "POST", | |
| f"{PROXY_BASE}/extract", | |
| json={"url": url, "audio_format": "best"}, | |
| headers=headers, | |
| timeout=600, | |
| ) as resp: | |
| resp.raise_for_status() | |
| tmp_dir = tempfile.mkdtemp() | |
| audio_path = f"{tmp_dir}/audio.wav" | |
| with open(audio_path, "wb") as f: | |
| for chunk in resp.iter_bytes(chunk_size=8192): | |
| f.write(chunk) | |
| return audio_path | |
| def process_youtube(url: str, language: str): | |
| """Yields (embed, transcript, summary, quiz, status_html) progressively.""" | |
| if not url or not url.strip(): | |
| yield "", "", "", "", "" | |
| return | |
| url = url.strip() | |
| video_id = get_youtube_video_id(url) | |
| if not video_id: | |
| yield "", "", "", "", make_status_html(error="Please enter a valid YouTube URL") | |
| return | |
| embed_html = make_embed_html(video_id) | |
| yield embed_html, "", "", "", make_status_html(1) | |
| try: | |
| t0 = time.time() | |
| if PROXY_BASE: | |
| audio_path = fetch_audio_from_proxy(url) | |
| else: | |
| gr.Info("Downloading audio from YouTube...") | |
| audio_path = download_youtube_audio(url) | |
| dl_time = time.time() - t0 | |
| except Exception as e: | |
| yield embed_html, "", "", "", make_status_html(error=f"Download failed: {e}") | |
| return | |
| yield embed_html, "", "", "", make_status_html(2, f"Download: {dl_time:.1f}s") | |
| for raw_text, summary, quiz, step, timing_str in _run_pipeline(audio_path, language): | |
| full_timing = f"Download: {dl_time:.1f}s | {timing_str}" if timing_str else "" | |
| yield embed_html, raw_text, summary, quiz, make_status_html(step, full_timing) | |
| EXAMPLES = { | |
| "MIT OpenCourseWare": "https://www.youtube.com/watch?v=7Pq-S557XQU", | |
| "Stanford CS229": "https://www.youtube.com/watch?v=jGwO_UgTS7I", | |
| } | |
| # --------------------------------------------------------------------------- | |
| # ICL Gradio Theme | |
| # --------------------------------------------------------------------------- | |
| _icl_blue = gr.themes.Color( | |
| c50="#F0F7FC", | |
| c100="#D4EFFC", | |
| c200="#A8DFFA", | |
| c300="#5CC4F0", | |
| c400="#00ACD7", | |
| c500="#0091D4", | |
| c600="#003E74", | |
| c700="#002147", | |
| c800="#001A38", | |
| c900="#001029", | |
| c950="#000A1A", | |
| name="icl-blue", | |
| ) | |
| _icl_tangerine = gr.themes.Color( | |
| c50="#FFF5EB", | |
| c100="#FFE6CC", | |
| c200="#FFCC99", | |
| c300="#FFB366", | |
| c400="#FF9933", | |
| c500="#EC7300", | |
| c600="#CC6300", | |
| c700="#A35000", | |
| c800="#7A3C00", | |
| c900="#522800", | |
| c950="#331900", | |
| name="icl-tangerine", | |
| ) | |
| _icl_grey = gr.themes.Color( | |
| c50="#F7F8F8", | |
| c100="#EBEEEE", | |
| c200="#D5D9D9", | |
| c300="#B8BCBC", | |
| c400="#9D9D9D", | |
| c500="#7A7A7A", | |
| c600="#5C5C5C", | |
| c700="#4A4A4A", | |
| c800="#373A36", | |
| c900="#2A2D2A", | |
| c950="#1A1C1A", | |
| name="icl-grey", | |
| ) | |
| ICL_THEME = gr.themes.Base( | |
| primary_hue=_icl_blue, | |
| secondary_hue=_icl_tangerine, | |
| neutral_hue=_icl_grey, | |
| font=[gr.themes.GoogleFont("Source Sans Pro"), "Arial", "sans-serif"], | |
| font_mono=[gr.themes.GoogleFont("Source Code Pro"), "monospace"], | |
| ).set( | |
| # Primary buttons – Navy background | |
| button_primary_background_fill="#002147", | |
| button_primary_background_fill_dark="#003E74", | |
| button_primary_background_fill_hover="#003E74", | |
| button_primary_background_fill_hover_dark="#0091D4", | |
| button_primary_border_color="#002147", | |
| button_primary_border_color_dark="#003E74", | |
| button_primary_border_color_hover="#003E74", | |
| button_primary_text_color="white", | |
| button_primary_text_color_dark="white", | |
| # Secondary buttons – white bg, blue border/text | |
| button_secondary_background_fill="white", | |
| button_secondary_background_fill_dark="#1A1C1A", | |
| button_secondary_background_fill_hover="#D4EFFC", | |
| button_secondary_background_fill_hover_dark="#001A38", | |
| button_secondary_border_color="#003E74", | |
| button_secondary_border_color_dark="#0091D4", | |
| button_secondary_border_color_hover="#002147", | |
| button_secondary_text_color="#003E74", | |
| button_secondary_text_color_dark="#D4EFFC", | |
| button_secondary_text_color_hover="#002147", | |
| # Focus & loader | |
| input_border_color_focus="#00ACD7", | |
| input_border_color_focus_dark="#00ACD7", | |
| loader_color="#003E74", | |
| loader_color_dark="#0091D4", | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # CSS – custom properties + minimal overrides | |
| # --------------------------------------------------------------------------- | |
| CSS = """ | |
| :root { | |
| --icl-navy: #002147; | |
| --icl-blue: #003E74; | |
| --icl-process-blue: #0091D4; | |
| --icl-pool: #00ACD7; | |
| --icl-light-blue: #D4EFFC; | |
| --icl-tangerine: #EC7300; | |
| --icl-violet: #653098; | |
| --icl-green: #02893B; | |
| --icl-lime: #BBCE00; | |
| --icl-red: #B22234; | |
| --icl-grey: #EBEEEE; | |
| --icl-cool-grey: #9D9D9D; | |
| --icl-dark-grey: #373A36; | |
| --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; | |
| --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px; | |
| } | |
| /* Header brand bar */ | |
| .icl-header { | |
| text-align: center; | |
| padding: var(--sp-5) var(--sp-4); | |
| border-bottom: 3px solid var(--icl-navy); | |
| margin-bottom: var(--sp-5); | |
| } | |
| .icl-header img { height: 60px; margin-bottom: var(--sp-2); } | |
| .dark .icl-header { border-bottom-color: var(--icl-pool); } | |
| /* Title & subtitle */ | |
| .main-title { text-align: center; color: var(--icl-navy); margin-bottom: 0 !important; } | |
| .subtitle { text-align: center; color: var(--icl-blue); margin-top: 0 !important; } | |
| .dark .main-title { color: var(--icl-light-blue); } | |
| .dark .subtitle { color: var(--icl-pool); } | |
| /* Tab selected override (Gradio tabs need !important) */ | |
| .tabs .tab-nav button.selected { | |
| border-color: var(--icl-navy) !important; | |
| color: var(--icl-navy) !important; | |
| } | |
| .dark .tabs .tab-nav button.selected { | |
| border-color: var(--icl-pool) !important; | |
| color: var(--icl-pool) !important; | |
| } | |
| /* Focus & active states */ | |
| button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { | |
| outline: 3px solid var(--icl-pool); | |
| outline-offset: 2px; | |
| } | |
| button:active { transform: scale(0.97); } | |
| /* Example buttons – compact inside bordered card */ | |
| .examples-row { | |
| justify-content: center !important; | |
| gap: var(--sp-2); | |
| border: 1px solid var(--icl-light-blue); | |
| border-radius: 8px; | |
| padding: var(--sp-3) var(--sp-4); | |
| background: var(--icl-grey); | |
| } | |
| .examples-row > * { flex: 0 0 auto !important; max-width: fit-content !important; } | |
| .dark .examples-row { background: #1f2937; border-color: var(--icl-blue); } | |
| /* Step progress indicator */ | |
| .status-bar { | |
| padding: var(--sp-3) var(--sp-4); | |
| border-radius: 8px; | |
| background: var(--icl-grey); | |
| border: 1px solid var(--icl-light-blue); | |
| } | |
| .status-bar.error { | |
| background: #f8d7da; | |
| border-color: #f5c6cb; | |
| color: #721c24; | |
| text-align: center; | |
| font-weight: 500; | |
| } | |
| .status-bar .steps { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0; | |
| } | |
| .status-bar .step { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 6px 14px; | |
| border-radius: 20px; | |
| font-size: 14px; | |
| font-weight: 500; | |
| background: var(--icl-light-blue); | |
| color: var(--icl-blue); | |
| white-space: nowrap; | |
| transition: all 0.3s ease; | |
| } | |
| .status-bar .step.active { | |
| background: var(--icl-blue); | |
| color: white; | |
| animation: pulse 1.5s ease-in-out infinite; | |
| } | |
| .status-bar .step.done { | |
| background: var(--icl-navy); | |
| color: white; | |
| } | |
| .status-bar .step .num { | |
| font-weight: 700; | |
| min-width: 18px; | |
| text-align: center; | |
| } | |
| .status-bar .conn { | |
| width: 24px; | |
| height: 2px; | |
| background: var(--icl-light-blue); | |
| flex-shrink: 0; | |
| } | |
| .status-bar .timing { | |
| text-align: center; | |
| margin-top: var(--sp-2); | |
| font-size: 13px; | |
| color: var(--icl-blue); | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.6; } | |
| } | |
| /* Dark mode – status bar */ | |
| .dark .status-bar { background: #1f2937; border-color: var(--icl-blue); } | |
| .dark .status-bar.error { background: #7f1d1d; border-color: #991b1b; color: #fca5a5; } | |
| .dark .status-bar .step { background: var(--icl-blue); color: var(--icl-light-blue); } | |
| .dark .status-bar .step.active { background: var(--icl-tangerine); color: white; } | |
| .dark .status-bar .step.done { background: var(--icl-navy); color: var(--icl-light-blue); } | |
| .dark .status-bar .conn { background: var(--icl-blue); } | |
| .dark .status-bar .timing { color: var(--icl-light-blue); } | |
| /* Footer */ | |
| .footer { | |
| text-align: center; | |
| color: var(--icl-dark-grey); | |
| font-size: 0.85em; | |
| margin-top: var(--sp-4); | |
| } | |
| .dark .footer { color: var(--icl-cool-grey); } | |
| /* Reduced motion */ | |
| @media (prefers-reduced-motion: reduce) { | |
| *, *::before, *::after { | |
| animation-duration: 0.01ms !important; | |
| animation-iteration-count: 1 !important; | |
| transition-duration: 0.01ms !important; | |
| } | |
| } | |
| /* Responsive */ | |
| @media (max-width: 768px) { | |
| .icl-header img { height: 40px; } | |
| .status-bar .step { padding: 4px 10px; font-size: 12px; } | |
| .status-bar .conn { width: 12px; } | |
| } | |
| @media (max-width: 480px) { | |
| .icl-header img { height: 32px; } | |
| .icl-header { padding: var(--sp-3) var(--sp-2); } | |
| } | |
| """ | |
| with gr.Blocks( | |
| title="Lecture Processor", | |
| css=CSS, | |
| theme=ICL_THEME, | |
| ) as demo: | |
| gr.HTML(""" | |
| <div class="icl-header"> | |
| <img src="https://upload.wikimedia.org/wikipedia/commons/5/51/Imperial_College_London_crest.svg" | |
| alt="ICL Crest" | |
| onerror="this.style.display='none';"> | |
| </div> | |
| """) | |
| gr.Markdown("# Lecture Processor", elem_classes="main-title") | |
| gr.Markdown( | |
| "Transcribe, summarize, and generate quizzes from lectures", | |
| elem_classes="subtitle", | |
| ) | |
| with gr.Row(): | |
| youtube_input = gr.Textbox( | |
| label="🔗 YouTube URL", | |
| placeholder="https://www.youtube.com/watch?v=...", | |
| scale=3, | |
| ) | |
| language_dropdown = gr.Dropdown( | |
| choices=list(LANGUAGES.keys()), | |
| value="English", | |
| label="Language", | |
| scale=1, | |
| ) | |
| youtube_btn = gr.Button("▶ Process Lecture", variant="primary", size="lg") | |
| gr.Markdown("**Examples:**") | |
| with gr.Row(elem_classes="examples-row"): | |
| for name, url in EXAMPLES.items(): | |
| gr.Button(name, variant="secondary", size="sm", min_width=160).click( | |
| fn=lambda u=url: u, outputs=[youtube_input] | |
| ) | |
| status_output = gr.HTML() | |
| video_embed = gr.HTML() | |
| with gr.Tabs(): | |
| with gr.TabItem("Transcript"): | |
| raw_output = gr.Textbox( | |
| label="Raw Transcription", lines=12 | |
| ) | |
| with gr.TabItem("Summary"): | |
| summary_output = gr.Textbox(label="Lecture Summary", lines=12) | |
| with gr.TabItem("Quiz"): | |
| quiz_output = gr.Textbox(label="Quiz Questions", lines=12) | |
| gr.Markdown( | |
| "Powered by **WhisperX** & **Gemma 3 4B** | Fine-tuned LoRA adapter", | |
| elem_classes="footer", | |
| ) | |
| outputs = [video_embed, raw_output, summary_output, quiz_output, status_output] | |
| youtube_btn.click( | |
| fn=process_youtube, | |
| inputs=[youtube_input, language_dropdown], | |
| outputs=outputs, | |
| ) | |
| youtube_input.submit( | |
| fn=process_youtube, | |
| inputs=[youtube_input, language_dropdown], | |
| outputs=outputs, | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch(server_name="0.0.0.0", share=True) | |