Spaces:
Running
Running
Fix video filters, text overlay, burn subtitles (en, hi, ur), add ZIP download and Beautify preset
457f453 | import os | |
| import uuid | |
| import subprocess | |
| import time | |
| import threading | |
| import PIL.Image | |
| # Monkeypatch PIL.Image.ANTIALIAS for compatibility with moviepy and newer Pillow versions | |
| if not hasattr(PIL.Image, 'ANTIALIAS'): | |
| PIL.Image.ANTIALIAS = PIL.Image.Resampling.LANCZOS | |
| from flask import Flask, request, jsonify, send_from_directory, render_template | |
| from utils.audio_engine import ( | |
| generate_speech, create_mixed_audio, generate_preview, | |
| generate_ai_video, | |
| VOICES, VOICE_STYLES, MOOD_LABELS, AGE_PRESETS | |
| ) | |
| from utils.video_effects import concatenate_clips, apply_copyright_filters, slice_video | |
| app = Flask(__name__, static_folder='static', static_url_path='') | |
| # Configuration | |
| UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads') | |
| OUTPUT_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'outputs') | |
| os.makedirs(UPLOAD_FOLDER, exist_ok=True) | |
| os.makedirs(OUTPUT_FOLDER, exist_ok=True) | |
| # ── Write Cookies File from Environment Variable if present ── | |
| # IMPORTANT: Store in app root, NOT uploads/, so the cleanup thread never deletes it | |
| APP_ROOT = os.path.dirname(os.path.abspath(__file__)) | |
| COOKIES_FILE_PATH = os.path.join(APP_ROOT, 'yt_cookies.txt') | |
| yt_cookies_content = os.environ.get('YT_COOKIES') | |
| if yt_cookies_content: | |
| try: | |
| import json as _json | |
| content_to_write = yt_cookies_content.strip() | |
| # Auto-convert JSON array format to Netscape format that yt-dlp reads | |
| if content_to_write.startswith('[') or content_to_write.startswith('{'): | |
| try: | |
| cookies_list = _json.loads(content_to_write) | |
| if isinstance(cookies_list, list): | |
| netscape_lines = [ | |
| '# Netscape HTTP Cookie File', | |
| '# http://curl.haxx.se/rfc/cookie_spec.html', | |
| '# This is a generated file! Do not edit.\n' | |
| ] | |
| for c in cookies_list: | |
| if isinstance(c, dict): | |
| domain = c.get('domain', '') | |
| flag = 'TRUE' if domain.startswith('.') or not c.get('hostOnly', True) else 'FALSE' | |
| path = c.get('path', '/') | |
| secure = 'TRUE' if c.get('secure', False) else 'FALSE' | |
| try: | |
| expiry = str(int(float(c.get('expirationDate', 0)))) | |
| except (ValueError, TypeError): | |
| expiry = '0' | |
| name = c.get('name', '') | |
| value = c.get('value', '') | |
| netscape_lines.append(f'{domain}\t{flag}\t{path}\t{secure}\t{expiry}\t{name}\t{value}') | |
| content_to_write = '\n'.join(netscape_lines) | |
| print(f'[startup] Converted {len(cookies_list)} JSON cookies → Netscape format.') | |
| except Exception as _je: | |
| print(f'[startup] JSON parse failed, treating as raw text: {_je}') | |
| with open(COOKIES_FILE_PATH, 'w', encoding='utf-8') as _f: | |
| _f.write(content_to_write) | |
| print(f'[startup] cookies written → {COOKIES_FILE_PATH} ({os.path.getsize(COOKIES_FILE_PATH)} bytes)') | |
| except Exception as _e: | |
| print(f'[startup] Failed to write cookies: {_e}') | |
| else: | |
| print('[startup] No YT_COOKIES env var found — downloads will run without cookies.') | |
| # ── Background Cleanup Thread (30-minute expiration) ── | |
| def start_cleanup_thread(): | |
| def cleanup_loop(): | |
| # wait a bit for startup to complete before the first sweep | |
| time.sleep(10) | |
| while True: | |
| try: | |
| now = time.time() | |
| for folder in [UPLOAD_FOLDER, OUTPUT_FOLDER]: | |
| if not os.path.exists(folder): | |
| continue | |
| for f in os.listdir(folder): | |
| # Never delete the cookies file | |
| if f.startswith('.') or f == '.gitkeep' or f == 'yt_cookies.txt' or f == 'cookies.txt': | |
| continue | |
| fp = os.path.join(folder, f) | |
| if os.path.isfile(fp): | |
| mtime = os.path.getmtime(fp) | |
| # 1800 seconds = 30 minutes | |
| if now - mtime > 1800: | |
| try: | |
| os.remove(fp) | |
| print(f"[background-cleanup] Removed expired file: {f}") | |
| except Exception as err: | |
| print(f"[background-cleanup] Failed to delete {f}: {err}") | |
| except Exception as e: | |
| print(f"[background-cleanup] Error: {e}") | |
| time.sleep(300) # Run every 5 minutes | |
| t = threading.Thread(target=cleanup_loop, daemon=True) | |
| t.start() | |
| start_cleanup_thread() | |
| # ── Clean up any leftover temp/partial files on startup ── | |
| for _f in os.listdir(OUTPUT_FOLDER): | |
| if '.temp' in _f or 'TEMP_MPY' in _f or _f.endswith('.part'): | |
| try: | |
| os.remove(os.path.join(OUTPUT_FOLDER, _f)) | |
| print(f'[cleanup] Removed temp file: {_f}') | |
| except Exception: | |
| pass | |
| def debug_info(): | |
| """Debug endpoint — check cookies, yt-dlp version, env.""" | |
| has_cookies = os.path.exists(COOKIES_FILE_PATH) and os.path.getsize(COOKIES_FILE_PATH) > 0 | |
| cookie_lines = 0 | |
| cookie_preview = '' | |
| if has_cookies: | |
| try: | |
| with open(COOKIES_FILE_PATH, 'r') as _f: | |
| lines = _f.readlines() | |
| cookie_lines = len([l for l in lines if l.strip() and not l.startswith('#')]) | |
| cookie_preview = lines[3][:80] if len(lines) > 3 else '(empty)' | |
| except Exception as e: | |
| cookie_preview = str(e) | |
| import subprocess as _sp | |
| ytdlp_version = 'unknown' | |
| try: | |
| r = _sp.run(['yt-dlp', '--version'], capture_output=True, text=True, timeout=5) | |
| ytdlp_version = r.stdout.strip() | |
| except Exception: | |
| pass | |
| curl_cffi_loaded = False | |
| curl_cffi_error = '' | |
| try: | |
| import curl_cffi | |
| curl_cffi_loaded = True | |
| except Exception as ce: | |
| curl_cffi_error = str(ce) | |
| return jsonify({ | |
| 'cookies_file_path': COOKIES_FILE_PATH, | |
| 'cookies_loaded': has_cookies, | |
| 'cookie_entries': cookie_lines, | |
| 'cookie_preview': cookie_preview, | |
| 'yt_cookies_env_set': bool(os.environ.get('YT_COOKIES')), | |
| 'yt_proxy_set': bool(os.environ.get('YT_PROXY')), | |
| 'ytdlp_version': ytdlp_version, | |
| 'curl_cffi_loaded': curl_cffi_loaded, | |
| 'curl_cffi_error': curl_cffi_error, | |
| }) | |
| # Helper to execute shell commands (e.g. yt-dlp using local venv) | |
| def get_pip_binary(binary_name): | |
| venv_bin = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'venv', 'bin', binary_name) | |
| if os.path.exists(venv_bin): | |
| return venv_bin | |
| return binary_name | |
| def _extract_video_id(url): | |
| """Extract YouTube video ID from any YouTube URL format.""" | |
| import re | |
| patterns = [ | |
| r'(?:v=|youtu\.be/|/embed/|/v/|/shorts/)([A-Za-z0-9_-]{11})', | |
| ] | |
| for p in patterns: | |
| m = re.search(p, url) | |
| if m: | |
| return m.group(1) | |
| return None | |
| def download_via_cobalt(url, output_path): | |
| """ | |
| Download via cobalt.tools API — works from datacenter IPs. | |
| Tries public cobalt instances with both old and new API formats. | |
| """ | |
| import requests as _req | |
| # Only try top 2 instances to save time | |
| instances = [ | |
| 'https://api.cobalt.tools', | |
| 'https://cobalt.api.timelessnesses.me', | |
| ] | |
| # Try both v7 (new) and v6 (old) payload formats | |
| payloads = [ | |
| # cobalt v7+ format | |
| { | |
| 'url': url, | |
| 'videoQuality': '720', | |
| 'filenameStyle': 'basic', | |
| 'downloadMode': 'auto', | |
| }, | |
| # cobalt v6 format (older instances) | |
| { | |
| 'url': url, | |
| 'vQuality': '720', | |
| 'filenamePattern': 'basic', | |
| 'isAudioOnly': False, | |
| 'disableMetadata': True, | |
| }, | |
| ] | |
| headers = {'Accept': 'application/json', 'Content-Type': 'application/json'} | |
| for instance in instances: | |
| for payload in payloads: | |
| try: | |
| print(f'[cobalt] Trying {instance} ...') | |
| resp = _req.post(f'{instance}/', json=payload, headers=headers, timeout=4) | |
| if resp.status_code not in (200, 201): | |
| print(f'[cobalt] {instance} returned HTTP {resp.status_code}') | |
| break # try next instance, not next payload | |
| data = resp.json() | |
| status = data.get('status', '') | |
| dl_url = data.get('url', '') | |
| if status in ('stream', 'tunnel', 'redirect', 'local', 'picker') and dl_url: | |
| print(f'[cobalt] Got URL ({status}), downloading ...') | |
| with _req.get(dl_url, stream=True, timeout=300, | |
| headers={'User-Agent': 'Mozilla/5.0'}) as r: | |
| r.raise_for_status() | |
| with open(output_path, 'wb') as f: | |
| for chunk in r.iter_content(65536): | |
| f.write(chunk) | |
| if os.path.exists(output_path) and os.path.getsize(output_path) > 0: | |
| print(f'[cobalt] Success! {os.path.getsize(output_path)//1024} KB') | |
| return True | |
| else: | |
| print(f'[cobalt] Unexpected status from {instance}: {status} | {data}') | |
| break | |
| except Exception as e: | |
| print(f'[cobalt] Error with {instance}: {e}') | |
| break | |
| return False | |
| def download_via_pytubefix(url, output_path): | |
| """ | |
| Download via pytubefix — uses different request patterns than yt-dlp | |
| and often works from datacenter IPs where yt-dlp SSL-fails. | |
| """ | |
| try: | |
| import socket | |
| # Set a tight socket timeout to prevent pytubefix from hanging the request | |
| socket.setdefaulttimeout(5) | |
| from pytubefix import YouTube | |
| from pytubefix.cli import on_progress | |
| # Try WEB and ANDROID client modes (WEB triggers Node-based PO generation) | |
| clients = ['WEB', 'ANDROID'] | |
| for client in clients: | |
| try: | |
| print(f'[pytubefix] Trying client={client} for {url} ...') | |
| yt = YouTube( | |
| url, | |
| client=client, | |
| on_progress_callback=on_progress, | |
| use_oauth=False, | |
| allow_oauth_cache=False, | |
| use_po_token=False # Do NOT use True, it prompts interactively in terminal | |
| ) | |
| # Try progressive (combined audio+video) MP4 first | |
| stream = ( | |
| yt.streams | |
| .filter(progressive=True, file_extension='mp4') | |
| .order_by('resolution') | |
| .last() # highest resolution | |
| ) | |
| if not stream: | |
| # Fall back to any MP4 | |
| stream = yt.streams.filter(file_extension='mp4').first() | |
| if not stream: | |
| print(f'[pytubefix] No suitable stream found with client={client}') | |
| continue | |
| print(f'[pytubefix] Downloading {stream.resolution} stream with client={client} ...') | |
| import tempfile, shutil | |
| # pytubefix downloads to a directory, so use a temp dir | |
| tmp_dir = tempfile.mkdtemp() | |
| try: | |
| dl_path = stream.download(output_path=tmp_dir, filename='video.mp4') | |
| if dl_path and os.path.exists(dl_path) and os.path.getsize(dl_path) > 0: | |
| shutil.move(dl_path, output_path) | |
| print(f'[pytubefix] Success with client={client}! {os.path.getsize(output_path)//1024} KB') | |
| return True | |
| finally: | |
| shutil.rmtree(tmp_dir, ignore_errors=True) | |
| except Exception as client_err: | |
| print(f'[pytubefix] Client {client} failed: {client_err}') | |
| except Exception as e: | |
| print(f'[pytubefix] General Error: {e}') | |
| return False | |
| def download_via_invidious(url, output_path): | |
| """ | |
| Download via public Invidious instances — these proxy YouTube traffic | |
| and are not datacenter-blocked. | |
| """ | |
| import requests as _req | |
| vid_id = _extract_video_id(url) | |
| if not vid_id: | |
| print('[invidious] Could not extract video ID') | |
| return False | |
| # Default fallback list | |
| instances = [ | |
| 'https://inv.nadeko.net', | |
| 'https://invidious.nerdvpn.de', | |
| 'https://invidious.f5.si', | |
| 'https://yt.chocolatemoo53.com', | |
| 'https://inv.thepixora.com', | |
| ] | |
| try: | |
| print('[invidious] Querying public instances API for live hosts ...') | |
| api_resp = _req.get('https://api.invidious.io/instances.json', timeout=10) | |
| if api_resp.status_code == 200: | |
| live_instances = [] | |
| for item in api_resp.json(): | |
| try: | |
| info = item[1] | |
| monitor = info.get('monitor') | |
| if monitor and not monitor.get('down', True) and monitor.get('last_status') == 200: | |
| uri = info.get('uri') | |
| if uri: | |
| live_instances.append(uri.rstrip('/')) | |
| except Exception: | |
| pass | |
| if live_instances: | |
| print(f'[invidious] Found {len(live_instances)} live healthy instances.') | |
| # Prioritize live instances, appending defaults just in case | |
| instances = live_instances + [inst for inst in instances if inst not in live_instances] | |
| except Exception as api_err: | |
| print(f'[invidious] Failed to fetch live instances: {api_err}. Using defaults.') | |
| # Try at most 2 instances to keep latency under 6 seconds | |
| for instance in instances[:2]: | |
| instance = instance.rstrip('/') | |
| try: | |
| print(f'[invidious] Querying {instance} for {vid_id} ...') | |
| api = f'{instance}/api/v1/videos/{vid_id}?fields=formatStreams' | |
| resp = _req.get(api, timeout=3, headers={'User-Agent': 'Mozilla/5.0'}) | |
| if resp.status_code != 200: | |
| print(f'[invidious] {instance} returned HTTP {resp.status_code}') | |
| continue | |
| streams = resp.json().get('formatStreams', []) | |
| # Pick best MP4 at or below 720p | |
| best = None | |
| for s in streams: | |
| if s.get('container') == 'mp4': | |
| try: | |
| q = int(s.get('qualityLabel', '0p').replace('p', '').split()[0]) | |
| except Exception: | |
| q = 0 | |
| if q <= 720: | |
| if best is None or q > int(best.get('qualityLabel', '0p').replace('p', '').split()[0]): | |
| best = s | |
| if best and best.get('url'): | |
| stream_url = best['url'] | |
| print(f'[invidious] Downloading {best.get("qualityLabel")} from {instance} ...') | |
| with _req.get(stream_url, stream=True, timeout=300, | |
| headers={'User-Agent': 'Mozilla/5.0'}) as r: | |
| r.raise_for_status() | |
| with open(output_path, 'wb') as f: | |
| for chunk in r.iter_content(65536): | |
| f.write(chunk) | |
| if os.path.exists(output_path) and os.path.getsize(output_path) > 0: | |
| print(f'[invidious] Success! {os.path.getsize(output_path)//1024} KB') | |
| return True | |
| else: | |
| print(f'[invidious] No suitable MP4 stream found on {instance}') | |
| except Exception as e: | |
| print(f'[invidious] Error with {instance}: {e}') | |
| return False | |
| def index(): | |
| return send_from_directory('static', 'index.html') | |
| def get_voices(): | |
| """Returns voices + style + age metadata.""" | |
| return jsonify({ | |
| 'voices': VOICES, | |
| 'voice_styles': VOICE_STYLES, | |
| 'mood_labels': MOOD_LABELS, | |
| 'age_presets': AGE_PRESETS, | |
| }) | |
| def preview_voice(): | |
| """ | |
| Generate a short voice preview sample and stream it back as audio/mpeg. | |
| Body: { voice, lang, style, style_degree, rate, pitch } | |
| """ | |
| import tempfile | |
| from flask import send_file | |
| voice = request.form.get('voice', 'en-US-EmmaMultilingualNeural') | |
| lang = request.form.get('lang', 'en-US') | |
| style = request.form.get('style', '') | |
| style_degree = float(request.form.get('style_degree', 1.0)) | |
| rate = request.form.get('rate', '+0%') | |
| pitch = request.form.get('pitch', '+0Hz') | |
| # Write to a temp file | |
| tmp = tempfile.NamedTemporaryFile(suffix='.mp3', delete=False, dir=UPLOAD_FOLDER) | |
| tmp.close() | |
| out_path = tmp.name | |
| ok = generate_preview(voice=voice, lang_prefix=lang, | |
| style=style, style_degree=style_degree, | |
| rate=rate, pitch=pitch, | |
| output_path=out_path) | |
| if not ok or not os.path.exists(out_path): | |
| return jsonify({'error': 'Preview generation failed'}), 500 | |
| return send_file(out_path, mimetype='audio/mpeg', | |
| as_attachment=False, | |
| download_name='preview.mp3') | |
| def clear_library(): | |
| """Delete all exported files from the outputs folder.""" | |
| deleted, failed = [], [] | |
| for f in os.listdir(OUTPUT_FOLDER): | |
| if f.endswith('.mp4') or f.endswith('.mp3'): | |
| try: | |
| os.remove(os.path.join(OUTPUT_FOLDER, f)) | |
| deleted.append(f) | |
| except Exception as ex: | |
| failed.append({'file': f, 'error': str(ex)}) | |
| return jsonify({'deleted': len(deleted), 'failed': failed}) | |
| def merge_videos(): | |
| """ | |
| Merge uploaded video clips and overlay generated/uploaded audio. | |
| Supports style, rate, pitch, style_degree for TTS voices. | |
| """ | |
| try: | |
| aspect_ratio = request.form.get('aspect_ratio', 'vertical') | |
| audio_source = request.form.get('audio_source', 'script') | |
| language = request.form.get('language', 'ur-PK') | |
| voice_id = request.form.get('voice', 'ur-PK-UzmaNeural') | |
| script_text = request.form.get('script_text', '') | |
| trim_audio = request.form.get('trim_audio', 'true') == 'true' | |
| # Voice style / mood params | |
| voice_style = request.form.get('style', '') | |
| voice_style_degree = float(request.form.get('style_degree', 1.0)) | |
| voice_rate = request.form.get('rate', '+0%') | |
| voice_pitch = request.form.get('pitch', '+0Hz') | |
| # Handle video uploads | |
| uploaded_videos = request.files.getlist('videos') | |
| if not uploaded_videos or len(uploaded_videos) == 0 or uploaded_videos[0].filename == '': | |
| return jsonify({'success': False, 'error': 'No video files uploaded.'}), 400 | |
| video_paths = [] | |
| job_id = str(uuid.uuid4()) | |
| # Save video uploads | |
| for idx, file in enumerate(uploaded_videos): | |
| filename = f"{job_id}_video_{idx}.mp4" | |
| filepath = os.path.join(UPLOAD_FOLDER, filename) | |
| file.save(filepath) | |
| video_paths.append(filepath) | |
| # 1. Merge videos together | |
| merged_temp_video = os.path.join(UPLOAD_FOLDER, f"{job_id}_merged_raw.mp4") | |
| duration = concatenate_clips(video_paths, aspect_ratio=aspect_ratio, output_path=merged_temp_video) | |
| # 2. Process / Generate audio | |
| final_audio_path = None | |
| speech_path = None | |
| bg_music_path = None | |
| if audio_source == 'script' and script_text.strip(): | |
| # Generate speech audio | |
| speech_path = os.path.join(UPLOAD_FOLDER, f"{job_id}_speech.mp3") | |
| generate_speech(script_text, voice_id, speech_path, | |
| rate=voice_rate, pitch=voice_pitch, | |
| style=voice_style, style_degree=voice_style_degree) | |
| elif audio_source == 'upload': | |
| uploaded_audio = request.files.get('audio_file') | |
| if uploaded_audio and uploaded_audio.filename != '': | |
| speech_path = os.path.join(UPLOAD_FOLDER, f"{job_id}_user_audio.mp3") | |
| uploaded_audio.save(speech_path) | |
| # Handle optional background music upload | |
| bg_music_file = request.files.get('bg_music_file') | |
| if bg_music_file and bg_music_file.filename != '': | |
| bg_music_path = os.path.join(UPLOAD_FOLDER, f"{job_id}_bg_music.mp3") | |
| bg_music_file.save(bg_music_path) | |
| # Mix audio tracks if we have speech or background music | |
| if speech_path or bg_music_path: | |
| mixed_audio = os.path.join(UPLOAD_FOLDER, f"{job_id}_mixed.mp3") | |
| ok = create_mixed_audio( | |
| voiceover_path=speech_path, | |
| bg_music_path=bg_music_path, | |
| target_duration=duration, | |
| output_path=mixed_audio | |
| ) | |
| if ok and os.path.exists(mixed_audio): | |
| final_audio_path = mixed_audio | |
| # 3. Combine merged video and final audio | |
| output_filename = f"merged_{job_id[:8]}.mp4" | |
| final_output_path = os.path.join(OUTPUT_FOLDER, output_filename) | |
| if final_audio_path and os.path.exists(final_audio_path): | |
| # Combine via FFmpeg for speed and precision | |
| cmd = [ | |
| "ffmpeg", "-y", "-i", merged_temp_video, "-i", final_audio_path, | |
| "-map", "0:v", "-map", "1:a", "-c:v", "copy", "-c:a", "aac", | |
| "-shortest" if trim_audio else "", final_output_path | |
| ] | |
| # Remove empty arguments from command | |
| cmd = [c for c in cmd if c != ""] | |
| subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) | |
| else: | |
| # No audio overlay, rename raw merged video | |
| os.rename(merged_temp_video, final_output_path) | |
| # Cleanup temp upload files | |
| for p in video_paths + [merged_temp_video, speech_path, bg_music_path, final_audio_path]: | |
| if p and os.path.exists(p): | |
| try: | |
| os.remove(p) | |
| except Exception: | |
| pass | |
| return jsonify({ | |
| 'success': True, | |
| 'message': 'Videos merged successfully!', | |
| 'filename': output_filename, | |
| 'duration': f"{duration:.2f}s" | |
| }) | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| return jsonify({'success': False, 'error': str(e)}), 500 | |
| def download_youtube_subtitles(url, lang, job_id): | |
| """ | |
| Downloads subtitles or auto-generated subtitles for a YouTube video using yt-dlp. | |
| Respects rate limits by employing cookies, proxy, and user impersonation. | |
| """ | |
| ytdlp_bin = get_pip_binary('yt-dlp') | |
| has_cookies = os.path.exists(COOKIES_FILE_PATH) and os.path.getsize(COOKIES_FILE_PATH) > 0 | |
| yt_proxy = os.environ.get('YT_PROXY', '') | |
| try: | |
| import curl_cffi | |
| has_curl_cffi = True | |
| except ImportError: | |
| has_curl_cffi = False | |
| output_template = os.path.join(UPLOAD_FOLDER, f"{job_id}_subs") | |
| cmd = [ | |
| ytdlp_bin, '--skip-download', | |
| '--write-subs', '--write-auto-subs', | |
| '--sub-format', 'srt', | |
| '--sub-langs', lang, | |
| '--geo-bypass', '--no-check-certificates', '-4', | |
| '--socket-timeout', '15', '--retries', '1', | |
| '-o', f"{output_template}.%(ext)s" | |
| ] | |
| if has_cookies: | |
| cmd.extend(['--cookies', COOKIES_FILE_PATH]) | |
| if yt_proxy: | |
| cmd.extend(['--proxy', yt_proxy]) | |
| if has_curl_cffi: | |
| cmd.extend(['--impersonate', 'chrome']) | |
| cmd.extend(['--extractor-args', 'youtube:player_client=ios,android,web']) | |
| else: | |
| cmd.extend(['--extractor-args', 'youtube:player_client=default,-tv']) | |
| cmd.append(url) | |
| print(f"[subtitles] Requesting subtitles ({lang}) for {url} ...") | |
| try: | |
| res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20) | |
| print(f"[subtitles] yt-dlp exit status: {res.returncode}") | |
| # Look for matching srt file in UPLOAD_FOLDER | |
| import glob | |
| matches = glob.glob(os.path.join(UPLOAD_FOLDER, f"{job_id}_subs.*.srt")) | |
| if matches: | |
| print(f"[subtitles] Found downloaded subtitles: {matches[0]}") | |
| return matches[0] | |
| else: | |
| stderr_out = res.stderr.decode('utf-8', errors='ignore')[-300:] | |
| print(f"[subtitles] Subtitle download returned no srt file. Stderr: {stderr_out}") | |
| except Exception as e: | |
| print(f"[subtitles] Exception during subtitle download: {e}") | |
| return None | |
| def clip_youtube_video(): | |
| """ | |
| Downloads a YouTube video, cuts it into clips, and applies anti-copyright filters. | |
| """ | |
| try: | |
| url = request.form.get('url') | |
| video_file = request.files.get('video_file') | |
| if not url and (not video_file or video_file.filename == ''): | |
| return jsonify({'success': False, 'error': 'YouTube URL or local video file upload is required.'}), 400 | |
| mode = request.form.get('mode', 'auto') # 'auto' or 'timestamps' | |
| interval = int(request.form.get('interval', 8)) | |
| timestamps_str = request.form.get('timestamps', '') | |
| # Filter options | |
| try: | |
| b_width = int(request.form.get('border_width', 0)) | |
| except ValueError: | |
| b_width = 0 | |
| job_id = str(uuid.uuid4()) | |
| # Subtitle Handling | |
| caption_lang = request.form.get('caption_lang', 'none') | |
| subtitle_file_path = None | |
| if caption_lang != 'none' and url: | |
| subtitle_file_path = download_youtube_subtitles(url, caption_lang, job_id) | |
| filters = { | |
| 'aspect_ratio': request.form.get('aspect_ratio', 'original'), | |
| 'mirror_mode': request.form.get('mirror_mode', 'none'), | |
| 'zoom': request.form.get('zoom', 'true') == 'true', | |
| 'color_filter': request.form.get('color_filter', 'none'), | |
| 'vignette': request.form.get('vignette', 'false') == 'true', | |
| 'noise_grain': request.form.get('noise_grain', 'false') == 'true', | |
| 'speed': float(request.form.get('speed', 1.04)), | |
| 'pitch_shift': float(request.form.get('pitch_shift', 0.8)), | |
| 'border_color': request.form.get('border_color', 'none'), | |
| 'border_width': b_width, | |
| 'text_overlay': request.form.get('text_overlay', ''), | |
| 'subtitle_file_path': subtitle_file_path | |
| } | |
| raw_download_path = os.path.join(UPLOAD_FOLDER, f"{job_id}_raw.mp4") | |
| dl_success = False | |
| # 1. Acquire Video File (either via direct upload or YouTube download) | |
| if video_file and video_file.filename != '': | |
| print(f'[upload] Saving uploaded video file to {raw_download_path} ...') | |
| video_file.save(raw_download_path) | |
| if os.path.exists(raw_download_path) and os.path.getsize(raw_download_path) > 0: | |
| dl_success = True | |
| print('[upload] Video file successfully uploaded and verified!') | |
| else: | |
| print(f'[download] Starting YouTube download for {url}') | |
| # ── Method A: cobalt.tools (best — no IP restrictions) ────────────── | |
| dl_success = download_via_cobalt(url, raw_download_path) | |
| # ── Method B: pytubefix (different request path — bypasses SSL block) ─ | |
| if not dl_success: | |
| print('[download] cobalt failed, trying pytubefix ...') | |
| dl_success = download_via_pytubefix(url, raw_download_path) | |
| # ── Method C: Invidious API (proxy fallback) ───────────────────────── | |
| if not dl_success: | |
| print('[download] pytubefix failed, trying Invidious ...') | |
| dl_success = download_via_invidious(url, raw_download_path) | |
| # ── Method D: yt-dlp with cookies (last resort) ────────────────────── | |
| if not dl_success: | |
| print('[download] Invidious failed, trying yt-dlp with cookies ...') | |
| ytdlp_bin = get_pip_binary('yt-dlp') | |
| try: | |
| import curl_cffi | |
| has_curl_cffi = True | |
| except ImportError: | |
| has_curl_cffi = False | |
| has_cookies = os.path.exists(COOKIES_FILE_PATH) and os.path.getsize(COOKIES_FILE_PATH) > 0 | |
| yt_proxy = os.environ.get('YT_PROXY', '') | |
| attempts = [] | |
| if has_curl_cffi: | |
| # Impersonate chrome to bypass TLS fingerprint blocking | |
| quality = 'bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720]/best' if has_cookies else 'bestvideo[height<=480][ext=mp4]+bestaudio/best[height<=480]/best' | |
| attempts.append({'impersonate': 'chrome', 'player_client': 'ios,android,web', | |
| 'quality': quality, 'timeout': 25}) | |
| else: | |
| # Fallback if curl-cffi is not available | |
| quality = 'bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720]/best' if has_cookies else 'worst[ext=mp4]/worst' | |
| attempts.append({'impersonate': None, 'player_client': 'default,-tv', | |
| 'quality': quality, 'timeout': 25}) | |
| stderr_text = '' | |
| attempt_errors = [] | |
| for idx, attempt in enumerate(attempts, 1): | |
| for path in [raw_download_path, raw_download_path + '.part']: | |
| if os.path.exists(path): | |
| try: os.remove(path) | |
| except Exception: pass | |
| cmd = [ytdlp_bin, '-f', attempt['quality'], '--merge-output-format', 'mp4', | |
| '--geo-bypass', '--no-check-certificates', '-4', | |
| '--socket-timeout', '15', '--retries', '1', '--fragment-retries', '2', | |
| '-o', raw_download_path] | |
| if has_cookies: cmd.extend(['--cookies', COOKIES_FILE_PATH]) | |
| if yt_proxy: cmd.extend(['--proxy', yt_proxy]) | |
| if attempt['impersonate']: cmd.extend(['--impersonate', attempt['impersonate']]) | |
| if attempt['player_client']: cmd.extend(['--extractor-args', f"youtube:player_client={attempt['player_client']}"]) | |
| cmd.append(url) | |
| try: | |
| print(f"[yt-dlp] Running attempt {idx} (impersonate={attempt['impersonate']}, client={attempt['player_client']})") | |
| res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=attempt['timeout']) | |
| err = res.stderr.decode('utf-8', errors='ignore') | |
| print(f"[yt-dlp] Attempt {idx} finished with return code {res.returncode}") | |
| if res.returncode == 0 and os.path.exists(raw_download_path) and os.path.getsize(raw_download_path) > 0: | |
| dl_success = True | |
| print(f'[yt-dlp] Succeeded on attempt {idx}') | |
| break | |
| attempt_errors.append(f"Attempt {idx} (impersonate={attempt['impersonate']}): {err.strip()[-300:]}") | |
| stderr_text = err | |
| # If we got a bot/sign-in message, cookies might be bad or missing. Don't continue to avoid lockout. | |
| if any(k in err for k in ['Sign in', 'bot', 'Private', 'members-only']): | |
| print(f"[yt-dlp] Aborting attempts due to bot/auth detection on attempt {idx}") | |
| break | |
| except subprocess.TimeoutExpired: | |
| print(f"[yt-dlp] Attempt {idx} timed out.") | |
| attempt_errors.append(f"Attempt {idx} timed out.") | |
| stderr_text = 'Download timed out.' | |
| except Exception as e: | |
| print(f"[yt-dlp] Attempt {idx} exception: {e}") | |
| attempt_errors.append(f"Attempt {idx} exception: {e}") | |
| stderr_text = str(e) | |
| continue | |
| if not dl_success: | |
| details = " | ".join(attempt_errors) if attempt_errors else (stderr_text[-600:].strip() if stderr_text else 'All download methods failed') | |
| return jsonify({'success': False, | |
| 'error': f'Download failed — tried cobalt, pytubefix, invidious, yt-dlp+cookies. Details: {details}'}), 500 | |
| # 2. Slice downloaded video | |
| temp_clips_dir = os.path.join(UPLOAD_FOLDER, f"{job_id}_slices") | |
| os.makedirs(temp_clips_dir, exist_ok=True) | |
| custom_ranges = [] | |
| if mode == 'timestamps' and timestamps_str: | |
| # Parse timestamps "10-20, 30-45" | |
| parts = timestamps_str.split(',') | |
| for part in parts: | |
| subparts = part.strip().split('-') | |
| if len(subparts) == 2: | |
| try: | |
| start_t = float(subparts[0].strip()) | |
| end_t = float(subparts[1].strip()) | |
| custom_ranges.append([start_t, end_t]) | |
| except ValueError: | |
| pass | |
| sliced_files = slice_video( | |
| raw_download_path, | |
| temp_clips_dir, | |
| mode=mode, | |
| intervals=interval, | |
| custom_ranges=custom_ranges | |
| ) | |
| if not sliced_files: | |
| return jsonify({'success': False, 'error': 'No clips generated during slicing.'}), 500 | |
| # 3. Apply safety filters to each clip and save to outputs | |
| processed_files = [] | |
| for idx, file_path in enumerate(sliced_files, 1): | |
| out_filename = f"clip_{job_id[:8]}_{idx}.mp4" | |
| out_path = os.path.join(OUTPUT_FOLDER, out_filename) | |
| apply_copyright_filters(file_path, out_path, filters) | |
| if os.path.exists(out_path): | |
| processed_files.append(out_filename) | |
| # Cleanup raw downloaded file & sliced folder & subtitles | |
| if os.path.exists(raw_download_path): | |
| os.remove(raw_download_path) | |
| import shutil | |
| if os.path.exists(temp_clips_dir): | |
| shutil.rmtree(temp_clips_dir) | |
| if subtitle_file_path and os.path.exists(subtitle_file_path): | |
| try: | |
| os.remove(subtitle_file_path) | |
| except Exception: | |
| pass | |
| return jsonify({ | |
| 'success': True, | |
| 'message': f'YouTube video processed into {len(processed_files)} clips!', | |
| 'filenames': processed_files | |
| }) | |
| except Exception as e: | |
| import shutil | |
| # Cleanup files in case of error | |
| if 'raw_download_path' in locals() and os.path.exists(raw_download_path): | |
| try: os.remove(raw_download_path) | |
| except Exception: pass | |
| if 'temp_clips_dir' in locals() and os.path.exists(temp_clips_dir): | |
| try: shutil.rmtree(temp_clips_dir) | |
| except Exception: pass | |
| if 'subtitle_file_path' in locals() and subtitle_file_path and os.path.exists(subtitle_file_path): | |
| try: os.remove(subtitle_file_path) | |
| except Exception: pass | |
| import traceback | |
| traceback.print_exc() | |
| return jsonify({'success': False, 'error': str(e)}), 500 | |
| def get_video_duration(path): | |
| try: | |
| cmd = [ | |
| "ffprobe", "-v", "error", "-show_entries", "format=duration", | |
| "-of", "default=noprint_wrappers=1:nokey=1", path | |
| ] | |
| res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) | |
| if res.returncode == 0: | |
| duration = float(res.stdout.strip()) | |
| return f"{duration:.1f}s" | |
| except Exception: | |
| pass | |
| return "unknown" | |
| def get_outputs(): | |
| """Lists all real (non-temp) output files, newest first.""" | |
| SKIP_PATTERNS = ('.temp', 'TEMP_MPY', '.part', '.tmp') | |
| files = [] | |
| for f in os.listdir(OUTPUT_FOLDER): | |
| # Only include clean .mp4 files — skip any temp/partial artifacts | |
| if not f.endswith('.mp4'): | |
| continue | |
| if any(pat in f for pat in SKIP_PATTERNS): | |
| # Also delete them from disk so they don't pile up | |
| try: | |
| os.remove(os.path.join(OUTPUT_FOLDER, f)) | |
| except Exception: | |
| pass | |
| continue | |
| path = os.path.join(OUTPUT_FOLDER, f) | |
| size_mb = os.path.getsize(path) / (1024 * 1024) | |
| files.append({ | |
| 'filename': f, | |
| 'size': f"{size_mb:.2f} MB", | |
| 'duration': get_video_duration(path) | |
| }) | |
| # Sort newest first | |
| files.sort( | |
| key=lambda x: os.path.getmtime(os.path.join(OUTPUT_FOLDER, x['filename'])), | |
| reverse=True | |
| ) | |
| return jsonify(files) | |
| def generate_video(): | |
| """ | |
| End-to-end AI script-to-video generation route. | |
| """ | |
| try: | |
| script_text = request.form.get('script_text', '') | |
| theme = request.form.get('theme', 'auto') | |
| aspect_ratio = request.form.get('aspect_ratio', 'vertical') | |
| voice_id = request.form.get('voice', 'ur-PK-UzmaNeural') | |
| voice_rate = request.form.get('rate', '+0%') | |
| voice_pitch = request.form.get('pitch', '+0Hz') | |
| trim_audio = request.form.get('trim_audio', 'true') == 'true' | |
| if not script_text.strip(): | |
| return jsonify({'success': False, 'error': 'Script text is required.'}), 400 | |
| bg_music_file = request.files.get('bg_music_file') | |
| job_id = str(uuid.uuid4()) | |
| output_filename = f"ai_video_{job_id[:8]}.mp4" | |
| final_output_path = os.path.join(OUTPUT_FOLDER, output_filename) | |
| res = generate_ai_video( | |
| script_text=script_text, | |
| theme=theme, | |
| aspect_ratio=aspect_ratio, | |
| voice_id=voice_id, | |
| rate=voice_rate, | |
| pitch=voice_pitch, | |
| bg_music_file=bg_music_file, | |
| trim_audio=trim_audio, | |
| output_path=final_output_path | |
| ) | |
| if res.get('success'): | |
| return jsonify({ | |
| 'success': True, | |
| 'message': 'AI Video generated successfully!', | |
| 'filename': output_filename, | |
| 'duration': f"{res.get('duration'):.2f}s", | |
| 'slides': res.get('sentences_count') | |
| }) | |
| else: | |
| return jsonify({'success': False, 'error': 'Video generation failed.'}), 500 | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| return jsonify({'success': False, 'error': str(e)}), 500 | |
| def download_file(filename): | |
| """Serves file from outputs folder.""" | |
| return send_from_directory(OUTPUT_FOLDER, filename) | |
| def download_all(): | |
| """ | |
| ZIP all exported MP4 clips in the outputs directory and return as a single download. | |
| """ | |
| import zipfile | |
| import io | |
| from flask import send_file | |
| from datetime import datetime | |
| try: | |
| # Create in-memory zip | |
| zip_buffer = io.BytesIO() | |
| with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: | |
| for f in os.listdir(OUTPUT_FOLDER): | |
| # skip temp / partial files | |
| if f.endswith('.mp4') and not any(pat in f for pat in ['.temp', 'TEMP_MPY', '.part', '.tmp']): | |
| path = os.path.join(OUTPUT_FOLDER, f) | |
| if os.path.exists(path) and os.path.getsize(path) > 0: | |
| zip_file.write(path, f) | |
| zip_buffer.seek(0) | |
| zip_filename = f"exported_clips_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip" | |
| return send_file( | |
| zip_buffer, | |
| mimetype='application/zip', | |
| as_attachment=True, | |
| download_name=zip_filename | |
| ) | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| return jsonify({'success': False, 'error': f"Failed to zip files: {str(e)}"}), 500 | |
| if __name__ == '__main__': | |
| import os | |
| port = int(os.environ.get('PORT', 5005)) | |
| host = os.environ.get('HOST', '127.0.0.1') | |
| debug = os.environ.get('FLASK_ENV', 'production') == 'development' | |
| app.run(debug=debug, host=host, port=port) | |