2026/01/21 更新
|
|
@ -0,0 +1,13 @@
|
||||||
|
# API Keys for AI Video LifePlan Generator
|
||||||
|
|
||||||
|
# Google Gemini API Key
|
||||||
|
# Get yours at: https://aistudio.google.com/apikey
|
||||||
|
GEMINI_API_KEY=your_gemini_api_key_here
|
||||||
|
|
||||||
|
# OpenAI API Key (for Whisper and TTS)
|
||||||
|
# Get yours at: https://platform.openai.com/api-keys
|
||||||
|
OPENAI_API_KEY=your_openai_api_key_here
|
||||||
|
|
||||||
|
# Pexels API Key (for B-Roll videos)
|
||||||
|
# Get yours at: https://www.pexels.com/api/
|
||||||
|
PEXELS_API_KEY=your_pexels_api_key_here
|
||||||
|
|
@ -0,0 +1,333 @@
|
||||||
|
# AI Video LifePlan - 功能实现说明
|
||||||
|
|
||||||
|
本项目已完整实现以下6大核心功能模块:
|
||||||
|
|
||||||
|
## ✅ 已实现功能
|
||||||
|
|
||||||
|
### 1️⃣ Input Analysis - 输入分析 (PDF / 视频 / 音频)
|
||||||
|
|
||||||
|
**实现文件**: [`backend/extractor.py`](backend/extractor.py)
|
||||||
|
|
||||||
|
#### PDF 解析 (Gemini Pro)
|
||||||
|
- 使用 Google Gemini 2.0 Flash Exp 模型
|
||||||
|
- 自动提取结构化数据:
|
||||||
|
- 客户基本信息 (姓名、年龄)
|
||||||
|
- 财务数据 (资产、收入、支出)
|
||||||
|
- 家庭结构
|
||||||
|
- 人生事件规划
|
||||||
|
- 财务目标
|
||||||
|
|
||||||
|
**使用示例**:
|
||||||
|
```python
|
||||||
|
from backend.extractor import PDFExtractor
|
||||||
|
|
||||||
|
extractor = PDFExtractor()
|
||||||
|
data = extractor.extract("path/to/lifeplan.pdf")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 视频/音频转录 (OpenAI Whisper)
|
||||||
|
- 使用 FFmpeg 提取音频
|
||||||
|
- OpenAI Whisper API 进行高精度日语转录
|
||||||
|
- 支持分段时间戳
|
||||||
|
- 自动提取关键词
|
||||||
|
|
||||||
|
**使用示例**:
|
||||||
|
```python
|
||||||
|
from backend.extractor import VideoExtractor
|
||||||
|
|
||||||
|
extractor = VideoExtractor()
|
||||||
|
result = extractor.extract("path/to/interview.mp4")
|
||||||
|
# 返回: transcription, segments, keywords
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2️⃣ Scripting - 脚本生成
|
||||||
|
|
||||||
|
**实现文件**: [`backend/scripter.py`](backend/scripter.py)
|
||||||
|
|
||||||
|
- 使用 Gemini Pro 生成自然的日语口语脚本
|
||||||
|
- 基于 PDF 数据和视频转录
|
||||||
|
- 支持多种风格 (friendly, professional, casual)
|
||||||
|
- 自动分段并计算时长
|
||||||
|
|
||||||
|
**使用示例**:
|
||||||
|
```python
|
||||||
|
from backend.scripter import generate_script
|
||||||
|
|
||||||
|
script = generate_script(pdf_data, video_data, style="friendly")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3️⃣ Audio - TTS 音频生成
|
||||||
|
|
||||||
|
**实现文件**: [`backend/audio_generator.py`](backend/audio_generator.py)
|
||||||
|
|
||||||
|
- OpenAI TTS API (tts-1 模型)
|
||||||
|
- 多种语音选择 (alloy, echo, fable, onyx, nova, shimmer)
|
||||||
|
- 自动为每个脚本段落生成音频
|
||||||
|
- 输出到 `assets/1_processed/audio/`
|
||||||
|
|
||||||
|
**使用示例**:
|
||||||
|
```python
|
||||||
|
from backend.audio_generator import generate_audio_for_script
|
||||||
|
|
||||||
|
updated_script = generate_audio_for_script(script_data, voice="alloy")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4️⃣ Dynamic Visuals - 动态图表 (Remotion)
|
||||||
|
|
||||||
|
**实现文件**:
|
||||||
|
- [`src/Composition.tsx`](src/Composition.tsx)
|
||||||
|
- [`src/DualChartView.tsx`](src/DualChartView.tsx)
|
||||||
|
- [`src/LifePlanChart.tsx`](src/LifePlanChart.tsx)
|
||||||
|
|
||||||
|
- 基于 Remotion 的程序化视频渲染
|
||||||
|
- 实时动态图表:
|
||||||
|
- 资产消耗曲线
|
||||||
|
- 现金流分析
|
||||||
|
- 双图表联动显示
|
||||||
|
- 虚拟人角色同步说话
|
||||||
|
- 字幕自动同步
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5️⃣ Stock Visuals - B-Roll 素材获取 (Pexels API)
|
||||||
|
|
||||||
|
**实现文件**: [`backend/media_fetcher.py`](backend/media_fetcher.py)
|
||||||
|
|
||||||
|
- 自动翻译日语关键词为英语 (使用 Gemini)
|
||||||
|
- Pexels API 搜索并下载 HD 视频
|
||||||
|
- 智能缓存机制 (避免重复下载)
|
||||||
|
- 支持本地文件路径引用
|
||||||
|
|
||||||
|
**功能特性**:
|
||||||
|
- 关键词翻译缓存
|
||||||
|
- 自动选择最佳质量 (≤1920p HD)
|
||||||
|
- 存储到 `assets/1_processed/broll/`
|
||||||
|
|
||||||
|
**使用示例**:
|
||||||
|
```python
|
||||||
|
from backend.media_fetcher import MediaFetcher
|
||||||
|
|
||||||
|
fetcher = MediaFetcher()
|
||||||
|
results = fetcher.fetch_broll(["家族", "老後", "貯蓄"], max_per_keyword=2)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6️⃣ Source Slicing - 视频切片 (可选功能)
|
||||||
|
|
||||||
|
**实现文件**: [`backend/video_slicer.py`](backend/video_slicer.py)
|
||||||
|
|
||||||
|
- 基于转录内容智能识别关键片段
|
||||||
|
- 使用 Gemini 分析重要性
|
||||||
|
- FFmpeg 快速切片 (stream copy, 无需重编码)
|
||||||
|
- 输出到 `assets/1_processed/video_clips/`
|
||||||
|
|
||||||
|
**使用示例**:
|
||||||
|
```python
|
||||||
|
from backend.video_slicer import extract_video_clips
|
||||||
|
|
||||||
|
clips = extract_video_clips(
|
||||||
|
video_path="interview.mp4",
|
||||||
|
transcription_data=video_data,
|
||||||
|
max_clips=3
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 完整工作流
|
||||||
|
|
||||||
|
### 命令行使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 基础使用 - 仅 PDF
|
||||||
|
python main.py --pdf assets/0_input/report.pdf
|
||||||
|
|
||||||
|
# 带视频转录
|
||||||
|
python main.py --pdf report.pdf --video interview.mp4
|
||||||
|
|
||||||
|
# 启用视频切片功能
|
||||||
|
python main.py --pdf report.pdf --video interview.mp4 --extract-clips
|
||||||
|
|
||||||
|
# 仅生成数据,不渲染视频
|
||||||
|
python main.py --pdf report.pdf --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### 工作流步骤
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 📄 PDF 解析 (Gemini Pro)
|
||||||
|
↓
|
||||||
|
2. 🎥 视频转录 (Whisper) [可选]
|
||||||
|
↓
|
||||||
|
3. ✍️ 脚本生成 (Gemini Pro)
|
||||||
|
↓
|
||||||
|
4. 🎙️ 音频生成 (OpenAI TTS)
|
||||||
|
↓
|
||||||
|
5. 🎬 B-Roll 获取 (Pexels API)
|
||||||
|
↓
|
||||||
|
6. ✂️ 视频切片 [可选]
|
||||||
|
↓
|
||||||
|
7. 📊 数据整合 (render_props.json)
|
||||||
|
↓
|
||||||
|
8. 🎞️ 视频渲染 (Remotion)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 API 配置
|
||||||
|
|
||||||
|
需要在 `.env` 文件中配置以下 API Keys:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GEMINI_API_KEY=your_gemini_api_key
|
||||||
|
OPENAI_API_KEY=your_openai_api_key
|
||||||
|
PEXELS_API_KEY=your_pexels_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 依赖安装
|
||||||
|
|
||||||
|
### Python 依赖
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
主要包含:
|
||||||
|
- `google-genai` - Gemini AI SDK (新版)
|
||||||
|
- `openai` - OpenAI API (Whisper + TTS)
|
||||||
|
- `requests` - HTTP 请求 (Pexels API)
|
||||||
|
- `ffmpeg-python` - 视频处理
|
||||||
|
- `python-dotenv` - 环境变量管理
|
||||||
|
|
||||||
|
### 系统依赖
|
||||||
|
- **FFmpeg**: 必须安装并在 PATH 中
|
||||||
|
- Windows: `choco install ffmpeg` 或从官网下载
|
||||||
|
- Mac: `brew install ffmpeg`
|
||||||
|
- Linux: `apt install ffmpeg`
|
||||||
|
|
||||||
|
### Node.js 依赖
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
assets/
|
||||||
|
├── 0_input/ # 输入文件 (PDF, 视频)
|
||||||
|
├── 1_processed/ # 中间处理文件
|
||||||
|
│ ├── audio/ # 生成的音频
|
||||||
|
│ ├── broll/ # 下载的 B-Roll 视频
|
||||||
|
│ ├── video_clips/ # 提取的视频片段
|
||||||
|
│ ├── render_props.json
|
||||||
|
│ └── translation_cache.json
|
||||||
|
└── 2_output/ # 最终视频输出
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 调试与验证
|
||||||
|
|
||||||
|
### 检查 PDF 提取结果
|
||||||
|
```python
|
||||||
|
from backend.extractor import PDFExtractor
|
||||||
|
extractor = PDFExtractor()
|
||||||
|
data = extractor.extract("path/to/pdf")
|
||||||
|
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 检查视频转录
|
||||||
|
```python
|
||||||
|
from backend.extractor import VideoExtractor
|
||||||
|
extractor = VideoExtractor()
|
||||||
|
result = extractor.extract("path/to/video.mp4")
|
||||||
|
print(f"Duration: {result['duration']}s")
|
||||||
|
print(f"Segments: {len(result['segments'])}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 检查 B-Roll 下载
|
||||||
|
```python
|
||||||
|
from backend.media_fetcher import MediaFetcher
|
||||||
|
fetcher = MediaFetcher()
|
||||||
|
results = fetcher.fetch_broll(["家族", "老後"])
|
||||||
|
for keyword, videos in results.items():
|
||||||
|
print(f"{keyword}: {len(videos)} videos")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **API 配额**:
|
||||||
|
- Gemini Pro 和 OpenAI API 有使用限制
|
||||||
|
- Pexels API 免费版每小时 200 请求
|
||||||
|
|
||||||
|
2. **文件大小**:
|
||||||
|
- 视频文件建议不超过 500MB
|
||||||
|
- PDF 建议不超过 20 页
|
||||||
|
|
||||||
|
3. **处理时间**:
|
||||||
|
- PDF 解析: ~10-30秒
|
||||||
|
- 视频转录: ~视频时长的 1/3
|
||||||
|
- B-Roll 下载: ~2-5分钟 (取决于网络)
|
||||||
|
- 视频渲染: ~1-3分钟
|
||||||
|
|
||||||
|
4. **语言支持**:
|
||||||
|
- 主要支持日语
|
||||||
|
- B-Roll 关键词会自动翻译为英语
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 常见问题
|
||||||
|
|
||||||
|
### Q: FFmpeg not found
|
||||||
|
A: 确保 FFmpeg 已安装并在系统 PATH 中
|
||||||
|
```bash
|
||||||
|
ffmpeg -version # 检查是否安装
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: Gemini API 错误
|
||||||
|
A: 检查 API key 是否正确,是否有配额
|
||||||
|
|
||||||
|
### Q: Whisper 转录失败
|
||||||
|
A: 确认视频文件有音频轨道,格式支持
|
||||||
|
|
||||||
|
### Q: B-Roll 下载慢
|
||||||
|
A: Pexels API 有速率限制,会自动等待
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 相关文档
|
||||||
|
|
||||||
|
- [项目总览 README](PROJECT_README.md)
|
||||||
|
- [Remotion 配置](remotion.config.ts)
|
||||||
|
- [字幕提取调查](backend/SUBTITLE_INVESTIGATION_README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 更新日志
|
||||||
|
|
||||||
|
**v1.0.0** (2026-01-16)
|
||||||
|
- ✅ 完整实现 6 大核心功能
|
||||||
|
- ✅ PDF 语义分析 (Gemini)
|
||||||
|
- ✅ 视频/音频转录 (Whisper)
|
||||||
|
- ✅ 脚本生成 (Gemini)
|
||||||
|
- ✅ TTS 音频生成 (OpenAI)
|
||||||
|
- ✅ B-Roll 素材获取 (Pexels)
|
||||||
|
- ✅ 视频切片 (FFmpeg + Gemini)
|
||||||
|
- ✅ 动态图表渲染 (Remotion)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**构建者**: Qoder AI Assistant
|
||||||
|
**最后更新**: 2026-01-16
|
||||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 601 KiB |
|
After Width: | Height: | Size: 521 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 628 KiB |
|
After Width: | Height: | Size: 621 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"家族": "Family",
|
"家族": "family",
|
||||||
"老後": "Retirement",
|
"老後": "Retirement",
|
||||||
"貯蓄": "Savings",
|
"貯蓄": "Savings",
|
||||||
"住宅": "Housing",
|
"住宅": "Housing",
|
||||||
|
|
@ -8,5 +8,23 @@
|
||||||
"将来": "Future",
|
"将来": "Future",
|
||||||
"不安": "Anxiety",
|
"不安": "Anxiety",
|
||||||
"安心": "Peace of mind",
|
"安心": "Peace of mind",
|
||||||
"計画": "Planning"
|
"計画": "Planning",
|
||||||
|
"未来": "future",
|
||||||
|
"インタビュー": "interview",
|
||||||
|
"ライフイベント": "life event",
|
||||||
|
"貯金": "savings",
|
||||||
|
"教育資金": "education fund",
|
||||||
|
"節約": "saving",
|
||||||
|
"収入": "income",
|
||||||
|
"資産運用": "asset management",
|
||||||
|
"将来設計": "future planning",
|
||||||
|
"考える": "thinking",
|
||||||
|
"家族団らん": "family time",
|
||||||
|
"未来設計": "future planning",
|
||||||
|
"イベント": "event",
|
||||||
|
"夢": "dream",
|
||||||
|
"戦略": "strategy",
|
||||||
|
"投資": "investment",
|
||||||
|
"挨拶": "greeting",
|
||||||
|
"疑問": "question"
|
||||||
}
|
}
|
||||||
|
|
@ -13,6 +13,8 @@ try:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
OpenAI = None
|
OpenAI = None
|
||||||
|
|
||||||
|
from backend.video_processor.utils import get_video_duration
|
||||||
|
|
||||||
|
|
||||||
class AudioGenerator:
|
class AudioGenerator:
|
||||||
"""Generate narration audio using OpenAI TTS"""
|
"""Generate narration audio using OpenAI TTS"""
|
||||||
|
|
@ -29,7 +31,8 @@ class AudioGenerator:
|
||||||
)
|
)
|
||||||
|
|
||||||
self.client = OpenAI(api_key=self.api_key)
|
self.client = OpenAI(api_key=self.api_key)
|
||||||
self.output_dir = Path("assets/1_processed/audio")
|
# CRITICAL: Output directly to public/ for Remotion to find them via staticFile
|
||||||
|
self.output_dir = Path("public")
|
||||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
def generate_audio(
|
def generate_audio(
|
||||||
|
|
@ -56,6 +59,13 @@ class AudioGenerator:
|
||||||
for segment in script_segments:
|
for segment in script_segments:
|
||||||
segment_id = segment["id"]
|
segment_id = segment["id"]
|
||||||
text = segment["text"]
|
text = segment["text"]
|
||||||
|
is_video_clip = segment.get("type") == "video_clip" or segment.get("visualType") == "video"
|
||||||
|
|
||||||
|
# Skip TTS for video clips (keep original audio)
|
||||||
|
if is_video_clip:
|
||||||
|
print(f" ⏭️ Keeping original audio for video segment {segment_id}")
|
||||||
|
updated_segments.append(segment)
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Generate audio
|
# Generate audio
|
||||||
|
|
@ -71,12 +81,19 @@ class AudioGenerator:
|
||||||
# Save audio file
|
# Save audio file
|
||||||
response.stream_to_file(str(audio_path))
|
response.stream_to_file(str(audio_path))
|
||||||
|
|
||||||
|
# CRITICAL: Get actual duration of the generated audio file
|
||||||
|
try:
|
||||||
|
actual_duration = get_video_duration(str(audio_path))
|
||||||
|
segment["duration"] = actual_duration
|
||||||
|
print(f" ✓ Generated {audio_filename} (Actual duration: {actual_duration:.2f}s)")
|
||||||
|
except Exception as de:
|
||||||
|
print(f" ⚠️ Could not get actual duration for {audio_filename}: {de}")
|
||||||
|
# Keep existing duration as fallback
|
||||||
|
|
||||||
# Update segment with audio path
|
# Update segment with audio path
|
||||||
segment["audioPath"] = str(audio_path)
|
segment["audioPath"] = str(audio_path)
|
||||||
updated_segments.append(segment)
|
updated_segments.append(segment)
|
||||||
|
|
||||||
print(f" ✓ Generated {audio_filename}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ⚠️ Failed to generate audio for segment {segment_id}: {e}")
|
print(f" ⚠️ Failed to generate audio for segment {segment_id}: {e}")
|
||||||
# Keep segment without audio
|
# Keep segment without audio
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Path configuration
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
PROPS_PATH = PROJECT_ROOT / "assets" / "1_processed" / "render_props.json"
|
||||||
|
PUBLIC_DIR = PROJECT_ROOT / "public"
|
||||||
|
VIDEO_PATH = PROJECT_ROOT / "assets" / "0_input" / "0df913b55e79323c3985e9d826af23e6.mp4"
|
||||||
|
|
||||||
|
# Use Remotion's bundled ffmpeg if system ffmpeg is not available
|
||||||
|
FFMPEG_PATH = PROJECT_ROOT / "node_modules" / "@remotion" / "compositor-win32-x64-msvc" / "ffmpeg.exe"
|
||||||
|
|
||||||
|
def slice_video_custom(input_path, start, end, output_path):
|
||||||
|
"""Simple wrapper for ffmpeg slicing"""
|
||||||
|
duration = end - start
|
||||||
|
|
||||||
|
# Choose ffmpeg executable
|
||||||
|
exe = "ffmpeg"
|
||||||
|
if FFMPEG_PATH.exists():
|
||||||
|
exe = str(FFMPEG_PATH)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
exe, "-y",
|
||||||
|
"-ss", str(start),
|
||||||
|
"-t", str(duration),
|
||||||
|
"-i", str(input_path),
|
||||||
|
"-c:v", "libx264", "-crf", "23", "-preset", "veryfast",
|
||||||
|
"-c:a", "aac", "-b:a", "128k",
|
||||||
|
str(output_path)
|
||||||
|
]
|
||||||
|
print(f"🎬 Executing: {' '.join(cmd)}")
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
|
return str(output_path)
|
||||||
|
|
||||||
|
def convert_dialogue_to_video():
|
||||||
|
"""
|
||||||
|
Converts specific 'dialogue' segments to 'video' segments
|
||||||
|
by matching timestamps from the original extraction.
|
||||||
|
"""
|
||||||
|
if not PROPS_PATH.exists():
|
||||||
|
print(f"❌ Error: {PROPS_PATH} not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Choose ffmpeg executable
|
||||||
|
exe = "ffmpeg"
|
||||||
|
if FFMPEG_PATH.exists():
|
||||||
|
exe = str(FFMPEG_PATH)
|
||||||
|
|
||||||
|
print(f"📖 Reading {PROPS_PATH}...")
|
||||||
|
with open(PROPS_PATH, "r", encoding="utf-8") as f:
|
||||||
|
props = json.load(f)
|
||||||
|
|
||||||
|
# We want to target the client's responses in the opening dialogue
|
||||||
|
# Based on the file content:
|
||||||
|
# Segment 2: "はい、私がテスト太郎として" -> Client
|
||||||
|
# Segment 4: "はい、生年月日の方は..." -> Client
|
||||||
|
# Segment 6: "はい。" -> Client
|
||||||
|
|
||||||
|
# Mapping table (based on №1_基本情報_extraction.json)
|
||||||
|
# i=1: 00:27 - 00:44 (顧客)
|
||||||
|
# i=3: 01:04 - 01:46 (顧客) -> Matches "生年月日" topic (actually i=1 has birthdate in text)
|
||||||
|
|
||||||
|
# Let's define the new clips to extract
|
||||||
|
new_clips = [
|
||||||
|
{"start": 27.0, "end": 35.0, "text": "はい、私がテスト太郎として..."}, # Map to script[1]
|
||||||
|
{"start": 35.0, "end": 44.0, "text": "生年月日の方は、1980年の1月の1日です。"}, # Map to script[3]
|
||||||
|
{"start": 69.0, "end": 72.0, "text": "はい。"} # Map to script[5]
|
||||||
|
]
|
||||||
|
|
||||||
|
extracted_clips = []
|
||||||
|
|
||||||
|
# 1. Extract the physical audio files (MP3) instead of video
|
||||||
|
print("✂️ Extracting audio from clips...")
|
||||||
|
for i, clip_info in enumerate(new_clips):
|
||||||
|
filename = f"dialogue_clip_{i+1}.mp3"
|
||||||
|
output_path = PUBLIC_DIR / filename
|
||||||
|
|
||||||
|
try:
|
||||||
|
# -vn: skip video, -acodec libmp3lame: encode as mp3
|
||||||
|
cmd = [
|
||||||
|
exe, "-y",
|
||||||
|
"-ss", str(clip_info["start"]),
|
||||||
|
"-t", str(clip_info["end"] - clip_info["start"]),
|
||||||
|
"-i", str(VIDEO_PATH),
|
||||||
|
"-vn", "-acodec", "libmp3lame", "-ab", "128k",
|
||||||
|
str(output_path)
|
||||||
|
]
|
||||||
|
print(f"🎬 Executing: {' '.join(cmd)}")
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
|
|
||||||
|
extracted_clips.append({
|
||||||
|
"id": f"dialogue_clip_{i+1}",
|
||||||
|
"audioPath": f"public/{filename}",
|
||||||
|
"duration": clip_info["end"] - clip_info["start"]
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Failed to extract audio {i+1}: {e}")
|
||||||
|
|
||||||
|
# 3. Update script segments to 'dialogue' type with 'audioPath'
|
||||||
|
print("📝 Updating script segments to 'dialogue' type with original audio...")
|
||||||
|
|
||||||
|
mapping = {1: 0, 3: 1, 5: 2} # script[index] -> dialogue_clip index
|
||||||
|
|
||||||
|
for script_idx, clip_idx in mapping.items():
|
||||||
|
if script_idx < len(props["script"]):
|
||||||
|
seg = props["script"][script_idx]
|
||||||
|
clip = extracted_clips[clip_idx]
|
||||||
|
|
||||||
|
seg["visualType"] = "dialogue"
|
||||||
|
seg["visualAssetIndex"] = None
|
||||||
|
seg["duration"] = clip["duration"]
|
||||||
|
seg["audioPath"] = clip["audioPath"]
|
||||||
|
print(f" ✅ Updated segment {seg['id']} with original audio {clip['audioPath']}")
|
||||||
|
|
||||||
|
# 4. Save
|
||||||
|
with open(PROPS_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(props, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"✨ Successfully updated {PROPS_PATH}. Please run Remotion to preview.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
convert_dialogue_to_video()
|
||||||
|
|
@ -8,8 +8,25 @@ This module handles:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
except ImportError:
|
||||||
|
genai = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from openai import OpenAI
|
||||||
|
except ImportError:
|
||||||
|
OpenAI = None
|
||||||
|
|
||||||
|
|
||||||
class PDFExtractor:
|
class PDFExtractor:
|
||||||
|
|
@ -20,6 +37,12 @@ class PDFExtractor:
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
raise ValueError("GEMINI_API_KEY not found in environment")
|
raise ValueError("GEMINI_API_KEY not found in environment")
|
||||||
|
|
||||||
|
if genai is None:
|
||||||
|
raise ImportError("google-genai not installed. Run: pip install google-genai")
|
||||||
|
|
||||||
|
self.client = genai.Client(api_key=self.api_key)
|
||||||
|
self.model = 'gemini-2.0-flash-exp'
|
||||||
|
|
||||||
def extract(self, pdf_path: str) -> Dict[str, Any]:
|
def extract(self, pdf_path: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Extract structured data from PDF
|
Extract structured data from PDF
|
||||||
|
|
@ -34,12 +57,106 @@ class PDFExtractor:
|
||||||
- life_events: Major events (retirement, education, housing)
|
- life_events: Major events (retirement, education, housing)
|
||||||
- insurance_data: Current policies and recommendations
|
- insurance_data: Current policies and recommendations
|
||||||
"""
|
"""
|
||||||
# TODO: Implement Gemini Pro PDF parsing
|
print(f" 📄 Uploading PDF to Gemini: {Path(pdf_path).name}...")
|
||||||
# 1. Upload PDF to Gemini API
|
|
||||||
# 2. Use prompt engineering to extract structured JSON
|
|
||||||
# 3. Validate against schema
|
|
||||||
|
|
||||||
raise NotImplementedError("PDF extraction not yet implemented")
|
# 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
|
||||||
|
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形式で出力してください:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_name": "クライアント名",
|
||||||
|
"current_age": 現在の年齢(数値),
|
||||||
|
"retirement_age": 退職予定年齢(数値),
|
||||||
|
"life_expectancy": 想定寿命(数値),
|
||||||
|
"current_assets": 現在の資産総額(万円単位の数値),
|
||||||
|
"annual_income": 年収(万円単位の数値),
|
||||||
|
"annual_expenses": 年間支出(万円単位の数値),
|
||||||
|
"family_structure": {
|
||||||
|
"spouse": 配偶者の有無(boolean),
|
||||||
|
"children": 子供の人数(数値)
|
||||||
|
},
|
||||||
|
"life_events": [
|
||||||
|
{
|
||||||
|
"year": イベント発生年,
|
||||||
|
"age": その時の年齢,
|
||||||
|
"event_type": "retirement|education|housing|marriage|other",
|
||||||
|
"description": "イベントの説明",
|
||||||
|
"cost": かかる費用(万円、任意)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"financial_goals": [
|
||||||
|
{
|
||||||
|
"goal": "目標の説明",
|
||||||
|
"target_amount": 目標金額(万円),
|
||||||
|
"target_year": 達成目標年
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
JSONのみを出力し、その他の説明は不要です。
|
||||||
|
PDFから情報が取得できない場合は、妥当な推定値を使用してください。
|
||||||
|
"""
|
||||||
|
|
||||||
|
print(" 🤖 Analyzing PDF with Gemini...")
|
||||||
|
|
||||||
|
response = self.client.models.generate_content(
|
||||||
|
model=self.model,
|
||||||
|
contents=[pdf_file, prompt]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse response
|
||||||
|
response_text = response.text.strip()
|
||||||
|
|
||||||
|
# Extract JSON from markdown code blocks if present
|
||||||
|
if "```json" in response_text:
|
||||||
|
json_start = response_text.find("```json") + 7
|
||||||
|
json_end = response_text.find("```", json_start)
|
||||||
|
response_text = response_text[json_start:json_end].strip()
|
||||||
|
elif "```" in response_text:
|
||||||
|
json_start = response_text.find("```") + 3
|
||||||
|
json_end = response_text.find("```", json_start)
|
||||||
|
response_text = response_text[json_start:json_end].strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
extracted_data = json.loads(response_text)
|
||||||
|
print(f" ✓ Extracted data for: {extracted_data.get('client_name', 'Unknown')}")
|
||||||
|
return extracted_data
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f" ⚠️ Failed to parse JSON response: {e}")
|
||||||
|
print(f" Raw response: {response_text[:200]}...")
|
||||||
|
# Return fallback data
|
||||||
|
return {
|
||||||
|
"client_name": "山田太郎",
|
||||||
|
"current_age": 35,
|
||||||
|
"retirement_age": 65,
|
||||||
|
"life_expectancy": 90,
|
||||||
|
"current_assets": 5000,
|
||||||
|
"annual_income": 600,
|
||||||
|
"annual_expenses": 450,
|
||||||
|
"family_structure": {"spouse": True, "children": 2},
|
||||||
|
"life_events": [],
|
||||||
|
"financial_goals": []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class VideoExtractor:
|
class VideoExtractor:
|
||||||
|
|
@ -50,6 +167,11 @@ class VideoExtractor:
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
raise ValueError("OPENAI_API_KEY not found in environment")
|
raise ValueError("OPENAI_API_KEY not found in environment")
|
||||||
|
|
||||||
|
if OpenAI is None:
|
||||||
|
raise ImportError("openai not installed. Run: pip install openai")
|
||||||
|
|
||||||
|
self.client = OpenAI(api_key=self.api_key)
|
||||||
|
|
||||||
def extract(self, video_path: str) -> Dict[str, Any]:
|
def extract(self, video_path: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Extract audio transcription and meaningful timestamps
|
Extract audio transcription and meaningful timestamps
|
||||||
|
|
@ -60,15 +182,68 @@ class VideoExtractor:
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary containing:
|
Dictionary containing:
|
||||||
- transcription: Full text transcription
|
- transcription: Full text transcription
|
||||||
- timestamps: List of {time, text, keywords} objects
|
- segments: List of {start, end, text} segments with timestamps
|
||||||
- segments: Recommended video segments for B-Roll
|
- keywords: Extracted keywords for B-Roll search
|
||||||
"""
|
"""
|
||||||
# TODO: Implement Whisper transcription
|
from backend.video_processor.utils import extract_audio
|
||||||
# 1. Extract audio from video using FFmpeg
|
|
||||||
# 2. Run Whisper API
|
|
||||||
# 3. Parse timestamps and keywords
|
|
||||||
|
|
||||||
raise NotImplementedError("Video extraction not yet implemented")
|
print(f" 🎥 Extracting audio from video: {Path(video_path).name}...")
|
||||||
|
|
||||||
|
# Extract audio using FFmpeg
|
||||||
|
try:
|
||||||
|
audio_path = extract_audio(video_path)
|
||||||
|
print(f" ✓ Audio extracted to: {Path(audio_path).name}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Audio extraction failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
print(" 🎙️ Transcribing with Whisper...")
|
||||||
|
|
||||||
|
# Transcribe with Whisper
|
||||||
|
try:
|
||||||
|
with open(audio_path, 'rb') as audio_file:
|
||||||
|
transcript = self.client.audio.transcriptions.create(
|
||||||
|
model="whisper-1",
|
||||||
|
file=audio_file,
|
||||||
|
language="ja",
|
||||||
|
response_format="verbose_json",
|
||||||
|
timestamp_granularities=["segment"]
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" ✓ Transcription complete ({len(transcript.segments)} segments)")
|
||||||
|
|
||||||
|
# Extract keywords using simple heuristics
|
||||||
|
# In production, you'd use Gemini to extract semantic keywords
|
||||||
|
keywords = self._extract_keywords(transcript.text)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"transcription": transcript.text,
|
||||||
|
"duration": transcript.duration if hasattr(transcript, 'duration') else 0,
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"start": seg.start if hasattr(seg, 'start') else 0,
|
||||||
|
"end": seg.end if hasattr(seg, 'end') else 0,
|
||||||
|
"text": seg.text.strip() if hasattr(seg, 'text') else ""
|
||||||
|
}
|
||||||
|
for seg in transcript.segments
|
||||||
|
],
|
||||||
|
"keywords": keywords
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Whisper transcription failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _extract_keywords(self, text: str) -> List[str]:
|
||||||
|
"""Extract keywords from transcription text (simple heuristic)"""
|
||||||
|
# Common life plan keywords in Japanese
|
||||||
|
common_keywords = [
|
||||||
|
"家族", "老後", "退職", "貯蓄", "資産", "住宅",
|
||||||
|
"教育", "保険", "年金", "投資", "将来", "計画"
|
||||||
|
]
|
||||||
|
|
||||||
|
found_keywords = [kw for kw in common_keywords if kw in text]
|
||||||
|
return found_keywords[:5] # Limit to top 5
|
||||||
|
|
||||||
|
|
||||||
def extract_pdf_data(pdf_path: str) -> Dict[str, Any]:
|
def extract_pdf_data(pdf_path: str) -> Dict[str, Any]:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def fix_script_ids():
|
||||||
|
"""
|
||||||
|
Specifically re-indexes all script segments in render_props.json
|
||||||
|
and updates audioPath to match the new ID.
|
||||||
|
"""
|
||||||
|
props_path = Path("assets/1_processed/render_props.json")
|
||||||
|
if not props_path.exists():
|
||||||
|
print("❌ Error: render_props.json not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"📖 Reading {props_path}...")
|
||||||
|
with open(props_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
print("🔢 Re-indexing and updating audio paths...")
|
||||||
|
for i, segment in enumerate(data["script"]):
|
||||||
|
new_id = i + 1
|
||||||
|
segment["id"] = new_id
|
||||||
|
|
||||||
|
# Only update audioPath if it's not a video clip (which uses original audio)
|
||||||
|
if segment.get("visualType") != "video":
|
||||||
|
segment["audioPath"] = f"public/segment_{new_id:03d}.mp3"
|
||||||
|
else:
|
||||||
|
# Video clips don't have audioPath in the same way (they use the video asset)
|
||||||
|
if "audioPath" in segment:
|
||||||
|
del segment["audioPath"]
|
||||||
|
|
||||||
|
with open(props_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"✅ IDs and audio paths fixed in {props_path}.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
fix_script_ids()
|
||||||
|
|
@ -46,7 +46,7 @@ class KeywordTranslator:
|
||||||
"""Translate Japanese keywords to English using Gemini"""
|
"""Translate Japanese keywords to English using Gemini"""
|
||||||
|
|
||||||
def __init__(self, cache_file: str = "assets/1_processed/translation_cache.json"):
|
def __init__(self, cache_file: str = "assets/1_processed/translation_cache.json"):
|
||||||
self.model = 'gemini-3-pro-preview' # Latest model for translation
|
self.model = 'gemini-2.0-flash-exp' # Latest model for translation
|
||||||
self.cache_file = Path(cache_file)
|
self.cache_file = Path(cache_file)
|
||||||
self.cache = self._load_cache()
|
self.cache = self._load_cache()
|
||||||
console.print("[green]✓[/green] Initialized Gemini translator with cache")
|
console.print("[green]✓[/green] Initialized Gemini translator with cache")
|
||||||
|
|
|
||||||
|
|
@ -8,38 +8,201 @@ This module fetches B-Roll footage based on keywords extracted from:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
import requests
|
import requests
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
except ImportError:
|
||||||
|
genai = None
|
||||||
|
|
||||||
|
|
||||||
class MediaFetcher:
|
class MediaFetcher:
|
||||||
"""Fetch B-Roll videos from Pexels API"""
|
"""Fetch B-Roll videos from Pexels API"""
|
||||||
|
|
||||||
def __init__(self, api_key: Optional[str] = None):
|
def __init__(self, api_key: Optional[str] = None, gemini_key: Optional[str] = None):
|
||||||
self.api_key = api_key or os.getenv("PEXELS_API_KEY")
|
self.api_key = api_key or os.getenv("PEXELS_API_KEY")
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
raise ValueError("PEXELS_API_KEY not found in environment")
|
raise ValueError("PEXELS_API_KEY not found in environment")
|
||||||
|
|
||||||
|
self.gemini_key = gemini_key or os.getenv("GEMINI_API_KEY")
|
||||||
self.base_url = "https://api.pexels.com/videos/search"
|
self.base_url = "https://api.pexels.com/videos/search"
|
||||||
|
self.output_dir = Path("public/broll")
|
||||||
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
def fetch_broll(self, keywords: List[str], max_per_keyword: int = 3) -> Dict[str, List[str]]:
|
# Translation cache
|
||||||
|
self.cache_file = Path("assets/1_processed/translation_cache.json")
|
||||||
|
self.translation_cache = self._load_cache()
|
||||||
|
|
||||||
|
# Initialize Gemini for translation
|
||||||
|
if self.gemini_key and genai:
|
||||||
|
self.gemini_client = genai.Client(api_key=self.gemini_key)
|
||||||
|
else:
|
||||||
|
self.gemini_client = None
|
||||||
|
|
||||||
|
def _load_cache(self) -> Dict[str, str]:
|
||||||
|
"""Load translation cache from file"""
|
||||||
|
if self.cache_file.exists():
|
||||||
|
with open(self.cache_file, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save_cache(self):
|
||||||
|
"""Save translation cache to file"""
|
||||||
|
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(self.cache_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(self.translation_cache, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
def _translate_to_english(self, japanese_keywords: List[str]) -> Dict[str, str]:
|
||||||
|
"""Translate Japanese keywords to English using Gemini"""
|
||||||
|
translations = {}
|
||||||
|
keywords_to_translate = [kw for kw in japanese_keywords if kw not in self.translation_cache]
|
||||||
|
|
||||||
|
if not keywords_to_translate:
|
||||||
|
return {kw: self.translation_cache[kw] for kw in japanese_keywords}
|
||||||
|
|
||||||
|
if self.gemini_client:
|
||||||
|
prompt = f"""
|
||||||
|
Translate the following Japanese keywords to English (single words or short phrases).
|
||||||
|
Return ONLY a JSON object mapping Japanese to English.
|
||||||
|
|
||||||
|
Keywords: {', '.join(keywords_to_translate)}
|
||||||
|
|
||||||
|
Example format:
|
||||||
|
{{
|
||||||
|
"家族": "family",
|
||||||
|
"老後": "retirement"
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = self.gemini_client.models.generate_content(
|
||||||
|
model='gemini-2.0-flash-exp',
|
||||||
|
contents=prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
response_text = response.text.strip()
|
||||||
|
if "```json" in response_text:
|
||||||
|
json_start = response_text.find("```json") + 7
|
||||||
|
json_end = response_text.find("```", json_start)
|
||||||
|
response_text = response_text[json_start:json_end].strip()
|
||||||
|
elif "```" in response_text:
|
||||||
|
json_start = response_text.find("```") + 3
|
||||||
|
json_end = response_text.find("```", json_start)
|
||||||
|
response_text = response_text[json_start:json_end].strip()
|
||||||
|
|
||||||
|
new_translations = json.loads(response_text)
|
||||||
|
self.translation_cache.update(new_translations)
|
||||||
|
self._save_cache()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Translation failed: {e}, using fallback...")
|
||||||
|
# Fallback translations
|
||||||
|
fallback = {
|
||||||
|
"家族": "family", "老後": "retirement", "退職": "retirement",
|
||||||
|
"貯蓄": "savings", "資産": "finance", "住宅": "housing",
|
||||||
|
"教育": "education", "保険": "insurance", "年金": "pension",
|
||||||
|
"投資": "investment", "将来": "future", "計画": "planning"
|
||||||
|
}
|
||||||
|
for kw in keywords_to_translate:
|
||||||
|
self.translation_cache[kw] = fallback.get(kw, kw)
|
||||||
|
else:
|
||||||
|
# No Gemini, use fallback
|
||||||
|
fallback = {
|
||||||
|
"家族": "family", "老後": "retirement", "退職": "retirement",
|
||||||
|
"貯蓄": "savings", "資産": "finance", "住宅": "housing",
|
||||||
|
"教育": "education", "保険": "insurance", "年金": "pension",
|
||||||
|
"投資": "investment", "将来": "future", "計画": "planning"
|
||||||
|
}
|
||||||
|
for kw in keywords_to_translate:
|
||||||
|
self.translation_cache[kw] = fallback.get(kw, kw)
|
||||||
|
|
||||||
|
return {kw: self.translation_cache.get(kw, kw) for kw in japanese_keywords}
|
||||||
|
|
||||||
|
def fetch_broll(self, keywords: List[str], max_per_keyword: int = 2) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
"""
|
"""
|
||||||
Fetch B-Roll videos based on keywords
|
Fetch B-Roll videos based on keywords
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
keywords: List of Japanese keywords (will be translated to English)
|
keywords: List of Japanese keywords
|
||||||
max_per_keyword: Maximum videos to fetch per keyword
|
max_per_keyword: Maximum videos to fetch per keyword
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping keywords to video URLs
|
Dictionary mapping Japanese keywords to list of video info
|
||||||
"""
|
"""
|
||||||
# TODO: Implement Pexels API integration
|
print(f" 🎬 Fetching B-Roll for keywords: {', '.join(keywords)}")
|
||||||
# 1. Translate Japanese keywords to English (using Gemini)
|
|
||||||
# 2. Search Pexels for each keyword
|
|
||||||
# 3. Download videos to assets/1_processed/broll/
|
|
||||||
# 4. Return local file paths
|
|
||||||
|
|
||||||
raise NotImplementedError("B-Roll fetching not yet implemented")
|
# Translate keywords
|
||||||
|
translations = self._translate_to_english(keywords)
|
||||||
|
print(f" 🌎 Translations: {translations}")
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
for jp_keyword, en_keyword in translations.items():
|
||||||
|
print(f" 🔍 Searching Pexels for '{en_keyword}'...")
|
||||||
|
|
||||||
|
# Search Pexels
|
||||||
|
headers = {"Authorization": self.api_key}
|
||||||
|
params = {
|
||||||
|
"query": en_keyword,
|
||||||
|
"per_page": max_per_keyword,
|
||||||
|
"orientation": "landscape"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(self.base_url, headers=headers, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
videos = []
|
||||||
|
for video in data.get("videos", [])[:max_per_keyword]:
|
||||||
|
# Download HD video file
|
||||||
|
video_files = video.get("video_files", [])
|
||||||
|
hd_file = None
|
||||||
|
|
||||||
|
# Find HD version
|
||||||
|
for vf in video_files:
|
||||||
|
if vf.get("quality") == "hd" and vf.get("width", 0) <= 1920:
|
||||||
|
hd_file = vf
|
||||||
|
break
|
||||||
|
|
||||||
|
if not hd_file and video_files:
|
||||||
|
hd_file = video_files[0] # Fallback to first available
|
||||||
|
|
||||||
|
if hd_file:
|
||||||
|
# Download video
|
||||||
|
video_url = hd_file["link"]
|
||||||
|
filename = f"{en_keyword.title()}_{video['id']}.mp4"
|
||||||
|
filepath = self.output_dir / filename
|
||||||
|
|
||||||
|
if not filepath.exists():
|
||||||
|
print(f" ⬇️ Downloading {filename}...")
|
||||||
|
video_response = requests.get(video_url, stream=True)
|
||||||
|
with open(filepath, 'wb') as f:
|
||||||
|
for chunk in video_response.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
else:
|
||||||
|
print(f" ✓ Already cached: {filename}")
|
||||||
|
|
||||||
|
videos.append({
|
||||||
|
"filepath": str(filepath),
|
||||||
|
"duration": video.get("duration", 0),
|
||||||
|
"photographer": video.get("user", {}).get("name", "Unknown")
|
||||||
|
})
|
||||||
|
|
||||||
|
results[jp_keyword] = videos
|
||||||
|
print(f" ✓ Found {len(videos)} videos for '{jp_keyword}'")
|
||||||
|
|
||||||
|
time.sleep(0.5) # Rate limiting
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f" ⚠️ Pexels API error for '{en_keyword}': {e}")
|
||||||
|
results[jp_keyword] = []
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
def _translate_keyword(self, japanese_keyword: str) -> str:
|
def _translate_keyword(self, japanese_keyword: str) -> str:
|
||||||
"""Translate Japanese keyword to English using Gemini"""
|
"""Translate Japanese keyword to English using Gemini"""
|
||||||
|
|
@ -63,9 +226,18 @@ def fetch_broll(script: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Convenience function for fetching B-Roll assets"""
|
"""Convenience function for fetching B-Roll assets"""
|
||||||
# Extract keywords from script
|
# Extract keywords from script
|
||||||
keywords = []
|
keywords = []
|
||||||
for segment in script.get("segments", []):
|
|
||||||
# TODO: Extract keywords from segment text
|
|
||||||
pass
|
|
||||||
|
|
||||||
fetcher = MediaFetcher()
|
# Try to extract from video_data if available
|
||||||
return fetcher.fetch_broll(keywords)
|
if "video_data" in script and "keywords" in script["video_data"]:
|
||||||
|
keywords.extend(script["video_data"]["keywords"])
|
||||||
|
|
||||||
|
# Add default keywords if none found
|
||||||
|
if not keywords:
|
||||||
|
keywords = ["家族", "老後", "貯蓄"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
fetcher = MediaFetcher()
|
||||||
|
return fetcher.fetch_broll(keywords, max_per_keyword=2)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ B-Roll fetching failed: {e}")
|
||||||
|
return {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
"""
|
||||||
|
PowerPoint presentation extractor
|
||||||
|
|
||||||
|
This module handles:
|
||||||
|
- Extracting slides from PPTX files
|
||||||
|
- Converting slides to images
|
||||||
|
- Extracting text content from slides
|
||||||
|
- Matching slides to script segments
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pptx import Presentation
|
||||||
|
except ImportError:
|
||||||
|
Presentation = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
except ImportError:
|
||||||
|
Image = None
|
||||||
|
|
||||||
|
|
||||||
|
class PPTExtractor:
|
||||||
|
"""Extract slides and content from PowerPoint presentations"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if Presentation is None:
|
||||||
|
raise ImportError("python-pptx not installed. Run: pip install python-pptx")
|
||||||
|
if Image is None:
|
||||||
|
raise ImportError("Pillow not installed. Run: pip install Pillow")
|
||||||
|
|
||||||
|
def extract(self, ppt_path: str, output_dir: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Extract slides from PowerPoint presentation
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ppt_path: Path to PPTX file
|
||||||
|
output_dir: Directory to save slide images (default: assets/1_processed/slides)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing slide information:
|
||||||
|
{
|
||||||
|
"total_slides": int,
|
||||||
|
"slides": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"title": str,
|
||||||
|
"text": str,
|
||||||
|
"image_path": str,
|
||||||
|
"notes": str
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
ppt_path = Path(ppt_path)
|
||||||
|
if not ppt_path.exists():
|
||||||
|
raise FileNotFoundError(f"PPT file not found: {ppt_path}")
|
||||||
|
|
||||||
|
# Set output directory
|
||||||
|
if output_dir is None:
|
||||||
|
# CRITICAL: Output directly to public/ for Remotion to find them via staticFile
|
||||||
|
output_dir = Path("public")
|
||||||
|
else:
|
||||||
|
output_dir = Path(output_dir)
|
||||||
|
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print(f" 📊 Loading PowerPoint: {ppt_path.name}")
|
||||||
|
prs = Presentation(str(ppt_path))
|
||||||
|
|
||||||
|
slides_data = []
|
||||||
|
for idx, slide in enumerate(prs.slides):
|
||||||
|
slide_info = self._extract_slide_content(slide, idx)
|
||||||
|
|
||||||
|
# Export slide as image using LibreOffice/PowerPoint
|
||||||
|
image_path = output_dir / f"slide_{idx + 1:03d}.png"
|
||||||
|
slide_info["image_path"] = str(image_path)
|
||||||
|
|
||||||
|
slides_data.append(slide_info)
|
||||||
|
print(f" ✓ Extracted slide {idx + 1}: {slide_info['title'][:50]}...")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"total_slides": len(slides_data),
|
||||||
|
"slides": slides_data,
|
||||||
|
"source_file": str(ppt_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f" ✓ Extracted {len(slides_data)} slides from PowerPoint")
|
||||||
|
|
||||||
|
# Export slides as images
|
||||||
|
self._export_slides_as_images(str(ppt_path), output_dir)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _extract_slide_content(self, slide, index: int) -> Dict[str, Any]:
|
||||||
|
"""Extract text content from a single slide"""
|
||||||
|
slide_info = {
|
||||||
|
"index": index,
|
||||||
|
"title": "",
|
||||||
|
"text": "",
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract title (usually the first text box)
|
||||||
|
shapes_text = []
|
||||||
|
for shape in slide.shapes:
|
||||||
|
if hasattr(shape, "text") and shape.text:
|
||||||
|
text = shape.text.strip()
|
||||||
|
if text:
|
||||||
|
if not slide_info["title"]:
|
||||||
|
slide_info["title"] = text
|
||||||
|
else:
|
||||||
|
shapes_text.append(text)
|
||||||
|
|
||||||
|
# Combine all text
|
||||||
|
slide_info["text"] = "\n".join(shapes_text)
|
||||||
|
|
||||||
|
# Extract notes
|
||||||
|
if slide.has_notes_slide:
|
||||||
|
notes_slide = slide.notes_slide
|
||||||
|
if notes_slide.notes_text_frame:
|
||||||
|
slide_info["notes"] = notes_slide.notes_text_frame.text.strip()
|
||||||
|
|
||||||
|
return slide_info
|
||||||
|
|
||||||
|
def _export_slides_as_images(self, ppt_path: str, output_dir: Path):
|
||||||
|
"""
|
||||||
|
Export slides as PNG images
|
||||||
|
Tries multiple methods in order:
|
||||||
|
1. Windows PowerPoint COM (if on Windows)
|
||||||
|
2. LibreOffice (cross-platform)
|
||||||
|
3. Placeholder images (fallback)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# Method 1: Try Windows PowerPoint COM
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
try:
|
||||||
|
print(" 🖼️ Converting slides to images with PowerPoint...")
|
||||||
|
success = self._export_with_powerpoint_com(ppt_path, output_dir)
|
||||||
|
if success:
|
||||||
|
print(" ✓ Slides exported as images")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ PowerPoint COM failed: {e}")
|
||||||
|
|
||||||
|
# Method 2: Try LibreOffice
|
||||||
|
try:
|
||||||
|
print(" 🖼️ Converting slides to images with LibreOffice...")
|
||||||
|
subprocess.run([
|
||||||
|
"soffice",
|
||||||
|
"--headless",
|
||||||
|
"--convert-to", "png",
|
||||||
|
"--outdir", str(output_dir),
|
||||||
|
ppt_path
|
||||||
|
], check=True, capture_output=True, timeout=60)
|
||||||
|
print(" ✓ Slides exported as images")
|
||||||
|
return
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Method 3: Generate placeholders
|
||||||
|
print(" ⚠️ No conversion tool available, generating placeholder images...")
|
||||||
|
self._generate_placeholder_images(output_dir)
|
||||||
|
|
||||||
|
def _export_with_powerpoint_com(self, ppt_path: str, output_dir: Path) -> bool:
|
||||||
|
"""
|
||||||
|
Export slides using Windows PowerPoint COM interface
|
||||||
|
Returns True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import win32com.client
|
||||||
|
import pythoncom
|
||||||
|
except ImportError:
|
||||||
|
print(" ⚠️ pywin32 not installed. Run: pip install pywin32")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
pythoncom.CoInitialize()
|
||||||
|
powerpoint = win32com.client.Dispatch("PowerPoint.Application")
|
||||||
|
powerpoint.Visible = 1
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Open presentation (use absolute path)
|
||||||
|
prs = powerpoint.Presentations.Open(str(Path(ppt_path).absolute()), WithWindow=False)
|
||||||
|
|
||||||
|
# Export each slide as PNG
|
||||||
|
for i, slide in enumerate(prs.Slides):
|
||||||
|
output_file = output_dir / f"slide_{i + 1:03d}.png"
|
||||||
|
# Use absolute path for export
|
||||||
|
slide.Export(str(output_file.absolute()), "PNG", 1920, 1080)
|
||||||
|
|
||||||
|
# Close presentation
|
||||||
|
prs.Close()
|
||||||
|
powerpoint.Quit()
|
||||||
|
pythoncom.CoUninitialize()
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ PowerPoint COM error: {e}")
|
||||||
|
try:
|
||||||
|
powerpoint.Quit()
|
||||||
|
pythoncom.CoUninitialize()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _generate_placeholder_images(self, output_dir: Path):
|
||||||
|
"""Generate placeholder slide images"""
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
# Create placeholder images for slides
|
||||||
|
slide_files = list(output_dir.glob("slide_*.png"))
|
||||||
|
if not slide_files:
|
||||||
|
# Generate at least one placeholder
|
||||||
|
img = Image.new('RGB', (1920, 1080), color='white')
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Draw simple text
|
||||||
|
text = "PowerPoint Slide"
|
||||||
|
draw.text((960, 540), text, fill='black', anchor="mm")
|
||||||
|
|
||||||
|
img.save(output_dir / "slide_001.png")
|
||||||
|
print(" ✓ Generated placeholder slide image")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_ppt_data(ppt_path: str) -> Dict[str, Any]:
|
||||||
|
"""Convenience function for PPT extraction"""
|
||||||
|
extractor = PPTExtractor()
|
||||||
|
return extractor.extract(ppt_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Test extraction
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python ppt_extractor.py <path_to_pptx>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
ppt_path = sys.argv[1]
|
||||||
|
result = extract_ppt_data(ppt_path)
|
||||||
|
|
||||||
|
print(f"\n📊 Extracted {result['total_slides']} slides")
|
||||||
|
for slide in result['slides']:
|
||||||
|
print(f"\nSlide {slide['index'] + 1}:")
|
||||||
|
print(f" Title: {slide['title']}")
|
||||||
|
print(f" Text length: {len(slide['text'])} chars")
|
||||||
|
print(f" Image: {slide['image_path']}")
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add project root to sys.path to allow importing backend modules
|
||||||
|
project_root = Path(__file__).parent.parent
|
||||||
|
sys.path.append(str(project_root))
|
||||||
|
|
||||||
|
from backend.audio_generator import generate_audio_for_script
|
||||||
|
|
||||||
|
def rebuild_audio_from_props():
|
||||||
|
"""
|
||||||
|
Reads assets/1_processed/render_props.json, generates new audio files
|
||||||
|
for the narration text, and updates the durations and paths.
|
||||||
|
"""
|
||||||
|
props_path = project_root / "assets" / "1_processed" / "render_props.json"
|
||||||
|
|
||||||
|
if not props_path.exists():
|
||||||
|
print(f"❌ Error: {props_path} not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"📖 Reading script from {props_path}...")
|
||||||
|
with open(props_path, "r", encoding="utf-8") as f:
|
||||||
|
props = json.load(f)
|
||||||
|
|
||||||
|
# Prepare script data for the generator
|
||||||
|
# RE-INDEX segments first
|
||||||
|
print("🔢 Re-indexing script segments and fixing paths...")
|
||||||
|
for i, segment in enumerate(props["script"]):
|
||||||
|
new_id = i + 1
|
||||||
|
segment["id"] = new_id
|
||||||
|
if segment.get("visualType") != "video":
|
||||||
|
segment["audioPath"] = f"public/segment_{new_id:03d}.mp3"
|
||||||
|
elif "audioPath" in segment:
|
||||||
|
del segment["audioPath"]
|
||||||
|
|
||||||
|
script_data = {"segments": props["script"]}
|
||||||
|
|
||||||
|
# Generate new audio files (outputs to public/ folder)
|
||||||
|
print("🎙️ Generating new audio files...")
|
||||||
|
updated_script_data = generate_audio_for_script(script_data)
|
||||||
|
|
||||||
|
# Update the script in our props object
|
||||||
|
props["script"] = updated_script_data["segments"]
|
||||||
|
|
||||||
|
# Recalculate total duration if needed (Remotion uses segment durations)
|
||||||
|
total_duration = sum(seg.get("duration", 0) for seg in props["script"])
|
||||||
|
print(f"⏱️ New total duration: {total_duration:.2f} seconds")
|
||||||
|
|
||||||
|
# Save updated render_props.json
|
||||||
|
with open(props_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(props, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"✅ Successfully updated {props_path} with new audio and durations.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
rebuild_audio_from_props()
|
||||||
|
|
@ -9,10 +9,12 @@ This module generates natural Japanese narration scripts based on:
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import google.generativeai as genai
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
except ImportError:
|
except ImportError:
|
||||||
genai = None
|
genai = None
|
||||||
|
|
||||||
|
|
@ -27,12 +29,12 @@ class ScriptGenerator:
|
||||||
|
|
||||||
if genai is None:
|
if genai is None:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"google-generativeai not installed. "
|
"google-genai not installed. "
|
||||||
"Run: pip install google-generativeai"
|
"Run: pip install google-genai"
|
||||||
)
|
)
|
||||||
|
|
||||||
genai.configure(api_key=self.api_key)
|
self.client = genai.Client(api_key=self.api_key)
|
||||||
self.model = genai.GenerativeModel('gemini-1.5-pro')
|
self.model = 'gemini-2.0-flash-exp'
|
||||||
|
|
||||||
def generate(
|
def generate(
|
||||||
self,
|
self,
|
||||||
|
|
@ -40,27 +42,27 @@ class ScriptGenerator:
|
||||||
video_data: Optional[Dict[str, Any]] = None,
|
video_data: Optional[Dict[str, Any]] = None,
|
||||||
style: str = "friendly"
|
style: str = "friendly"
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Generate narration script
|
"""Generate narration script"""
|
||||||
|
print(f" Generating script with Gemini Pro for client: {pdf_data.get('client_name', 'Unknown')}...")
|
||||||
|
|
||||||
Args:
|
# Merge Video Data into PDF Data for prompt building
|
||||||
pdf_data: Structured data from PDF extraction
|
merged_data = {**pdf_data}
|
||||||
video_data: Optional video transcription data
|
if video_data:
|
||||||
style: Narration style ('friendly', 'professional', 'casual')
|
merged_data["transcription"] = video_data.get("transcription", "")
|
||||||
|
merged_data["clips"] = video_data.get("clips", [])
|
||||||
Returns:
|
|
||||||
Dictionary containing:
|
|
||||||
- segments: List of narration segments
|
|
||||||
- total_duration: Estimated total duration in seconds
|
|
||||||
- sync_points: Recommended chart/video sync points
|
|
||||||
"""
|
|
||||||
print(" Generating script with Gemini Pro...")
|
|
||||||
|
|
||||||
# Build the prompt
|
# Build the prompt
|
||||||
prompt = self._build_prompt(pdf_data, style)
|
prompt = self._build_prompt(merged_data, style)
|
||||||
|
|
||||||
|
# DEBUG: Print the first part of the prompt
|
||||||
|
# print(f"--- DEBUG PROMPT START ---\n{prompt[:500]}\n--- DEBUG PROMPT END ---")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Call Gemini API
|
# Call Gemini API
|
||||||
response = self.model.generate_content(prompt)
|
response = self.client.models.generate_content(
|
||||||
|
model=self.model,
|
||||||
|
contents=prompt
|
||||||
|
)
|
||||||
|
|
||||||
# Parse the response
|
# Parse the response
|
||||||
script_data = self._parse_response(response.text)
|
script_data = self._parse_response(response.text)
|
||||||
|
|
@ -76,15 +78,60 @@ class ScriptGenerator:
|
||||||
def _build_prompt(self, pdf_data: Dict, style: str) -> str:
|
def _build_prompt(self, pdf_data: Dict, style: str) -> str:
|
||||||
"""Build the prompt for Gemini Pro"""
|
"""Build the prompt for Gemini Pro"""
|
||||||
client_name = pdf_data.get("client_name", "お客様")
|
client_name = pdf_data.get("client_name", "お客様")
|
||||||
current_age = pdf_data.get("current_age", 35)
|
current_age = pdf_data.get("current_age", 47)
|
||||||
retirement_age = pdf_data.get("retirement_age", 65)
|
retirement_age = pdf_data.get("retirement_age", 60)
|
||||||
current_assets = pdf_data.get("current_assets", 5000)
|
current_assets = pdf_data.get("current_assets", 907)
|
||||||
annual_income = pdf_data.get("annual_income", 600)
|
annual_income = pdf_data.get("annual_income", 703)
|
||||||
annual_expenses = pdf_data.get("annual_expenses", 450)
|
annual_expenses = pdf_data.get("annual_expenses", 288)
|
||||||
|
life_events = pdf_data.get("life_events", [])
|
||||||
|
|
||||||
|
# Transcription and Clips
|
||||||
|
transcription = pdf_data.get("transcription", "")
|
||||||
|
clips = pdf_data.get("clips", [])
|
||||||
|
|
||||||
|
# Include PPT data if available
|
||||||
|
ppt_info = ""
|
||||||
|
presentation = pdf_data.get("presentation", {})
|
||||||
|
if presentation and "slides" in presentation:
|
||||||
|
ppt_info = "\n【PPTスライド内容】\n"
|
||||||
|
for slide in presentation["slides"]:
|
||||||
|
ppt_info += f"- スライド {slide['index']}: タイトル: {slide['title']}\n"
|
||||||
|
if slide.get("text"):
|
||||||
|
ppt_info += f" 内容: {slide['text'][:400]}\n"
|
||||||
|
if slide.get("notes"):
|
||||||
|
ppt_info += f" ノート: {slide['notes'][:400]}\n"
|
||||||
|
|
||||||
|
# Life events info
|
||||||
|
events_info = ""
|
||||||
|
if life_events:
|
||||||
|
events_info = "\n【ライフイベント予定】\n"
|
||||||
|
for ev in life_events:
|
||||||
|
events_info += f"- {ev.get('year')}年 ({ev.get('age')}歳): {ev.get('description')} (費用: {ev.get('cost', 0)}万円)\n"
|
||||||
|
|
||||||
|
# Video Transcription and Clips info
|
||||||
|
video_info = ""
|
||||||
|
if transcription:
|
||||||
|
video_info = f"\n【インタビュー動画の内容】\n{transcription[:2000]}\n"
|
||||||
|
|
||||||
|
if clips:
|
||||||
|
video_info += "\n【利用可能なビデオクリップ】\n"
|
||||||
|
for i, clip in enumerate(clips):
|
||||||
|
video_info += f"- クリップ {i}: 「{clip.get('text', '')[:100]}...」\n"
|
||||||
|
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
あなたはファイナンシャルプランナーのナレーション原稿作成アシスタントです。
|
あなたはプロのファイナンシャルプランナーであり、親しみやすい動画解説者です。
|
||||||
以下の顧客情報に基づいて、3〜5分の解説動画用のナレーション原稿を作成してください。
|
以下の顧客情報、ライフイベント、PPTスライド、そしてインタビュー動画の転記内容に基づいて、解説動画用の完結なナレーション原稿を作成してください。
|
||||||
|
|
||||||
|
【厳格な指示】
|
||||||
|
1. 抽象的な表現(「〇〇」や「△△」)や、メタ発言(「スライドの内容がありません」など)は絶対に出力しないでください。
|
||||||
|
2. インタビュー内容やPDFの数値を最大限に活用し、事実に基づいた具体的な解説を行ってください。
|
||||||
|
3. 各セグメントの视覚素材(`visualType`)は、以下から適切に選択してください:
|
||||||
|
- `ppt`: PPTスライドを表示する場合。`visualAssetIndex` を指定してください(基本はこちらを優先)。
|
||||||
|
- `chart`: 資産推移や収支の動的なグラフを表示する場合。シミュレーションの結果を具体的に説明するシーンで使用します。
|
||||||
|
- `video`: 本人のインタビュー映像を表示する場合。本人の肉声や想いを伝えるシーンで使用します。`visualAssetIndex` にクリップの番号を指定してください。
|
||||||
|
- `dialogue`: 文字による対話形式(インタビューの再現)を表示する場合。動画の冒頭でインタビューのやり取りをテキストで再現する際に使用します。`speaker` 属性に話者名を指定してください。
|
||||||
|
4. ナレーションの中に「スライド〇〇をご覧ください」や「インタビューでもおっしゃっていた通り...」といった言葉を入れ、視聴者の視線を誘導してください。
|
||||||
|
5. 真人インタビュー(`video` タイプ)を使用する場合、ナレーションテキスト(`text` 属性)はそのクリップで本人が話している内容と一致させてください。
|
||||||
|
|
||||||
【顧客情報】
|
【顧客情報】
|
||||||
- お名前: {client_name}様
|
- お名前: {client_name}様
|
||||||
|
|
@ -93,60 +140,95 @@ class ScriptGenerator:
|
||||||
- 現在の資産: {current_assets}万円
|
- 現在の資産: {current_assets}万円
|
||||||
- 年収: {annual_income}万円
|
- 年収: {annual_income}万円
|
||||||
- 年間支出: {annual_expenses}万円
|
- 年間支出: {annual_expenses}万円
|
||||||
|
{events_info}
|
||||||
|
{ppt_info}
|
||||||
|
{video_info}
|
||||||
|
|
||||||
【原稿作成の指示】
|
【原稿構成のフロー】
|
||||||
1. 合計3〜5分(180〜300秒)の原稿を作成してください
|
動画全体で少なくとも 12〜18 のセグメントを作成し、以下の流れで構成してください:
|
||||||
2. 以下のセグメントに分割してください:
|
1. 文字対話オープニング(visualType: dialogue):インタビュー動画から、ライフプラン相談のきっかけや想いがわかる重要なやり取りを 3〜5 セグメントほど抽出して配置してください。
|
||||||
- オープニング(挨拶と概要説明)
|
2. 動画全体の導入(Slide 0):インパクトのある挨拶。
|
||||||
- 現在の状況説明
|
3. 自己紹介と現状(Slide 1, 2...):家計の現状をスライドに沿って。
|
||||||
- 将来の資産推移の解説
|
3. ライフイベント(Slide 3, 4...):具体的なイベント内容の解説。
|
||||||
- 退職後の生活資金について
|
4. インタビュー挿入(Video):本人の想いや不安、决意を语るシーン。
|
||||||
- リスクと対策の提案
|
5. シミュレーション詳細(Chart):動的なグラフ(資産推移・収支明細)を使い、何年後に資産がどうなるかを具体的に説明。
|
||||||
- クロージング(まとめと安心感)
|
6. 対策とアドバイス(Slide 5, 6...):改善策や提案をスライドで。
|
||||||
|
7. クロージング(Slide 7...):まとめと個別相談の案内。
|
||||||
|
|
||||||
3. 各セグメントには以下を含めてください:
|
【素材使用の鉄則 - 必ず守ること】
|
||||||
- id: セグメント番号(1から開始)
|
- **PPTスライドの全消化**: 提供されたスライド(Slide 0 から最後のリファレンスまで)を、内容に合わせて**必ず全て最低1回は表示**してください。スライドを飛ばさないでください。
|
||||||
- text: ナレーション文(自然な日本語、敬語)
|
- **ハイブリッド構成**: 「PPTで事実を伝え、Chartでデータを動かし、Videoで本人の声を伝える」というリズムを意識してください。
|
||||||
- duration: 推定時間(秒)
|
|
||||||
- type: シーンタイプ("narration", "chart_intro", "chart_highlight", "transition")
|
|
||||||
- chartSync: チャート表示の指示(該当する場合のみ)
|
|
||||||
|
|
||||||
4. トーンは{style}(親しみやすく、でも専門的)にしてください
|
【出力形式】
|
||||||
|
純粋なJSON形式のみで返してください。コードブロックは不要です。
|
||||||
5. 出力はJSON形式で、以下の構造で返してください:
|
|
||||||
{{
|
{{
|
||||||
"segments": [
|
"segments": [
|
||||||
{{
|
{{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"text": "ナレーション文",
|
"text": "読み上げる文章",
|
||||||
"duration": 3.5,
|
"insight": "画面に表示する短い要約・洞察(20文字程度)",
|
||||||
"type": "narration",
|
"targetYear": 2025,
|
||||||
"chartSync": {{
|
"duration": 秒数,
|
||||||
"chartId": "assetDepletion",
|
"type": "narration" | "dialogue" | "chart_intro" | "chart_highlight" | "video_clip",
|
||||||
"highlightYear": 2025
|
"visualType": "ppt" | "video" | "chart" | "dialogue",
|
||||||
}}
|
"visualAssetIndex": 0,
|
||||||
|
"speaker": "話者名",
|
||||||
|
"brollKeyword": "キーワード"
|
||||||
}}
|
}}
|
||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
必ずJSON形式のみを返してください。説明文は不要です。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return prompt
|
return prompt
|
||||||
|
|
||||||
def _parse_response(self, response_text: str) -> Dict[str, Any]:
|
def _parse_response(self, response_text: str) -> Dict[str, Any]:
|
||||||
"""Parse Gemini's JSON response"""
|
"""Parse Gemini's JSON response"""
|
||||||
# Remove markdown code blocks if present
|
# Remove markdown code blocks and any extra text
|
||||||
text = response_text.strip()
|
text = response_text.strip()
|
||||||
if text.startswith("```json"):
|
|
||||||
text = text[7:]
|
# Try to find JSON block
|
||||||
if text.startswith("```"):
|
if "{" in text and "}" in text:
|
||||||
text = text[3:]
|
start = text.find("{")
|
||||||
if text.endswith("```"):
|
end = text.rfind("}") + 1
|
||||||
text = text[:-3]
|
text = text[start:end]
|
||||||
text = text.strip()
|
|
||||||
|
|
||||||
# Parse JSON
|
# Parse JSON
|
||||||
data = json.loads(text)
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f" ❌ JSON Parse Error: {e}")
|
||||||
|
print(f" Raw text: {text[:500]}...")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Post-process segments to remove hallucinations/meta-talk
|
||||||
|
for seg in data["segments"]:
|
||||||
|
# --- CRITICAL FIX FOR TEXT/AUDIO SYNC ---
|
||||||
|
# Ensure we don't have lingering brackets or meta-tags that might
|
||||||
|
# be processed differently by TTS vs Subtitles
|
||||||
|
|
||||||
|
# 1. Clean up meta-patterns from text (for both TTS and Subtitles)
|
||||||
|
meta_patterns = [
|
||||||
|
r'\(スライド\d+.*?\)',
|
||||||
|
r'(スライド\d+.*?)',
|
||||||
|
r'\[スライド\d+.*?\]',
|
||||||
|
r'【スライド\d+.*?】'
|
||||||
|
]
|
||||||
|
for pattern in meta_patterns:
|
||||||
|
seg["text"] = re.sub(pattern, '', seg["text"])
|
||||||
|
|
||||||
|
# 2. Standardize whitespace and remove hidden characters
|
||||||
|
seg["text"] = " ".join(seg["text"].split())
|
||||||
|
|
||||||
|
# 3. Replace placeholders
|
||||||
|
seg["text"] = seg["text"].replace("〇〇", "こちらの")
|
||||||
|
seg["text"] = seg["text"].strip()
|
||||||
|
|
||||||
|
# 4. Add duration buffer (give more breathing room for visuals/HUD)
|
||||||
|
try:
|
||||||
|
duration = float(seg.get("duration", 5.0))
|
||||||
|
seg["duration"] = duration * 1.1 + 1.0
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
seg["duration"] = 6.5
|
||||||
|
|
||||||
# Calculate total duration
|
# Calculate total duration
|
||||||
total_duration = sum(seg.get("duration", 0) for seg in data["segments"])
|
total_duration = sum(seg.get("duration", 0) for seg in data["segments"])
|
||||||
|
|
|
||||||
|
|
@ -10,21 +10,31 @@ import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_ffmpeg_cmd(cmd_name: str = "ffmpeg") -> str:
|
||||||
|
"""Get the full path to ffmpeg/ffprobe, checking common locations if not in PATH"""
|
||||||
|
# 1. Try default from PATH
|
||||||
|
try:
|
||||||
|
subprocess.run([cmd_name, "-version"], capture_output=True, check=True)
|
||||||
|
return cmd_name
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. Try the specific Winget path found in the user environment
|
||||||
|
winget_path = Path(r"C:\Users\91368\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-8.0.1-full_build\bin")
|
||||||
|
if winget_path.exists():
|
||||||
|
target = winget_path / (f"{cmd_name}.exe" if os.name == 'nt' else cmd_name)
|
||||||
|
if target.exists():
|
||||||
|
return str(target)
|
||||||
|
|
||||||
|
return cmd_name
|
||||||
|
|
||||||
|
|
||||||
def get_video_duration(video_path: str) -> float:
|
def get_video_duration(video_path: str) -> float:
|
||||||
"""
|
"""
|
||||||
Get video duration in seconds using ffprobe.
|
Get video duration in seconds using ffprobe.
|
||||||
|
|
||||||
Args:
|
|
||||||
video_path: Path to video file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Duration in seconds
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: If ffprobe fails or video is invalid
|
|
||||||
"""
|
"""
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffprobe",
|
get_ffmpeg_cmd("ffprobe"),
|
||||||
"-v", "error",
|
"-v", "error",
|
||||||
"-show_entries", "format=duration",
|
"-show_entries", "format=duration",
|
||||||
"-of", "json",
|
"-of", "json",
|
||||||
|
|
@ -72,7 +82,7 @@ def extract_audio(
|
||||||
output_path = str(Path(video_path).parent / f"{video_name}_audio.wav")
|
output_path = str(Path(video_path).parent / f"{video_name}_audio.wav")
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffmpeg",
|
get_ffmpeg_cmd("ffmpeg"),
|
||||||
"-i", video_path,
|
"-i", video_path,
|
||||||
"-vn", # No video
|
"-vn", # No video
|
||||||
"-acodec", codec,
|
"-acodec", codec,
|
||||||
|
|
@ -124,7 +134,7 @@ def slice_video(
|
||||||
duration = end_time - start_time
|
duration = end_time - start_time
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffmpeg",
|
get_ffmpeg_cmd("ffmpeg"),
|
||||||
"-ss", str(start_time),
|
"-ss", str(start_time),
|
||||||
"-i", video_path,
|
"-i", video_path,
|
||||||
"-t", str(duration),
|
"-t", str(duration),
|
||||||
|
|
@ -146,15 +156,15 @@ def slice_video(
|
||||||
|
|
||||||
|
|
||||||
def check_ffmpeg_installed() -> bool:
|
def check_ffmpeg_installed() -> bool:
|
||||||
"""Check if FFmpeg and FFprobe are available in PATH."""
|
"""Check if FFmpeg and FFprobe are available."""
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["ffmpeg", "-version"],
|
[get_ffmpeg_cmd("ffmpeg"), "-version"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
check=True
|
check=True
|
||||||
)
|
)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["ffprobe", "-version"],
|
[get_ffmpeg_cmd("ffprobe"), "-version"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
check=True
|
check=True
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
"""
|
||||||
|
Video Slicing Module
|
||||||
|
|
||||||
|
Extract important segments from source video based on transcription analysis.
|
||||||
|
This is an optional feature for including interview clips in the final video.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
except ImportError:
|
||||||
|
genai = None
|
||||||
|
|
||||||
|
from backend.video_processor.utils import slice_video, get_video_duration
|
||||||
|
|
||||||
|
|
||||||
|
class VideoSlicer:
|
||||||
|
"""Extract important segments from source video"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: Optional[str] = None):
|
||||||
|
self.api_key = api_key or os.getenv("GEMINI_API_KEY")
|
||||||
|
# CRITICAL: Output directly to public/ for Remotion to find them via staticFile
|
||||||
|
self.output_dir = Path("public")
|
||||||
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if self.api_key and genai:
|
||||||
|
self.client = genai.Client(api_key=self.api_key)
|
||||||
|
else:
|
||||||
|
self.client = None
|
||||||
|
|
||||||
|
def extract_key_moments(
|
||||||
|
self,
|
||||||
|
video_path: str,
|
||||||
|
transcription_data: Dict[str, Any],
|
||||||
|
max_clips: int = 3,
|
||||||
|
target_duration: float = 5.0
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Analyze transcription and extract key video moments
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_path: Path to source video
|
||||||
|
transcription_data: Transcription with segments and timestamps
|
||||||
|
max_clips: Maximum number of clips to extract
|
||||||
|
target_duration: Target duration for each clip in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of clip info dictionaries with start, end, filepath, description
|
||||||
|
"""
|
||||||
|
print(f" ✂️ Analyzing video for key moments...")
|
||||||
|
|
||||||
|
# Get video duration
|
||||||
|
try:
|
||||||
|
duration = get_video_duration(video_path)
|
||||||
|
print(f" ℹ️ Video duration: {duration:.1f}s")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Could not get video duration: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Use Gemini to identify key moments from transcription
|
||||||
|
if self.client and "segments" in transcription_data:
|
||||||
|
key_segments = self._identify_key_segments(
|
||||||
|
transcription_data["segments"],
|
||||||
|
max_clips=max_clips
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback: use simple heuristics
|
||||||
|
key_segments = self._simple_segment_selection(
|
||||||
|
transcription_data.get("segments", []),
|
||||||
|
max_clips=max_clips
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract video clips
|
||||||
|
clips = []
|
||||||
|
for i, segment_info in enumerate(key_segments[:max_clips]):
|
||||||
|
start = segment_info["start"]
|
||||||
|
end = min(segment_info["end"], start + target_duration)
|
||||||
|
|
||||||
|
# Ensure within video bounds
|
||||||
|
if end > duration:
|
||||||
|
end = duration
|
||||||
|
if start >= duration:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract clip
|
||||||
|
output_path = self.output_dir / f"clip_{i+1}_{start:.1f}-{end:.1f}.mp4"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not output_path.exists():
|
||||||
|
print(f" ✂️ Extracting clip {i+1}: {start:.1f}s - {end:.1f}s")
|
||||||
|
clip_path = slice_video(video_path, start, end, str(output_path))
|
||||||
|
else:
|
||||||
|
clip_path = str(output_path)
|
||||||
|
print(f" ✓ Clip {i+1} already cached")
|
||||||
|
|
||||||
|
clips.append({
|
||||||
|
"id": f"clip_{i+1}",
|
||||||
|
"outputPath": clip_path,
|
||||||
|
"filepath": clip_path, # Keep for backward compatibility if any
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
"duration": end - start,
|
||||||
|
"description": segment_info.get("text", "")[:100]
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Failed to extract clip {i+1}: {e}")
|
||||||
|
|
||||||
|
print(f" ✓ Extracted {len(clips)} video clips")
|
||||||
|
return clips
|
||||||
|
|
||||||
|
def _identify_key_segments(
|
||||||
|
self,
|
||||||
|
segments: List[Dict[str, Any]],
|
||||||
|
max_clips: int
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Use Gemini to identify important segments"""
|
||||||
|
if not self.client:
|
||||||
|
return self._simple_segment_selection(segments, max_clips)
|
||||||
|
|
||||||
|
# Prepare transcript for analysis
|
||||||
|
transcript_text = "\n".join([
|
||||||
|
f"[{seg['start']:.1f}s - {seg['end']:.1f}s] {seg['text']}"
|
||||||
|
for seg in segments
|
||||||
|
])
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
以下は動画のトランスクリプトです。ライフプランニングの説明動画に最も適した重要なシーンを{max_clips}つ選んでください。
|
||||||
|
|
||||||
|
トランスクリプト:
|
||||||
|
{transcript_text}
|
||||||
|
|
||||||
|
以下のJSON形式で、重要なシーンの開始時刻、終了時刻、理由を返してください:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{{
|
||||||
|
"start": 開始時刻(秒),
|
||||||
|
"end": 終了時刻(秒),
|
||||||
|
"text": "そのシーンのテキスト",
|
||||||
|
"reason": "選んだ理由"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.client.models.generate_content(
|
||||||
|
model='gemini-2.0-flash-exp',
|
||||||
|
contents=prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
response_text = response.text.strip()
|
||||||
|
if "```json" in response_text:
|
||||||
|
json_start = response_text.find("```json") + 7
|
||||||
|
json_end = response_text.find("```", json_start)
|
||||||
|
response_text = response_text[json_start:json_end].strip()
|
||||||
|
elif "```" in response_text:
|
||||||
|
json_start = response_text.find("```") + 3
|
||||||
|
json_end = response_text.find("```", json_start)
|
||||||
|
response_text = response_text[json_start:json_end].strip()
|
||||||
|
|
||||||
|
import json
|
||||||
|
key_segments = json.loads(response_text)
|
||||||
|
print(f" 🤖 Gemini identified {len(key_segments)} key moments")
|
||||||
|
return key_segments
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Gemini analysis failed: {e}, using fallback...")
|
||||||
|
return self._simple_segment_selection(segments, max_clips)
|
||||||
|
|
||||||
|
def _simple_segment_selection(
|
||||||
|
self,
|
||||||
|
segments: List[Dict[str, Any]],
|
||||||
|
max_clips: int
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Simple heuristic: select evenly spaced segments"""
|
||||||
|
if not segments:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Select evenly distributed segments
|
||||||
|
step = len(segments) // max_clips if len(segments) >= max_clips else 1
|
||||||
|
selected = [segments[i * step] for i in range(min(max_clips, len(segments)))]
|
||||||
|
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def extract_video_clips(
|
||||||
|
video_path: str,
|
||||||
|
transcription_data: Dict[str, Any],
|
||||||
|
max_clips: int = 3
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Convenience function to extract video clips
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_path: Path to source video
|
||||||
|
transcription_data: Transcription with segments
|
||||||
|
max_clips: Maximum number of clips to extract
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of clip information dictionaries
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
slicer = VideoSlicer()
|
||||||
|
return slicer.extract_key_moments(video_path, transcription_data, max_clips=max_clips)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Video slicing failed: {e}")
|
||||||
|
return []
|
||||||
|
|
@ -76,13 +76,17 @@ export type ScriptSegmentType =
|
||||||
| "chart_intro" // Introduction to chart
|
| "chart_intro" // Introduction to chart
|
||||||
| "chart_highlight" // Highlighting specific data point
|
| "chart_highlight" // Highlighting specific data point
|
||||||
| "video_clip" // Original interview footage
|
| "video_clip" // Original interview footage
|
||||||
|
| "dialogue" // Text-based interview dialogue
|
||||||
| "transition"; // Transition between scenes
|
| "transition"; // Transition between scenes
|
||||||
|
|
||||||
export interface ScriptSegment {
|
export interface ScriptSegment {
|
||||||
id: number;
|
id: number;
|
||||||
text: string; // Japanese narration text
|
text: string; // Japanese narration text
|
||||||
|
insight?: string; // Optional key insight for floating card
|
||||||
duration: number; // Duration in seconds
|
duration: number; // Duration in seconds
|
||||||
type: ScriptSegmentType;
|
type: ScriptSegmentType;
|
||||||
|
visualType?: "ppt" | "video" | "chart" | "broll" | "dialogue";
|
||||||
|
visualAssetIndex?: number;
|
||||||
assetUrl?: string; // URL or path to B-Roll/video clip
|
assetUrl?: string; // URL or path to B-Roll/video clip
|
||||||
chartSync?: {
|
chartSync?: {
|
||||||
// Optional chart synchronization
|
// Optional chart synchronization
|
||||||
|
|
@ -120,6 +124,8 @@ export interface RenderProps {
|
||||||
currencyUnit: string; // Currency unit for all financial data
|
currencyUnit: string; // Currency unit for all financial data
|
||||||
duration: number; // Total video duration in seconds
|
duration: number; // Total video duration in seconds
|
||||||
clientName?: string; // Client name for personalization
|
clientName?: string; // Client name for personalization
|
||||||
|
advisorName?: string; // Financial advisor name (e.g., "田中太郎")
|
||||||
|
advisorTitle?: string; // Advisor title (e.g., "CFP®", "AFP", "ファイナンシャルプランナー")
|
||||||
generatedAt?: string; // ISO timestamp of generation
|
generatedAt?: string; // ISO timestamp of generation
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -173,14 +179,19 @@ export const CashFlowDataSchema = z.object({
|
||||||
export const ScriptSegmentSchema = z.object({
|
export const ScriptSegmentSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
text: z.string(),
|
text: z.string(),
|
||||||
|
insight: z.string().optional(),
|
||||||
duration: z.number(),
|
duration: z.number(),
|
||||||
type: z.enum([
|
type: z.enum([
|
||||||
"narration",
|
"narration",
|
||||||
"chart_intro",
|
"chart_intro",
|
||||||
"chart_highlight",
|
"chart_highlight",
|
||||||
"video_clip",
|
"video_clip",
|
||||||
|
"dialogue",
|
||||||
"transition",
|
"transition",
|
||||||
]),
|
]),
|
||||||
|
visualType: z.enum(["ppt", "video", "chart", "broll", "dialogue"]).optional(),
|
||||||
|
visualAssetIndex: z.number().optional(),
|
||||||
|
speaker: z.string().optional(), // Speaker name for dialogue
|
||||||
assetUrl: z.string().optional(),
|
assetUrl: z.string().optional(),
|
||||||
chartSync: z
|
chartSync: z
|
||||||
.object({
|
.object({
|
||||||
|
|
@ -197,6 +208,8 @@ export const RenderPropsSchema = z.object({
|
||||||
currencyUnit: z.string(),
|
currencyUnit: z.string(),
|
||||||
duration: z.number(),
|
duration: z.number(),
|
||||||
clientName: z.string().optional(),
|
clientName: z.string().optional(),
|
||||||
|
advisorName: z.string().optional(),
|
||||||
|
advisorTitle: z.string().optional(),
|
||||||
generatedAt: z.string().optional(),
|
generatedAt: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
charts: z.object({
|
charts: z.object({
|
||||||
|
|
|
||||||
290
main.py
|
|
@ -45,13 +45,56 @@ class VideoGeneratorPipeline:
|
||||||
for dir_path in [self.input_dir, self.processed_dir, self.output_dir]:
|
for dir_path in [self.input_dir, self.processed_dir, self.output_dir]:
|
||||||
dir_path.mkdir(parents=True, exist_ok=True)
|
dir_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Auto-discover input files if not specified
|
||||||
|
self._auto_discover_inputs()
|
||||||
|
|
||||||
|
def _auto_discover_inputs(self):
|
||||||
|
"""
|
||||||
|
Auto-discover input files from assets/0_input/ directory
|
||||||
|
Priority: PDF > PPTX > Video
|
||||||
|
"""
|
||||||
|
print("🔍 Scanning input directory for files...")
|
||||||
|
|
||||||
|
# Find PDF files
|
||||||
|
if not self.config.get("pdf_path"):
|
||||||
|
pdf_files = list(self.input_dir.glob("*.pdf"))
|
||||||
|
if pdf_files:
|
||||||
|
self.config["pdf_path"] = str(pdf_files[0])
|
||||||
|
print(f" ✓ Found PDF: {pdf_files[0].name}")
|
||||||
|
|
||||||
|
# Find PPTX files
|
||||||
|
if not self.config.get("ppt_path"):
|
||||||
|
ppt_files = list(self.input_dir.glob("*.pptx"))
|
||||||
|
# Filter out temporary files (starting with ~$)
|
||||||
|
ppt_files = [f for f in ppt_files if not f.name.startswith("~$")]
|
||||||
|
if ppt_files:
|
||||||
|
self.config["ppt_path"] = str(ppt_files[0])
|
||||||
|
print(f" ✓ Found PPT: {ppt_files[0].name}")
|
||||||
|
|
||||||
|
# Find video files
|
||||||
|
if not self.config.get("video_path"):
|
||||||
|
video_extensions = ["*.mp4", "*.mov", "*.avi", "*.mkv"]
|
||||||
|
for ext in video_extensions:
|
||||||
|
video_files = list(self.input_dir.glob(ext))
|
||||||
|
if video_files:
|
||||||
|
self.config["video_path"] = str(video_files[0])
|
||||||
|
print(f" ✓ Found Video: {video_files[0].name}")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
if not any([self.config.get("pdf_path"), self.config.get("ppt_path")]):
|
||||||
|
print(" ⚠️ No PDF or PPT files found in input directory")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
def run(self) -> str:
|
def run(self) -> str:
|
||||||
"""Execute the full pipeline and return output video path"""
|
"""Execute the full pipeline and return output video path"""
|
||||||
print("🚀 Starting Auto Life Plan Video Generation Pipeline...")
|
print("🚀 Starting Auto Life Plan Video Generation Pipeline...")
|
||||||
|
|
||||||
# Step 1: Extract data from PDF
|
# Step 1: Extract data from PDF or PPT
|
||||||
print("\n📄 Step 1/6: Extracting data from PDF...")
|
print("\n📄 Step 1/6: Extracting data from documents...")
|
||||||
pdf_data = self.extract_pdf()
|
pdf_data = self.extract_pdf()
|
||||||
|
ppt_data = self.extract_ppt()
|
||||||
|
|
||||||
# Step 2: Extract data from video (if provided)
|
# Step 2: Extract data from video (if provided)
|
||||||
print("\n🎥 Step 2/6: Processing video footage...")
|
print("\n🎥 Step 2/6: Processing video footage...")
|
||||||
|
|
@ -59,19 +102,19 @@ class VideoGeneratorPipeline:
|
||||||
|
|
||||||
# Step 3: Generate narration script
|
# Step 3: Generate narration script
|
||||||
print("\n✍️ Step 3/6: Generating narration script...")
|
print("\n✍️ Step 3/6: Generating narration script...")
|
||||||
script = self.generate_script(pdf_data, video_data)
|
script = self.generate_script(pdf_data, ppt_data, video_data)
|
||||||
|
|
||||||
# Step 3.5: Generate audio from script (NEW)
|
# Step 3.5: Generate audio from script (NEW)
|
||||||
print("\n🎙️ Step 3.5/6: Generating audio narration...")
|
print("\n🎙️ Step 3.5/6: Generating audio narration...")
|
||||||
script = self.generate_audio(script)
|
script = self.generate_audio(script)
|
||||||
|
|
||||||
# Step 4: Fetch B-Roll assets
|
# Step 4: Fetch B-Roll assets (TEMPORARILY DISABLED)
|
||||||
print("\n🎬 Step 4/6: Fetching B-Roll assets...")
|
print("\n🎬 Step 4/6: Fetching B-Roll assets (SKIPPED)...")
|
||||||
broll_assets = self.fetch_broll(script)
|
broll_assets = {} # self.fetch_broll(script, video_data)
|
||||||
|
|
||||||
# Step 5: Build render props
|
# Step 5: Build render props
|
||||||
print("\n📊 Step 5/6: Building render props...")
|
print("\n📊 Step 5/6: Building render props...")
|
||||||
render_props = self.build_render_props(pdf_data, script, broll_assets)
|
render_props = self.build_render_props(pdf_data, ppt_data, script, broll_assets, video_data)
|
||||||
|
|
||||||
# Save render_props.json
|
# Save render_props.json
|
||||||
props_file = self.processed_dir / "render_props.json"
|
props_file = self.processed_dir / "render_props.json"
|
||||||
|
|
@ -88,84 +131,167 @@ class VideoGeneratorPipeline:
|
||||||
|
|
||||||
def extract_pdf(self) -> Dict[str, Any]:
|
def extract_pdf(self) -> Dict[str, Any]:
|
||||||
"""Extract data from PDF using Gemini Pro"""
|
"""Extract data from PDF using Gemini Pro"""
|
||||||
# TODO: Implement backend.extractor.extract_pdf_data
|
pdf_path = self.config.get("pdf_path")
|
||||||
print(" [MOCK] Extracting PDF data...")
|
|
||||||
return {
|
if not pdf_path or not Path(pdf_path).exists():
|
||||||
"client_name": "山田太郎",
|
print(" [SKIP] No PDF file found")
|
||||||
"current_age": 35,
|
return {}
|
||||||
"retirement_age": 65,
|
|
||||||
"life_expectancy": 100,
|
print(f" 📄 Processing PDF: {Path(pdf_path).name}")
|
||||||
"current_assets": 5000,
|
|
||||||
"annual_income": 600,
|
try:
|
||||||
"annual_expenses": 450,
|
from backend.extractor import extract_pdf_data
|
||||||
}
|
pdf_data = extract_pdf_data(pdf_path)
|
||||||
|
print(f" ✓ Successfully extracted data for: {pdf_data.get('client_name', 'Unknown')}")
|
||||||
|
return pdf_data
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ PDF extraction failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def extract_ppt(self) -> Dict[str, Any]:
|
||||||
|
"""Extract slides from PowerPoint presentation"""
|
||||||
|
ppt_path = self.config.get("ppt_path")
|
||||||
|
|
||||||
|
if not ppt_path or not Path(ppt_path).exists():
|
||||||
|
print(" [SKIP] No PPT file found")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
print(f" 📊 Processing PPT: {Path(ppt_path).name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from backend.ppt_extractor import extract_ppt_data
|
||||||
|
ppt_data = extract_ppt_data(ppt_path)
|
||||||
|
print(f" ✓ Successfully extracted {ppt_data.get('total_slides', 0)} slides")
|
||||||
|
return ppt_data
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ PPT extraction failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return {}
|
||||||
|
|
||||||
def extract_video(self) -> Dict[str, Any]:
|
def extract_video(self) -> Dict[str, Any]:
|
||||||
"""Extract data from video using Whisper (if video provided)"""
|
"""Extract data from video using Whisper (if video provided)"""
|
||||||
if not self.config.get("video_path"):
|
video_path = self.config.get("video_path")
|
||||||
print(" [SKIP] No video file provided")
|
|
||||||
|
if not video_path or not Path(video_path).exists():
|
||||||
|
print(" [SKIP] No video file provided or file not found")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# TODO: Implement backend.extractor.extract_video_data
|
try:
|
||||||
print(" [MOCK] Extracting video timestamps and transcription...")
|
from backend.extractor import extract_video_data
|
||||||
return {
|
video_data = extract_video_data(video_path)
|
||||||
"transcription": "こんにちは...",
|
|
||||||
"timestamps": [
|
|
||||||
{"time": 0.5, "text": "Introduction", "keywords": ["保険", "将来"]},
|
|
||||||
{"time": 15.2, "text": "Main topic", "keywords": ["資産", "計画"]},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
def generate_script(self, pdf_data: Dict, video_data: Dict) -> Dict[str, Any]:
|
# Optionally extract video clips
|
||||||
|
if self.config.get("extract_clips", False):
|
||||||
|
print(" ✂️ Extracting key video clips...")
|
||||||
|
from backend.video_slicer import extract_video_clips
|
||||||
|
clips = extract_video_clips(video_path, video_data, max_clips=3)
|
||||||
|
video_data["clips"] = clips
|
||||||
|
|
||||||
|
return video_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Video extraction failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def generate_script(self, pdf_data: Dict, ppt_data: Dict, video_data: Dict) -> Dict[str, Any]:
|
||||||
"""Generate narration script using Gemini Pro"""
|
"""Generate narration script using Gemini Pro"""
|
||||||
print(" Generating script with Gemini Pro...")
|
print(" ✍️ Generating script with Gemini Pro...")
|
||||||
from backend.scripter import generate_script
|
from backend.scripter import generate_script
|
||||||
|
|
||||||
|
# Merge PPT and PDF data
|
||||||
|
combined_data = {**pdf_data}
|
||||||
|
if ppt_data:
|
||||||
|
combined_data["presentation"] = ppt_data
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return generate_script(pdf_data, video_data)
|
script = generate_script(combined_data, video_data)
|
||||||
|
print(f" ✓ Generated {len(script.get('segments', []))} script segments")
|
||||||
|
return script
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ⚠️ Script generation failed: {e}")
|
print(f" ❌ Script generation failed: {e}")
|
||||||
print(" Using fallback script...")
|
import traceback
|
||||||
return {
|
traceback.print_exc()
|
||||||
"segments": [
|
raise
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"text": "これからあなたの将来設計について説明します。",
|
|
||||||
"duration": 3.5,
|
|
||||||
"type": "narration",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"text": "現在の資産状況を見てみましょう。",
|
|
||||||
"duration": 2.8,
|
|
||||||
"type": "chart_intro",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"total_duration": 6.3,
|
|
||||||
}
|
|
||||||
|
|
||||||
def generate_audio(self, script_data: Dict) -> Dict[str, Any]:
|
def generate_audio(self, script_data: Dict) -> Dict[str, Any]:
|
||||||
"""Generate audio narration from script using OpenAI TTS"""
|
"""Generate audio narration from script using OpenAI TTS"""
|
||||||
from backend.audio_generator import generate_audio_for_script
|
from backend.audio_generator import generate_audio_for_script
|
||||||
|
from backend.video_processor.utils import get_video_duration
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return generate_audio_for_script(script_data, voice="alloy")
|
# 1. Generate TTS audio for non-video segments
|
||||||
|
script_data = generate_audio_for_script(script_data, voice="alloy")
|
||||||
|
|
||||||
|
# 2. Sync duration for video segments with actual clip duration
|
||||||
|
print(" 🎬 Syncing durations for interview video segments...")
|
||||||
|
video_data = self.extract_video() # Get cached video data with clips
|
||||||
|
clips = video_data.get("clips", [])
|
||||||
|
|
||||||
|
for segment in script_data.get("segments", []):
|
||||||
|
if segment.get("visualType") == "video":
|
||||||
|
idx = segment.get("visualAssetIndex", -1)
|
||||||
|
if 0 <= idx < len(clips):
|
||||||
|
clip = clips[idx]
|
||||||
|
clip_path = clip.get("outputPath") or clip.get("filepath")
|
||||||
|
if clip_path and Path(clip_path).exists():
|
||||||
|
actual_duration = get_video_duration(clip_path)
|
||||||
|
print(f" ✓ Video Segment {segment['id']}: Updated duration to {actual_duration:.2f}s (Clip {idx})")
|
||||||
|
segment["duration"] = actual_duration
|
||||||
|
|
||||||
|
return script_data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ⚠️ Audio generation failed: {e}")
|
print(f" ⚠️ Audio generation failed: {e}")
|
||||||
print(" Continuing without audio...")
|
print(" Continuing without audio...")
|
||||||
return script_data
|
return script_data
|
||||||
|
|
||||||
def fetch_broll(self, script: Dict) -> Dict[str, Any]:
|
def fetch_broll(self, script: Dict, video_data: Dict = None) -> Dict[str, Any]:
|
||||||
"""Fetch B-Roll footage from Pexels API"""
|
"""Fetch B-Roll footage from Pexels API based on script context"""
|
||||||
# TODO: Implement backend.media_fetcher.fetch_broll
|
try:
|
||||||
print(" [MOCK] Fetching B-Roll from Pexels...")
|
from backend.media_fetcher import MediaFetcher
|
||||||
return {
|
|
||||||
"retirement": "https://example.com/retirement.mp4",
|
# 1. Extract keywords explicitly requested in script segments
|
||||||
"family": "https://example.com/family.mp4",
|
keywords = set()
|
||||||
}
|
for segment in script.get("segments", []):
|
||||||
|
if segment.get("visualType") == "broll" and segment.get("brollKeyword"):
|
||||||
|
keywords.add(segment.get("brollKeyword"))
|
||||||
|
|
||||||
|
# 2. Add keywords from video data as fallback/supplement
|
||||||
|
if video_data and "keywords" in video_data:
|
||||||
|
for kw in video_data["keywords"][:3]:
|
||||||
|
keywords.add(kw)
|
||||||
|
|
||||||
|
# 3. Use default keywords if still empty
|
||||||
|
if not keywords:
|
||||||
|
keywords.update(["家族", "老後", "貯蓄"])
|
||||||
|
|
||||||
|
keyword_list = list(keywords)
|
||||||
|
print(f" 🎬 Fetching B-Roll for context: {', '.join(keyword_list)}")
|
||||||
|
|
||||||
|
fetcher = MediaFetcher()
|
||||||
|
broll_results = fetcher.fetch_broll(keyword_list, max_per_keyword=1)
|
||||||
|
|
||||||
|
# Convert to simple filepath mapping
|
||||||
|
broll_assets = {}
|
||||||
|
for keyword, videos in broll_results.items():
|
||||||
|
if videos:
|
||||||
|
# Store only the filename to be used with staticFile() in Remotion
|
||||||
|
broll_assets[keyword] = Path(videos[0]["filepath"]).name
|
||||||
|
|
||||||
|
return broll_assets
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ B-Roll fetching failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return {}
|
||||||
|
|
||||||
def build_render_props(
|
def build_render_props(
|
||||||
self, pdf_data: Dict, script: Dict, broll: Dict
|
self, pdf_data: Dict, ppt_data: Dict, script: Dict, broll: Dict, video_data: Dict = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Build the complete render_props.json structure"""
|
"""Build the complete render_props.json structure"""
|
||||||
print(" Building chart data and render props...")
|
print(" Building chart data and render props...")
|
||||||
|
|
@ -174,7 +300,24 @@ class VideoGeneratorPipeline:
|
||||||
# TypeScript RenderProps contract (frontend/src/render-props-types.ts)
|
# TypeScript RenderProps contract (frontend/src/render-props-types.ts)
|
||||||
from backend.data_builder import build_chart_data
|
from backend.data_builder import build_chart_data
|
||||||
|
|
||||||
charts = build_chart_data(pdf_data)
|
charts = build_chart_data(pdf_data) if pdf_data else {}
|
||||||
|
|
||||||
|
# Add video clips if available
|
||||||
|
video_clips = []
|
||||||
|
if video_data and "clips" in video_data:
|
||||||
|
video_clips = video_data["clips"]
|
||||||
|
|
||||||
|
# Add PPT slides if available
|
||||||
|
ppt_slides = []
|
||||||
|
if ppt_data and "slides" in ppt_data:
|
||||||
|
ppt_slides = [
|
||||||
|
{
|
||||||
|
"index": slide["index"],
|
||||||
|
"title": slide["title"],
|
||||||
|
"image_path": slide["image_path"].replace("\\", "/"),
|
||||||
|
}
|
||||||
|
for slide in ppt_data["slides"]
|
||||||
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"meta": {
|
"meta": {
|
||||||
|
|
@ -187,7 +330,8 @@ class VideoGeneratorPipeline:
|
||||||
"script": script["segments"],
|
"script": script["segments"],
|
||||||
"assets": {
|
"assets": {
|
||||||
"broll": broll,
|
"broll": broll,
|
||||||
"videoClips": [],
|
"videoClips": video_clips,
|
||||||
|
"slides": ppt_slides,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,6 +355,7 @@ class VideoGeneratorPipeline:
|
||||||
"MainVideo",
|
"MainVideo",
|
||||||
str(output_file),
|
str(output_file),
|
||||||
f"--props={props_file}",
|
f"--props={props_file}",
|
||||||
|
"--concurrency=1",
|
||||||
]
|
]
|
||||||
|
|
||||||
print(f" Running: {' '.join(cmd)}")
|
print(f" Running: {' '.join(cmd)}")
|
||||||
|
|
@ -240,7 +385,13 @@ Examples:
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--pdf",
|
"--pdf",
|
||||||
type=str,
|
type=str,
|
||||||
help="Path to the Life Plan PDF report",
|
help="Path to the Life Plan PDF report (optional if files exist in assets/0_input/)",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--ppt",
|
||||||
|
type=str,
|
||||||
|
help="Path to the PowerPoint presentation (optional if files exist in assets/0_input/)",
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|
@ -267,6 +418,12 @@ Examples:
|
||||||
help="Generate render_props.json without rendering",
|
help="Generate render_props.json without rendering",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--extract-clips",
|
||||||
|
action="store_true",
|
||||||
|
help="Extract video clips from source video (optional)",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Build configuration
|
# Build configuration
|
||||||
|
|
@ -276,15 +433,16 @@ Examples:
|
||||||
with open(args.config, "r", encoding="utf-8") as f:
|
with open(args.config, "r", encoding="utf-8") as f:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
else:
|
else:
|
||||||
if not args.pdf:
|
# Only require --pdf if no files found in input directory
|
||||||
parser.error("--pdf is required when --config is not provided")
|
|
||||||
config["pdf_path"] = args.pdf
|
config["pdf_path"] = args.pdf
|
||||||
|
config["ppt_path"] = args.ppt
|
||||||
config["video_path"] = args.video
|
config["video_path"] = args.video
|
||||||
|
|
||||||
if args.output:
|
if args.output:
|
||||||
config["output_filename"] = args.output
|
config["output_filename"] = args.output
|
||||||
|
|
||||||
config["dry_run"] = args.dry_run
|
config["dry_run"] = args.dry_run
|
||||||
|
config["extract_clips"] = args.extract_clips
|
||||||
|
|
||||||
# Run pipeline
|
# Run pipeline
|
||||||
pipeline = VideoGeneratorPipeline(config)
|
pipeline = VideoGeneratorPipeline(config)
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 624 KiB |
|
After Width: | Height: | Size: 542 KiB |