Spaces:
Configuration error
Configuration error
| import os | |
| import subprocess | |
| import logging | |
| import uuid | |
| import threading | |
| import tempfile | |
| import shutil | |
| import time | |
| from datetime import datetime, timedelta | |
| import gc | |
| import psutil | |
| from flask import Flask, request, jsonify, send_from_directory | |
| from flask_cors import CORS | |
| import yt_dlp | |
| import hashlib | |
| import jwt | |
| import json | |
| import math | |
| import re | |
| from collections import Counter | |
| from urllib.parse import urlparse | |
| from werkzeug.utils import secure_filename | |
| # AI imports | |
| try: | |
| import whisper | |
| WHISPER_AVAILABLE = True | |
| print("Whisper imported successfully") | |
| except ImportError as e: | |
| WHISPER_AVAILABLE = False | |
| print(f"Whisper import failed: {e}") | |
| app = Flask(__name__) | |
| APP_NAME = "CapVideo" | |
| # Hugging Face runs the repository in /app. Locally, keep transient processing | |
| # files beside the application (or provide CAPVIDEO_DATA_DIR explicitly). | |
| BASE_DIR = os.environ.get('CAPVIDEO_DATA_DIR', os.path.dirname(os.path.abspath(__file__))) | |
| UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads') | |
| PROCESSED_FOLDER = os.path.join(BASE_DIR, 'processed') | |
| os.makedirs(UPLOAD_FOLDER, exist_ok=True) | |
| os.makedirs(PROCESSED_FOLDER, exist_ok=True) | |
| # Storage | |
| TEMP_STORAGE_LIMIT = 500 * 1024 * 1024 # 500MB | |
| job_status = {} | |
| users = {} | |
| user_jobs = {} | |
| processing_lock = threading.Lock() | |
| # JWT Secret | |
| # A deployment should set SECRET_KEY. The generated fallback avoids publishing a | |
| # reusable development secret in the repository. | |
| SECRET_KEY = os.environ.get('SECRET_KEY') or uuid.uuid4().hex | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| # Load Whisper model | |
| whisper_model = None | |
| if WHISPER_AVAILABLE: | |
| try: | |
| print("Loading Whisper model (tiny)...") | |
| # Use tiny model for faster loading and less memory | |
| whisper_model = whisper.load_model("tiny") | |
| print("Whisper tiny model loaded successfully") | |
| except Exception as e: | |
| print(f"Whisper loading failed: {e}") | |
| WHISPER_AVAILABLE = False | |
| CORS(app) | |
| # Helper functions | |
| def get_directory_size(directory): | |
| total = 0 | |
| try: | |
| for entry in os.scandir(directory): | |
| if entry.is_file(): | |
| total += entry.stat().st_size | |
| except: | |
| pass | |
| return total | |
| def cleanup_old_files(): | |
| current_time = datetime.now() | |
| cutoff_time = current_time - timedelta(hours=2) | |
| with processing_lock: | |
| jobs_to_remove = [] | |
| for job_id, job_info in list(job_status.items()): | |
| if job_info.get('status') in ['completed', 'failed']: | |
| jobs_to_remove.append(job_id) | |
| for job_id in jobs_to_remove[5:]: # Keep last 5 jobs | |
| cleanup_job_files(job_id) | |
| def cleanup_job_files(job_id): | |
| with processing_lock: | |
| job_status.pop(job_id, None) | |
| user_jobs.pop(job_id, None) | |
| for folder in [UPLOAD_FOLDER, PROCESSED_FOLDER]: | |
| for filename in os.listdir(folder): | |
| if filename.startswith(job_id): | |
| try: | |
| os.remove(os.path.join(folder, filename)) | |
| except: | |
| pass | |
| def format_time(seconds): | |
| hours = int(seconds // 3600) | |
| minutes = int((seconds % 3600) // 60) | |
| secs = int(seconds % 60) | |
| millis = int((seconds - int(seconds)) * 1000) | |
| return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}" | |
| def generate_srt(segments, srt_path): | |
| try: | |
| with open(srt_path, "w", encoding="utf-8") as f: | |
| idx = 1 | |
| for seg in segments: | |
| text = seg['text'].strip() | |
| if not text: | |
| continue | |
| f.write(f"{idx}\n") | |
| f.write(f"{format_time(seg['start'])} --> {format_time(seg['end'])}\n") | |
| f.write(f"{text}\n\n") | |
| idx += 1 | |
| return True | |
| except Exception as e: | |
| logger.error(f"SRT generation failed: {e}") | |
| raise | |
| STOP_WORDS = { | |
| 'about', 'after', 'again', 'also', 'and', 'are', 'because', 'been', 'before', | |
| 'being', 'between', 'but', 'can', 'could', 'did', 'does', 'each', 'for', 'from', | |
| 'explain', 'have', 'here', 'how', 'into', 'its', 'just', 'like', 'make', 'more', 'most', 'not', 'now', | |
| 'only', 'our', 'out', 'over', 'really', 'should', 'some', 'such', 'than', 'that', | |
| 'the', 'their', 'then', 'there', 'these', 'they', 'this', 'those', 'through', | |
| 'something', 'today', 'under', 'using', 'very', 'was', 'were', 'what', 'when', 'where', 'which', 'while', | |
| 'will', 'with', 'would', 'you', 'your', 'video', 'okay', 'right', 'yeah' | |
| } | |
| def format_timestamp(seconds): | |
| """Return a compact timestamp that a learner can scan quickly.""" | |
| seconds = max(0, int(seconds or 0)) | |
| minutes, seconds = divmod(seconds, 60) | |
| hours, minutes = divmod(minutes, 60) | |
| return f"{hours}:{minutes:02d}:{seconds:02d}" if hours else f"{minutes}:{seconds:02d}" | |
| def compact_text(text, limit=220): | |
| text = re.sub(r'\s+', ' ', (text or '')).strip() | |
| if len(text) <= limit: | |
| return text | |
| shortened = text[:limit].rsplit(' ', 1)[0] | |
| return f"{shortened}..." | |
| def extract_keywords(text, limit=4): | |
| words = re.findall(r"[A-Za-z][A-Za-z'-]{2,}", (text or '').lower()) | |
| counts = Counter(word for word in words if word not in STOP_WORDS) | |
| return [word for word, _ in counts.most_common(limit)] | |
| def best_evidence_sentence(text, keyword): | |
| sentences = re.split(r'(?<=[.!?])\s+', (text or '').strip()) | |
| keyword = keyword.lower() | |
| for sentence in sentences: | |
| if keyword in sentence.lower() and len(sentence) > 20: | |
| return compact_text(sentence, 190) | |
| return compact_text(sentences[0] if sentences else text, 190) | |
| def build_learning_kit(segments): | |
| """Create a citation-friendly study kit from Whisper timestamps. | |
| This intentionally keeps every generated item tied to a point in the source | |
| video. It is useful even when an optional external LLM key is not available. | |
| """ | |
| usable = [segment for segment in (segments or []) if segment.get('text', '').strip()] | |
| if not usable: | |
| return {'chapters': [], 'key_terms': [], 'recall_cards': []} | |
| # Three to six chapters keeps the result legible for both short and long lectures. | |
| duration = float(usable[-1].get('end', 0) or 0) | |
| chapter_count = min(6, max(3, int(math.ceil(duration / 180)))) | |
| chunk_size = max(1, int(math.ceil(len(usable) / chapter_count))) | |
| chapters = [] | |
| for chapter_number, start_index in enumerate(range(0, len(usable), chunk_size), start=1): | |
| chunk = usable[start_index:start_index + chunk_size] | |
| if not chunk: | |
| continue | |
| passage = ' '.join(segment['text'].strip() for segment in chunk) | |
| keywords = extract_keywords(passage, limit=3) | |
| title = ' - '.join(word.title() for word in keywords) or f'Key idea {chapter_number}' | |
| chapters.append({ | |
| 'timestamp': format_timestamp(chunk[0].get('start', 0)), | |
| 'start_seconds': round(float(chunk[0].get('start', 0)), 1), | |
| 'title': title, | |
| 'summary': best_evidence_sentence(passage, keywords[0]) if keywords else compact_text(passage), | |
| }) | |
| full_text = ' '.join(segment['text'].strip() for segment in usable) | |
| key_terms = extract_keywords(full_text, limit=6) | |
| recall_cards = [] | |
| for index, term in enumerate(key_terms[:5]): | |
| matching = next((segment for segment in usable if term in segment['text'].lower()), usable[0]) | |
| recall_cards.append({ | |
| 'question': f'What does the video explain about "{term}"?', | |
| 'answer': best_evidence_sentence(matching['text'], term), | |
| 'timestamp': format_timestamp(matching.get('start', 0)), | |
| }) | |
| return { | |
| 'chapters': chapters, | |
| 'key_terms': [term.title() for term in key_terms], | |
| 'recall_cards': recall_cards, | |
| } | |
| def overlay_subtitles(input_path, srt_path, output_path, caption_settings=None): | |
| try: | |
| if caption_settings is None: | |
| caption_settings = {} | |
| # Get caption settings with defaults | |
| font_size = caption_settings.get('size', '20') | |
| font_color = caption_settings.get('color', 'white') | |
| font_family = caption_settings.get('font', 'arial') | |
| bg_color = caption_settings.get('bgColor', 'none') | |
| position = caption_settings.get('position', 'bottom') | |
| alignment = caption_settings.get('alignment', 'center') | |
| outline = caption_settings.get('outlineThickness', 'medium') | |
| shadow = caption_settings.get('shadowDistance', 'medium') | |
| font_style = caption_settings.get('fontStyle', 'normal') | |
| # Convert SRT to ASS for better styling control | |
| ass_path = srt_path.replace('.srt', '.ass') | |
| # Color mapping for ASS (AABBGGRR format where AA=00 is opaque) | |
| color_map = { | |
| 'white': '00FFFFFF', | |
| 'yellow': '0000FFFF', | |
| 'cyan': '00FFFF00', | |
| 'lime': '0000FF00', | |
| 'orange': '0000A5FF', | |
| 'red': '000000FF', | |
| 'pink': '00FFC0CB', | |
| 'purple': '00A020F0', | |
| 'light-blue': '00E6D8AD', | |
| 'light-green': '0090EE90' | |
| } | |
| # Background color mapping | |
| bg_color_map = { | |
| 'none': 'FF000000', # Fully transparent | |
| 'black': '00000000', # Black fully opaque | |
| 'dark-gray': '00333333', # Dark gray fully opaque | |
| 'semi-transparent': '80000000', # Black 50% opacity | |
| 'dark-blue': '00800000', # Dark blue fully opaque | |
| 'dark-red': '00000080', # Dark red fully opaque | |
| 'dark-green': '00008000', # Dark green fully opaque | |
| 'dark-purple': '00800080', # Dark purple fully opaque | |
| 'navy': '00800000', # Navy fully opaque | |
| 'charcoal': '00363636' # Charcoal fully opaque | |
| } | |
| # Font mapping | |
| font_map = { | |
| 'arial': 'Arial', | |
| 'helvetica': 'Helvetica', | |
| 'times-new-roman': 'Times New Roman', | |
| 'courier-new': 'Courier New', | |
| 'verdana': 'Verdana', | |
| 'georgia': 'Georgia', | |
| 'impact': 'Impact', | |
| 'comic-sans': 'Comic Sans MS', | |
| 'trebuchet': 'Trebuchet MS', | |
| 'arial-black': 'Arial Black', | |
| 'palatino': 'Palatino Linotype' | |
| } | |
| # Position mapping | |
| position_map = { | |
| 'bottom': (2, '10', '10', '20'), | |
| 'top': (8, '10', '10', '20'), | |
| 'bottom-left': (1, '40', '10', '20'), | |
| 'bottom-right': (3, '10', '40', '20'), | |
| 'top-left': (7, '40', '10', '20'), | |
| 'top-right': (9, '10', '40', '20'), | |
| 'middle': (5, '10', '10', '0') | |
| } | |
| # Get position settings | |
| alignment_code, margin_l, margin_r, margin_v = position_map.get(position, (2, '10', '10', '20')) | |
| # Adjust alignment based on text alignment setting | |
| if position in ['top', 'middle', 'bottom']: | |
| if alignment == 'left': | |
| alignment_code -= 1 | |
| margin_l = '40' | |
| margin_r = '10' | |
| elif alignment == 'right': | |
| alignment_code += 1 | |
| margin_l = '10' | |
| margin_r = '40' | |
| # Get colors | |
| primary_color = color_map.get(font_color, '00FFFFFF') | |
| back_color = bg_color_map.get(bg_color, 'FF000000') | |
| font_name = font_map.get(font_family.lower(), 'Arial') | |
| # Font style | |
| bold = -1 if 'bold' in font_style else 0 | |
| italic = -1 if 'italic' in font_style else 0 | |
| # Border settings | |
| has_background = bg_color != 'none' | |
| if has_background: | |
| border_style = '4' # Opaque box | |
| outline_val = '2' # Padding | |
| shadow_val = '0' # No shadow with box | |
| else: | |
| border_style = '1' # Outline + shadow | |
| outline_val = {'none': '0', 'thin': '1', 'medium': '2', 'thick': '3', 'extra-thick': '4'}.get(outline, '2') | |
| shadow_val = {'none': '0', 'subtle': '1', 'medium': '2', 'large': '3', 'extra-large': '4'}.get(shadow, '2') | |
| # Create ASS file | |
| ass_content = f"""[Script Info] | |
| Title: CapVideo Subtitles | |
| ScriptType: v4.00+ | |
| PlayResX: 384 | |
| PlayResY: 288 | |
| [V4+ Styles] | |
| Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding | |
| Style: Default,{font_name},{font_size},&H{primary_color},&H{primary_color},&H00000000,&H{back_color},{bold},{italic},0,0,100,100,0,0,{border_style},{outline_val},{shadow_val},{alignment_code},{margin_l},{margin_r},{margin_v},1 | |
| [Events] | |
| Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text | |
| """ | |
| # Read SRT file | |
| with open(srt_path, 'r', encoding='utf-8') as f: | |
| srt_lines = f.readlines() | |
| i = 0 | |
| while i < len(srt_lines): | |
| line = srt_lines[i].strip() | |
| if '-->' in line: | |
| time_parts = line.split(' --> ') | |
| if len(time_parts) == 2: | |
| # Convert SRT time to ASS time | |
| start = time_parts[0].strip().replace(',', '.') | |
| end = time_parts[1].strip().replace(',', '.') | |
| # Parse times | |
| start_parts = start.split(':') | |
| end_parts = end.split(':') | |
| start_h, start_m = int(start_parts[0]), int(start_parts[1]) | |
| start_s = float(start_parts[2]) | |
| end_h, end_m = int(end_parts[0]), int(end_parts[1]) | |
| end_s = float(end_parts[2]) | |
| start_ass = f"{start_h}:{start_m:02d}:{start_s:05.2f}" | |
| end_ass = f"{end_h}:{end_m:02d}:{end_s:05.2f}" | |
| # Get text | |
| i += 1 | |
| text_lines = [] | |
| while i < len(srt_lines) and srt_lines[i].strip(): | |
| text_lines.append(srt_lines[i].strip()) | |
| i += 1 | |
| # Curly braces introduce ASS override tags, so escaping them | |
| # prevents a spoken phrase from unexpectedly changing styling. | |
| text = '\\N'.join(text_lines).replace('{', '\\{').replace('}', '\\}') | |
| ass_content += f"Dialogue: 0,{start_ass},{end_ass},Default,,0,0,0,,{text}\n" | |
| i += 1 | |
| # Write ASS file | |
| with open(ass_path, 'w', encoding='utf-8') as f: | |
| f.write(ass_content) | |
| logger.info(f"ASS subtitle created: color={font_color}, bg={bg_color}") | |
| # Build FFmpeg command using ASS | |
| if os.name == 'nt': | |
| ass_escaped = ass_path.replace('\\', '/').replace(':', '\\:') | |
| else: | |
| ass_escaped = ass_path.replace(':', '\\:') | |
| cmd = [ | |
| 'ffmpeg', '-y', | |
| '-i', input_path, | |
| '-vf', f"ass='{ass_escaped}'", | |
| '-c:v', 'libx264', | |
| '-c:a', 'copy', | |
| '-preset', 'fast', | |
| output_path | |
| ] | |
| logger.info("Running FFmpeg command") | |
| result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) | |
| if result.returncode != 0: | |
| logger.error(f"FFmpeg error: {result.stderr}") | |
| raise Exception(f"FFmpeg failed: {result.stderr}") | |
| if os.path.exists(output_path) and os.path.getsize(output_path) > 0: | |
| logger.info("Styled subtitles embedded successfully") | |
| # Clean up ASS file | |
| try: | |
| os.remove(ass_path) | |
| except: | |
| pass | |
| return True | |
| else: | |
| raise Exception("Output file not created") | |
| except Exception as e: | |
| logger.error(f"Subtitle overlay failed: {e}") | |
| raise Exception(f"Subtitle overlay failed: {str(e)}") | |
| def hash_password(password): | |
| return hashlib.sha256(password.encode()).hexdigest() | |
| def verify_token(token): | |
| try: | |
| decoded = jwt.decode(token, SECRET_KEY, algorithms=['HS256']) | |
| return decoded['username'] | |
| except: | |
| return None | |
| def process_video_task(job_id, filepath, filename, is_youtube=False, token=None, caption_settings=None): | |
| try: | |
| logger.info(f"Starting processing for job {job_id}") | |
| with processing_lock: | |
| job_status[job_id] = {'status': 'transcribing', 'filename': filename} | |
| if not WHISPER_AVAILABLE or whisper_model is None: | |
| error_msg = "Whisper model not loaded. Please check server logs." | |
| logger.error(error_msg) | |
| raise Exception(error_msg) | |
| # Transcribe with whisper | |
| logger.info(f"Starting transcription with Whisper for {filename}...") | |
| language = (caption_settings or {}).get('language') or None | |
| result = whisper_model.transcribe(filepath, word_timestamps=True, language=language) | |
| if not result or 'segments' not in result: | |
| raise Exception("No speech detected in the video") | |
| with processing_lock: | |
| job_status[job_id] = {'status': 'generating_captions', 'filename': filename} | |
| srt_path = os.path.join(PROCESSED_FOLDER, f"{job_id}_captions.srt") | |
| output_filename = f"{job_id}_with_subtitles.mp4" | |
| output_path = os.path.join(PROCESSED_FOLDER, output_filename) | |
| generate_srt(result["segments"], srt_path) | |
| transcription_text = " ".join([seg['text'].strip() for seg in result["segments"]]) | |
| video_duration = result['segments'][-1]['end'] if result['segments'] else 0 | |
| with processing_lock: | |
| job_status[job_id] = {'status': 'building_learning_kit', 'filename': filename} | |
| learning_kit = build_learning_kit(result['segments']) | |
| transcript_filename = f"{job_id}_transcript.txt" | |
| transcript_path = os.path.join(PROCESSED_FOLDER, transcript_filename) | |
| with open(transcript_path, 'w', encoding='utf-8') as transcript_file: | |
| transcript_file.write(f"{APP_NAME} transcript: {filename}\n\n") | |
| for segment in result['segments']: | |
| transcript_file.write( | |
| f"[{format_timestamp(segment.get('start', 0))}] {segment['text'].strip()}\n" | |
| ) | |
| with processing_lock: | |
| job_status[job_id] = {'status': 'embedding_subtitles', 'filename': filename} | |
| logger.info(f"Using caption settings: {caption_settings}") | |
| overlay_subtitles(filepath, srt_path, output_path, caption_settings) | |
| if os.path.exists(output_path): | |
| end_time = datetime.now() | |
| with processing_lock: | |
| job_info = { | |
| 'status': 'completed', | |
| 'filename': filename, | |
| 'download_url': f"/download/{output_filename}", | |
| 'captions_url': f"/download/{os.path.basename(srt_path)}", | |
| 'transcript_url': f"/download/{transcript_filename}", | |
| 'transcription': transcription_text, | |
| 'learning_kit': learning_kit, | |
| 'date': end_time.strftime('%Y-%m-%d'), | |
| 'time': end_time.strftime('%H:%M:%S'), | |
| 'duration': f"{int(video_duration // 60)}:{int(video_duration % 60):02d}" | |
| } | |
| job_status[job_id] = job_info | |
| user_jobs[job_id] = job_info | |
| if token: | |
| username = verify_token(token) | |
| if username and username in users: | |
| users[username]['history'].append(job_id) | |
| # Cleanup | |
| try: | |
| if filepath != output_path: | |
| os.remove(filepath) | |
| except: | |
| pass | |
| logger.info(f"Processing completed for job {job_id}") | |
| else: | |
| raise Exception("Output video not created") | |
| except Exception as e: | |
| logger.error(f"Processing failed for job {job_id}: {str(e)}") | |
| import traceback | |
| traceback.print_exc() | |
| start_time = datetime.now() | |
| with processing_lock: | |
| job_info = { | |
| 'status': 'failed', | |
| 'filename': filename, | |
| 'error': str(e), | |
| 'date': start_time.strftime('%Y-%m-%d'), | |
| 'time': start_time.strftime('%H:%M:%S'), | |
| 'duration': 'N/A' | |
| } | |
| job_status[job_id] = job_info | |
| user_jobs[job_id] = job_info | |
| if token: | |
| username = verify_token(token) | |
| if username and username in users: | |
| users[username]['history'].append(job_id) | |
| def download_youtube_video(youtube_url, job_id): | |
| try: | |
| # The extension must be chosen by yt-dlp after format merging. Returning a | |
| # hard-coded .mp4 path was the main reason the previous flow often failed. | |
| temp_template = os.path.join(UPLOAD_FOLDER, f"{job_id}_youtube.%(ext)s") | |
| ydl_opts = { | |
| 'format': 'bv*[height<=720]+ba/b[height<=720]/b', | |
| 'outtmpl': temp_template, | |
| 'merge_output_format': 'mp4', | |
| 'quiet': True, | |
| 'no_warnings': True, | |
| 'extract_flat': False, | |
| 'noplaylist': True, | |
| 'retries': 3, | |
| 'fragment_retries': 3, | |
| 'socket_timeout': 20, | |
| } | |
| # Cookies are deliberately opt-in and server-side only. Never ask users to | |
| # paste browser cookies into the app; put an authorized cookie file in a | |
| # deployment secret and set YTDLP_COOKIEFILE if it is genuinely required. | |
| cookie_file = os.environ.get('YTDLP_COOKIEFILE') | |
| if cookie_file and os.path.isfile(cookie_file): | |
| ydl_opts['cookiefile'] = cookie_file | |
| with yt_dlp.YoutubeDL(ydl_opts) as ydl: | |
| info = ydl.extract_info(youtube_url, download=True) | |
| requested = info.get('requested_downloads') or [] | |
| downloaded_path = requested[0].get('filepath') if requested else ydl.prepare_filename(info) | |
| if not os.path.isfile(downloaded_path): | |
| candidates = [ | |
| os.path.join(UPLOAD_FOLDER, candidate) | |
| for candidate in os.listdir(UPLOAD_FOLDER) | |
| if candidate.startswith(f"{job_id}_youtube.") | |
| ] | |
| if not candidates: | |
| raise FileNotFoundError('yt-dlp completed without creating a usable media file') | |
| downloaded_path = max(candidates, key=os.path.getmtime) | |
| title = secure_filename(info.get('title', 'youtube_video')) or 'youtube_video' | |
| return downloaded_path, f"{title}{os.path.splitext(downloaded_path)[1] or '.mp4'}" | |
| except Exception as e: | |
| logger.error(f"YouTube download failed: {e}") | |
| raise Exception(f"YouTube download failed: {str(e)}") | |
| # ========== ROUTES ========== | |
| def serve_index(): | |
| return send_from_directory('.', 'index.html') | |
| def health(): | |
| return jsonify({ | |
| "status": "ok", | |
| "app": APP_NAME, | |
| "whisper_available": WHISPER_AVAILABLE, | |
| "model_loaded": whisper_model is not None, | |
| "storage_used_mb": round(get_directory_size(BASE_DIR) / 1024 / 1024, 2) | |
| }) | |
| def signup(): | |
| try: | |
| data = request.get_json() | |
| if not data: | |
| return jsonify({'error': 'No data provided'}), 400 | |
| username = data.get('username', '').strip() | |
| password = data.get('password', '') | |
| if not username or not password: | |
| return jsonify({'error': 'Username and password required'}), 400 | |
| if len(username) < 3: | |
| return jsonify({'error': 'Username must be at least 3 characters'}), 400 | |
| password_hash = hashlib.sha256(password.encode()).hexdigest() | |
| with processing_lock: | |
| if username in users: | |
| return jsonify({'error': 'Username already exists'}), 400 | |
| users[username] = { | |
| 'password_hash': password_hash, | |
| 'history': [], | |
| 'favorites': set() | |
| } | |
| token = jwt.encode({'username': username}, SECRET_KEY, algorithm='HS256') | |
| logger.info(f"User signed up: {username}") | |
| return jsonify({'token': token}), 201 | |
| except Exception as e: | |
| logger.error(f"Signup error: {e}") | |
| return jsonify({'error': 'Signup failed'}), 500 | |
| def login(): | |
| try: | |
| data = request.get_json() | |
| if not data: | |
| return jsonify({'error': 'No data provided'}), 400 | |
| username = data.get('username', '').strip() | |
| password = data.get('password', '') | |
| if not username or not password: | |
| return jsonify({'error': 'Username and password required'}), 400 | |
| password_hash = hashlib.sha256(password.encode()).hexdigest() | |
| with processing_lock: | |
| user = users.get(username) | |
| if not user or user['password_hash'] != password_hash: | |
| return jsonify({'error': 'Invalid credentials'}), 401 | |
| token = jwt.encode({'username': username}, SECRET_KEY, algorithm='HS256') | |
| logger.info(f"User logged in: {username}") | |
| return jsonify({'token': token}), 200 | |
| except Exception as e: | |
| logger.error(f"Login error: {e}") | |
| return jsonify({'error': 'Login failed'}), 500 | |
| def upload_video(): | |
| if 'video' not in request.files: | |
| return jsonify({'error': 'No video file provided'}), 400 | |
| video = request.files['video'] | |
| if video.filename == '': | |
| return jsonify({'error': 'Empty filename'}), 400 | |
| # Check file extension | |
| allowed_extensions = {'.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv', '.wmv', '.m4v', '.3gp'} | |
| file_ext = os.path.splitext(video.filename.lower())[1] | |
| if file_ext not in allowed_extensions: | |
| return jsonify({'error': 'Only video files are allowed'}), 400 | |
| current_storage = get_directory_size(BASE_DIR) | |
| if current_storage > TEMP_STORAGE_LIMIT * 0.8: | |
| cleanup_old_files() | |
| job_id = str(uuid.uuid4()) | |
| filename = f"{job_id}_{video.filename}" | |
| filepath = os.path.join(UPLOAD_FOLDER, filename) | |
| try: | |
| video.save(filepath) | |
| with processing_lock: | |
| job_status[job_id] = {'status': 'uploaded', 'filename': video.filename} | |
| # Get caption settings from form data | |
| caption_settings = {} | |
| if 'captionSettings' in request.form: | |
| try: | |
| caption_settings = json.loads(request.form['captionSettings']) | |
| logger.info(f"Caption settings received: {caption_settings}") | |
| except Exception as e: | |
| logger.warning(f"Could not parse caption settings: {e}") | |
| token = request.headers.get('Authorization', '').replace('Bearer ', '') | |
| thread = threading.Thread( | |
| target=process_video_task, | |
| args=(job_id, filepath, video.filename, False, token, caption_settings), | |
| daemon=True | |
| ) | |
| thread.start() | |
| return jsonify({'job_id': job_id}), 202 | |
| except Exception as e: | |
| logger.error(f"Upload failed: {e}") | |
| return jsonify({'error': f'Upload failed: {str(e)}'}), 500 | |
| def transcribe_youtube(): | |
| try: | |
| data = request.get_json(silent=True) or {} | |
| youtube_url = data.get('url') | |
| caption_settings = data.get('captionSettings', {}) | |
| if not youtube_url: | |
| return jsonify({'error': 'YouTube URL required'}), 400 | |
| # Validate the host instead of accepting a URL that only contains this | |
| # text in its path or query string. | |
| parsed_url = urlparse(youtube_url) | |
| allowed_hosts = {'youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be', 'www.youtu.be'} | |
| if parsed_url.scheme not in {'http', 'https'} or parsed_url.netloc.lower() not in allowed_hosts: | |
| return jsonify({'error': 'Invalid YouTube URL'}), 400 | |
| current_storage = get_directory_size(BASE_DIR) | |
| if current_storage > TEMP_STORAGE_LIMIT * 0.8: | |
| cleanup_old_files() | |
| job_id = str(uuid.uuid4()) | |
| with processing_lock: | |
| job_status[job_id] = {'status': 'downloading', 'filename': 'YouTube Video'} | |
| # Start download in background | |
| token = request.headers.get('Authorization', '').replace('Bearer ', '') | |
| def download_and_process(): | |
| try: | |
| video_path, filename = download_youtube_video(youtube_url, job_id) | |
| thread = threading.Thread( | |
| target=process_video_task, | |
| args=(job_id, video_path, filename, True, token, caption_settings), | |
| daemon=True | |
| ) | |
| thread.start() | |
| except Exception as e: | |
| logger.error(f"YouTube download failed: {e}") | |
| with processing_lock: | |
| job_status[job_id] = {'status': 'failed', 'error': str(e)} | |
| download_thread = threading.Thread(target=download_and_process, daemon=True) | |
| download_thread.start() | |
| return jsonify({'job_id': job_id}), 202 | |
| except Exception as e: | |
| logger.error(f"YouTube processing failed: {e}") | |
| return jsonify({'error': str(e)}), 500 | |
| def get_status(job_id): | |
| with processing_lock: | |
| if job_id not in job_status: | |
| return jsonify({'error': 'Job not found'}), 404 | |
| return jsonify(job_status[job_id]) | |
| def download_file(filename): | |
| path = os.path.join(PROCESSED_FOLDER, filename) | |
| if not os.path.exists(path): | |
| return jsonify({'error': 'File not found'}), 404 | |
| return send_from_directory(PROCESSED_FOLDER, filename, as_attachment=True) | |
| def get_profile(): | |
| token = request.headers.get('Authorization', '').replace('Bearer ', '') | |
| username = verify_token(token) | |
| if not username: | |
| return jsonify({'error': 'Unauthorized'}), 401 | |
| with processing_lock: | |
| user = users.get(username, {}) | |
| job_ids = user.get('history', []) | |
| favorites = user.get('favorites', set()) | |
| history = [] | |
| for job_id in job_ids: | |
| job_info = user_jobs.get(job_id, {}).copy() | |
| if job_info: | |
| job_info['job_id'] = job_id | |
| job_info['favorited'] = job_id in favorites | |
| history.append(job_info) | |
| history.sort(key=lambda x: (x.get('date', ''), x.get('time', '')), reverse=True) | |
| return jsonify({ | |
| 'username': username, | |
| 'job_count': len(job_ids), | |
| 'favorite_count': len(favorites), | |
| 'history': history | |
| }), 200 | |
| def toggle_favorite(job_id): | |
| token = request.headers.get('Authorization', '').replace('Bearer ', '') | |
| username = verify_token(token) | |
| if not username: | |
| return jsonify({'error': 'Unauthorized'}), 401 | |
| with processing_lock: | |
| user = users.get(username) | |
| if not user: | |
| return jsonify({'error': 'User not found'}), 404 | |
| if job_id not in user.get('history', []): | |
| return jsonify({'error': 'Job not found in user history'}), 404 | |
| if 'favorites' not in user: | |
| user['favorites'] = set() | |
| if job_id in user['favorites']: | |
| user['favorites'].discard(job_id) | |
| favorited = False | |
| else: | |
| user['favorites'].add(job_id) | |
| favorited = True | |
| return jsonify({'favorited': favorited}), 200 | |
| def delete_history_item(job_id): | |
| token = request.headers.get('Authorization', '').replace('Bearer ', '') | |
| username = verify_token(token) | |
| if not username: | |
| return jsonify({'error': 'Unauthorized'}), 401 | |
| with processing_lock: | |
| user = users.get(username) | |
| if not user: | |
| return jsonify({'error': 'User not found'}), 404 | |
| if job_id in user['history']: | |
| user['history'].remove(job_id) | |
| if 'favorites' in user and job_id in user['favorites']: | |
| user['favorites'].discard(job_id) | |
| if job_id in job_status: | |
| del job_status[job_id] | |
| if job_id in user_jobs: | |
| del user_jobs[job_id] | |
| cleanup_job_files(job_id) | |
| return jsonify({'message': 'History item deleted successfully'}), 200 | |
| def serve_static(path): | |
| return send_from_directory('.', path) | |
| # Periodic cleanup | |
| def cleanup_loop(): | |
| while True: | |
| time.sleep(1800) # 30 minutes | |
| cleanup_old_files() | |
| cleanup_thread = threading.Thread(target=cleanup_loop, daemon=True) | |
| cleanup_thread.start() | |
| if __name__ == '__main__': | |
| port = int(os.environ.get("PORT", 7860)) | |
| print("=" * 60) | |
| print("CapVideo Hugging Face Edition starting...") | |
| print("=" * 60) | |
| print(f"Whisper: {'available' if WHISPER_AVAILABLE else 'not available'}") | |
| if WHISPER_AVAILABLE and whisper_model: | |
| print(f" Model: tiny (loaded from GitHub)") | |
| print(f"Server: http://0.0.0.0:{port}") | |
| print(f"Storage: {TEMP_STORAGE_LIMIT/1024/1024}MB limit") | |
| print("=" * 60) | |
| app.run(host='0.0.0.0', port=port, debug=False) | |