362 lines
13 KiB
Python
362 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Generate introduction video for PPT slides 2 & 3 with API-based content generation
|
||
- Extract detailed info from PDF using Gemini API
|
||
- Generate rich narration based on PDF data
|
||
- Only display PPT slides (no PDF page switching)
|
||
- Generate audio using OpenAI TTS
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
import sys
|
||
import time
|
||
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 PPTIntroVideoGenerator:
|
||
def __init__(self):
|
||
self.project_root = Path(__file__).parent.parent
|
||
|
||
# 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)
|
||
|
||
print("✓ Gemini API client initialized")
|
||
|
||
def extract_pdf_info(self) -> Dict:
|
||
"""Extract detailed information from PDF using Gemini"""
|
||
print("\n📄 Extracting information from PDF...")
|
||
|
||
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||
|
||
# Upload PDF to Gemini
|
||
print(" Uploading PDF...")
|
||
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
|
||
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 - focus on family and life events
|
||
prompt = """
|
||
このPDFから、家族紹介とライフイベントに関する詳細情報を抽出してください。
|
||
|
||
以下のJSON形式で出力してください:
|
||
|
||
{
|
||
"family": {
|
||
"representative": {
|
||
"name": "代表者名",
|
||
"age": 年齢,
|
||
"birth_date": "生年月日",
|
||
"occupation": "業種",
|
||
"annual_income": 年収(万円),
|
||
"description": "簡単な説明"
|
||
},
|
||
"spouse": {
|
||
"name": "配偶者名",
|
||
"age": 年齢,
|
||
"birth_date": "生年月日",
|
||
"occupation": "業種",
|
||
"annual_income": 年収(万円),
|
||
"description": "簡単な説明"
|
||
},
|
||
"children": [
|
||
{
|
||
"name": "子供の呼称",
|
||
"age": 年齢,
|
||
"birth_date": "生年月日",
|
||
"education_plan": "進学プラン(保育園→小学校→中学校など)",
|
||
"description": "簡単な説明"
|
||
}
|
||
],
|
||
"household": {
|
||
"total_income": 世帯年収(万円),
|
||
"annual_expenses": 年間支出(万円),
|
||
"current_assets": 現在資産(万円),
|
||
"savings_per_year": 年間貯蓄額(万円)
|
||
}
|
||
},
|
||
"life_events": [
|
||
{
|
||
"year": イベント発生年,
|
||
"age_representative": その時の代表者年齢,
|
||
"event_name": "イベント名",
|
||
"description": "詳細説明",
|
||
"cost": 費用(万円),
|
||
"category": "カテゴリー(教育/住宅/退職など)"
|
||
}
|
||
],
|
||
"financial_outlook": {
|
||
"retirement_age": 退職年齢,
|
||
"life_expectancy": 想定寿命,
|
||
"key_concerns": ["懸念事項1", "懸念事項2"],
|
||
"opportunities": ["機会1", "機会2"]
|
||
}
|
||
}
|
||
|
||
可能な限り具体的で詳細な情報を抽出してください。
|
||
"""
|
||
|
||
print(" Analyzing PDF content...")
|
||
response = self.client.models.generate_content(
|
||
model="gemini-2.0-flash-exp",
|
||
contents=[pdf_file, prompt],
|
||
config=types.GenerateContentConfig(
|
||
temperature=0.1,
|
||
response_mime_type="application/json"
|
||
)
|
||
)
|
||
|
||
pdf_info = json.loads(response.text)
|
||
print(" ✓ PDF information extracted")
|
||
|
||
# Print summary
|
||
family = pdf_info.get("family", {})
|
||
rep = family.get("representative", {})
|
||
print(f"\n 📊 Extracted Info:")
|
||
print(f" - Client: {rep.get('name')}")
|
||
print(f" - Household income: {family.get('household', {}).get('total_income')}万円")
|
||
print(f" - Life events: {len(pdf_info.get('life_events', []))} events")
|
||
|
||
return pdf_info
|
||
|
||
def generate_narration_script(self, pdf_info: Dict) -> List[Dict]:
|
||
"""Generate narration script using Gemini based on PDF info"""
|
||
print("\n🤖 Generating narration script...")
|
||
|
||
family = pdf_info.get("family", {})
|
||
events = pdf_info.get("life_events", [])
|
||
outlook = pdf_info.get("financial_outlook", {})
|
||
|
||
prompt = f"""
|
||
あなたはプロのファイナンシャルプランナーであり、温かみのある動画ナレーターです。
|
||
|
||
【重要な制約】
|
||
- PPTスライド2枚(slide_002.png と slide_003.png)のみを使用
|
||
- スライド2: 家族基本情報(家族構成、年齢、職業、年収)
|
||
- スライド3: ライフイベント(将来の予定)
|
||
- 各スライドにつき2-4個のセグメントを作成
|
||
- 合計5-7個のセグメントを生成
|
||
|
||
【抽出したPDF情報】
|
||
■ 家族情報:
|
||
{json.dumps(family, ensure_ascii=False, indent=2)}
|
||
|
||
■ ライフイベント(最初の5件):
|
||
{json.dumps(events[:5] if events else [], ensure_ascii=False, indent=2)}
|
||
|
||
■ 将来展望:
|
||
{json.dumps(outlook, ensure_ascii=False, indent=2)}
|
||
|
||
【要件】
|
||
1. セグメント構造:
|
||
- id: ユニークなID(例: "s2_intro", "s2_family", "s3_events")
|
||
- text: ナレーション文(30-50秒分、温かく親しみやすい語り口)
|
||
- estimatedDuration: 推定時間(秒、実際の音声生成後に更新されます)
|
||
- visualType: "ppt" (固定)
|
||
- visualAssetIndex: 1(スライド2)または 2(スライド3)
|
||
- highlightArea: "full" (固定)
|
||
|
||
2. ナレーションのポイント:
|
||
- PDFの具体的な数値を必ず含める(名前、年齢、年収、費用など)
|
||
- 親しみやすく温かい語り口
|
||
- 各セグメント30-50秒程度
|
||
- 自然な流れで情報を伝える
|
||
- 聞き手が理解しやすいように整理
|
||
|
||
3. セグメント配分例:
|
||
【スライド2(家族情報)】
|
||
- セグメント1: 家族紹介の導入(10-15秒)
|
||
- セグメント2: 代表者の詳細(25-35秒)
|
||
- セグメント3: 配偶者と子供の詳細(30-40秒)
|
||
- セグメント4: 家計サマリー(20-30秒)
|
||
|
||
【スライド3(ライフイベント)】
|
||
- セグメント5: イベント概要の導入(10-15秒)
|
||
- セグメント6: 具体的なイベント紹介(35-45秒)
|
||
- セグメント7: 将来への展望(20-30秒)
|
||
|
||
4. 推定時間の設定:
|
||
- 日本語の読み上げ速度: 約300-350文字/分(5-6文字/秒)
|
||
- テキストの長さから適切な時間を推定
|
||
|
||
【出力】
|
||
JSON配列のみを返してください。説明文は不要です。
|
||
|
||
例:
|
||
[
|
||
{{
|
||
"id": "s2_intro",
|
||
"text": "それでは、テスト太郎様のご家族についてご紹介いたします...",
|
||
"estimatedDuration": 12,
|
||
"visualType": "ppt",
|
||
"visualAssetIndex": 1,
|
||
"highlightArea": "full"
|
||
}},
|
||
...
|
||
]
|
||
"""
|
||
|
||
response = self.client.models.generate_content(
|
||
model="gemini-2.0-flash-exp",
|
||
contents=prompt,
|
||
config=types.GenerateContentConfig(
|
||
temperature=0.8,
|
||
response_mime_type="application/json"
|
||
)
|
||
)
|
||
|
||
segments = json.loads(response.text)
|
||
print(f" ✓ Generated {len(segments)} narration segments")
|
||
|
||
# Display preview
|
||
print("\n 📝 Script Preview:")
|
||
for i, seg in enumerate(segments, 1):
|
||
slide_num = seg['visualAssetIndex'] + 1
|
||
print(f"\n {i}. [{seg['id']}] - Slide {slide_num} ({seg.get('estimatedDuration', 0)}s)")
|
||
print(f" {seg['text'][:80]}...")
|
||
|
||
return segments
|
||
|
||
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||
"""Generate TTS audio using OpenAI"""
|
||
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 with OpenAI TTS...")
|
||
|
||
audio_gen = AudioGenerator()
|
||
output_dir = self.project_root / "public" / "ppt_intro_audio"
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
for i, seg in enumerate(segments, 1):
|
||
text = seg["text"]
|
||
audio_filename = f"narration_{seg['id']}.mp3"
|
||
audio_path = output_dir / audio_filename
|
||
|
||
print(f" [{i}/{len(segments)}] {seg['id']}: Generating...")
|
||
|
||
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_intro_audio/{audio_filename}"
|
||
print(f" ✓ {actual_duration:.2f}s")
|
||
except Exception as e:
|
||
print(f" ⚠️ Failed: {e}")
|
||
seg["duration"] = seg.get("estimatedDuration", 10)
|
||
continue
|
||
|
||
total_duration = sum(s.get("duration", 0) for s in segments)
|
||
print(f"\n ✓ Generated {len(segments)} audio files ({total_duration:.1f}s total)")
|
||
return segments
|
||
|
||
def create_render_props(self, segments: List[Dict]) -> Dict:
|
||
"""Create render_props configuration"""
|
||
print("\n📦 Creating render props...")
|
||
|
||
# Load existing render_props for charts and assets
|
||
props_path = self.project_root / "assets/1_processed/render_props.json"
|
||
with open(props_path, encoding="utf-8") as f:
|
||
existing_props = json.load(f)
|
||
|
||
new_props = {
|
||
"meta": {
|
||
"title": "ライフプラン紹介 - 家族とイベント",
|
||
"currencyUnit": "万円",
|
||
"duration": sum(s.get("duration", 0) for s in segments),
|
||
"clientName": existing_props["meta"].get("clientName", "お客様")
|
||
},
|
||
"charts": existing_props.get("charts", {}),
|
||
"script": segments,
|
||
"assets": {
|
||
"slides": existing_props["assets"]["slides"],
|
||
"videoClips": [],
|
||
"broll": existing_props["assets"].get("broll", {})
|
||
}
|
||
}
|
||
|
||
output_path = self.project_root / "assets/1_processed/render_props_ppt_intro.json"
|
||
with open(output_path, "w", encoding="utf-8") as f:
|
||
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||
|
||
print(f" ✓ Saved to {output_path.name}")
|
||
return new_props
|
||
|
||
def run(self) -> str:
|
||
"""Execute full pipeline"""
|
||
print("=" * 70)
|
||
print("🚀 PPT Introduction Video Generator")
|
||
print("=" * 70)
|
||
|
||
try:
|
||
# Extract PDF info
|
||
pdf_info = self.extract_pdf_info()
|
||
|
||
# Generate script
|
||
segments = self.generate_narration_script(pdf_info)
|
||
|
||
# Generate audio
|
||
segments = self.generate_audio(segments)
|
||
|
||
# Create render props
|
||
props = self.create_render_props(segments)
|
||
|
||
# Summary
|
||
print("\n" + "=" * 70)
|
||
print("✅ Generation Complete!")
|
||
print("=" * 70)
|
||
|
||
output_file = "assets/2_output/ppt_intro_video.mp4"
|
||
print(f"\n📹 Render command:")
|
||
print(f" npx remotion render MainVideo {output_file} \\")
|
||
print(f" --props=\"assets/1_processed/render_props_ppt_intro.json\" --concurrency=1")
|
||
|
||
print(f"\n📊 Video Summary:")
|
||
print(f" - Target slides: slide_002.png, slide_003.png")
|
||
print(f" - Total segments: {len(segments)}")
|
||
print(f" - Total duration: {props['meta']['duration']:.1f}s")
|
||
print(f" - Client: {props['meta']['clientName']}")
|
||
print(f" - Config file: render_props_ppt_intro.json")
|
||
|
||
return output_file
|
||
|
||
except Exception as e:
|
||
print(f"\n❌ Error: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
raise
|
||
|
||
|
||
if __name__ == "__main__":
|
||
generator = PPTIntroVideoGenerator()
|
||
generator.run()
|