292 lines
12 KiB
Python
292 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Generate family introduction video combining PPT and PDF pages
|
||
Focus: Family member introduction ONLY
|
||
"""
|
||
|
||
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 FamilyIntroGenerator:
|
||
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)
|
||
|
||
def load_materials(self) -> Dict:
|
||
"""Load PPT, PDF and existing render_props data"""
|
||
print("📦 Loading materials...")
|
||
|
||
# Load existing render_props
|
||
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)
|
||
|
||
# Convert PDF pages to images
|
||
pdf_images = self.convert_pdf_to_images()
|
||
|
||
# Extract family info from existing data
|
||
family_info = {
|
||
"rep_name": props_data["meta"].get("clientName", "テスト太郎"),
|
||
"rep_age": 47,
|
||
"rep_birth": "1980年1月1日",
|
||
"rep_income": 703,
|
||
"rep_industry": "情報通信業",
|
||
"spouse_name": "花子",
|
||
"spouse_age": 37,
|
||
"spouse_birth": "1990年2月2日",
|
||
"spouse_income": 368,
|
||
"spouse_industry": "卸売・小売業",
|
||
"child_name": "息子",
|
||
"child_age": 7,
|
||
"child_birth": "2020年3月3日",
|
||
"child_education": "保育園認可→小学校公立→中学校私立",
|
||
"total_income": 1071,
|
||
"total_assets": 852,
|
||
"annual_expense": 1038
|
||
}
|
||
|
||
print(f"✓ Loaded PPT slides: {len(props_data['assets']['slides'])} pages")
|
||
print(f"✓ Loaded PDF images: {len(pdf_images)} pages")
|
||
|
||
return {
|
||
"family": family_info,
|
||
"ppt_slides": props_data["assets"]["slides"],
|
||
"pdf_images": pdf_images,
|
||
"broll": props_data["assets"].get("broll", {}),
|
||
"video_clips": props_data["assets"].get("videoClips", []),
|
||
"existing_props": props_data
|
||
}
|
||
|
||
def convert_pdf_to_images(self) -> List[str]:
|
||
"""Convert PDF pages to images"""
|
||
import fitz # PyMuPDF
|
||
|
||
print("📄 Converting PDF pages to images...")
|
||
|
||
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))
|
||
|
||
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}")
|
||
|
||
if (page_num + 1) % 10 == 0:
|
||
print(f" ✓ Converted {page_num + 1}/{len(doc)} pages")
|
||
|
||
print(f"✓ Converted {len(doc)} PDF pages")
|
||
doc.close()
|
||
return image_paths
|
||
|
||
def generate_script(self, materials: Dict) -> List[Dict]:
|
||
"""Generate family introduction script with PPT and PDF switching"""
|
||
print("🤖 Generating family introduction script...")
|
||
|
||
family = materials["family"]
|
||
|
||
prompt = f"""
|
||
あなたはプロのファイナンシャルプランナーであり、動画ナレーターです。
|
||
|
||
【重要】家族成員紹介のみに焦点を当てた動画原稿を作成してください。
|
||
PPTページとPDFページを適切に切り替えながら、家族の魅力を伝えます。
|
||
|
||
【家族情報】
|
||
- 家族代表: {family["rep_name"]} ({family["rep_age"]}歳、生年月日{family["rep_birth"]})
|
||
業種: {family["rep_industry"]}
|
||
年収: {family["rep_income"]}万円
|
||
- 配偶者: {family["spouse_name"]} ({family["spouse_age"]}歳、生年月日{family["spouse_birth"]})
|
||
業種: {family["spouse_industry"]}
|
||
年収: {family["spouse_income"]}万円
|
||
- お子様: {family["child_name"]} ({family["child_age"]}歳、生年月日{family["child_birth"]})
|
||
教育: {family["child_education"]}
|
||
- 世帯年収: {family["total_income"]}万円
|
||
- 現在資産: {family["total_assets"]}万円
|
||
- 年間支出: {family["annual_expense"]}万円
|
||
|
||
【利用可能な視覚素材】
|
||
- PPTスライド: {len(materials["ppt_slides"])}ページ
|
||
スライド0=タイトルページ「ライフプラン」
|
||
スライド1=家族基本情報ページ(テスト太郎47歳/花子37歳/息子7歳、業種・年収・進学プラン詳細)
|
||
- PDFページ: {len(materials["pdf_images"])}ページ
|
||
ページ0=表紙
|
||
ページ1=目次
|
||
ページ2=家族基本情報詳細(テスト太郎/花子/息子の生年月日・年齢・年収・業種)
|
||
ページ3=ライフイベント一覧
|
||
|
||
【要件】
|
||
1. 以下の構成で原稿を作成(**家族成員紹介のみ**):
|
||
- intro: オープニング(10秒、PPTスライド0でタイトル表示)
|
||
- family_overview: 家族全体の概要(20秒、PPTスライド1、3人家族構成・業種・年収紹介)
|
||
- family_details: 家族詳細情報(30秒、PDFページ2のまま継続、代表者→配偶者→子供の詳細)
|
||
- financial_summary: 家計まとめ(10秒、PPTスライド1で締めくくり)
|
||
|
||
2. 各セグメントの構造:
|
||
- id: セグメントID(英数字)
|
||
- text: ナレーション文(温かみのある語り口、具体的な数値・業種含む)
|
||
- duration: 推定時間(秒)
|
||
- visualType: "ppt" or "pdf_page"
|
||
- visualAssetIndex: PPTスライド番号(visualType="ppt"の場合、0始まり)
|
||
- pdfPageIndex: PDFページ番号(visualType="pdf_page"の場合、0始まり)
|
||
- highlightArea: "full"
|
||
|
||
3. **重要ポイント**:
|
||
- PPT素材を最大活用:スライド1には家族全員の詳細情報が記載されている
|
||
- 同じページは連続して使用し、途中で別ページに切り替えない
|
||
- PPTスライド1で家族概要を紹介後、PDFページ2で各成員の詳細を深掘り
|
||
- 各家族成員の個性や魅力を丁寧に紹介(年齢・生年月日・業種・年収すべて言及)
|
||
|
||
**出力形式**: 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" / "family_intro_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']}: {text[:30]}...")
|
||
|
||
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/family_intro_audio/{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 with PPT and PDF pages"""
|
||
|
||
existing_props = materials["existing_props"]
|
||
pdf_images = materials["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"【家族紹介】{materials['family']['rep_name']}様ご家族",
|
||
"currencyUnit": "万円",
|
||
"duration": sum(s.get("duration", 0) for s in segments),
|
||
"clientName": materials["family"]["rep_name"]
|
||
},
|
||
"charts": existing_props.get("charts", {}),
|
||
"script": segments,
|
||
"assets": {
|
||
"pdfPages": pdf_page_assets,
|
||
"slides": existing_props["assets"]["slides"], # Keep PPT slides
|
||
"videoClips": existing_props["assets"].get("videoClips", []),
|
||
"broll": existing_props["assets"].get("broll", {})
|
||
}
|
||
}
|
||
|
||
output_path = self.project_root / "assets/1_processed/render_props_family_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 family intro render props to {output_path.name}")
|
||
return new_props
|
||
|
||
def run(self) -> str:
|
||
"""Execute full pipeline"""
|
||
print("🚀 Generating family introduction video...\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/family_intro_video.mp4"
|
||
print(f"✅ Pipeline complete! Render command:")
|
||
print(f" npx remotion render MainVideo {output_file} \\")
|
||
print(f" --props=\"assets/1_processed/render_props_family_intro.json\" --concurrency=1")
|
||
print()
|
||
print("📋 改动说明:")
|
||
print(" 1. 内容范围: 仅家族成员介绍(代表者+配偶者+お子様)")
|
||
print(" 2. 视觉素材: PPT与PDF页面自然切换")
|
||
print(" 3. 无主干依赖: 两种素材平衡使用,不依赖单一文件作为主线")
|
||
print(" 4. 语调: 温暖亲切的家庭介绍风格")
|
||
print(f" 5. 总时长: {props['meta']['duration']:.1f}秒")
|
||
|
||
return output_file
|
||
|
||
if __name__ == "__main__":
|
||
generator = FamilyIntroGenerator()
|
||
generator.run()
|