Spaces:
Sleeping
Sleeping
File size: 21,684 Bytes
342e0fb be3f38d 342e0fb 0890748 0de0dd3 342e0fb 0890748 be3f38d 3b06b5f be3f38d e9af3d2 342e0fb e9af3d2 be3f38d cd3139e e9af3d2 6dda67c cd3139e e9af3d2 cd3139e 6dda67c cd3139e e9af3d2 6dda67c be3f38d e9af3d2 be3f38d cd3139e e9af3d2 6dda67c e9af3d2 6dda67c e9af3d2 6dda67c cd3139e 6dda67c cd3139e 342e0fb e9af3d2 6dda67c e9af3d2 6dda67c e9af3d2 6dda67c be3f38d 6dda67c be3f38d 6dda67c e9af3d2 6dda67c be3f38d 6dda67c be3f38d 6dda67c be3f38d 6dda67c e9af3d2 6dda67c 3b06b5f e9af3d2 3b06b5f 0890748 3b06b5f e9af3d2 cd3139e 342e0fb e9af3d2 342e0fb cd3139e e9af3d2 cd3139e e9af3d2 cd3139e 342e0fb 6dda67c 342e0fb 6dda67c cd3139e 6dda67c 3b06b5f e9af3d2 be3f38d cd3139e be3f38d 342e0fb 6dda67c 342e0fb 0890748 cd3139e 6dda67c d392f23 e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d e9af3d2 be3f38d 161d310 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 | import os
import re
import time
import json
import logging
from groq import Groq
from dotenv import load_dotenv
load_dotenv()
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
api_key = os.getenv("GROQ_API_KEY")
MODEL_NAME = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
client = Groq(api_key=api_key)
MIN_DURATION = 60 # seconds
MAX_DURATION = 180 # seconds
TARGET_DURATION = 90 # ideal segment length
# ── Backtick fence markers ──
_FENCE_JSON = "```json"
_FENCE = "```"
# ─────────────────────────────────────────────────────────────────────────────
# VALIDATION — no mechanical padding, only natural segments pass
# ─────────────────────────────────────────────────────────────────────────────
def validate_segments(segments, video_duration=None):
"""
Accept only segments the AI correctly identified as complete scenes.
Short segments are REJECTED — not padded — to avoid random cuts.
The AI is instructed to return full scenes, so if it returns something
short it means it only found the punchline, not the full story.
"""
valid = []
for seg in segments:
start = seg.get("start_time", 0)
end = seg.get("end_time", 0)
dur = end - start
# Clamp end_time to video duration if known
if video_duration and end > video_duration:
end = video_duration
seg["end_time"] = round(end, 2)
dur = end - start
# Too long → hard reject
if dur > MAX_DURATION:
logger.warning(
f"⚠️ Too long ({dur:.1f}s) — skipped: "
f"'{seg.get('title', '')}' [{start}s–{end}s]"
)
continue
# Too short → reject and log so we can tune the prompt
if dur < MIN_DURATION:
logger.warning(
f"⚠️ Too short ({dur:.1f}s) — AI returned only punchline, not full scene. "
f"Skipped: '{seg.get('title', '')}' [{start}s–{end}s]"
)
continue
seg["duration"] = round(dur, 2)
valid.append(seg)
logger.info(f"✅ Valid segments after filter: {len(valid)}/{len(segments)}")
return valid
# ─────────────────────────────────────────────────────────────────────────────
# FALLBACK — heuristic when AI returns nothing
# ─────────────────────────────────────────────────────────────────────────────
def _fallback_segments_from_transcript(transcript: str, video_duration: float) -> list:
"""
If AI returns nothing useful, generate segments from the transcript using
the densest word windows around timestamp markers.
Only used as a last resort.
"""
logger.warning("⚠️ Using fallback segment generator from transcript timestamps")
lines = []
for match in re.finditer(r'\[(\d+\.?\d*)\s*-\s*(\d+\.?\d*)\]\s*(.*)', transcript):
lines.append({
"start": float(match.group(1)),
"end": float(match.group(2)),
"text": match.group(3).strip(),
})
if not lines:
return []
candidates = []
step = 60
window = TARGET_DURATION
t = lines[0]["start"]
max_t = lines[-1]["end"]
while t + MIN_DURATION <= max_t:
w_end = min(t + window, max_t)
in_window = [l for l in lines if l["start"] >= t and l["end"] <= w_end]
word_count = sum(len(l["text"].split()) for l in in_window)
candidates.append({
"start_time": round(t, 2),
"end_time": round(w_end, 2),
"word_count": word_count,
"title": f"Highlight at {int(t // 60)}m{int(t % 60):02d}s",
"description": "Auto-detected highlight segment",
"reason": "Fallback: highest word-density window",
"viral_score": word_count,
})
t += step
if not candidates:
return []
candidates.sort(key=lambda x: x["word_count"], reverse=True)
top = candidates[:3]
top.sort(key=lambda x: x["start_time"])
deduped = []
for c in top:
if deduped and c["start_time"] < deduped[-1]["end_time"] - 20:
continue
deduped.append(c)
logger.info(f"🔧 Fallback generated {len(deduped)} segment(s)")
return deduped
# ─────────────────────────────────────────────────────────────────────────────
# CORE ANALYSIS — single chunk
# ─────────────────────────────────────────────────────────────────────────────
def analyze_transcript(transcript, video_duration=None):
"""
Send one transcript chunk to Groq and return validated segments.
"""
prompt = f"""
You are a viral short-form video editor specializing in TikTok, Reels, and YouTube Shorts.
Your job is to find COMPLETE, PUBLISH-READY segments — not just funny lines or punchlines.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⛔ CRITICAL DURATION RULE — VIOLATIONS WILL BE DELETED
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- end_time − start_time MUST be between {MIN_DURATION} and {MAX_DURATION} seconds
- Segments shorter than {MIN_DURATION}s will be DELETED, not fixed
- "I found a funny line at 150s" is NOT a valid segment
- "I found a complete story from 95s to 220s that includes setup + peak + conclusion" IS valid
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ MANDATORY THINKING PROCESS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
For every candidate moment, follow these steps:
Step 1 — Find the INTERESTING MOMENT (the peak/punchline/reveal)
Step 2 — Scroll BACK in the transcript to find where the SETUP begins
(usually 30–90 seconds before the peak)
Ask: "When does the viewer first need to start watching to understand this?"
Step 3 — Scroll FORWARD to find where the NATURAL CONCLUSION ends
(usually 15–40 seconds after the peak)
Ask: "Is the story/joke/argument fully resolved? Is there a reaction?"
Step 4 — Check: end_time − start_time is between {MIN_DURATION} and {MAX_DURATION} seconds
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📌 CORRECT vs WRONG EXAMPLE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Funny moment at 150s:
✅ CORRECT: start=95s (setup), end=220s (reaction ends) → 125s duration
❌ WRONG: start=145s (just before punchline), end=165s → 20s duration
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📋 A COMPLETE SEGMENT MUST HAVE ALL OF THESE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. HOOK — something in the first 5s that makes viewers keep watching
2. SETUP — enough context so a stranger understands without watching anything before
3. BUILD-UP — tension, humor, conflict, or emotion growing
4. PAYOFF — the peak moment (punchline / reveal / climax)
5. CONCLUSION — resolution, reaction, or natural pause — NOT an abrupt cut
6. STANDALONE — makes complete sense on its own
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔢 OUTPUT FORMAT — raw JSON only, no markdown, no explanation
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
{{
"segments": [
{{
"start_time": <float — where the SETUP begins, NOT the funny moment>,
"end_time": <float — where the CONCLUSION ends, NOT just the punchline>,
"title": "<punchy YouTube Shorts title, max 60 chars>",
"description": "<1-2 sentences describing the full arc: setup → peak → conclusion>",
"reason": "<why this is complete and viral-worthy, max 25 words>"
}}
]
}}
If no complete story qualifies: {{"segments": []}}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
TRANSCRIPT:
{transcript}
"""
max_retries = 3
base_delay = 5
for attempt in range(max_retries):
try:
response = client.chat.completions.create(
model = MODEL_NAME,
messages = [
{
"role": "system",
"content": (
"You are a JSON-only assistant. "
"Output raw JSON only — no markdown, no code blocks, no explanation. "
f"Every segment MUST be between {MIN_DURATION} and {MAX_DURATION} seconds. "
"Short segments (punchlines without setup) will be DELETED — not fixed. "
"Always include: full setup before the peak + reaction/conclusion after. "
"Use the timestamps in the transcript to pick natural scene boundaries."
),
},
{"role": "user", "content": prompt},
],
temperature = 0.3,
)
content = response.choices[0].message.content.strip()
logger.info(f"🤖 AI Raw Response (first 300 chars): {content[:300]}...")
# Strip markdown fences if model ignored system prompt
if _FENCE_JSON in content:
content = content.split(_FENCE_JSON)[1].split(_FENCE)[0].strip()
elif _FENCE in content:
content = content.split(_FENCE)[1].split(_FENCE)[0].strip()
data = json.loads(content)
raw_segments = data.get("segments", [])
valid_segments = validate_segments(raw_segments, video_duration=video_duration)
# Fallback if AI returned nothing valid
if not valid_segments:
logger.warning("⚠️ AI returned 0 valid segments — trying fallback generator")
fallback = _fallback_segments_from_transcript(
transcript, video_duration or 0
)
valid_segments = validate_segments(fallback, video_duration=video_duration)
data["segments"] = valid_segments
content = json.dumps(data)
logger.info(
f"🤖 Parsed: {len(raw_segments)} raw → {len(valid_segments)} valid segments"
)
return {"content": content}
except Exception as e:
logger.error(f"❌ Error in Groq analysis (attempt {attempt + 1}): {e}")
if attempt < max_retries - 1:
wait = base_delay * (2 ** attempt)
logger.warning(f"⚠️ Retrying in {wait}s...")
time.sleep(wait)
logger.error("❌ All retry attempts failed.")
return {"content": '{"segments": []}'}
# ─────────────────────────────────────────────────────────────────────────────
# SCENE-AWARE CHUNKING
# ─────────────────────────────────────────────────────────────────────────────
def _sentence_chunk(transcript: str, max_tokens: int) -> list[str]:
"""
Fallback chunker when transcript has no timestamps.
Splits at sentence boundaries with overlap.
"""
sentences = transcript.replace('\n', ' ').split('. ')
chunks = []
current_chunk = []
current_length = 0
for sentence in sentences:
sentence_length = len(sentence.split())
if current_length + sentence_length > max_tokens and current_chunk:
chunks.append('. '.join(current_chunk) + '.')
current_chunk = current_chunk[-5:] + [sentence]
current_length = sum(len(s.split()) for s in current_chunk)
else:
current_chunk.append(sentence)
current_length += sentence_length
if current_chunk:
chunks.append('. '.join(current_chunk) + '.')
return chunks
def smart_chunk_transcript(transcript: str, max_tokens: int = 4000) -> list[str]:
"""
Split transcript into coherent chunks at SCENE BOUNDARIES.
Strategy:
- Parse all [start - end] timestamp lines
- Cut only when the chunk is full AND there is a natural gap (>3s) between
consecutive lines — a sign of a scene or topic change
- Keep 10-line overlap between chunks so cross-boundary stories aren't lost
- Falls back to sentence-level splitting if no timestamps found
"""
lines = []
for match in re.finditer(r'\[(\d+\.?\d*)\s*-\s*(\d+\.?\d*)\]\s*(.*)', transcript):
lines.append({
"start": float(match.group(1)),
"end": float(match.group(2)),
"text": match.group(3).strip(),
})
if not lines:
logger.info("📦 No timestamps found — using sentence chunker")
return _sentence_chunk(transcript, max_tokens)
chunks = []
current_lines = []
current_words = 0
for i, line in enumerate(lines):
current_lines.append(line)
current_words += len(line["text"].split())
is_last = (i + 1 == len(lines))
is_full = (current_words >= max_tokens)
# Detect scene break: gap > 3 seconds between this line and the next
next_gap = (
lines[i + 1]["start"] - line["end"]
if not is_last else 999
)
at_scene_break = next_gap > 3.0
if is_last or (is_full and at_scene_break):
chunk_text = "\n".join(
f"[{l['start']} - {l['end']}] {l['text']}"
for l in current_lines
)
chunks.append(chunk_text)
logger.info(
f"📦 Chunk {len(chunks)}: "
f"{current_lines[0]['start']:.0f}s – {current_lines[-1]['end']:.0f}s "
f"({current_words} words)"
)
# Overlap: keep last 10 lines for context continuity
current_lines = current_lines[-10:]
current_words = sum(len(l["text"].split()) for l in current_lines)
logger.info(f"📦 Split into {len(chunks)} scene-aware chunk(s)")
return chunks
# ─────────────────────────────────────────────────────────────────────────────
# MAIN ENTRY POINT
# ─────────────────────────────────────────────────────────────────────────────
def analyze_transcript_with_chunking(transcript, video_duration=None):
"""
Analyze transcript using scene-aware chunking for long content.
Processes each chunk separately and merges + deduplicates results.
"""
word_count = len(transcript.split())
if word_count > 3000:
logger.info(f"📦 Transcript is {word_count} words — using scene-aware chunking...")
chunks = smart_chunk_transcript(transcript, max_tokens=3000)
all_segments = []
for i, chunk in enumerate(chunks):
logger.info(f"🔄 Processing chunk {i + 1}/{len(chunks)}...")
result = analyze_transcript(chunk, video_duration=video_duration)
try:
data = json.loads(result["content"])
if "segments" in data:
all_segments.extend(data["segments"])
logger.info(
f"✅ Chunk {i + 1}: "
f"{len(data['segments'])} valid segment(s)"
)
except Exception as e:
logger.warning(f"⚠️ Failed to parse chunk {i + 1}: {e}")
continue
if all_segments:
# Deduplicate: two segments are the same if their start times
# round to within 10 seconds of each other
unique_segments = []
seen_times = set()
for seg in sorted(all_segments, key=lambda s: s.get("start_time", 0)):
time_key = round(seg.get("start_time", 0) / 10) * 10
if time_key not in seen_times:
unique_segments.append(seg)
seen_times.add(time_key)
logger.info(f"📊 Total unique viral segments found: {len(unique_segments)}")
return {"content": json.dumps({"segments": unique_segments[:10]})}
logger.warning("⚠️ No valid segments found across all chunks.")
return {"content": '{"segments": []}'}
# Short transcript — process directly without chunking
return analyze_transcript(transcript, video_duration=video_duration)
# ─────────────────────────────────────────────────────────────────────────────
# TESTING
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
test_transcript = """
[0.0 - 5.0] Welcome to today's video about productivity hacks that actually work.
[5.0 - 15.0] The first hack is something I call the 2-minute rule. If something takes less than 2 minutes, do it immediately.
[15.0 - 30.0] This simple rule has transformed my life. I used to procrastinate on small tasks, but now I handle them right away.
[30.0 - 45.0] The second hack is batching similar tasks together. Instead of checking email 20 times a day, I check it twice.
[45.0 - 60.0] This has saved me hours every week. I batch my emails, phone calls, and even errands.
[60.0 - 90.0] The third hack is the Pomodoro Technique. Work for 25 minutes, then take a 5-minute break.
[90.0 - 120.0] This technique helps me stay focused and avoid burnout. I get more done in less time.
[120.0 - 150.0] The fourth hack is to eliminate distractions completely. Turn off notifications, close tabs, and focus.
[150.0 - 180.0] When you eliminate distractions, your productivity skyrockets. I finish in 2 hours what used to take 6.
[180.0 - 210.0] The fifth and final hack is to review your day every evening. Spend 5 minutes planning tomorrow.
[210.0 - 240.0] This evening review changed everything for me. I wake up knowing exactly what to do and I never waste morning time figuring out priorities.
"""
logger.info("🧪 Testing AI Analysis...")
result = analyze_transcript_with_chunking(test_transcript, video_duration=240.0)
try:
data = json.loads(result["content"])
segments = data.get("segments", [])
logger.info(f"✅ Found {len(segments)} publish-ready segment(s):\n")
for i, seg in enumerate(segments):
duration = seg["end_time"] - seg["start_time"]
logger.info(
f"#{i + 1} [{seg['start_time']:.0f}s – {seg['end_time']:.0f}s] ({duration:.0f}s)\n"
f" 📌 Title: {seg.get('title', 'N/A')}\n"
f" 📝 Description: {seg.get('description', 'N/A')}\n"
f" 💡 Story Arc: {seg.get('reason', 'N/A')}\n"
)
except Exception as e:
logger.error(f"❌ Error parsing result: {e}")
logger.info(f"Raw result: {result}") |