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,335 @@
|
||||
import os
|
||||
import uuid
|
||||
import time
|
||||
import json
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def analyze_video_for_titles(api_key, video_path, transcript=None):
|
||||
"""
|
||||
Transcribes a video and uses Gemini to suggest viral YouTube titles.
|
||||
If transcript is provided, skips Whisper transcription.
|
||||
Returns: { "titles": [...], "transcript_summary": "...", "language": "...", "segments": [...], "video_duration": ... }
|
||||
"""
|
||||
if transcript is None:
|
||||
from main import transcribe_video
|
||||
print("🎬 [Thumbnail] Transcribing video...")
|
||||
transcript = transcribe_video(video_path)
|
||||
else:
|
||||
print("🎬 [Thumbnail] Using pre-computed transcript (Whisper already done)...")
|
||||
|
||||
print("📤 [Thumbnail] Uploading video to Gemini...")
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
file_upload = client.files.upload(file=video_path)
|
||||
while True:
|
||||
file_info = client.files.get(name=file_upload.name)
|
||||
if file_info.state == "ACTIVE":
|
||||
break
|
||||
elif file_info.state == "FAILED":
|
||||
raise Exception("Video processing failed by Gemini.")
|
||||
time.sleep(2)
|
||||
|
||||
prompt = f"""You are a YouTube title expert who creates viral, click-worthy titles.
|
||||
|
||||
Analyze this video and its transcript, then suggest 10 YouTube titles that would maximize CTR (click-through rate).
|
||||
|
||||
TRANSCRIPT:
|
||||
{transcript['text']}
|
||||
|
||||
RULES:
|
||||
- Titles must be under 70 characters
|
||||
- Use power words, curiosity gaps, and emotional triggers
|
||||
- Mix styles: how-to, listicle, story-driven, controversial, question-based
|
||||
- Make them specific to the actual content, not generic
|
||||
- Include numbers where appropriate
|
||||
- Consider the language of the video (detected: {transcript['language']})
|
||||
- Titles should be in the SAME LANGUAGE as the video transcript
|
||||
|
||||
Also provide a brief summary of the video content (2-3 sentences).
|
||||
|
||||
After generating all 10 titles, pick the TOP 2 you most recommend and explain concisely WHY (CTR potential, emotional hook, uniqueness, etc.). Reference them by their 0-based index in the titles array.
|
||||
|
||||
OUTPUT JSON:
|
||||
{{
|
||||
"titles": ["title1", "title2", ...],
|
||||
"transcript_summary": "Brief summary of the video content...",
|
||||
"language": "{transcript['language']}",
|
||||
"recommended": [
|
||||
{{"index": 0, "reason": "Why this title is best..."}},
|
||||
{{"index": 3, "reason": "Why this title is second best..."}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
print("🤖 [Thumbnail] Asking Gemini for title suggestions...")
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents=[file_upload, prompt],
|
||||
config=types.GenerateContentConfig(
|
||||
response_mime_type="application/json"
|
||||
)
|
||||
)
|
||||
|
||||
# Extract segments and duration from transcript for later use
|
||||
segments = transcript.get("segments", [])
|
||||
video_duration = segments[-1]["end"] if segments else 0
|
||||
|
||||
try:
|
||||
text = response.text.strip()
|
||||
if text.startswith("```json"):
|
||||
text = text[7:]
|
||||
if text.startswith("```"):
|
||||
text = text[3:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
text = text.strip()
|
||||
|
||||
start_idx = text.find('{')
|
||||
end_idx = text.rfind('}')
|
||||
if start_idx != -1 and end_idx != -1:
|
||||
text = text[start_idx:end_idx + 1]
|
||||
|
||||
result = json.loads(text)
|
||||
result["transcript_summary"] = result.get("transcript_summary", "")
|
||||
result["language"] = result.get("language", transcript["language"])
|
||||
result["segments"] = segments
|
||||
result["video_duration"] = video_duration
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
print(f"❌ [Thumbnail] Failed to parse titles JSON: {response.text}")
|
||||
return {
|
||||
"titles": ["Could not generate titles - please try again"],
|
||||
"transcript_summary": transcript["text"][:500],
|
||||
"language": transcript["language"],
|
||||
"segments": segments,
|
||||
"video_duration": video_duration
|
||||
}
|
||||
|
||||
|
||||
def refine_titles(api_key, context, user_message, conversation_history=None):
|
||||
"""
|
||||
Takes video context + user feedback and returns refined title suggestions.
|
||||
"""
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
history_text = ""
|
||||
if conversation_history:
|
||||
for msg in conversation_history:
|
||||
role = msg.get("role", "user")
|
||||
history_text += f"\n{role.upper()}: {msg['content']}"
|
||||
|
||||
prompt = f"""You are a YouTube title expert. Based on the video context and the user's feedback, suggest 8 new refined YouTube titles.
|
||||
|
||||
VIDEO CONTEXT:
|
||||
{context}
|
||||
|
||||
CONVERSATION HISTORY:{history_text}
|
||||
|
||||
USER'S NEW REQUEST:
|
||||
{user_message}
|
||||
|
||||
RULES:
|
||||
- Titles must be under 70 characters
|
||||
- Incorporate the user's feedback/direction
|
||||
- Keep titles viral and click-worthy
|
||||
- If the user asks for a specific style, follow it
|
||||
- Titles should be in the same language as the original content
|
||||
|
||||
OUTPUT JSON:
|
||||
{{
|
||||
"titles": ["title1", "title2", ...]
|
||||
}}"""
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents=[prompt],
|
||||
config=types.GenerateContentConfig(
|
||||
response_mime_type="application/json"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
text = response.text.strip()
|
||||
if text.startswith("```json"):
|
||||
text = text[7:]
|
||||
if text.startswith("```"):
|
||||
text = text[3:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
text = text.strip()
|
||||
|
||||
start_idx = text.find('{')
|
||||
end_idx = text.rfind('}')
|
||||
if start_idx != -1 and end_idx != -1:
|
||||
text = text[start_idx:end_idx + 1]
|
||||
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
print(f"❌ [Thumbnail] Failed to parse refined titles: {response.text}")
|
||||
return {"titles": ["Could not refine titles - please try again"]}
|
||||
|
||||
|
||||
def generate_thumbnail(api_key, title, session_id, face_image_path=None, bg_image_path=None, extra_prompt="", count=3, video_context=""):
|
||||
"""
|
||||
Generates YouTube thumbnails using Gemini image generation.
|
||||
Returns list of saved image paths (relative URLs).
|
||||
"""
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
output_dir = os.path.join("output", "thumbnails", session_id)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
prompt_parts = []
|
||||
|
||||
# Add face image if provided
|
||||
if face_image_path and os.path.exists(face_image_path):
|
||||
face_img = Image.open(face_image_path)
|
||||
prompt_parts.append(face_img)
|
||||
|
||||
# Add background image if provided
|
||||
if bg_image_path and os.path.exists(bg_image_path):
|
||||
bg_img = Image.open(bg_image_path)
|
||||
prompt_parts.append(bg_img)
|
||||
|
||||
# Build video context block
|
||||
context_block = ""
|
||||
if video_context:
|
||||
context_block = f"""
|
||||
VIDEO CONTEXT (use this to understand the video and design a relevant thumbnail):
|
||||
{video_context}
|
||||
"""
|
||||
|
||||
# Build extra instructions block (high priority)
|
||||
extra_block = ""
|
||||
if extra_prompt:
|
||||
extra_block = f"""
|
||||
⚠️ MANDATORY USER INSTRUCTIONS (MUST follow these exactly — they override any default behavior):
|
||||
{extra_prompt}
|
||||
"""
|
||||
|
||||
text_prompt = f"""Generate a professional, eye-catching YouTube thumbnail image.
|
||||
|
||||
VIDEO TITLE (for reference — do NOT put the full title on the thumbnail): "{title}"
|
||||
{context_block}
|
||||
TEXT ON THE THUMBNAIL:
|
||||
- Based on the title AND the video context, create a SHORT visual hook: 1 to 5 words maximum
|
||||
- It should capture the core emotion, surprise, or promise of the video
|
||||
- The thumbnail text should COMPLEMENT the YouTube title (which appears below), not repeat it
|
||||
- Examples: "$10K EN 30 DÍAS", "ESTO FUNCIONA", "NO LO SABÍAS", "GRATIS 🔥"
|
||||
- Use ALL CAPS for maximum impact, split into 2-3 lines
|
||||
{extra_block}
|
||||
DESIGN REQUIREMENTS:
|
||||
- The text MUST be large, bold, and high-contrast (readable at small sizes)
|
||||
- Use vibrant, eye-catching colors that match the video's mood
|
||||
- Professional YouTube thumbnail aesthetic
|
||||
- Clean composition — text and face/subject as clear focal points
|
||||
- NO clutter, NO small text, NO watermarks"""
|
||||
|
||||
if face_image_path and os.path.exists(face_image_path):
|
||||
text_prompt += "\n- Include the provided face/person prominently with an exaggerated expression (surprise, excitement, shock)"
|
||||
|
||||
if bg_image_path and os.path.exists(bg_image_path):
|
||||
text_prompt += "\n- Use the provided background image as the base/backdrop"
|
||||
|
||||
prompt_parts.append(text_prompt)
|
||||
|
||||
thumbnails = []
|
||||
last_error = None
|
||||
for i in range(count):
|
||||
print(f"🎨 [Thumbnail] Generating thumbnail {i + 1}/{count}...")
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model="gemini-3.1-flash-image-preview",
|
||||
contents=prompt_parts,
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=["TEXT", "IMAGE"],
|
||||
image_config=types.ImageConfig(
|
||||
aspect_ratio="16:9",
|
||||
image_size="2K"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
for part in response.parts:
|
||||
if part.text is not None:
|
||||
print(f"📝 [Thumbnail] Gemini text: {part.text}")
|
||||
elif image := part.as_image():
|
||||
filename = f"thumb_{i + 1}.jpg"
|
||||
filepath = os.path.join(output_dir, filename)
|
||||
image.save(filepath)
|
||||
thumbnails.append(f"/thumbnails/{session_id}/{filename}")
|
||||
print(f"✅ [Thumbnail] Saved: {filepath}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
print(f"❌ [Thumbnail] Generation {i + 1} failed: {e}")
|
||||
|
||||
if not thumbnails and last_error:
|
||||
raise RuntimeError(f"All thumbnail generations failed. Last error: {last_error}")
|
||||
|
||||
return thumbnails
|
||||
|
||||
|
||||
def generate_youtube_description(api_key, title, transcript_segments, language, video_duration):
|
||||
"""
|
||||
Uses Gemini to generate a YouTube description with chapter markers from transcript segments.
|
||||
Returns: { "description": "full description text with chapters" }
|
||||
"""
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
# Format segments for the prompt
|
||||
formatted_segments = []
|
||||
for seg in transcript_segments:
|
||||
start = seg.get("start", 0)
|
||||
mins = int(start // 60)
|
||||
secs = int(start % 60)
|
||||
timestamp = f"{mins}:{secs:02d}"
|
||||
formatted_segments.append(f"[{timestamp}] {seg.get('text', '').strip()}")
|
||||
|
||||
segments_text = "\n".join(formatted_segments)
|
||||
|
||||
# Format total duration
|
||||
dur_mins = int(video_duration // 60)
|
||||
dur_secs = int(video_duration % 60)
|
||||
duration_str = f"{dur_mins}:{dur_secs:02d}"
|
||||
|
||||
prompt = f"""You are a YouTube SEO expert. Generate a complete YouTube video description for the following video.
|
||||
|
||||
VIDEO TITLE: "{title}"
|
||||
VIDEO LANGUAGE: {language}
|
||||
VIDEO DURATION: {duration_str}
|
||||
|
||||
TRANSCRIPT WITH TIMESTAMPS:
|
||||
{segments_text}
|
||||
|
||||
REQUIREMENTS:
|
||||
1. Write the description in the SAME LANGUAGE as the video ({language})
|
||||
2. Start with a compelling 2-3 sentence summary/hook
|
||||
3. Add relevant CTAs (subscribe, like, comment)
|
||||
4. Generate YouTube CHAPTERS based on the transcript timestamps:
|
||||
- First chapter MUST start at 0:00
|
||||
- Minimum 3 chapters, each at least 10 seconds apart
|
||||
- Chapter titles should be concise and descriptive
|
||||
- Format: 0:00 Chapter Title
|
||||
- Place chapters in their own section with a blank line before and after
|
||||
5. Add 5-10 relevant hashtags at the end
|
||||
6. Keep the total description under 5000 characters
|
||||
|
||||
OUTPUT: Return ONLY the description text (no JSON wrapper, no markdown code blocks). The description should be ready to paste directly into YouTube."""
|
||||
|
||||
print("🤖 [Thumbnail] Generating YouTube description with chapters...")
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents=[prompt],
|
||||
)
|
||||
|
||||
description = response.text.strip()
|
||||
# Clean up any accidental markdown wrappers
|
||||
if description.startswith("```"):
|
||||
lines = description.split("\n")
|
||||
description = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
|
||||
|
||||
return {"description": description}
|
||||
Reference in New Issue
Block a user