项目重命名: ReelForge => Pixelle-Video
This commit is contained in:
179
pixelle_video/services/narration_generator.py
Normal file
179
pixelle_video/services/narration_generator.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Narration generation service
|
||||
|
||||
Supports two content sources:
|
||||
1. Topic: Generate narrations from a topic/theme
|
||||
2. Content: Extract/refine narrations from user-provided content
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import List, Optional, Literal
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from pixelle_video.models.storyboard import StoryboardConfig, ContentMetadata
|
||||
from pixelle_video.prompts import (
|
||||
build_topic_narration_prompt,
|
||||
build_content_narration_prompt,
|
||||
)
|
||||
|
||||
|
||||
class NarrationGeneratorService:
|
||||
"""Narration generation service"""
|
||||
|
||||
def __init__(self, pixelle_video_core):
|
||||
"""
|
||||
Initialize
|
||||
|
||||
Args:
|
||||
pixelle_video_core: PixelleVideoCore instance (for calling llm)
|
||||
"""
|
||||
self.core = pixelle_video_core
|
||||
|
||||
async def generate_narrations(
|
||||
self,
|
||||
config: StoryboardConfig,
|
||||
source_type: Literal["topic", "content"],
|
||||
content_metadata: Optional[ContentMetadata] = None,
|
||||
topic: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Generate storyboard narrations from different sources
|
||||
|
||||
Args:
|
||||
config: Storyboard configuration
|
||||
source_type: Type of content source ("topic" or "content")
|
||||
content_metadata: Content metadata (optional, not currently used)
|
||||
topic: Topic/theme (required if source_type="topic")
|
||||
content: User-provided content (required if source_type="content")
|
||||
|
||||
Returns:
|
||||
List of narration texts
|
||||
|
||||
Raises:
|
||||
ValueError: If parameters don't match source_type or narration count mismatch
|
||||
json.JSONDecodeError: If unable to parse LLM response as JSON
|
||||
|
||||
Examples:
|
||||
# Generate from topic
|
||||
>>> narrations = await service.generate_narrations(
|
||||
... config=config,
|
||||
... source_type="topic",
|
||||
... topic="如何提高学习效率"
|
||||
... )
|
||||
|
||||
# Generate from user content
|
||||
>>> narrations = await service.generate_narrations(
|
||||
... config=config,
|
||||
... source_type="content",
|
||||
... content="Today I want to share three useful tips..."
|
||||
... )
|
||||
"""
|
||||
# 1. Build prompt based on source_type
|
||||
if source_type == "topic":
|
||||
if topic is None:
|
||||
raise ValueError("topic is required when source_type='topic'")
|
||||
logger.info(f"Generating topic narrations for: {topic}")
|
||||
prompt = build_topic_narration_prompt(
|
||||
topic=topic,
|
||||
n_storyboard=config.n_storyboard,
|
||||
min_words=config.min_narration_words,
|
||||
max_words=config.max_narration_words
|
||||
)
|
||||
|
||||
else: # content
|
||||
if content is None:
|
||||
raise ValueError("content is required when source_type='content'")
|
||||
logger.info(f"Generating narrations from user content ({len(content)} chars)")
|
||||
prompt = build_content_narration_prompt(
|
||||
content=content,
|
||||
n_storyboard=config.n_storyboard,
|
||||
min_words=config.min_narration_words,
|
||||
max_words=config.max_narration_words
|
||||
)
|
||||
|
||||
# 2. Call LLM (using self.core.llm)
|
||||
response = await self.core.llm(
|
||||
prompt=prompt,
|
||||
temperature=0.8, # Higher temperature for more creativity
|
||||
max_tokens=2000
|
||||
)
|
||||
|
||||
logger.debug(f"LLM response: {response[:200]}...")
|
||||
|
||||
# 3. Parse JSON
|
||||
try:
|
||||
result = self._parse_json(response)
|
||||
narrations = result["narrations"]
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse LLM response as JSON: {e}")
|
||||
logger.error(f"Response: {response}")
|
||||
raise
|
||||
except KeyError:
|
||||
logger.error("Response JSON missing 'narrations' key")
|
||||
logger.error(f"Response: {response}")
|
||||
raise ValueError("Invalid response format")
|
||||
|
||||
# 4. Validate count (take first N if got more)
|
||||
if len(narrations) > config.n_storyboard:
|
||||
logger.warning(
|
||||
f"Got {len(narrations)} narrations, taking first {config.n_storyboard}"
|
||||
)
|
||||
narrations = narrations[:config.n_storyboard]
|
||||
elif len(narrations) < config.n_storyboard:
|
||||
raise ValueError(
|
||||
f"Expected at least {config.n_storyboard} narrations, "
|
||||
f"got only {len(narrations)}"
|
||||
)
|
||||
|
||||
# 5. Validate word count for each narration
|
||||
for i, text in enumerate(narrations):
|
||||
word_count = len(text)
|
||||
if word_count < config.min_narration_words:
|
||||
logger.warning(
|
||||
f"Narration {i} too short: {word_count} chars "
|
||||
f"(min: {config.min_narration_words})"
|
||||
)
|
||||
|
||||
logger.info(f"Generated {len(narrations)} narrations successfully")
|
||||
return narrations
|
||||
|
||||
def _parse_json(self, text: str) -> dict:
|
||||
"""
|
||||
Parse JSON from text, with fallback to extract JSON from markdown code blocks
|
||||
|
||||
Args:
|
||||
text: Text containing JSON
|
||||
|
||||
Returns:
|
||||
Parsed JSON dict
|
||||
"""
|
||||
# Try direct parsing first
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Try to extract JSON from markdown code block
|
||||
json_pattern = r'```(?:json)?\s*([\s\S]+?)\s*```'
|
||||
match = re.search(json_pattern, text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group(1))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Try to find any JSON object in the text
|
||||
json_pattern = r'\{[^{}]*"narrations"\s*:\s*\[[^\]]*\][^{}]*\}'
|
||||
match = re.search(json_pattern, text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group(0))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# If all fails, raise error
|
||||
raise json.JSONDecodeError("No valid JSON found", text, 0)
|
||||
|
||||
Reference in New Issue
Block a user