Files
viral_project/ai-youtube-shorts-generator/shorts_generator/highlights.py
T

307 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}