File size: 41,935 Bytes
132f09c ff37c87 4452746 ff37c87 4452746 656ce65 4452746 656ce65 4452746 656ce65 4452746 656ce65 4452746 656ce65 4452746 656ce65 4452746 ff37c87 132f09c 4452746 132f09c 4452746 d6426f2 4452746 d6426f2 4452746 132f09c 3dc5240 f7d5742 3dc5240 f7d5742 3dc5240 437ce9e 3dc5240 437ce9e f7d5742 437ce9e f7d5742 437ce9e 8c4d4e3 f7d5742 8c4d4e3 437ce9e 8c4d4e3 3dc5240 8c4d4e3 3dc5240 437ce9e 8c4d4e3 3dc5240 8c4d4e3 f7d5742 8c4d4e3 3dc5240 f7d5742 3dc5240 132f09c 457f453 132f09c 113afb7 132f09c 457f453 132f09c 457f453 132f09c 457f453 132f09c 457f453 132f09c 113afb7 132f09c 113afb7 ee2a4a8 437ce9e 3dc5240 437ce9e 3dc5240 ff37c87 437ce9e 3dc5240 20eeabf 3dc5240 20eeabf 3dc5240 f7d5742 3dc5240 f7d5742 3dc5240 f7d5742 3dc5240 b10f144 3dc5240 20eeabf 3dc5240 b10f144 3dc5240 b10f144 3dc5240 20eeabf b10f144 3dc5240 b10f144 3dc5240 b10f144 3dc5240 b490bff b10f144 3dc5240 b10f144 132f09c ee2a4a8 132f09c 457f453 132f09c 457f453 132f09c 457f453 132f09c 457f453 132f09c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 | 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
@app.route('/api/debug')
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
@app.route('/')
def index():
return send_from_directory('static', 'index.html')
@app.route('/api/voices', methods=['GET'])
def get_voices():
"""Returns voices + style + age metadata."""
return jsonify({
'voices': VOICES,
'voice_styles': VOICE_STYLES,
'mood_labels': MOOD_LABELS,
'age_presets': AGE_PRESETS,
})
@app.route('/api/preview-voice', methods=['POST'])
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')
@app.route('/api/clear-library', methods=['POST'])
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})
@app.route('/api/merge', methods=['POST'])
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
@app.route('/api/clip', methods=['POST'])
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"
@app.route('/api/outputs', methods=['GET'])
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)
@app.route('/api/generate-video', methods=['POST'])
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
@app.route('/api/outputs/<filename>', methods=['GET'])
def download_file(filename):
"""Serves file from outputs folder."""
return send_from_directory(OUTPUT_FOLDER, filename)
@app.route('/api/download-all', methods=['GET'])
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)
|