Added YouTube Creator scene building flow documentation

This commit is contained in:
ajaysi
2025-12-21 17:15:23 +05:30
parent 1d745c9bc8
commit 59913bffa9
51 changed files with 7478 additions and 631 deletions

View File

@@ -85,6 +85,7 @@ def edit_image(
from services.subscription.preflight_validator import validate_image_editing_operations
from fastapi import HTTPException
logger.info(f"[Image Editing] 🔍 Starting pre-flight validation for user_id={user_id}")
db = next(get_db())
try:
pricing_service = PricingService(db)
@@ -93,14 +94,15 @@ def edit_image(
pricing_service=pricing_service,
user_id=user_id
)
logger.info(f"[Image Editing] ✅ Pre-flight validation passed for user_id={user_id} - proceeding with image editing")
except HTTPException as http_ex:
# Re-raise immediately - don't proceed with API call
logger.error(f"[Image Editing] ❌ Pre-flight validation failed - blocking API call")
logger.error(f"[Image Editing] ❌ Pre-flight validation failed for user_id={user_id} - blocking API call: {http_ex.detail}")
raise
finally:
db.close()
logger.info(f"[Image Editing] ✅ Pre-flight validation passed - proceeding with image editing")
else:
logger.warning(f"[Image Editing] ⚠️ No user_id provided - skipping pre-flight validation (this should not happen in production)")
# Validate input
if not input_image_bytes:

View File

@@ -9,6 +9,7 @@ from .image_generation import (
HuggingFaceImageProvider,
GeminiImageProvider,
StabilityImageProvider,
WaveSpeedImageProvider,
)
from utils.logger_utils import get_service_logger
@@ -26,6 +27,8 @@ def _select_provider(explicit: Optional[str]) -> str:
return "huggingface"
if os.getenv("STABILITY_API_KEY"):
return "stability"
if os.getenv("WAVESPEED_API_KEY"):
return "wavespeed"
# Fallback to huggingface to enable a path if configured
return "huggingface"
@@ -37,6 +40,8 @@ def _get_provider(provider_name: str):
return GeminiImageProvider()
if provider_name == "stability":
return StabilityImageProvider()
if provider_name == "wavespeed":
return WaveSpeedImageProvider()
raise ValueError(f"Unknown image provider: {provider_name}")
@@ -56,6 +61,7 @@ def generate_image(prompt: str, options: Optional[Dict[str, Any]] = None, user_i
from services.subscription.preflight_validator import validate_image_generation_operations
from fastapi import HTTPException
logger.info(f"[Image Generation] 🔍 Starting pre-flight validation for user_id={user_id}")
db = next(get_db())
try:
pricing_service = PricingService(db)
@@ -64,14 +70,15 @@ def generate_image(prompt: str, options: Optional[Dict[str, Any]] = None, user_i
pricing_service=pricing_service,
user_id=user_id
)
logger.info(f"[Image Generation] ✅ Pre-flight validation passed for user_id={user_id} - proceeding with image generation")
except HTTPException as http_ex:
# Re-raise immediately - don't proceed with API call
logger.error(f"[Image Generation] ❌ Pre-flight validation failed - blocking API call")
logger.error(f"[Image Generation] ❌ Pre-flight validation failed for user_id={user_id} - blocking API call: {http_ex.detail}")
raise
finally:
db.close()
logger.info(f"[Image Generation] ✅ Pre-flight validation passed - proceeding with image generation")
else:
logger.warning(f"[Image Generation] ⚠️ No user_id provided - skipping pre-flight validation (this should not happen in production)")
opts = options or {}
provider_name = _select_provider(opts.get("provider"))
@@ -96,6 +103,10 @@ def generate_image(prompt: str, options: Optional[Dict[str, Any]] = None, user_i
if provider_name == "huggingface" and not image_options.model:
# Provide a sensible default HF model if none specified
image_options.model = "black-forest-labs/FLUX.1-Krea-dev"
if provider_name == "wavespeed" and not image_options.model:
# Provide a sensible default WaveSpeed model if none specified
image_options.model = "ideogram-v3-turbo"
logger.info("Generating image via provider=%s model=%s", provider_name, image_options.model)
provider = _get_provider(provider_name)

View File

@@ -336,6 +336,8 @@ class StoryVideoGenerationService:
# Match duration to audio if needed
if video_clip.duration > audio_duration:
video_clip = video_clip.subclip(0, audio_duration)
# Re-attach audio after subclip (subclip loses audio)
video_clip = video_clip.with_audio(audio_clip)
elif video_clip.duration < audio_duration:
# Loop the video if it's shorter than audio
loops_needed = int(audio_duration / video_clip.duration) + 1

View File

@@ -177,7 +177,7 @@ class WaveSpeedClient:
f"[WaveSpeed] Too many polling errors ({consecutive_errors}) for {prediction_id}, "
f"status_code={status_code}. Giving up."
)
raise HTTPException(status_code=exc.status_code, detail=detail) from exc
raise HTTPException(status_code=exc.status_code, detail=detail) from exc
backoff = min(30.0, interval_seconds * (2 ** (consecutive_errors - 1)))
logger.warning(
@@ -464,16 +464,17 @@ class WaveSpeedClient:
response_json = response.json()
data = response_json.get("data") or response_json
# Check status - if "created" or "processing", we need to poll even in sync mode
status = data.get("status", "").lower()
outputs = data.get("outputs") or []
prediction_id = data.get("id")
# Handle sync mode - result should be directly in outputs
# BUT: If status is "created" or "processing" with no outputs, fall back to polling
if enable_sync_mode:
outputs = data.get("outputs") or []
if not outputs:
logger.error(f"[WaveSpeed] No outputs in sync mode response: {response.text}")
raise HTTPException(
status_code=502,
detail="WaveSpeed image generator returned no outputs",
)
# If we have outputs and status is "completed", use them directly
if outputs and status == "completed":
logger.info(f"[WaveSpeed] Got immediate results from sync mode (status: {status})")
# Extract image URL from outputs
image_url = None
if isinstance(outputs, list) and len(outputs) > 0:
@@ -504,16 +505,30 @@ class WaveSpeedClient:
detail="Failed to fetch generated image from WaveSpeed URL",
)
# Async mode - poll for result
prediction_id = data.get("id")
# Sync mode returned "created" or "processing" status - need to poll
if not prediction_id:
logger.error(f"[WaveSpeed] No prediction ID in async response: {response.text}")
logger.error(f"[WaveSpeed] Sync mode returned status '{status}' but no prediction ID: {response.text}")
raise HTTPException(
status_code=502,
detail="WaveSpeed response missing prediction id for async mode",
detail="WaveSpeed sync mode returned async response without prediction ID",
)
logger.info(
f"[WaveSpeed] Sync mode returned status '{status}' with no outputs. "
f"Falling back to polling (prediction_id: {prediction_id})"
)
# Fall through to async polling logic below
# Async mode OR sync mode that returned "created"/"processing" - poll for result
if not prediction_id:
logger.error(f"[WaveSpeed] No prediction ID in response: {response.text}")
raise HTTPException(
status_code=502,
detail="WaveSpeed response missing prediction id",
)
# Poll for result
# Poll for result (use longer timeout for image generation)
logger.info(f"[WaveSpeed] Polling for image generation result (prediction_id: {prediction_id}, status: {status})")
result = self.poll_until_complete(prediction_id, timeout_seconds=240, interval_seconds=1.0)
outputs = result.get("outputs") or []

View File

@@ -2,17 +2,95 @@
YouTube Video Planner Service
Generates video plans, outlines, and insights using AI with persona integration.
Supports optional Exa research for enhanced, data-driven plans.
"""
from typing import Dict, Any, Optional, List
from loguru import logger
from fastapi import HTTPException
import os
from services.llm_providers.main_text_generation import llm_text_gen
from utils.logger_utils import get_service_logger
logger = get_service_logger("youtube.planner")
# Video type configurations for optimization
VIDEO_TYPE_CONFIGS = {
"tutorial": {
"hook_strategy": "Problem statement or quick preview of solution",
"structure": "Problem → Steps → Result → Key Takeaways",
"visual_style": "Clean, instructional, screen-recordings or clear demonstrations",
"tone": "Clear, patient, instructional",
"optimal_scenes": "2-6 scenes showing sequential steps",
"avatar_style": "Approachable instructor, professional yet friendly",
"cta_focus": "Subscribe for more tutorials, try it yourself"
},
"review": {
"hook_strategy": "Product reveal or strong opinion statement",
"structure": "Hook → Overview → Pros/Cons → Verdict → CTA",
"visual_style": "Product-focused, close-ups, comparison shots",
"tone": "Honest, engaging, opinionated but fair",
"optimal_scenes": "4-8 scenes covering different aspects",
"avatar_style": "Trustworthy reviewer, confident, credible",
"cta_focus": "Check links in description, subscribe for reviews"
},
"educational": {
"hook_strategy": "Intriguing question or surprising fact",
"structure": "Question → Explanation → Examples → Conclusion",
"visual_style": "Illustrative, concept visualization, animations",
"tone": "Authoritative yet accessible, engaging",
"optimal_scenes": "3-10 scenes breaking down concepts",
"avatar_style": "Knowledgeable educator, professional, warm",
"cta_focus": "Learn more, subscribe for educational content"
},
"entertainment": {
"hook_strategy": "Grab attention immediately with energy/humor",
"structure": "Hook → Setup → Payoff → Share/Subscribe",
"visual_style": "Dynamic, energetic, varied angles, transitions",
"tone": "High energy, funny, engaging, personality-driven",
"optimal_scenes": "3-8 scenes with varied pacing",
"avatar_style": "Energetic creator, expressive, relatable",
"cta_focus": "Like, share, subscribe for more fun content"
},
"vlog": {
"hook_strategy": "Preview of day/event or personal moment",
"structure": "Introduction → Journey/Experience → Reflection → CTA",
"visual_style": "Natural, personal, authentic moments",
"tone": "Conversational, authentic, relatable",
"optimal_scenes": "5-15 scenes following narrative",
"avatar_style": "Authentic person, approachable, real",
"cta_focus": "Follow my journey, subscribe for daily updates"
},
"product_demo": {
"hook_strategy": "Product benefit or transformation",
"structure": "Benefit → Features → Use Cases → CTA",
"visual_style": "Product-focused, polished, commercial quality",
"tone": "Enthusiastic, persuasive, benefit-focused",
"optimal_scenes": "3-7 scenes highlighting features",
"avatar_style": "Professional presenter, polished, confident",
"cta_focus": "Get it now, learn more, special offer"
},
"reaction": {
"hook_strategy": "Preview of reaction or content being reacted to",
"structure": "Setup → Reaction → Commentary → CTA",
"visual_style": "Split-screen or picture-in-picture, expressive",
"tone": "Authentic reactions, engaging commentary",
"optimal_scenes": "4-10 scenes with reactions",
"avatar_style": "Expressive creator, authentic reactions",
"cta_focus": "Watch full video, subscribe for reactions"
},
"storytelling": {
"hook_strategy": "Intriguing opening or compelling question",
"structure": "Hook → Setup → Conflict → Resolution → CTA",
"visual_style": "Cinematic, narrative-driven, emotional",
"tone": "Engaging, immersive, story-focused",
"optimal_scenes": "6-15 scenes following narrative arc",
"avatar_style": "Storyteller, warm, engaging narrator",
"cta_focus": "Subscribe for more stories, share your thoughts"
}
}
class YouTubePlannerService:
"""Service for planning YouTube videos with AI assistance."""
@@ -21,16 +99,21 @@ class YouTubePlannerService:
"""Initialize the planner service."""
logger.info("[YouTubePlanner] Service initialized")
def generate_video_plan(
async def generate_video_plan(
self,
user_idea: str,
duration_type: str, # "shorts", "medium", "long"
video_type: Optional[str] = None, # "tutorial", "review", etc.
target_audience: Optional[str] = None,
video_goal: Optional[str] = None,
brand_style: Optional[str] = None,
persona_data: Optional[Dict[str, Any]] = None,
reference_image_description: Optional[str] = None,
source_content_id: Optional[str] = None, # For blog/story conversion
source_content_type: Optional[str] = None, # "blog", "story"
user_id: str = None,
include_scenes: bool = False, # For shorts: combine plan + scenes in one call
enable_research: bool = True, # Always enable research by default for enhanced plans
) -> Dict[str, Any]:
"""
Generate a comprehensive video plan from user input.
@@ -38,6 +121,10 @@ class YouTubePlannerService:
Args:
user_idea: User's video idea or topic
duration_type: "shorts" (≤60s), "medium" (1-4min), "long" (4-10min)
video_type: Optional video format type (tutorial, review, etc.)
target_audience: Optional target audience description
video_goal: Optional primary goal of the video
brand_style: Optional brand aesthetic preferences
persona_data: Optional persona data for tone/style
reference_image_description: Optional description of reference image
source_content_id: Optional ID of source content (blog/story)
@@ -50,9 +137,14 @@ class YouTubePlannerService:
try:
logger.info(
f"[YouTubePlanner] Generating plan: idea={user_idea[:50]}..., "
f"duration={duration_type}, user={user_id}"
f"duration={duration_type}, video_type={video_type}, user={user_id}"
)
# Get video type config
video_type_config = {}
if video_type and video_type in VIDEO_TYPE_CONFIGS:
video_type_config = VIDEO_TYPE_CONFIGS[video_type]
# Build persona context
persona_context = self._build_persona_context(persona_data)
@@ -78,43 +170,108 @@ class YouTubePlannerService:
- Use this as visual inspiration for the video
"""
# Generate smart defaults based on video type if selected
# When video_type is selected, use its config for defaults; otherwise use user inputs or generic defaults
if video_type_config:
default_tone = video_type_config.get('tone', 'Professional and engaging')
default_visual_style = video_type_config.get('visual_style', 'Professional and engaging')
default_goal = video_goal or f"Create engaging {video_type} content"
default_audience = target_audience or f"Viewers interested in {video_type} content"
else:
# No video type selected - use user inputs or generic defaults
default_tone = 'Professional and engaging'
default_visual_style = 'Professional and engaging'
default_goal = video_goal or 'Engage and inform viewers'
default_audience = target_audience or 'General YouTube audience'
# Perform Exa research if enabled (after defaults are set)
research_context = ""
research_sources = []
research_enabled = False
if enable_research:
logger.info(f"[YouTubePlanner] 🔍 Starting Exa research for plan generation (idea: {user_idea[:50]}...)")
research_enabled = True
try:
research_context, research_sources = await self._perform_exa_research(
user_idea=user_idea,
video_type=video_type,
target_audience=default_audience,
user_id=user_id
)
if research_sources:
logger.info(
f"[YouTubePlanner] ✅ Exa research completed successfully: "
f"{len(research_sources)} sources found. Research context length: {len(research_context)} chars"
)
else:
logger.warning(f"[YouTubePlanner] ⚠️ Exa research completed but no sources returned")
except HTTPException as http_ex:
# Subscription limit exceeded or other HTTP errors
error_detail = http_ex.detail
if isinstance(error_detail, dict):
error_msg = error_detail.get("message", error_detail.get("error", str(http_ex)))
else:
error_msg = str(error_detail)
logger.warning(
f"[YouTubePlanner] ⚠️ Exa research skipped due to subscription limits or error: {error_msg} "
f"(status={http_ex.status_code}). Continuing without research."
)
# Continue without research - non-critical failure
except Exception as e:
error_msg = str(e)
logger.warning(
f"[YouTubePlanner] ⚠️ Exa research failed (non-critical): {error_msg}. "
f"Continuing without research."
)
# Continue without research - non-critical failure
else:
logger.info(f"[YouTubePlanner] Exa research disabled for this plan generation")
# Generate comprehensive video plan
planning_prompt = f"""You are an expert YouTube content strategist. Create a comprehensive video plan based on the user's idea.
video_type_context = ""
if video_type_config:
video_type_context = f"""
**Video Type: {video_type}**
Follow these guidelines:
- Structure: {video_type_config.get('structure', '')}
- Hook: {video_type_config.get('hook_strategy', '')}
- Visual: {video_type_config.get('visual_style', '')}
- Tone: {video_type_config.get('tone', '')}
- CTA: {video_type_config.get('cta_focus', '')}
"""
planning_prompt = f"""Create a YouTube video plan for: "{user_idea}"
**User's Video Idea:**
{user_idea}
**Video Format:** {video_type or 'General'} | **Duration:** {duration_type} ({duration_context['target_seconds']}s target)
**Audience:** {default_audience}
**Goal:** {default_goal}
**Style:** {brand_style or default_visual_style}
**Video Duration Type:**
{duration_type} ({duration_context['description']})
{video_type_context}
**Duration Guidelines:**
- Target length: {duration_context['target_seconds']} seconds
- Hook duration: {duration_context['hook_seconds']} seconds
- Main content: {duration_context['main_seconds']} seconds
- CTA duration: {duration_context['cta_seconds']} seconds
- Maximum scenes: {duration_context['max_scenes']} (for shorts, keep 2-4 scenes total)
**Constraints:**
- Duration: {duration_context['target_seconds']}s (Hook: {duration_context['hook_seconds']}s, Main: {duration_context['main_seconds']}s, CTA: {duration_context['cta_seconds']}s)
- Max scenes: {duration_context['max_scenes']}
{persona_context}
{persona_context if persona_data else ""}
{source_context if source_content_id else ""}
{image_context if reference_image_description else ""}
{research_context if research_context else ""}
{source_context}
**Generate a plan with:**
1. **Video Summary**: 2-3 sentences capturing the essence
2. **Target Audience**: {f"Match: {target_audience}" if target_audience else f"Infer from video idea and {video_type or 'content type'}"}
3. **Video Goal**: {f"Align with: {video_goal}" if video_goal else f"Infer appropriate goal for {video_type or 'this'} content"}
4. **Key Message**: Single memorable takeaway
5. **Hook Strategy**: Engaging opening for first {duration_context['hook_seconds']}s{f" ({video_type_config.get('hook_strategy', '')})" if video_type_config else ""}
6. **Content Outline**: 3-5 sections totaling {duration_context['target_seconds']}s{f" following: {video_type_config.get('structure', '')}" if video_type_config else ""}
7. **Call-to-Action**: Actionable CTA{f" ({video_type_config.get('cta_focus', '')})" if video_type_config else ""}
8. **Visual Style**: Match {brand_style or default_visual_style}
9. **Tone**: {default_tone}
10. **SEO Keywords**: 5-7 relevant terms based on video idea
11. **Avatar Recommendations**: {f"{video_type_config.get('avatar_style', '')} " if video_type_config else ""}matching audience and style
{image_context}
**Your Task:**
Create a detailed video plan that includes:
1. **Video Summary**: A 2-3 sentence overview of what the video will cover
2. **Target Audience**: Who this video is for
3. **Video Goal**: Primary objective (educate, entertain, sell, inspire, etc.)
4. **Key Message**: The main takeaway viewers should remember
5. **Hook Strategy**: Attention-grabbing opening (first {duration_context['hook_seconds']} seconds)
6. **Content Outline**: High-level structure with 3-5 main sections
7. **Call-to-Action**: Clear CTA that fits the video goal
8. **Visual Style**: Recommended visual approach (cinematic, tutorial, vlog, etc.)
9. **Tone**: Recommended tone (professional, casual, energetic, etc.)
10. **SEO Keywords**: 5-7 relevant keywords for YouTube SEO
**Format your response as JSON:**
**Response Format (JSON):**
{{
"video_summary": "...",
"target_audience": "...",
@@ -122,22 +279,27 @@ Create a detailed video plan that includes:
"key_message": "...",
"hook_strategy": "...",
"content_outline": [
{{"section": "Section 1", "description": "...", "duration_estimate": 30}},
{{"section": "Section 2", "description": "...", "duration_estimate": 45}}
{{"section": "...", "description": "...", "duration_estimate": 30}},
{{"section": "...", "description": "...", "duration_estimate": 45}}
],
"call_to_action": "...",
"visual_style": "...",
"tone": "...",
"seo_keywords": ["keyword1", "keyword2", ...]
"seo_keywords": ["keyword1", "keyword2", ...],
"avatar_recommendations": {{
"description": "...",
"style": "...",
"energy": "..."
}}
}}
Make sure the content outline fits within the {duration_type} duration constraints.
**Critical:** Content outline durations must sum to {duration_context['target_seconds']}s (±20%).
"""
system_prompt = (
"You are an expert YouTube content strategist specializing in creating "
"engaging, well-structured video plans. Your plans are data-driven, "
"audience-focused, and optimized for YouTube's algorithm."
"You are an expert YouTube content strategist. Create clear, actionable video plans "
"that are optimized for the specified video type and audience. Focus on accuracy and "
"specificity - these plans will be used to generate actual video content."
)
# For shorts, combine plan + scenes in one call to save API calls
@@ -157,8 +319,8 @@ Create detailed scenes (up to {duration_context['max_scenes']} scenes) that incl
**Scene Format:**
Each scene should be detailed enough for video generation. Total duration must fit within {duration_context['target_seconds']} seconds.
**Update JSON structure to include "scenes" array:**
Add a "scenes" field with the complete scene breakdown.
**Update JSON structure to include "scenes" array and "avatar_recommendations":**
Add a "scenes" field with the complete scene breakdown, and include "avatar_recommendations" with ideal presenter appearance, style, and energy.
"""
json_struct = {
@@ -208,12 +370,20 @@ Add a "scenes" field with the complete scene breakdown.
"duration_estimate", "emphasis"
]
}
},
"avatar_recommendations": {
"type": "object",
"properties": {
"description": {"type": "string"},
"style": {"type": "string"},
"energy": {"type": "string"}
}
}
},
"required": [
"video_summary", "target_audience", "video_goal", "key_message",
"hook_strategy", "content_outline", "call_to_action",
"visual_style", "tone", "seo_keywords", "scenes"
"visual_style", "tone", "seo_keywords", "scenes", "avatar_recommendations"
]
}
else:
@@ -242,16 +412,26 @@ Add a "scenes" field with the complete scene breakdown.
"seo_keywords": {
"type": "array",
"items": {"type": "string"}
},
"avatar_recommendations": {
"type": "object",
"properties": {
"description": {"type": "string"},
"style": {"type": "string"},
"energy": {"type": "string"}
}
}
},
"required": [
"video_summary", "target_audience", "video_goal", "key_message",
"hook_strategy", "content_outline", "call_to_action",
"visual_style", "tone", "seo_keywords"
"visual_style", "tone", "seo_keywords", "avatar_recommendations"
]
}
# Generate plan using LLM
# Generate plan using LLM with structured JSON response
# llm_text_gen handles subscription checks and provider selection automatically
# json_struct ensures deterministic structured response (returns dict, not string)
response = llm_text_gen(
prompt=planning_prompt,
system_prompt=system_prompt,
@@ -259,34 +439,89 @@ Add a "scenes" field with the complete scene breakdown.
json_struct=json_struct
)
# Parse response (handle both dict and JSON string)
# Parse response (structured responses return dict, text responses return string)
if isinstance(response, dict):
plan_data = response
else:
import json
plan_data = json.loads(response)
try:
plan_data = json.loads(response)
except json.JSONDecodeError as e:
logger.error(f"[YouTubePlanner] Failed to parse JSON response: {e}")
logger.debug(f"[YouTubePlanner] Raw response: {response[:500]}")
raise HTTPException(
status_code=500,
detail="Failed to parse video plan response. Please try again."
)
# Validate and enhance plan quality
plan_data = self._validate_and_enhance_plan(
plan_data, duration_context, video_type, video_type_config
)
# Add metadata
plan_data["duration_type"] = duration_type
plan_data["duration_metadata"] = duration_context
plan_data["user_idea"] = user_idea
# If scenes were included, mark them for scene builder
if include_scenes and duration_type == "shorts" and "scenes" in plan_data:
plan_data["_scenes_included"] = True
logger.info(
f"[YouTubePlanner] ✅ Plan + {len(plan_data.get('scenes', []))} scenes "
f"generated in 1 AI call (optimized for shorts)"
)
# Add research metadata to plan
plan_data["research_enabled"] = research_enabled
if research_sources:
plan_data["research_sources"] = research_sources
plan_data["research_sources_count"] = len(research_sources)
else:
if include_scenes and duration_type == "shorts":
plan_data["research_sources"] = []
plan_data["research_sources_count"] = 0
# Log research status in plan metadata for debugging
if research_enabled:
logger.info(
f"[YouTubePlanner] 📊 Plan metadata: research_enabled=True, "
f"research_sources_count={plan_data.get('research_sources_count', 0)}, "
f"research_context_length={len(research_context)} chars"
)
# Validate and process scenes if included (for shorts)
if include_scenes and duration_type == "shorts":
if "scenes" in plan_data and plan_data["scenes"]:
# Validate scenes count and duration
scenes = plan_data["scenes"]
scene_count = len(scenes)
total_scene_duration = sum(
scene.get("duration_estimate", 0) for scene in scenes
)
max_scenes = duration_context["max_scenes"]
target_duration = duration_context["target_seconds"]
if scene_count > max_scenes:
logger.warning(
f"[YouTubePlanner] Scene count ({scene_count}) exceeds max ({max_scenes}). "
f"Truncating to first {max_scenes} scenes."
)
plan_data["scenes"] = scenes[:max_scenes]
# Warn if total duration is off
if abs(total_scene_duration - target_duration) > target_duration * 0.3:
logger.warning(
f"[YouTubePlanner] Total scene duration ({total_scene_duration}s) "
f"differs significantly from target ({target_duration}s)"
)
plan_data["_scenes_included"] = True
logger.info(
f"[YouTubePlanner] ✅ Plan + {len(plan_data['scenes'])} scenes "
f"generated in 1 AI call (optimized for shorts)"
)
else:
# LLM did not return scenes; downstream will regenerate
plan_data["_scenes_included"] = False
logger.warning(
"[YouTubePlanner] Shorts optimization requested but no scenes returned; "
"scene builder will generate scenes separately."
)
logger.info(f"[YouTubePlanner] ✅ Plan generated successfully")
logger.info(f"[YouTubePlanner] ✅ Plan generated successfully")
return plan_data
@@ -355,4 +590,264 @@ Add a "scenes" field with the complete scene breakdown.
}
return contexts.get(duration_type, contexts["medium"])
def _validate_and_enhance_plan(
self,
plan_data: Dict[str, Any],
duration_context: Dict[str, Any],
video_type: Optional[str],
video_type_config: Dict[str, Any],
) -> Dict[str, Any]:
"""
Validate and enhance plan quality before returning.
Performs quality checks:
- Validates required fields
- Validates content outline duration matches target
- Ensures SEO keywords are present
- Validates avatar recommendations
- Adds quality metadata
"""
# Ensure required fields exist
required_fields = [
"video_summary", "target_audience", "video_goal", "key_message",
"hook_strategy", "content_outline", "call_to_action",
"visual_style", "tone", "seo_keywords"
]
missing_fields = [field for field in required_fields if not plan_data.get(field)]
if missing_fields:
logger.warning(f"[YouTubePlanner] Missing required fields: {missing_fields}")
# Fill with defaults to prevent errors
for field in missing_fields:
if field == "seo_keywords":
plan_data[field] = []
elif field == "content_outline":
plan_data[field] = []
else:
plan_data[field] = f"[{field} not generated]"
# Validate content outline duration
if plan_data.get("content_outline"):
total_duration = sum(
section.get("duration_estimate", 0)
for section in plan_data["content_outline"]
)
target_duration = duration_context.get("target_seconds", 150)
# Allow 20% variance
tolerance = target_duration * 0.2
if abs(total_duration - target_duration) > tolerance:
logger.warning(
f"[YouTubePlanner] Content outline duration ({total_duration}s) "
f"doesn't match target ({target_duration}s). Adjusting..."
)
# Normalize durations proportionally
if total_duration > 0:
scale_factor = target_duration / total_duration
for section in plan_data["content_outline"]:
if "duration_estimate" in section:
section["duration_estimate"] = round(
section["duration_estimate"] * scale_factor, 1
)
# Validate SEO keywords
if not plan_data.get("seo_keywords") or len(plan_data["seo_keywords"]) < 3:
logger.warning(
f"[YouTubePlanner] Insufficient SEO keywords ({len(plan_data.get('seo_keywords', []))}). "
f"Plan may need enhancement."
)
# Validate avatar recommendations
if not plan_data.get("avatar_recommendations"):
logger.warning("[YouTubePlanner] Avatar recommendations missing. Generating defaults...")
plan_data["avatar_recommendations"] = {
"description": video_type_config.get("avatar_style", "Professional YouTube creator"),
"style": plan_data.get("visual_style", "Professional"),
"energy": plan_data.get("tone", "Engaging")
}
else:
# Ensure all avatar recommendation fields exist
avatar_rec = plan_data["avatar_recommendations"]
if not avatar_rec.get("description"):
avatar_rec["description"] = video_type_config.get("avatar_style", "Professional YouTube creator")
if not avatar_rec.get("style"):
avatar_rec["style"] = plan_data.get("visual_style", "Professional")
if not avatar_rec.get("energy"):
avatar_rec["energy"] = plan_data.get("tone", "Engaging")
# Add quality metadata
plan_data["_quality_checks"] = {
"content_outline_validated": bool(plan_data.get("content_outline")),
"seo_keywords_count": len(plan_data.get("seo_keywords", [])),
"avatar_recommendations_present": bool(plan_data.get("avatar_recommendations")),
"all_required_fields_present": len(missing_fields) == 0,
}
logger.info(
f"[YouTubePlanner] Plan quality validated: "
f"outline_sections={len(plan_data.get('content_outline', []))}, "
f"seo_keywords={len(plan_data.get('seo_keywords', []))}, "
f"avatar_recs={'yes' if plan_data.get('avatar_recommendations') else 'no'}"
)
return plan_data
async def _perform_exa_research(
self,
user_idea: str,
video_type: Optional[str],
target_audience: str,
user_id: str
) -> tuple[str, List[Dict[str, Any]]]:
"""
Perform Exa research directly using ExaResearchProvider (common module).
Uses the same pattern as podcast research with proper subscription checks.
Returns:
Tuple of (research_context_string, research_sources_list)
"""
try:
# Pre-flight validation for Exa search only (not full blog writer workflow)
# We only need to validate Exa API calls, not LLM operations
from services.database import get_db
from services.subscription import PricingService
from models.subscription_models import APIProvider
db = next(get_db())
try:
pricing_service = PricingService(db)
# Only validate Exa API call, not the full research workflow
operations_to_validate = [
{
'provider': APIProvider.EXA,
'tokens_requested': 0,
'actual_provider_name': 'exa',
'operation_type': 'exa_neural_search'
}
]
can_proceed, message, error_details = pricing_service.check_comprehensive_limits(
user_id=user_id,
operations=operations_to_validate
)
if not can_proceed:
usage_info = error_details.get('usage_info', {}) if error_details else {}
logger.warning(
f"[YouTubePlanner] Exa search blocked for user {user_id}: {message}"
)
raise HTTPException(
status_code=429,
detail={
'error': message,
'message': message,
'provider': 'exa',
'usage_info': usage_info if usage_info else error_details
}
)
logger.info(f"[YouTubePlanner] Exa search pre-flight validation passed for user {user_id}")
except HTTPException:
raise
except Exception as e:
logger.warning(f"[YouTubePlanner] Exa search pre-flight validation failed: {e}")
raise
finally:
db.close()
# Use ExaResearchProvider directly (common module, same as podcast)
from services.blog_writer.research.exa_provider import ExaResearchProvider
from types import SimpleNamespace
# Build research query
query_parts = [user_idea]
if video_type:
query_parts.append(f"{video_type} video")
if target_audience and target_audience != "General YouTube audience":
query_parts.append(target_audience)
research_query = " ".join(query_parts)
# Configure Exa research (same pattern as podcast)
cfg = SimpleNamespace(
exa_search_type="neural",
exa_category="web", # Focus on web content for YouTube
exa_include_domains=[],
exa_exclude_domains=[],
max_sources=10, # Limit sources for cost efficiency
source_types=[],
)
# Perform research
provider = ExaResearchProvider()
result = await provider.search(
prompt=research_query,
topic=user_idea,
industry="",
target_audience=target_audience,
config=cfg,
user_id=user_id,
)
# Track usage
cost_total = 0.0
if isinstance(result, dict):
cost_total = result.get("cost", {}).get("total", 0.005) if result.get("cost") else 0.005
provider.track_exa_usage(user_id, cost_total)
# Extract sources and content
sources = result.get("sources", []) or []
research_content = result.get("content", "")
# Build research context for prompt
research_context = ""
if research_content and sources:
# Limit content to 2000 chars to avoid token bloat
limited_content = research_content[:2000]
research_context = f"""
**Research & Current Information:**
Based on current web research, here are relevant insights and trends:
{limited_content}
**Key Research Sources ({len(sources)} sources):**
"""
# Add top 5 sources for context
for idx, source in enumerate(sources[:5], 1):
title = source.get("title", "Untitled") or "Untitled"
url = source.get("url", "") or ""
excerpt = (source.get("excerpt", "") or "")[:200]
if not excerpt:
excerpt = (source.get("summary", "") or "")[:200]
research_context += f"\n{idx}. {title}\n {excerpt}\n Source: {url}\n"
research_context += "\n**Use this research to:**\n"
research_context += "- Identify current trends and popular angles\n"
research_context += "- Enhance SEO keywords with real search data\n"
research_context += "- Ensure content is relevant and up-to-date\n"
research_context += "- Reference credible sources in the plan\n"
research_context += "- Identify gaps or unique angles not covered by competitors\n"
# Format sources for response
formatted_sources = []
for source in sources:
formatted_sources.append({
"title": source.get("title", "") or "",
"url": source.get("url", "") or "",
"excerpt": (source.get("excerpt", "") or "")[:300],
"published_at": source.get("published_at"),
"credibility_score": source.get("credibility_score", 0.85) or 0.85,
})
logger.info(f"[YouTubePlanner] Exa research completed: {len(formatted_sources)} sources found")
return research_context, formatted_sources
except HTTPException:
# Re-raise HTTPException (subscription limits, etc.)
raise
except Exception as e:
logger.error(f"[YouTubePlanner] Research error: {e}", exc_info=True)
# Non-critical failure - return empty research
return "", []

View File

@@ -32,6 +32,11 @@ class YouTubeSceneBuilderService:
"""
Build structured scenes from a video plan.
This method is optimized to minimize AI calls:
- For shorts: Reuses scenes if already generated in plan (0 AI calls)
- For medium/long: Generates scenes + batch enhances (1-3 AI calls total)
- Custom script: Parses script without AI calls (0 AI calls)
Args:
video_plan: Video plan from planner service
user_id: Clerk user ID for subscription checking
@@ -41,22 +46,38 @@ class YouTubeSceneBuilderService:
List of scene dictionaries with narration, visual prompts, timing, etc.
"""
try:
duration_type = video_plan.get('duration_type', 'medium')
logger.info(
f"[YouTubeSceneBuilder] Building scenes from plan: "
f"duration={video_plan.get('duration_type')}, "
f"sections={len(video_plan.get('content_outline', []))}"
f"duration={duration_type}, "
f"sections={len(video_plan.get('content_outline', []))}, "
f"user={user_id}"
)
duration_metadata = video_plan.get("duration_metadata", {})
max_scenes = duration_metadata.get("max_scenes", 10)
# If custom script provided, parse it into scenes
if custom_script:
# Optimization: Check if scenes already exist in plan (prevents duplicate generation)
# This can happen if plan was generated with include_scenes=True for shorts
existing_scenes = video_plan.get("scenes", [])
if existing_scenes and video_plan.get("_scenes_included"):
# Scenes already generated in plan - reuse them (0 AI calls)
logger.info(
f"[YouTubeSceneBuilder] ♻️ Reusing {len(existing_scenes)} scenes from plan "
f"(duration={duration_type}) - skipping generation to save AI calls"
)
scenes = self._normalize_scenes_from_plan(video_plan, duration_metadata)
# If custom script provided, parse it into scenes (0 AI calls for parsing)
elif custom_script:
logger.info(
f"[YouTubeSceneBuilder] Parsing custom script for scene generation "
f"(0 AI calls required)"
)
scenes = self._parse_custom_script(
custom_script, video_plan, duration_metadata, user_id
)
# For shorts, check if scenes were already generated in plan (optimization)
elif video_plan.get("_scenes_included") and video_plan.get("duration_type") == "shorts":
elif video_plan.get("_scenes_included") and duration_type == "shorts":
prebuilt = video_plan.get("scenes") or []
if prebuilt:
logger.info(