feat: implement vertical crop, local LLM deepseek-v4-flash, multi-threading, word-level pink box highlight subtitles, and Nextcloud scan sync
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .pipeline import generate_shorts
|
||||
|
||||
__all__ = ["generate_shorts"]
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Per-clip cropping via MuAPI /autocrop.
|
||||
|
||||
Given the source video URL plus a highlight's start/end and a target aspect
|
||||
ratio, MuAPI returns a vertically-cropped short ready for posting.
|
||||
"""
|
||||
from typing import Dict
|
||||
|
||||
from . import muapi
|
||||
from .downloader import _extract_video_url
|
||||
|
||||
|
||||
def crop_clip(source_video_url: str, start_time: float, end_time: float, aspect_ratio: str = "9:16") -> str:
|
||||
"""Submit one autocrop job and return the URL of the rendered short."""
|
||||
payload = {
|
||||
"video_url": source_video_url,
|
||||
"start_time": float(start_time),
|
||||
"end_time": float(end_time),
|
||||
"aspect_ratio": aspect_ratio,
|
||||
}
|
||||
print(f"[clip] {start_time:.1f}s → {end_time:.1f}s @ {aspect_ratio}", flush=True)
|
||||
result = muapi.run("autocrop", payload, label=f"autocrop({start_time:.0f}-{end_time:.0f})")
|
||||
return _extract_video_url(result)
|
||||
|
||||
|
||||
def crop_highlights(source_video_url: str, highlights: list, aspect_ratio: str = "9:16") -> list:
|
||||
"""Crop every highlight, attaching the resulting URL back onto the dict."""
|
||||
out = []
|
||||
for i, h in enumerate(highlights, 1):
|
||||
print(f"[clip] {i}/{len(highlights)}: {h.get('title', '(untitled)')}", flush=True)
|
||||
try:
|
||||
url = crop_clip(
|
||||
source_video_url,
|
||||
h["start_time"],
|
||||
h["end_time"],
|
||||
aspect_ratio=aspect_ratio,
|
||||
)
|
||||
out.append({**h, "clip_url": url})
|
||||
except Exception as e:
|
||||
print(f"[clip] {i} failed: {e}", flush=True)
|
||||
out.append({**h, "clip_url": None, "error": str(e)})
|
||||
return out
|
||||
@@ -0,0 +1,67 @@
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
MUAPI_API_KEY = os.getenv("MUAPI_API_KEY", "").strip()
|
||||
MUAPI_BASE_URL = os.getenv("MUAPI_BASE_URL", "https://api.muapi.ai/api/v1").rstrip("/")
|
||||
|
||||
POLL_INTERVAL_SECONDS = float(os.getenv("MUAPI_POLL_INTERVAL", "5"))
|
||||
POLL_TIMEOUT_SECONDS = float(os.getenv("MUAPI_POLL_TIMEOUT", "600"))
|
||||
|
||||
# Local-mode (--mode local) settings — only consulted when running offline.
|
||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "").strip()
|
||||
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
|
||||
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
|
||||
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "openai").strip().lower()
|
||||
LOCAL_WHISPER_MODEL = os.getenv("LOCAL_WHISPER_MODEL", "base")
|
||||
LOCAL_WHISPER_DEVICE = os.getenv("LOCAL_WHISPER_DEVICE", "auto") # auto / cpu / cuda
|
||||
LOCAL_OUTPUT_DIR = os.getenv("LOCAL_OUTPUT_DIR", "output")
|
||||
|
||||
# VAD (Voice Activity Detection) settings for faster-whisper
|
||||
# Default threshold is 0.5; lower = more sensitive, higher = less sensitive
|
||||
# Default min_speech_duration_ms is 250ms; increase to avoid tiny false positives
|
||||
# Default min_silence_duration_ms is 2000ms; increase to avoid splitting mid-sentence
|
||||
# DISABLED by default because VAD is too aggressive on mixed speech/music content
|
||||
LOCAL_WHISPER_VAD_FILTER = os.getenv("LOCAL_WHISPER_VAD_FILTER", "false").strip().lower() == "true"
|
||||
_vad_params_env = os.getenv("LOCAL_WHISPER_VAD_PARAMETERS", "")
|
||||
if _vad_params_env:
|
||||
import json
|
||||
LOCAL_WHISPER_VAD_PARAMETERS = json.loads(_vad_params_env)
|
||||
else:
|
||||
# Match faster-whisper defaults when VAD is enabled
|
||||
LOCAL_WHISPER_VAD_PARAMETERS = {
|
||||
"threshold": 0.5,
|
||||
"min_speech_duration_ms": 250,
|
||||
"max_speech_duration_s": float("inf"),
|
||||
"min_silence_duration_ms": 2000,
|
||||
"speech_pad_ms": 400,
|
||||
}
|
||||
|
||||
|
||||
def require_api_key() -> str:
|
||||
if not MUAPI_API_KEY:
|
||||
raise RuntimeError(
|
||||
"MUAPI_API_KEY is not set. Add it to your .env file or export it as an env var."
|
||||
)
|
||||
return MUAPI_API_KEY
|
||||
|
||||
|
||||
def require_openai_key() -> str:
|
||||
if not OPENAI_API_KEY:
|
||||
raise RuntimeError(
|
||||
"OPENAI_API_KEY is not set. Local mode needs an OpenAI key for highlight ranking. "
|
||||
"Add it to your .env or export it, or switch back to --mode api."
|
||||
)
|
||||
return OPENAI_API_KEY
|
||||
|
||||
|
||||
def require_gemini_key() -> str:
|
||||
if not GEMINI_API_KEY:
|
||||
raise RuntimeError(
|
||||
"GEMINI_API_KEY is not set. Local mode needs a Gemini key when LLM_PROVIDER=gemini. "
|
||||
"Add it to your .env or export it, or switch LLM_PROVIDER back to openai."
|
||||
)
|
||||
return GEMINI_API_KEY
|
||||
@@ -0,0 +1,36 @@
|
||||
"""YouTube source video download via MuAPI /youtube-download."""
|
||||
from typing import Dict
|
||||
|
||||
from . import muapi
|
||||
|
||||
|
||||
def _extract_video_url(result: Dict) -> str:
|
||||
"""MuAPI result shapes vary by endpoint — try common keys."""
|
||||
for key in ("video_url", "url", "output_url", "result_url"):
|
||||
v = result.get(key)
|
||||
if isinstance(v, str) and v.startswith("http"):
|
||||
return v
|
||||
|
||||
output = result.get("outputs") or result.get("output") or result.get("result") or {}
|
||||
if isinstance(output, dict):
|
||||
for key in ("video_url", "url", "output_url"):
|
||||
v = output.get(key)
|
||||
if isinstance(v, str) and v.startswith("http"):
|
||||
return v
|
||||
if isinstance(output, list) and output and isinstance(output[0], str) and output[0].startswith("http"):
|
||||
return output[0]
|
||||
|
||||
raise RuntimeError(f"Could not find downloaded video URL in MuAPI response: {result}")
|
||||
|
||||
|
||||
def download_youtube(video_url: str, fmt: str = "720") -> str:
|
||||
"""Hand a YouTube URL to MuAPI; return a hosted mp4 URL we can read from."""
|
||||
print(f"[download] requesting {video_url} @ {fmt}p", flush=True)
|
||||
result = muapi.run(
|
||||
"youtube-download",
|
||||
{"video_url": video_url, "format": fmt},
|
||||
label="youtube-download",
|
||||
)
|
||||
out = _extract_video_url(result)
|
||||
print(f"[download] ready: {out}", flush=True)
|
||||
return out
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Find the most viral-worthy highlights in a transcript.
|
||||
|
||||
Logic ported from ViralVadoo's transcript_analysis/highlight_generator.py:
|
||||
- content-type / density detection
|
||||
- chunking for long videos with overlap
|
||||
- virality-criteria prompt
|
||||
- score-based dedupe with overlap suppression
|
||||
|
||||
The LLM call is pluggable via the `llm_fn` argument so the same prompts can
|
||||
drive either MuAPI (default, --mode api) or a direct local LLM client
|
||||
(--mode local).
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from . import muapi
|
||||
|
||||
|
||||
LLMFn = Callable[[str], str]
|
||||
|
||||
|
||||
CONTENT_TYPE_PROMPT = """Analyze this video transcript sample and classify the content type.
|
||||
Choose one: podcast, interview, tutorial, lecture, commentary, debate, vlog, other.
|
||||
Also estimate content density: low (mostly filler/chit-chat), medium, or high (dense info/stories).
|
||||
Respond with JSON only: {"content_type": "...", "density": "..."}"""
|
||||
|
||||
|
||||
VIRALITY_CRITERIA = """
|
||||
Virality signals to prioritize (ranked by impact):
|
||||
1. HOOK MOMENTS — statements that create immediate curiosity ("The secret is...", "Nobody talks about...", "I was completely wrong about...")
|
||||
2. EMOTIONAL PEAKS — genuine surprise, laughter, anger, vulnerability, excitement; raw unscripted reactions
|
||||
3. OPINION BOMBS — strong, polarizing or counter-intuitive statements that trigger agree/disagree
|
||||
4. REVELATION MOMENTS — surprising facts, stats, or confessions that reframe how the viewer thinks
|
||||
5. CONFLICT/TENSION — disagreement, pushback, or a problem being confronted head-on
|
||||
6. QUOTABLE ONE-LINERS — a sentence that works as a standalone quote card
|
||||
7. STORY PEAKS — the climax or twist of an anecdote; the payoff moment
|
||||
8. PRACTICAL VALUE — a concrete tip, hack, or insight the viewer can immediately apply
|
||||
"""
|
||||
|
||||
|
||||
HIGHLIGHT_SYSTEM_PROMPT = """You are an elite short-form video editor who has studied thousands of viral clips on TikTok, Instagram Reels, and YouTube Shorts. You know exactly what makes viewers stop scrolling, watch to the end, and share.
|
||||
|
||||
{virality_criteria}
|
||||
|
||||
Content type: {content_type} | Density: {density}
|
||||
|
||||
Your task: identify the most viral-worthy highlights from the transcript.
|
||||
|
||||
Rules:
|
||||
- Every highlight must open with a strong HOOK — a line that grabs attention within the first 3 seconds
|
||||
- Duration sweet spot: 45-90 seconds. Go shorter (20-44s) only for a perfect standalone one-liner. Go longer (91-180s) only when a story arc needs full context to land
|
||||
- Never cut mid-sentence or mid-thought — each clip must feel complete and self-contained
|
||||
- Clips must not overlap significantly with each other
|
||||
- Score 0-100 on viral potential (not general quality)
|
||||
- {num_clips_instruction}
|
||||
- For each highlight, identify the single best "hook_sentence" — the opening line that would make someone stop scrolling
|
||||
- Explain in one sentence why this clip is viral ("virality_reason")
|
||||
|
||||
Respond ONLY with valid JSON (no markdown, no explanation):
|
||||
{{"highlights":[{{"title":"string","start_time":float,"end_time":float,"score":int,"hook_sentence":"string","virality_reason":"string"}}]}}"""
|
||||
|
||||
|
||||
CHUNK_SIZE_SECONDS = 1200 # 20-min chunks for long videos
|
||||
LONG_VIDEO_THRESHOLD = 1800 # chunk videos longer than 30 min
|
||||
CHUNK_OVERLAP_SECONDS = 60
|
||||
GPT_CALL_TIMEOUT_SECONDS = 300 # cap LLM polls at 5 min — a wedged call should fail fast
|
||||
MAX_HIGHLIGHT_API_ATTEMPTS = 3
|
||||
|
||||
|
||||
def call_muapi_llm(prompt: str) -> str:
|
||||
"""Default LLM backend: MuAPI gpt-5-mini."""
|
||||
result = muapi.run(
|
||||
"gpt-5-mini",
|
||||
{"prompt": prompt},
|
||||
label="gpt-5-mini",
|
||||
timeout=GPT_CALL_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
outputs = result.get("outputs")
|
||||
if isinstance(outputs, list) and outputs and isinstance(outputs[0], str) and outputs[0].strip():
|
||||
return outputs[0]
|
||||
|
||||
for key in ("output", "text", "response", "result", "content"):
|
||||
v = result.get(key)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v
|
||||
if isinstance(v, dict):
|
||||
inner = v.get("text") or v.get("content")
|
||||
if isinstance(inner, str) and inner.strip():
|
||||
return inner
|
||||
if isinstance(v, list) and v and isinstance(v[0], str):
|
||||
return v[0]
|
||||
|
||||
raise RuntimeError(f"Could not extract gpt-5-mini text from response: {result}")
|
||||
|
||||
|
||||
def _parse_json_loose(raw: str) -> Dict:
|
||||
"""gpt-5-4 sometimes wraps JSON in markdown fences — strip and parse."""
|
||||
text = raw.strip()
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||
text = re.sub(r"\s*```$", "", text)
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start != -1 and end != -1:
|
||||
return json.loads(text[start:end + 1])
|
||||
raise
|
||||
|
||||
|
||||
def _coerce_float(value: object, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _coerce_int(value: object, default: int = 0) -> int:
|
||||
try:
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _sanitize_highlights(raw_highlights: object, duration: float) -> List[Dict]:
|
||||
"""Normalize model output into the expected shape; skip invalid entries."""
|
||||
if not isinstance(raw_highlights, list):
|
||||
return []
|
||||
|
||||
max_end = duration if duration > 0 else float("inf")
|
||||
cleaned: List[Dict] = []
|
||||
for item in raw_highlights:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
start = _coerce_float(item.get("start_time"), default=-1.0)
|
||||
end = _coerce_float(item.get("end_time"), default=-1.0)
|
||||
if start < 0 or end <= start:
|
||||
continue
|
||||
|
||||
if max_end != float("inf"):
|
||||
start = min(start, max_end)
|
||||
end = min(end, max_end)
|
||||
if end <= start:
|
||||
continue
|
||||
|
||||
cleaned.append(
|
||||
{
|
||||
"title": str(item.get("title") or "Untitled Highlight").strip(),
|
||||
"start_time": start,
|
||||
"end_time": end,
|
||||
"score": max(0, min(100, _coerce_int(item.get("score"), default=0))),
|
||||
"hook_sentence": str(item.get("hook_sentence") or "").strip(),
|
||||
"virality_reason": str(item.get("virality_reason") or "").strip(),
|
||||
}
|
||||
)
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
def detect_content_type(transcript: Dict, llm_fn: LLMFn = call_muapi_llm) -> Dict[str, str]:
|
||||
segments = transcript.get("segments", [])
|
||||
sample = " ".join(s["text"] for s in segments[:25])[:3000]
|
||||
prompt = f"{CONTENT_TYPE_PROMPT}\n\nTranscript sample:\n{sample}"
|
||||
try:
|
||||
raw = llm_fn(prompt)
|
||||
return _parse_json_loose(raw)
|
||||
except Exception:
|
||||
return {"content_type": "other", "density": "medium"}
|
||||
|
||||
|
||||
def build_transcript_text(transcript: Dict) -> str:
|
||||
segments = transcript.get("segments", [])
|
||||
return "\n".join(f"[{s['start']:.1f}s] {s['text'].strip()}" for s in segments)
|
||||
|
||||
|
||||
def chunk_transcript(transcript: Dict) -> List[Dict]:
|
||||
segments = transcript.get("segments", [])
|
||||
duration = transcript.get("duration", segments[-1]["end"] if segments else 0)
|
||||
chunks = []
|
||||
start = 0
|
||||
while start < duration:
|
||||
end = min(start + CHUNK_SIZE_SECONDS, duration)
|
||||
chunk_segs = [
|
||||
s for s in segments
|
||||
if s["start"] >= start and s["end"] <= end + CHUNK_OVERLAP_SECONDS
|
||||
]
|
||||
if chunk_segs:
|
||||
chunk = dict(transcript)
|
||||
chunk["segments"] = chunk_segs
|
||||
chunk["duration"] = end - start
|
||||
chunk["_offset"] = start
|
||||
chunks.append(chunk)
|
||||
start += CHUNK_SIZE_SECONDS - CHUNK_OVERLAP_SECONDS
|
||||
return chunks
|
||||
|
||||
|
||||
def call_highlight_api(
|
||||
transcript_text: str,
|
||||
content_info: Dict,
|
||||
duration: float,
|
||||
num_clips: int,
|
||||
is_chunk: bool = False,
|
||||
llm_fn: LLMFn = call_muapi_llm,
|
||||
) -> Dict:
|
||||
# Ask for ~2× the user's target so dedupe has headroom, but cap so the model
|
||||
# doesn't have to generate a huge JSON payload (which times out gpt-5-mini).
|
||||
target = max(num_clips * 2, 5)
|
||||
natural_max = max(2 if is_chunk else 3, int(duration / 90))
|
||||
min_clips = min(target, natural_max, 8)
|
||||
system = HIGHLIGHT_SYSTEM_PROMPT.format(
|
||||
virality_criteria=VIRALITY_CRITERIA,
|
||||
content_type=content_info.get("content_type", "other"),
|
||||
density=content_info.get("density", "medium"),
|
||||
num_clips_instruction=f"Generate at least {min_clips} highlights",
|
||||
)
|
||||
base_prompt = f"{system}\n\nTranscript:\n{transcript_text}"
|
||||
prompt = base_prompt
|
||||
last_error = "unknown"
|
||||
|
||||
for attempt in range(1, MAX_HIGHLIGHT_API_ATTEMPTS + 1):
|
||||
raw = llm_fn(prompt)
|
||||
try:
|
||||
parsed = _parse_json_loose(raw)
|
||||
highlights = _sanitize_highlights(parsed.get("highlights"), duration=duration)
|
||||
if highlights:
|
||||
return {"highlights": highlights}
|
||||
last_error = "no valid highlights in response"
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
|
||||
if attempt < MAX_HIGHLIGHT_API_ATTEMPTS:
|
||||
print(
|
||||
f"[highlights] invalid model output on attempt {attempt}/{MAX_HIGHLIGHT_API_ATTEMPTS}; retrying",
|
||||
flush=True,
|
||||
)
|
||||
prompt = (
|
||||
base_prompt
|
||||
+ "\n\nIMPORTANT: Return ONLY valid JSON with a top-level 'highlights' array."
|
||||
+ " Each item must include: title, start_time, end_time, score, hook_sentence, virality_reason."
|
||||
+ " No markdown fences, no commentary."
|
||||
)
|
||||
|
||||
raise RuntimeError(
|
||||
f"Highlight generator produced invalid output after {MAX_HIGHLIGHT_API_ATTEMPTS} attempts: {last_error}"
|
||||
)
|
||||
|
||||
|
||||
def dedupe_highlights(highlights: List[Dict]) -> List[Dict]:
|
||||
"""Drop a highlight if it overlaps >50% with a higher-scoring one already kept."""
|
||||
highlights = sorted(highlights, key=lambda x: int(x.get("score", 0)), reverse=True)
|
||||
kept: List[Dict] = []
|
||||
for h in highlights:
|
||||
h_start = float(h["start_time"])
|
||||
h_end = float(h["end_time"])
|
||||
h_dur = h_end - h_start
|
||||
overlapping = False
|
||||
for k in kept:
|
||||
latest_start = max(h_start, float(k["start_time"]))
|
||||
earliest_end = min(h_end, float(k["end_time"]))
|
||||
overlap = earliest_end - latest_start
|
||||
if overlap > 0 and overlap > 0.5 * h_dur:
|
||||
overlapping = True
|
||||
break
|
||||
if not overlapping:
|
||||
kept.append(h)
|
||||
return kept
|
||||
|
||||
|
||||
def get_highlights(
|
||||
transcript: Dict,
|
||||
num_clips: int = 3,
|
||||
llm_fn: Optional[LLMFn] = None,
|
||||
) -> Dict:
|
||||
"""Main entry point — returns {highlights: [...]} sorted by score.
|
||||
|
||||
`llm_fn` swaps the underlying LLM. Defaults to MuAPI gpt-5-mini; local
|
||||
mode passes in a local LLM-backed callable.
|
||||
"""
|
||||
llm_fn = llm_fn or call_muapi_llm
|
||||
duration = transcript.get("duration", 0)
|
||||
content_info = detect_content_type(transcript, llm_fn=llm_fn)
|
||||
print(f"[highlights] content={content_info.get('content_type')} density={content_info.get('density')} duration={duration:.0f}s", flush=True)
|
||||
|
||||
if duration >= LONG_VIDEO_THRESHOLD:
|
||||
chunks = chunk_transcript(transcript)
|
||||
print(f"[highlights] long video — splitting into {len(chunks)} chunks", flush=True)
|
||||
all_highlights: List[Dict] = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
offset = chunk.get("_offset", 0)
|
||||
text = build_transcript_text(chunk)
|
||||
print(f"[highlights] chunk {i + 1}/{len(chunks)} (offset {offset:.0f}s)", flush=True)
|
||||
result = call_highlight_api(text, content_info, chunk["duration"], num_clips=num_clips, is_chunk=True, llm_fn=llm_fn)
|
||||
for h in result.get("highlights", []):
|
||||
h["start_time"] = float(h["start_time"]) + offset
|
||||
h["end_time"] = float(h["end_time"]) + offset
|
||||
all_highlights.append(h)
|
||||
highlights = dedupe_highlights(all_highlights)
|
||||
else:
|
||||
text = build_transcript_text(transcript)
|
||||
result = call_highlight_api(text, content_info, duration, num_clips=num_clips, llm_fn=llm_fn)
|
||||
highlights = dedupe_highlights(result.get("highlights", []))
|
||||
|
||||
return {"highlights": highlights}
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Local-mode backends — no MuAPI calls, runs on your machine.
|
||||
|
||||
Used when the pipeline is invoked with mode="local". Requires the optional
|
||||
deps in requirements-local.txt (yt-dlp, faster-whisper, openai, google-genai,
|
||||
opencv, moviepy) plus an LLM API key for highlight ranking.
|
||||
"""
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Local clipping: ffmpeg subclip + OpenCV face-aware vertical crop.
|
||||
|
||||
Two stages per highlight:
|
||||
1. Cut the source video to [start, end] with ffmpeg (re-encoded, audio kept).
|
||||
2. Reframe the cut to the target aspect ratio. For 9:16 we slide a vertical
|
||||
window horizontally across the frame to keep faces centred (Haar
|
||||
cascade — same approach as the original repo, no external models).
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from ..config import LOCAL_OUTPUT_DIR
|
||||
|
||||
|
||||
def _ratio(aspect_ratio: str) -> float:
|
||||
"""Parse '9:16' → 9/16, '1:1' → 1.0."""
|
||||
try:
|
||||
w, h = aspect_ratio.split(":")
|
||||
return float(w) / float(h)
|
||||
except (ValueError, ZeroDivisionError):
|
||||
return 9.0 / 16.0
|
||||
|
||||
|
||||
def _cut_subclip(source_path: str, start: float, end: float, out_path: str) -> str:
|
||||
"""ffmpeg -ss start -to end → re-encoded mp4 with audio."""
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-loglevel", "error",
|
||||
"-i", source_path,
|
||||
"-ss", f"{start:.3f}",
|
||||
"-to", f"{end:.3f}",
|
||||
"-c:v", "libx264", "-preset", "fast", "-crf", "20",
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
out_path,
|
||||
]
|
||||
subprocess.run(cmd, check=True)
|
||||
return out_path
|
||||
|
||||
|
||||
def _reframe_vertical(in_path: str, out_path: str, aspect_ratio: str) -> str:
|
||||
"""Crop the cut clip to the target aspect ratio, tracking faces if possible."""
|
||||
try:
|
||||
import cv2 # type: ignore
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"opencv-python is required for --mode local. Install it with:\n"
|
||||
" pip install -r requirements-local.txt"
|
||||
) from e
|
||||
|
||||
target_ratio = _ratio(aspect_ratio)
|
||||
cap = cv2.VideoCapture(in_path)
|
||||
if not cap.isOpened():
|
||||
raise RuntimeError(f"could not open {in_path}")
|
||||
|
||||
src_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
src_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||
|
||||
# Compute the largest crop that fits inside the frame at the target ratio.
|
||||
if target_ratio < src_w / src_h:
|
||||
crop_h = src_h
|
||||
crop_w = int(crop_h * target_ratio)
|
||||
else:
|
||||
crop_w = src_w
|
||||
crop_h = int(crop_w / target_ratio)
|
||||
crop_w = max(2, crop_w - (crop_w % 2))
|
||||
crop_h = max(2, crop_h - (crop_h % 2))
|
||||
|
||||
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
|
||||
|
||||
silent_path = out_path + ".silent.mp4"
|
||||
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||
writer = cv2.VideoWriter(silent_path, fourcc, fps, (crop_w, crop_h))
|
||||
|
||||
last_center: Optional[Tuple[int, int]] = None
|
||||
smoothing = 0.15 # how aggressively to chase a new face position
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(40, 40))
|
||||
if len(faces) > 0:
|
||||
# Pick the largest face — usually the speaker.
|
||||
x, y, w, h = max(faces, key=lambda f: f[2] * f[3])
|
||||
cx = x + w // 2
|
||||
cy = y + h // 2
|
||||
if last_center is None:
|
||||
last_center = (cx, cy)
|
||||
else:
|
||||
lx, ly = last_center
|
||||
last_center = (
|
||||
int(lx + (cx - lx) * smoothing),
|
||||
int(ly + (cy - ly) * smoothing),
|
||||
)
|
||||
if last_center is None:
|
||||
last_center = (src_w // 2, src_h // 2)
|
||||
|
||||
cx, cy = last_center
|
||||
x0 = max(0, min(src_w - crop_w, cx - crop_w // 2))
|
||||
y0 = max(0, min(src_h - crop_h, cy - crop_h // 2))
|
||||
cropped = frame[y0:y0 + crop_h, x0:x0 + crop_w]
|
||||
writer.write(cropped)
|
||||
|
||||
cap.release()
|
||||
writer.release()
|
||||
|
||||
# Mux audio from the cut clip back onto the silent reframed video.
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-loglevel", "error",
|
||||
"-i", silent_path,
|
||||
"-i", in_path,
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
"-map", "0:v:0", "-map", "1:a:0?",
|
||||
"-shortest",
|
||||
out_path,
|
||||
]
|
||||
subprocess.run(cmd, check=True)
|
||||
os.remove(silent_path)
|
||||
return out_path
|
||||
|
||||
|
||||
def crop_clip_local(
|
||||
source_path: str,
|
||||
start_time: float,
|
||||
end_time: float,
|
||||
aspect_ratio: str,
|
||||
out_path: str,
|
||||
) -> str:
|
||||
"""Cut + reframe one highlight, returning the local mp4 path."""
|
||||
cut_path = out_path + ".cut.mp4"
|
||||
try:
|
||||
_cut_subclip(source_path, start_time, end_time, cut_path)
|
||||
_reframe_vertical(cut_path, out_path, aspect_ratio)
|
||||
finally:
|
||||
if os.path.exists(cut_path):
|
||||
os.remove(cut_path)
|
||||
return out_path
|
||||
|
||||
|
||||
def crop_highlights_local(
|
||||
source_path: str,
|
||||
highlights: List[Dict],
|
||||
aspect_ratio: str = "9:16",
|
||||
out_dir: Optional[str] = None,
|
||||
) -> List[Dict]:
|
||||
out_dir = out_dir or LOCAL_OUTPUT_DIR
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
results: List[Dict] = []
|
||||
for i, h in enumerate(highlights, 1):
|
||||
out_path = os.path.join(out_dir, f"short_{i:02d}.mp4")
|
||||
print(f"[clip/local] {i}/{len(highlights)}: {h.get('title', '(untitled)')}", flush=True)
|
||||
try:
|
||||
crop_clip_local(
|
||||
source_path,
|
||||
float(h["start_time"]),
|
||||
float(h["end_time"]),
|
||||
aspect_ratio,
|
||||
out_path,
|
||||
)
|
||||
results.append({**h, "clip_url": out_path})
|
||||
except Exception as e:
|
||||
print(f"[clip/local] {i} failed: {e}", flush=True)
|
||||
results.append({**h, "clip_url": None, "error": str(e)})
|
||||
return results
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Local YouTube download via yt-dlp.
|
||||
|
||||
Returns a local mp4 path so the rest of the local pipeline can read it
|
||||
directly off disk.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
from typing import Optional
|
||||
|
||||
from ..config import LOCAL_OUTPUT_DIR
|
||||
|
||||
|
||||
def _import_ytdlp():
|
||||
try:
|
||||
import yt_dlp # type: ignore
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"yt-dlp is required for --mode local. Install it with:\n"
|
||||
" pip install -r requirements-local.txt"
|
||||
) from e
|
||||
return yt_dlp
|
||||
|
||||
|
||||
def _format_for(fmt: str) -> str:
|
||||
"""Map our '720' / '1080' shorthand to a yt-dlp format selector."""
|
||||
try:
|
||||
height = int(fmt)
|
||||
except ValueError:
|
||||
height = 720
|
||||
return (
|
||||
f"bestvideo[height<={height}][ext=mp4]+bestaudio[ext=m4a]/"
|
||||
f"best[height<={height}][ext=mp4]/best"
|
||||
)
|
||||
|
||||
|
||||
def _extract_youtube_video_id(source: str) -> Optional[str]:
|
||||
"""Best-effort extraction of a YouTube video id from a URL."""
|
||||
parsed = urlparse(source)
|
||||
host = (parsed.netloc or "").lower()
|
||||
if host.startswith("www."):
|
||||
host = host[4:]
|
||||
|
||||
if host in ("youtu.be", "www.youtu.be"):
|
||||
video_id = parsed.path.lstrip("/").split("/", 1)[0]
|
||||
return video_id or None
|
||||
|
||||
if "youtube.com" in host:
|
||||
if parsed.path.startswith("/watch"):
|
||||
qs = parse_qs(parsed.query)
|
||||
video_id = qs.get("v", [""])[0]
|
||||
return video_id or None
|
||||
match = re.search(r"/(?:shorts|embed|live)/([^/?#&]+)", parsed.path)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_local_path(source: str) -> Optional[str]:
|
||||
"""Return a local filesystem path if the input already points at one."""
|
||||
parsed = urlparse(source)
|
||||
if parsed.scheme == "file":
|
||||
raw_path = unquote(parsed.path)
|
||||
if parsed.netloc and parsed.netloc not in ("", "localhost"):
|
||||
raw_path = f"//{parsed.netloc}{raw_path}"
|
||||
candidate = Path(raw_path).expanduser()
|
||||
if candidate.exists() and candidate.is_file():
|
||||
return str(candidate.resolve())
|
||||
raise RuntimeError(f"Local file URL does not exist: {source}")
|
||||
|
||||
if parsed.scheme in ("http", "https"):
|
||||
return None
|
||||
|
||||
candidate = Path(source).expanduser()
|
||||
if candidate.exists() and candidate.is_file():
|
||||
return str(candidate.resolve())
|
||||
|
||||
if any(sep in source for sep in (os.sep, "/")) or source.startswith("~") or source.startswith("."):
|
||||
raise RuntimeError(f"Local file path does not exist: {source}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _existing_download(out_dir: str, video_id: str) -> Optional[str]:
|
||||
"""Return a cached download path if we already have this YouTube id."""
|
||||
for ext in (".mp4", ".mkv", ".webm"):
|
||||
candidate = os.path.join(out_dir, f"source_{video_id}{ext}")
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def download_youtube_local(video_url: str, fmt: str = "720", out_dir: Optional[str] = None) -> str:
|
||||
"""Download a remote URL or return a local file path unchanged."""
|
||||
local_path = _resolve_local_path(video_url)
|
||||
if local_path:
|
||||
print(f"[download/local] using local file: {local_path}", flush=True)
|
||||
return local_path
|
||||
|
||||
yt_dlp = _import_ytdlp()
|
||||
out_dir = out_dir or LOCAL_OUTPUT_DIR
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
video_id = _extract_youtube_video_id(video_url)
|
||||
if video_id:
|
||||
cached = _existing_download(out_dir, video_id)
|
||||
if cached:
|
||||
print(f"[download/local] reusing cached download: {cached}", flush=True)
|
||||
return cached
|
||||
|
||||
print(f"[download/local] {video_url} @ {fmt}p → {out_dir}/", flush=True)
|
||||
ydl_opts = {
|
||||
"format": _format_for(fmt),
|
||||
"outtmpl": os.path.join(out_dir, "source_%(id)s.%(ext)s"),
|
||||
"merge_output_format": "mp4",
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"noprogress": True,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url, download=True)
|
||||
path = ydl.prepare_filename(info)
|
||||
# merge_output_format may rename the extension after merge
|
||||
if not os.path.exists(path):
|
||||
stem, _ = os.path.splitext(path)
|
||||
for ext in (".mp4", ".mkv", ".webm"):
|
||||
if os.path.exists(stem + ext):
|
||||
path = stem + ext
|
||||
break
|
||||
|
||||
print(f"[download/local] ready: {path}", flush=True)
|
||||
return path
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Local LLM backend — OpenAI or Gemini, selected by LLM_PROVIDER."""
|
||||
from ..config import (
|
||||
GEMINI_MODEL,
|
||||
LLM_PROVIDER,
|
||||
OPENAI_MODEL,
|
||||
require_gemini_key,
|
||||
require_openai_key,
|
||||
)
|
||||
|
||||
|
||||
def call_openai_llm(prompt: str) -> str:
|
||||
"""OpenAI Chat Completions backend used by --mode local."""
|
||||
try:
|
||||
from openai import OpenAI # type: ignore
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"openai is required for --mode local. Install it with:\n"
|
||||
" pip install -r requirements-local.txt"
|
||||
) from e
|
||||
|
||||
client = OpenAI(api_key=require_openai_key())
|
||||
response = client.chat.completions.create(
|
||||
model=OPENAI_MODEL,
|
||||
temperature=0.7,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
|
||||
def call_gemini_llm(prompt: str) -> str:
|
||||
"""Gemini backend used by --mode local when LLM_PROVIDER=gemini."""
|
||||
try:
|
||||
from google import genai # type: ignore
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"google-genai is required for LLM_PROVIDER=gemini. Install it with:\n"
|
||||
" pip install -r requirements-local.txt"
|
||||
) from e
|
||||
|
||||
client = genai.Client(api_key=require_gemini_key())
|
||||
response = client.models.generate_content(
|
||||
model=GEMINI_MODEL,
|
||||
contents=prompt,
|
||||
config={
|
||||
"temperature": 0.2,
|
||||
"response_mime_type": "application/json",
|
||||
"max_output_tokens": 8192,
|
||||
},
|
||||
)
|
||||
return response.text or ""
|
||||
|
||||
|
||||
def call_local_llm(prompt: str) -> str:
|
||||
"""Dispatch to the configured local LLM provider."""
|
||||
provider = (LLM_PROVIDER or "openai").strip().lower()
|
||||
if provider == "openai":
|
||||
return call_openai_llm(prompt)
|
||||
if provider == "gemini":
|
||||
return call_gemini_llm(prompt)
|
||||
raise RuntimeError(
|
||||
f"Unknown LLM_PROVIDER={provider!r}. Use 'openai' or 'gemini'."
|
||||
)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Local transcription via faster-whisper.
|
||||
|
||||
Reads a local media file and returns the same shape the highlight generator
|
||||
expects: {duration, segments[start, end, text]}.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from ..config import LOCAL_OUTPUT_DIR, LOCAL_WHISPER_DEVICE, LOCAL_WHISPER_MODEL
|
||||
|
||||
|
||||
def _transcript_cache_path(media_path: str) -> Path:
|
||||
"""Return the .srt cache path for a media file."""
|
||||
cache_dir = Path(LOCAL_OUTPUT_DIR)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cache_dir / (Path(media_path).stem + ".srt")
|
||||
|
||||
|
||||
def _format_srt_timestamp(seconds: float) -> str:
|
||||
total_ms = max(0, int(round(seconds * 1000)))
|
||||
ms = total_ms % 1000
|
||||
total_s = total_ms // 1000
|
||||
s = total_s % 60
|
||||
total_m = total_s // 60
|
||||
m = total_m % 60
|
||||
h = total_m // 60
|
||||
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
||||
|
||||
|
||||
def _parse_srt_timestamp(value: str) -> float:
|
||||
match = re.fullmatch(r"(\d{2}):(\d{2}):(\d{2}),(\d{3})", value.strip())
|
||||
if not match:
|
||||
raise ValueError(f"Invalid SRT timestamp: {value!r}")
|
||||
hours, minutes, seconds, millis = map(int, match.groups())
|
||||
return hours * 3600 + minutes * 60 + seconds + (millis / 1000.0)
|
||||
|
||||
|
||||
def _write_srt_cache(media_path: str, transcript: Dict) -> Path:
|
||||
cache_path = _transcript_cache_path(media_path)
|
||||
lines = []
|
||||
for idx, segment in enumerate(transcript.get("segments", []), start=1):
|
||||
start = _format_srt_timestamp(float(segment["start"]))
|
||||
end = _format_srt_timestamp(float(segment["end"]))
|
||||
text = str(segment.get("text", "")).strip().replace("\r", "").replace("\n", " ")
|
||||
lines.append(str(idx))
|
||||
lines.append(f"{start} --> {end}")
|
||||
lines.append(text)
|
||||
lines.append("")
|
||||
|
||||
cache_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return cache_path
|
||||
|
||||
|
||||
def _load_srt_cache(cache_path: Path) -> Dict:
|
||||
content = cache_path.read_text(encoding="utf-8-sig").strip()
|
||||
if not content:
|
||||
return {"duration": 0.0, "segments": []}
|
||||
|
||||
segments = []
|
||||
for block in re.split(r"\n\s*\n", content):
|
||||
lines = [line.strip("\ufeff") for line in block.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
continue
|
||||
if "-->" not in lines[0] and len(lines) > 1 and "-->" in lines[1]:
|
||||
lines = lines[1:]
|
||||
if not lines or "-->" not in lines[0]:
|
||||
continue
|
||||
start_raw, end_raw = [part.strip() for part in lines[0].split("-->", 1)]
|
||||
text = "\n".join(lines[1:]).strip()
|
||||
segments.append(
|
||||
{
|
||||
"start": _parse_srt_timestamp(start_raw),
|
||||
"end": _parse_srt_timestamp(end_raw),
|
||||
"text": text,
|
||||
}
|
||||
)
|
||||
|
||||
duration = segments[-1]["end"] if segments else 0.0
|
||||
return {"duration": duration, "segments": segments}
|
||||
|
||||
|
||||
def _resolve_device() -> str:
|
||||
if LOCAL_WHISPER_DEVICE != "auto":
|
||||
return LOCAL_WHISPER_DEVICE
|
||||
try:
|
||||
import torch # type: ignore
|
||||
if torch.cuda.is_available():
|
||||
# Test that CUDA actually works (catches missing cuBLAS/cuDNN libs)
|
||||
torch.zeros(1, device="cuda")
|
||||
return "cuda"
|
||||
except (ImportError, OSError, RuntimeError):
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
|
||||
def transcribe_local(media_path: str, language: Optional[str] = None) -> Dict:
|
||||
"""Run faster-whisper on a local file path, caching the result as .srt."""
|
||||
cache_path = _transcript_cache_path(media_path)
|
||||
if cache_path.exists():
|
||||
source_mtime = os.path.getmtime(media_path)
|
||||
cache_mtime = cache_path.stat().st_mtime
|
||||
if cache_mtime >= source_mtime:
|
||||
print(f"[transcribe/local] reusing cached transcript: {cache_path}", flush=True)
|
||||
cached = _load_srt_cache(cache_path)
|
||||
# Treat empty cache as invalid (likely from a failed/partial run) — delete and re-transcribe
|
||||
if not cached["segments"] or cached["duration"] <= 0.0:
|
||||
print(f"[transcribe/local] cache is empty/invalid, deleting: {cache_path}", flush=True)
|
||||
cache_path.unlink(missing_ok=True)
|
||||
else:
|
||||
print(
|
||||
f"[transcribe/local] {len(cached['segments'])} cached segments, "
|
||||
f"{cached['duration']:.0f}s of audio",
|
||||
flush=True,
|
||||
)
|
||||
return cached
|
||||
|
||||
try:
|
||||
from faster_whisper import WhisperModel # type: ignore
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"faster-whisper is required for --mode local. Install it with:\n"
|
||||
" pip install -r requirements-local.txt"
|
||||
) from e
|
||||
|
||||
device = _resolve_device()
|
||||
compute_type = "float16" if device == "cuda" else "int8"
|
||||
print(f"[transcribe/local] faster-whisper model={LOCAL_WHISPER_MODEL} device={device}", flush=True)
|
||||
|
||||
from ..config import LOCAL_WHISPER_VAD_FILTER, LOCAL_WHISPER_VAD_PARAMETERS
|
||||
|
||||
model = WhisperModel(LOCAL_WHISPER_MODEL, device=device, compute_type=compute_type)
|
||||
|
||||
transcribe_kwargs = {
|
||||
"audio": media_path,
|
||||
"language": language,
|
||||
"beam_size": 5,
|
||||
"condition_on_previous_text": False,
|
||||
}
|
||||
if LOCAL_WHISPER_VAD_FILTER:
|
||||
transcribe_kwargs["vad_filter"] = True
|
||||
transcribe_kwargs["vad_parameters"] = LOCAL_WHISPER_VAD_PARAMETERS
|
||||
else:
|
||||
transcribe_kwargs["vad_filter"] = False
|
||||
|
||||
segments_iter, info = model.transcribe(**transcribe_kwargs)
|
||||
|
||||
segments = []
|
||||
for s in segments_iter:
|
||||
segments.append({
|
||||
"start": float(s.start),
|
||||
"end": float(s.end),
|
||||
"text": (s.text or "").strip(),
|
||||
})
|
||||
|
||||
duration = float(getattr(info, "duration", 0.0)) or (segments[-1]["end"] if segments else 0.0)
|
||||
print(f"[transcribe/local] {len(segments)} segments, {duration:.0f}s of audio", flush=True)
|
||||
transcript = {"duration": duration, "segments": segments}
|
||||
cache_path = _write_srt_cache(media_path, transcript)
|
||||
print(f"[transcribe/local] wrote cache: {cache_path}", flush=True)
|
||||
return transcript
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Thin MuAPI client: submit a job, poll until it finishes, return the result."""
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .config import (
|
||||
MUAPI_BASE_URL,
|
||||
POLL_INTERVAL_SECONDS,
|
||||
POLL_TIMEOUT_SECONDS,
|
||||
require_api_key,
|
||||
)
|
||||
|
||||
|
||||
class MuAPIError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _headers() -> Dict[str, str]:
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": require_api_key(),
|
||||
}
|
||||
|
||||
|
||||
def submit(endpoint: str, payload: Dict[str, Any], retries: int = 3) -> str:
|
||||
"""POST to /api/v1/{endpoint} and return the request_id; retry transient errors."""
|
||||
url = f"{MUAPI_BASE_URL}/{endpoint.lstrip('/')}"
|
||||
last_err: Optional[Exception] = None
|
||||
for _ in range(retries):
|
||||
try:
|
||||
resp = requests.post(url, json=payload, headers=_headers(), timeout=120)
|
||||
if resp.status_code >= 400:
|
||||
raise MuAPIError(f"{endpoint} submit failed [{resp.status_code}]: {resp.text}")
|
||||
data = resp.json()
|
||||
request_id = data.get("request_id") or data.get("id")
|
||||
if not request_id:
|
||||
raise MuAPIError(f"{endpoint} response had no request_id: {data}")
|
||||
return str(request_id)
|
||||
except (requests.Timeout, requests.ConnectionError) as e:
|
||||
last_err = e
|
||||
time.sleep(2)
|
||||
raise MuAPIError(f"{endpoint} submit failed after {retries} retries: {last_err}")
|
||||
|
||||
|
||||
def fetch_result(request_id: str, retries: int = 3) -> Dict[str, Any]:
|
||||
"""GET the latest result for a request_id; retry on transient timeouts."""
|
||||
url = f"{MUAPI_BASE_URL}/predictions/{request_id}/result"
|
||||
last_err: Optional[Exception] = None
|
||||
for _ in range(retries):
|
||||
try:
|
||||
resp = requests.get(url, headers=_headers(), timeout=90)
|
||||
if resp.status_code >= 400:
|
||||
raise MuAPIError(f"poll failed [{resp.status_code}]: {resp.text}")
|
||||
return resp.json()
|
||||
except (requests.Timeout, requests.ConnectionError) as e:
|
||||
last_err = e
|
||||
time.sleep(2)
|
||||
raise MuAPIError(f"poll failed after {retries} retries: {last_err}")
|
||||
|
||||
|
||||
def poll(
|
||||
request_id: str,
|
||||
interval: float = POLL_INTERVAL_SECONDS,
|
||||
timeout: float = POLL_TIMEOUT_SECONDS,
|
||||
label: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Block until the prediction is done; return the final payload."""
|
||||
deadline = time.time() + timeout
|
||||
last_status = None
|
||||
while time.time() < deadline:
|
||||
data = fetch_result(request_id)
|
||||
status = (data.get("status") or "").lower()
|
||||
if status and status != last_status:
|
||||
print(f"[muapi] {label or request_id}: {status}", flush=True)
|
||||
last_status = status
|
||||
|
||||
if status in ("completed", "succeeded", "success"):
|
||||
return data
|
||||
if status in ("failed", "error"):
|
||||
raise MuAPIError(f"{label or request_id} failed: {data}")
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
raise MuAPIError(f"{label or request_id} timed out after {timeout}s")
|
||||
|
||||
|
||||
def run(
|
||||
endpoint: str,
|
||||
payload: Dict[str, Any],
|
||||
label: Optional[str] = None,
|
||||
interval: float = POLL_INTERVAL_SECONDS,
|
||||
timeout: float = POLL_TIMEOUT_SECONDS,
|
||||
) -> Dict[str, Any]:
|
||||
"""Submit then poll. Returns the final result payload."""
|
||||
request_id = submit(endpoint, payload)
|
||||
return poll(request_id, interval=interval, timeout=timeout, label=label or endpoint)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""End-to-end orchestrator.
|
||||
|
||||
Two modes:
|
||||
* mode="api" (default) — MuAPI does download / transcribe / LLM / autocrop.
|
||||
Fast, no local deps, pay-per-call.
|
||||
* mode="local" — yt-dlp + faster-whisper + OpenAI or Gemini + ffmpeg/opencv.
|
||||
Self-hosted, LLM_PROVIDER selects OpenAI or Gemini.
|
||||
"""
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .clipper import crop_highlights
|
||||
from .downloader import download_youtube
|
||||
from .highlights import call_muapi_llm, get_highlights
|
||||
from .transcriber import transcribe
|
||||
|
||||
|
||||
def _run_local(
|
||||
youtube_url: str,
|
||||
num_clips: int,
|
||||
aspect_ratio: str,
|
||||
download_format: str,
|
||||
language: Optional[str],
|
||||
) -> Dict:
|
||||
from .local.clipper import crop_highlights_local
|
||||
from .local.downloader import download_youtube_local
|
||||
from .local.llm import call_local_llm
|
||||
from .local.transcriber import transcribe_local
|
||||
|
||||
source_path = download_youtube_local(youtube_url, fmt=download_format)
|
||||
|
||||
transcript = transcribe_local(source_path, language=language)
|
||||
if not transcript["segments"]:
|
||||
raise RuntimeError(
|
||||
"Whisper produced no segments. The video may have no detectable speech."
|
||||
)
|
||||
|
||||
highlights_result = get_highlights(transcript, num_clips=num_clips, llm_fn=call_local_llm)
|
||||
all_highlights: List[Dict] = highlights_result.get("highlights", [])
|
||||
if not all_highlights:
|
||||
raise RuntimeError("Highlight generator returned zero clips.")
|
||||
|
||||
top = sorted(all_highlights, key=lambda h: int(h.get("score", 0)), reverse=True)[:num_clips]
|
||||
print(f"[pipeline/local] cropping {len(top)} of {len(all_highlights)} candidates", flush=True)
|
||||
|
||||
shorts = crop_highlights_local(source_path, top, aspect_ratio=aspect_ratio)
|
||||
|
||||
return {
|
||||
"mode": "local",
|
||||
"source_video_url": source_path,
|
||||
"transcript": transcript,
|
||||
"highlights": all_highlights,
|
||||
"shorts": shorts,
|
||||
}
|
||||
|
||||
|
||||
def _run_api(
|
||||
youtube_url: str,
|
||||
num_clips: int,
|
||||
aspect_ratio: str,
|
||||
download_format: str,
|
||||
language: Optional[str],
|
||||
) -> Dict:
|
||||
source_url = download_youtube(youtube_url, fmt=download_format)
|
||||
|
||||
transcript = transcribe(source_url, language=language)
|
||||
if not transcript["segments"]:
|
||||
raise RuntimeError(
|
||||
"Whisper produced no segments. The video may have no detectable speech."
|
||||
)
|
||||
|
||||
highlights_result = get_highlights(transcript, num_clips=num_clips, llm_fn=call_muapi_llm)
|
||||
all_highlights: List[Dict] = highlights_result.get("highlights", [])
|
||||
if not all_highlights:
|
||||
raise RuntimeError("Highlight generator returned zero clips.")
|
||||
|
||||
top = sorted(all_highlights, key=lambda h: int(h.get("score", 0)), reverse=True)[:num_clips]
|
||||
print(f"[pipeline] cropping {len(top)} of {len(all_highlights)} candidates", flush=True)
|
||||
|
||||
shorts = crop_highlights(source_url, top, aspect_ratio=aspect_ratio)
|
||||
|
||||
return {
|
||||
"mode": "api",
|
||||
"source_video_url": source_url,
|
||||
"transcript": transcript,
|
||||
"highlights": all_highlights,
|
||||
"shorts": shorts,
|
||||
}
|
||||
|
||||
|
||||
def generate_shorts(
|
||||
youtube_url: str,
|
||||
num_clips: int = 3,
|
||||
aspect_ratio: str = "9:16",
|
||||
download_format: str = "720",
|
||||
language: Optional[str] = None,
|
||||
mode: str = "api",
|
||||
) -> Dict:
|
||||
"""Run the full pipeline and return a structured result.
|
||||
|
||||
Args:
|
||||
youtube_url: source URL.
|
||||
num_clips: how many shorts to render.
|
||||
aspect_ratio: e.g. "9:16", "1:1".
|
||||
download_format: source resolution ("360" / "480" / "720" / "1080").
|
||||
language: ISO-639-1 to force Whisper language detection.
|
||||
mode: "api" (default, MuAPI) or "local" (yt-dlp + faster-whisper +
|
||||
OpenAI or Gemini + ffmpeg).
|
||||
|
||||
Returns:
|
||||
{
|
||||
"mode": "api" | "local",
|
||||
"source_video_url": str, # hosted URL (api) or local path (local)
|
||||
"transcript": {...},
|
||||
"highlights": [...], # all candidates ranked
|
||||
"shorts": [...], # top `num_clips` with clip_url / local path
|
||||
}
|
||||
"""
|
||||
mode = (mode or "api").lower()
|
||||
if mode == "local":
|
||||
return _run_local(youtube_url, num_clips, aspect_ratio, download_format, language)
|
||||
if mode == "api":
|
||||
return _run_api(youtube_url, num_clips, aspect_ratio, download_format, language)
|
||||
raise ValueError(f"Unknown mode: {mode!r}. Use 'api' or 'local'.")
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Transcription via MuAPI /openai-whisper.
|
||||
|
||||
Sends a hosted media URL to MuAPI's Whisper endpoint and returns the segment
|
||||
shape expected by the highlight generator: {duration, segments[start,end,text]}.
|
||||
The API runs verbose_json server-side, so we get per-segment timestamps for free.
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, Optional
|
||||
|
||||
from . import muapi
|
||||
|
||||
|
||||
def _coerce_verbose(raw) -> Dict:
|
||||
"""The /openai-whisper result can land as a dict or a JSON string depending on
|
||||
how the worker stored it. Normalise to a dict with `duration` and `segments`."""
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_verbose_payload(result: Dict) -> Dict:
|
||||
"""MuAPI wraps results inconsistently across endpoints. Hunt for the
|
||||
verbose_json blob (which has `segments` + `duration`)."""
|
||||
for key in ("output", "result", "outputs"):
|
||||
v = result.get(key)
|
||||
if isinstance(v, dict) and "segments" in v:
|
||||
return v
|
||||
if isinstance(v, list) and v:
|
||||
first = v[0]
|
||||
decoded = _coerce_verbose(first)
|
||||
if "segments" in decoded:
|
||||
return decoded
|
||||
if isinstance(v, str):
|
||||
decoded = _coerce_verbose(v)
|
||||
if "segments" in decoded:
|
||||
return decoded
|
||||
|
||||
if "segments" in result:
|
||||
return result
|
||||
|
||||
raise RuntimeError(f"Could not find Whisper segments in MuAPI response: {result}")
|
||||
|
||||
|
||||
def transcribe(media_url: str, language: Optional[str] = None) -> Dict:
|
||||
"""Run MuAPI /openai-whisper on a hosted media URL.
|
||||
|
||||
Returns {duration: float, segments: [{start, end, text}, ...]} so it slots
|
||||
straight into the highlight generator.
|
||||
"""
|
||||
print(f"[transcribe] muapi /openai-whisper on {media_url}", flush=True)
|
||||
payload = {
|
||||
"audio_url": media_url,
|
||||
"response_format": "verbose_json",
|
||||
}
|
||||
if language:
|
||||
payload["language"] = language
|
||||
|
||||
result = muapi.run("openai-whisper", payload, label="openai-whisper")
|
||||
verbose = _extract_verbose_payload(result)
|
||||
|
||||
segments = []
|
||||
for s in verbose.get("segments") or []:
|
||||
segments.append({
|
||||
"start": float(s.get("start", 0.0)),
|
||||
"end": float(s.get("end", 0.0)),
|
||||
"text": (s.get("text") or "").strip(),
|
||||
})
|
||||
|
||||
duration = float(verbose.get("duration") or (segments[-1]["end"] if segments else 0.0))
|
||||
print(f"[transcribe] {len(segments)} segments, {duration:.0f}s of audio", flush=True)
|
||||
return {"duration": duration, "segments": segments}
|
||||
Reference in New Issue
Block a user