nebulacut / server.py
areebsatin's picture
Update Cobalt API with working instances and v10 format
9ce2113
Raw
History Blame Contribute Delete
35.7 kB
#!/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/<path:filename>")
def serve_clip(filename):
return send_from_directory(str(CLIPS_DIR), filename)
@app.route("/<path:filename>")
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)