Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| NebulaCut β Viral Shorts Backend | |
| Analyzes YouTube videos, finds hook segments, cuts 9:16 clips. | |
| Requirements: pip install flask flask-cors yt-dlp | |
| System deps: ffmpeg (must be in PATH) | |
| """ | |
| import json | |
| import math | |
| import os | |
| import re | |
| import struct | |
| import subprocess | |
| import urllib.error | |
| import urllib.request | |
| import uuid | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from pathlib import Path | |
| import http.client | |
| import ssl | |
| from flask import Flask, jsonify, request, send_from_directory, Response, stream_with_context | |
| from flask_cors import CORS | |
| from werkzeug.utils import secure_filename | |
| # ββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ALLOWED_EXTENSIONS = {'mp4', 'webm', 'mkv', 'avi', 'mov', 'flv', 'wmv'} | |
| MAX_UPLOAD_SIZE = 500 * 1024 * 1024 # 500MB | |
| def _env_int(name: str, default: int) -> int: | |
| raw = os.getenv(name) | |
| if raw is None: | |
| return default | |
| try: | |
| return int(raw) | |
| except ValueError: | |
| return default | |
| def _env_float(name: str, default: float) -> float: | |
| raw = os.getenv(name) | |
| if raw is None: | |
| return default | |
| try: | |
| return float(raw) | |
| except ValueError: | |
| return default | |
| _CPU_COUNT = max(1, os.cpu_count() or 4) | |
| HOST = os.getenv("CLIPPER_HOST", "0.0.0.0") | |
| PORT = _env_int("CLIPPER_PORT", 7860) | |
| MAX_CLIPS = _env_int("CLIPPER_MAX_CLIPS", 3) # fewer clips = faster end-to-end | |
| CLIP_DURATION = _env_float("CLIPPER_DURATION", 22) # shorter clips encode faster | |
| MIN_GAP_SECONDS = _env_float("CLIPPER_MIN_GAP", 60) # minimum spacing between clip start times | |
| SAMPLE_RATE = _env_int("CLIPPER_SAMPLE_RATE", 4000) # Hz for audio energy (lower = faster decode) | |
| ENERGY_WINDOW = _env_int("CLIPPER_ENERGY_WINDOW", 4) # smoothing window in seconds | |
| ENERGY_STEP = _env_float("CLIPPER_ENERGY_STEP", 2.0) # seconds between RMS samples (higher = faster) | |
| GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip() | |
| GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-1.5-flash") | |
| GEMINI_TIMEOUT = _env_int("GEMINI_TIMEOUT", 18) | |
| USE_GEMINI = os.getenv("USE_GEMINI", "0").strip().lower() not in ("0", "false", "no") | |
| DOWNLOAD_MAX_HEIGHT = _env_int("CLIPPER_DOWNLOAD_MAX_HEIGHT", 1080) | |
| OUTPUT_HEIGHT = _env_int("CLIPPER_OUTPUT_HEIGHT", 1920) # 9:16 β 1080Γ1920 | |
| OUTPUT_WIDTH = int(OUTPUT_HEIGHT * 9 / 16) | |
| ENCODE_PRESET = os.getenv("CLIPPER_ENCODE_PRESET", "ultrafast") | |
| ENCODE_CRF = _env_int("CLIPPER_CRF", 32) | |
| X264_TUNE = os.getenv("CLIPPER_X264_TUNE", "zerolatency").strip() | |
| MAX_WORKERS = _env_int("CLIPPER_MAX_WORKERS", min(6, _CPU_COUNT)) | |
| BASE_DIR = Path(__file__).parent.resolve() | |
| CLIPS_DIR = BASE_DIR / "clips" | |
| DOWNLOADS_DIR = BASE_DIR / "downloads" | |
| CLIPS_DIR.mkdir(exist_ok=True) | |
| DOWNLOADS_DIR.mkdir(exist_ok=True) | |
| # Font path: use Linux path for Docker/HuggingFace, fallback to Windows | |
| import platform | |
| if platform.system() == "Windows": | |
| FONT_PATH = "C\\\\:/Windows/Fonts/arialbd.ttf" | |
| else: | |
| FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" | |
| app = Flask(__name__) | |
| app.config['MAX_CONTENT_LENGTH'] = MAX_UPLOAD_SIZE | |
| CORS(app) | |
| def allowed_file(filename): | |
| return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS | |
| # ββ Dependency check ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def check_deps(): | |
| missing = [] | |
| for tool, flag in [("ffmpeg", "-version"), ("ffprobe", "-version"), ("yt-dlp", "--version")]: | |
| r = subprocess.run([tool, flag], capture_output=True) | |
| if r.returncode not in (0, 1): | |
| missing.append(tool) | |
| return missing | |
| # ββ Cobalt API Download (fallback for restricted environments) ββββββββββββββββ | |
| COBALT_INSTANCES = [ | |
| {"url": "https://api.cobalt.tools", "version": 10}, | |
| {"url": "https://cobalt.canine.tools", "version": 10}, | |
| {"url": "https://co.eepy.today", "version": 10}, | |
| {"url": "https://cobalt.api.ayo.tf", "version": 10}, | |
| ] | |
| def download_via_cobalt(youtube_url: str) -> Path: | |
| """ | |
| Download video using Cobalt API as fallback when yt-dlp fails | |
| (e.g., on HuggingFace Spaces where YouTube is blocked). | |
| """ | |
| uid = uuid.uuid4().hex[:10] | |
| output_path = DOWNLOADS_DIR / f"{uid}.mp4" | |
| last_error = None | |
| for instance in COBALT_INSTANCES: | |
| api_url = instance["url"] | |
| try: | |
| # Cobalt API v10 format | |
| payload = json.dumps({ | |
| "url": youtube_url, | |
| "videoQuality": "720", | |
| "youtubeVideoCodec": "h264", | |
| "filenameStyle": "basic" | |
| }).encode("utf-8") | |
| headers = { | |
| "Content-Type": "application/json", | |
| "Accept": "application/json", | |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" | |
| } | |
| ctx = ssl.create_default_context() | |
| ctx.check_hostname = False | |
| ctx.verify_mode = ssl.CERT_NONE | |
| req = urllib.request.Request( | |
| api_url, | |
| data=payload, | |
| headers=headers, | |
| method="POST" | |
| ) | |
| with urllib.request.urlopen(req, timeout=30, context=ctx) as response: | |
| response_text = response.read().decode("utf-8") | |
| try: | |
| data = json.loads(response_text) | |
| except json.JSONDecodeError as je: | |
| last_error = f"{api_url}: JSON error - {response_text[:100]}" | |
| continue | |
| # Check status | |
| status = data.get("status") | |
| if status == "error": | |
| error_code = data.get("error", {}).get("code", "unknown") | |
| last_error = f"{api_url}: {error_code}" | |
| continue | |
| # Get download URL based on status | |
| download_url = None | |
| if status in ("redirect", "tunnel", "stream"): | |
| download_url = data.get("url") | |
| elif status == "picker": | |
| picker = data.get("picker", []) | |
| if picker: | |
| download_url = picker[0].get("url") | |
| elif data.get("url"): | |
| download_url = data["url"] | |
| if not download_url: | |
| last_error = f"{api_url}: No URL in response - status={status}" | |
| continue | |
| # Download the video file | |
| dl_headers = { | |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", | |
| "Accept": "*/*", | |
| "Accept-Encoding": "identity" | |
| } | |
| dl_req = urllib.request.Request(download_url, headers=dl_headers) | |
| with urllib.request.urlopen(dl_req, timeout=300, context=ctx) as resp: | |
| with open(output_path, "wb") as f: | |
| while True: | |
| chunk = resp.read(131072) # 128KB chunks | |
| if not chunk: | |
| break | |
| f.write(chunk) | |
| if output_path.exists() and output_path.stat().st_size > 50000: | |
| return output_path | |
| else: | |
| size = output_path.stat().st_size if output_path.exists() else 0 | |
| last_error = f"{api_url}: File too small ({size} bytes)" | |
| if output_path.exists(): | |
| output_path.unlink() | |
| continue | |
| except urllib.error.HTTPError as e: | |
| last_error = f"{api_url}: HTTP {e.code}" | |
| continue | |
| except urllib.error.URLError as e: | |
| last_error = f"{api_url}: URL Error - {e.reason}" | |
| continue | |
| except Exception as e: | |
| last_error = f"{api_url}: {type(e).__name__}: {str(e)[:100]}" | |
| continue | |
| raise RuntimeError(f"All Cobalt instances failed. Last error: {last_error}") | |
| # ββ Step 1: Download ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def download_video(youtube_url: str): | |
| uid = uuid.uuid4().hex[:10] | |
| template = str(DOWNLOADS_DIR / f"{uid}.%(ext)s") | |
| # Try yt-dlp first | |
| result = subprocess.run( | |
| [ | |
| "yt-dlp", | |
| "--force-ipv4", | |
| "--geo-bypass", | |
| "--no-check-certificates", | |
| "-f", f"bestvideo[ext=mp4][height<={DOWNLOAD_MAX_HEIGHT}]+bestaudio[ext=m4a]/best[ext=mp4][height<={DOWNLOAD_MAX_HEIGHT}]/best[height<={DOWNLOAD_MAX_HEIGHT}]/best", | |
| "--merge-output-format", "mp4", | |
| "--no-playlist", | |
| "--no-part", | |
| "--concurrent-fragments", "8", | |
| "--write-auto-subs", | |
| "--write-subs", | |
| "--sub-langs", "en.*,en", | |
| "--sub-format", "vtt/srv3/json3/best", | |
| "-o", template, | |
| youtube_url, | |
| ], | |
| capture_output=True, | |
| ) | |
| if result.returncode != 0: | |
| stderr = result.stderr.decode(errors="replace") | |
| # Check if it's a network error (common on HuggingFace) | |
| if "No address associated with hostname" in stderr or "Unable to download" in stderr: | |
| # Try Cobalt API as fallback | |
| try: | |
| video_path = download_via_cobalt(youtube_url) | |
| return video_path, [], [video_path] | |
| except Exception as cobalt_err: | |
| raise RuntimeError(f"yt-dlp failed (network blocked), Cobalt fallback also failed: {cobalt_err}") | |
| raise RuntimeError(f"Download failed:\n{stderr[-800:]}") | |
| matches = list(DOWNLOADS_DIR.glob(f"{uid}.*")) | |
| if not matches: | |
| raise RuntimeError("yt-dlp finished but produced no output file.") | |
| video_path = next((m for m in matches if m.suffix.lower() in (".mp4", ".webm", ".mkv")), None) | |
| if not video_path: | |
| video_path = matches[0] | |
| subtitle_paths = [m for m in matches if m.suffix.lower() in (".vtt", ".json3", ".srv3")] | |
| return video_path, subtitle_paths, matches | |
| def _ts_to_seconds(ts: str) -> float: | |
| parts = ts.split(":") | |
| if len(parts) == 3: | |
| h, m, s = parts | |
| else: | |
| h, m, s = "0", parts[0], parts[1] | |
| return int(h) * 3600 + int(m) * 60 + float(s.replace(",", ".")) | |
| def parse_vtt(vtt_path: Path): | |
| cues = [] | |
| lines = vtt_path.read_text(encoding="utf-8", errors="replace").splitlines() | |
| i = 0 | |
| while i < len(lines): | |
| line = lines[i].strip() | |
| if "-->" not in line: | |
| i += 1 | |
| continue | |
| parts = [p.strip() for p in line.split("-->")] | |
| if len(parts) != 2: | |
| i += 1 | |
| continue | |
| try: | |
| start = _ts_to_seconds(parts[0].split(" ")[0]) | |
| end = _ts_to_seconds(parts[1].split(" ")[0]) | |
| except ValueError: | |
| i += 1 | |
| continue | |
| i += 1 | |
| text_lines = [] | |
| while i < len(lines) and lines[i].strip(): | |
| cleaned = re.sub(r"<[^>]+>", "", lines[i]).strip() | |
| if cleaned and cleaned not in ("WEBVTT",): | |
| text_lines.append(cleaned) | |
| i += 1 | |
| text = re.sub(r"\s+", " ", " ".join(text_lines)).strip() | |
| if text: | |
| cues.append({"start": start, "end": end, "text": text}) | |
| i += 1 | |
| return cues | |
| def parse_json3(json3_path: Path): | |
| cues = [] | |
| try: | |
| data = json.loads(json3_path.read_text(encoding="utf-8", errors="replace")) | |
| except (OSError, json.JSONDecodeError): | |
| return cues | |
| for ev in data.get("events", []): | |
| segs = ev.get("segs") or [] | |
| text = "".join(seg.get("utf8", "") for seg in segs).strip() | |
| text = re.sub(r"\s+", " ", text) | |
| if not text: | |
| continue | |
| start_ms = ev.get("tStartMs") | |
| if start_ms is None: | |
| continue | |
| dur_ms = ev.get("dDurationMs", 1200) | |
| try: | |
| start = float(start_ms) / 1000.0 | |
| end = start + max(0.2, float(dur_ms) / 1000.0) | |
| except (TypeError, ValueError): | |
| continue | |
| cues.append({"start": start, "end": end, "text": text}) | |
| return cues | |
| # ββ Step 2: Video info ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_video_info(video_path: Path): | |
| """Return (duration, width, height) via ffprobe JSON.""" | |
| r = subprocess.run( | |
| ["ffprobe", "-v", "quiet", "-print_format", "json", | |
| "-show_format", "-show_streams", str(video_path)], | |
| capture_output=True, | |
| ) | |
| data = json.loads(r.stdout) | |
| duration = float(data["format"]["duration"]) | |
| width = height = 0 | |
| for s in data.get("streams", []): | |
| if s.get("codec_type") == "video": | |
| width = int(s["width"]) | |
| height = int(s["height"]) | |
| break | |
| return duration, width, height | |
| # ββ Step 3: Audio energy analysis ββββββββββββββββββββββββββββββββββββββββββββ | |
| def extract_audio_energy(video_path: Path, duration: float): | |
| """ | |
| Pipe raw mono 8kHz PCM from FFmpeg and compute per-second RMS energy. | |
| Returns list of (time_sec, rms) tuples. | |
| """ | |
| proc = subprocess.Popen( | |
| [ | |
| "ffmpeg", "-i", str(video_path), | |
| "-vn", "-ar", str(SAMPLE_RATE), "-ac", "1", | |
| "-f", "s16le", "pipe:1", | |
| "-loglevel", "quiet", | |
| ], | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.DEVNULL, | |
| ) | |
| raw, _ = proc.communicate() | |
| if not raw: | |
| # Fallback: uniform energy (clips will be evenly spaced) | |
| return [(t, 1.0) for t in range(int(duration))] | |
| n = len(raw) // 2 | |
| samples = struct.unpack(f"<{n}h", raw) | |
| win = SAMPLE_RATE * ENERGY_WINDOW # samples per window | |
| step = max(SAMPLE_RATE // 2, int(SAMPLE_RATE * max(0.5, ENERGY_STEP))) | |
| result = [] | |
| for i in range(0, n - win, step): | |
| chunk = samples[i : i + win : 16] # subsample β faster RMS | |
| if not chunk: | |
| continue | |
| rms = math.sqrt(sum(int(s) * int(s) for s in chunk) / len(chunk)) | |
| result.append((i / SAMPLE_RATE, rms)) | |
| return result | |
| # ββ Step 4: Find hook segments ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def find_segments(energies, duration: float, n_clips: int = MAX_CLIPS): | |
| """ | |
| Greedy peak selection: | |
| 1. Smooth the RMS curve. | |
| 2. Repeatedly pick the highest-energy moment, then | |
| black out a MIN_GAP_SECONDS radius around it. | |
| Returns list of (start, end) in seconds. | |
| """ | |
| if not energies: | |
| step = max(60.0, (duration - CLIP_DURATION) / max(n_clips, 1)) | |
| return [(round(i * step + 10, 2), round(i * step + 10 + CLIP_DURATION, 2)) | |
| for i in range(n_clips) if i * step + 10 + CLIP_DURATION <= duration] | |
| times = [e[0] for e in energies] | |
| vals = [e[1] for e in energies] | |
| # Smooth | |
| w = min(10, max(3, len(vals) // 20)) | |
| smoothed = [] | |
| for i in range(len(vals)): | |
| lo, hi = max(0, i - w), min(len(vals), i + w + 1) | |
| smoothed.append(sum(vals[lo:hi]) / (hi - lo)) | |
| # Greedy selection | |
| used = [False] * len(smoothed) | |
| peaks = [] | |
| # Index spacing follows CLIPPER_ENERGY_STEP (seconds between RMS samples). | |
| gap_idx = max(1, int(MIN_GAP_SECONDS / max(0.25, ENERGY_STEP))) | |
| while len(peaks) < n_clips * 2: | |
| best_i = max( | |
| (i for i in range(len(smoothed)) if not used[i]), | |
| key=lambda i: smoothed[i], | |
| default=-1, | |
| ) | |
| if best_i < 0: | |
| break | |
| peaks.append(times[best_i]) | |
| lo = max(0, best_i - gap_idx) | |
| hi = min(len(used), best_i + gap_idx + 1) | |
| for j in range(lo, hi): | |
| used[j] = True | |
| peaks.sort() | |
| peaks = peaks[:n_clips] | |
| # If we have fewer peaks than requested, fill with evenly spaced ones | |
| if len(peaks) < n_clips: | |
| step = max(60.0, (duration - CLIP_DURATION) / max(n_clips, 1)) | |
| t = 10.0 | |
| while len(peaks) < n_clips and t + CLIP_DURATION <= duration: | |
| if all(abs(t - p) >= MIN_GAP_SECONDS for p in peaks): | |
| peaks.append(t) | |
| t += step | |
| peaks.sort() | |
| # Convert to (start, end) | |
| segments = [] | |
| for pt in peaks: | |
| start = max(0.0, pt - CLIP_DURATION * 0.25) | |
| end = start + CLIP_DURATION | |
| if end > duration: | |
| end = duration | |
| start = max(0.0, end - CLIP_DURATION) | |
| segments.append((round(start, 2), round(end, 2))) | |
| return segments | |
| def build_transcript_chunks(cues, duration: float): | |
| if not cues: | |
| return [] | |
| chunks = [] | |
| for cue in cues[:220]: | |
| chunks.append({ | |
| "start": round(float(cue["start"]), 2), | |
| "end": round(float(cue["end"]), 2), | |
| "text": cue["text"][:160], | |
| }) | |
| # Keep payload bounded for Gemini | |
| return chunks[:180] | |
| def _extract_json_array(raw_text: str): | |
| match = re.search(r"\[[\s\S]*\]", raw_text) | |
| if not match: | |
| return None | |
| try: | |
| return json.loads(match.group(0)) | |
| except json.JSONDecodeError: | |
| return None | |
| def rank_segments_with_gemini(chunks, duration: float, n_clips: int): | |
| if not USE_GEMINI or not GEMINI_API_KEY or not chunks: | |
| return None | |
| prompt = ( | |
| "You are a short-form growth editor. " | |
| "Given transcript chunks from a long video, return ONLY a JSON array of best moments.\n" | |
| "Each item must have: start, end, score, reason.\n" | |
| f"Rules: output up to {n_clips} moments, each about {int(CLIP_DURATION)} seconds, " | |
| f"duration range 25-60s, no overlap over {int(MIN_GAP_SECONDS/2)}s. " | |
| "Prioritize hook strength, retention potential, curiosity, emotional payoff, and shareability.\n" | |
| f"Video duration: {round(duration,2)} seconds.\n" | |
| f"Transcript chunks JSON:\n{json.dumps(chunks, ensure_ascii=True)}" | |
| ) | |
| payload = { | |
| "contents": [{"role": "user", "parts": [{"text": prompt}]}], | |
| "generationConfig": {"temperature": 0.2, "responseMimeType": "application/json"}, | |
| } | |
| url = ( | |
| f"https://generativelanguage.googleapis.com/v1beta/models/" | |
| f"{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}" | |
| ) | |
| req = urllib.request.Request( | |
| url, | |
| data=json.dumps(payload).encode("utf-8"), | |
| headers={"Content-Type": "application/json"}, | |
| method="POST", | |
| ) | |
| try: | |
| with urllib.request.urlopen(req, timeout=GEMINI_TIMEOUT) as res: | |
| data = json.loads(res.read().decode("utf-8", "replace")) | |
| except (urllib.error.URLError, TimeoutError, json.JSONDecodeError): | |
| return None | |
| text_parts = [] | |
| for cand in data.get("candidates", []): | |
| content = cand.get("content", {}) | |
| for part in content.get("parts", []): | |
| if isinstance(part, dict) and "text" in part: | |
| text_parts.append(part["text"]) | |
| parsed = _extract_json_array("\n".join(text_parts)) | |
| if not isinstance(parsed, list): | |
| return None | |
| segments = [] | |
| for item in parsed: | |
| if not isinstance(item, dict): | |
| continue | |
| try: | |
| s = float(item.get("start")) | |
| e = float(item.get("end")) | |
| except (TypeError, ValueError): | |
| continue | |
| if e <= s: | |
| continue | |
| # Normalize to target clip duration | |
| if (e - s) < 20: | |
| e = s + CLIP_DURATION | |
| if (e - s) > 70: | |
| e = s + CLIP_DURATION | |
| if e > duration: | |
| e = duration | |
| s = max(0.0, e - CLIP_DURATION) | |
| segments.append((round(s, 2), round(e, 2))) | |
| # De-overlap and trim | |
| out = [] | |
| for s, e in sorted(segments, key=lambda x: x[0]): | |
| if not out or abs(s - out[-1][0]) >= (MIN_GAP_SECONDS * 0.6): | |
| out.append((s, e)) | |
| if len(out) >= n_clips: | |
| break | |
| return out[:n_clips] if out else None | |
| # ββ Step 5: Build 9:16 crop filter βββββββββββββββββββββββββββββββββββββββββββ | |
| def build_vf(width: int, height: int) -> str: | |
| """Return an FFmpeg -vf string that center-crops to 9:16 output size.""" | |
| ratio = 9 / 16 | |
| if width / height > ratio: | |
| # Landscape β crop left/right | |
| cw = int(height * ratio) | |
| ch = height | |
| cx = (width - cw) // 2 | |
| cy = 0 | |
| else: | |
| # Portrait / square β crop top/bottom | |
| cw = width | |
| ch = int(width / ratio) | |
| cx = 0 | |
| cy = (height - ch) // 2 | |
| # Force even numbers | |
| cw -= cw % 2 | |
| ch -= ch % 2 | |
| return f"crop={cw}:{ch}:{cx}:{cy},scale={OUTPUT_WIDTH}:{OUTPUT_HEIGHT}:flags=fast_bilinear" | |
| def build_vf_pad(width: int, height: int) -> str: | |
| """Scale to fit inside 9:16 output preserving aspect ratio, pad remainder black.""" | |
| # scale down to fit, keeping aspect ratio | |
| # then pad symmetrically to exactly 1080x1920 | |
| return ( | |
| f"scale={OUTPUT_WIDTH}:{OUTPUT_HEIGHT}:force_original_aspect_ratio=decrease:flags=fast_bilinear," | |
| f"pad={OUTPUT_WIDTH}:{OUTPUT_HEIGHT}:(ow-iw)/2:(oh-ih)/2:black," | |
| "setsar=1" | |
| ) | |
| def _escape_drawtext_text(text: str) -> str: | |
| return ( | |
| text.replace("\\", "\\\\") | |
| .replace(":", "\\:") | |
| .replace("'", "\\'") | |
| .replace(",", "\\,") | |
| .replace("%", "\\%") | |
| .replace("[", "\\[") | |
| .replace("]", "\\]") | |
| ) | |
| def _hormozi_style_text(text: str) -> str: | |
| tokens = re.findall(r"[A-Za-z0-9']+|[^A-Za-z0-9'\s]+", text) | |
| emphasis = {"you", "free", "secret", "mistake", "money", "viral", "growth", "now", "never", "always"} | |
| out = [] | |
| for t in tokens: | |
| low = t.lower() | |
| if low in emphasis or len(t) >= 8: | |
| out.append(t.upper()) | |
| else: | |
| out.append(t) | |
| return " ".join(out).strip() | |
| def build_caption_events(cues, clip_start: float, clip_end: float): | |
| if not cues: | |
| return [] | |
| events = [] | |
| overlapping = [c for c in cues if c["end"] >= clip_start and c["start"] <= clip_end] | |
| for cue in overlapping: | |
| cue_s = max(clip_start, float(cue["start"])) | |
| cue_e = min(clip_end, float(cue["end"])) | |
| text = re.sub(r"\s+", " ", cue["text"]).strip() | |
| if not text or cue_e <= cue_s: | |
| continue | |
| words = text.split() | |
| if not words: | |
| continue | |
| group_size = 4 | |
| groups = [" ".join(words[i:i + group_size]) for i in range(0, len(words), group_size)] | |
| span = max(0.35, (cue_e - cue_s) / max(len(groups), 1)) | |
| for idx, g in enumerate(groups): | |
| s = cue_s + idx * span - clip_start | |
| e = min(cue_e - clip_start, s + max(0.35, span * 0.95)) | |
| if e - s < 0.2: | |
| continue | |
| events.append({"start": round(max(0.0, s), 2), "end": round(max(0.0, e), 2), "text": _hormozi_style_text(g)}) | |
| return events[:50] | |
| def build_caption_filter(events): | |
| if not events: | |
| return "" | |
| fs = max(38, min(72, int(OUTPUT_HEIGHT * 0.065))) | |
| parts = [] | |
| for ev in events: | |
| txt = _escape_drawtext_text(ev["text"]) | |
| parts.append( | |
| "drawtext=" | |
| f"fontfile={FONT_PATH}:" | |
| f"text='{txt}':" | |
| "x=(w-text_w)/2:" | |
| "y=h*0.78:" | |
| f"fontsize={fs}:" | |
| "fontcolor=white:" | |
| "borderw=6:bordercolor=black@0.95:" | |
| "shadowx=0:shadowy=3:shadowcolor=black@0.9:" | |
| f"enable='between(t\\,{ev['start']}\\,{ev['end']})'" | |
| ) | |
| return ",".join(parts) | |
| # ββ Step 6: Cut clip ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def cut_clip(video_path: Path, start: float, end: float, | |
| idx: int, width: int, height: int, mode: str = "fill", cues=None) -> Path: | |
| name = f"short_{idx + 1}_{uuid.uuid4().hex[:6]}.mp4" | |
| out = CLIPS_DIR / name | |
| base_vf = build_vf_pad(width, height) if mode == "pad" else build_vf(width, height) | |
| caption_events = build_caption_events(cues or [], start, end) | |
| caption_vf = build_caption_filter(caption_events) | |
| vf = f"{base_vf},{caption_vf}" if caption_vf else base_vf | |
| dur = round(end - start, 2) | |
| ff = [ | |
| "ffmpeg", | |
| "-ss", str(start), | |
| "-i", str(video_path), | |
| "-t", str(dur), | |
| "-vf", vf, | |
| "-c:v", "libx264", | |
| "-preset", ENCODE_PRESET, | |
| ] | |
| if X264_TUNE: | |
| ff += ["-tune", X264_TUNE] | |
| ff += [ | |
| "-crf", str(ENCODE_CRF), | |
| "-c:a", "aac", | |
| "-b:a", "96k", | |
| "-threads", "0", | |
| "-movflags", "+faststart", | |
| "-y", | |
| str(out), | |
| ] | |
| r = subprocess.run(ff, capture_output=True) | |
| if r.returncode != 0: | |
| raise RuntimeError( | |
| f"FFmpeg failed for clip {idx + 1}: " | |
| f"{r.stderr.decode(errors='replace')[-600:]}" | |
| ) | |
| return out | |
| # ββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def process(): | |
| data = request.get_json(force=True, silent=True) or {} | |
| youtube_url = (data.get("youtubeUrl") or "").strip() | |
| if not youtube_url: | |
| return jsonify({"error": "youtubeUrl is required"}), 400 | |
| n_clips = max(1, min(int(data.get("clips", MAX_CLIPS)), MAX_CLIPS)) | |
| mode = data.get("mode", "fill").strip().lower() | |
| analysis_mode = (data.get("analysisMode") or "local").strip().lower() | |
| if mode not in ("fill", "pad"): | |
| mode = "fill" | |
| use_gemini = analysis_mode == "gemini" and USE_GEMINI | |
| def generate(): | |
| video_path = None | |
| subtitle_paths = [] | |
| extra_download_files = [] | |
| cues = [] | |
| try: | |
| # 1. Download | |
| yield json.dumps({"phase": "download", "message": "Downloading source video..."}) + "\n" | |
| video_path, subtitle_paths, extra_download_files = download_video(youtube_url) | |
| for sub in subtitle_paths: | |
| suffix = sub.suffix.lower() | |
| if suffix == ".vtt": | |
| cues.extend(parse_vtt(sub)) | |
| elif suffix == ".json3": | |
| cues.extend(parse_json3(sub)) | |
| if not cues: | |
| yield json.dumps({ | |
| "warning": "No subtitle transcript found; captions may be limited on this video." | |
| }) + "\n" | |
| # 2. Info | |
| yield json.dumps({"phase": "metadata", "message": "Reading video metadata..."}) + "\n" | |
| duration, width, height = get_video_info(video_path) | |
| if duration < 20: | |
| yield json.dumps({"error": "Video too short (minimum 20 s)."}) + "\n" | |
| return | |
| if width == 0 or height == 0: | |
| yield json.dumps({"error": "Could not read video dimensions."}) + "\n" | |
| return | |
| # 3. Audio energy + viral ranking | |
| yield json.dumps({"phase": "analysis", "message": "Analyzing viral moments..."}) + "\n" | |
| segments = None | |
| if use_gemini: | |
| chunks = build_transcript_chunks(cues, duration) | |
| segments = rank_segments_with_gemini(chunks, duration, n_clips=n_clips) | |
| if not segments: | |
| yield json.dumps({ | |
| "warning": "Gemini analysis unavailable. Falling back to local heuristic ranking." | |
| }) + "\n" | |
| # 4. Hook segments fallback | |
| if not segments: | |
| energies = extract_audio_energy(video_path, duration) | |
| segments = find_segments(energies, duration, n_clips=n_clips) | |
| if not segments: | |
| yield json.dumps({"error": "No viable segments found."}) + "\n" | |
| return | |
| # Tell the frontend how many clips to expect | |
| yield json.dumps({"total": len(segments)}) + "\n" | |
| yield json.dumps({"phase": "captioning", "message": "Generating Hormozi-style captions..."}) + "\n" | |
| # 5. Encode ALL clips in parallel, stream each one as it finishes | |
| def _cut(args): | |
| i, s, e = args | |
| return i, cut_clip(video_path, s, e, i, width, height, mode, cues=cues) | |
| yield json.dumps({"phase": "rendering", "message": "Rendering captioned shorts..."}) + "\n" | |
| with ThreadPoolExecutor(max_workers=min(len(segments), max(1, MAX_WORKERS))) as pool: | |
| futures = {pool.submit(_cut, (i, s, e)): i | |
| for i, (s, e) in enumerate(segments)} | |
| for future in as_completed(futures): | |
| try: | |
| i, clip_path = future.result() | |
| yield json.dumps({ | |
| "clip": f"/clips/{clip_path.name}", | |
| "index": i, | |
| }) + "\n" | |
| except Exception as clip_err: | |
| i = futures[future] | |
| yield json.dumps({"warning": f"Clip {i + 1} failed: {clip_err}"}) + "\n" | |
| except Exception as exc: | |
| yield json.dumps({"error": str(exc)}) + "\n" | |
| finally: | |
| for f in extra_download_files: | |
| if isinstance(f, Path) and f.exists(): | |
| try: | |
| f.unlink() | |
| except OSError: | |
| pass | |
| if video_path and isinstance(video_path, Path) and video_path.exists(): | |
| try: | |
| video_path.unlink() | |
| except OSError: | |
| pass | |
| return Response( | |
| stream_with_context(generate()), | |
| mimetype="application/x-ndjson", | |
| headers={"X-Accel-Buffering": "no"}, # prevent proxy buffering | |
| ) | |
| def upload_process(): | |
| """Process an uploaded video file instead of a YouTube URL.""" | |
| if 'video' not in request.files: | |
| return jsonify({"error": "No video file provided"}), 400 | |
| file = request.files['video'] | |
| if file.filename == '': | |
| return jsonify({"error": "No file selected"}), 400 | |
| if not allowed_file(file.filename): | |
| return jsonify({"error": f"Invalid file type. Allowed: {', '.join(ALLOWED_EXTENSIONS)}"}), 400 | |
| # Get form parameters | |
| n_clips = max(1, min(int(request.form.get("clips", MAX_CLIPS)), 10)) | |
| mode = request.form.get("mode", "fill").strip().lower() | |
| if mode not in ("fill", "pad"): | |
| mode = "fill" | |
| # Save uploaded file | |
| uid = uuid.uuid4().hex[:10] | |
| filename = secure_filename(file.filename) | |
| ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else 'mp4' | |
| video_path = DOWNLOADS_DIR / f"{uid}.{ext}" | |
| file.save(str(video_path)) | |
| def generate(): | |
| cues = [] | |
| try: | |
| # 1. Read metadata | |
| yield json.dumps({"phase": "metadata", "message": "Reading video metadata..."}) + "\n" | |
| duration, width, height = get_video_info(video_path) | |
| if duration < 20: | |
| yield json.dumps({"error": "Video too short (minimum 20 seconds)."}) + "\n" | |
| return | |
| if width == 0 or height == 0: | |
| yield json.dumps({"error": "Could not read video dimensions."}) + "\n" | |
| return | |
| # 2. Audio energy analysis | |
| yield json.dumps({"phase": "analysis", "message": "Analyzing viral moments..."}) + "\n" | |
| energies = extract_audio_energy(video_path, duration) | |
| segments = find_segments(energies, duration, n_clips=n_clips) | |
| if not segments: | |
| yield json.dumps({"error": "No viable segments found."}) + "\n" | |
| return | |
| # 3. Tell frontend how many clips to expect | |
| yield json.dumps({"total": len(segments)}) + "\n" | |
| yield json.dumps({"phase": "captioning", "message": "Generating captions..."}) + "\n" | |
| # 4. Encode clips in parallel | |
| def _cut(args): | |
| i, s, e = args | |
| return i, cut_clip(video_path, s, e, i, width, height, mode, cues=cues) | |
| yield json.dumps({"phase": "rendering", "message": "Rendering shorts..."}) + "\n" | |
| with ThreadPoolExecutor(max_workers=min(len(segments), max(1, MAX_WORKERS))) as pool: | |
| futures = {pool.submit(_cut, (i, s, e)): i for i, (s, e) in enumerate(segments)} | |
| for future in as_completed(futures): | |
| try: | |
| i, clip_path = future.result() | |
| yield json.dumps({ | |
| "clip": f"/clips/{clip_path.name}", | |
| "index": i, | |
| }) + "\n" | |
| except Exception as clip_err: | |
| i = futures[future] | |
| yield json.dumps({"warning": f"Clip {i + 1} failed: {clip_err}"}) + "\n" | |
| except Exception as exc: | |
| yield json.dumps({"error": str(exc)}) + "\n" | |
| finally: | |
| # Clean up uploaded file | |
| if video_path.exists(): | |
| try: | |
| video_path.unlink() | |
| except OSError: | |
| pass | |
| return Response( | |
| stream_with_context(generate()), | |
| mimetype="application/x-ndjson", | |
| headers={"X-Accel-Buffering": "no"}, | |
| ) | |
| def serve_clip(filename): | |
| return send_from_directory(str(CLIPS_DIR), filename) | |
| def serve_frontend_assets(filename): | |
| return send_from_directory(str(BASE_DIR), filename) | |
| def health(): | |
| missing = check_deps() | |
| return jsonify({ | |
| "status": "ok" if not missing else "degraded", | |
| "missing_tools": missing, | |
| "gemini_ready": bool(GEMINI_API_KEY and USE_GEMINI), | |
| "gemini_model": GEMINI_MODEL, | |
| }) | |
| def root(): | |
| return send_from_directory(str(BASE_DIR), "index.html") | |
| # ββ Entry βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| print("\n[>>] NebulaCut - Clipper Backend") | |
| print(f" http://localhost:{PORT}\n") | |
| missing = check_deps() | |
| if missing: | |
| print(f"[!] Missing: {', '.join(missing)}") | |
| print(" Install them or clips won't generate.\n") | |
| else: | |
| print("[OK] ffmpeg, ffprobe, yt-dlp found\n") | |
| app.run(host=HOST, port=PORT, debug=False, threaded=True) | |