284 lines
10 KiB
Python
284 lines
10 KiB
Python
"""
|
||
Generate detailed narration video for specific PPT slide with PDF context
|
||
|
||
This script creates a comprehensive narration video combining:
|
||
1. PPT slide content (text + visual)
|
||
2. PDF financial data context
|
||
3. AI-generated detailed explanation
|
||
4. Smooth transitions between content sections
|
||
|
||
Usage:
|
||
python backend/generate_detailed_narration.py --slide 2
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
from typing import Dict, Any, List
|
||
from dotenv import load_dotenv
|
||
|
||
# Import Gemini API
|
||
from google import genai
|
||
from google.genai import types
|
||
|
||
|
||
class DetailedNarrationGenerator:
|
||
"""Generate detailed narration video for specific slide"""
|
||
|
||
def __init__(self, slide_index: int = 2):
|
||
load_dotenv()
|
||
self.project_root = Path(__file__).parent.parent
|
||
self.slide_index = slide_index
|
||
self.api_key = os.getenv("GEMINI_API_KEY")
|
||
|
||
if not self.api_key:
|
||
raise ValueError("GEMINI_API_KEY not found in .env")
|
||
|
||
self.client = genai.Client(api_key=self.api_key)
|
||
|
||
def load_ppt_slide(self) -> Dict[str, Any]:
|
||
"""Load specific PPT slide data"""
|
||
import sys
|
||
sys.path.insert(0, str(self.project_root))
|
||
from backend.ppt_extractor import extract_ppt_data
|
||
|
||
ppt_path = self.project_root / "assets/0_input/ライフプラン情報.pptx"
|
||
ppt_data = extract_ppt_data(str(ppt_path))
|
||
|
||
if self.slide_index >= len(ppt_data["slides"]):
|
||
raise ValueError(f"Slide {self.slide_index} not found")
|
||
|
||
return ppt_data["slides"][self.slide_index]
|
||
|
||
def load_pdf_context(self) -> Dict[str, Any]:
|
||
"""Load PDF financial data as context"""
|
||
import sys
|
||
sys.path.insert(0, str(self.project_root))
|
||
from backend.extractor import extract_pdf_data
|
||
|
||
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||
return extract_pdf_data(str(pdf_path))
|
||
|
||
def generate_narration_script(self, slide: Dict, pdf_data: Dict) -> List[Dict]:
|
||
"""
|
||
Generate detailed narration script using Gemini
|
||
|
||
Returns:
|
||
List of narration segments with text, duration, visual cues
|
||
"""
|
||
print(f"🤖 Generating detailed narration for slide {self.slide_index}...")
|
||
|
||
prompt = f"""
|
||
あなたはプロのファイナンシャルプランナーであり、親しみやすい動画解説者です。
|
||
|
||
以下の情報に基づいて、詳細な解説動画用のナレーション原稿を作成してください。
|
||
|
||
【PPTスライド内容】
|
||
タイトル: {slide['title']}
|
||
テキスト:
|
||
{slide['text']}
|
||
|
||
【PDFから抽出した追加情報】
|
||
- 家族代表: {pdf_data.get('client_name', 'テスト太郎')} ({pdf_data.get('current_age', 47)}歳)
|
||
- 年収: {pdf_data.get('annual_income', 703)}万円
|
||
- 現在の資産: {pdf_data.get('current_assets', 836)}万円
|
||
- 年間支出: {pdf_data.get('annual_expenditure', 1038)}万円
|
||
|
||
【要件】
|
||
1. スライドの内容を**段落ごとに分解**し、各段落を詳細に解説してください
|
||
2. 各セグメントは以下の構造で出力してください:
|
||
- intro: スライド全体の導入(15秒程度)
|
||
- section_1: 家族代表の基本情報(20秒程度)
|
||
- section_2: 配偶者情報(15秒程度)
|
||
- section_3: お子様情報と教育プラン(20秒程度)
|
||
- summary: まとめ(10秒程度)
|
||
|
||
3. 各セグメントには以下を含めてください:
|
||
- text: ナレーション文(自然な口語体、具体的な数値を含む)
|
||
- duration: 推定時間(秒)
|
||
- visualCue: 画面表示の指示("ppt" / "zoom_in" / "highlight_section")
|
||
- highlightArea: 強調する領域("top" / "middle" / "bottom" / "full")
|
||
|
||
4. 数値やデータは必ず具体的に言及し、抽象的な表現は避けてください
|
||
5. 親しみやすく、わかりやすい言葉遣いを心がけてください
|
||
|
||
**出力形式**: JSON配列として返してください
|
||
[
|
||
{{
|
||
"id": "intro",
|
||
"text": "それでは、ご家族の基本情報について詳しく見ていきましょう。...",
|
||
"duration": 15,
|
||
"visualCue": "ppt",
|
||
"highlightArea": "full"
|
||
}},
|
||
...
|
||
]
|
||
"""
|
||
|
||
response = self.client.models.generate_content(
|
||
model="gemini-2.0-flash",
|
||
contents=prompt,
|
||
config=types.GenerateContentConfig(
|
||
temperature=0.7,
|
||
response_mime_type="application/json"
|
||
)
|
||
)
|
||
|
||
result = json.loads(response.text)
|
||
print(f"✓ Generated {len(result)} narration segments")
|
||
return result
|
||
|
||
def create_smooth_transitions(self, segments: List[Dict]) -> List[Dict]:
|
||
"""Add transition effects between segments"""
|
||
enhanced_segments = []
|
||
|
||
for i, seg in enumerate(segments):
|
||
# Enhance segment with visual metadata
|
||
enhanced_seg = {
|
||
**seg,
|
||
"type": "narration",
|
||
"visualType": "ppt",
|
||
"visualAssetIndex": self.slide_index,
|
||
"brollKeyword": f"Japanese family planning {seg.get('highlightArea', 'full')}",
|
||
}
|
||
|
||
enhanced_segments.append(enhanced_seg)
|
||
|
||
return enhanced_segments
|
||
|
||
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||
"""Generate TTS audio for each segment"""
|
||
import sys
|
||
sys.path.insert(0, str(self.project_root))
|
||
from backend.audio_generator import AudioGenerator
|
||
from backend.video_processor.utils import get_video_duration
|
||
|
||
print("🎙️ Generating audio for narration segments...")
|
||
|
||
audio_gen = AudioGenerator()
|
||
output_dir = self.project_root / "public" / f"slide_{self.slide_index}_narration"
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
for i, seg in enumerate(segments):
|
||
text = seg["text"]
|
||
audio_filename = f"narration_{seg['id']}.mp3"
|
||
audio_path = output_dir / audio_filename
|
||
|
||
print(f" [{i+1}] Generating: {text[:50]}...")
|
||
|
||
# Use OpenAI TTS API directly
|
||
try:
|
||
response = audio_gen.client.audio.speech.create(
|
||
model="tts-1",
|
||
voice="nova",
|
||
input=text
|
||
)
|
||
response.stream_to_file(str(audio_path))
|
||
|
||
# Get actual duration
|
||
actual_duration = get_video_duration(str(audio_path))
|
||
seg["duration"] = actual_duration
|
||
|
||
seg["audioPath"] = f"public/slide_{self.slide_index}_narration/{audio_filename}"
|
||
except Exception as e:
|
||
print(f" ⚠️ Failed: {e}")
|
||
continue
|
||
|
||
print(f"✓ Generated audio for {len(segments)} segments")
|
||
return segments
|
||
|
||
def create_render_props(self, segments: List[Dict], slide: Dict, pdf_data: Dict) -> Dict:
|
||
"""Create render_props.json for the detailed narration video"""
|
||
|
||
# Load original props for charts
|
||
original_props_path = self.project_root / "assets/1_processed/render_props.json"
|
||
if original_props_path.exists():
|
||
with open(original_props_path, "r", encoding="utf-8") as f:
|
||
original_props = json.load(f)
|
||
else:
|
||
original_props = {"charts": {}, "assets": {"slides": []}}
|
||
|
||
# Construct new props
|
||
new_props = {
|
||
"meta": {
|
||
"title": f"【詳細解説】{slide['title']} - {pdf_data.get('client_name', 'お客様')}様",
|
||
"currencyUnit": "万円",
|
||
"duration": sum(s.get("duration", 0) for s in segments),
|
||
"clientName": pdf_data.get("client_name", "お客様")
|
||
},
|
||
"charts": original_props.get("charts", {}),
|
||
"script": segments,
|
||
"assets": {
|
||
**original_props.get("assets", {}),
|
||
"targetSlide": slide
|
||
}
|
||
}
|
||
|
||
# Save to file
|
||
output_path = self.project_root / f"assets/1_processed/render_props_slide_{self.slide_index}_detailed.json"
|
||
with open(output_path, "w", encoding="utf-8") as f:
|
||
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||
|
||
print(f"✓ Saved render props to {output_path.name}")
|
||
return new_props
|
||
|
||
def run(self) -> str:
|
||
"""Execute full pipeline"""
|
||
print(f"🚀 Generating detailed narration video for slide {self.slide_index}...")
|
||
print()
|
||
|
||
# Load data
|
||
slide = self.load_ppt_slide()
|
||
pdf_data = self.load_pdf_context()
|
||
print(f"✓ Loaded slide: {slide['title']}")
|
||
print(f"✓ Loaded PDF context for: {pdf_data.get('client_name')}")
|
||
print()
|
||
|
||
# Generate narration
|
||
segments = self.generate_narration_script(slide, pdf_data)
|
||
print()
|
||
|
||
# Add transitions
|
||
segments = self.create_smooth_transitions(segments)
|
||
print(f"✓ Added smooth transitions")
|
||
print()
|
||
|
||
# Generate audio
|
||
segments = self.generate_audio(segments)
|
||
print()
|
||
|
||
# Create render props
|
||
props = self.create_render_props(segments, slide, pdf_data)
|
||
print()
|
||
|
||
output_file = f"assets/2_output/slide_{self.slide_index}_detailed.mp4"
|
||
print(f"✅ Pipeline complete! Use the following command to render:")
|
||
print(f" npx remotion render MainVideo {output_file} \\")
|
||
print(f" --props=\"assets/1_processed/render_props_slide_{self.slide_index}_detailed.json\"")
|
||
|
||
return output_file
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description="Generate detailed narration video for specific PPT slide"
|
||
)
|
||
parser.add_argument("--slide", type=int, default=2, help="Slide index (default: 2)")
|
||
args = parser.parse_args()
|
||
|
||
try:
|
||
generator = DetailedNarrationGenerator(slide_index=args.slide)
|
||
generator.run()
|
||
except Exception as e:
|
||
print(f"\n❌ Generation failed: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return 1
|
||
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
exit(main())
|