Base code

This commit is contained in:
Kunthawat Greethong
2026-01-08 22:39:53 +07:00
parent 697115c61a
commit c35fa52117
2169 changed files with 626670 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
# Story Writer Service
Story generation service using prompt chaining approach, migrated from `ToBeMigrated/ai_writers/ai_story_writer/`.
## Structure
```
backend/
├── services/
│ └── story_writer/
│ ├── __init__.py
│ ├── story_service.py # Core story generation logic
│ └── README.md
├── api/
│ └── story_writer/
│ ├── __init__.py
│ ├── router.py # API endpoints
│ ├── task_manager.py # Async task management
│ └── cache_manager.py # Result caching
└── models/
└── story_models.py # Pydantic models
```
## Features
- **Prompt Chaining**: Generates stories through premise → outline → start → continuation
- **Multiple Personas**: Supports 11 predefined author personas/genres
- **Configurable Parameters**:
- Story setting, characters, plot elements
- Writing style, tone, narrative POV
- Audience age group, content rating, ending preference
- **Subscription Integration**: Automatic usage tracking via `main_text_generation`
- **Provider Support**: Works with both Gemini and HuggingFace
- **Async Task Management**: Long-running story generation with polling
- **Caching**: Result caching for identical requests
## API Endpoints
### Synchronous Endpoints
- `POST /api/story/generate-premise` - Generate story premise
- `POST /api/story/generate-outline` - Generate outline from premise
- `POST /api/story/generate-start` - Generate story beginning
- `POST /api/story/continue` - Continue story generation
### Asynchronous Endpoints
- `POST /api/story/generate-full` - Generate complete story (returns task_id)
- `GET /api/story/task/{task_id}/status` - Get task status
- `GET /api/story/task/{task_id}/result` - Get completed task result
### Cache Management
- `GET /api/story/cache/stats` - Get cache statistics
- `POST /api/story/cache/clear` - Clear cache
## Usage Example
```python
from services.story_writer.story_service import StoryWriterService
service = StoryWriterService()
# Generate full story
result = service.generate_full_story(
persona="Award-Winning Science Fiction Author",
story_setting="A bustling futuristic city in 2150",
character_input="John, a tall muscular man with a kind heart",
plot_elements="The hero's journey, Good vs. evil",
writing_style="Formal",
story_tone="Suspenseful",
narrative_pov="Third Person Limited",
audience_age_group="Adults",
content_rating="PG-13",
ending_preference="Happy",
user_id="clerk_user_id",
max_iterations=10
)
print(result["premise"])
print(result["outline"])
print(result["story"])
```
## Migration Notes
- Updated imports from legacy `...gpt_providers.text_generation.main_text_generation` to `services.llm_providers.main_text_generation`
- Added `user_id` parameter to all LLM calls for subscription support
- Removed Streamlit dependencies (UI moved to frontend)
- Added proper error handling with HTTPException support
- Added async task management for long-running operations
- Added caching support for identical requests
## Integration
The router is automatically registered via `alwrity_utils/router_manager.py` in the optional routers section.

View File

@@ -0,0 +1,10 @@
"""
Story Writer Service
Provides story generation functionality using prompt chaining.
Supports multiple personas, styles, and iterative story generation.
"""
from .story_service import StoryWriterService
__all__ = ['StoryWriterService']

View File

@@ -0,0 +1,392 @@
"""
Audio Generation Service for Story Writer
Generates audio narration for story scenes using TTS (Text-to-Speech) providers.
"""
import os
import uuid
from typing import List, Dict, Any, Optional
from pathlib import Path
from loguru import logger
from fastapi import HTTPException
class StoryAudioGenerationService:
"""Service for generating audio narration for story scenes."""
def __init__(self, output_dir: Optional[str] = None):
"""
Initialize the audio generation service.
Parameters:
output_dir (str, optional): Directory to save generated audio files.
Defaults to 'backend/story_audio' if not provided.
"""
if output_dir:
self.output_dir = Path(output_dir)
else:
# Default to backend/story_audio directory
base_dir = Path(__file__).parent.parent.parent
self.output_dir = base_dir / "story_audio"
# Create output directory if it doesn't exist
self.output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"[StoryAudioGeneration] Initialized with output directory: {self.output_dir}")
def _generate_audio_filename(self, scene_number: int, scene_title: str) -> str:
"""Generate a unique filename for a scene audio file."""
# Clean scene title for filename
clean_title = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in scene_title[:30])
unique_id = str(uuid.uuid4())[:8]
return f"scene_{scene_number}_{clean_title}_{unique_id}.mp3"
def _generate_audio_gtts(
self,
text: str,
output_path: Path,
lang: str = "en",
slow: bool = False
) -> bool:
"""
Generate audio using Google Text-to-Speech (gTTS).
Parameters:
text (str): Text to convert to speech.
output_path (Path): Path to save the audio file.
lang (str): Language code (default: "en").
slow (bool): Whether to speak slowly (default: False).
Returns:
bool: True if generation was successful, False otherwise.
"""
try:
from gtts import gTTS
# Generate speech
tts = gTTS(text=text, lang=lang, slow=slow)
# Save to file
tts.save(str(output_path))
logger.info(f"[StoryAudioGeneration] Generated audio using gTTS: {output_path}")
return True
except ImportError as e:
logger.error(f"[StoryAudioGeneration] gTTS not installed. ImportError: {e}. Install with: pip install gtts")
return False
except Exception as e:
logger.error(f"[StoryAudioGeneration] Error generating audio with gTTS: {type(e).__name__}: {e}")
return False
def _generate_audio_pyttsx3(
self,
text: str,
output_path: Path,
rate: int = 150,
voice: Optional[str] = None
) -> bool:
"""
Generate audio using pyttsx3 (offline TTS).
Parameters:
text (str): Text to convert to speech.
output_path (Path): Path to save the audio file.
rate (int): Speech rate (default: 150).
voice (str, optional): Voice ID to use.
Returns:
bool: True if generation was successful, False otherwise.
"""
try:
import pyttsx3
# Initialize TTS engine
engine = pyttsx3.init()
# Set speech rate
engine.setProperty('rate', rate)
# Set voice if provided
if voice:
voices = engine.getProperty('voices')
for v in voices:
if voice in v.id:
engine.setProperty('voice', v.id)
break
# Generate speech and save to file
engine.save_to_file(text, str(output_path))
engine.runAndWait()
logger.info(f"[StoryAudioGeneration] Generated audio using pyttsx3: {output_path}")
return True
except ImportError:
logger.error("[StoryAudioGeneration] pyttsx3 not installed. Install with: pip install pyttsx3")
return False
except Exception as e:
logger.error(f"[StoryAudioGeneration] Error generating audio with pyttsx3: {e}")
return False
def generate_scene_audio(
self,
scene: Dict[str, Any],
user_id: str,
provider: str = "gtts",
lang: str = "en",
slow: bool = False,
rate: int = 150
) -> Dict[str, Any]:
"""
Generate audio narration for a single story scene.
Parameters:
scene (Dict[str, Any]): Scene data with audio_narration text.
user_id (str): Clerk user ID for subscription checking (for future usage tracking).
provider (str): TTS provider to use ("gtts", "pyttsx3", etc.).
lang (str): Language code for TTS (default: "en").
slow (bool): Whether to speak slowly (default: False, gTTS only).
rate (int): Speech rate (default: 150, pyttsx3 only).
Returns:
Dict[str, Any]: Audio metadata including file path, URL, and scene info.
"""
scene_number = scene.get("scene_number", 0)
scene_title = scene.get("title", "Untitled")
audio_narration = scene.get("audio_narration", "")
if not audio_narration:
raise ValueError(f"Scene {scene_number} ({scene_title}) has no audio_narration")
try:
logger.info(f"[StoryAudioGeneration] Generating audio for scene {scene_number}: {scene_title}")
logger.debug(f"[StoryAudioGeneration] Audio narration: {audio_narration[:100]}...")
# Generate audio filename
audio_filename = self._generate_audio_filename(scene_number, scene_title)
audio_path = self.output_dir / audio_filename
# Generate audio based on provider
success = False
if provider == "gtts":
success = self._generate_audio_gtts(
text=audio_narration,
output_path=audio_path,
lang=lang,
slow=slow
)
elif provider == "pyttsx3":
success = self._generate_audio_pyttsx3(
text=audio_narration,
output_path=audio_path,
rate=rate
)
else:
# Default to gTTS
logger.warning(f"[StoryAudioGeneration] Unknown provider '{provider}', using gTTS")
success = self._generate_audio_gtts(
text=audio_narration,
output_path=audio_path,
lang=lang,
slow=slow
)
if not success or not audio_path.exists():
raise RuntimeError(f"Failed to generate audio file: {audio_path}")
# Get file size
file_size = audio_path.stat().st_size
logger.info(f"[StoryAudioGeneration] Saved audio to: {audio_path} ({file_size} bytes)")
# Return audio metadata
return {
"scene_number": scene_number,
"scene_title": scene_title,
"audio_path": str(audio_path),
"audio_filename": audio_filename,
"audio_url": f"/api/story/audio/{audio_filename}", # API endpoint to serve audio
"provider": provider,
"file_size": file_size,
}
except HTTPException:
# Re-raise HTTPExceptions (e.g., 429 subscription limit)
raise
except Exception as e:
logger.error(f"[StoryAudioGeneration] Error generating audio for scene {scene_number}: {e}")
raise RuntimeError(f"Failed to generate audio for scene {scene_number}: {str(e)}") from e
def generate_scene_audio_list(
self,
scenes: List[Dict[str, Any]],
user_id: str,
provider: str = "gtts",
lang: str = "en",
slow: bool = False,
rate: int = 150,
progress_callback: Optional[callable] = None
) -> List[Dict[str, Any]]:
"""
Generate audio narration for multiple story scenes.
Parameters:
scenes (List[Dict[str, Any]]): List of scene data with audio_narration text.
user_id (str): Clerk user ID for subscription checking.
provider (str): TTS provider to use ("gtts", "pyttsx3", etc.).
lang (str): Language code for TTS (default: "en").
slow (bool): Whether to speak slowly (default: False, gTTS only).
rate (int): Speech rate (default: 150, pyttsx3 only).
progress_callback (callable, optional): Callback function for progress updates.
Returns:
List[Dict[str, Any]]: List of audio metadata for each scene.
"""
if not scenes:
raise ValueError("No scenes provided for audio generation")
logger.info(f"[StoryAudioGeneration] Generating audio for {len(scenes)} scenes")
audio_results = []
total_scenes = len(scenes)
for idx, scene in enumerate(scenes):
try:
# Generate audio for scene
audio_result = self.generate_scene_audio(
scene=scene,
user_id=user_id,
provider=provider,
lang=lang,
slow=slow,
rate=rate
)
audio_results.append(audio_result)
# Call progress callback if provided
if progress_callback:
progress = ((idx + 1) / total_scenes) * 100
progress_callback(progress, f"Generated audio for scene {scene.get('scene_number', idx + 1)}")
logger.info(f"[StoryAudioGeneration] Generated audio {idx + 1}/{total_scenes}")
except Exception as e:
logger.error(f"[StoryAudioGeneration] Failed to generate audio for scene {idx + 1}: {e}")
# Continue with next scene instead of failing completely
# Use empty strings for required fields instead of None
audio_results.append({
"scene_number": scene.get("scene_number", idx + 1),
"scene_title": scene.get("title", "Untitled"),
"audio_filename": "",
"audio_url": "",
"provider": provider,
"file_size": 0,
"error": str(e),
})
logger.info(f"[StoryAudioGeneration] Generated {len(audio_results)} audio files out of {total_scenes} scenes")
return audio_results
def generate_ai_audio(
self,
scene_number: int,
scene_title: str,
text: str,
user_id: str,
voice_id: str = "Wise_Woman",
speed: float = 1.0,
volume: float = 1.0,
pitch: float = 0.0,
emotion: str = "happy",
english_normalization: bool = False,
sample_rate: Optional[int] = None,
bitrate: Optional[int] = None,
channel: Optional[str] = None,
format: Optional[str] = None,
language_boost: Optional[str] = None,
enable_sync_mode: Optional[bool] = True,
) -> Dict[str, Any]:
"""
Generate AI audio for a single scene using main_audio_generation.
Parameters:
scene_number (int): Scene number.
scene_title (str): Scene title.
text (str): Text to convert to speech.
user_id (str): Clerk user ID for subscription checking.
voice_id (str): Voice ID for AI audio generation (default: "Wise_Woman").
speed (float): Speech speed (0.5-2.0, default: 1.0).
volume (float): Speech volume (0.1-10.0, default: 1.0).
pitch (float): Speech pitch (-12 to 12, default: 0.0).
emotion (str): Emotion for speech (default: "happy").
english_normalization (bool): Enable English text normalization for better number reading (default: False).
Returns:
Dict[str, Any]: Audio metadata including file path, URL, and scene info.
"""
if not text or not text.strip():
raise ValueError(f"Scene {scene_number} ({scene_title}) requires non-empty text")
try:
logger.info(f"[StoryAudioGeneration] Generating AI audio for scene {scene_number}: {scene_title}")
logger.debug(f"[StoryAudioGeneration] Text length: {len(text)} characters, voice: {voice_id}")
# Import main_audio_generation
from services.llm_providers.main_audio_generation import generate_audio
# Generate audio using main_audio_generation service
result = generate_audio(
text=text.strip(),
voice_id=voice_id,
speed=speed,
volume=volume,
pitch=pitch,
emotion=emotion,
user_id=user_id,
english_normalization=english_normalization,
sample_rate=sample_rate,
bitrate=bitrate,
channel=channel,
format=format,
language_boost=language_boost,
enable_sync_mode=enable_sync_mode,
)
# Save audio to file
audio_filename = self._generate_audio_filename(scene_number, scene_title)
audio_path = self.output_dir / audio_filename
with open(audio_path, "wb") as f:
f.write(result.audio_bytes)
logger.info(f"[StoryAudioGeneration] Saved AI audio to: {audio_path} ({result.file_size} bytes)")
# Calculate cost (for response)
character_count = result.text_length
cost_per_1000_chars = 0.05
cost = (character_count / 1000.0) * cost_per_1000_chars
# Return audio metadata
return {
"scene_number": scene_number,
"scene_title": scene_title,
"audio_path": str(audio_path),
"audio_filename": audio_filename,
"audio_url": f"/api/story/audio/{audio_filename}",
"provider": result.provider,
"model": result.model,
"voice_id": result.voice_id,
"text_length": result.text_length,
"file_size": result.file_size,
"cost": cost,
}
except HTTPException:
# Re-raise HTTPExceptions (e.g., 429 subscription limit)
raise
except Exception as e:
logger.error(f"[StoryAudioGeneration] Error generating AI audio for scene {scene_number}: {e}")
raise RuntimeError(f"Failed to generate AI audio for scene {scene_number}: {str(e)}") from e

View File

@@ -0,0 +1,274 @@
"""
Image Generation Service for Story Writer
Generates images for story scenes using the existing image generation service.
"""
import os
import base64
import uuid
from typing import List, Dict, Any, Optional
from pathlib import Path
from fastapi import HTTPException
from services.llm_providers.main_image_generation import generate_image
from services.llm_providers.image_generation import ImageGenerationResult
from utils.logger_utils import get_service_logger
logger = get_service_logger("story_writer.image_generation")
class StoryImageGenerationService:
"""Service for generating images for story scenes."""
def __init__(self, output_dir: Optional[str] = None):
"""
Initialize the image generation service.
Parameters:
output_dir (str, optional): Directory to save generated images.
Defaults to 'backend/story_images' if not provided.
"""
if output_dir:
self.output_dir = Path(output_dir)
else:
# Default to backend/story_images directory
base_dir = Path(__file__).parent.parent.parent
self.output_dir = base_dir / "story_images"
# Create output directory if it doesn't exist
self.output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"[StoryImageGeneration] Initialized with output directory: {self.output_dir}")
def _generate_image_filename(self, scene_number: int, scene_title: str) -> str:
"""Generate a unique filename for a scene image."""
# Clean scene title for filename
clean_title = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in scene_title[:30])
unique_id = str(uuid.uuid4())[:8]
return f"scene_{scene_number}_{clean_title}_{unique_id}.png"
def generate_scene_image(
self,
scene: Dict[str, Any],
user_id: str,
provider: Optional[str] = None,
width: int = 1024,
height: int = 1024,
model: Optional[str] = None
) -> Dict[str, Any]:
"""
Generate an image for a single story scene.
Parameters:
scene (Dict[str, Any]): Scene data with image_prompt.
user_id (str): Clerk user ID for subscription checking.
provider (str, optional): Image generation provider (gemini, huggingface, stability).
width (int): Image width (default: 1024).
height (int): Image height (default: 1024).
model (str, optional): Model to use for image generation.
Returns:
Dict[str, Any]: Image metadata including file path, URL, and scene info.
"""
scene_number = scene.get("scene_number", 0)
scene_title = scene.get("title", "Untitled")
image_prompt = scene.get("image_prompt", "")
if not image_prompt:
raise ValueError(f"Scene {scene_number} ({scene_title}) has no image_prompt")
try:
logger.info(f"[StoryImageGeneration] Generating image for scene {scene_number}: {scene_title}")
logger.debug(f"[StoryImageGeneration] Image prompt: {image_prompt[:100]}...")
# Generate image using main_image_generation service
image_options = {
"provider": provider,
"width": width,
"height": height,
"model": model,
}
result: ImageGenerationResult = generate_image(
prompt=image_prompt,
options=image_options,
user_id=user_id
)
# Save image to file
image_filename = self._generate_image_filename(scene_number, scene_title)
image_path = self.output_dir / image_filename
with open(image_path, "wb") as f:
f.write(result.image_bytes)
logger.info(f"[StoryImageGeneration] Saved image to: {image_path}")
# Return image metadata
# Use relative path for image_url (will be served via API endpoint)
return {
"scene_number": scene_number,
"scene_title": scene_title,
"image_path": str(image_path),
"image_filename": image_filename,
"image_url": f"/api/story/images/{image_filename}", # API endpoint to serve images
"width": result.width,
"height": result.height,
"provider": result.provider,
"model": result.model,
"seed": result.seed,
}
except HTTPException:
# Re-raise HTTPExceptions (e.g., 429 subscription limit)
raise
except Exception as e:
logger.error(f"[StoryImageGeneration] Error generating image for scene {scene_number}: {e}")
raise RuntimeError(f"Failed to generate image for scene {scene_number}: {str(e)}") from e
def generate_scene_images(
self,
scenes: List[Dict[str, Any]],
user_id: str,
provider: Optional[str] = None,
width: int = 1024,
height: int = 1024,
model: Optional[str] = None,
progress_callback: Optional[callable] = None
) -> List[Dict[str, Any]]:
"""
Generate images for multiple story scenes.
Parameters:
scenes (List[Dict[str, Any]]): List of scene data with image_prompts.
user_id (str): Clerk user ID for subscription checking.
provider (str, optional): Image generation provider (gemini, huggingface, stability).
width (int): Image width (default: 1024).
height (int): Image height (default: 1024).
model (str, optional): Model to use for image generation.
progress_callback (callable, optional): Callback function for progress updates.
Returns:
List[Dict[str, Any]]: List of image metadata for each scene.
"""
if not scenes:
raise ValueError("No scenes provided for image generation")
logger.info(f"[StoryImageGeneration] Generating images for {len(scenes)} scenes")
image_results = []
total_scenes = len(scenes)
for idx, scene in enumerate(scenes):
try:
# Generate image for scene
image_result = self.generate_scene_image(
scene=scene,
user_id=user_id,
provider=provider,
width=width,
height=height,
model=model
)
image_results.append(image_result)
# Call progress callback if provided
if progress_callback:
progress = ((idx + 1) / total_scenes) * 100
progress_callback(progress, f"Generated image for scene {scene.get('scene_number', idx + 1)}")
logger.info(f"[StoryImageGeneration] Generated image {idx + 1}/{total_scenes}")
except Exception as e:
logger.error(f"[StoryImageGeneration] Failed to generate image for scene {idx + 1}: {e}")
# Continue with next scene instead of failing completely
image_results.append({
"scene_number": scene.get("scene_number", idx + 1),
"scene_title": scene.get("title", "Untitled"),
"error": str(e),
"image_path": None,
"image_url": None,
})
logger.info(f"[StoryImageGeneration] Generated {len(image_results)} images out of {total_scenes} scenes")
return image_results
def regenerate_scene_image(
self,
scene_number: int,
scene_title: str,
prompt: str,
user_id: str,
provider: Optional[str] = None,
width: int = 1024,
height: int = 1024,
model: Optional[str] = None
) -> Dict[str, Any]:
"""
Regenerate an image for a single scene using a direct prompt (no AI prompt generation).
Parameters:
scene_number (int): Scene number.
scene_title (str): Scene title.
prompt (str): Direct prompt to use for image generation.
user_id (str): Clerk user ID for subscription checking.
provider (str, optional): Image generation provider (gemini, huggingface, stability).
width (int): Image width (default: 1024).
height (int): Image height (default: 1024).
model (str, optional): Model to use for image generation.
Returns:
Dict[str, Any]: Image metadata including file path, URL, and scene info.
"""
if not prompt or not prompt.strip():
raise ValueError(f"Scene {scene_number} ({scene_title}) requires a non-empty prompt")
try:
logger.info(f"[StoryImageGeneration] Regenerating image for scene {scene_number}: {scene_title}")
logger.debug(f"[StoryImageGeneration] Using direct prompt: {prompt[:100]}...")
# Generate image using main_image_generation service with the direct prompt
image_options = {
"provider": provider,
"width": width,
"height": height,
"model": model,
}
result: ImageGenerationResult = generate_image(
prompt=prompt.strip(),
options=image_options,
user_id=user_id
)
# Save image to file
image_filename = self._generate_image_filename(scene_number, scene_title)
image_path = self.output_dir / image_filename
with open(image_path, "wb") as f:
f.write(result.image_bytes)
logger.info(f"[StoryImageGeneration] Saved regenerated image to: {image_path}")
# Return image metadata
return {
"scene_number": scene_number,
"scene_title": scene_title,
"image_path": str(image_path),
"image_filename": image_filename,
"image_url": f"/api/story/images/{image_filename}",
"width": result.width,
"height": result.height,
"provider": result.provider,
"model": result.model,
"seed": result.seed,
}
except HTTPException:
# Re-raise HTTPExceptions (e.g., 429 subscription limit)
raise
except Exception as e:
logger.error(f"[StoryImageGeneration] Error regenerating image for scene {scene_number}: {e}")
raise RuntimeError(f"Failed to regenerate image for scene {scene_number}: {str(e)}") from e

View File

@@ -0,0 +1,352 @@
"""
Prompt Enhancement Service for HunyuanVideo Generation
Uses AI to deeply understand story context and generate optimized
HunyuanVideo prompts following best practices with 7 components.
"""
from typing import Dict, Any, List, Optional
from loguru import logger
from fastapi import HTTPException
from services.llm_providers.main_text_generation import llm_text_gen
class PromptEnhancerService:
"""Service for generating HunyuanVideo-optimized prompts from story context."""
def __init__(self):
"""Initialize the prompt enhancer service."""
logger.info("[PromptEnhancer] Service initialized")
def enhance_scene_prompt(
self,
current_scene: Dict[str, Any],
story_context: Dict[str, Any],
all_scenes: List[Dict[str, Any]],
user_id: str
) -> str:
"""
Generate a HunyuanVideo-optimized prompt for a scene using two-stage AI analysis.
Args:
current_scene: Scene data for the scene being processed
story_context: Complete story context (setup, premise, outline, story text)
all_scenes: List of all scenes for consistency analysis
user_id: Clerk user ID for subscription checking
Returns:
str: Optimized HunyuanVideo prompt (300-500 words) with 7 components
"""
try:
logger.info(f"[PromptEnhancer] Enhancing prompt for scene {current_scene.get('scene_number', 'unknown')}")
# Stage 1: Deep story context analysis
story_insights = self._analyze_story_context(
current_scene=current_scene,
story_context=story_context,
all_scenes=all_scenes,
user_id=user_id
)
# Stage 2: Generate optimized HunyuanVideo prompt
optimized_prompt = self._generate_hunyuan_prompt(
current_scene=current_scene,
story_context=story_context,
story_insights=story_insights,
all_scenes=all_scenes,
user_id=user_id
)
logger.info(f"[PromptEnhancer] Generated prompt length: {len(optimized_prompt)} characters")
return optimized_prompt
except HTTPException as http_err:
# Propagate subscription limit errors (429) to frontend for modal display
# Only fallback for other HTTP errors (5xx, etc.)
if http_err.status_code == 429:
error_msg = self._extract_error_message(http_err)
logger.warning(f"[PromptEnhancer] Subscription limit exceeded (HTTP 429): {error_msg}")
# Re-raise to propagate to frontend for subscription modal
raise
else:
# For other HTTP errors, log and fallback
error_msg = self._extract_error_message(http_err)
logger.error(f"[PromptEnhancer] Error enhancing prompt (HTTP {http_err.status_code}): {error_msg}", exc_info=True)
return self._generate_fallback_prompt(current_scene, story_context)
except Exception as e:
logger.error(f"[PromptEnhancer] Error enhancing prompt: {str(e)}", exc_info=True)
# Fallback to basic prompt if enhancement fails
return self._generate_fallback_prompt(current_scene, story_context)
def _analyze_story_context(
self,
current_scene: Dict[str, Any],
story_context: Dict[str, Any],
all_scenes: List[Dict[str, Any]],
user_id: str
) -> str:
"""
Stage 1: Use AI to analyze complete story context and extract insights.
Returns:
str: Story insights as JSON string for use in prompt generation
"""
# Build comprehensive context for analysis
analysis_prompt = f"""You are analyzing a complete story to extract key insights for AI video generation.
**STORY SETUP:**
- Persona: {story_context.get('persona', 'N/A')}
- Setting: {story_context.get('story_setting', 'N/A')}
- Characters: {story_context.get('characters', 'N/A')}
- Plot Elements: {story_context.get('plot_elements', 'N/A')}
- Writing Style: {story_context.get('writing_style', 'N/A')}
- Tone: {story_context.get('story_tone', 'N/A')}
- Narrative POV: {story_context.get('narrative_pov', 'N/A')}
- Audience: {story_context.get('audience_age_group', 'N/A')}
- Content Rating: {story_context.get('content_rating', 'N/A')}
**STORY PREMISE:**
{story_context.get('premise', 'N/A')}
**STORY CONTENT:**
{story_context.get('story_content', 'N/A')[:2000]}...
**ALL SCENES OVERVIEW:**
"""
# Add summary of all scenes
for idx, scene in enumerate(all_scenes, 1):
scene_num = scene.get('scene_number', idx)
analysis_prompt += f"\nScene {scene_num}: {scene.get('title', 'Untitled')}"
analysis_prompt += f"\n Description: {scene.get('description', '')[:150]}..."
analysis_prompt += f"\n Image Prompt: {scene.get('image_prompt', '')[:150]}..."
if scene.get('character_descriptions'):
chars = ', '.join(scene.get('character_descriptions', [])[:3])
analysis_prompt += f"\n Characters: {chars}"
analysis_prompt += "\n"
analysis_prompt += f"""
**CURRENT SCENE FOR VIDEO GENERATION:**
Scene {current_scene.get('scene_number', 'N/A')}: {current_scene.get('title', 'Untitled')}
Description: {current_scene.get('description', '')}
Image Prompt: {current_scene.get('image_prompt', '')}
Key Events: {', '.join(current_scene.get('key_events', [])[:5])}
Character Descriptions: {', '.join(current_scene.get('character_descriptions', [])[:5])}
**YOUR TASK:**
Analyze this story and extract key insights for video generation. Focus on:
1. Narrative arc and position of current scene within it
2. Character consistency (how characters appear across scenes)
3. Visual style patterns from image prompts
4. Tone and atmosphere progression
5. Key themes and motifs
6. Visual narrative flow
7. Camera and composition needs for this specific scene
Provide your analysis as structured insights that can guide prompt generation.
"""
try:
insights = llm_text_gen(
prompt=analysis_prompt,
system_prompt="You are an expert story analyst specializing in visual narrative and cinematic storytelling. Provide detailed, actionable insights for video generation.",
user_id=user_id
)
logger.debug(f"[PromptEnhancer] Story insights extracted: {insights[:200]}...")
return insights
except HTTPException as http_err:
# Propagate subscription limit errors (429) to frontend
if http_err.status_code == 429:
error_msg = self._extract_error_message(http_err)
logger.warning(f"[PromptEnhancer] Subscription limit exceeded during story analysis (HTTP 429): {error_msg}")
# Re-raise to propagate to frontend for subscription modal
raise
else:
# For other HTTP errors, log and fallback
error_msg = self._extract_error_message(http_err)
logger.warning(f"[PromptEnhancer] Story analysis failed (HTTP {http_err.status_code}): {error_msg}, using basic context")
return "Standard narrative flow with consistent character presentation"
except Exception as e:
logger.warning(f"[PromptEnhancer] Story analysis failed, using basic context: {str(e)}")
return "Standard narrative flow with consistent character presentation"
def _generate_hunyuan_prompt(
self,
current_scene: Dict[str, Any],
story_context: Dict[str, Any],
story_insights: str,
all_scenes: List[Dict[str, Any]],
user_id: str
) -> str:
"""
Stage 2: Generate scene-specific HunyuanVideo prompt with all 7 components.
Returns:
str: Complete HunyuanVideo prompt (300-500 words)
"""
# Collect character descriptions across all scenes for consistency
all_characters = {}
for scene in all_scenes:
for char_desc in scene.get('character_descriptions', []):
if char_desc and char_desc not in all_characters:
all_characters[char_desc] = scene.get('scene_number', 0)
# Collect image prompts for visual style reference
image_prompts = [scene.get('image_prompt', '') for scene in all_scenes if scene.get('image_prompt')]
# Determine scene position in narrative arc
current_scene_num = current_scene.get('scene_number', 0)
total_scenes = len(all_scenes)
scene_position = "beginning" if current_scene_num <= total_scenes // 3 else ("middle" if current_scene_num <= 2 * total_scenes // 3 else "climax")
prompt_generation_request = f"""Generate a professional HunyuanVideo prompt for this story scene.
**STORY INSIGHTS (from deep analysis):**
{story_insights}
**STORY SETUP:**
- Setting: {story_context.get('story_setting', 'N/A')}
- Tone: {story_context.get('story_tone', 'N/A')}
- Style: {story_context.get('writing_style', 'N/A')}
- Audience: {story_context.get('audience_age_group', 'N/A')}
**VISUAL STYLE REFERENCE (from generated images):**
{chr(10).join([f"- {prompt[:100]}..." for prompt in image_prompts[:3]])}
**CHARACTER CONSISTENCY (across all scenes):**
{chr(10).join([f"- {char}" for char in list(all_characters.keys())[:5]])}
**CURRENT SCENE DETAILS:**
- Scene {current_scene.get('scene_number', 'N/A')} of {total_scenes} (narrative position: {scene_position})
- Title: {current_scene.get('title', 'Untitled')}
- Description: {current_scene.get('description', '')}
- Image Prompt: {current_scene.get('image_prompt', '')}
- Key Events: {', '.join(current_scene.get('key_events', [])[:5])}
- Characters in scene: {', '.join(current_scene.get('character_descriptions', [])[:5])}
- Audio Narration: {current_scene.get('audio_narration', '')[:200]}
**REQUIREMENTS:**
Create a comprehensive HunyuanVideo prompt (300-500 words) following the 7-component structure:
1. **SUBJECT**: Clearly define the main focus - characters, objects, or action. Include character descriptions that match the visual style from image prompts and maintain consistency across scenes.
2. **SCENE**: Describe the environment and setting. Ensure it matches the story_setting and aligns with the visual style established in previous scenes.
3. **MOTION**: Detail the specific actions and movements. Reference key_events and ensure motion fits the narrative flow and story_insights about the scene's position in the arc.
4. **CAMERA MOVEMENT**: Specify cinematic camera work appropriate for this moment in the story. Consider the narrative position ({scene_position}) - use establishing shots for beginning, dynamic shots for climax.
5. **ATMOSPHERE**: Set the emotional tone. This should reflect the story_tone but also consider where we are in the narrative arc based on story_insights.
6. **LIGHTING**: Define lighting that matches the visual style from image prompts and supports the atmosphere. Ensure consistency with the established visual aesthetic.
7. **SHOT COMPOSITION**: Describe framing and composition that serves the visual narrative. Consider the story's visual style and ensure it flows naturally with the overall story.
Write the prompt as a flowing, detailed description (not a list) that integrates all 7 components naturally. Make it vivid, cinematic, and consistent with the story's established visual and narrative style. The prompt should be between 300-500 words.
"""
try:
optimized_prompt = llm_text_gen(
prompt=prompt_generation_request,
system_prompt="You are an expert video prompt engineer specializing in HunyuanVideo text-to-video generation. Create detailed, cinematic prompts that follow best practices and ensure high-quality video output.",
user_id=user_id
)
# Clean up and validate prompt length
optimized_prompt = optimized_prompt.strip()
word_count = len(optimized_prompt.split())
if word_count < 200:
logger.warning(f"[PromptEnhancer] Generated prompt is too short ({word_count} words), enhancing...")
# Add more detail if too short
optimized_prompt += self._add_cinematic_details(current_scene, story_context)
elif word_count > 600:
logger.warning(f"[PromptEnhancer] Generated prompt is too long ({word_count} words), trimming...")
# Trim if too long (keep first ~500 words)
words = optimized_prompt.split()
optimized_prompt = ' '.join(words[:500])
logger.info(f"[PromptEnhancer] Generated prompt: {len(optimized_prompt.split())} words")
return optimized_prompt
except HTTPException as http_err:
# Propagate subscription limit errors (429) to frontend
if http_err.status_code == 429:
error_msg = self._extract_error_message(http_err)
logger.warning(f"[PromptEnhancer] Subscription limit exceeded during prompt generation (HTTP 429): {error_msg}")
# Re-raise to propagate to frontend for subscription modal
raise
else:
# For other HTTP errors, log and fallback
error_msg = self._extract_error_message(http_err)
logger.error(f"[PromptEnhancer] Prompt generation failed (HTTP {http_err.status_code}): {error_msg}", exc_info=True)
return self._generate_fallback_prompt(current_scene, story_context)
except Exception as e:
logger.error(f"[PromptEnhancer] Prompt generation failed: {str(e)}", exc_info=True)
return self._generate_fallback_prompt(current_scene, story_context)
def _add_cinematic_details(
self,
current_scene: Dict[str, Any],
story_context: Dict[str, Any]
) -> str:
"""Add cinematic details to enhance a too-short prompt."""
return f"""
The scene unfolds with careful attention to visual storytelling. The {story_context.get('story_setting', 'environment')} serves as more than background - it actively participates in the narrative. Lighting and composition work together to emphasize the emotional weight of this moment, with camera movements that guide the viewer's attention naturally through the space. Every element - from the way light falls to the positioning of characters - contributes to the overall narrative impact.
"""
def _extract_error_message(self, http_err: HTTPException) -> str:
"""
Extract meaningful error message from HTTPException.
Handles both dict-based details (from subscription limit errors) and string details.
"""
if isinstance(http_err.detail, dict):
# For subscription limit errors, extract the 'message' or 'error' field
return http_err.detail.get('message') or http_err.detail.get('error') or str(http_err.detail)
elif isinstance(http_err.detail, str):
return http_err.detail
else:
return str(http_err.detail)
def _generate_fallback_prompt(
self,
current_scene: Dict[str, Any],
story_context: Dict[str, Any]
) -> str:
"""Generate a basic fallback prompt if AI enhancement fails."""
scene_title = current_scene.get('title', 'Untitled Scene')
scene_desc = current_scene.get('description', '')
image_prompt = current_scene.get('image_prompt', '')
setting = story_context.get('story_setting', 'the scene')
tone = story_context.get('story_tone', 'engaging')
return f"""A cinematic scene titled "{scene_title}" set in {setting}. {scene_desc[:200]}.
The scene features {', '.join(current_scene.get('character_descriptions', [])[:2]) if current_scene.get('character_descriptions') else 'the main characters'}.
Visual style follows: {image_prompt[:150]}.
The {tone} atmosphere is enhanced by natural lighting and dynamic camera movements that follow the action.
Shot composition emphasizes the narrative importance of this moment, with careful framing that draws attention to key elements.
The scene maintains visual consistency with previous moments while advancing the story's visual narrative."""
def enhance_scene_prompt_for_video(
current_scene: Dict[str, Any],
story_context: Dict[str, Any],
all_scenes: List[Dict[str, Any]],
user_id: str
) -> str:
"""
Convenience function to enhance a scene prompt for HunyuanVideo generation.
Args:
current_scene: Scene data for the scene being processed
story_context: Complete story context dictionary
all_scenes: List of all scenes for consistency
user_id: Clerk user ID for subscription checking
Returns:
str: Optimized HunyuanVideo prompt
"""
service = PromptEnhancerService()
return service.enhance_scene_prompt(current_scene, story_context, all_scenes, user_id)

View File

@@ -0,0 +1,14 @@
"""Story Writer service component helpers."""
from .base import StoryServiceBase
from .setup import StorySetupMixin
from .outline import StoryOutlineMixin
from .story_content import StoryContentMixin
__all__ = [
"StoryServiceBase",
"StorySetupMixin",
"StoryOutlineMixin",
"StoryContentMixin",
]

View File

@@ -0,0 +1,332 @@
"""Core shared functionality for Story Writer service components."""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from loguru import logger
from services.llm_providers.main_text_generation import llm_text_gen
class StoryServiceBase:
"""Base class providing shared helpers for story writer operations."""
guidelines: str = """\
Writing Guidelines:
Delve deeper. Lose yourself in the world you're building. Unleash vivid
descriptions to paint the scenes in your reader's mind.
Develop your characters — let their motivations, fears, and complexities unfold naturally.
Weave in the threads of your outline, but don't feel constrained by it.
Allow your story to surprise you as you write. Use rich imagery, sensory details, and
evocative language to bring the setting, characters, and events to life.
Introduce elements subtly that can blossom into complex subplots, relationships,
or worldbuilding details later in the story.
Keep things intriguing but not fully resolved.
Avoid boxing the story into a corner too early.
Plant the seeds of subplots or potential character arc shifts that can be expanded later.
IMPORTANT: Respect the story length target. Write with appropriate detail and pacing
to reach the target word count, but do NOT exceed it. Once you've reached the target
length and provided satisfying closure, conclude the story by writing IAMDONE.
"""
# ------------------------------------------------------------------ #
# LLM Utilities
# ------------------------------------------------------------------ #
def generate_with_retry(
self,
prompt: str,
*,
system_prompt: Optional[str] = None,
user_id: Optional[str] = None,
) -> str:
"""Generate content using llm_text_gen with retry handling and subscription support."""
if not user_id:
raise RuntimeError("user_id is required for subscription checking")
try:
return llm_text_gen(prompt=prompt, system_prompt=system_prompt, user_id=user_id)
except HTTPException:
raise
except Exception as exc:
logger.error(f"Error generating content: {exc}")
raise RuntimeError(f"Failed to generate content: {exc}") from exc
# ------------------------------------------------------------------ #
# Prompt helpers
# ------------------------------------------------------------------ #
def build_persona_prompt(
self,
persona: str,
story_setting: str,
character_input: str,
plot_elements: str,
writing_style: str,
story_tone: str,
narrative_pov: str,
audience_age_group: str,
content_rating: str,
ending_preference: str,
) -> str:
"""Build the persona prompt with all story parameters."""
return f"""{persona}
**STORY SETUP CONTEXT:**
**Setting:**
{story_setting}
- Use this specific setting throughout the story
- Incorporate setting details naturally into scenes and descriptions
- Ensure the setting is clearly established and consistent
**Characters:**
{character_input}
- Use these specific characters in the story
- Develop these characters according to their descriptions
- Maintain character consistency across all scenes
- Create character arcs that align with the plot elements
**Plot Elements:**
{plot_elements}
- Incorporate these plot elements into the story structure
- Address each plot element in relevant scenes
- Build connections between plot elements logically
- Ensure the ending addresses the main plot elements
**Writing Style:**
{writing_style}
- This writing style should be reflected in EVERY aspect of the story
- The language, sentence structure, and narrative approach must match this style exactly
- If this is a custom or combined style, interpret it in the context of the audience age group
- Adapt the style's complexity to match {audience_age_group}
**Story Tone:**
{story_tone}
- This tone must be maintained consistently throughout the entire story
- The emotional atmosphere, mood, and overall feeling must match this tone
- If this is a custom or combined tone, interpret it age-appropriately for {audience_age_group}
- Ensure the tone is suitable for {content_rating} content rating
**Narrative Point of View:**
{narrative_pov}
- Use this perspective consistently throughout the story
- Maintain the chosen perspective in all narration
- Apply the perspective appropriately for {audience_age_group}
**Target Audience:**
{audience_age_group}
- ALL content must be age-appropriate for this audience
- Language complexity, vocabulary, sentence length, and themes must match this age group
- Concepts must be understandable and relatable to this audience
- Adjust all story elements (style, tone, plot) to be appropriate for this age group
**Content Rating:**
{content_rating}
- All content must stay within these content boundaries
- Themes, language, and subject matter must respect this rating
- Ensure the writing style and tone are compatible with this rating
**Ending Preference:**
{ending_preference}
- The story should build toward this type of ending
- All plot development should lead naturally to this ending style
- Create expectations that align with this ending preference
- Ensure the ending is appropriate for {audience_age_group} and {content_rating}
**CRITICAL INSTRUCTIONS:**
- Use ALL of the above story setup parameters to guide your writing
- The writing style, tone, narrative POV, audience age group, and content rating are NOT optional - they are REQUIRED constraints
- Every word, sentence, and description must align with these parameters
- When parameters interact (e.g., style + age group, tone + content rating), ensure they work together harmoniously
- Tailor the language complexity, vocabulary, and concepts to the specified audience age group
- Maintain consistency with the specified writing style and tone throughout
- Ensure all content is appropriate for the specified content rating
- Build the narrative toward the specified ending preference
- Use the setting, characters, and plot elements provided to create a coherent, engaging story
Make sure the story is engaging, well-crafted, and perfectly tailored to ALL of the specified parameters above.
"""
def _get_parameter_interaction_guidance(
self,
writing_style: str,
story_tone: str,
audience_age_group: str,
content_rating: str,
) -> str:
"""Generate guidance for interpreting custom/combined parameter values and their interactions."""
guidance = "**PARAMETER INTERACTION GUIDANCE:**\n\n"
style_words = writing_style.lower().split()
if len(style_words) > 1:
guidance += f"**Writing Style Analysis:** The style '{writing_style}' appears to combine multiple approaches:\n"
for word in style_words:
guidance += f"- '{word.title()}': Interpret this aspect in the context of {audience_age_group}\n"
guidance += (
"Combine all aspects naturally. For example, if 'Educational Playful':\n"
f" → Use playful, engaging language to teach concepts naturally\n"
f" → Make learning fun and interactive for {audience_age_group}\n"
" → Combine educational content with fun, magical elements\n\n"
)
else:
guidance += f"**Writing Style:** '{writing_style}'\n"
guidance += f"- Interpret this style appropriately for {audience_age_group}\n"
guidance += "- Adapt the style's complexity to match the audience's reading level\n\n"
tone_words = story_tone.lower().split()
if len(tone_words) > 1:
guidance += f"**Story Tone Analysis:** The tone '{story_tone}' combines multiple emotional qualities:\n"
for word in tone_words:
guidance += f"- '{word.title()}': Express this emotion in an age-appropriate way for {audience_age_group}\n"
guidance += (
"Blend these emotions throughout the story. For example, if 'Educational Whimsical':\n"
" → Use whimsical, playful language to convey educational concepts\n"
" → Make the tone both informative and magical\n"
f" → Combine wonder and learning in an age-appropriate way for {audience_age_group}\n\n"
)
else:
guidance += f"**Story Tone:** '{story_tone}'\n"
guidance += f"- Interpret this tone age-appropriately for {audience_age_group}\n"
guidance += f"- Ensure the tone is suitable for {content_rating} content rating\n\n"
guidance += "**PARAMETER INTERACTION EXAMPLES:**\n\n"
if "Children (5-12)" in audience_age_group:
guidance += f"- When writing_style is '{writing_style}' AND audience_age_group is 'Children (5-12)':\n"
guidance += " → Simplify the style's complexity while maintaining its essence\n"
guidance += " → Use age-appropriate vocabulary and sentence structure\n"
guidance += " → Make the style engaging and accessible for children\n\n"
if "Children (5-12)" in audience_age_group and "dark" in story_tone.lower():
guidance += f"- When story_tone is '{story_tone}' AND audience_age_group is 'Children (5-12)':\n"
guidance += " → Interpret 'dark' as mysterious and adventurous, not scary or frightening\n"
guidance += " → Use shadows, secrets, and puzzles rather than fear or horror\n"
guidance += " → Maintain a sense of wonder and excitement\n"
guidance += " → Keep it thrilling but age-appropriate\n\n"
guidance += f"- When writing_style is '{writing_style}' AND story_tone is '{story_tone}':\n"
guidance += " → Combine the style and tone naturally\n"
guidance += " → Use the style to express the tone effectively\n"
guidance += f" → Ensure both work together harmoniously for {audience_age_group}\n\n"
guidance += f"- When content_rating is '{content_rating}':\n"
guidance += " → Ensure the writing style and tone respect these content boundaries\n"
guidance += " → Adjust language, themes, and subject matter to fit the rating\n"
guidance += f" → Maintain age-appropriateness for {audience_age_group}\n\n"
guidance += "**PARAMETER CONFLICT RESOLUTION:**\n"
guidance += "If parameters seem to conflict, prioritize in this order:\n"
guidance += "1. Audience age group appropriateness (safety and comprehension) - HIGHEST PRIORITY\n"
guidance += "2. Content rating compliance (content boundaries)\n"
guidance += "3. Writing style and tone (creative expression)\n"
guidance += "4. Other parameters (narrative POV, ending preference)\n\n"
guidance += "Always ensure that ALL parameters work together to create appropriate, engaging content.\n"
return guidance
# ------------------------------------------------------------------ #
# Outline helpers shared across modules
# ------------------------------------------------------------------ #
def _format_outline_for_prompt(self, outline: Any) -> str:
"""Format outline (structured or text) for use in prompts."""
if isinstance(outline, list):
outline_text = "\n".join(
[
f"Scene {scene.get('scene_number', idx + 1)}: {scene.get('title', 'Untitled')}\n"
f" Description: {scene.get('description', '')}\n"
f" Key Events: {', '.join(scene.get('key_events', []))}"
for idx, scene in enumerate(outline)
]
)
return outline_text
return str(outline)
def _parse_text_outline(self, outline_prompt: str, user_id: str) -> List[Dict[str, Any]]:
"""Fallback method to parse text outline if JSON parsing fails."""
outline_text = self.generate_with_retry(outline_prompt, user_id=user_id)
lines = outline_text.strip().split("\n")
scenes: List[Dict[str, Any]] = []
current_scene: Optional[Dict[str, Any]] = None
for line in lines:
cleaned = line.strip()
if not cleaned:
continue
if cleaned[0].isdigit() or cleaned.startswith("Scene") or cleaned.startswith("Chapter"):
if current_scene:
scenes.append(current_scene)
scene_number = len(scenes) + 1
title = cleaned.replace(f"{scene_number}.", "").replace("Scene", "").replace("Chapter", "").strip()
current_scene = {
"scene_number": scene_number,
"title": title or f"Scene {scene_number}",
"description": "",
"image_prompt": f"A scene from the story: {title}",
"audio_narration": "",
"character_descriptions": [],
"key_events": [],
}
continue
if current_scene:
if current_scene["description"]:
current_scene["description"] += " " + cleaned
else:
current_scene["description"] = cleaned
if current_scene["image_prompt"].startswith("A scene from the story"):
current_scene["image_prompt"] = f"A detailed visual representation of: {current_scene['description'][:200]}"
if not current_scene["audio_narration"]:
current_scene["audio_narration"] = (
current_scene["description"][:150] + "..."
if len(current_scene["description"]) > 150
else current_scene["description"]
)
if current_scene:
scenes.append(current_scene)
if not scenes:
scenes.append(
{
"scene_number": 1,
"title": "Story Outline",
"description": outline_text.strip(),
"image_prompt": f"A scene from the story: {outline_text[:200]}",
"audio_narration": outline_text[:150] + "..." if len(outline_text) > 150 else outline_text,
"character_descriptions": [],
"key_events": [],
}
)
logger.info(f"[StoryWriter] Parsed {len(scenes)} scenes from text outline")
return scenes
def _get_story_length_guidance(self, story_length: str) -> tuple[int, int]:
"""Return word count guidance based on story length."""
story_length_lower = story_length.lower()
if "short" in story_length_lower or "1000" in story_length_lower:
return (1000, 0)
if "long" in story_length_lower or "10000" in story_length_lower:
return (3000, 2500)
return (2000, 1500)
@staticmethod
def load_json_response(response_text: Any) -> Dict[str, Any]:
"""Normalize responses from llm_text_gen (dict or json string)."""
if isinstance(response_text, dict):
return response_text
if isinstance(response_text, str):
return json.loads(response_text)
raise ValueError(f"Unexpected response type: {type(response_text)}")

View File

@@ -0,0 +1,171 @@
"""Story outline generation helpers."""
from __future__ import annotations
import json
from typing import Any, Dict
from fastapi import HTTPException
from loguru import logger
from services.llm_providers.main_text_generation import llm_text_gen
from .base import StoryServiceBase
class StoryOutlineMixin(StoryServiceBase):
"""Provides outline generation behaviour."""
def _get_outline_schema(self) -> Dict[str, Any]:
"""Return JSON schema for structured story outlines."""
return {
"type": "object",
"properties": {
"scenes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"scene_number": {"type": "integer"},
"title": {"type": "string"},
"description": {"type": "string"},
"image_prompt": {"type": "string"},
"audio_narration": {"type": "string"},
"character_descriptions": {"type": "array", "items": {"type": "string"}},
"key_events": {"type": "array", "items": {"type": "string"}},
},
"required": ["scene_number", "title", "description", "image_prompt", "audio_narration"],
},
}
},
"required": ["scenes"],
}
def generate_outline(
self,
*,
premise: str,
persona: str,
story_setting: str,
character_input: str,
plot_elements: str,
writing_style: str,
story_tone: str,
narrative_pov: str,
audience_age_group: str,
content_rating: str,
ending_preference: str,
user_id: str,
use_structured_output: bool = True,
) -> Any:
"""Generate a story outline with optional structured JSON output."""
persona_prompt = self.build_persona_prompt(
persona,
story_setting,
character_input,
plot_elements,
writing_style,
story_tone,
narrative_pov,
audience_age_group,
content_rating,
ending_preference,
)
parameter_guidance = self._get_parameter_interaction_guidance(
writing_style, story_tone, audience_age_group, content_rating
)
outline_prompt = f"""\
{persona_prompt}
**PREMISE:**
{premise}
{parameter_guidance}
**YOUR TASK:**
Create a detailed story outline with multiple scenes that brings this premise to life. The outline must perfectly align with ALL of the story setup parameters provided above.
**SCENE PROGRESSION STRUCTURE:**
**Scene 1-2 (Opening):**
- Introduce the setting ({story_setting}) and main characters ({character_input})
- Establish the {story_tone} tone from the beginning
- Set up the main conflict or adventure based on the plot elements ({plot_elements})
- Hook the audience with an engaging opening that matches {writing_style} style
- Use the {narrative_pov} perspective to establish the story world
- Create intrigue and interest appropriate for {audience_age_group}
- Respect the {content_rating} content rating from the start
**Scene 3-7 (Development):**
- Develop the plot elements ({plot_elements}) in detail
- Build character relationships and growth using the specified characters ({character_input})
- Create tension, obstacles, or challenges that advance the story
- Maintain the {writing_style} style consistently throughout
- Progress toward the {ending_preference} ending
- Explore the setting ({story_setting}) more deeply
- Ensure all content is age-appropriate for {audience_age_group}
- Maintain the {story_tone} tone while developing the plot
- Respect the {content_rating} content rating in all scenes
- Use the {narrative_pov} perspective consistently
**Final Scenes (Resolution):**
- Resolve the main conflict established in the plot elements ({plot_elements})
- Deliver the {ending_preference} ending
- Tie together all plot elements and character arcs
- Provide satisfying closure appropriate for {audience_age_group}
- Maintain the {writing_style} style and {story_tone} tone until the end
- Ensure the ending respects the {content_rating} content rating
- Use the {narrative_pov} perspective to conclude the story
**OUTLINE STRUCTURE:**
For each scene, provide:
1. **Scene Number and Title**
2. **Description** (written in {writing_style}, maintaining {story_tone}, and age-appropriate for {audience_age_group})
3. **Image Prompt** (vivid, visually descriptive, includes setting/characters, age-appropriate)
4. **Audio Narration** (2-3 sentences, engaging, maintains style/tone, suitable for narration)
5. **Character Descriptions** (for characters appearing in the scene)
6. **Key Events** (bullet list of important happenings)
**CONTEXT INTEGRATION REQUIREMENTS:**
- Ensure every scene reflects the setting ({story_setting})
- Keep characters consistent with ({character_input})
- Integrate plot elements ({plot_elements}) logically
- Maintain persona voice ({persona})
- Respect audience age group ({audience_age_group}) and content rating ({content_rating})
Before finalizing, verify that every scene adheres to the writing style, tone, age appropriateness, content rating, and narrative POV. Create 5-10 scenes that tell a complete, engaging story with clear progression and satisfying resolution.
"""
try:
if use_structured_output:
outline_schema = self._get_outline_schema()
try:
response = self.load_json_response(
llm_text_gen(prompt=outline_prompt, json_struct=outline_schema, user_id=user_id)
)
scenes = response.get("scenes", [])
if scenes:
logger.info(f"[StoryWriter] Generated {len(scenes)} structured scenes for user {user_id}")
logger.info(
"[StoryWriter] Outline generated with parameters: "
f"audience={audience_age_group}, style={writing_style}, tone={story_tone}"
)
return scenes
logger.warning("[StoryWriter] No scenes found in structured output, falling back to text parsing")
raise ValueError("No scenes found in structured output")
except (json.JSONDecodeError, ValueError, KeyError) as exc:
logger.warning(
f"[StoryWriter] Failed to parse structured JSON outline ({exc}), falling back to text parsing"
)
return self._parse_text_outline(outline_prompt, user_id)
outline = self.generate_with_retry(outline_prompt, user_id=user_id)
return outline.strip()
except HTTPException:
raise
except Exception as exc:
logger.error(f"Outline Generation Error: {exc}")
raise RuntimeError(f"Failed to generate outline: {exc}") from exc

View File

@@ -0,0 +1,275 @@
"""Story setup generation helpers."""
from __future__ import annotations
import json
from typing import Any, Dict, List
from fastapi import HTTPException
from loguru import logger
from services.llm_providers.main_text_generation import llm_text_gen
from .base import StoryServiceBase
class StorySetupMixin(StoryServiceBase):
"""Provides story setup generation behaviour."""
def generate_premise(
self,
*,
persona: str,
story_setting: str,
character_input: str,
plot_elements: str,
writing_style: str,
story_tone: str,
narrative_pov: str,
audience_age_group: str,
content_rating: str,
ending_preference: str,
user_id: str,
) -> str:
"""Generate a story premise."""
persona_prompt = self.build_persona_prompt(
persona,
story_setting,
character_input,
plot_elements,
writing_style,
story_tone,
narrative_pov,
audience_age_group,
content_rating,
ending_preference,
)
parameter_guidance = self._get_parameter_interaction_guidance(
writing_style, story_tone, audience_age_group, content_rating
)
premise_prompt = f"""\
{persona_prompt}
{parameter_guidance}
**TASK: Write a SINGLE, BRIEF premise sentence (1-2 sentences maximum, approximately 20-40 words) for this story.**
The premise MUST:
1. Be written in the specified {writing_style} writing style
- Interpret and apply this style appropriately for {audience_age_group}
- Match the language complexity, sentence structure, and narrative approach of this style
2. Match the {story_tone} story tone exactly
- Express the emotional atmosphere and mood indicated by this tone
- Ensure the tone is age-appropriate for {audience_age_group}
3. Be appropriate for {audience_age_group} with {content_rating} content rating
- Use language complexity that matches this audience's reading level
- Use vocabulary that is understandable to this age group
- Present concepts that are relatable and explainable to this audience
- Respect the {content_rating} content rating boundaries
4. Briefly describe the story elements:
- Setting: {story_setting}
- Characters: {character_input}
- Main plot: {plot_elements}
5. Be clear, engaging, and set up the story without telling the whole story
6. Be written from the {narrative_pov} point of view
7. Set up for a {ending_preference} ending
**CRITICAL: This is a PREMISE, not the full story.**
- Keep it to 1-2 sentences maximum (approximately 20-40 words)
- Do NOT write the full story or multiple paragraphs
- Do NOT reveal the resolution or ending
- Focus on the setup: who, where, and what the main challenge/adventure is
- Use ALL story setup parameters to guide your language and content choices
- Tailor every word to the target audience ({audience_age_group}) and writing style ({writing_style})
Write ONLY the premise sentence(s). Do not write anything else.
"""
try:
premise = self.generate_with_retry(premise_prompt, user_id=user_id).strip()
sentences = premise.split(". ")
if len(sentences) > 2:
premise = ". ".join(sentences[:2])
if not premise.endswith("."):
premise += "."
return premise
except HTTPException:
raise
except Exception as exc:
logger.error(f"Premise Generation Error: {exc}")
raise RuntimeError(f"Failed to generate premise: {exc}") from exc
# ------------------------------------------------------------------ #
# Setup options
# ------------------------------------------------------------------ #
def _build_setup_schema(self) -> Dict[str, Any]:
"""Return JSON schema for structured setup options."""
return {
"type": "object",
"properties": {
"options": {
"type": "array",
"items": {
"type": "object",
"properties": {
"persona": {"type": "string"},
"story_setting": {"type": "string"},
"character_input": {"type": "string"},
"plot_elements": {"type": "string"},
"writing_style": {"type": "string"},
"story_tone": {"type": "string"},
"narrative_pov": {"type": "string"},
"audience_age_group": {"type": "string"},
"content_rating": {"type": "string"},
"ending_preference": {"type": "string"},
"story_length": {"type": "string"},
"premise": {"type": "string"},
"reasoning": {"type": "string"},
},
"required": [
"persona",
"story_setting",
"character_input",
"plot_elements",
"writing_style",
"story_tone",
"narrative_pov",
"audience_age_group",
"content_rating",
"ending_preference",
"story_length",
"premise",
"reasoning",
],
},
"minItems": 3,
"maxItems": 3,
}
},
"required": ["options"],
}
def generate_story_setup_options(
self,
*,
story_idea: str,
user_id: str,
) -> List[Dict[str, Any]]:
"""Generate 3 story setup options from a user's story idea."""
suggested_writing_styles = ['Formal', 'Casual', 'Poetic', 'Humorous', 'Academic', 'Journalistic', 'Narrative']
suggested_story_tones = ['Dark', 'Uplifting', 'Suspenseful', 'Whimsical', 'Melancholic', 'Mysterious', 'Romantic', 'Adventurous']
suggested_narrative_povs = ['First Person', 'Third Person Limited', 'Third Person Omniscient']
suggested_audience_age_groups = ['Children (5-12)', 'Young Adults (13-17)', 'Adults (18+)', 'All Ages']
suggested_content_ratings = ['G', 'PG', 'PG-13', 'R']
suggested_ending_preferences = ['Happy', 'Tragic', 'Cliffhanger', 'Twist', 'Open-ended', 'Bittersweet']
setup_prompt = f"""\
You are an expert story writer and creative writing assistant. A user has provided the following story idea or information:
{story_idea}
Based on this story idea, generate exactly 3 different, well-thought-out story setup options. Each option should be CREATIVE, PERSONALIZED, and perfectly tailored to the user's specific story idea.
**CRITICAL - Creative Freedom:**
- You have COMPLETE FREEDOM to craft personalized values that best fit the user's story idea
- Do NOT limit yourself to predefined options - create custom, creative values that perfectly match the story concept
- For example, if the user wants "a story about how stars are made for a 5-year-old", you might create:
- Writing Style: "Educational Playful" or "Simple Scientific" (not just "Casual" or "Poetic")
- Story Tone: "Wonder-filled" or "Curious Discovery" (not just "Whimsical" or "Uplifting")
- Narrative POV: "Second Person (You)" or "Omniscient Narrator as Guide" (not just standard options)
- The goal is to create the PERFECT setup for THIS specific story, not to fit into generic categories
Each option should:
1. Have a unique and creative persona that fits the story idea perfectly
2. Define a compelling story setting that brings the idea to life
3. Describe interesting and engaging characters
4. Include key plot elements that drive the narrative
5. Create CUSTOM, PERSONALIZED values for writing style, story tone, narrative POV, audience age group, content rating, and ending preference that best serve the story idea
6. Select an appropriate story length: "Short (>1000 words)" for brief stories, "Medium (>5000 words)" for standard-length stories, or "Long (>10000 words)" for extended, detailed stories
7. Generate a brief story premise (1-2 sentences, approximately 20-40 words) that summarizes the story concept
8. Provide a brief reasoning (2-3 sentences) explaining why this setup works well for the story idea
**IMPORTANT - Premise Requirements:**
- The premise MUST be age-appropriate for the selected audience_age_group
- For Children (5-12): Use simple, everyday words. Avoid complex vocabulary like "nebular", "ionized", "cosmic", "stellar", "melancholic", "bittersweet"
- The premise MUST match the selected writing_style (e.g., if custom style is "Educational Playful", use playful educational language)
- The premise MUST match the selected story_tone (e.g., if custom tone is "Wonder-filled", create a sense of wonder)
- Keep the premise to 1-2 sentences maximum
- Focus on who, where, and what the main challenge/adventure is
**Suggested Options (for reference only - feel free to create better custom values):**
- Writing Styles (suggestions): {', '.join(suggested_writing_styles)}
- Story Tones (suggestions): {', '.join(suggested_story_tones)}
- Narrative POVs (suggestions): {', '.join(suggested_narrative_povs)}
- Audience Age Groups (suggestions): {', '.join(suggested_audience_age_groups)}
- Content Ratings (suggestions): {', '.join(suggested_content_ratings)}
- Ending Preferences (suggestions): {', '.join(suggested_ending_preferences)}
- Story Lengths: "Short (>1000 words)", "Medium (>5000 words)", "Long (>10000 words)"
**Remember:** These are ONLY suggestions. If a custom value better serves the story idea, CREATE IT!
Return exactly 3 options as a JSON array. Each option must include a "premise" field with the story premise.
"""
setup_schema = self._build_setup_schema()
try:
logger.info(f"[StoryWriter] Generating story setup options for user {user_id}")
response = self.load_json_response(
llm_text_gen(prompt=setup_prompt, json_struct=setup_schema, user_id=user_id)
)
options = response.get("options", [])
if len(options) != 3:
logger.warning(f"[StoryWriter] Expected 3 options but got {len(options)}, correcting count")
if len(options) < 3:
raise ValueError(f"Expected 3 options but got {len(options)}")
options = options[:3]
for idx, option in enumerate(options):
if not option.get("premise") or not option.get("premise", "").strip():
logger.info(f"[StoryWriter] Generating premise for option {idx + 1}")
try:
option["premise"] = self.generate_premise(
persona=option.get("persona", ""),
story_setting=option.get("story_setting", ""),
character_input=option.get("character_input", ""),
plot_elements=option.get("plot_elements", ""),
writing_style=option.get("writing_style", "Narrative"),
story_tone=option.get("story_tone", "Adventurous"),
narrative_pov=option.get("narrative_pov", "Third Person Limited"),
audience_age_group=option.get("audience_age_group", "All Ages"),
content_rating=option.get("content_rating", "G"),
ending_preference=option.get("ending_preference", "Happy"),
user_id=user_id,
)
except Exception as exc: # pragma: no cover - fallback clause
logger.warning(f"[StoryWriter] Failed to generate premise for option {idx + 1}: {exc}")
option["premise"] = (
f"A {option.get('story_setting', 'story')} story featuring "
f"{option.get('character_input', 'characters')}."
)
else:
premise = option["premise"].strip()
sentences = premise.split(". ")
if len(sentences) > 2:
premise = ". ".join(sentences[:2])
if not premise.endswith("."):
premise += "."
option["premise"] = premise
logger.info(f"[StoryWriter] Generated {len(options)} story setup options with premises for user {user_id}")
return options
except HTTPException:
raise
except json.JSONDecodeError as exc:
logger.error(f"[StoryWriter] Failed to parse JSON response for story setup: {exc}")
raise RuntimeError(f"Failed to parse story setup options: {exc}") from exc
except Exception as exc:
logger.error(f"[StoryWriter] Error generating story setup options: {exc}")
raise RuntimeError(f"Failed to generate story setup options: {exc}") from exc

View File

@@ -0,0 +1,428 @@
"""Story content generation helpers."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from loguru import logger
from services.story_writer.image_generation_service import StoryImageGenerationService
from .base import StoryServiceBase
from .outline import StoryOutlineMixin
class StoryContentMixin(StoryOutlineMixin):
"""Provides story drafting and continuation behaviour."""
# ------------------------------------------------------------------ #
# Story start
# ------------------------------------------------------------------ #
def generate_story_start(
self,
*,
premise: str,
outline: Any,
persona: str,
story_setting: str,
character_input: str,
plot_elements: str,
writing_style: str,
story_tone: str,
narrative_pov: str,
audience_age_group: str,
content_rating: str,
ending_preference: str,
story_length: str = "Medium",
user_id: str,
) -> str:
"""Generate the starting section (or full short story)."""
persona_prompt = self.build_persona_prompt(
persona,
story_setting,
character_input,
plot_elements,
writing_style,
story_tone,
narrative_pov,
audience_age_group,
content_rating,
ending_preference,
)
outline_text = self._format_outline_for_prompt(outline)
story_length_lower = story_length.lower()
is_short_story = "short" in story_length_lower or "1000" in story_length_lower
if is_short_story:
logger.info(f"[StoryWriter] Generating complete short story (~1000 words) in single call for user {user_id}")
short_story_prompt = f"""\
{persona_prompt}
You have a gripping premise in mind:
{premise}
Your imagination has crafted a rich narrative outline:
{outline_text}
**YOUR TASK:**
Write the COMPLETE story from beginning to end. This is a SHORT story, so you need to write the entire narrative in a single response.
**STORY LENGTH TARGET:**
- Target: Approximately 1000 words (900-1100 words acceptable)
- This is a SHORT story, so be concise but complete
- Cover all key scenes from your outline
- Provide a satisfying conclusion that addresses all plot elements
- Ensure the story makes sense as a complete narrative
**STORY STRUCTURE:**
1. **Opening**: Establish setting, characters, and initial situation
2. **Development**: Develop the plot, introduce conflicts, build tension
3. **Climax**: Reach the story's peak moment
4. **Resolution**: Resolve conflicts and provide closure
**IMPORTANT INSTRUCTIONS:**
- Write the COMPLETE story in this single response
- Aim for approximately 1000 words (900-1100 words)
- Ensure the story is complete and makes sense as a standalone narrative
- Include all essential elements from your outline
- Provide a satisfying ending that matches the ending preference: {ending_preference}
- Do NOT leave the story incomplete - this is the only generation call for short stories
- Once you've finished the complete story, conclude naturally - do NOT write IAMDONE
**WRITING STYLE:**
{self.guidelines}
**REMEMBER:**
- This is a SHORT story - be concise but complete
- Write the ENTIRE story in this response
- Aim for ~1000 words
- Ensure the story is complete and satisfying
- Cover all key elements from your outline
"""
try:
complete_story = self.generate_with_retry(short_story_prompt, user_id=user_id)
complete_story = complete_story.replace("IAMDONE", "").strip()
logger.info(
f"[StoryWriter] Generated complete short story ({len(complete_story.split())} words) for user {user_id}"
)
return complete_story
except HTTPException:
raise
except Exception as exc:
logger.error(f"Short Story Generation Error: {exc}")
raise RuntimeError(f"Failed to generate short story: {exc}") from exc
initial_word_count, _ = self._get_story_length_guidance(story_length)
starting_prompt = f"""\
{persona_prompt}
You have a gripping premise in mind:
{premise}
Your imagination has crafted a rich narrative outline:
{outline_text}
First, silently review the outline and the premise. Consider how to start the story.
Start to write the very beginning of the story. You are not expected to finish
the whole story now. Your writing should be detailed enough that you are only
scratching the surface of the first bullet of your outline. Try to write AT
MINIMUM {initial_word_count} WORDS.
**STORY LENGTH TARGET:**
This story is targeted to be {story_length}. Write with appropriate detail and pacing
to reach this target length across the entire story. For this initial section, focus
on establishing the setting, characters, and beginning of the plot in {initial_word_count} words.
{self.guidelines}
"""
try:
starting_draft = self.generate_with_retry(starting_prompt, user_id=user_id)
return starting_draft.strip()
except HTTPException:
raise
except Exception as exc:
logger.error(f"Story Start Generation Error: {exc}")
raise RuntimeError(f"Failed to generate story start: {exc}") from exc
# ------------------------------------------------------------------ #
# Continuation
# ------------------------------------------------------------------ #
def continue_story(
self,
*,
premise: str,
outline: Any,
story_text: str,
persona: str,
story_setting: str,
character_input: str,
plot_elements: str,
writing_style: str,
story_tone: str,
narrative_pov: str,
audience_age_group: str,
content_rating: str,
ending_preference: str,
story_length: str = "Medium",
user_id: str,
) -> str:
"""Continue writing the story."""
persona_prompt = self.build_persona_prompt(
persona,
story_setting,
character_input,
plot_elements,
writing_style,
story_tone,
narrative_pov,
audience_age_group,
content_rating,
ending_preference,
)
outline_text = self._format_outline_for_prompt(outline)
_, continuation_word_count = self._get_story_length_guidance(story_length)
current_word_count = len(story_text.split()) if story_text else 0
story_length_lower = story_length.lower()
if "short" in story_length_lower or "1000" in story_length_lower:
# Safety check: short stories shouldn't reach here
return "IAMDONE"
if "long" in story_length_lower or "10000" in story_length_lower:
target_total_words = 10000
else:
target_total_words = 4500
buffer_target = int(target_total_words * 1.05)
if current_word_count >= buffer_target:
logger.info(
f"[StoryWriter] Word count ({current_word_count}) at or past buffer target ({buffer_target}). Story is complete."
)
return "IAMDONE"
if current_word_count >= target_total_words and (current_word_count - target_total_words) < 50:
logger.info(
f"[StoryWriter] Word count ({current_word_count}) is very close to target ({target_total_words}). Story is complete."
)
return "IAMDONE"
remaining_words = max(0, buffer_target - current_word_count)
if remaining_words < 50:
logger.info(f"[StoryWriter] Remaining words ({remaining_words}) are minimal. Story is complete.")
return "IAMDONE"
continuation_prompt = f"""\
{persona_prompt}
You have a gripping premise in mind:
{premise}
Your imagination has crafted a rich narrative outline:
{outline_text}
You've begun to immerse yourself in this world, and the words are flowing.
Here's what you've written so far:
{story_text}
=====
First, silently review the outline and story so far. Identify what the single
next part of your outline you should write.
Your task is to continue where you left off and write the next part of the story.
You are not expected to finish the whole story now. Your writing should be
detailed enough that you are only scratching the surface of the next part of
your outline. Try to write AT MINIMUM {continuation_word_count} WORDS.
**STORY LENGTH TARGET:**
This story is targeted to be {story_length} (target: {target_total_words} words total, with 5% buffer allowed).
You have written approximately {current_word_count} words so far, leaving approximately
{remaining_words} words remaining.
**CRITICAL INSTRUCTIONS - READ CAREFULLY:**
1. Write the next section with appropriate detail, aiming for approximately {min(continuation_word_count, remaining_words)} words.
2. **STOP CONDITION:** If after writing this continuation, the total word count will reach or exceed {target_total_words} words, you MUST conclude the story immediately by writing IAMDONE.
3. The story should reach a natural conclusion that addresses all plot elements and provides satisfying closure.
4. Once you've written IAMDONE, do NOT write any more content - stop immediately.
**WORD COUNT LIMIT:**
- Target: {target_total_words} words total (with 5% buffer: {int(target_total_words * 1.05)} words maximum)
- Current word count: {current_word_count} words
- Remaining words: {remaining_words} words
- **CRITICAL: If your continuation would bring the total to {target_total_words} words or more, conclude the story NOW and write IAMDONE.**
- **Do NOT exceed {int(target_total_words * 1.05)} words. This is a hard limit.**
- **Ensure the story is complete and makes sense when you write IAMDONE.**
{self.guidelines}
"""
try:
continuation = self.generate_with_retry(continuation_prompt, user_id=user_id)
return continuation.strip()
except HTTPException:
raise
except Exception as exc:
logger.error(f"Story Continuation Error: {exc}")
raise RuntimeError(f"Failed to continue story: {exc}") from exc
# ------------------------------------------------------------------ #
# Full generation orchestration
# ------------------------------------------------------------------ #
def generate_full_story(
self,
*,
persona: str,
story_setting: str,
character_input: str,
plot_elements: str,
writing_style: str,
story_tone: str,
narrative_pov: str,
audience_age_group: str,
content_rating: str,
ending_preference: str,
user_id: str,
max_iterations: int = 10,
) -> Dict[str, Any]:
"""Generate a complete story using prompt chaining."""
try:
logger.info(f"[StoryWriter] Generating premise for user {user_id}")
premise = self.generate_premise(
persona=persona,
story_setting=story_setting,
character_input=character_input,
plot_elements=plot_elements,
writing_style=writing_style,
story_tone=story_tone,
narrative_pov=narrative_pov,
audience_age_group=audience_age_group,
content_rating=content_rating,
ending_preference=ending_preference,
user_id=user_id,
)
if not premise:
raise RuntimeError("Failed to generate premise")
logger.info(f"[StoryWriter] Generating outline for user {user_id}")
outline = self.generate_outline(
premise=premise,
persona=persona,
story_setting=story_setting,
character_input=character_input,
plot_elements=plot_elements,
writing_style=writing_style,
story_tone=story_tone,
narrative_pov=narrative_pov,
audience_age_group=audience_age_group,
content_rating=content_rating,
ending_preference=ending_preference,
user_id=user_id,
)
if not outline:
raise RuntimeError("Failed to generate outline")
logger.info(f"[StoryWriter] Generating story start for user {user_id}")
draft = self.generate_story_start(
premise=premise,
outline=outline,
persona=persona,
story_setting=story_setting,
character_input=character_input,
plot_elements=plot_elements,
writing_style=writing_style,
story_tone=story_tone,
narrative_pov=narrative_pov,
audience_age_group=audience_age_group,
content_rating=content_rating,
ending_preference=ending_preference,
user_id=user_id,
)
if not draft:
raise RuntimeError("Failed to generate story start")
iteration = 0
while "IAMDONE" not in draft and iteration < max_iterations:
iteration += 1
logger.info(f"[StoryWriter] Continuation iteration {iteration}/{max_iterations}")
continuation = self.continue_story(
premise=premise,
outline=outline,
story_text=draft,
persona=persona,
story_setting=story_setting,
character_input=character_input,
plot_elements=plot_elements,
writing_style=writing_style,
story_tone=story_tone,
narrative_pov=narrative_pov,
audience_age_group=audience_age_group,
content_rating=content_rating,
ending_preference=ending_preference,
user_id=user_id,
)
if continuation:
draft += "\n\n" + continuation
else:
logger.warning(f"[StoryWriter] Empty continuation at iteration {iteration}")
break
final_story = draft.replace("IAMDONE", "").strip()
outline_response = outline
if isinstance(outline, list):
outline_response = "\n".join(
[
f"Scene {scene.get('scene_number', idx + 1)}: {scene.get('title', 'Untitled')}\n"
f" {scene.get('description', '')}"
for idx, scene in enumerate(outline)
]
)
return {
"premise": premise,
"outline": str(outline_response),
"story": final_story,
"iterations": iteration,
"is_complete": "IAMDONE" in draft or iteration >= max_iterations,
}
except Exception as exc:
logger.error(f"[StoryWriter] Error generating full story: {exc}")
raise RuntimeError(f"Failed to generate full story: {exc}") from exc
# ------------------------------------------------------------------ #
# Multimedia helpers
# ------------------------------------------------------------------ #
def generate_scene_images(
self,
*,
scenes: List[Dict[str, Any]],
user_id: str,
provider: Optional[str] = None,
width: int = 1024,
height: int = 1024,
model: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Generate images for story scenes."""
image_service = StoryImageGenerationService()
return image_service.generate_scene_images(
scenes=scenes, user_id=user_id, provider=provider, width=width, height=height, model=model
)

View File

@@ -0,0 +1,30 @@
"""
Story Writer Service
Core service for generating stories using prompt chaining approach.
Migrated from ToBeMigrated/ai_writers/ai_story_writer/ai_story_generator.py
"""
from typing import Dict, Any, Optional, List
from loguru import logger
from fastapi import HTTPException
import json
from services.llm_providers.main_text_generation import llm_text_gen
from services.story_writer.service_components import (
StoryContentMixin,
StoryOutlineMixin,
StoryServiceBase,
StorySetupMixin,
)
class StoryWriterService(
StoryContentMixin,
StorySetupMixin,
StoryOutlineMixin,
StoryServiceBase,
):
"""Facade class combining story writer behaviours via modular mixins."""
__slots__ = ()

View File

@@ -0,0 +1,452 @@
"""
Video Generation Service for Story Writer
Combines images and audio into animated video clips using MoviePy.
"""
import os
import uuid
from typing import List, Dict, Any, Optional
from pathlib import Path
from loguru import logger
from fastapi import HTTPException
class StoryVideoGenerationService:
"""Service for generating videos from story scenes, images, and audio."""
def __init__(self, output_dir: Optional[str] = None):
"""
Initialize the video generation service.
Parameters:
output_dir (str, optional): Directory to save generated videos.
Defaults to 'backend/story_videos' if not provided.
"""
if output_dir:
self.output_dir = Path(output_dir)
else:
# Default to backend/story_videos directory
base_dir = Path(__file__).parent.parent.parent
self.output_dir = base_dir / "story_videos"
# Create output directory if it doesn't exist
self.output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"[StoryVideoGeneration] Initialized with output directory: {self.output_dir}")
def _generate_video_filename(self, story_title: str = "story") -> str:
"""Generate a unique filename for a story video."""
# Clean story title for filename
clean_title = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in story_title[:30])
unique_id = str(uuid.uuid4())[:8]
return f"story_{clean_title}_{unique_id}.mp4"
def save_scene_video(self, video_bytes: bytes, scene_number: int, user_id: str) -> Dict[str, str]:
"""
Save individual scene video bytes to file.
Parameters:
video_bytes: Raw video file bytes (mp4/webm format)
scene_number: Scene number for naming
user_id: Clerk user ID for naming
Returns:
Dict[str, str]: Video metadata with video_url and video_filename
"""
try:
# Generate filename with scene number and user ID
clean_user_id = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in user_id[:16])
timestamp = str(uuid.uuid4())[:8]
filename = f"scene_{scene_number}_{clean_user_id}_{timestamp}.mp4"
video_path = self.output_dir / filename
# Write video bytes to file
with open(video_path, 'wb') as f:
f.write(video_bytes)
file_size = video_path.stat().st_size
logger.info(f"[StoryVideoGeneration] Saved scene {scene_number} video: {filename} ({file_size} bytes)")
# Generate URL path (relative to /api/story/videos/)
video_url = f"/api/story/videos/{filename}"
return {
"video_filename": filename,
"video_url": video_url,
"video_path": str(video_path),
"file_size": file_size
}
except Exception as e:
logger.error(f"[StoryVideoGeneration] Error saving scene video: {e}", exc_info=True)
raise RuntimeError(f"Failed to save scene video: {str(e)}") from e
def generate_scene_video(
self,
scene: Dict[str, Any],
image_path: str,
audio_path: str,
user_id: str,
duration: Optional[float] = None,
fps: int = 24
) -> Dict[str, Any]:
"""
Generate a video clip for a single story scene.
Parameters:
scene (Dict[str, Any]): Scene data.
image_path (str): Path to the scene image file.
audio_path (str): Path to the scene audio file.
user_id (str): Clerk user ID for subscription checking (for future usage tracking).
duration (float, optional): Video duration in seconds. If None, uses audio duration.
fps (int): Frames per second for video (default: 24).
Returns:
Dict[str, Any]: Video metadata including file path, URL, and scene info.
"""
scene_number = scene.get("scene_number", 0)
scene_title = scene.get("title", "Untitled")
try:
logger.info(f"[StoryVideoGeneration] Generating video for scene {scene_number}: {scene_title}")
# Import MoviePy
try:
# MoviePy v2.x exposes classes at top-level (moviepy.ImageClip, etc)
from moviepy import ImageClip, AudioFileClip, concatenate_videoclips
except Exception as _imp_err:
# Detailed diagnostics to help users fix environment issues
try:
import sys as _sys
import platform as _platform
import importlib
mv = None
imv = None
ff_path = "unresolved"
try:
mv = importlib.import_module("moviepy")
except Exception:
pass
try:
imv = importlib.import_module("imageio")
except Exception:
pass
try:
import imageio_ffmpeg as _iff
ff_path = _iff.get_ffmpeg_exe()
except Exception:
pass
logger.error(
"[StoryVideoGeneration] MoviePy import failed. "
f"py={_sys.executable} plat={_platform.platform()} "
f"moviepy_ver={getattr(mv,'__version__', 'NA')} "
f"imageio_ver={getattr(imv,'__version__', 'NA')} "
f"ffmpeg_path={ff_path} err={_imp_err}"
)
except Exception:
# best-effort diagnostics
pass
logger.error("[StoryVideoGeneration] MoviePy not installed. Install with: pip install moviepy imageio imageio-ffmpeg")
raise RuntimeError("MoviePy is not installed. Please install it to generate videos.")
# Load image and audio
image_file = Path(image_path)
audio_file = Path(audio_path)
if not image_file.exists():
raise FileNotFoundError(f"Image not found: {image_path}")
if not audio_file.exists():
raise FileNotFoundError(f"Audio not found: {audio_path}")
# Load audio to get duration
audio_clip = AudioFileClip(str(audio_file))
audio_duration = audio_clip.duration
# Use provided duration or audio duration
video_duration = duration if duration is not None else audio_duration
# Create image clip (MoviePy v2: use with_* API)
image_clip = ImageClip(str(image_file)).with_duration(video_duration)
image_clip = image_clip.with_fps(fps)
# Set audio to image clip
video_clip = image_clip.with_audio(audio_clip)
# Generate video filename
video_filename = f"scene_{scene_number}_{scene_title.replace(' ', '_').replace('/', '_')[:50]}_{uuid.uuid4().hex[:8]}.mp4"
video_path = self.output_dir / video_filename
# Write video file
video_clip.write_videofile(
str(video_path),
fps=fps,
codec='libx264',
audio_codec='aac',
preset='medium',
threads=4,
logger=None # Disable MoviePy's default logger
)
# Clean up clips
video_clip.close()
audio_clip.close()
image_clip.close()
# Get file size
file_size = video_path.stat().st_size
logger.info(f"[StoryVideoGeneration] Saved video to: {video_path} ({file_size} bytes)")
# Return video metadata
return {
"scene_number": scene_number,
"scene_title": scene_title,
"video_path": str(video_path),
"video_filename": video_filename,
"video_url": f"/api/story/videos/{video_filename}", # API endpoint to serve videos
"duration": video_duration,
"fps": fps,
"file_size": file_size,
}
except HTTPException:
# Re-raise HTTPExceptions (e.g., 429 subscription limit)
raise
except Exception as e:
logger.error(f"[StoryVideoGeneration] Error generating video for scene {scene_number}: {e}")
raise RuntimeError(f"Failed to generate video for scene {scene_number}: {str(e)}") from e
def generate_story_video(
self,
scenes: List[Dict[str, Any]],
image_paths: List[Optional[str]],
audio_paths: List[str],
user_id: str,
story_title: str = "Story",
fps: int = 24,
transition_duration: float = 0.5,
progress_callback: Optional[callable] = None,
video_paths: Optional[List[Optional[str]]] = None
) -> Dict[str, Any]:
"""
Generate a complete story video from multiple scenes.
Parameters:
scenes (List[Dict[str, Any]]): List of scene data.
image_paths (List[Optional[str]]): List of image file paths (None if scene has animated video).
audio_paths (List[str]): List of audio file paths for each scene.
user_id (str): Clerk user ID for subscription checking.
story_title (str): Title of the story (default: "Story").
fps (int): Frames per second for video (default: 24).
transition_duration (float): Duration of transitions between scenes in seconds (default: 0.5).
progress_callback (callable, optional): Callback function for progress updates.
video_paths (Optional[List[Optional[str]]]): List of animated video file paths (None if scene has static image).
Returns:
Dict[str, Any]: Video metadata including file path, URL, and story info.
"""
if not scenes or not audio_paths:
raise ValueError("Scenes and audio paths are required")
if len(scenes) != len(audio_paths):
raise ValueError("Number of scenes and audio paths must match")
# Ensure video_paths is a list and matches scenes length
if video_paths is None:
video_paths = [None] * len(scenes)
elif len(video_paths) != len(scenes):
video_paths = video_paths + [None] * (len(scenes) - len(video_paths))
logger.debug(f"[StoryVideoGeneration] video_paths length: {len(video_paths)}, scenes length: {len(scenes)}")
try:
logger.info(f"[StoryVideoGeneration] Generating story video for {len(scenes)} scenes")
# Import MoviePy
try:
from moviepy import ImageClip, AudioFileClip, concatenate_videoclips
except Exception as _imp_err:
# Detailed diagnostics to help users fix environment issues
try:
import sys as _sys
import platform as _platform
import importlib
mv = None
imv = None
ff_path = "unresolved"
try:
mv = importlib.import_module("moviepy")
except Exception:
pass
try:
imv = importlib.import_module("imageio")
except Exception:
pass
try:
import imageio_ffmpeg as _iff
ff_path = _iff.get_ffmpeg_exe()
except Exception:
pass
logger.error(
"[StoryVideoGeneration] MoviePy import failed. "
f"py={_sys.executable} plat={_platform.platform()} "
f"moviepy_ver={getattr(mv,'__version__', 'NA')} "
f"imageio_ver={getattr(imv,'__version__', 'NA')} "
f"ffmpeg_path={ff_path} err={_imp_err}"
)
except Exception:
pass
logger.error("[StoryVideoGeneration] MoviePy not installed. Install with: pip install moviepy imageio imageio-ffmpeg")
raise RuntimeError("MoviePy is not installed. Please install it to generate videos.")
scene_clips = []
total_duration = 0.0
# Import VideoFileClip for animated videos
try:
from moviepy import VideoFileClip
except ImportError:
VideoFileClip = None
for idx, (scene, image_path, audio_path, video_path) in enumerate(zip(scenes, image_paths, audio_paths, video_paths)):
try:
scene_number = scene.get("scene_number", idx + 1)
scene_title = scene.get("title", "Untitled")
logger.info(f"[StoryVideoGeneration] Processing scene {scene_number}/{len(scenes)}: {scene_title}")
logger.debug(f"[StoryVideoGeneration] Scene {scene_number} paths - video: {video_path}, audio: {audio_path}, image: {image_path}")
# Prefer animated video if available
# Check video_path is not None and is a valid string before calling Path()
if video_path is not None and isinstance(video_path, (str, Path)) and video_path and Path(video_path).exists():
logger.info(f"[StoryVideoGeneration] Using animated video for scene {scene_number}: {video_path}")
# Load animated video
if VideoFileClip is None:
raise RuntimeError("VideoFileClip not available - MoviePy may not be fully installed")
video_clip = VideoFileClip(str(video_path))
# Handle audio: use embedded audio if no separate audio_path provided
if audio_path is not None and isinstance(audio_path, (str, Path)) and audio_path and Path(audio_path).exists():
# Load separate audio file and replace video's audio
logger.info(f"[StoryVideoGeneration] Replacing video audio with separate audio file: {audio_path}")
audio_clip = AudioFileClip(str(audio_path))
audio_duration = audio_clip.duration
video_clip = video_clip.with_audio(audio_clip)
# 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
video_clip = concatenate_videoclips([video_clip] * loops_needed).subclip(0, audio_duration)
video_clip = video_clip.with_audio(audio_clip)
else:
# Use embedded audio from video
logger.info(f"[StoryVideoGeneration] Using embedded audio from video for scene {scene_number}")
audio_duration = video_clip.duration
# Video already has audio, no need to replace
scene_clips.append(video_clip)
total_duration += audio_duration
elif audio_path is not None and isinstance(audio_path, (str, Path)) and audio_path and Path(audio_path).exists():
# No video, but we have audio - use with image or create blank
audio_file = Path(audio_path)
audio_clip = AudioFileClip(str(audio_file))
audio_duration = audio_clip.duration
if image_path is not None and isinstance(image_path, (str, Path)) and image_path and Path(image_path).exists():
# Fall back to static image with audio
logger.info(f"[StoryVideoGeneration] Using static image for scene {scene_number}: {image_path}")
image_file = Path(image_path)
# Create image clip (MoviePy v2: use with_* API)
image_clip = ImageClip(str(image_file)).with_duration(audio_duration)
image_clip = image_clip.with_fps(fps)
# Set audio to image clip
video_clip = image_clip.with_audio(audio_clip)
scene_clips.append(video_clip)
total_duration += audio_duration
else:
logger.warning(f"[StoryVideoGeneration] Audio provided but no video or image for scene {scene_number}, skipping")
continue
else:
logger.warning(f"[StoryVideoGeneration] No video, audio, or image found for scene {scene_number}, skipping")
continue
# Call progress callback if provided
if progress_callback:
progress = ((idx + 1) / len(scenes)) * 90 # Reserve 10% for final composition
progress_callback(progress, f"Processed scene {scene_number}/{len(scenes)}")
logger.info(f"[StoryVideoGeneration] Processed scene {idx + 1}/{len(scenes)}")
except Exception as e:
logger.error(
f"[StoryVideoGeneration] Failed to process scene {idx + 1} ({scene_number}): {e}\n"
f" video_path: {video_path} (type: {type(video_path)})\n"
f" audio_path: {audio_path} (type: {type(audio_path)})\n"
f" image_path: {image_path} (type: {type(image_path)})"
)
# Continue with next scene instead of failing completely
continue
if not scene_clips:
raise RuntimeError("No valid scene clips were created")
# Concatenate all scene clips
logger.info(f"[StoryVideoGeneration] Concatenating {len(scene_clips)} scene clips")
final_video = concatenate_videoclips(scene_clips, method="compose")
# Generate video filename
video_filename = self._generate_video_filename(story_title)
video_path = self.output_dir / video_filename
# Call progress callback
if progress_callback:
progress_callback(95, "Rendering final video...")
# Write video file
final_video.write_videofile(
str(video_path),
fps=fps,
codec='libx264',
audio_codec='aac',
preset='medium',
threads=4,
logger=None # Disable MoviePy's default logger
)
# Get file size
file_size = video_path.stat().st_size
# Clean up clips
final_video.close()
for clip in scene_clips:
clip.close()
# Call progress callback
if progress_callback:
progress_callback(100, "Video generation complete!")
logger.info(f"[StoryVideoGeneration] Saved story video to: {video_path} ({file_size} bytes)")
# Return video metadata
return {
"video_path": str(video_path),
"video_filename": video_filename,
"video_url": f"/api/story/videos/{video_filename}", # API endpoint to serve videos
"duration": total_duration,
"fps": fps,
"file_size": file_size,
"num_scenes": len(scene_clips),
}
except HTTPException:
# Re-raise HTTPExceptions (e.g., 429 subscription limit)
raise
except Exception as e:
logger.error(f"[StoryVideoGeneration] Error generating story video: {e}")
raise RuntimeError(f"Failed to generate story video: {str(e)}") from e

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
from loguru import logger
def log_video_stack_diagnostics() -> None:
try:
import sys
import platform
import importlib
mv = importlib.import_module("moviepy")
im = importlib.import_module("imageio")
try:
import imageio_ffmpeg as iff
ff = iff.get_ffmpeg_exe()
except Exception:
ff = "unresolved"
logger.info(
"[VideoStack] py={} plat={} moviepy={} imageio={} ffmpeg={}",
sys.executable,
platform.platform(),
getattr(mv, "__version__", "NA"),
getattr(im, "__version__", "NA"),
ff,
)
except Exception as e:
logger.error("[VideoStack] diagnostics failed: {}", e)
def assert_supported_moviepy() -> None:
"""Fail fast if MoviePy isn't version 2.x."""
try:
import pkg_resources as pr
mv = pr.get_distribution("moviepy").version
if not mv.startswith("2."):
raise RuntimeError(
f"Unsupported MoviePy version {mv}. Expected 2.x. "
"Please install with: pip install moviepy==2.1.2"
)
except Exception as e:
# Log and re-raise so startup fails clearly
logger.error("[VideoStack] version check failed: {}", e)
raise