303 lines
11 KiB
Python
303 lines
11 KiB
Python
"""
|
||
Generate comprehensive video using PDF pages as visual anchor (NOT PPT)
|
||
|
||
CRITICAL: This script uses PDF page content as the structural backbone
|
||
- PDF pages are converted to images and used as visual materials
|
||
- PPT slides are NOT used as the main visual sequence
|
||
- Content organization follows PDF page structure
|
||
|
||
Usage:
|
||
python backend/generate_combined_video.py
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
from typing import Dict, Any, List
|
||
from dotenv import load_dotenv
|
||
|
||
from google import genai
|
||
from google.genai import types
|
||
|
||
|
||
class CombinedVideoGenerator:
|
||
"""Generate video with PDF pages as structural anchor"""
|
||
|
||
def __init__(self):
|
||
load_dotenv()
|
||
self.project_root = Path(__file__).parent.parent
|
||
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 convert_pdf_to_images(self) -> List[str]:
|
||
"""Convert PDF pages to images for video use"""
|
||
print("📄 Converting PDF pages to images...")
|
||
|
||
try:
|
||
import fitz # PyMuPDF
|
||
except ImportError:
|
||
print("⚠️ PyMuPDF not installed, using placeholder images")
|
||
return []
|
||
|
||
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||
output_dir = self.project_root / "public" / "pdf_pages"
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
doc = fitz.open(str(pdf_path))
|
||
image_paths = []
|
||
|
||
for page_num in range(len(doc)):
|
||
page = doc[page_num]
|
||
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2x resolution
|
||
|
||
image_filename = f"pdf_page_{page_num + 1:03d}.png"
|
||
image_path = output_dir / image_filename
|
||
pix.save(str(image_path))
|
||
|
||
image_paths.append(f"pdf_pages/{image_filename}")
|
||
print(f" ✓ Page {page_num + 1}/{len(doc)}")
|
||
|
||
doc.close()
|
||
print(f"✓ Converted {len(image_paths)} PDF pages")
|
||
return image_paths
|
||
|
||
def load_all_materials(self) -> Dict[str, Any]:
|
||
"""Load materials with PDF as primary visual source"""
|
||
import sys
|
||
sys.path.insert(0, str(self.project_root))
|
||
|
||
print("📦 Loading all materials...")
|
||
|
||
# Convert PDF to images (PRIMARY VISUAL SOURCE)
|
||
pdf_images = self.convert_pdf_to_images()
|
||
|
||
# Load existing render_props for charts ONLY
|
||
props_path = self.project_root / "assets/1_processed/render_props.json"
|
||
with open(props_path, "r", encoding="utf-8") as f:
|
||
props_data = json.load(f)
|
||
print(f"✓ Loaded chart data")
|
||
|
||
# Prepare PDF summary
|
||
pdf_summary = {
|
||
"client_name": props_data["meta"].get("clientName", "お客様"),
|
||
"current_age": 47,
|
||
"spouse_age": 37,
|
||
"child_age": 7,
|
||
"annual_income": 703,
|
||
"current_assets": 836,
|
||
"annual_expenditure": 1038,
|
||
"charts": props_data["charts"],
|
||
"pdf_images": pdf_images # PRIMARY VISUAL ASSETS
|
||
}
|
||
print(f"✓ Prepared PDF data with {len(pdf_images)} page images")
|
||
|
||
return {
|
||
"pdf": pdf_summary,
|
||
"existing_props": props_data
|
||
}
|
||
|
||
def generate_comprehensive_script(self, materials: Dict) -> List[Dict]:
|
||
"""
|
||
Generate script using PDF page structure (NOT PPT)
|
||
|
||
Structure based on PDF pages:
|
||
1. Opening (intro)
|
||
2. Family basic info (PDF page 1)
|
||
3. Life events (PDF page 2)
|
||
4. Asset projection intro (PDF page 3)
|
||
5. Chart analysis (use chart view)
|
||
6. Education plan (PDF page 4)
|
||
7. Summary (PDF page 5)
|
||
"""
|
||
print("🤖 Generating script based on PDF structure...")
|
||
|
||
pdf_data = materials["pdf"]
|
||
pdf_images = pdf_data["pdf_images"]
|
||
|
||
prompt = f"""
|
||
あなたはプロのファイナンシャルプランナーであり、動画解説のナレーターです。
|
||
|
||
以下の情報に基づいて、**PDFページを視覚素材とする**解説動画のナレーション原稿を作成してください。
|
||
|
||
【重要】視覚素材はPDFページ画像を使用します(PPTスライドではありません)
|
||
|
||
【家族情報】
|
||
- 家族代表: {pdf_data["client_name"]} ({pdf_data["current_age"]}歳)
|
||
- 配偶者: {pdf_data["spouse_age"]}歳
|
||
- お子様: {pdf_data["child_age"]}歳
|
||
- 現在の年収: {pdf_data["annual_income"]}万円
|
||
- 現在の資産: {pdf_data["current_assets"]}万円
|
||
- 年間支出: {pdf_data["annual_expenditure"]}万円
|
||
|
||
【利用可能なPDFページ画像】
|
||
{len(pdf_images)}ページ分の画像が利用可能
|
||
|
||
【要件】
|
||
1. 以下の構成で原稿を作成(**家族成員紹介のみ**):
|
||
- intro: オープニング(10秒、家族紹介の導入)
|
||
- rep_intro: 家族代表紹介(20秒、PDFページ1、代表者の詳細)
|
||
- spouse_intro: 配偶者紹介(15秒、PDFページ1、配偶者の詳細)
|
||
- child_intro: お子様紹介(15秒、PDFページ1、お子様と教育プラン)
|
||
- family_summary: 家族紹介まとめ(10秒、家族全体のまとめ)
|
||
|
||
2. 各セグメントの構造:
|
||
- id: セグメントID
|
||
- text: ナレーション文(具体的な数値・年齢・職業を含む)
|
||
- duration: 推定時間(秒)
|
||
- visualType: "pdf_page"
|
||
- pdfPageIndex: PDFページ番号(0始まり)
|
||
- highlightArea: "full"
|
||
|
||
3. **重要**: 家族成員の年齢、職業、年収など具体的な情報を丁寧に紹介
|
||
4. 温かみのある親しみやすい語り口で、家族の魅力を伝える
|
||
|
||
**出力形式**: 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"
|
||
)
|
||
)
|
||
|
||
result = json.loads(response.text)
|
||
print(f"✓ Generated {len(result)} script segments")
|
||
return result
|
||
|
||
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...")
|
||
|
||
audio_gen = AudioGenerator()
|
||
output_dir = self.project_root / "public" / "pdf_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}/{len(segments)}] {text[:40]}...")
|
||
|
||
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/pdf_narration/{audio_filename}"
|
||
except Exception as e:
|
||
print(f" ⚠️ Failed: {e}")
|
||
continue
|
||
|
||
print(f"✓ Generated {len(segments)} audio files")
|
||
return segments
|
||
|
||
def create_render_props(self, segments: List[Dict], materials: Dict) -> Dict:
|
||
"""Create render_props using PDF pages as visuals"""
|
||
|
||
existing_props = materials["existing_props"]
|
||
pdf_images = materials["pdf"]["pdf_images"]
|
||
|
||
# Build PDF page assets
|
||
pdf_page_assets = [
|
||
{
|
||
"index": i,
|
||
"image_path": f"public/{img}",
|
||
"description": f"PDF Page {i+1}"
|
||
}
|
||
for i, img in enumerate(pdf_images)
|
||
]
|
||
|
||
new_props = {
|
||
"meta": {
|
||
"title": f"【PDF解説】{materials['pdf']['client_name']}様のライフプラン",
|
||
"currencyUnit": "万円",
|
||
"duration": sum(s.get("duration", 0) for s in segments),
|
||
"clientName": materials["pdf"]["client_name"]
|
||
},
|
||
"charts": existing_props["charts"],
|
||
"script": segments,
|
||
"assets": {
|
||
"pdfPages": pdf_page_assets, # PDF pages as PRIMARY visuals
|
||
"slides": [], # Empty - not using PPT
|
||
"videoClips": existing_props["assets"].get("videoClips", []),
|
||
"broll": existing_props["assets"].get("broll", {})
|
||
}
|
||
}
|
||
|
||
output_path = self.project_root / "assets/1_processed/render_props_pdf_based.json"
|
||
with open(output_path, "w", encoding="utf-8") as f:
|
||
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||
|
||
print(f"✓ Saved PDF-based render props to {output_path.name}")
|
||
return new_props
|
||
|
||
def run(self) -> str:
|
||
"""Execute full pipeline"""
|
||
print("🚀 Generating PDF-based comprehensive video...\n")
|
||
|
||
# Load materials (PDF as primary)
|
||
materials = self.load_all_materials()
|
||
print()
|
||
|
||
# Generate script
|
||
segments = self.generate_comprehensive_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/pdf_based_video.mp4"
|
||
print(f"✅ Pipeline complete! Render command:")
|
||
print(f" npx remotion render MainVideo {output_file} \\")
|
||
print(f" --props=\"assets/1_processed/render_props_pdf_based.json\"")
|
||
print()
|
||
print("📋 改动说明:")
|
||
print(" 1. 替换视觉素材: PPT slides → PDF page images")
|
||
print(" 2. 结构锚点: 按PDF页面组织内容,而非PPT顺序")
|
||
print(" 3. 新增assets.pdfPages字段存储PDF页面图像")
|
||
print(" 4. visualType: 'pdf_page'(替代'ppt')")
|
||
print(" 5. pdfPageIndex: 指向PDF页码(替代slideIndex)")
|
||
|
||
return output_file
|
||
|
||
|
||
def main():
|
||
try:
|
||
generator = CombinedVideoGenerator()
|
||
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())
|