351 lines
13 KiB
Python
351 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Generate video for specific PPT slides (slides 2 & 3)
|
||
Extract information from PDF but only display PPT slides
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Dict, List
|
||
from dotenv import load_dotenv
|
||
from google import genai
|
||
from google.genai import types
|
||
|
||
# Load environment
|
||
load_dotenv()
|
||
|
||
|
||
class PPTSlidesVideoGenerator:
|
||
def __init__(self, slide_indices: List[int] = [1, 2]):
|
||
"""
|
||
Initialize generator for specific PPT slides
|
||
|
||
Args:
|
||
slide_indices: List of slide indices to generate video for (0-based)
|
||
Default: [1, 2] means slide 2 and slide 3 (slide_002.png, slide_003.png)
|
||
"""
|
||
self.project_root = Path(__file__).parent.parent
|
||
self.slide_indices = slide_indices
|
||
|
||
# Initialize Gemini client
|
||
api_key = os.getenv("GEMINI_API_KEY")
|
||
if not api_key:
|
||
raise ValueError("GEMINI_API_KEY not found in environment")
|
||
self.client = genai.Client(api_key=api_key)
|
||
|
||
def load_materials(self) -> Dict:
|
||
"""Load PPT slides and PDF data"""
|
||
print("📦 Loading materials...")
|
||
|
||
# Load existing render_props to get PPT slides info
|
||
props_path = self.project_root / "assets/1_processed/render_props.json"
|
||
with open(props_path, encoding="utf-8") as f:
|
||
props_data = json.load(f)
|
||
|
||
all_slides = props_data["assets"]["slides"]
|
||
target_slides = [slide for slide in all_slides if slide["index"] in self.slide_indices]
|
||
|
||
print(f"✓ Loaded {len(target_slides)} PPT slides (indices: {self.slide_indices})")
|
||
|
||
# Extract PDF data using Gemini
|
||
pdf_data = self.extract_pdf_data()
|
||
|
||
return {
|
||
"ppt_slides": target_slides,
|
||
"pdf_data": pdf_data,
|
||
"existing_props": props_data
|
||
}
|
||
|
||
def extract_pdf_data(self) -> Dict:
|
||
"""Extract relevant data from PDF using Gemini"""
|
||
print("📄 Extracting data from PDF...")
|
||
|
||
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||
|
||
# Upload PDF to Gemini
|
||
with open(pdf_path, 'rb') as f:
|
||
upload_config = types.UploadFileConfig(mime_type='application/pdf')
|
||
pdf_file = self.client.files.upload(file=f, config=upload_config)
|
||
|
||
# Wait for processing
|
||
import time
|
||
while pdf_file.state == "PROCESSING":
|
||
print(" ⏳ Processing PDF...")
|
||
time.sleep(2)
|
||
pdf_file = self.client.files.get(name=pdf_file.name)
|
||
|
||
if pdf_file.state == "FAILED":
|
||
raise RuntimeError(f"PDF processing failed: {pdf_file.error}")
|
||
|
||
print(" ✓ PDF uploaded successfully")
|
||
|
||
# Extraction prompt
|
||
prompt = """
|
||
このPDFからライフプラン情報を詳細に抽出してください。
|
||
|
||
以下のJSON形式で出力してください:
|
||
|
||
{
|
||
"client_info": {
|
||
"name": "クライアント名",
|
||
"birth_date": "生年月日",
|
||
"age": 年齢,
|
||
"occupation": "業種",
|
||
"annual_income": 年収(万円)
|
||
},
|
||
"spouse_info": {
|
||
"name": "配偶者名",
|
||
"birth_date": "生年月日",
|
||
"age": 年齢,
|
||
"occupation": "業種",
|
||
"annual_income": 年収(万円)
|
||
},
|
||
"children": [
|
||
{
|
||
"name": "子供の名前",
|
||
"birth_date": "生年月日",
|
||
"age": 年齢,
|
||
"education_plan": "教育プラン(保育園→小学校→中学校など)"
|
||
}
|
||
],
|
||
"financial_summary": {
|
||
"household_income": 世帯年収(万円),
|
||
"annual_expenses": 年間支出(万円),
|
||
"current_assets": 現在資産(万円),
|
||
"savings": 貯蓄額(万円)
|
||
},
|
||
"life_events": [
|
||
{
|
||
"year": イベント発生年,
|
||
"age": その時の年齢,
|
||
"description": "イベント説明",
|
||
"cost": 費用(万円)
|
||
}
|
||
],
|
||
"future_projections": {
|
||
"retirement_age": 退職年齢,
|
||
"life_expectancy": 想定寿命,
|
||
"retirement_savings_needed": 必要な老後資金(万円)
|
||
}
|
||
}
|
||
|
||
可能な限り詳細な情報を抽出してください。数値は必ず含めてください。
|
||
"""
|
||
|
||
response = self.client.models.generate_content(
|
||
model="gemini-2.0-flash",
|
||
contents=[pdf_file, prompt],
|
||
config=types.GenerateContentConfig(
|
||
temperature=0.1,
|
||
response_mime_type="application/json"
|
||
)
|
||
)
|
||
|
||
pdf_data = json.loads(response.text)
|
||
print("✓ PDF data extracted successfully")
|
||
return pdf_data
|
||
|
||
def generate_script(self, materials: Dict) -> List[Dict]:
|
||
"""Generate narration script for PPT slides based on PDF data"""
|
||
print("🤖 Generating narration script...")
|
||
|
||
ppt_slides = materials["ppt_slides"]
|
||
pdf_data = materials["pdf_data"]
|
||
|
||
# Handle case where pdf_data might be a list or dict
|
||
if isinstance(pdf_data, list):
|
||
pdf_data = pdf_data[0] if pdf_data else {}
|
||
|
||
# Build detailed context from PDF
|
||
client_info = pdf_data.get("client_info", {})
|
||
spouse_info = pdf_data.get("spouse_info", {})
|
||
children = pdf_data.get("children", [])
|
||
financial = pdf_data.get("financial_summary", {})
|
||
life_events = pdf_data.get("life_events", [])
|
||
|
||
prompt = f"""
|
||
あなたはプロのファイナンシャルプランナーであり、動画ナレーターです。
|
||
|
||
【重要】以下の2枚のPPTスライドについて、PDFから抽出した詳細情報を使って、
|
||
豊富で魅力的なナレーション原稿を作成してください。
|
||
|
||
【利用可能なPPTスライド】
|
||
スライド1 (slide_002.png): インデックス={ppt_slides[0]["index"]}
|
||
スライド2 (slide_003.png): インデックス={ppt_slides[1]["index"] if len(ppt_slides) > 1 else "N/A"}
|
||
|
||
【PDFから抽出した詳細情報】
|
||
■ クライアント情報:
|
||
{json.dumps(client_info, ensure_ascii=False, indent=2)}
|
||
|
||
■ 配偶者情報:
|
||
{json.dumps(spouse_info, ensure_ascii=False, indent=2)}
|
||
|
||
■ 子供情報:
|
||
{json.dumps(children, ensure_ascii=False, indent=2)}
|
||
|
||
■ 財務サマリー:
|
||
{json.dumps(financial, ensure_ascii=False, indent=2)}
|
||
|
||
■ ライフイベント (最初の5件):
|
||
{json.dumps(life_events[:5], ensure_ascii=False, indent=2)}
|
||
|
||
【要件】
|
||
1. **2枚のPPTスライドのみを使用**してください
|
||
- スライド1枚あたり2-4個のセグメントを作成
|
||
- 合計で4-6個のセグメントを生成
|
||
|
||
2. 各セグメントの構造:
|
||
- id: セグメントID (例: "slide2_intro", "slide2_family", "slide3_events")
|
||
- text: ナレーション文 (30-60秒分の自然な日本語、具体的な数値を多く含む)
|
||
- duration: 推定時間(秒、実際の音声生成後に更新されます)
|
||
- visualType: "ppt" (固定)
|
||
- visualAssetIndex: PPTスライドのインデックス (0始まり)
|
||
- highlightArea: "full" (固定)
|
||
|
||
3. **ナレーション作成のポイント**:
|
||
- PDFから抽出した具体的な数値を必ず使用(名前、年齢、生年月日、年収、業種など)
|
||
- 温かみのある親しみやすい語り口
|
||
- 視聴者が理解しやすいように、情報を整理して伝える
|
||
- 各セグメント30-60秒程度の適切な長さ
|
||
- 同じスライドを連続して使用しても良い(内容が異なれば)
|
||
|
||
4. **セグメント例**:
|
||
- スライド2の前半: 家族構成の紹介
|
||
- スライド2の後半: 各メンバーの詳細(年齢、職業、年収など)
|
||
- スライド3の前半: ライフイベントの概要
|
||
- スライド3の後半: 具体的なイベントと費用
|
||
|
||
**出力形式**: JSON配列のみを返してください。
|
||
"""
|
||
|
||
response = self.client.models.generate_content(
|
||
model="gemini-2.0-flash",
|
||
contents=prompt,
|
||
config=types.GenerateContentConfig(
|
||
temperature=0.7,
|
||
response_mime_type="application/json"
|
||
)
|
||
)
|
||
|
||
segments = json.loads(response.text)
|
||
print(f"✓ Generated {len(segments)} script segments")
|
||
|
||
# Display preview
|
||
print("\n📝 Script Preview:")
|
||
for i, seg in enumerate(segments, 1):
|
||
print(f"\n{i}. [{seg['id']}] - Slide {seg['visualAssetIndex'] + 1}")
|
||
print(f" Text: {seg['text'][:100]}...")
|
||
|
||
return segments
|
||
|
||
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||
"""Generate TTS audio for each segment"""
|
||
sys.path.insert(0, str(self.project_root))
|
||
from backend.audio_generator import AudioGenerator
|
||
from backend.video_processor.utils import get_video_duration
|
||
|
||
print("\n🎙️ Generating audio...")
|
||
|
||
audio_gen = AudioGenerator()
|
||
output_dir = self.project_root / "public" / "ppt_slides_audio"
|
||
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}/{len(segments)}] {seg['id']}: Generating audio...")
|
||
|
||
try:
|
||
response = audio_gen.client.audio.speech.create(
|
||
model="tts-1",
|
||
voice="nova",
|
||
input=text
|
||
)
|
||
response.stream_to_file(str(audio_path))
|
||
|
||
actual_duration = get_video_duration(str(audio_path))
|
||
seg["duration"] = actual_duration
|
||
seg["audioPath"] = f"public/ppt_slides_audio/{audio_filename}"
|
||
print(f" ✓ Duration: {actual_duration:.2f}s")
|
||
except Exception as e:
|
||
print(f" ⚠️ Failed: {e}")
|
||
seg["duration"] = 5.0 # Fallback duration
|
||
continue
|
||
|
||
total_duration = sum(s.get("duration", 0) for s in segments)
|
||
print(f"\n✓ Generated {len(segments)} audio files (Total: {total_duration:.1f}s)")
|
||
return segments
|
||
|
||
def create_render_props(self, segments: List[Dict], materials: Dict) -> Dict:
|
||
"""Create render_props with only PPT slides"""
|
||
|
||
existing_props = materials["existing_props"]
|
||
|
||
new_props = {
|
||
"meta": {
|
||
"title": f"ライフプラン紹介 - スライド{self.slide_indices[0]+1}・{self.slide_indices[1]+1 if len(self.slide_indices) > 1 else ''}",
|
||
"currencyUnit": "万円",
|
||
"duration": sum(s.get("duration", 0) for s in segments),
|
||
"clientName": materials["pdf_data"].get("client_info", {}).get("name", "お客様")
|
||
},
|
||
"charts": existing_props.get("charts", {}),
|
||
"script": segments,
|
||
"assets": {
|
||
"slides": existing_props["assets"]["slides"], # Keep all slides
|
||
"videoClips": [],
|
||
"broll": existing_props["assets"].get("broll", {})
|
||
}
|
||
}
|
||
|
||
output_path = self.project_root / "assets/1_processed/render_props_ppt_slides.json"
|
||
with open(output_path, "w", encoding="utf-8") as f:
|
||
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||
|
||
print(f"\n✓ Saved render props to {output_path.name}")
|
||
return new_props
|
||
|
||
def run(self) -> str:
|
||
"""Execute full pipeline"""
|
||
print(f"🚀 Generating video for PPT slides {[i+1 for i in self.slide_indices]}...\n")
|
||
|
||
# Load materials
|
||
materials = self.load_materials()
|
||
print()
|
||
|
||
# Generate script
|
||
segments = self.generate_script(materials)
|
||
print()
|
||
|
||
# Generate audio
|
||
segments = self.generate_audio(segments)
|
||
print()
|
||
|
||
# Create render props
|
||
props = self.create_render_props(segments, materials)
|
||
print()
|
||
|
||
output_file = "assets/2_output/ppt_slides_video.mp4"
|
||
print(f"✅ Pipeline complete!")
|
||
print(f"\n📹 Render command:")
|
||
print(f" npx remotion render MainVideo {output_file} \\")
|
||
print(f" --props=\"assets/1_processed/render_props_ppt_slides.json\" --concurrency=1")
|
||
print()
|
||
print(f"📊 Video Summary:")
|
||
print(f" - Target slides: {[f'slide_{i+1:03d}.png' for i in self.slide_indices]}")
|
||
print(f" - Total segments: {len(segments)}")
|
||
print(f" - Total duration: {props['meta']['duration']:.1f}s")
|
||
print(f" - Client: {props['meta']['clientName']}")
|
||
|
||
return output_file
|
||
|
||
|
||
if __name__ == "__main__":
|
||
# Generate video for slides 2 and 3 (indices 1 and 2)
|
||
generator = PPTSlidesVideoGenerator(slide_indices=[1, 2])
|
||
generator.run()
|