#!/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 ──────────────────────────────────────────────────────────────────── @app.route("/api/process", methods=["POST"]) 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 ) @app.route("/api/upload", methods=["POST"]) 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"}, ) @app.route("/clips/") def serve_clip(filename): return send_from_directory(str(CLIPS_DIR), filename) @app.route("/") def serve_frontend_assets(filename): return send_from_directory(str(BASE_DIR), filename) @app.route("/health") 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, }) @app.route("/") 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)