Compare commits
6 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
1c702e01db | |
|
|
af4dfd3bb3 | |
|
|
3c7e51eb7b | |
|
|
9e5f11472a | |
|
|
672912f2be | |
|
|
423743082c |
23
.env.example
|
|
@ -1,22 +1,13 @@
|
||||||
# =============================================================================
|
# API Keys for AI Video LifePlan Generator
|
||||||
# Auto Life Plan Video Generator - Environment Variables
|
|
||||||
# =============================================================================
|
|
||||||
# Copy this file to .env and fill in your actual API keys
|
|
||||||
# DO NOT commit .env to version control!
|
|
||||||
|
|
||||||
# Google Gemini API (for PDF parsing and script generation)
|
# Google Gemini API Key
|
||||||
|
# Get yours at: https://aistudio.google.com/apikey
|
||||||
GEMINI_API_KEY=your_gemini_api_key_here
|
GEMINI_API_KEY=your_gemini_api_key_here
|
||||||
|
|
||||||
# OpenAI API (for Whisper transcription)
|
# OpenAI API Key (for Whisper and TTS)
|
||||||
|
# Get yours at: https://platform.openai.com/api-keys
|
||||||
OPENAI_API_KEY=your_openai_api_key_here
|
OPENAI_API_KEY=your_openai_api_key_here
|
||||||
|
|
||||||
# Pexels API (for B-Roll footage)
|
# Pexels API Key (for B-Roll videos)
|
||||||
|
# Get yours at: https://www.pexels.com/api/
|
||||||
PEXELS_API_KEY=your_pexels_api_key_here
|
PEXELS_API_KEY=your_pexels_api_key_here
|
||||||
|
|
||||||
# Google Cloud TTS (optional, if using TTS)
|
|
||||||
# GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
DEFAULT_INFLATION_RATE=0.02
|
|
||||||
DEFAULT_DEFICIT_INTEREST_RATE=0.15
|
|
||||||
DEFAULT_INVESTMENT_RETURN=0.03
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,433 @@
|
||||||
|
# 前端组件体系
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件列表**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx)
|
||||||
|
- [Composition.tsx](file://src/Composition.tsx)
|
||||||
|
- [Root.tsx](file://src/Root.tsx)
|
||||||
|
- [index.ts](file://src/index.ts)
|
||||||
|
- [types.ts](file://src/types.ts)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [mock-data.json](file://src/mock-data.json)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json)
|
||||||
|
- [package.json](file://package.json)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [组件详解](#组件详解)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能与优化建议](#性能与优化建议)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录:使用示例与定制指南](#附录使用示例与定制指南)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本文件系统性梳理了基于 Remotion 的视频画面构建前端组件体系,重点围绕以下核心组件展开:
|
||||||
|
- LifePlanChart.tsx:资产消耗趋势面积图,展示“资产余额”随时间变化的动态演进
|
||||||
|
- CashFlowChart.tsx:现金流组合图,展示“收入/支出明细”的堆叠柱状与线条
|
||||||
|
- UnifiedHUD.tsx:统一信息显示层,作为可复用的头部仪表盘
|
||||||
|
- DualChartView.tsx:双图表合成视图,同步播放资产与现金流两条时间线
|
||||||
|
- Composition.tsx:占位的视频合成结构(当前为空)
|
||||||
|
- Root.tsx 与 index.ts:注册根入口与 Remotion 合成配置
|
||||||
|
- 类型定义:types.ts 与 frontend/src/render-props-types.ts,确保数据契约一致
|
||||||
|
|
||||||
|
目标是帮助初学者快速理解组件职责与使用方式,同时为 UI 开发者提供样式覆盖、动画定制与扩展指导。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
该仓库采用“按功能模块组织”的前端结构,核心组件位于 src 目录;类型定义在 src 与 frontend/src 下分别维护,以满足前后端数据契约一致性。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "前端源码(src)"
|
||||||
|
A["index.ts<br/>注册根入口"]
|
||||||
|
B["Root.tsx<br/>声明多个 Composition"]
|
||||||
|
C["LifePlanChart.tsx<br/>资产趋势图"]
|
||||||
|
D["CashFlowChart.tsx<br/>现金流图"]
|
||||||
|
E["UnifiedHUD.tsx<br/>统一信息显示层"]
|
||||||
|
F["DualChartView.tsx<br/>双图表合成视图"]
|
||||||
|
G["Composition.tsx<br/>占位合成结构"]
|
||||||
|
H["types.ts<br/>YearlySnapshot/LifePlanChartProps"]
|
||||||
|
I["mock-data.json<br/>资产模拟数据"]
|
||||||
|
J["mock-cashflow.json<br/>现金流模拟数据"]
|
||||||
|
end
|
||||||
|
subgraph "前端类型(frontend/src)"
|
||||||
|
K["render-props-types.ts<br/>render_props.json 类型定义"]
|
||||||
|
end
|
||||||
|
subgraph "配置"
|
||||||
|
L["package.json<br/>依赖与脚本"]
|
||||||
|
M["remotion.config.ts<br/>Remotion 配置"]
|
||||||
|
end
|
||||||
|
A --> B
|
||||||
|
B --> C
|
||||||
|
B --> D
|
||||||
|
B --> F
|
||||||
|
F --> C
|
||||||
|
F --> D
|
||||||
|
F --> E
|
||||||
|
C --> H
|
||||||
|
D --> H
|
||||||
|
F --> H
|
||||||
|
B --> I
|
||||||
|
B --> J
|
||||||
|
L --> A
|
||||||
|
M --> A
|
||||||
|
K --> B
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [index.ts](file://src/index.ts#L1-L6)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [Composition.tsx](file://src/Composition.tsx#L1-L4)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json#L1-L200)
|
||||||
|
- [package.json](file://package.json#L1-L43)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts#L1-L12)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [index.ts](file://src/index.ts#L1-L6)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [package.json](file://package.json#L1-L43)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts#L1-L12)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- LifePlanChart:基于 Recharts 的面积图,支持渐变填充、自定义 X 轴刻度(年份+年龄)、HUD 信息展示、帧级插值动画
|
||||||
|
- CashFlowChart:基于 Recharts 的组合图,支持堆叠柱状(生活/住房/教育/事件/税费)、线条(收入)、HUD 信息展示、年度内顺序增长动画
|
||||||
|
- UnifiedHUD:通用头部仪表盘,展示年份、年龄、收入、支出、净现金流、资产余额
|
||||||
|
- DualChartView:双图表合成容器,统一流程控制与数据转换,保证两条时间线同步
|
||||||
|
- Root/Composition:Remotion 合成注册与默认参数注入,连接 mock 数据与组件
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [Composition.tsx](file://src/Composition.tsx#L1-L4)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
Remotion 通过 Root.tsx 中的 Composition 声明多个视频片段,每个片段对应一个组件。组件内部使用 useCurrentFrame/useVideoConfig 进行动画插值,结合 Recharts 实现可视化。数据由 mock 文件或后端生成的 render_props.json 提供,类型定义在 frontend/src/render-props-types.ts 与 src/types.ts 中保持一致。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Studio as "Remotion Studio"
|
||||||
|
participant Root as "Root.tsx"
|
||||||
|
participant Comp as "Composition"
|
||||||
|
participant CompA as "LifePlanChart"
|
||||||
|
participant CompB as "CashFlowChart"
|
||||||
|
participant CompC as "DualChartView"
|
||||||
|
participant HUD as "UnifiedHUD"
|
||||||
|
Studio->>Root : 加载入口
|
||||||
|
Root->>Comp : 注册多个 Composition
|
||||||
|
Comp->>CompA : 渲染资产趋势图
|
||||||
|
Comp->>CompB : 渲染现金流图
|
||||||
|
Comp->>CompC : 渲染双图表合成
|
||||||
|
CompA->>CompA : useCurrentFrame/useVideoConfig 插值
|
||||||
|
CompB->>CompB : useCurrentFrame/useVideoConfig 插值
|
||||||
|
CompC->>CompC : 统一进度计算
|
||||||
|
CompC->>HUD : 传递当前年/年龄/收支/资产
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
|
||||||
|
## 组件详解
|
||||||
|
|
||||||
|
### LifePlanChart.tsx(资产消耗图表)
|
||||||
|
- 视觉设计
|
||||||
|
- 面积图填充:根据当前可见数据的资产余额正负区间计算渐变分界,形成蓝(正)红(负)过渡
|
||||||
|
- X 轴刻度:每 5 年显示一次,包含“年份/本人年龄/配偶年龄(若存在)”
|
||||||
|
- Y 轴单位:货币单位,自动千位分隔与亿单位格式化
|
||||||
|
- HUD:展示当前年份、资产余额(正负颜色区分)、收入/支出数值
|
||||||
|
- 动画逻辑
|
||||||
|
- 使用插值将帧数映射到完整时间线索引,支持外部传入 dataIndex/fraction 以实现跨组件同步
|
||||||
|
- 对当前年与下一年进行线性插值,生成“当前年进度点”,使面积边界平滑过渡
|
||||||
|
- Y 轴域基于全量数据计算,避免资产余额剧烈波动导致的轴跳动
|
||||||
|
- 响应式行为
|
||||||
|
- 小于 10 年时固定 X 轴范围,避免标签拥挤
|
||||||
|
- 使用 ResponsiveContainer 自适应容器尺寸
|
||||||
|
- 关键 props
|
||||||
|
- timeline: YearlySnapshot[]
|
||||||
|
- meta: { currencyUnit, startYear }
|
||||||
|
- hideHeader: 是否隐藏 HUD
|
||||||
|
- externalDataIndex/externalFraction: 外部同步索引与小数进度
|
||||||
|
- 与 HUD 的关系
|
||||||
|
- 通过外部 props 或内部计算提供 HUD 所需的最新数据
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["进入组件"]) --> GetFrame["useCurrentFrame/useVideoConfig 获取帧/时长"]
|
||||||
|
GetFrame --> Interp["插值计算 exactIndex"]
|
||||||
|
Interp --> Slice["确定 dataEndIndex 与 fraction"]
|
||||||
|
Slice --> BuildVisible["构建 visibleData含插值点"]
|
||||||
|
BuildVisible --> Grad["计算渐变分界 gradientOffset"]
|
||||||
|
Grad --> Domain["计算 Y 轴域全量数据"]
|
||||||
|
Domain --> Render["渲染图表与 HUD"]
|
||||||
|
Render --> End(["完成"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
|
||||||
|
### CashFlowChart.tsx(现金流图表)
|
||||||
|
- 视觉设计
|
||||||
|
- 组合图:堆叠柱状表示各类支出(生活/住房/教育/事件/税费),线条表示收入
|
||||||
|
- 图例与工具提示启用,X 轴刻度包含“年份/年龄”
|
||||||
|
- HUD:展示当前年份、年龄、总收入、总支出、净现金流、资产余额
|
||||||
|
- 动画逻辑
|
||||||
|
- 统一流程:插值映射到完整时间线,支持外部传入 dataIndex/yearProgress
|
||||||
|
- 年度内顺序增长:将一年拆分为 6 段(5 个支出类别 + 收入线),按段落顺序逐步显示
|
||||||
|
- 当前年份插值:在下一年方向延伸一个“进度点”,使线条平滑过渡
|
||||||
|
- 固定域模式:小于 10 年时补齐空年份,防止坐标偏移
|
||||||
|
- 响应式行为
|
||||||
|
- 小于 10 年时固定 X 轴范围
|
||||||
|
- Y 轴域基于全量数据计算,避免退休等极端波动导致的轴跳动
|
||||||
|
- 关键 props
|
||||||
|
- timeline: CashFlowData[]
|
||||||
|
- meta: { currencyUnit }
|
||||||
|
- hideHeader: 是否隐藏 HUD
|
||||||
|
- externalDataIndex/externalYearProgress: 外部同步索引与年内进度
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
StartCF(["进入组件"]) --> CF_GetFrame["useCurrentFrame/useVideoConfig 获取帧/时长"]
|
||||||
|
CF_GetFrame --> CF_Interp["插值计算 exactProgress"]
|
||||||
|
CF_Interp --> CF_Slice["确定 dataEndIndex 与 yearProgress"]
|
||||||
|
CF_Slice --> CF_Flatten["扁平化数据并计算 totalExpense"]
|
||||||
|
CF_Flatten --> CF_Grow["按段落计算增长倍率"]
|
||||||
|
CF_Grow --> CF_BuildVisible["构建 visibleData含插值点"]
|
||||||
|
CF_BuildVisible --> CF_Domain["计算 Y 轴域全量数据"]
|
||||||
|
CF_Domain --> CF_Render["渲染组合图与 HUD"]
|
||||||
|
CF_Render --> EndCF(["完成"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
|
||||||
|
### UnifiedHUD.tsx(统一信息显示层)
|
||||||
|
- 设计定位:作为独立的 HUD 组件,可被任意图表或合成视图复用
|
||||||
|
- 内容构成:当前年份与年龄、收入、支出、净现金流、资产余额,带单位与图标
|
||||||
|
- 样式要点:渐变背景、卡片式布局、对齐与间距统一、大号资产余额突出显示
|
||||||
|
- 使用建议:在外部组件中直接传入最新年份数据即可复用
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class UnifiedHUD {
|
||||||
|
+year : number
|
||||||
|
+age : number
|
||||||
|
+income : number
|
||||||
|
+expense : number
|
||||||
|
+netCashFlow : number
|
||||||
|
+assetBalance : number
|
||||||
|
+currencyUnit : string
|
||||||
|
}
|
||||||
|
class MetricCard {
|
||||||
|
+label : string
|
||||||
|
+value : number
|
||||||
|
+unit : string
|
||||||
|
+color : string
|
||||||
|
+icon : string
|
||||||
|
+isLarge : boolean
|
||||||
|
}
|
||||||
|
UnifiedHUD --> MetricCard : "组合多个指标卡片"
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
|
||||||
|
### DualChartView.tsx(双图表合成结构)
|
||||||
|
- 组合模式:上层资产趋势图(LifePlanChart),下层现金流图(CashFlowChart)
|
||||||
|
- 同步策略:统一插值进度,将 CashFlowData 转换为 LifePlanChart 所需的 YearlySnapshot 结构
|
||||||
|
- 元数据传递:将货币单位与起始年份透传给资产图
|
||||||
|
- 外部同步:向两个子组件传递 dataEndIndex 与 yearProgress,确保动画完全一致
|
||||||
|
- 边框与标题:为两个图表容器添加边框、圆角与标题栏,提升可读性
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant DCV as "DualChartView"
|
||||||
|
participant LP as "LifePlanChart"
|
||||||
|
participant CF as "CashFlowChart"
|
||||||
|
participant HUD as "UnifiedHUD"
|
||||||
|
DCV->>DCV : 计算统一进度 exactProgress
|
||||||
|
DCV->>DCV : 转换 CashFlowData -> YearlySnapshot
|
||||||
|
DCV->>LP : 传入 timeline/meta/hideHeader/同步索引
|
||||||
|
DCV->>CF : 传入 timeline/meta/hideHeader/同步索引
|
||||||
|
DCV->>HUD : 传入当前年/年龄/收支/资产
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
### Composition.tsx(视频合成结构)
|
||||||
|
- 当前实现:占位组件,返回空内容
|
||||||
|
- 建议用途:可作为顶层合成容器,承载全局背景、遮罩或全局 HUD;也可保留为空以便后续扩展
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [Composition.tsx](file://src/Composition.tsx#L1-L4)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- 组件间依赖
|
||||||
|
- DualChartView 依赖 LifePlanChart、CashFlowChart、UnifiedHUD
|
||||||
|
- LifePlanChart 与 CashFlowChart 依赖 Recharts 与 Remotion hooks
|
||||||
|
- Root.tsx 通过 Composition 注册多个合成片段,并注入默认数据
|
||||||
|
- 类型契约
|
||||||
|
- YearlySnapshot 与 CashFlowData 在 src/types.ts 与 frontend/src/render-props-types.ts 中保持一致,确保前后端数据结构匹配
|
||||||
|
- 外部依赖
|
||||||
|
- react、remotion、recharts、@types/react 等
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
Root["Root.tsx"] --> LP["LifePlanChart.tsx"]
|
||||||
|
Root --> CF["CashFlowChart.tsx"]
|
||||||
|
Root --> DCV["DualChartView.tsx"]
|
||||||
|
DCV --> LP
|
||||||
|
DCV --> CF
|
||||||
|
DCV --> HUD["UnifiedHUD.tsx"]
|
||||||
|
LP --> Types["types.ts"]
|
||||||
|
CF --> Types
|
||||||
|
DCV --> Types
|
||||||
|
Root --> MockA["mock-data.json"]
|
||||||
|
Root --> MockB["mock-cashflow.json"]
|
||||||
|
Root --> Pkg["package.json"]
|
||||||
|
Root --> RConf["remotion.config.ts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json#L1-L200)
|
||||||
|
- [package.json](file://package.json#L1-L43)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts#L1-L12)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [package.json](file://package.json#L1-L43)
|
||||||
|
|
||||||
|
## 性能与优化建议
|
||||||
|
- 避免不必要的重渲染
|
||||||
|
- 使用 React.memo 包裹图表组件
|
||||||
|
- 使用 React.useMemo 缓存 xAxisDomain、yAxisDomain、转换后的 timeline 等昂贵计算结果
|
||||||
|
- 控制动画复杂度
|
||||||
|
- Recharts 默认动画关闭,避免额外开销;如需自定义动画,建议仅在必要元素上启用
|
||||||
|
- 数据切片与插值
|
||||||
|
- 仅渲染可见数据片段,减少 DOM 数量
|
||||||
|
- 插值计算尽量在组件外完成或缓存
|
||||||
|
- 响应式容器
|
||||||
|
- 使用 ResponsiveContainer 时,尽量固定宽高比,减少频繁测量
|
||||||
|
- Y 轴域稳定性
|
||||||
|
- 基于全量数据计算 Y 轴域,避免动画过程中的轴跳动
|
||||||
|
|
||||||
|
[本节为通用性能建议,不直接分析具体文件]
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- 数据类型不匹配
|
||||||
|
- 若出现运行时报错,检查 frontend/src/render-props-types.ts 与 src/types.ts 的接口是否与实际 JSON 数据一致
|
||||||
|
- 动画不同步
|
||||||
|
- 确认 DualChartView 是否向子组件传递了相同的 externalDataIndex 与 externalYearProgress
|
||||||
|
- Y 轴抖动
|
||||||
|
- 确保 LifePlanChart 与 CashFlowChart 的 yAxisDomain 基于全量 timeline 计算
|
||||||
|
- HUD 显示异常
|
||||||
|
- 检查 UnifiedHUD 的输入字段是否正确传入(year/age/income/expense/netCashFlow/assetBalance/currencyUnit)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
该组件体系以 Remotion 为核心,结合 Recharts 实现了高质量的财务可视化动画。LifePlanChart 与 CashFlowChart 分别聚焦“资产趋势”与“收支明细”,DualChartView 则提供了统一的双轨同步体验。通过明确的类型契约与稳定的动画插值策略,组件具备良好的可维护性与扩展性。建议在生产环境中进一步完善错误处理、日志记录与性能监控。
|
||||||
|
|
||||||
|
[本节为总结性内容,不直接分析具体文件]
|
||||||
|
|
||||||
|
## 附录:使用示例与定制指南
|
||||||
|
|
||||||
|
### 如何使用组件(初学者预览)
|
||||||
|
- 直接渲染单个图表
|
||||||
|
- 在 Root.tsx 中注册对应的 Composition,并通过 defaultProps 传入 mock 数据
|
||||||
|
- 示例路径参考:
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json#L1-L200)
|
||||||
|
- 在合成视图中组合
|
||||||
|
- 使用 DualChartView 将资产与现金流同步展示
|
||||||
|
- 示例路径参考:
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
### 组件组合模式
|
||||||
|
- 双图表合成
|
||||||
|
- 上层:LifePlanChart(资产余额趋势)
|
||||||
|
- 下层:CashFlowChart(收入/支出明细)
|
||||||
|
- 共享:统一进度、货币单位、起始年份
|
||||||
|
- HUD 复用
|
||||||
|
- 可在任意场景使用 UnifiedHUD 展示关键指标
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
|
||||||
|
### 定制化与扩展指导(UI 开发者)
|
||||||
|
- 样式覆盖
|
||||||
|
- 通过 props 传入 meta(currencyUnit/startYear)与 hideHeader 控制 HUD 显示
|
||||||
|
- 在外部容器中设置统一的字体、间距与背景色
|
||||||
|
- 动画修改
|
||||||
|
- 修改插值区间与插值函数,调整动画节奏
|
||||||
|
- 在 LifePlanChart 中调整 gradientOffset 的计算逻辑,改变渐变分界
|
||||||
|
- 在 CashFlowChart 中调整 yearly growth 的段落数与倍率曲线
|
||||||
|
- 数据适配
|
||||||
|
- 若后端数据结构变化,优先更新 frontend/src/render-props-types.ts 与 src/types.ts
|
||||||
|
- 在 DualChartView 中补充必要的字段映射(如 totalExpense/netCashFlow/assetBalance)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
# 现金流图表 (CashFlowChart)
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文档中引用的文件**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx)
|
||||||
|
- [types.ts](file://src/types.ts)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json)
|
||||||
|
- [mock-cashflow-original.json](file://src/mock-cashflow-original.json)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx)
|
||||||
|
- [Root.tsx](file://src/Root.tsx)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构概述](#架构概述)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖分析](#依赖分析)
|
||||||
|
7. [性能考虑](#性能考虑)
|
||||||
|
8. [故障排除指南](#故障排除指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
现金流图表 (CashFlowChart) 组件是一个复合图表,用于可视化年度收入与支出构成。该组件结合了堆叠柱状图和折线图,分别展示各项支出(生活、住房、教育、事件、税费)和总收入。通过顺序增长动画和插值点技术,实现了动态的视觉效果。HUD区域展示了净现金流和总支出等关键指标。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
项目结构清晰,主要包含前端和后端两个部分。前端部分位于`frontend/src`目录下,包含了渲染属性类型定义。后端部分位于`backend`目录下,包含了视频处理和数据提取的逻辑。核心的图表组件位于`src`目录下,包括`CashFlowChart.tsx`、`DualChartView.tsx`等文件。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "前端"
|
||||||
|
Frontend[frontend/src]
|
||||||
|
end
|
||||||
|
subgraph "后端"
|
||||||
|
Backend[backend]
|
||||||
|
end
|
||||||
|
subgraph "核心组件"
|
||||||
|
Src[src]
|
||||||
|
end
|
||||||
|
Frontend --> Src
|
||||||
|
Backend --> Src
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
`CashFlowChart` 组件是本项目的核心,负责展示年度收支构成。它使用 Recharts 库中的 `ComposedChart` 组件,结合 `Bar` 和 `Line` 组件,实现了堆叠柱状图和折线图的组合。通过 `getMultiplier` 函数控制每个支出类别依次增长的视觉效果,实现了顺序增长动画。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L75-L441)
|
||||||
|
|
||||||
|
## 架构概述
|
||||||
|
`CashFlowChart` 组件的架构设计合理,通过 `flattenedData` 结构将嵌套的 `expenses` 对象转换为 Recharts 可读的扁平字段。Y轴域(yAxisDomain)基于完整时间线数据保持稳定,防止图表跳动。HUD区域通过计算并展示净现金流和总支出,提供了直观的财务概览。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A[CashFlowData] --> B[flattenedData]
|
||||||
|
B --> C[ComposedChart]
|
||||||
|
C --> D[Bar]
|
||||||
|
C --> E[Line]
|
||||||
|
C --> F[HUD]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L116-L131)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L116-L131)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
### CashFlowChart 分析
|
||||||
|
`CashFlowChart` 组件通过 `flattenedData` 结构将嵌套的 `expenses` 对象转换为 Recharts 可读的扁平字段。`getMultiplier` 函数控制每个支出类别依次增长的视觉效果,实现了顺序增长动画。在年内进度(yearProgress)期间,通过插值点(interpolatedPoint)实现收入红线的水平延伸动画。
|
||||||
|
|
||||||
|
#### 对象导向组件
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class CashFlowData {
|
||||||
|
+year : number
|
||||||
|
+age : number
|
||||||
|
+income : Income
|
||||||
|
+expenses : Expenses
|
||||||
|
}
|
||||||
|
class Income {
|
||||||
|
+total : number
|
||||||
|
}
|
||||||
|
class Expenses {
|
||||||
|
+living : number
|
||||||
|
+housing : number
|
||||||
|
+education : number
|
||||||
|
+tax : number
|
||||||
|
+event : number
|
||||||
|
}
|
||||||
|
class CashFlowChart {
|
||||||
|
+timeline : CashFlowData[]
|
||||||
|
+meta : Meta
|
||||||
|
+hideHeader : boolean
|
||||||
|
+externalDataIndex : number
|
||||||
|
+externalYearProgress : number
|
||||||
|
}
|
||||||
|
CashFlowData --> Income
|
||||||
|
CashFlowData --> Expenses
|
||||||
|
CashFlowChart --> CashFlowData
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L16-L32)
|
||||||
|
- [types.ts](file://src/types.ts#L4-L32)
|
||||||
|
|
||||||
|
#### API/服务组件
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Frame as "当前帧"
|
||||||
|
participant Config as "视频配置"
|
||||||
|
participant Progress as "进度计算"
|
||||||
|
participant Data as "数据处理"
|
||||||
|
participant Chart as "图表渲染"
|
||||||
|
Frame->>Config : 获取帧数
|
||||||
|
Config->>Progress : 计算精确进度
|
||||||
|
Progress->>Data : 计算数据结束索引
|
||||||
|
Data->>Data : 计算年内进度
|
||||||
|
Data->>Chart : 生成扁平化数据
|
||||||
|
Chart->>Chart : 应用顺序增长动画
|
||||||
|
Chart->>Chart : 添加插值点
|
||||||
|
Chart->>Chart : 渲染图表
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L83-L179)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L83-L179)
|
||||||
|
|
||||||
|
### 概念概述
|
||||||
|
`CashFlowChart` 组件的设计理念是通过动态的视觉效果,直观地展示年度收支构成。通过顺序增长动画和插值点技术,增强了用户的交互体验。HUD区域提供了关键的财务指标,帮助用户快速了解财务状况。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start([开始]) --> LoadData["加载数据"]
|
||||||
|
LoadData --> ProcessData["处理数据"]
|
||||||
|
ProcessData --> AnimateBars["动画柱状图"]
|
||||||
|
AnimateBars --> ExtendLine["延伸折线"]
|
||||||
|
ExtendLine --> RenderChart["渲染图表"]
|
||||||
|
RenderChart --> ShowHUD["显示HUD"]
|
||||||
|
ShowHUD --> End([结束])
|
||||||
|
```
|
||||||
|
|
||||||
|
[无来源,因为此图表显示的是概念性工作流,而非实际代码结构]
|
||||||
|
|
||||||
|
[无来源,因为此部分不分析特定文件]
|
||||||
|
|
||||||
|
## 依赖分析
|
||||||
|
`CashFlowChart` 组件依赖于 Recharts 库和 Remotion 库。Recharts 提供了图表渲染功能,Remotion 提供了动画和视频渲染功能。通过 `useCurrentFrame` 和 `useVideoConfig` 钩子,实现了与视频帧的同步。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[CashFlowChart] --> B[Recharts]
|
||||||
|
A --> C[Remotion]
|
||||||
|
B --> D[ComposedChart]
|
||||||
|
B --> E[Bar]
|
||||||
|
B --> F[Line]
|
||||||
|
C --> G[useCurrentFrame]
|
||||||
|
C --> H[useVideoConfig]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L2-L13)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L2)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L2-L13)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L2)
|
||||||
|
|
||||||
|
## 性能考虑
|
||||||
|
`CashFlowChart` 组件通过 `React.memo` 和 `React.useMemo` 优化了性能,避免了不必要的重新渲染。Y轴域(yAxisDomain)基于完整时间线数据保持稳定,防止图表跳动。通过 `interpolate` 函数实现了平滑的动画效果。
|
||||||
|
|
||||||
|
[无来源,因为此部分提供一般性指导]
|
||||||
|
|
||||||
|
## 故障排除指南
|
||||||
|
在使用 `CashFlowChart` 组件时,可能会遇到以下问题:
|
||||||
|
- 数据未正确加载:检查 `timeline` 和 `meta` 属性是否正确传递。
|
||||||
|
- 动画效果不正常:检查 `externalDataIndex` 和 `externalYearProgress` 属性是否正确设置。
|
||||||
|
- 图表跳动:检查 `yAxisDomain` 是否基于完整时间线数据。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L228-L236)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L34-L39)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
`CashFlowChart` 组件通过结合堆叠柱状图和折线图,实现了年度收支构成的动态可视化。通过顺序增长动画和插值点技术,增强了用户的交互体验。HUD区域提供了关键的财务指标,帮助用户快速了解财务状况。通过合理的架构设计和性能优化,确保了组件的稳定性和高效性。
|
||||||
|
|
||||||
|
[无来源,因为此部分总结而不分析特定文件]
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
# 统一信息显示层(UnifiedHUD)
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件**
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx)
|
||||||
|
- [types.ts](file://src/types.ts)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx)
|
||||||
|
- [Root.tsx](file://src/Root.tsx)
|
||||||
|
- [mock-data.json](file://src/mock-data.json)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能考量](#性能考量)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录:扩展与定制指南](#附录扩展与定制指南)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
UnifiedHUD 是一个位于视频画面顶部的状态仪表盘,用于集中展示关键财务指标,帮助观众在观看生命周期模拟视频时快速把握当前年度与主要财务状态。它采用线性渐变背景、分组布局与响应式间距,通过 MetricCard 子组件动态呈现“收入”“支出”“净现金流”“资产余额”等指标,并对正负值进行颜色与图标的差异化处理,确保信息传达直观清晰。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
本仓库包含多个图表组件与统一入口,UnifiedHUD 作为可复用的头部仪表盘,既可独立使用,也可嵌入到其他图表组件中,例如 LifePlanChart 与 CashFlowChart 的 HUD 区域,以及在 DualChartView 中与图表主体同步展示。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "视频渲染入口"
|
||||||
|
Root["Root.tsx<br/>注册 Composition"]
|
||||||
|
end
|
||||||
|
subgraph "图表组件"
|
||||||
|
LifePlan["LifePlanChart.tsx"]
|
||||||
|
CashFlow["CashFlowChart.tsx"]
|
||||||
|
Dual["DualChartView.tsx"]
|
||||||
|
Unified["UnifiedHUD.tsx"]
|
||||||
|
end
|
||||||
|
subgraph "数据"
|
||||||
|
MockData["mock-data.json"]
|
||||||
|
MockCash["mock-cashflow.json"]
|
||||||
|
end
|
||||||
|
Root --> LifePlan
|
||||||
|
Root --> CashFlow
|
||||||
|
Root --> Dual
|
||||||
|
Dual --> LifePlan
|
||||||
|
Dual --> CashFlow
|
||||||
|
LifePlan --> Unified
|
||||||
|
CashFlow --> Unified
|
||||||
|
LifePlan -. 使用 .-> MockData
|
||||||
|
CashFlow -. 使用 .-> MockCash
|
||||||
|
Dual -. 使用 .-> MockCash
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
- [mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json#L1-L200)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
- [mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json#L1-L200)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
UnifiedHUD 提供统一的头部仪表盘,包含:
|
||||||
|
- 年度与年龄信息区
|
||||||
|
- 指标卡片区(收入、支出、净现金流、资产余额)
|
||||||
|
- MetricCard 子组件,支持标签、数值、单位、颜色、图标与“大号”模式
|
||||||
|
|
||||||
|
其 props 接口 UnifiedHUDProps 定义如下:
|
||||||
|
- year:当前年份
|
||||||
|
- age:当前年龄
|
||||||
|
- income:年度收入
|
||||||
|
- expense:年度支出
|
||||||
|
- netCashFlow:年度净现金流
|
||||||
|
- assetBalance:年末资产余额
|
||||||
|
- currencyUnit:货币单位字符串
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L3-L11)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
UnifiedHUD 与图表组件的关系如下:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Root as "Root.tsx"
|
||||||
|
participant LifePlan as "LifePlanChart.tsx"
|
||||||
|
participant CashFlow as "CashFlowChart.tsx"
|
||||||
|
participant Dual as "DualChartView.tsx"
|
||||||
|
participant Unified as "UnifiedHUD.tsx"
|
||||||
|
Root->>LifePlan : 注册并传入 timeline/meta
|
||||||
|
Root->>CashFlow : 注册并传入 timeline/meta
|
||||||
|
Root->>Dual : 注册并传入 cashflowData
|
||||||
|
Dual->>LifePlan : 传入转换后的 timeline/metahideHeader=true
|
||||||
|
Dual->>CashFlow : 传入原始 timeline/metahideHeader=true
|
||||||
|
LifePlan->>Unified : 渲染 HUD独立或嵌入
|
||||||
|
CashFlow->>Unified : 渲染 HUD独立或嵌入
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### UnifiedHUD 组件
|
||||||
|
- 结构与布局
|
||||||
|
- 外层容器采用水平分栏布局,左右两侧分别展示“年份/年龄”与“指标卡片组”,中间以等间距分隔,整体具有圆角、阴影与线性渐变背景,营造现代感与层次感。
|
||||||
|
- 指标卡片组内部使用等间距布局,卡片按顺序排列,便于用户快速扫描关键指标。
|
||||||
|
- 数据来源与映射
|
||||||
|
- 年份与年龄来自 props;其余指标(收入、支出、净现金流、资产余额)来自 props,并通过 MetricCard 呈现。
|
||||||
|
- 货币单位由 props 传入,用于统一显示格式。
|
||||||
|
- 颜色与图标策略
|
||||||
|
- 收入卡片:使用暖色调图标与颜色,强调增长与收益。
|
||||||
|
- 支出卡片:使用中性偏警示色,突出成本与消耗。
|
||||||
|
- 净现金流卡片:正数使用绿色系,负数使用红色系,配合“+/-”图标直观表达盈亏状况。
|
||||||
|
- 资产余额卡片:正数使用浅绿系,负数使用浅红系,采用“💰”图标强调资产属性。
|
||||||
|
- 内联样式组织
|
||||||
|
- 外层容器样式集中定义,包含布局、背景、圆角、阴影与间距。
|
||||||
|
- 指标卡片内部样式按层级拆分:标签区(小字号、高对比度)、数值区(大字号、居右对齐、图标与单位组合),并支持“大号”模式以提升视觉权重。
|
||||||
|
- 响应式字体大小
|
||||||
|
- 通过“大号”模式切换数值主字体大小,使资产余额等关键指标在有限空间内获得更强的可读性与视觉焦点。
|
||||||
|
- 与图表组件的复用
|
||||||
|
- LifePlanChart 与 CashFlowChart 均提供独立 HUD 区域,但 UnifiedHUD 可作为通用组件在需要时直接复用,减少重复实现。
|
||||||
|
- 在 DualChartView 中,两个图表均以 hideHeader=true 的方式嵌入,避免重复 HUD,同时在上层容器中自行管理统一的 HUD 展示逻辑。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
|
||||||
|
### MetricCard 子组件
|
||||||
|
- 功能与交互
|
||||||
|
- 接收标签、数值、单位、颜色、图标与“是否大号”的布尔标志。
|
||||||
|
- 数值显示时自动添加千位分隔符;对净现金流卡片,若为正值且标签为“NET FLOW”,会前置“+”号以增强可读性。
|
||||||
|
- 图标与单位以右侧对齐的方式组合显示,形成紧凑而清晰的指标单元。
|
||||||
|
- 颜色与正负逻辑
|
||||||
|
- 收入与支出卡片的颜色由调用方指定;净现金流卡片根据数值正负动态选择颜色与图标。
|
||||||
|
- 资产余额卡片同样根据正负动态选择颜色与图标,确保视觉一致性。
|
||||||
|
- 字体与间距
|
||||||
|
- 标签区采用较小字号与高字重,保证信息层级清晰。
|
||||||
|
- 数值区支持“大号”模式,使关键指标在视觉上更突出。
|
||||||
|
- 单位区采用略小于主数值的字号,保持整体平衡。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L111-L164)
|
||||||
|
|
||||||
|
### 与 LifePlanChart 的集成
|
||||||
|
- HUD 设计差异
|
||||||
|
- LifePlanChart 自带 HUD,采用浅色背景与简洁排版,资产余额根据正负值切换蓝色/红色。
|
||||||
|
- UnifiedHUD 采用深色背景与高对比度配色,适合在复杂图表上方叠加显示。
|
||||||
|
- 数据映射
|
||||||
|
- LifePlanChart 的数据结构包含年份、年龄与财务三要素;UnifiedHUD 的数据来源于外部传入的指标集合。
|
||||||
|
- 在 DualChartView 中,CashFlow 数据被转换为 LifePlan 所需的 YearlySnapshot 形态,以便在 LifePlanChart 中复用 HUD 逻辑。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
### 与 CashFlowChart 的集成
|
||||||
|
- HUD 设计差异
|
||||||
|
- CashFlowChart 的 HUD 采用横向分组布局,分别展示“总收入”“总支出”“结余”,并根据结余正负切换颜色。
|
||||||
|
- UnifiedHUD 的 HUD 更聚焦于“收入/支出/净现金流/资产余额”四类核心指标,便于跨图表统一认知。
|
||||||
|
- 数据映射
|
||||||
|
- CashFlowChart 的数据包含年、年龄、收入明细、支出明细、总支出、净现金流与资产余额,这些字段可直接映射到 UnifiedHUD 的 props。
|
||||||
|
- 在 DualChartView 中,CashFlowChart 与 LifePlanChart 共享同一套动画进度,从而在 HUD 上保持一致的时间点。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
### 数据模型与来源
|
||||||
|
- LifePlanChart 使用 YearlySnapshot 结构,包含年份、年龄与财务三要素,典型数据来自 mock-data.json。
|
||||||
|
- CashFlowChart 使用 CashFlowData 结构,包含年份、年龄、收入明细、支出明细、总支出、净现金流与资产余额,典型数据来自 mock-cashflow.json。
|
||||||
|
- DualChartView 将 CashFlow 数据转换为 LifePlan 所需形态,以便在 LifePlanChart 中统一展示 HUD。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json#L1-L200)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
Unified["UnifiedHUD.tsx"]
|
||||||
|
Metric["MetricCard(内联)"]
|
||||||
|
LifePlan["LifePlanChart.tsx"]
|
||||||
|
CashFlow["CashFlowChart.tsx"]
|
||||||
|
Dual["DualChartView.tsx"]
|
||||||
|
Types["types.ts"]
|
||||||
|
Unified --> Metric
|
||||||
|
LifePlan --> Unified
|
||||||
|
CashFlow --> Unified
|
||||||
|
Dual --> LifePlan
|
||||||
|
Dual --> CashFlow
|
||||||
|
LifePlan --> Types
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
|
||||||
|
## 性能考量
|
||||||
|
- 内联样式的组织
|
||||||
|
- 将外层容器与卡片内部样式拆分,避免重复计算与不必要的重绘。
|
||||||
|
- 对字体大小、颜色与间距采用集中配置,便于统一维护与性能优化。
|
||||||
|
- 动画与渲染
|
||||||
|
- 在图表组件中,通过外部传参控制 HUD 的可见性(如 hideHeader=true),减少重复渲染。
|
||||||
|
- 在 DualChartView 中,通过统一的帧进度计算与外部数据索引传递,确保 HUD 与图表主体同步,避免额外的动画开销。
|
||||||
|
- 数据映射与缓存
|
||||||
|
- 在 DualChartView 中,使用 useMemo 缓存 LifePlan 时间线的转换结果,避免每帧重建数组导致的图表抖动与重渲染。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- HUD 不显示或显示异常
|
||||||
|
- 检查是否正确传入 UnifiedHUD 的 props(year、age、income、expense、netCashFlow、assetBalance、currencyUnit)。
|
||||||
|
- 若在图表组件中使用 hideHeader=true,请确认上层容器是否提供了替代 HUD。
|
||||||
|
- 颜色与图标不正确
|
||||||
|
- 确认净现金流与资产余额的正负逻辑是否按预期设置;对于负值,应使用红色系与相应图标。
|
||||||
|
- 检查 MetricCard 的 isLarge 标志是否按需启用,以确保关键指标的视觉权重。
|
||||||
|
- 数据错位或不一致
|
||||||
|
- 在 DualChartView 中,确认 CashFlow 数据已正确转换为 LifePlan 所需形态,且外部传参的 externalDataIndex 与 externalFraction 正确传递。
|
||||||
|
- 字体大小与间距问题
|
||||||
|
- 如需调整字体大小,优先通过“大号”模式或统一的样式配置进行修改;避免在多处硬编码字体尺寸。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
UnifiedHUD 通过统一的 UI 设计与灵活的 props 接口,为视频画面提供了清晰、一致的关键财务指标展示层。其 MetricCard 子组件支持动态颜色与图标,结合正负值的视觉策略,有效提升了信息传达效率。在 LifePlanChart、CashFlowChart 与 DualChartView 中,UnifiedHUD 可作为独立 HUD 或与图表主体协同工作,满足不同场景下的可视化需求。
|
||||||
|
|
||||||
|
## 附录:扩展与定制指南
|
||||||
|
|
||||||
|
### 扩展新的指标卡片
|
||||||
|
- 新增卡片步骤
|
||||||
|
- 在 UnifiedHUD 中新增 MetricCard 实例,传入 label、value、unit、color、icon 与可选的 isLarge。
|
||||||
|
- 若新指标存在正负值含义,建议沿用正数绿色/负数红色的策略,并在图标与文案上保持一致风格。
|
||||||
|
- 数据映射
|
||||||
|
- 在图表组件中,将新指标从数据源提取并传入 UnifiedHUD 的对应 props。
|
||||||
|
- 在 DualChartView 或其他容器中,确保外部传参包含新指标的数据。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
### 修改现有卡片的视觉样式
|
||||||
|
- 字体与间距
|
||||||
|
- 通过“大号”模式提升关键指标的视觉权重;对非关键指标维持常规字号。
|
||||||
|
- 统一标签区与数值区的间距,确保信息层级清晰。
|
||||||
|
- 颜色主题
|
||||||
|
- 收入/支出/净现金流/资产余额分别采用与其语义相符的颜色体系;保持与图表主体的整体色彩协调。
|
||||||
|
- 如需调整主题,建议在组件外部集中管理颜色常量,避免散落的硬编码颜色值。
|
||||||
|
- 内联样式组织
|
||||||
|
- 将外层容器样式与卡片内部样式分离,便于维护与复用。
|
||||||
|
- 对常用样式(如字号、字重、间距、圆角、阴影)建立统一规范,减少重复定义。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
# 视频合成结构(Composition)
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件**
|
||||||
|
- [Composition.tsx](file://src/Composition.tsx)
|
||||||
|
- [Root.tsx](file://src/Root.tsx)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx)
|
||||||
|
- [types.ts](file://src/types.ts)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [mock-data.json](file://src/mock-data.json)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能考量](#性能考量)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录](#附录)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本文件围绕 Composition.tsx 在 Remotion 框架中的角色展开,重点说明其作为“可渲染的视频合成单元”的职责与设计意图。尽管当前文件仅导出一个空实现的 MyComposition 函数,但其命名与位置遵循 Remotion 的约定,为后续扩展预留了清晰的入口点。我们将在文档中系统阐述:
|
||||||
|
- 如何通过 Remotion 的 Composition 组件进行注册与参数配置(durationInFrames、fps 等)
|
||||||
|
- 如何将动态数据(来自 render_props.json 或本地 mock 数据)传递给子组件
|
||||||
|
- 如何构建多层画面布局(如画中画、分屏)
|
||||||
|
- 如何创建多个 Composition 变体以适配不同视频模板或客户场景
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
Remotion 应用通过根组件集中注册多个 Composition,每个 Composition 对应一个独立的视频片段。Composition.tsx 当前处于待实现状态,未来将作为统一的入口或模板基座,承载 LifePlanChart、CashFlowChart、UnifiedHUD 等组件的组合与编排。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
Root["Root.tsx<br/>注册多个 Composition"] --> Comp1["Composition.tsx<br/>MyComposition待实现"]
|
||||||
|
Root --> LP["LifePlanChart.tsx"]
|
||||||
|
Root --> CF["CashFlowChart.tsx"]
|
||||||
|
Root --> DCV["DualChartView.tsx"]
|
||||||
|
DCV --> LP
|
||||||
|
DCV --> CF
|
||||||
|
DCV --> HUD["UnifiedHUD.tsx"]
|
||||||
|
LP --> Types["types.ts"]
|
||||||
|
CF --> Types
|
||||||
|
LP -. 使用 .-> Mock1["mock-data.json"]
|
||||||
|
CF -. 使用 .-> Mock2["mock-cashflow.json"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [Composition.tsx](file://src/Composition.tsx#L1-L4)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json#L1-L200)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [Composition.tsx](file://src/Composition.tsx#L1-L4)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- Composition.tsx:当前为空实现,命名为 MyComposition,符合 Remotion 入口约定,用于承载后续的视频合成逻辑与组件组合。
|
||||||
|
- Root.tsx:集中注册多个 Composition,包括 LifePlanChart、LifePlanBarChart、CashFlowChart、DualChartView 等,每个注册项包含时长、帧率、分辨率与默认数据。
|
||||||
|
- LifePlanChart.tsx:资产余额趋势图,支持动画推进与 HUD 展示。
|
||||||
|
- CashFlowChart.tsx:收支明细图,支持分段增长动画与 HUD 展示。
|
||||||
|
- DualChartView.tsx:双图视图,同步控制两个子图的动画进度,实现画中画/分屏效果。
|
||||||
|
- UnifiedHUD.tsx:统一 HUD,展示当前年份与关键财务指标。
|
||||||
|
- types.ts:定义 YearlySnapshot、LifePlanChartProps 等类型,确保组件间数据契约一致。
|
||||||
|
- render-props-types.ts:前端侧 render_props.json 类型定义,与后端生成的数据结构对齐。
|
||||||
|
- mock-data.json、mock-cashflow.json:演示数据,供本地开发与调试使用。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [Composition.tsx](file://src/Composition.tsx#L1-L4)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json#L1-L200)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
Remotion 的视频合成由“根注册 + 组合组件 + 子组件”三层构成:
|
||||||
|
- 根注册层:在 Root.tsx 中通过 Composition 组件注册多个视频片段,设置时长、帧率、分辨率与默认数据。
|
||||||
|
- 组合层:Composition.tsx 作为入口或模板基座,负责组织 LifePlanChart、CashFlowChart、UnifiedHUD 等组件;也可参考 DualChartView 的双图同步策略。
|
||||||
|
- 子组件层:各图表组件内部管理动画、数据切片与 HUD 渲染,保证视觉一致性与性能稳定。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Dev as "开发者"
|
||||||
|
participant Root as "Root.tsx"
|
||||||
|
participant Comp as "Composition.tsx"
|
||||||
|
participant LP as "LifePlanChart.tsx"
|
||||||
|
participant CF as "CashFlowChart.tsx"
|
||||||
|
participant DCV as "DualChartView.tsx"
|
||||||
|
participant HUD as "UnifiedHUD.tsx"
|
||||||
|
Dev->>Root : 编写/修改根注册
|
||||||
|
Root->>Comp : 注册 MyComposition待实现
|
||||||
|
Root->>LP : 注册 LifePlanChart
|
||||||
|
Root->>CF : 注册 CashFlowChart
|
||||||
|
Root->>DCV : 注册 DualChartView
|
||||||
|
Comp-->>Dev : 作为入口承载组件组合
|
||||||
|
LP-->>Dev : 动画推进与 HUD
|
||||||
|
CF-->>Dev : 分段增长动画与 HUD
|
||||||
|
DCV-->>Dev : 双图同步与布局
|
||||||
|
HUD-->>Dev : 统一指标展示
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [Composition.tsx](file://src/Composition.tsx#L1-L4)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### Composition.tsx(入口与模板)
|
||||||
|
- 角色定位:当前为空实现,命名为 MyComposition,遵循 Remotion 入口约定,未来将作为视频合成单元的承载者。
|
||||||
|
- 设计建议:
|
||||||
|
- 将 LifePlanChart、CashFlowChart、UnifiedHUD 等组件按需组合,形成单页视频。
|
||||||
|
- 通过外层容器实现画中画或分屏布局,复用 DualChartView 的同步策略。
|
||||||
|
- 从 defaultProps 或外部传入的数据源中提取 timeline、meta 等字段,传递给子组件。
|
||||||
|
- 参数配置:可在 Root.tsx 中参考现有注册方式,为 MyComposition 设置 durationInFrames、fps、width、height、defaultProps 等。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [Composition.tsx](file://src/Composition.tsx#L1-L4)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
|
||||||
|
### LifePlanChart.tsx(资产余额趋势)
|
||||||
|
- 功能要点:
|
||||||
|
- 基于 Recharts 的面积图展示资产余额趋势,支持自定义 X 轴刻度与渐变填充。
|
||||||
|
- 使用 useCurrentFrame 与 useVideoConfig 获取帧索引与时长,通过 interpolate 实现平滑动画推进。
|
||||||
|
- 支持 HUD 展示当前年份、资产余额、收入与支出等关键指标。
|
||||||
|
- 性能优化:
|
||||||
|
- 使用 memo 包装组件,避免不必要的重渲染。
|
||||||
|
- 使用 useMemo 计算 Y 轴域与动画数据,减少每帧计算量。
|
||||||
|
- 复杂度分析:
|
||||||
|
- 动画推进与数据切片的时间复杂度近似 O(n),n 为 timeline 长度;Y 轴域计算为 O(n)。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["进入组件"]) --> GetFrame["获取当前帧与总时长"]
|
||||||
|
GetFrame --> Interp["插值计算精确索引"]
|
||||||
|
Interp --> Slice["根据索引切片可见数据"]
|
||||||
|
Slice --> InterpolatePoint{"是否需要插值点?"}
|
||||||
|
InterpolatePoint --> |是| AddPoint["插入插值点以平滑过渡"]
|
||||||
|
InterpolatePoint --> |否| SkipPoint["跳过"]
|
||||||
|
AddPoint --> CalcY["计算 Y 轴域基于全量数据"]
|
||||||
|
SkipPoint --> CalcY
|
||||||
|
CalcY --> Render["渲染图表与 HUD"]
|
||||||
|
Render --> End(["退出"])
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
|
||||||
|
### CashFlowChart.tsx(收支明细)
|
||||||
|
- 功能要点:
|
||||||
|
- 使用组合图展示收入线与多类支出堆叠柱状图,支持分段增长动画。
|
||||||
|
- 自定义 X 轴刻度显示年份与年龄,提供 HUD 展示当前年份与收支平衡。
|
||||||
|
- 动画策略:
|
||||||
|
- 通过分段比例控制各类别柱状图的生长顺序,使动画更易理解。
|
||||||
|
- 在固定域模式下补齐缺失年份,避免坐标偏移。
|
||||||
|
- 复杂度分析:
|
||||||
|
- 数据扁平化与可见数据切片为 O(n);Y 轴域计算为 O(n)。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["进入组件"]) --> GetFrame["获取当前帧与总时长"]
|
||||||
|
GetFrame --> Interp["插值计算进度"]
|
||||||
|
Interp --> Segment["计算分段比例"]
|
||||||
|
Segment --> Flatten["扁平化数据并计算汇总"]
|
||||||
|
Flatten --> Slice["切片可见数据并应用分段动画"]
|
||||||
|
Slice --> Pad{"是否需要补齐年份?"}
|
||||||
|
Pad --> |是| Fill["填充空年份占位"]
|
||||||
|
Pad --> |否| SkipFill["跳过"]
|
||||||
|
Fill --> Domain["计算 Y 轴域"]
|
||||||
|
SkipFill --> Domain
|
||||||
|
Domain --> Render["渲染图表与 HUD"]
|
||||||
|
Render --> End(["退出"])
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
|
||||||
|
### DualChartView.tsx(双图同步)
|
||||||
|
- 功能要点:
|
||||||
|
- 同步控制 LifePlanChart 与 CashFlowChart 的动画进度,实现上/下分屏布局。
|
||||||
|
- 将 CashFlow 数据转换为 LifePlan 所需格式,确保两图共享同一动画时序。
|
||||||
|
- 通过 hideHeader 控制子图头部显示,减少重复信息。
|
||||||
|
- 关键流程:
|
||||||
|
- 统一插值计算动画进度,分别传递到两个子图。
|
||||||
|
- 使用 useMemo 预处理转换后的 timeline 与 meta,避免每帧重建数组。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant DCV as "DualChartView"
|
||||||
|
participant LP as "LifePlanChart"
|
||||||
|
participant CF as "CashFlowChart"
|
||||||
|
participant HUD as "UnifiedHUD"
|
||||||
|
DCV->>DCV : 插值计算动画进度
|
||||||
|
DCV->>LP : 传递外部索引与分数
|
||||||
|
DCV->>CF : 传递外部年进度
|
||||||
|
DCV->>HUD : 传递当前年份与指标
|
||||||
|
LP-->>DCV : 渲染资产余额趋势
|
||||||
|
CF-->>DCV : 渲染收支明细
|
||||||
|
HUD-->>DCV : 渲染统一 HUD
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
|
||||||
|
### UnifiedHUD.tsx(统一 HUD)
|
||||||
|
- 功能要点:
|
||||||
|
- 展示当前年份、年龄、收入、支出、净现金流与资产余额等关键指标。
|
||||||
|
- 使用统一配色与图标,突出正负差异。
|
||||||
|
- 设计建议:
|
||||||
|
- 可作为独立组件在 Composition 或 DualChartView 中复用,或由子图内部自行渲染。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- 组件耦合:
|
||||||
|
- DualChartView 依赖 LifePlanChart、CashFlowChart 与 UnifiedHUD,形成组合关系。
|
||||||
|
- LifePlanChart 与 CashFlowChart 内部依赖 Recharts 与 Remotion 的时间控制 API。
|
||||||
|
- 数据契约:
|
||||||
|
- types.ts 定义 YearlySnapshot、LifePlanChartProps,确保组件间数据结构一致。
|
||||||
|
- render-props-types.ts 定义前端侧 render_props.json 结构,便于与后端数据对接。
|
||||||
|
- 外部数据:
|
||||||
|
- Root.tsx 通过 defaultProps 将 mock-data.json 与 mock-cashflow.json 注入对应 Composition。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
Root["Root.tsx"] --> Comp["Composition.tsx"]
|
||||||
|
Root --> LP["LifePlanChart.tsx"]
|
||||||
|
Root --> CF["CashFlowChart.tsx"]
|
||||||
|
Root --> DCV["DualChartView.tsx"]
|
||||||
|
DCV --> LP
|
||||||
|
DCV --> CF
|
||||||
|
DCV --> HUD["UnifiedHUD.tsx"]
|
||||||
|
LP --> Types["types.ts"]
|
||||||
|
CF --> Types
|
||||||
|
LP -. defaultProps .-> Mock1["mock-data.json"]
|
||||||
|
CF -. defaultProps .-> Mock2["mock-cashflow.json"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [Composition.tsx](file://src/Composition.tsx#L1-L4)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json#L1-L200)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
## 性能考量
|
||||||
|
- 动画推进:
|
||||||
|
- 使用 useCurrentFrame 与 interpolate 进行帧级插值,避免每帧重复计算动画进度。
|
||||||
|
- 数据切片与缓存:
|
||||||
|
- 通过 useMemo 缓存 Y 轴域、转换后的 timeline 与 meta,减少数组重建与昂贵计算。
|
||||||
|
- 图表渲染:
|
||||||
|
- Recharts 默认动画关闭,避免内部动画与外部插值冲突导致的闪烁。
|
||||||
|
- 布局与缩放:
|
||||||
|
- 使用 ResponsiveContainer 与固定宽高比,确保在不同分辨率下的稳定渲染。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- 数据不匹配:
|
||||||
|
- 若出现图表渲染异常,检查 YearlySnapshot 与 CashFlowData 字段是否与组件期望一致。
|
||||||
|
- 参考 render-props-types.ts 的类型定义,确保前后端数据结构一致。
|
||||||
|
- 动画卡顿或跳帧:
|
||||||
|
- 确认未在组件内执行昂贵的每帧计算,必要时使用 useMemo 缓存中间结果。
|
||||||
|
- 检查 interpolate 的区间与 extrapolate 配置,避免越界导致的异常值。
|
||||||
|
- 布局错位:
|
||||||
|
- 确保 DualChartView 中两个子图的边距与字体设置一致,避免视觉错位。
|
||||||
|
- 数据缺失:
|
||||||
|
- 在固定域模式下,CashFlowChart 会补齐空年份占位;若仍出现偏移,检查 timeline 的起止年份与 domain 计算逻辑。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
Composition.tsx 当前作为 Remotion 的入口模板,虽未实现具体逻辑,但已具备清晰的扩展路径。结合 Root.tsx 的注册方式与各子组件的动画与布局策略,开发者可以快速构建多种视频模板(单图、双图、画中画等),并通过 defaultProps 或外部数据源(render_props.json)灵活适配不同客户场景。建议在实现 MyComposition 时,优先参考 DualChartView 的同步策略与布局模式,并充分利用 useMemo 与 Recharts 的性能特性,确保视频渲染的流畅与稳定。
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### 在 Root.tsx 中注册 Composition 的参考流程
|
||||||
|
- 为每个视频模板创建一个 Composition 注册项,设置 id、component、durationInFrames、fps、width、height、defaultProps 等。
|
||||||
|
- defaultProps 中注入数据源(如 mock-data.json 或 mock-cashflow.json),确保组件可直接消费。
|
||||||
|
- 参考现有注册项,为 MyComposition 预留参数与数据结构。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
|
||||||
|
### 将动态数据传递给子组件的实践
|
||||||
|
- render-props-types.ts 提供了前端侧 render_props.json 的类型定义,可用于校验与消费后端生成的数据。
|
||||||
|
- 在 Composition 中,将 render_props.json 的 charts、script、assets 等字段映射到子组件 props,实现数据驱动的视频内容生成。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
### 创建多个 Composition 变体的建议
|
||||||
|
- 单图模板:以 LifePlanChart 或 CashFlowChart 为核心,配合 HUD 展示关键指标。
|
||||||
|
- 双图模板:采用 DualChartView 的同步策略,实现资产余额与收支明细的联动展示。
|
||||||
|
- 画中画模板:在 Composition 中嵌套子组件,通过外层容器实现画中画布局,复用 HUD 与动画逻辑。
|
||||||
|
- 客户场景适配:通过不同的 defaultProps 与 meta 配置,快速切换货币单位、起始年份、时长等参数,满足不同客户的视频需求。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
|
@ -0,0 +1,427 @@
|
||||||
|
# 资产消耗图表 (LifePlanChart)
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx)
|
||||||
|
- [types.ts](file://src/types.ts)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [mock-data.json](file://src/mock-data.json)
|
||||||
|
- [Root.tsx](file://src/Root.tsx)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能考量](#性能考量)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录](#附录)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本文件围绕 LifePlanChart 组件展开,系统性阐述其作为“资产消耗趋势可视化图表”的实现方式。组件基于 Recharts 的 AreaChart 展示用户生命周期内资产余额的变化,结合 Remotion 的 useCurrentFrame 和 interpolate 实现逐帧动画的数据流;通过自定义 X 轴刻度 CustomXAxisTick 渲染“年份+主被保险人年龄+配偶年龄”的三行标签;采用渐变填充策略(蓝色代表正资产、红色代表负资产)直观反映资产状态;HUD 区域实时显示当前帧对应的年份、资产余额、收入与支出;并通过数据插值算法实现平滑的逐帧动画效果。文档还给出与 render_props.json 数据结构的映射关系示例,说明 YearlySnapshot 类型如何驱动图表渲染,并提供面向初学者的使用示例与面向高级开发者的性能优化建议。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
本项目采用前端 Remotion 视频渲染管线,LifePlanChart 位于 src 目录下,配合 Root.tsx 注册为 Composition,mock-data.json 提供演示数据,frontend/src/render-props-types.ts 定义了后端生成的 render_props.json 的类型契约。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "前端渲染层"
|
||||||
|
Root["Root.tsx<br/>注册 Composition"]
|
||||||
|
LifePlan["LifePlanChart.tsx<br/>资产消耗趋势图"]
|
||||||
|
Dual["DualChartView.tsx<br/>双图联动视图"]
|
||||||
|
end
|
||||||
|
subgraph "数据层"
|
||||||
|
Mock["mock-data.json<br/>演示数据"]
|
||||||
|
Types["types.ts<br/>YearlySnapshot 接口"]
|
||||||
|
RenderTypes["render-props-types.ts<br/>render_props.json 类型契约"]
|
||||||
|
end
|
||||||
|
Root --> LifePlan
|
||||||
|
Root --> Dual
|
||||||
|
LifePlan --> Types
|
||||||
|
Dual --> LifePlan
|
||||||
|
LifePlan -. 使用 .-> Mock
|
||||||
|
Dual -. 使用 .-> RenderTypes
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- 组件名称:LifePlanChart
|
||||||
|
- 功能定位:资产余额随生命周期变化的趋势可视化,支持 HUD 实时信息与平滑动画
|
||||||
|
- 关键特性:
|
||||||
|
- 基于 Recharts 的 AreaChart
|
||||||
|
- 自定义 X 轴三行标签(年份、主被保险人年龄、配偶年龄)
|
||||||
|
- 渐变填充(正资产蓝、负资产红)
|
||||||
|
- 基于 Remotion 的逐帧插值动画
|
||||||
|
- HUD 实时显示年份、资产余额、收入与支出
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
LifePlanChart 在 Remotion 中作为 Composition 的子组件运行,通过 useCurrentFrame 获取当前帧号,用 interpolate 将帧号映射到 timeline 的索引区间,从而计算出可见数据片段与插值点,实现“从左至右”推进的平滑动画。HUD 区域根据当前数据点实时更新;Y 轴域基于全量 timeline 计算,避免动画过程中因极值变化导致的跳变;X 轴域在短序列时固定范围,保证标签可读性。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant R as "Remotion 播放器"
|
||||||
|
participant C as "LifePlanChart"
|
||||||
|
participant RC as "Recharts 图表"
|
||||||
|
participant U as "HUD 区域"
|
||||||
|
R->>C : "useCurrentFrame() 获取当前帧"
|
||||||
|
C->>C : "interpolate(帧号) => exactIndex"
|
||||||
|
C->>C : "计算 dataEndIndex 与 fraction"
|
||||||
|
C->>C : "构建 visibleData含插值点"
|
||||||
|
C->>RC : "传入 visibleData、渐变配置、Y/X 轴域"
|
||||||
|
RC-->>U : "渲染图表与 HUD"
|
||||||
|
U-->>R : "实时显示年份/资产余额/收入/支出"
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L70-L141)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L175-L210)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L292-L341)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### 数据模型与类型映射
|
||||||
|
- YearlySnapshot:单年度数据结构,包含年份、年龄(主被保险人与可选配偶)、财务指标(收入、支出、资产余额),以及可选事件与关键标记。
|
||||||
|
- LifePlanChartProps:组件属性,包括 timeline、meta(货币单位、起始年份)、是否隐藏头部、外部传入的索引与进度,用于与上层联动或外部控制动画。
|
||||||
|
- render_props.json 映射:后端生成的 render_props.json 中的 charts.assetDepletion.timeline 字段与 YearlySnapshot 结构一致,可直接作为 defaultProps 传入 LifePlanChart。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
YEARLY_SNAPSHOT {
|
||||||
|
number year
|
||||||
|
number rep_age
|
||||||
|
number? spouse_age
|
||||||
|
number income
|
||||||
|
number expenditure
|
||||||
|
number asset_balance
|
||||||
|
string[]? events
|
||||||
|
boolean? is_critical
|
||||||
|
}
|
||||||
|
LIFE_PLAN_CHART_PROPS {
|
||||||
|
YearlySnapshot[] timeline
|
||||||
|
object meta
|
||||||
|
boolean hide_header
|
||||||
|
number? external_data_index
|
||||||
|
number? external_fraction
|
||||||
|
}
|
||||||
|
RENDER_PROPS {
|
||||||
|
object meta
|
||||||
|
object charts
|
||||||
|
object script
|
||||||
|
object assets
|
||||||
|
}
|
||||||
|
RENDER_PROPS ||--|| LIFE_PLAN_CHART_PROPS : "defaultProps"
|
||||||
|
LIFE_PLAN_CHART_PROPS ||--o{ YEARLY_SNAPSHOT : "timeline"
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L55-L61)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L17-L29)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L55-L61)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L17-L29)
|
||||||
|
|
||||||
|
### 自定义 X 轴刻度:三行标签渲染
|
||||||
|
- 渲染内容:年份(第一行)、主被保险人年龄(第二行,加粗)、配偶年龄(第三行,若无则显示“-”)。
|
||||||
|
- 实现要点:
|
||||||
|
- 通过 payload.index 获取当前刻度对应 timeline 的索引。
|
||||||
|
- 从 timelineData 中取出对应 YearlySnapshot,安全地渲染三行文本。
|
||||||
|
- 使用 translate(x,y) 对齐刻度位置,设置字体大小、颜色与对齐方式。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start["进入 CustomXAxisTick 渲染"] --> GetIndex["根据 payload.index 获取索引"]
|
||||||
|
GetIndex --> Lookup["从 timelineData 查找 YearlySnapshot"]
|
||||||
|
Lookup --> HasData{"是否存在数据点?"}
|
||||||
|
HasData --> |否| EndNull["返回空节点"]
|
||||||
|
HasData --> |是| RenderRows["绘制三行文本:<br/>1) 年份<br/>2) 主被保险人年龄加粗<br/>3) 配偶年龄若存在"]
|
||||||
|
RenderRows --> End["完成渲染"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L16-L65)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L16-L65)
|
||||||
|
|
||||||
|
### 渐变填充逻辑:正负资产分色
|
||||||
|
- 计算规则:
|
||||||
|
- 若全部资产余额非负:渐变完全为蓝色。
|
||||||
|
- 若全部资产余额非正:渐变完全为红色。
|
||||||
|
- 若混合:按最大值与最小值的比例计算零线位置,形成蓝到红的渐变过渡。
|
||||||
|
- 关键点:
|
||||||
|
- 基于“可见数据”计算梯度偏移,确保与当前屏幕显示一致。
|
||||||
|
- 通过 <linearGradient> 的两个 stop 点共同决定颜色分界,实现平滑过渡。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start["开始计算渐变偏移"] --> CheckEmpty{"visibleData 是否为空?"}
|
||||||
|
CheckEmpty --> |是| Off0["返回偏移 0"]
|
||||||
|
CheckEmpty --> |否| Extrema["计算 visibleData 中的最大值与最小值"]
|
||||||
|
Extrema --> AllPos{"最小值 >= 0 ?"}
|
||||||
|
AllPos --> |是| Off1["返回偏移 1全蓝"]
|
||||||
|
AllPos --> |否| AllNeg{"最大值 <= 0 ?"}
|
||||||
|
AllNeg --> |是| Off0["返回偏移 0全红"]
|
||||||
|
AllNeg --> |否| Mix["偏移 = 最大值/(最大值-最小值)"]
|
||||||
|
Off1 --> End["结束"]
|
||||||
|
Off0 --> End
|
||||||
|
Mix --> End
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L142-L163)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L142-L163)
|
||||||
|
|
||||||
|
### HUD 区域:实时信息展示
|
||||||
|
- 内容:
|
||||||
|
- 当前年份
|
||||||
|
- 资产余额(正数为蓝色,负数为红色)
|
||||||
|
- 收入(绿色,带“+”前缀)
|
||||||
|
- 支出(红色,带“-”前缀)
|
||||||
|
- 数据来源:基于当前帧计算出的 dataEndIndex 对应的 YearlySnapshot。
|
||||||
|
- 设计考虑:使用统一背景与阴影提升可读性,布局在隐藏头部模式下仍保持一致性。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L203-L289)
|
||||||
|
|
||||||
|
### 动画数据流与插值算法
|
||||||
|
- 插值映射:将帧号映射到 timeline 的索引区间,得到 exactIndex。
|
||||||
|
- 数据切片:dataEndIndex 为 floor(exactIndex),fraction 为小数部分。
|
||||||
|
- 可见数据:取 timeline.slice(0, dataEndIndex+1),并在末尾插入按 fraction 插值得到的“当前帧”插值点。
|
||||||
|
- 插值字段:
|
||||||
|
- year:线性插值
|
||||||
|
- ages.rep/spouse:线性插值(若一方缺失则不参与插值)
|
||||||
|
- financials.income/expenditure/assetBalance:线性插值
|
||||||
|
- 外部控制:支持 externalDataIndex 与 externalFraction,便于与其他图表联动(例如 DualChartView)。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
F0["useCurrentFrame()"] --> Map["interpolate(帧号) => exactIndex"]
|
||||||
|
Map --> Split["dataEndIndex = floor(exactIndex)<br/>fraction = exactIndex - dataEndIndex"]
|
||||||
|
Split --> Slice["visibleData = timeline.slice(0, dataEndIndex+1)"]
|
||||||
|
Split --> Interp{"fraction > 0 且 dataEndIndex < length-1 ?"}
|
||||||
|
Interp --> |是| Build["构造插值点year/ages/financials"]
|
||||||
|
Interp --> |否| Skip["跳过插值"]
|
||||||
|
Build --> Append["追加到 visibleData"]
|
||||||
|
Skip --> Append
|
||||||
|
Append --> Render["Recharts 渲染"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L81-L141)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L81-L141)
|
||||||
|
|
||||||
|
### Y 轴域与 X 轴域策略
|
||||||
|
- Y 轴域:基于全量 timeline 的资产余额极值计算,添加 10% 边距,避免动画过程中的剧烈跳变。
|
||||||
|
- X 轴域:当可见数据长度小于 10 时,固定 X 轴范围为 timeline[0].year 到 timeline[0].year + 9,确保标签可读;否则使用类别类型自动适配。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L175-L189)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L311-L319)
|
||||||
|
|
||||||
|
### 与 render_props.json 的映射关系示例
|
||||||
|
- 后端输出:charts.assetDepletion.timeline 为 YearlySnapshot 数组,meta 包含 currencyUnit 与 startYear。
|
||||||
|
- 前端使用:在 Root.tsx 中将 mock-data.json 的 timeline 与 meta 作为 defaultProps 传给 LifePlanChart。
|
||||||
|
- 双图联动:在 DualChartView.tsx 中将 CashFlowData 转换为 YearlySnapshot 形态,再传给 LifePlanChart,实现两图同步动画。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Backend as "Python 后端"
|
||||||
|
participant JSON as "render_props.json"
|
||||||
|
participant Frontend as "Remotion 前端"
|
||||||
|
participant Root as "Root.tsx"
|
||||||
|
participant LifePlan as "LifePlanChart"
|
||||||
|
Backend->>JSON : "生成 charts.assetDepletion"
|
||||||
|
Frontend->>Root : "加载 render_props.json"
|
||||||
|
Root->>LifePlan : "defaultProps = { timeline, meta }"
|
||||||
|
LifePlan-->>Frontend : "渲染资产消耗趋势图"
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L55-L61)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L17-L29)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L48-L76)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L55-L61)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L17-L29)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L48-L76)
|
||||||
|
|
||||||
|
### 组件类图(代码级)
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class YearlySnapshot {
|
||||||
|
+number year
|
||||||
|
+object ages
|
||||||
|
+number ages.rep
|
||||||
|
+number? ages.spouse
|
||||||
|
+object financials
|
||||||
|
+number financials.income
|
||||||
|
+number financials.expenditure
|
||||||
|
+number financials.assetBalance
|
||||||
|
+string[]? events
|
||||||
|
+boolean? isCritical
|
||||||
|
}
|
||||||
|
class LifePlanChartProps {
|
||||||
|
+YearlySnapshot[] timeline
|
||||||
|
+object meta
|
||||||
|
+string meta.currencyUnit
|
||||||
|
+number meta.startYear
|
||||||
|
+boolean hideHeader
|
||||||
|
+number? externalDataIndex
|
||||||
|
+number? externalFraction
|
||||||
|
}
|
||||||
|
class LifePlanChart {
|
||||||
|
+React.FC<LifePlanChartProps>
|
||||||
|
+useCurrentFrame()
|
||||||
|
+interpolate()
|
||||||
|
+React.memo()
|
||||||
|
}
|
||||||
|
LifePlanChartProps --> YearlySnapshot : "timeline"
|
||||||
|
LifePlanChart --> LifePlanChartProps : "接收"
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L70-L110)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L70-L110)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- 组件依赖:
|
||||||
|
- Recharts:AreaChart、XAxis、YAxis、CartesianGrid、ReferenceLine、ResponsiveContainer
|
||||||
|
- Remotion:useCurrentFrame、useVideoConfig、interpolate
|
||||||
|
- React:memo、useMemo、useCurrentFrame
|
||||||
|
- 数据依赖:
|
||||||
|
- YearlySnapshot 接口定义数据结构
|
||||||
|
- render_props.json 的 charts.assetDepletion.timeline 与 meta
|
||||||
|
- 上层依赖:
|
||||||
|
- Root.tsx 注册 Composition 并传入 defaultProps
|
||||||
|
- DualChartView.tsx 将 CashFlowData 转换为 YearlySnapshot 传入 LifePlanChart
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
LifePlan["LifePlanChart.tsx"] --> Recharts["Recharts 组件"]
|
||||||
|
LifePlan --> Remotion["Remotion hooks"]
|
||||||
|
LifePlan --> Types["types.ts"]
|
||||||
|
Root["Root.tsx"] --> LifePlan
|
||||||
|
Dual["DualChartView.tsx"] --> LifePlan
|
||||||
|
RenderTypes["render-props-types.ts"] --> Root
|
||||||
|
Mock["mock-data.json"] --> Root
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L30)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L55-L61)
|
||||||
|
- [mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L30)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L55-L61)
|
||||||
|
- [mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
|
||||||
|
## 性能考量
|
||||||
|
- 使用 React.memo 包裹 LifePlanChart,避免 props 未变化时的重渲染。
|
||||||
|
- 使用 React.useMemo:
|
||||||
|
- 缓存 gradientOffset,仅在 visibleData 变化时重新计算。
|
||||||
|
- 缓存 xAxisDomain,避免每帧重建数组。
|
||||||
|
- 在上层(如 DualChartView)将 CashFlowData 转换为 YearlySnapshot 的结果缓存,避免每帧重建数组。
|
||||||
|
- 关闭 AreaChart 内置动画(isAnimationActive=false),改用插值驱动的平滑动画,减少不必要的重绘。
|
||||||
|
- Y 轴域基于全量 timeline 计算,避免动画过程中的跳变,降低视觉抖动。
|
||||||
|
- X 轴域在短序列时固定范围,提高标签可读性与渲染稳定性。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L67-L70)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L142-L174)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L175-L189)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L48-L76)
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- HUD 数值异常:
|
||||||
|
- 检查 currentData 来源是否正确(dataEndIndex 对应的 timeline 元素)。
|
||||||
|
- 确认 financials 字段是否包含 income、expenditure、assetBalance。
|
||||||
|
- 渐变颜色不符合预期:
|
||||||
|
- 确认 visibleData 是否为空或仅包含正/负值。
|
||||||
|
- 检查 gradientOffset 的计算逻辑与 <linearGradient> 的 stop 配置。
|
||||||
|
- X 轴标签错位或拥挤:
|
||||||
|
- 检查 CustomXAxisTick 的 payload.index 与 timelineData 的索引映射。
|
||||||
|
- 调整 interval 与 domain,或在短序列时固定 X 轴范围。
|
||||||
|
- 动画卡顿或跳变:
|
||||||
|
- 确保关闭 AreaChart 内置动画,使用插值驱动。
|
||||||
|
- 检查 externalDataIndex 与 externalFraction 是否由上层正确传递。
|
||||||
|
- 使用 memo 与 useMemo 缓存昂贵计算。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L16-L65)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L142-L189)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L311-L341)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L120-L130)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
LifePlanChart 通过 Remotion 的逐帧插值与 Recharts 的 AreaChart,实现了资产余额随生命周期平滑推进的可视化。其三行 X 轴标签清晰呈现时间与年龄信息,渐变填充直观表达资产正负状态,HUD 实时反馈关键财务指标。借助 memo 与 useMemo 的性能优化,以及与 render_props.json 的强类型映射,组件在复杂视频场景中具备良好的稳定性与扩展性。
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### 初学者使用示例
|
||||||
|
- 直接渲染(使用 mock 数据):
|
||||||
|
- 在 Root.tsx 中注册 LifePlanChart Composition,并将 mock-data.json 的 timeline 与 meta 作为 defaultProps 传入。
|
||||||
|
- 运行 Remotion 渲染,即可看到资产消耗趋势动画。
|
||||||
|
- 双图联动:
|
||||||
|
- 在 DualChartView.tsx 中将 CashFlowData 转换为 YearlySnapshot,分别传给 LifePlanChart 与 CashFlowChart,两者共享同一动画进度,实现同步推进。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L17-L29)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L48-L76)
|
||||||
|
|
||||||
|
### 高级开发者优化建议
|
||||||
|
- 性能优化:
|
||||||
|
- 在上层(如 DualChartView)对数据转换结果使用 useMemo 缓存。
|
||||||
|
- 在 LifePlanChart 内部对 gradientOffset、xAxisDomain、visibleData 的构建进行 memo 化。
|
||||||
|
- 避免在渲染函数中创建新对象或数组,减少不必要的重渲染。
|
||||||
|
- 自定义动画曲线:
|
||||||
|
- 通过自定义 interpolate 的缓动函数(如 easeInOut)替换默认线性映射,改变推进速度曲线。
|
||||||
|
- 在外部控制外部动画进度(externalDataIndex 与 externalFraction),实现多图表联动与同步暂停/加速。
|
||||||
|
- 数据校验:
|
||||||
|
- 使用 render-props-types.ts 中的 Zod Schema 对 render_props.json 进行运行时校验,确保数据结构一致性。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L67-L70)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L142-L174)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L194-L231)
|
||||||
|
|
@ -0,0 +1,418 @@
|
||||||
|
# 快速入门
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md)
|
||||||
|
- [.env.example](file://.env.example)
|
||||||
|
- [requirements.txt](file://requirements.txt)
|
||||||
|
- [package.json](file://package.json)
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts)
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖与环境配置](#依赖与环境配置)
|
||||||
|
7. [输入文件准备](#输入文件准备)
|
||||||
|
8. [执行生成流程](#执行生成流程)
|
||||||
|
9. [依赖关系分析](#依赖关系分析)
|
||||||
|
10. [性能与优化建议](#性能与优化建议)
|
||||||
|
11. [故障排查指南](#故障排查指南)
|
||||||
|
12. [结论](#结论)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本指南面向首次接触本项目的用户,帮助您从零开始完成依赖安装、环境配置、输入准备与完整生成流程。系统采用“Python 后端 + Node.js 前端”的混合架构:Python 负责数据提取、脚本生成、图表模拟与渲染参数构建;前端使用 Remotion 进行程序化视频渲染。通过本指南,您将理解为什么需要 Gemini、Whisper 和 Pexels 的 API 密钥,以及每一步背后的原理。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
项目采用分层组织:
|
||||||
|
- 根目录包含入口脚本、依赖清单、环境样例与资产目录
|
||||||
|
- backend 子目录包含数据提取、脚本生成、媒体抓取与图表数据构建等模块
|
||||||
|
- frontend 子目录为 Remotion 视频渲染前端工程
|
||||||
|
- assets 目录用于存放输入、中间产物与最终输出
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A["根目录<br/>main.py, .env.example, requirements.txt, package.json"] --> B["backend/<br/>extractor.py, scripter.py, media_fetcher.py, data_builder.py"]
|
||||||
|
A --> C["frontend/<br/>src/*"]
|
||||||
|
A --> D["assets/<br/>0_input, 1_processed, 2_output"]
|
||||||
|
A --> E["remotion.config.ts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L50-L80)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- 主入口与流水线编排:负责解析 CLI 参数、组织各阶段任务、保存渲染参数并触发前端渲染
|
||||||
|
- 数据提取模块:使用 Gemini 解析 PDF,使用 Whisper 提取视频字幕与时间戳
|
||||||
|
- 脚本生成模块:基于 PDF 与视频内容生成自然日语旁白脚本
|
||||||
|
- 媒体抓取模块:根据关键词从 Pexels 搜索并下载 B-Roll 视频
|
||||||
|
- 图表数据构建:基于财务模拟生成资产枯竭与现金流图表数据
|
||||||
|
- 渲染契约类型定义:前后端共享的渲染参数结构,确保 Python 生成的数据与前端渲染一致
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [main.py](file://main.py#L33-L304)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L1-L69)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
系统采用“数据驱动”的流水线式架构:Python 后端负责数据处理与渲染参数生成,前端 Remotion 负责将数据转换为最终视频。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "Python 后端"
|
||||||
|
EX["PDF/视频提取<br/>extractor.py"]
|
||||||
|
SC["脚本生成<br/>scripter.py"]
|
||||||
|
MF["媒体抓取<br/>media_fetcher.py"]
|
||||||
|
DB["图表数据构建<br/>data_builder.py"]
|
||||||
|
VP["主流水线编排<br/>main.py"]
|
||||||
|
end
|
||||||
|
subgraph "前端"
|
||||||
|
RP["渲染契约类型<br/>render-props-types.ts"]
|
||||||
|
RC["Remotion 配置<br/>remotion.config.ts"]
|
||||||
|
end
|
||||||
|
IN["输入 PDF/视频<br/>assets/0_input"] --> VP
|
||||||
|
VP --> EX
|
||||||
|
VP --> SC
|
||||||
|
VP --> MF
|
||||||
|
VP --> DB
|
||||||
|
DB --> RP
|
||||||
|
SC --> RP
|
||||||
|
MF --> RP
|
||||||
|
RP --> RC
|
||||||
|
RC --> OUT["输出视频<br/>assets/2_output"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [main.py](file://main.py#L33-L304)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L1-L69)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts#L1-L12)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### 主流水线编排(main.py)
|
||||||
|
- 负责解析 CLI 参数、组织六步流程(提取、转录、生成脚本、抓取媒体、构建渲染参数、触发渲染),并在 dry-run 模式下仅生成渲染参数
|
||||||
|
- 输出路径默认保存到 assets/2_output/life_plan_video.mp4
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as "用户"
|
||||||
|
participant M as "main.py"
|
||||||
|
participant B as "后端模块"
|
||||||
|
participant R as "Remotion"
|
||||||
|
U->>M : 运行 CLI指定 PDF/可选视频/干运行
|
||||||
|
M->>B : 步骤1:提取 PDF 数据
|
||||||
|
M->>B : 步骤2:提取视频数据可选
|
||||||
|
M->>B : 步骤3:生成脚本
|
||||||
|
M->>B : 步骤4:抓取 B-Roll
|
||||||
|
M->>B : 步骤5:构建渲染参数
|
||||||
|
M->>R : 步骤6:触发渲染可选
|
||||||
|
R-->>U : 输出最终视频
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [main.py](file://main.py#L33-L304)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [main.py](file://main.py#L33-L304)
|
||||||
|
|
||||||
|
### 数据提取(PDF/视频)
|
||||||
|
- PDF 提取:使用 Gemini 解析 Life Plan 报告,抽取客户信息、财务数据、重大事件等结构化数据
|
||||||
|
- 视频提取:使用 Whisper 将面试视频转写为文本与时间戳,便于后续脚本与媒体同步
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["输入:PDF/视频"] --> B["PDF 提取Gemini"]
|
||||||
|
A --> C["视频提取Whisper"]
|
||||||
|
B --> D["结构化数据"]
|
||||||
|
C --> E["转写文本与时间戳"]
|
||||||
|
D --> F["脚本生成Gemini"]
|
||||||
|
E --> F
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L1-L69)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L1-L69)
|
||||||
|
|
||||||
|
### 媒体抓取(B-Roll)
|
||||||
|
- 基于脚本与关键词从 Pexels 搜索并下载 B-Roll 视频,作为背景素材
|
||||||
|
- 支持将日文关键词翻译为英文后再搜索,提高检索命中率
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
S["脚本生成结果"] --> K["提取关键词"]
|
||||||
|
K --> T["关键词翻译Gemini"]
|
||||||
|
T --> P["Pexels 搜索"]
|
||||||
|
P --> D["下载视频至本地"]
|
||||||
|
D --> A["返回媒体映射"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
|
||||||
|
### 图表数据构建
|
||||||
|
- 使用财务模拟引擎计算年金收入、支出、资产余额与关键事件影响
|
||||||
|
- 生成资产枯竭与现金流两个图表所需的时间序列数据
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
I["PDF 结构化数据"] --> F["财务模拟器"]
|
||||||
|
F --> AD["资产枯竭时间线"]
|
||||||
|
F --> CF["现金流时间线"]
|
||||||
|
AD --> O["渲染参数charts.assetDepletion"]
|
||||||
|
CF --> O
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
|
||||||
|
### 渲染契约类型(前后端数据契约)
|
||||||
|
- 定义了渲染参数的完整结构(元信息、图表数据、脚本段落、媒体集合),并与前端类型保持一致
|
||||||
|
- 通过 Zod Schema 在运行时进行校验,保证数据一致性
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class YearlySnapshot {
|
||||||
|
+year : number
|
||||||
|
+ages : {rep : number, spouse? : number}
|
||||||
|
+financials : {income, expenditure, assetBalance}
|
||||||
|
+events? : string[]
|
||||||
|
+isCritical? : boolean
|
||||||
|
}
|
||||||
|
class CashFlowData {
|
||||||
|
+year : number
|
||||||
|
+age : number
|
||||||
|
+income.total : number
|
||||||
|
+expenses : {living, housing, education, tax, event}
|
||||||
|
+totalExpense? : number
|
||||||
|
+netCashFlow? : number
|
||||||
|
+assetBalance? : number
|
||||||
|
}
|
||||||
|
class ScriptSegment {
|
||||||
|
+id : number
|
||||||
|
+text : string
|
||||||
|
+duration : number
|
||||||
|
+type : enum
|
||||||
|
+assetUrl? : string
|
||||||
|
+chartSync? : {chartId, highlightYear?, animationStart?}
|
||||||
|
}
|
||||||
|
class AssetCollection {
|
||||||
|
+broll : Record<string,string>
|
||||||
|
+videoClips : VideoClipReference[]
|
||||||
|
+ttsAudio? : string
|
||||||
|
}
|
||||||
|
class RenderProps {
|
||||||
|
+meta : {title, currencyUnit, duration, clientName?, generatedAt?}
|
||||||
|
+charts : {assetDepletion, cashFlow}
|
||||||
|
+script : ScriptSegment[]
|
||||||
|
+assets : AssetCollection
|
||||||
|
}
|
||||||
|
RenderProps --> YearlySnapshot : "charts.assetDepletion.timeline"
|
||||||
|
RenderProps --> CashFlowData : "charts.cashFlow.timeline"
|
||||||
|
RenderProps --> ScriptSegment : "script[]"
|
||||||
|
RenderProps --> AssetCollection : "assets"
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
## 依赖与环境配置
|
||||||
|
|
||||||
|
### 1) 依赖安装
|
||||||
|
- Python 依赖:安装项目所需的 Python 包(含 dotenv、ffmpeg-python、requests、tqdm、rich 等)
|
||||||
|
- Node.js 依赖:安装前端渲染与开发工具(Remotion、React、Recharts、TypeScript 等)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
P["Python 依赖"] --> R["requirements.txt"]
|
||||||
|
N["Node.js 依赖"] --> PJ["package.json"]
|
||||||
|
R --> PY["pip install -r requirements.txt"]
|
||||||
|
PJ --> NP["npm install"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [package.json](file://package.json#L1-L43)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [package.json](file://package.json#L1-L43)
|
||||||
|
|
||||||
|
### 2) 环境配置
|
||||||
|
- 复制环境样例文件为实际配置文件,并填入对应 API 密钥
|
||||||
|
- 必需密钥:
|
||||||
|
- GEMINI_API_KEY:用于 PDF 解析与脚本生成
|
||||||
|
- OPENAI_API_KEY:用于 Whisper 音频转写
|
||||||
|
- PEXELS_API_KEY:用于 B-Roll 视频搜索与下载
|
||||||
|
- 可选配置项(财务模拟参数):
|
||||||
|
- DEFAULT_INFLATION_RATE
|
||||||
|
- DEFAULT_DEFICIT_INTEREST_RATE
|
||||||
|
- DEFAULT_INVESTMENT_RETURN
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
CP[".env.example -> .env"] --> KEYS["填写 API 密钥"]
|
||||||
|
KEYS --> RUN["运行生成流程"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [.env.example](file://.env.example#L1-L23)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [.env.example](file://.env.example#L1-L23)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L81-L131)
|
||||||
|
|
||||||
|
### 3) 为什么需要这些 API 密钥
|
||||||
|
- Gemini(Google):用于 PDF 结构化解析与自然日语脚本生成
|
||||||
|
- Whisper(OpenAI):用于将面试视频转写为带时间戳的文本,便于脚本与画面同步
|
||||||
|
- Pexels:用于按关键词搜索并下载合适的背景视频素材
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L36-L49)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L1-L69)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
|
||||||
|
## 输入文件准备
|
||||||
|
- 将 Life Plan PDF 报告与面试视频放入 assets/0_input 目录
|
||||||
|
- 系统会自动在 assets/1_processed 中生成中间文件,在 assets/2_output 中生成最终视频
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
I["assets/0_input/report.pdf"] --> M["main.py"]
|
||||||
|
V["assets/0_input/interview.mp4"] --> M
|
||||||
|
M --> O["assets/2_output/life_plan_video.mp4"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L50-L80)
|
||||||
|
- [main.py](file://main.py#L33-L84)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L50-L80)
|
||||||
|
- [main.py](file://main.py#L33-L84)
|
||||||
|
|
||||||
|
## 执行生成流程
|
||||||
|
|
||||||
|
### 基础运行(仅 PDF)
|
||||||
|
- 命令示例:参考项目快速启动说明中的基础用法
|
||||||
|
- 流程:提取 PDF → 生成脚本 → 抓取媒体 → 构建渲染参数 → 触发渲染
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L112-L131)
|
||||||
|
|
||||||
|
### 带视频输入
|
||||||
|
- 命令示例:参考项目快速启动说明中的“带面试视频”用法
|
||||||
|
- 流程:提取 PDF 与视频 → 生成脚本(结合视频转写)→ 抓取媒体 → 构建渲染参数 → 触发渲染
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L112-L131)
|
||||||
|
|
||||||
|
### 干运行模式(仅生成渲染参数)
|
||||||
|
- 命令示例:参考项目快速启动说明中的“干运行”用法
|
||||||
|
- 作用:跳过渲染阶段,仅生成 render_props.json,便于调试与验证数据结构
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L112-L131)
|
||||||
|
|
||||||
|
### CLI 参数说明
|
||||||
|
- --pdf:必填(当未提供配置文件时),指定 Life Plan 报告路径
|
||||||
|
- --video:可选,指定面试视频路径
|
||||||
|
- --config:可选,指定自定义配置 JSON 文件
|
||||||
|
- --output:可选,自定义输出文件名
|
||||||
|
- --dry-run:可选,仅生成渲染参数,不触发渲染
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [main.py](file://main.py#L228-L304)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- Python 后端模块之间存在清晰的职责分离:extractor 负责数据提取,scripter 负责脚本生成,media_fetcher 负责媒体抓取,data_builder 负责图表数据构建
|
||||||
|
- main.py 作为编排者,协调各模块并生成渲染参数
|
||||||
|
- 前端通过 render-props-types.ts 严格约束渲染参数结构,确保与后端一致
|
||||||
|
- remotion.config.ts 控制渲染图像格式与覆盖策略
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
EX["extractor.py"] --> DB["data_builder.py"]
|
||||||
|
SC["scripter.py"] --> DB
|
||||||
|
MF["media_fetcher.py"] --> DB
|
||||||
|
DB --> RP["render-props-types.ts"]
|
||||||
|
EX --> RP
|
||||||
|
SC --> RP
|
||||||
|
MF --> RP
|
||||||
|
RP --> RC["remotion.config.ts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L1-L69)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts#L1-L12)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L1-L69)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts#L1-L12)
|
||||||
|
|
||||||
|
## 性能与优化建议
|
||||||
|
- 优先使用 Gemini 直接解析 PDF,避免额外的本地 PDF 处理依赖
|
||||||
|
- Whisper 转写建议选择合适语言与采样率,减少不必要的重试与失败
|
||||||
|
- Pexels 搜索时尽量提供精准关键词,减少无效请求
|
||||||
|
- 在本地缓存翻译结果与媒体下载结果,降低重复调用成本
|
||||||
|
- 使用干运行模式先验证数据结构正确性,再进行完整渲染
|
||||||
|
|
||||||
|
[本节为通用建议,无需特定文件引用]
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- “未找到 API 密钥”
|
||||||
|
- 确认 .env 文件已创建且包含有效密钥
|
||||||
|
- 确认环境变量加载顺序与 dotenv 配置一致
|
||||||
|
- “Remotion 渲染失败”
|
||||||
|
- 确认 Node.js 依赖已安装
|
||||||
|
- 检查前端源码结构是否符合预期
|
||||||
|
- “PDF 提取失败”
|
||||||
|
- 确认 PDF 可读且未加密
|
||||||
|
- 检查 Gemini API 配额与限制
|
||||||
|
- “视频分析依赖缺失”
|
||||||
|
- 使用测试脚本检查 FFmpeg 与 OpenAI 库是否安装
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L218-L233)
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py#L1-L161)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
通过本快速入门指南,您已经了解了项目的整体架构、关键依赖与环境配置要求,并掌握了从输入准备到完整生成的全流程操作。建议在首次运行时使用干运行模式验证数据结构,随后再进行完整渲染以获得最佳体验。
|
||||||
|
|
@ -0,0 +1,462 @@
|
||||||
|
# 故障排除
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md)
|
||||||
|
- [verify_structure.py](file://verify_structure.py)
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [requirements.txt](file://requirements.txt)
|
||||||
|
- [package.json](file://package.json)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py)
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py)
|
||||||
|
- [tests/test_video_processor.py](file://tests/test_video_processor.py)
|
||||||
|
- [.env.example](file://.env.example)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能注意事项](#性能注意事项)
|
||||||
|
8. [故障排除指南](#故障排除指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录](#附录)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本指南面向使用本项目的用户与开发者,聚焦“常见问题现象—根本原因—解决方案”的闭环排查流程,并结合项目内置的结构验证脚本,帮助快速定位缺失文件、环境变量、依赖安装与渲染失败等问题。同时提供可操作的诊断步骤与修复命令,降低技术支持门槛。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
项目采用“Python 后端 + Node.js 前端(Remotion)”的混合架构,数据通过中间产物 JSON 在两端之间传递。关键目录与文件包括:
|
||||||
|
- assets/0_input:原始输入(PDF、MP4)
|
||||||
|
- assets/1_processed:中间产物(提取结果、翻译缓存、B-Roll下载等)
|
||||||
|
- assets/2_output:最终输出(生成的视频)
|
||||||
|
- backend:Python 模块(PDF/视频解析、脚本生成、媒体抓取、数据模拟)
|
||||||
|
- frontend:Remotion 前端工程(React + TypeScript + Recharts)
|
||||||
|
- 根目录配置:.env、requirements.txt、package.json、remotion.config.ts、verify_structure.py、main.py
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A["main.py<br/>主入口与流水线编排"] --> B["backend.extractor.py<br/>PDF/视频解析"]
|
||||||
|
A --> C["backend.scripter.py<br/>脚本生成"]
|
||||||
|
A --> D["backend.media_fetcher.py<br/>B-Roll抓取"]
|
||||||
|
A --> E["backend.data_builder.py<br/>财务模拟/图表数据"]
|
||||||
|
A --> F["assets/1_processed/render_props.json<br/>数据契约"]
|
||||||
|
F --> G["frontend/src/index.ts<br/>Remotion渲染入口"]
|
||||||
|
G --> H["frontend/src/components/*<br/>图表与合成组件"]
|
||||||
|
H --> I["最终视频输出<br/>assets/2_output/*.mp4"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [main.py](file://main.py#L1-L305)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L1-L69)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L50-L80)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L132-L141)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- 主入口与流水线编排:负责参数解析、目录准备、步骤调度与调用各模块、保存 render_props.json 并触发 Remotion 渲染。
|
||||||
|
- 数据提取器:封装 PDF 与视频解析接口,读取环境变量中的 API Key,抛出明确错误提示。
|
||||||
|
- 脚本生成器:根据 PDF 与可选视频内容生成自然语言脚本,同样依赖环境变量。
|
||||||
|
- 媒体抓取器:基于关键词从 Pexels 抓取 B-Roll 视频,依赖 Pexels API Key。
|
||||||
|
- 数据构建器:执行财务模拟,生成资产枯竭与现金流图表数据,供前端渲染使用。
|
||||||
|
- 结构验证脚本:校验目录与文件完整性、导入可用性、CLI 可用性,辅助迁移与初始化。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [main.py](file://main.py#L33-L301)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L14-L69)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L15-L72)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
- [verify_structure.py](file://verify_structure.py#L1-L136)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
下图展示从输入到输出的端到端流程,以及关键错误点的定位位置。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as "用户"
|
||||||
|
participant M as "main.py"
|
||||||
|
participant X as "extractor.py"
|
||||||
|
participant S as "scripter.py"
|
||||||
|
participant MF as "media_fetcher.py"
|
||||||
|
participant DB as "data_builder.py"
|
||||||
|
participant R as "Remotion渲染"
|
||||||
|
participant O as "assets/2_output"
|
||||||
|
U->>M : 运行命令传入PDF/可选视频
|
||||||
|
M->>X : 提取PDF/视频数据
|
||||||
|
X-->>M : 返回结构化数据或报错
|
||||||
|
M->>S : 生成脚本
|
||||||
|
S-->>M : 返回脚本或报错
|
||||||
|
M->>MF : 抓取B-Roll
|
||||||
|
MF-->>M : 返回资源或报错
|
||||||
|
M->>DB : 构建图表数据
|
||||||
|
DB-->>M : 返回render_props.json
|
||||||
|
M->>R : 触发渲染npx remotion render
|
||||||
|
R-->>O : 输出最终视频
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [main.py](file://main.py#L48-L225)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L23-L42)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L22-L48)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L25-L41)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### 组件A:主入口与流水线编排
|
||||||
|
- 功能要点
|
||||||
|
- 参数解析与配置合并(支持 --pdf/--video/--config/--output/--dry-run)
|
||||||
|
- 自动创建 assets 子目录
|
||||||
|
- 步骤顺序:PDF 解析 → 视频解析(可选)→ 脚本生成 → B-Roll 抓取 → 图表数据构建 → 保存 render_props.json → 触发渲染
|
||||||
|
- 渲染命令通过 npx remotion render 执行,当前处于占位状态(注释掉实际子进程调用)
|
||||||
|
- 关键错误点
|
||||||
|
- 缺少必要参数时直接报错
|
||||||
|
- 渲染阶段若未启用实际子进程调用,会显示占位信息而非真实错误
|
||||||
|
- 优化建议
|
||||||
|
- 在渲染阶段取消注释子进程调用,确保错误能被上层捕获
|
||||||
|
- 将 dry-run 与渲染阶段解耦,便于独立调试
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["开始"]) --> Parse["解析参数与配置"]
|
||||||
|
Parse --> MkDirs["创建assets子目录"]
|
||||||
|
MkDirs --> Step1["Step 1: PDF解析"]
|
||||||
|
Step1 --> Step2["Step 2: 视频解析可选"]
|
||||||
|
Step2 --> Step3["Step 3: 生成脚本"]
|
||||||
|
Step3 --> Step4["Step 4: 抓取B-Roll"]
|
||||||
|
Step4 --> Step5["Step 5: 构建图表数据"]
|
||||||
|
Step5 --> SaveProps["保存render_props.json"]
|
||||||
|
SaveProps --> Render["触发Remotion渲染"]
|
||||||
|
Render --> End(["结束"])
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [main.py](file://main.py#L48-L225)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [main.py](file://main.py#L228-L301)
|
||||||
|
|
||||||
|
### 组件B:数据提取器(PDF/视频)
|
||||||
|
- 功能要点
|
||||||
|
- PDFExtractor:从 PDF 中抽取结构化数据,需要 GEMINI_API_KEY
|
||||||
|
- VideoExtractor:对视频进行转录与时间戳抽取,需要 OPENAI_API_KEY
|
||||||
|
- 错误处理
|
||||||
|
- 若环境变量缺失,直接抛出异常,提示缺少对应 API Key
|
||||||
|
- 诊断要点
|
||||||
|
- 确认 .env 文件存在且包含有效密钥
|
||||||
|
- 确认网络可达与配额充足
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class PDFExtractor {
|
||||||
|
+__init__(api_key)
|
||||||
|
+extract(pdf_path) Dict
|
||||||
|
}
|
||||||
|
class VideoExtractor {
|
||||||
|
+__init__(api_key)
|
||||||
|
+extract(video_path) Dict
|
||||||
|
}
|
||||||
|
PDFExtractor <.. VideoExtractor : "均依赖环境变量"
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L84)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L84)
|
||||||
|
|
||||||
|
### 组件C:脚本生成器
|
||||||
|
- 功能要点
|
||||||
|
- 基于 PDF 与可选视频内容生成自然语言脚本,需要 GEMINI_API_KEY
|
||||||
|
- 错误处理
|
||||||
|
- 环境变量缺失时抛出异常
|
||||||
|
- 诊断要点
|
||||||
|
- 配置 .env 并确认 Gemini API 可用
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L14-L48)
|
||||||
|
|
||||||
|
### 组件D:媒体抓取器(B-Roll)
|
||||||
|
- 功能要点
|
||||||
|
- 依据关键词从 Pexels 搜索并下载 B-Roll,需要 PEKELS_API_KEY
|
||||||
|
- 错误处理
|
||||||
|
- 环境变量缺失时抛出异常
|
||||||
|
- 诊断要点
|
||||||
|
- 配置 .env 并确认 Pexels API 可用
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L15-L41)
|
||||||
|
|
||||||
|
### 组件E:数据构建器(财务模拟)
|
||||||
|
- 功能要点
|
||||||
|
- 使用默认通胀率、赤字利率、投资回报率等参数进行年金模拟,生成资产枯竭与现金流数据
|
||||||
|
- 错误处理
|
||||||
|
- 无显式异常处理,需在上游保证输入数据合法
|
||||||
|
- 诊断要点
|
||||||
|
- 确保 PDF 解析返回的数据字段齐全
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- Python 依赖
|
||||||
|
- dotenv:加载 .env
|
||||||
|
- ffmpeg-python、requests:媒体处理与网络请求
|
||||||
|
- tqdm、rich:进度与日志
|
||||||
|
- Node.js 依赖
|
||||||
|
- remotion、react、recharts:渲染引擎与图表
|
||||||
|
- 开发工具链:ESLint、TypeScript、Prettier
|
||||||
|
- 外部服务
|
||||||
|
- Google Gemini API(PDF 解析、脚本生成)
|
||||||
|
- OpenAI Whisper(音频转录)
|
||||||
|
- Pexels API(B-Roll)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph "Python"
|
||||||
|
P1["dotenv"]
|
||||||
|
P2["ffmpeg-python"]
|
||||||
|
P3["requests"]
|
||||||
|
P4["tqdm/rich"]
|
||||||
|
end
|
||||||
|
subgraph "Node.js"
|
||||||
|
N1["remotion"]
|
||||||
|
N2["react/recharts"]
|
||||||
|
N3["typescript/eslint/prettier"]
|
||||||
|
end
|
||||||
|
subgraph "外部服务"
|
||||||
|
E1["Gemini API"]
|
||||||
|
E2["OpenAI Whisper"]
|
||||||
|
E3["Pexels API"]
|
||||||
|
end
|
||||||
|
P1 --> E1
|
||||||
|
P2 --> E2
|
||||||
|
P3 --> E3
|
||||||
|
N1 --> E3
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [package.json](file://package.json#L1-L43)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [package.json](file://package.json#L1-L43)
|
||||||
|
|
||||||
|
## 性能注意事项
|
||||||
|
- API 调用成本与配额
|
||||||
|
- Gemini、Whisper、Pexels 均有配额限制,建议在本地或 CI 中合理控制并发与重试策略
|
||||||
|
- 渲染性能
|
||||||
|
- Remotion 渲染耗时与图表复杂度、B-Roll 视频长度相关,建议先使用 dry-run 生成数据以验证流程
|
||||||
|
- 本地依赖
|
||||||
|
- FFmpeg 安装与可用性直接影响视频转录与处理,建议在测试前单独验证
|
||||||
|
|
||||||
|
[本节为通用指导,不直接分析具体文件]
|
||||||
|
|
||||||
|
## 故障排除指南
|
||||||
|
|
||||||
|
### 一、环境变量与密钥问题
|
||||||
|
- 现象
|
||||||
|
- “API 密钥未找到”
|
||||||
|
- 根因
|
||||||
|
- .env 文件缺失或未正确填写;或运行环境中未加载 .env
|
||||||
|
- 修复步骤
|
||||||
|
- 复制示例文件并填写密钥
|
||||||
|
- 复制命令参考:[PROJECT_README.md](file://PROJECT_README.md#L99-L111)
|
||||||
|
- 示例文件路径:[.env.example](file://.env.example#L1-L23)
|
||||||
|
- 确认密钥类型与来源
|
||||||
|
- Gemini:用于 PDF 解析与脚本生成
|
||||||
|
- OpenAI:用于 Whisper 转录
|
||||||
|
- Pexels:用于 B-Roll 下载
|
||||||
|
- 验证方法
|
||||||
|
- 运行结构验证脚本,检查后端模块导入是否成功
|
||||||
|
- 参考:[verify_structure.py](file://verify_structure.py#L75-L97)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L97-L111)
|
||||||
|
- [.env.example](file://.env.example#L1-L23)
|
||||||
|
- [verify_structure.py](file://verify_structure.py#L75-L97)
|
||||||
|
|
||||||
|
### 二、PDF 提取失败
|
||||||
|
- 现象
|
||||||
|
- PDF 解析阶段报错或返回空数据
|
||||||
|
- 根因
|
||||||
|
- PDF 不可读或受密码保护;Gemini API 额度不足或网络异常
|
||||||
|
- 修复步骤
|
||||||
|
- 确保输入 PDF 可打开且非加密
|
||||||
|
- 检查 .env 中 GEMINI_API_KEY 是否正确
|
||||||
|
- 如为首次使用,确认 API 已激活并有足够配额
|
||||||
|
- 验证方法
|
||||||
|
- 使用 verify_structure.py 的导入测试,确认 extractor 模块可用
|
||||||
|
- 参考:[verify_structure.py](file://verify_structure.py#L75-L97)
|
||||||
|
- 查看 extractor 抛出的异常信息(缺少密钥时会直接报错)
|
||||||
|
- 参考:[backend/extractor.py](file://backend/extractor.py#L18-L22)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L228-L231)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L18-L22)
|
||||||
|
- [verify_structure.py](file://verify_structure.py#L75-L97)
|
||||||
|
|
||||||
|
### 三、视频转录失败
|
||||||
|
- 现象
|
||||||
|
- 视频解析阶段报错或无法生成时间戳
|
||||||
|
- 根因
|
||||||
|
- 缺少 OPENAI_API_KEY;FFmpeg 未安装或不可用;视频格式不受支持
|
||||||
|
- 修复步骤
|
||||||
|
- 安装 FFmpeg 并确保在 PATH 中
|
||||||
|
- 参考:[tests/test_video_cli.py](file://tests/test_video_cli.py#L71-L80)
|
||||||
|
- 确认 .env 中 OPENAI_API_KEY 已设置
|
||||||
|
- 使用独立测试工具检查依赖
|
||||||
|
- 参考:[tests/test_video_cli.py](file://tests/test_video_cli.py#L68-L91)
|
||||||
|
- 验证方法
|
||||||
|
- 运行单元测试(需 OPENAI_API_KEY),验证音频优先策略
|
||||||
|
- 参考:[tests/test_video_processor.py](file://tests/test_video_processor.py#L113-L140)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py#L68-L91)
|
||||||
|
- [tests/test_video_processor.py](file://tests/test_video_processor.py#L113-L140)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L48-L51)
|
||||||
|
|
||||||
|
### 四、B-Roll 抓取失败
|
||||||
|
- 现象
|
||||||
|
- B-Roll 获取阶段报错或返回空结果
|
||||||
|
- 根因
|
||||||
|
- 缺少 PEKELS_API_KEY;网络异常;关键词为空
|
||||||
|
- 修复步骤
|
||||||
|
- 确认 .env 中 PEKELS_API_KEY 已设置
|
||||||
|
- 检查网络连通性与 API 配额
|
||||||
|
- 验证方法
|
||||||
|
- 运行结构验证脚本,确认 media_fetcher 模块可用
|
||||||
|
- 参考:[verify_structure.py](file://verify_structure.py#L75-L97)
|
||||||
|
- 查看 media_fetcher 抛出的异常信息(缺少密钥时会直接报错)
|
||||||
|
- 参考:[backend/media_fetcher.py](file://backend/media_fetcher.py#L18-L21)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L228-L231)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L18-L21)
|
||||||
|
- [verify_structure.py](file://verify_structure.py#L75-L97)
|
||||||
|
|
||||||
|
### 五、Remotion 渲染失败
|
||||||
|
- 现象
|
||||||
|
- 渲染阶段报错或无输出
|
||||||
|
- 根因
|
||||||
|
- Node.js 依赖未安装;Remotion 配置不正确;前端组件缺失;渲染命令未启用
|
||||||
|
- 修复步骤
|
||||||
|
- 安装 Node.js 依赖
|
||||||
|
- 参考:[PROJECT_README.md](file://PROJECT_README.md#L91-L95)
|
||||||
|
- 依赖清单:[package.json](file://package.json#L1-L43)
|
||||||
|
- 确认 frontend/src 目录结构与组件注册正确
|
||||||
|
- 参考:[PROJECT_README.md](file://PROJECT_README.md#L50-L80)
|
||||||
|
- 检查 remotion.config.ts 是否存在
|
||||||
|
- 参考:[PROJECT_README.md](file://PROJECT_README.md#L56-L58)
|
||||||
|
- 在 main.py 中启用渲染子进程调用(当前为占位)
|
||||||
|
- 参考:[main.py](file://main.py#L217-L225)
|
||||||
|
- 验证方法
|
||||||
|
- 使用 verify_structure.py 的 CLI 测试,确认 main.py --help 可用
|
||||||
|
- 参考:[verify_structure.py](file://verify_structure.py#L98-L121)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L91-L95)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L50-L80)
|
||||||
|
- [package.json](file://package.json#L1-L43)
|
||||||
|
- [main.py](file://main.py#L217-L225)
|
||||||
|
- [verify_structure.py](file://verify_structure.py#L98-L121)
|
||||||
|
|
||||||
|
### 六、项目目录结构完整性检查
|
||||||
|
- 现象
|
||||||
|
- 运行后提示某些文件缺失或导入失败
|
||||||
|
- 根因
|
||||||
|
- 迁移或克隆后遗漏关键目录/文件
|
||||||
|
- 修复步骤
|
||||||
|
- 使用结构验证脚本一次性检查所有必需项
|
||||||
|
- 参考:[verify_structure.py](file://verify_structure.py#L12-L74)
|
||||||
|
- 若缺失,请对照项目结构补充
|
||||||
|
- 参考:[PROJECT_README.md](file://PROJECT_README.md#L50-L80)
|
||||||
|
- 验证方法
|
||||||
|
- 运行导入测试,确认 backend 包与子模块均可导入
|
||||||
|
- 参考:[verify_structure.py](file://verify_structure.py#L75-L97)
|
||||||
|
- 运行 CLI 测试,确认 main.py --help 可用
|
||||||
|
- 参考:[verify_structure.py](file://verify_structure.py#L98-L121)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [verify_structure.py](file://verify_structure.py#L12-L74)
|
||||||
|
- [verify_structure.py](file://verify_structure.py#L75-L97)
|
||||||
|
- [verify_structure.py](file://verify_structure.py#L98-L121)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L50-L80)
|
||||||
|
|
||||||
|
### 七、Dry Run 与数据验证
|
||||||
|
- 现象
|
||||||
|
- 希望跳过渲染阶段,仅生成数据
|
||||||
|
- 根因
|
||||||
|
- 未使用 --dry-run 参数
|
||||||
|
- 修复步骤
|
||||||
|
- 使用 --dry-run 生成 render_props.json,随后手动检查
|
||||||
|
- 参考:[PROJECT_README.md](file://PROJECT_README.md#L126-L130)
|
||||||
|
- 验证方法
|
||||||
|
- 检查 assets/1_processed/render_props.json 是否生成
|
||||||
|
- 参考:[main.py](file://main.py#L72-L77)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L126-L130)
|
||||||
|
- [main.py](file://main.py#L72-L77)
|
||||||
|
|
||||||
|
### 八、Python 依赖缺失
|
||||||
|
- 现象
|
||||||
|
- 导入 backend 模块时报错或功能受限
|
||||||
|
- 根因
|
||||||
|
- requirements.txt 未安装或版本不匹配
|
||||||
|
- 修复步骤
|
||||||
|
- 安装 Python 依赖
|
||||||
|
- 参考:[PROJECT_README.md](file://PROJECT_README.md#L87-L90)
|
||||||
|
- 依赖清单:[requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- 验证方法
|
||||||
|
- 运行 verify_structure.py 的导入测试
|
||||||
|
- 参考:[verify_structure.py](file://verify_structure.py#L75-L97)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L87-L90)
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [verify_structure.py](file://verify_structure.py#L75-L97)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
本指南围绕“环境变量、API 密钥、依赖安装、数据提取、脚本生成、B-Roll 抓取、财务模拟、Remotion 渲染”八个关键环节,提供了系统化的诊断与修复路径。配合 verify_structure.py 的结构检查与 dry-run 数据验证,可显著降低问题定位成本,提升整体使用体验。
|
||||||
|
|
||||||
|
[本节为总结性内容,不直接分析具体文件]
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### A. 常见命令速查
|
||||||
|
- 安装 Python 依赖
|
||||||
|
- 参考:[PROJECT_README.md](file://PROJECT_README.md#L87-L90)
|
||||||
|
- 安装 Node.js 依赖
|
||||||
|
- 参考:[PROJECT_README.md](file://PROJECT_README.md#L91-L95)
|
||||||
|
- 运行结构验证
|
||||||
|
- 参考:[verify_structure.py](file://verify_structure.py#L128-L136)
|
||||||
|
- 生成数据(跳过渲染)
|
||||||
|
- 参考:[PROJECT_README.md](file://PROJECT_README.md#L126-L130)
|
||||||
|
- 检查 FFmpeg 依赖
|
||||||
|
- 参考:[tests/test_video_cli.py](file://tests/test_video_cli.py#L71-L80)
|
||||||
|
|
||||||
|
### B. 关键文件清单
|
||||||
|
- 环境变量模板:[.env.example](file://.env.example#L1-L23)
|
||||||
|
- Python 依赖清单:[requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- Node.js 依赖清单:[package.json](file://package.json#L1-L43)
|
||||||
|
- 主入口与流水线:[main.py](file://main.py#L1-L305)
|
||||||
|
- 结构验证脚本:[verify_structure.py](file://verify_structure.py#L1-L136)
|
||||||
|
- 数据提取器:[backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- 脚本生成器:[backend/scripter.py](file://backend/scripter.py#L1-L69)
|
||||||
|
- 媒体抓取器:[backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
- 数据构建器:[backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
- 视频处理测试工具:[tests/test_video_cli.py](file://tests/test_video_cli.py#L1-L161)
|
||||||
|
- 视频处理单元测试:[tests/test_video_processor.py](file://tests/test_video_processor.py#L1-L143)
|
||||||
|
|
@ -0,0 +1,378 @@
|
||||||
|
# 数据模型与契约
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [types.ts](file://src/types.ts)
|
||||||
|
- [data_builder.py](file://backend/video_processor/data_builder.py)
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx)
|
||||||
|
- [mock-data.json](file://src/mock-data.json)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [引言](#引言)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能考量](#性能考量)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录](#附录)
|
||||||
|
|
||||||
|
## 引言
|
||||||
|
本文件聚焦于“核心数据模型与系统间数据契约”,围绕 render-props-types.ts 中定义的 RenderPropsSchema 设计进行深入解析。该契约是 Python 后端与 Remotion 前端之间的唯一通信桥梁:后端生成 render_props.json,前端通过 Zod 运行时校验确保数据完整性,再驱动可视化组件渲染视频内容。本文将逐层拆解 YearlySnapshot、CashFlowData、ScriptSegment、AssetCollection 等关键数据结构的字段语义、类型约束与业务逻辑;阐明 Zod 如何在运行时保障数据一致性;并通过示例文件展示 render_props.json 的实际形态,帮助初学者理解数据契约的重要性与最佳实践。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
本项目采用前后端分离的模块化组织:
|
||||||
|
- 前端(Remotion)位于 src 与 frontend/src,负责渲染与可视化;
|
||||||
|
- 后端(Python)位于 backend,负责数据模拟与 render_props.json 生成;
|
||||||
|
- 契约文件 render-props-types.ts 定义了 TypeScript 接口与 Zod Schema,确保前后端对同一份数据结构达成一致。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "后端"
|
||||||
|
PY_MAIN["main.py<br/>构建与保存 render_props.json"]
|
||||||
|
PY_DB["backend/video_processor/data_builder.py<br/>财务模拟与图表数据生成"]
|
||||||
|
end
|
||||||
|
subgraph "前端"
|
||||||
|
TS_TYPES["frontend/src/render-props-types.ts<br/>RenderPropsSchema 定义与运行时校验"]
|
||||||
|
REACT_LPC["src/LifePlanChart.tsx<br/>资产枯竭曲线图"]
|
||||||
|
REACT_CFC["src/CashFlowChart.tsx<br/>现金流分解图"]
|
||||||
|
TYPES_TS["src/types.ts<br/>YearlySnapshot 等类型"]
|
||||||
|
end
|
||||||
|
PY_MAIN --> PY_DB
|
||||||
|
PY_DB --> PY_MAIN
|
||||||
|
PY_MAIN --> TS_TYPES
|
||||||
|
TS_TYPES --> REACT_LPC
|
||||||
|
TS_TYPES --> REACT_CFC
|
||||||
|
TYPES_TS --> REACT_LPC
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [main.py](file://main.py#L145-L199)
|
||||||
|
- [data_builder.py](file://backend/video_processor/data_builder.py#L150-L200)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L117-L231)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [main.py](file://main.py#L145-L199)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L117-L231)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
本节从“数据契约”的角度,系统梳理 RenderProps 及其子结构,明确字段含义、类型与业务约束,并说明 Zod Schema 的作用。
|
||||||
|
|
||||||
|
- RenderProps(顶层契约)
|
||||||
|
- meta:视频元信息,包含标题、货币单位、总时长、可选客户名与生成时间戳
|
||||||
|
- charts:两类图表数据
|
||||||
|
- assetDepletion:资产枯竭曲线的时间序列与元信息(货币单位、起始年份)
|
||||||
|
- cashFlow:现金流分解的时间序列与元信息(货币单位)
|
||||||
|
- script:脚本段落数组,描述旁白、图表介绍/高亮、视频片段、转场等
|
||||||
|
- assets:媒体资源集合,包含 B-Roll 映射、视频片段引用、可选 TTS 音频路径
|
||||||
|
|
||||||
|
- YearlySnapshot(年度快照)
|
||||||
|
- year:年份
|
||||||
|
- ages:代表者年龄与可选配偶年龄
|
||||||
|
- financials:收入、支出、资产余额
|
||||||
|
- events:当年事件标签列表
|
||||||
|
- isCritical:资产余额是否为负(标记风险临界点)
|
||||||
|
|
||||||
|
- CashFlowData(年度现金流)
|
||||||
|
- year、age:年份与年龄
|
||||||
|
- income.total:年度总收入
|
||||||
|
- expenses:生活、住房、教育、税金、事件等细项
|
||||||
|
- totalExpense、netCashFlow、assetBalance:派生或补充字段(由后端计算)
|
||||||
|
|
||||||
|
- ScriptSegment(脚本段落)
|
||||||
|
- id、text、duration:段落标识、日文旁白文本、时长(秒)
|
||||||
|
- type:段落类型枚举(旁白、图表介绍、图表高亮、视频片段、转场)
|
||||||
|
- assetUrl:B-Roll 或视频片段 URL/路径
|
||||||
|
- chartSync:可选的图表同步参数(图表 ID、高亮年份、动画起始帧)
|
||||||
|
|
||||||
|
- AssetCollection(媒体资源集合)
|
||||||
|
- broll:关键词到视频 URL/路径的映射
|
||||||
|
- videoClips:视频片段引用数组(id、源路径、起止时间、输出路径)
|
||||||
|
- ttsAudio:可选的 TTS 语音文件路径
|
||||||
|
|
||||||
|
- Zod Schema(运行时校验)
|
||||||
|
- YearlySnapshotSchema、CashFlowDataSchema、ScriptSegmentSchema、RenderPropsSchema
|
||||||
|
- 提供 parse/safeParse 两种校验方式,前者失败抛错,后者返回布尔判断
|
||||||
|
- validateRenderProps/isRenderProps 两个辅助函数,分别用于强制校验与类型守卫
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L18-L231)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
RenderPropsSchema 作为 Python 后端与 Remotion 前端之间的“数据契约”,贯穿以下流程:
|
||||||
|
- 后端根据 PDF 输入与财务模拟器生成 render_props.json
|
||||||
|
- 前端读取 render_props.json,使用 Zod Schema 进行运行时校验
|
||||||
|
- 校验通过后,前端组件(如 LifePlanChart、CashFlowChart)消费数据并渲染视频
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Backend as "Python 后端"
|
||||||
|
participant Builder as "data_builder.py"
|
||||||
|
participant Main as "main.py"
|
||||||
|
participant Frontend as "Remotion 前端"
|
||||||
|
participant Schema as "render-props-types.ts"
|
||||||
|
participant Charts as "LifePlanChart/CashFlowChart"
|
||||||
|
Backend->>Builder : "输入PDF数据"
|
||||||
|
Builder->>Builder : "财务模拟与图表数据生成"
|
||||||
|
Builder-->>Main : "返回 charts 财务数据"
|
||||||
|
Main->>Main : "拼装 meta/script/assets"
|
||||||
|
Main-->>Frontend : "render_props.json"
|
||||||
|
Frontend->>Schema : "validateRenderProps/isRenderProps"
|
||||||
|
Schema-->>Frontend : "校验结果"
|
||||||
|
Frontend->>Charts : "传入 RenderProps 渲染"
|
||||||
|
Charts-->>Frontend : "输出视频帧"
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [data_builder.py](file://backend/video_processor/data_builder.py#L150-L200)
|
||||||
|
- [main.py](file://main.py#L145-L199)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L194-L250)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### RenderPropsSchema 设计与运行时校验
|
||||||
|
- 设计要点
|
||||||
|
- 严格嵌套结构:meta、charts、script、assets 四大块,每块内部字段一一对应
|
||||||
|
- 类型精确:数值、字符串、枚举、可选字段均有明确约束
|
||||||
|
- 元信息与数据一致性:charts.meta 携带货币单位与起始年份,保证图表显示一致
|
||||||
|
- 运行时校验
|
||||||
|
- parse:失败即抛异常,适合强约束场景
|
||||||
|
- safeParse:返回成功/失败,便于条件分支处理
|
||||||
|
- 辅助函数:validateRenderProps 与 isRenderProps 提供统一入口
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["开始"]) --> Load["读取 render_props.json"]
|
||||||
|
Load --> Parse["调用 RenderPropsSchema.parse/safeParse"]
|
||||||
|
Parse --> Valid{"校验通过?"}
|
||||||
|
Valid --> |否| Throw["抛出错误/返回失败"]
|
||||||
|
Valid --> |是| Use["前端组件消费数据"]
|
||||||
|
Use --> End(["结束"])
|
||||||
|
Throw --> End
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L237-L250)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L194-L250)
|
||||||
|
|
||||||
|
### YearlySnapshot 与 CashFlowData 的字段语义与业务逻辑
|
||||||
|
- YearlySnapshot
|
||||||
|
- 年度视角的财务快照,支持代表者与配偶双人年龄,便于双人财务规划可视化
|
||||||
|
- financials.income/expenditure/assetBalance 三元组构成资产枯竭曲线的核心
|
||||||
|
- events 与 isCritical 用于标注重大事件与风险节点
|
||||||
|
- CashFlowData
|
||||||
|
- 将 YearlySnapshot 转换为更利于“收入-支出”分解展示的数据形式
|
||||||
|
- expenses 细分五类,便于堆叠柱状图呈现
|
||||||
|
- totalExpense、netCashFlow、assetBalance 为派生字段,便于 HUD 展示与动画控制
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class YearlySnapshot {
|
||||||
|
+number year
|
||||||
|
+ages
|
||||||
|
+financials
|
||||||
|
+string[] events?
|
||||||
|
+boolean isCritical?
|
||||||
|
}
|
||||||
|
class CashFlowData {
|
||||||
|
+number year
|
||||||
|
+number age
|
||||||
|
+income
|
||||||
|
+expenses
|
||||||
|
+number totalExpense?
|
||||||
|
+number netCashFlow?
|
||||||
|
+number assetBalance?
|
||||||
|
}
|
||||||
|
class incomes {
|
||||||
|
+number total
|
||||||
|
}
|
||||||
|
class expenses {
|
||||||
|
+number living
|
||||||
|
+number housing
|
||||||
|
+number education
|
||||||
|
+number tax
|
||||||
|
+number event
|
||||||
|
}
|
||||||
|
YearlySnapshot --> incomes : "financials.income"
|
||||||
|
YearlySnapshot --> expenses : "financials.expenditure"
|
||||||
|
CashFlowData --> incomes : "income"
|
||||||
|
CashFlowData --> expenses : "expenses"
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L18-L49)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L18)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L18-L49)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L18)
|
||||||
|
|
||||||
|
### ScriptSegment 与 AssetCollection 的脚本与媒体资源
|
||||||
|
- ScriptSegment
|
||||||
|
- 类型枚举覆盖旁白、图表介绍/高亮、视频片段、转场等场景
|
||||||
|
- chartSync 支持与图表联动,实现高亮年份与动画起始帧的同步
|
||||||
|
- AssetCollection
|
||||||
|
- broll 关键词到视频资源的映射,便于按主题检索
|
||||||
|
- videoClips 记录剪辑片段的来源与裁剪参数,便于复用与导出
|
||||||
|
- ttsAudio 为可选的人工合成语音路径
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class ScriptSegment {
|
||||||
|
+number id
|
||||||
|
+string text
|
||||||
|
+number duration
|
||||||
|
+string type
|
||||||
|
+string assetUrl?
|
||||||
|
+chartSync?
|
||||||
|
}
|
||||||
|
class AssetCollection {
|
||||||
|
+Record~string,string~ broll
|
||||||
|
+VideoClipReference[] videoClips
|
||||||
|
+string ttsAudio?
|
||||||
|
}
|
||||||
|
class VideoClipReference {
|
||||||
|
+string id
|
||||||
|
+string sourcePath
|
||||||
|
+number startTime
|
||||||
|
+number duration
|
||||||
|
+string outputPath?
|
||||||
|
}
|
||||||
|
ScriptSegment --> AssetCollection : "引用媒体资源"
|
||||||
|
AssetCollection --> VideoClipReference : "包含"
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L74-L111)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L99-L111)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L74-L111)
|
||||||
|
|
||||||
|
### 前端消费与可视化组件
|
||||||
|
- LifePlanChart(资产枯竭曲线)
|
||||||
|
- 使用 YearlySnapshot 渲染面积图,支持插值与渐变色(正负资产区域)
|
||||||
|
- HUD 展示当前年份、资产余额、收支差额
|
||||||
|
- CashFlowChart(现金流分解)
|
||||||
|
- 使用 CashFlowData 渲染堆叠柱状图与收入线,支持分段顺序增长动画
|
||||||
|
- HUD 展示当前年份、收入、总支出、净现金流
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Frontend as "Remotion 前端"
|
||||||
|
participant Schema as "RenderPropsSchema"
|
||||||
|
participant LPC as "LifePlanChart"
|
||||||
|
participant CFC as "CashFlowChart"
|
||||||
|
Frontend->>Schema : "校验 render_props.json"
|
||||||
|
Schema-->>Frontend : "校验通过"
|
||||||
|
Frontend->>LPC : "传入 assetDepletion 数据"
|
||||||
|
Frontend->>CFC : "传入 cashFlow 数据"
|
||||||
|
LPC-->>Frontend : "渲染资产曲线帧"
|
||||||
|
CFC-->>Frontend : "渲染现金流帧"
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L194-L231)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- 后端到前端的数据流
|
||||||
|
- main.py 负责组装 render_props.json 并保存
|
||||||
|
- data_builder.py 生成 charts 数据(资产枯竭与现金流)
|
||||||
|
- 前端 render-props-types.ts 以 Zod Schema 对齐数据结构
|
||||||
|
- 前端组件对数据的依赖
|
||||||
|
- LifePlanChart 依赖 YearlySnapshot 类型
|
||||||
|
- CashFlowChart 依赖 CashFlowData 类型
|
||||||
|
- 两者均依赖 RenderPropsSchema 的校验结果
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
PY["main.py"] --> DB["data_builder.py"]
|
||||||
|
DB --> JSON["render_props.json"]
|
||||||
|
JSON --> TS["render-props-types.ts"]
|
||||||
|
TS --> LPC["LifePlanChart.tsx"]
|
||||||
|
TS --> CFC["CashFlowChart.tsx"]
|
||||||
|
TYPES["types.ts"] --> LPC
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [main.py](file://main.py#L145-L199)
|
||||||
|
- [data_builder.py](file://backend/video_processor/data_builder.py#L150-L200)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L117-L231)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [main.py](file://main.py#L145-L199)
|
||||||
|
- [data_builder.py](file://backend/video_processor/data_builder.py#L150-L200)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L117-L231)
|
||||||
|
|
||||||
|
## 性能考量
|
||||||
|
- 数据规模与渲染开销
|
||||||
|
- 大时间序列(如多代人、长周期)会增加 Recharts 渲染压力
|
||||||
|
- 建议在后端进行数据采样或聚合,减少前端渲染负担
|
||||||
|
- 动画与插值
|
||||||
|
- 插值与顺序增长动画需谨慎使用,避免在长序列上产生过多帧级计算
|
||||||
|
- 校验成本
|
||||||
|
- Zod 校验在开发阶段建议开启,生产环境可考虑缓存校验结果或仅在首帧执行
|
||||||
|
|
||||||
|
[本节为通用指导,不直接分析具体文件]
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- 校验失败
|
||||||
|
- 现象:validateRenderProps 抛错;isRenderProps 返回 false
|
||||||
|
- 排查:检查字段类型(数字/字符串)、可选字段是否遗漏、枚举值是否正确
|
||||||
|
- 参考:Zod Schema 定义与辅助函数
|
||||||
|
- 数据不一致
|
||||||
|
- 现象:图表显示货币单位不一致、起始年份错位
|
||||||
|
- 排查:确认 charts.meta.currencyUnit 与 charts.meta.startYear 是否与 timeline 对齐
|
||||||
|
- 脚本与媒体资源不匹配
|
||||||
|
- 现象:ScriptSegment.chartSync 未生效或 assetUrl 无法加载
|
||||||
|
- 排查:确认 chartId、highlightYear、animationStart 与 assets.broll/videoClips 的键值一致
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L194-L250)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
RenderPropsSchema 通过严格的类型与运行时校验,将 Python 后端的 render_props.json 与 Remotion 前端的可视化组件紧密耦合,形成稳定、可演进的数据契约。YearlySnapshot 与 CashFlowData 的清晰边界,配合 ScriptSegment 与 AssetCollection 的脚本与媒体资源管理,使得系统在保持前后端松耦合的同时,具备良好的扩展性与可维护性。建议在后续迭代中持续对齐契约版本,确保前后端同步更新。
|
||||||
|
|
||||||
|
[本节为总结性内容,不直接分析具体文件]
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### render_props.json 实例与字段对照
|
||||||
|
- 示例文件
|
||||||
|
- mock-data.json:资产枯竭曲线的 timeline 与 meta
|
||||||
|
- mock-cashflow.json:现金流分解的 timeline 与 meta
|
||||||
|
- 字段对照
|
||||||
|
- meta.title、meta.currencyUnit、meta.duration、meta.clientName、meta.generatedAt
|
||||||
|
- charts.assetDepletion.timeline、charts.assetDepletion.meta.currencyUnit、charts.assetDepletion.meta.startYear
|
||||||
|
- charts.cashFlow.timeline、charts.cashFlow.meta.currencyUnit
|
||||||
|
- script[].id、script[].text、script[].duration、script[].type、script[].assetUrl、script[].chartSync
|
||||||
|
- assets.broll、assets.videoClips[]、assets.ttsAudio
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json#L1-L800)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L117-L231)
|
||||||
|
|
@ -0,0 +1,304 @@
|
||||||
|
# AI生成
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件**
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md)
|
||||||
|
- [requirements.txt](file://requirements.txt)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py)
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py)
|
||||||
|
- [tests/test_video_processor.py](file://tests/test_video_processor.py)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能考量](#性能考量)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录](#附录)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本项目是一个“自动人生规划视频生成器”,通过将PDF版人生规划报告与采访视频,结合AI生成的自然日语旁白、图表动画与B-Roll素材,最终合成3-5分钟的个性化解释性视频。其中,scripter.py模块负责使用Google Gemini Pro API将结构化的财务数据转化为自然流畅的日语旁白脚本。本文围绕该模块展开,详细说明提示词工程设计(如何整合客户数据、财务预测与视频节奏要求)、在主流程中的位置与输入输出格式、以及面向初学者的流程图与面向高级开发者的实现细节与优化建议。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
- 后端Python模块位于 backend/,包含:
|
||||||
|
- scripter.py:脚本生成器(当前为占位实现)
|
||||||
|
- extractor.py:PDF与视频抽取器(当前为占位实现)
|
||||||
|
- data_builder.py:财务模拟与图表数据构建
|
||||||
|
- media_fetcher.py:B-Roll素材抓取(当前为占位实现)
|
||||||
|
- video_processor/:视频处理工具与数据结构(供未来集成)
|
||||||
|
- 前端Remotion渲染引擎位于 frontend/,通过 render_props.json 与后端对接
|
||||||
|
- main.py 作为入口,编排整个流水线
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A["main.py<br/>入口与流水线编排"] --> B["backend/scripter.py<br/>脚本生成器"]
|
||||||
|
A --> C["backend/extractor.py<br/>PDF/视频抽取器"]
|
||||||
|
A --> D["backend/data_builder.py<br/>财务模拟与图表数据"]
|
||||||
|
A --> E["backend/media_fetcher.py<br/>B-Roll素材抓取"]
|
||||||
|
A --> F["frontend/<br/>Remotion渲染引擎"]
|
||||||
|
G["backend/video_processor/schemas.py<br/>视频处理数据结构"] -. 可选集成 .-> A
|
||||||
|
H["backend/video_processor/utils.py<br/>FFmpeg工具集"] -. 可选集成 .-> A
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [main.py](file://main.py#L48-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L14-L69)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L84)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L15-L72)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L12-L91)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L13-L164)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [main.py](file://main.py#L48-L84)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L132-L141)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- 脚本生成器(ScriptGenerator):封装Gemini Pro调用、提示词构建、响应解析与时长估计
|
||||||
|
- PDF/视频抽取器(PDFExtractor/VideoExtractor):从PDF与视频中提取结构化数据与时间戳
|
||||||
|
- 财务模拟器(FinancialSimulator):基于参数模拟资产枯竭与现金流,生成图表所需数据
|
||||||
|
- B-Roll素材抓取器(MediaFetcher):根据关键词检索并下载B-Roll素材
|
||||||
|
- 视频处理工具(VideoProcessor):提供视频分析与切片等能力(可选)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L14-L69)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L84)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L15-L115)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L15-L72)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L12-L91)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L13-L164)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
脚本生成在整体流水线中的位置如下:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as "用户"
|
||||||
|
participant M as "main.py<br/>VideoGeneratorPipeline"
|
||||||
|
participant X as "extractor.py<br/>PDF/视频抽取"
|
||||||
|
participant S as "scripter.py<br/>ScriptGenerator"
|
||||||
|
participant D as "data_builder.py<br/>财务模拟"
|
||||||
|
participant R as "Remotion<br/>前端渲染"
|
||||||
|
U->>M : 运行命令行
|
||||||
|
M->>X : 提取PDF/视频数据
|
||||||
|
X-->>M : 返回结构化数据
|
||||||
|
M->>S : 生成旁白脚本
|
||||||
|
S-->>M : 返回脚本片段与同步点
|
||||||
|
M->>D : 构建图表数据
|
||||||
|
D-->>M : 返回资产/现金流数据
|
||||||
|
M->>R : 写入render_props.json并触发渲染
|
||||||
|
R-->>U : 输出MP4视频
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [main.py](file://main.py#L48-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L22-L49)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [main.py](file://main.py#L48-L84)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L132-L141)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### ScriptGenerator(脚本生成器)
|
||||||
|
- 职责
|
||||||
|
- 初始化:从环境变量读取API密钥,校验可用性
|
||||||
|
- generate:接收PDF数据与可选视频数据,返回包含分段、总时长与同步点的结构化脚本
|
||||||
|
- _build_prompt:构建提示词(当前为占位),应整合客户信息、财务预测、风格约束与输出格式要求
|
||||||
|
- 输入输出
|
||||||
|
- 输入:pdf_data(结构化财务数据)、video_data(可选,含转录与时间戳)、style(友好/专业/随意)
|
||||||
|
- 输出:包含segments(脚本文本、时长、类型)、total_duration、sync_points
|
||||||
|
- 设计要点
|
||||||
|
- 风格控制:通过style参数与提示词工程控制语气与专业度
|
||||||
|
- 结构化输出:要求模型以JSON格式返回segments,便于后续解析与时长估算
|
||||||
|
- 多模态融合:将视频转录的时间戳与脚本同步点对齐,提升节奏一致性
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class ScriptGenerator {
|
||||||
|
+api_key : str
|
||||||
|
+__init__(api_key)
|
||||||
|
+generate(pdf_data, video_data, style) Dict
|
||||||
|
-_build_prompt(pdf_data, style) str
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L14-L69)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L14-L69)
|
||||||
|
|
||||||
|
### 提示词工程设计(Prompt Engineering)
|
||||||
|
- 数据整合
|
||||||
|
- 客户数据:姓名、年龄、退休年龄、寿命预期、资产、收入、支出等
|
||||||
|
- 财务预测:来自data_builder.py的资产枯竭与现金流数据,用于生成“现状”“趋势”“风险预警”等段落
|
||||||
|
- 视频节奏:来自extractor.py的视频转录与时间戳,用于生成“配合画面”的过渡与强调段落
|
||||||
|
- 风格与语气
|
||||||
|
- friendly:自然、温和、易懂
|
||||||
|
- professional:严谨、准确、适合正式场合
|
||||||
|
- casual:轻松、口语化、贴近日常
|
||||||
|
- 输出约束
|
||||||
|
- JSON格式,包含segments数组,每段含id、text、duration、type
|
||||||
|
- 总时长控制在3-5分钟范围内
|
||||||
|
- 同步点
|
||||||
|
- 在脚本中标注chart/intro/transition等类型,便于后续与图表动画与B-Roll素材对齐
|
||||||
|
|
||||||
|
提示词模板设计要点(概念性说明,不对应具体代码):
|
||||||
|
- 角色设定:你是专业的财务顾问,用日语向客户讲解其人生规划
|
||||||
|
- 上下文:提供客户画像与财务预测摘要
|
||||||
|
- 任务:生成一段自然流畅的旁白,包含引言、现状分析、趋势解读、风险提示与建议
|
||||||
|
- 风格:根据style参数调整语气
|
||||||
|
- 输出:严格遵循JSON结构,包含分段文本与时长
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L22-L49)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L23-L41)
|
||||||
|
|
||||||
|
### main.py 中的 generate_script 方法
|
||||||
|
- 位置:VideoGeneratorPipeline.generate_script
|
||||||
|
- 功能:调用后端脚本生成模块,返回结构化脚本
|
||||||
|
- 当前实现:占位返回mock脚本,包含segments列表
|
||||||
|
- 实际实现建议:调用ScriptGenerator.generate,传入pdf_data与video_data,解析返回的JSON并估算每段时长
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["进入 generate_script"]) --> CheckArgs["检查输入参数<br/>pdf_data, video_data"]
|
||||||
|
CheckArgs --> CallGen["调用 ScriptGenerator.generate(...)"]
|
||||||
|
CallGen --> ParseResp["解析返回的JSON结构<br/>segments, total_duration, sync_points"]
|
||||||
|
ParseResp --> EstimateDur["估算每段时长<br/>按目标时长比例分配"]
|
||||||
|
EstimateDur --> ReturnScript["返回结构化脚本"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [main.py](file://main.py#L115-L134)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L22-L49)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [main.py](file://main.py#L115-L134)
|
||||||
|
|
||||||
|
### 财务数据与脚本的衔接
|
||||||
|
- data_builder.py 模块根据pdf_data生成资产枯竭与现金流数据,供前端图表使用
|
||||||
|
- scripter.py 应在提示词中引用这些数据摘要,使脚本内容与图表一致
|
||||||
|
- 通过style参数与提示词工程,确保脚本语气与数据呈现相匹配
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L22-L49)
|
||||||
|
|
||||||
|
### B-Roll素材与脚本的协同
|
||||||
|
- media_fetcher.py 的fetch_broll函数可根据脚本关键词检索B-Roll素材
|
||||||
|
- 关键词提取:从segments.text中抽取主题词,如“資産”“将来”“リスク”等
|
||||||
|
- 同步:将脚本的sync_points与B-Roll片段起止时间对齐,提升观看体验
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L15-L72)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L22-L49)
|
||||||
|
|
||||||
|
### 视频处理(可选集成)
|
||||||
|
- schemas.py 定义了VideoSegment、KeyMoment、VideoAnalysisResult等数据结构
|
||||||
|
- utils.py 提供FFmpeg封装,支持时长查询、音频提取、视频切片
|
||||||
|
- 可选策略:audio-first或direct,用于抽取视频转录与时间戳,辅助scripter生成更贴合画面的脚本
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L12-L91)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L13-L164)
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py#L1-L161)
|
||||||
|
- [tests/test_video_processor.py](file://tests/test_video_processor.py#L1-L143)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- main.py 依赖 backend/scripter.py 生成脚本,依赖 backend/data_builder.py 生成图表数据
|
||||||
|
- backend/scripter.py 依赖环境变量中的GEMINI_API_KEY
|
||||||
|
- backend/media_fetcher.py 依赖PEXELS_API_KEY
|
||||||
|
- backend/extractor.py 依赖GEMINI_API_KEY与OPENAI_API_KEY
|
||||||
|
- 视频处理模块与main.py之间为可选集成
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
M["main.py"] --> SG["backend/scripter.py"]
|
||||||
|
M --> DB["backend/data_builder.py"]
|
||||||
|
M --> MF["backend/media_fetcher.py"]
|
||||||
|
M --> EX["backend/extractor.py"]
|
||||||
|
SG --> ENV1["GEMINI_API_KEY"]
|
||||||
|
MF --> ENV2["PEXELS_API_KEY"]
|
||||||
|
EX --> ENV3["GEMINI_API_KEY/OPENAI_API_KEY"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [main.py](file://main.py#L48-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L17-L21)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L18-L22)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L18-L21)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L106-L111)
|
||||||
|
|
||||||
|
## 性能考量
|
||||||
|
- API调用成本控制
|
||||||
|
- 使用缓存:对重复的提示词与翻译结果进行缓存,减少Gemini/Whisper调用次数
|
||||||
|
- 批量请求:合并相似请求,避免频繁往返
|
||||||
|
- 响应解析与结构化
|
||||||
|
- 强制JSON输出,减少后处理开销
|
||||||
|
- 对异常响应进行重试与降级策略
|
||||||
|
- 渲染阶段
|
||||||
|
- render_props.json尽量紧凑,避免冗余字段
|
||||||
|
- 图表数据按需生成,避免不必要的计算
|
||||||
|
|
||||||
|
[本节为通用建议,无需特定文件引用]
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- API密钥缺失
|
||||||
|
- 确认 .env 文件存在且包含有效密钥
|
||||||
|
- GEMINI_API_KEY、OPENAI_API_KEY、PEXELS_API_KEY
|
||||||
|
- FFmpeg依赖
|
||||||
|
- 使用测试脚本检查依赖安装情况
|
||||||
|
- 若未安装,按提示安装FFmpeg与ffprobe
|
||||||
|
- 渲染失败
|
||||||
|
- 确认前端依赖已安装
|
||||||
|
- 检查render_props.json结构是否符合前端schema
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L106-L111)
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py#L68-L91)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L148-L164)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
scripter.py模块是将结构化财务数据转化为自然日语旁白的关键环节。通过精心设计的提示词工程,将客户数据、财务预测与视频节奏要求有机融合,可生成既专业又富有人情味的脚本。在主流水线中,它位于PDF/视频抽取之后、图表数据构建之前,为后续B-Roll与渲染阶段提供高质量的文本基础。随着各模块逐步完善,系统将形成一条从数据到视频的完整自动化管线。
|
||||||
|
|
||||||
|
[本节为总结性内容,无需特定文件引用]
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### 实际代码示例(路径指引)
|
||||||
|
- 调整生成风格
|
||||||
|
- 在调用处设置style参数,参考:[backend/scripter.py](file://backend/scripter.py#L22-L49)
|
||||||
|
- 添加新的解说段落类型
|
||||||
|
- 在提示词中新增类型定义与样例,参考:[backend/scripter.py](file://backend/scripter.py#L50-L59)
|
||||||
|
- 解析与扩展脚本结构
|
||||||
|
- 在返回后对segments进行二次加工,参考:[main.py](file://main.py#L115-L134)
|
||||||
|
|
||||||
|
### API与配置清单
|
||||||
|
- 环境变量
|
||||||
|
- GEMINI_API_KEY:Google Gemini API
|
||||||
|
- OPENAI_API_KEY:OpenAI Whisper
|
||||||
|
- PEXELS_API_KEY:Pexels B-Roll
|
||||||
|
- 依赖安装
|
||||||
|
- Python依赖:参考 [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L106-L111)
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
|
@ -0,0 +1,340 @@
|
||||||
|
# 媒体获取
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件**
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py)
|
||||||
|
- [assets/1_processed/broll_fetch_results.json](file://assets/1_processed/broll_fetch_results.json)
|
||||||
|
- [assets/1_processed/translation_cache.json](file://assets/1_processed/translation_cache.json)
|
||||||
|
- [assets/1_processed/video_extractions/№1_基本情報_extraction.json](file://assets/1_processed/video_extractions/№1_基本情報_extraction.json)
|
||||||
|
- [assets/1_processed/video_extractions/№2_ライフイベント_extraction.json](file://assets/1_processed/video_extractions/№2_ライフイベント_extraction.json)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能考量](#性能考量)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录](#附录)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本文件围绕“B-Roll素材获取系统”展开,聚焦于如何通过Pexels API自动检索与财务规划主题相关的背景视频片段。文档解释关键词提取机制(从PDF、视频转写、生成的解说脚本中识别“退休”“家庭”“教育”等关键主题),并结合main.py中的fetch_broll方法说明媒体获取在整个流水线中的协调流程。面向初学者提供媒体获取流程图,面向高级开发者则深入分析API速率限制处理、素材质量评估、版权合规性检查以及本地缓存策略,并给出扩展至其他媒体资源提供商或自定义素材库集成的实践建议。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
媒体获取系统位于后端目录,主要涉及以下模块:
|
||||||
|
- 后端媒体获取:backend/media_fetcher.py(当前为占位实现)
|
||||||
|
- 调查与完整实现:backend/investigate_media_fetch.py(包含翻译器与Pexels抓取器)
|
||||||
|
- 关键词来源:backend/extractor.py(PDF与视频抽取)、backend/scripter.py(脚本生成)
|
||||||
|
- 渲染参数构建:backend/data_builder.py(用于将B-Roll映射到渲染参数)
|
||||||
|
- 流水线编排:main.py(VideoGeneratorPipeline.run串联各阶段)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A["main.py<br/>VideoGeneratorPipeline.run"] --> B["backend/media_fetcher.py<br/>fetch_broll(占位)"]
|
||||||
|
A --> C["backend/scripter.py<br/>generate_script(占位)"]
|
||||||
|
A --> D["backend/extractor.py<br/>extract_pdf_data/extract_video_data(占位)"]
|
||||||
|
A --> E["backend/data_builder.py<br/>build_chart_data(占位)"]
|
||||||
|
B --> F["backend/investigate_media_fetch.py<br/>KeywordTranslator/PexelsFetcher(完整实现)"]
|
||||||
|
F --> G["assets/1_processed/translation_cache.json<br/>翻译缓存"]
|
||||||
|
F --> H["assets/1_processed/broll_fetch_results.json<br/>下载结果汇总"]
|
||||||
|
D --> I["assets/1_processed/video_extractions/*_extraction.json<br/>视频抽取结果(含broll_keywords)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [main.py](file://main.py#L48-L83)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L62-L72)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L148-L268)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L62-L69)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L74-L84)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
- [assets/1_processed/translation_cache.json](file://assets/1_processed/translation_cache.json#L1-L12)
|
||||||
|
- [assets/1_processed/broll_fetch_results.json](file://assets/1_processed/broll_fetch_results.json#L1-L77)
|
||||||
|
- [assets/1_processed/video_extractions/№1_基本情報_extraction.json](file://assets/1_processed/video_extractions/№1_基本情報_extraction.json#L73-L104)
|
||||||
|
- [assets/1_processed/video_extractions/№2_ライフイベント_extraction.json](file://assets/1_processed/video_extractions/№2_ライフイベント_extraction.json#L1-L104)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [main.py](file://main.py#L48-L83)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L15-L72)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L1-L359)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- MediaFetcher(占位):定义fetch_broll接口,负责从Pexels搜索并下载B-Roll,当前抛出未实现异常。
|
||||||
|
- KeywordTranslator(调查实现):使用Gemini将日文关键词翻译为英文,带缓存避免重复调用。
|
||||||
|
- PexelsFetcher(调查实现):基于Pexels API搜索视频、选择合适清晰度、下载到本地并记录元数据。
|
||||||
|
- VideoGeneratorPipeline.fetch_broll(占位):在流水线中调用媒体获取,返回字典形式的B-Roll映射。
|
||||||
|
- 关键词来源:
|
||||||
|
- PDF抽取:提取客户信息、财务数据、重大事件等,作为脚本生成输入。
|
||||||
|
- 视频抽取:输出转写文本与时间戳,其中包含broll_keywords。
|
||||||
|
- 脚本生成:生成分段脚本,供后续抽取关键词。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L15-L72)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L45-L146)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L148-L268)
|
||||||
|
- [main.py](file://main.py#L136-L144)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L14-L69)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
媒体获取在整体流水线中的位置如下:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant CLI as "命令行入口(main.py)"
|
||||||
|
participant Pipe as "VideoGeneratorPipeline"
|
||||||
|
participant Ext as "extract_pdf/extract_video"
|
||||||
|
participant Scr as "generate_script"
|
||||||
|
participant MF as "fetch_broll(占位)"
|
||||||
|
participant IF as "investigate_media_fetch.py<br/>翻译+Pexels抓取"
|
||||||
|
participant DB as "data_builder"
|
||||||
|
CLI->>Pipe : run()
|
||||||
|
Pipe->>Ext : 提取PDF/视频数据
|
||||||
|
Pipe->>Scr : 生成脚本(含segments)
|
||||||
|
Pipe->>MF : fetch_broll(script)
|
||||||
|
MF-->>IF : 使用翻译器+Pexels抓取
|
||||||
|
IF-->>Pipe : 返回本地B-Roll路径映射
|
||||||
|
Pipe->>DB : 构建渲染参数(含assets.broll)
|
||||||
|
Pipe-->>CLI : 输出视频路径
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [main.py](file://main.py#L48-L83)
|
||||||
|
- [main.py](file://main.py#L136-L144)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L62-L72)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L270-L359)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L145-L199)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### 组件A:媒体获取流水线(从脚本抽取关键词到Pexels抓取)
|
||||||
|
- 关键词来源与映射
|
||||||
|
- PDF抽取:提供财务与生命周期事件的基础数据。
|
||||||
|
- 视频抽取:输出转写与时间戳,其中包含broll_keywords,可直接作为关键词源。
|
||||||
|
- 脚本生成:生成segments,每个segment包含文本,可用于进一步抽取主题关键词(如“退休”“家庭”“教育”)。
|
||||||
|
- 占位实现与调查实现对比
|
||||||
|
- 占位实现:MediaFetcher.fetch_broll抛出未实现异常,VideoGeneratorPipeline.fetch_broll返回模拟数据。
|
||||||
|
- 调查实现:KeywordTranslator与PexelsFetcher提供完整的翻译与抓取逻辑,包括缓存、质量筛选与下载进度显示。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["开始"]) --> Src["确定关键词来源<br/>PDF/视频抽取/脚本"]
|
||||||
|
Src --> KWF["关键词清洗与去重"]
|
||||||
|
KWF --> Trans["翻译为英文(缓存命中优先)"]
|
||||||
|
Trans --> Search["Pexels搜索(限定时长/尺寸)"]
|
||||||
|
Search --> Quality{"满足质量要求?"}
|
||||||
|
Quality --> |否| Search
|
||||||
|
Quality --> |是| Download["下载到本地assets/1_processed/broll/"]
|
||||||
|
Download --> Record["记录元数据(路径/时长/摄影师)"]
|
||||||
|
Record --> End(["结束"])
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L67-L146)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L158-L214)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L220-L268)
|
||||||
|
- [assets/1_processed/translation_cache.json](file://assets/1_processed/translation_cache.json#L1-L12)
|
||||||
|
- [assets/1_processed/broll_fetch_results.json](file://assets/1_processed/broll_fetch_results.json#L1-L77)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L25-L41)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L67-L146)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L158-L214)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L23-L41)
|
||||||
|
- [assets/1_processed/video_extractions/№1_基本情報_extraction.json](file://assets/1_processed/video_extractions/№1_基本情報_extraction.json#L73-L104)
|
||||||
|
- [assets/1_processed/video_extractions/№2_ライフイベント_extraction.json](file://assets/1_processed/video_extractions/№2_ライフイベント_extraction.json#L1-L104)
|
||||||
|
|
||||||
|
### 组件B:类关系与职责
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class KeywordTranslator {
|
||||||
|
+translate(japanese_keywords) Dict
|
||||||
|
-_load_cache() Dict
|
||||||
|
-_save_cache() void
|
||||||
|
-cache_file
|
||||||
|
-cache
|
||||||
|
-model
|
||||||
|
}
|
||||||
|
class PexelsFetcher {
|
||||||
|
+search_videos(query, max_results, min_duration) List
|
||||||
|
+download_video(video, keyword) Optional~str~
|
||||||
|
-api_key
|
||||||
|
-output_dir
|
||||||
|
-base_url
|
||||||
|
}
|
||||||
|
class MediaFetcher {
|
||||||
|
+fetch_broll(keywords, max_per_keyword) Dict
|
||||||
|
-_translate_keyword(japanese_keyword) str
|
||||||
|
-_download_video(url, output_path) str
|
||||||
|
-api_key
|
||||||
|
-base_url
|
||||||
|
}
|
||||||
|
KeywordTranslator --> PexelsFetcher : "配合使用"
|
||||||
|
MediaFetcher ..> KeywordTranslator : "占位实现中计划使用"
|
||||||
|
MediaFetcher ..> PexelsFetcher : "占位实现中计划使用"
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L45-L146)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L148-L268)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L15-L72)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L45-L146)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L148-L268)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L15-L72)
|
||||||
|
|
||||||
|
### 组件C:API调用序列(以调查实现为例)
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Caller as "调用方"
|
||||||
|
participant KT as "KeywordTranslator"
|
||||||
|
participant Pex as "PexelsFetcher"
|
||||||
|
participant API as "Pexels API"
|
||||||
|
participant FS as "文件系统"
|
||||||
|
Caller->>KT : translate(japanese_keywords)
|
||||||
|
KT->>KT : 检查缓存
|
||||||
|
KT->>API : Gemini翻译(批量)
|
||||||
|
API-->>KT : 英文关键词映射
|
||||||
|
KT->>KT : 更新缓存并保存
|
||||||
|
Caller->>Pex : search_videos(english_query, ...)
|
||||||
|
Pex->>API : GET /videos/search
|
||||||
|
API-->>Pex : 视频列表(JSON)
|
||||||
|
Pex->>Pex : 过滤HD/时长/尺寸
|
||||||
|
Pex->>FS : 下载MP4到本地
|
||||||
|
FS-->>Pex : 文件路径
|
||||||
|
Pex-->>Caller : 元数据列表
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L104-L136)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L158-L214)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L220-L268)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L104-L136)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L158-L214)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L220-L268)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- 外部依赖
|
||||||
|
- Pexels API:用于视频搜索与下载。
|
||||||
|
- Gemini API:用于关键词翻译。
|
||||||
|
- Whisper/OpenAI API:用于视频转写(当前为占位)。
|
||||||
|
- 内部依赖
|
||||||
|
- main.py的VideoGeneratorPipeline.run串联PDF抽取、视频抽取、脚本生成、媒体获取、渲染参数构建与渲染。
|
||||||
|
- media_fetcher.py的fetch_broll接口被pipeline调用,当前返回模拟数据。
|
||||||
|
- investigate_media_fetch.py提供可运行的翻译与抓取实现,可直接复用。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
M["main.py<br/>VideoGeneratorPipeline"] --> MF["backend/media_fetcher.py<br/>fetch_broll(占位)"]
|
||||||
|
M --> SCR["backend/scripter.py<br/>generate_script(占位)"]
|
||||||
|
M --> EXT["backend/extractor.py<br/>extract_pdf/extract_video(占位)"]
|
||||||
|
M --> DB["backend/data_builder.py<br/>build_chart_data(占位)"]
|
||||||
|
MF --> IM["backend/investigate_media_fetch.py<br/>翻译+Pexels抓取"]
|
||||||
|
IM --> T["translation_cache.json"]
|
||||||
|
IM --> R["broll_fetch_results.json"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [main.py](file://main.py#L48-L83)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L62-L72)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L270-L359)
|
||||||
|
- [assets/1_processed/translation_cache.json](file://assets/1_processed/translation_cache.json#L1-L12)
|
||||||
|
- [assets/1_processed/broll_fetch_results.json](file://assets/1_processed/broll_fetch_results.json#L1-L77)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [main.py](file://main.py#L48-L83)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L62-L72)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L270-L359)
|
||||||
|
|
||||||
|
## 性能考量
|
||||||
|
- API速率限制处理
|
||||||
|
- 批量翻译:KeywordTranslator对未缓存的关键词进行批量翻译,减少请求次数。
|
||||||
|
- 请求节流:PexelsFetcher在搜索时设置合理per_page与最小时长,避免无效请求。
|
||||||
|
- 缓存策略:translation_cache.json持久化翻译结果,避免重复调用Gemini。
|
||||||
|
- 素材质量评估
|
||||||
|
- 尺寸与方向:PexelsFetcher优先选择横向、中等尺寸的视频,便于编辑。
|
||||||
|
- 清晰度与时长:优先选择HD且满足最低时长的视频,确保可用性。
|
||||||
|
- 版权合规性检查
|
||||||
|
- Pexels提供商用许可的视频文件链接,下载前应确认授权范围。
|
||||||
|
- 记录摄影师信息,便于注明来源与合规追踪。
|
||||||
|
- 本地缓存策略
|
||||||
|
- 已下载文件存在即跳过,避免重复下载。
|
||||||
|
- 结果汇总文件broll_fetch_results.json便于二次使用与审计。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L158-L214)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L220-L268)
|
||||||
|
- [assets/1_processed/translation_cache.json](file://assets/1_processed/translation_cache.json#L1-L12)
|
||||||
|
- [assets/1_processed/broll_fetch_results.json](file://assets/1_processed/broll_fetch_results.json#L1-L77)
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- 环境变量缺失
|
||||||
|
- PEXELS_API_KEY或GEMINI_API_KEY未配置会导致初始化失败或请求错误。
|
||||||
|
- 翻译解析失败
|
||||||
|
- Gemini返回内容格式不符合预期时,调查脚本提供回退逻辑(使用原文)。
|
||||||
|
- Pexels请求异常
|
||||||
|
- 捕获网络异常并返回空列表,避免中断流程。
|
||||||
|
- 下载中断
|
||||||
|
- 发生异常时删除部分文件,防止损坏文件残留。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L18-L24)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L139-L145)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L216-L218)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L263-L267)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
媒体获取系统通过“关键词提取—翻译—Pexels搜索—下载—缓存”的闭环,实现了财务规划主题的B-Roll自动化采集。当前仓库提供了完整的调查实现,可直接用于生产环境;同时保留了占位的media_fetcher与pipeline方法,便于逐步替换为正式实现。建议在正式实现中:
|
||||||
|
- 完成MediaFetcher.fetch_broll的Pexels集成;
|
||||||
|
- 在pipeline中注入真实关键词来源(PDF/视频抽取/脚本);
|
||||||
|
- 引入更严格的错误处理与重试机制;
|
||||||
|
- 增加素材元数据校验与合规性检查。
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### 初学者媒体获取流程图
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["输入:PDF/视频/脚本"] --> B["抽取关键词<br/>PDF/视频抽取/脚本"]
|
||||||
|
B --> C["翻译为英文(缓存命中优先)"]
|
||||||
|
C --> D["Pexels搜索(限定时长/清晰度)"]
|
||||||
|
D --> E{"找到可用素材?"}
|
||||||
|
E --> |否| C
|
||||||
|
E --> |是| F["下载到本地assets/1_processed/broll/"]
|
||||||
|
F --> G["记录元数据并保存汇总"]
|
||||||
|
G --> H["返回B-Roll映射给渲染参数"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L67-L146)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L158-L214)
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L220-L268)
|
||||||
|
- [assets/1_processed/broll_fetch_results.json](file://assets/1_processed/broll_fetch_results.json#L1-L77)
|
||||||
|
|
||||||
|
### 高级开发者实践建议
|
||||||
|
- 扩展支持其他媒体提供商
|
||||||
|
- 参考PexelsFetcher模式,新增ProviderFetcher类,统一接口(search_videos、download_video)。
|
||||||
|
- 在pipeline中按优先级或预算策略选择不同提供商。
|
||||||
|
- 自定义素材库集成
|
||||||
|
- 将本地素材库路径加入搜索链路,先查本地再查在线API。
|
||||||
|
- 对本地素材进行索引与元数据管理,保持与在线素材一致的数据结构。
|
||||||
|
- 错误恢复与重试
|
||||||
|
- 为Pexels请求与下载增加指数退避重试。
|
||||||
|
- 对翻译失败的关键词记录日志并人工审核。
|
||||||
|
- 渲染参数映射
|
||||||
|
- 将返回的B-Roll映射到render_props.json的assets.broll字段,确保时序与脚本同步。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L148-L268)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L145-L199)
|
||||||
|
|
@ -0,0 +1,312 @@
|
||||||
|
# PDF数据提取
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件**
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md)
|
||||||
|
- [.env.example](file://.env.example)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [tests/test_video_processor.py](file://tests/test_video_processor.py)
|
||||||
|
- [verify_structure.py](file://verify_structure.py)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [组件详解](#组件详解)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能考量](#性能考量)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录](#附录)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本文件聚焦于“PDF数据提取”能力,面向使用Google Gemini API从日语财务规划PDF报告中抽取结构化数据的场景。文档将系统阐述:
|
||||||
|
- PDFExtractor类的设计与实现机制:如何通过GEMINI_API_KEY进行身份验证、调用Gemini模型解析PDF并提取客户基本信息、财务数据、人生事件和保险信息。
|
||||||
|
- 在整体流水线中的调用时机与数据流转:结合main.py中的extract_pdf方法,说明从PDF上传到结构化JSON输出的完整过程。
|
||||||
|
- 针对初学者的流程图与针对高级开发者的提示工程策略、错误处理机制、数据验证模式及扩展支持新PDF模板的方法。
|
||||||
|
- 提供可直接参考的代码片段路径,帮助快速定位实现位置并进行定制化改造。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
该项目采用“Python后端 + TypeScript前端”的混合架构,围绕render_props.json作为前后端契约数据进行编排:
|
||||||
|
- 后端负责输入解析、数据提取、脚本生成、素材抓取与图表数据模拟。
|
||||||
|
- 前端基于Remotion渲染最终视频,类型定义由render-props-types.ts约束。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A["main.py<br/>主入口与流水线编排"] --> B["backend/extractor.py<br/>PDF/视频提取器"]
|
||||||
|
A --> C["backend/scripter.py<br/>脚本生成器"]
|
||||||
|
A --> D["backend/data_builder.py<br/>图表数据模拟"]
|
||||||
|
A --> E["frontend/src/render-props-types.ts<br/>类型契约"]
|
||||||
|
B --> F["Google Gemini API<br/>PDF解析/视频理解"]
|
||||||
|
C --> F
|
||||||
|
D --> E
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [main.py](file://main.py#L48-L199)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L14-L68)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L232)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L132-L141)
|
||||||
|
- [main.py](file://main.py#L48-L199)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- PDFExtractor:封装Gemini API调用,负责从PDF中抽取结构化数据(客户信息、财务数据、人生事件、保险信息)。
|
||||||
|
- ScriptGenerator:封装Gemini API调用,基于PDF与视频数据生成自然日语的叙述脚本。
|
||||||
|
- FinancialSimulator与data_builder:将PDF提取的参数转化为资产枯竭与现金流图表数据,形成render_props.json所需结构。
|
||||||
|
- main.py:流水线编排入口,按步骤执行PDF提取、视频提取、脚本生成、素材抓取、图表数据模拟、render_props.json生成与视频渲染。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L14-L68)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
- [main.py](file://main.py#L48-L199)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
下图展示了从PDF上传到结构化JSON输出的关键交互与数据流:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as "用户"
|
||||||
|
participant M as "main.py<br/>VideoGeneratorPipeline"
|
||||||
|
participant X as "backend/extractor.py<br/>PDFExtractor"
|
||||||
|
participant G as "Google Gemini API"
|
||||||
|
participant S as "backend/scripter.py<br/>ScriptGenerator"
|
||||||
|
participant D as "backend/data_builder.py<br/>FinancialSimulator"
|
||||||
|
participant R as "frontend/src/render-props-types.ts<br/>类型契约"
|
||||||
|
U->>M : 运行命令行
|
||||||
|
M->>M : 解析参数/加载配置
|
||||||
|
M->>X : 调用extract_pdf()当前为占位
|
||||||
|
Note right of X : 实际实现应调用G,解析PDF并返回结构化数据
|
||||||
|
X-->>M : 返回pdf_datamock
|
||||||
|
M->>S : 生成脚本当前为占位
|
||||||
|
S-->>M : 返回scriptmock
|
||||||
|
M->>D : 构建图表数据基于pdf_data
|
||||||
|
D-->>M : 返回charts数据
|
||||||
|
M->>M : 组装render_props.json
|
||||||
|
M-->>U : 输出render_props.json
|
||||||
|
Note over M,R : render_props.json遵循前端类型契约
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [main.py](file://main.py#L48-L199)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L14-L68)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L232)
|
||||||
|
|
||||||
|
## 组件详解
|
||||||
|
|
||||||
|
### PDFExtractor类设计与实现要点
|
||||||
|
- 身份验证:优先使用构造函数传入的api_key,否则从环境变量GEMINI_API_KEY读取;若两者均不可得则抛出异常。
|
||||||
|
- 数据结构:返回字典包含四类键:client_info、financial_data、life_events、insurance_data。
|
||||||
|
- 当前状态:方法体为占位实现,预留了后续接入Gemini API的步骤(上传PDF、提示工程抽取结构化JSON、Schema校验)。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class PDFExtractor {
|
||||||
|
+api_key : str
|
||||||
|
+__init__(api_key=None)
|
||||||
|
+extract(pdf_path) Dict
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L43)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L43)
|
||||||
|
- [.env.example](file://.env.example#L7-L9)
|
||||||
|
|
||||||
|
### 在流水线中的调用时机与数据流转
|
||||||
|
- main.py的VideoGeneratorPipeline.run()按顺序执行:PDF提取、视频提取、脚本生成、素材抓取、图表数据模拟、render_props.json生成、视频渲染。
|
||||||
|
- extract_pdf()当前为占位实现,返回mock数据;实际实现应调用PDFExtractor.extract()并返回结构化结果。
|
||||||
|
- 该结构化数据随后被data_builder.build_chart_data()消费,生成资产枯竭与现金流图表数据,最终写入render_props.json。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["开始:main.run()"]) --> Step1["Step 1:extract_pdf()占位"]
|
||||||
|
Step1 --> Step2["Step 2:extract_video()可选"]
|
||||||
|
Step2 --> Step3["Step 3:generate_script()占位"]
|
||||||
|
Step3 --> Step4["Step 4:fetch_broll()占位"]
|
||||||
|
Step4 --> Step5["Step 5:build_render_props()占位"]
|
||||||
|
Step5 --> Step6["保存render_props.json"]
|
||||||
|
Step6 --> End(["结束:可选渲染视频"])
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [main.py](file://main.py#L48-L199)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [main.py](file://main.py#L48-L199)
|
||||||
|
|
||||||
|
### 结构化数据契约与验证
|
||||||
|
- render_props.json是前后端契约,前端通过Zod Schema进行运行时校验,确保数据一致性。
|
||||||
|
- YearlySnapshot与CashFlowData等核心类型在前端定义,后端data_builder.py需与之保持一致。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
RENDER_PROPS {
|
||||||
|
object meta
|
||||||
|
object charts
|
||||||
|
array script
|
||||||
|
object assets
|
||||||
|
}
|
||||||
|
CHARTS {
|
||||||
|
object assetDepletion
|
||||||
|
object cashFlow
|
||||||
|
}
|
||||||
|
ASSET_DEPLETION {
|
||||||
|
array timeline
|
||||||
|
object meta
|
||||||
|
}
|
||||||
|
CASH_FLOW {
|
||||||
|
array timeline
|
||||||
|
object meta
|
||||||
|
}
|
||||||
|
YEARLY_SNAPSHOT {
|
||||||
|
number year
|
||||||
|
object ages
|
||||||
|
object financials
|
||||||
|
array events
|
||||||
|
boolean isCritical
|
||||||
|
}
|
||||||
|
CASHFLOW_DATA {
|
||||||
|
number year
|
||||||
|
number age
|
||||||
|
object income
|
||||||
|
object expenses
|
||||||
|
number totalExpense
|
||||||
|
number netCashFlow
|
||||||
|
number assetBalance
|
||||||
|
}
|
||||||
|
RENDER_PROPS ||--o{ CHARTS : "包含"
|
||||||
|
CHARTS ||--o{ ASSET_DEPLETION : "包含"
|
||||||
|
CHARTS ||--o{ CASH_FLOW : "包含"
|
||||||
|
ASSET_DEPLETION ||--o{ YEARLY_SNAPSHOT : "timeline"
|
||||||
|
CASH_FLOW ||--o{ CASHFLOW_DATA : "timeline"
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L232)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L232)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
|
||||||
|
### 提示工程策略(建议实践)
|
||||||
|
以下为通用提示工程最佳实践,便于在PDFExtractor中落地(具体提示词需根据目标PDF模板与字段进行定制):
|
||||||
|
- 明确角色与任务:指定模型扮演“财务规划报告解析专家”,职责是抽取结构化字段。
|
||||||
|
- 指定输出格式:要求返回JSON,字段名与类型明确,避免自由文本。
|
||||||
|
- 提供上下文与约束:包含PDF语言(日语)、单位(万円)、关键字段清单(姓名、年龄、退休年龄、寿命预期、资产、收入、支出、重大事件年份与金额、现有保险信息等)。
|
||||||
|
- 使用Few-shot示例:给出少量正确样例,提升抽取稳定性。
|
||||||
|
- 错误兜底:当某字段缺失时,返回空值或占位符,保证JSON结构完整。
|
||||||
|
- 分步抽取:先抽取客户与财务信息,再抽取人生事件,最后抽取保险信息,降低复杂度。
|
||||||
|
- 校验与重试:对返回的JSON进行Schema校验,失败则重试或回退到更保守的提示。
|
||||||
|
|
||||||
|
说明:以上为通用策略建议,具体提示词需结合实际PDF模板与字段进行定制。
|
||||||
|
|
||||||
|
### 错误处理机制
|
||||||
|
- API密钥缺失:PDFExtractor与ScriptGenerator在初始化时检查GEMINI_API_KEY或OPENAI_API_KEY,缺失时抛出异常,便于早期发现配置问题。
|
||||||
|
- PDF提取占位:main.py的extract_pdf()当前返回mock数据,便于前端联调;实际部署时应替换为PDFExtractor.extract()。
|
||||||
|
- 视频提取占位:同上,extract_video()当前返回mock数据。
|
||||||
|
- 渲染阶段:main.py的render_video()当前为占位,实际渲染逻辑已注释,便于调试。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L43)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L14-L49)
|
||||||
|
- [main.py](file://main.py#L85-L135)
|
||||||
|
|
||||||
|
### 扩展支持新PDF模板的方法
|
||||||
|
- 字段映射:在PDFExtractor中新增字段映射规则,将新模板中的关键词映射到标准字段(client_info、financial_data、life_events、insurance_data)。
|
||||||
|
- 提示词适配:针对新模板优化提示词,增加领域特定的few-shot示例与约束。
|
||||||
|
- Schema演进:若新增字段,需同步更新前端render-props-types.ts中的Schema与类型定义。
|
||||||
|
- 测试覆盖:使用tests/test_video_processor.py类似的风格编写单元测试,验证字段抽取稳定性与边界情况。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L43)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L232)
|
||||||
|
- [tests/test_video_processor.py](file://tests/test_video_processor.py#L1-L143)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- main.py依赖backend模块(extractor、scripter、data_builder),并通过render_props.json与前端类型契约对接。
|
||||||
|
- extractor与scripter依赖Gemini API;data_builder依赖后端财务模拟逻辑。
|
||||||
|
- 前端通过Zod Schema对render_props.json进行强类型校验,确保数据一致性。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
M["main.py"] --> E["backend/extractor.py"]
|
||||||
|
M --> S["backend/scripter.py"]
|
||||||
|
M --> D["backend/data_builder.py"]
|
||||||
|
E --> G["Google Gemini API"]
|
||||||
|
S --> G
|
||||||
|
D --> F["前端类型契约<br/>render-props-types.ts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [main.py](file://main.py#L48-L199)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L84)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L14-L68)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L232)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [main.py](file://main.py#L48-L199)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L132-L141)
|
||||||
|
|
||||||
|
## 性能考量
|
||||||
|
- API调用成本:Gemini API按Token计费,建议在提示词中明确字段清单与输出格式,减少冗余内容,控制成本。
|
||||||
|
- 并发与批处理:若需要同时处理多份PDF,可在上游引入队列与并发控制,避免API限流。
|
||||||
|
- 缓存策略:对常见字段与few-shot示例进行缓存,减少重复请求。
|
||||||
|
- 本地预处理:在上传前对PDF进行OCR与清洗,提高抽取准确率,减少重试次数。
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- “API密钥未找到”
|
||||||
|
- 确认.GEMINI_API_KEY或.OPENAI_API_KEY存在于环境变量中,或在.env中配置。
|
||||||
|
- 参考.env.example进行配置。
|
||||||
|
- “PDF提取失败”
|
||||||
|
- 确保PDF可读且未加密;检查Gemini配额与限额。
|
||||||
|
- 参考PROJECT_README.md中的故障排查章节。
|
||||||
|
- “渲染失败”
|
||||||
|
- 确认前端依赖安装完成,render_props.json结构与前端Schema一致。
|
||||||
|
- “导入失败”
|
||||||
|
- 使用verify_structure.py检查目录与文件是否齐全,确认Python包导入正常。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [.env.example](file://.env.example#L7-L14)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L217-L233)
|
||||||
|
- [verify_structure.py](file://verify_structure.py#L1-L136)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
PDF数据提取是本项目的核心环节之一,它将静态PDF转化为结构化数据,驱动后续脚本生成、图表模拟与视频渲染。当前实现处于占位阶段,建议尽快完成PDFExtractor与ScriptGenerator的实际集成,并完善提示工程、Schema校验与错误处理,以支撑多样化的PDF模板与高质量的产出。
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### 关键实现片段路径(不展示具体代码内容)
|
||||||
|
- PDFExtractor类与初始化逻辑
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L23)
|
||||||
|
- PDF提取方法占位与待实现说明
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L23-L43)
|
||||||
|
- main.py中extract_pdf占位实现
|
||||||
|
- [main.py](file://main.py#L85-L98)
|
||||||
|
- main.py中build_render_props占位实现
|
||||||
|
- [main.py](file://main.py#L145-L199)
|
||||||
|
- data_builder中图表数据构建
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
- 前端类型契约(Zod Schema)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L232)
|
||||||
|
- 环境变量配置示例
|
||||||
|
- [.env.example](file://.env.example#L7-L14)
|
||||||
|
- 项目数据流说明
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L132-L141)
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
# 数据处理
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件**
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py)
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [.env.example](file://.env.example)
|
||||||
|
- [requirements.txt](file://requirements.txt)
|
||||||
|
- [tests/test_video_processor.py](file://tests/test_video_processor.py)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能考量](#性能考量)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录](#附录)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本文件聚焦于数据处理子系统,围绕财务规划PDF与访谈视频的提取流程展开,解释以下要点:
|
||||||
|
- extractor.py 模块如何通过 Google Gemini API 解析财务规划PDF报告,抽取客户基本信息、资产状况、收支数据等关键字段;
|
||||||
|
- main.py 中的 extract_pdf 与 extract_video 方法在整体流水线中的协调机制;
|
||||||
|
- 针对初学者的数据提取流程图,展示从原始文件到结构化数据的转换过程;
|
||||||
|
- 面向高级开发者的提示工程策略、错误处理机制与数据验证模式;
|
||||||
|
- 实际代码示例路径,帮助扩展支持新的PDF模板或处理不同格式的视频文件。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
数据处理子系统位于 backend 目录,主要由以下模块组成:
|
||||||
|
- extractor.py:负责 PDF 与视频的结构化提取(当前为占位实现,预留 API 调用与提示工程);
|
||||||
|
- data_builder.py:基于提取结果进行财务模拟与图表数据构建;
|
||||||
|
- scripter.py:基于 PDF 与视频内容生成日语叙述脚本(占位);
|
||||||
|
- media_fetcher.py:基于关键词抓取 B-Roll 视频(占位);
|
||||||
|
- video_processor:视频分析策略与数据结构定义(音频优先策略已实现,直接分析待实现)。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A["main.py<br/>主入口与流水线编排"] --> B["backend/extractor.py<br/>PDF/视频提取器"]
|
||||||
|
A --> C["backend/data_builder.py<br/>财务模拟与图表数据构建"]
|
||||||
|
A --> D["backend/scripter.py<br/>脚本生成器"]
|
||||||
|
A --> E["backend/media_fetcher.py<br/>B-Roll抓取器"]
|
||||||
|
A --> F["backend/video_processor/__init__.py<br/>视频分析策略入口"]
|
||||||
|
F --> G["backend/video_processor/schemas.py<br/>视频分析数据结构"]
|
||||||
|
A --> H["frontend/src/render-props-types.ts<br/>渲染契约类型定义"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [main.py](file://main.py#L33-L226)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L1-L69)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py#L1-L60)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L1-L91)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L132-L208)
|
||||||
|
- [main.py](file://main.py#L33-L226)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- PDFExtractor:封装 PDF 提取逻辑,初始化时校验 Gemini API 密钥,提供 extract 接口用于返回结构化数据字典(当前抛出未实现异常,留待实现)。
|
||||||
|
- VideoExtractor:封装视频提取逻辑,初始化时校验 OpenAI API 密钥,提供 extract 接口用于返回包含转录文本、时间戳与关键字的字典(当前抛出未实现异常,留待实现)。
|
||||||
|
- FinancialSimulator:根据提取参数进行年金现金流模拟,产出资产枯竭与现金流量两种图表数据,支持通胀、投资回报与赤字复利等参数。
|
||||||
|
- ScriptGenerator:封装脚本生成逻辑,初始化时校验 Gemini API 密钥,提供 generate 接口用于返回脚本段落与同步点(当前抛出未实现异常,留待实现)。
|
||||||
|
- MediaFetcher:封装 B-Roll 抓取逻辑,初始化时校验 Pexels API 密钥,提供 fetch_broll 接口用于返回按关键词分组的视频链接(当前抛出未实现异常,留待实现)。
|
||||||
|
- VideoProcessor:提供 analyze_video 入口,支持“音频优先”与“直接分析”两种策略;音频优先策略依赖 Whisper 进行转录与时间戳抽取,直接分析策略待实现。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L1-L69)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py#L1-L60)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L1-L91)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
数据处理子系统在 main.py 的流水线中承担“提取”阶段,随后进入“生成脚本”“抓取素材”“构建渲染参数”“渲染视频”的后续步骤。PDF 提取与视频提取分别由 extractor.py 中的 PDFExtractor 与 VideoExtractor 负责;最终的渲染参数由 data_builder.py 生成,前端通过 render-props-types.ts 的类型定义进行强约束。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant CLI as "命令行"
|
||||||
|
participant Main as "main.py"
|
||||||
|
participant Ext as "backend/extractor.py"
|
||||||
|
participant Sim as "backend/data_builder.py"
|
||||||
|
participant Script as "backend/scripter.py"
|
||||||
|
participant Media as "backend/media_fetcher.py"
|
||||||
|
participant Render as "Remotion 前端"
|
||||||
|
CLI->>Main : 启动流水线
|
||||||
|
Main->>Ext : extract_pdf()
|
||||||
|
Ext-->>Main : 结构化PDF数据
|
||||||
|
Main->>Ext : extract_video()
|
||||||
|
Ext-->>Main : 视频转录与时间戳
|
||||||
|
Main->>Script : generate_script(pdf_data, video_data)
|
||||||
|
Script-->>Main : 叙述脚本
|
||||||
|
Main->>Media : fetch_broll(script)
|
||||||
|
Media-->>Main : B-Roll资源
|
||||||
|
Main->>Sim : build_chart_data(pdf_data)
|
||||||
|
Sim-->>Main : 图表数据
|
||||||
|
Main->>Render : 写入 render_props.json 并触发渲染
|
||||||
|
Render-->>CLI : 输出视频
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [main.py](file://main.py#L48-L226)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L1-L69)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L117-L135)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### PDF提取器(PDFExtractor)
|
||||||
|
PDFExtractor 负责调用 Gemini API 对财务规划PDF进行解析,抽取客户基本信息、资产状况、收支数据等关键字段。当前实现为占位,抛出未实现异常,留待完成。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class PDFExtractor {
|
||||||
|
+api_key : str
|
||||||
|
+extract(pdf_path) Dict
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L43)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L43)
|
||||||
|
|
||||||
|
### 视频提取器(VideoExtractor)
|
||||||
|
VideoExtractor 负责调用 Whisper API 对视频进行转录与时间戳抽取,返回包含转录文本、时间戳与关键字的结构化数据。当前实现为占位,抛出未实现异常,留待完成。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class VideoExtractor {
|
||||||
|
+api_key : str
|
||||||
|
+extract(video_path) Dict
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L45-L72)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L45-L72)
|
||||||
|
|
||||||
|
### 财务模拟与图表数据构建(FinancialSimulator)
|
||||||
|
FinancialSimulator 基于提取参数进行年金现金流模拟,产出资产枯竭与现金流量两种图表数据。其核心逻辑包括:
|
||||||
|
- 按年龄循环推进,计算每年收入、通胀调整支出、事件成本;
|
||||||
|
- 当资产余额为正时应用投资回报,为负时应用赤字利息(复利);
|
||||||
|
- 将年度快照转换为现金流量图表数据,便于前端可视化。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["开始模拟"]) --> Init["读取参数<br/>当前年龄/退休年龄/寿命预期/资产/收入/支出/事件"]
|
||||||
|
Init --> Loop{"按年龄循环"}
|
||||||
|
Loop --> |计算| Income["收入 = 年收入 或 0退休后"]
|
||||||
|
Income --> Inflation["通胀调整支出"]
|
||||||
|
Inflation --> Events["事件成本累加"]
|
||||||
|
Events --> Net["净现金流 = 收入 - 支出 - 事件"]
|
||||||
|
Net --> Balance{"资产余额 >= 0 ?"}
|
||||||
|
Balance --> |是| Invest["正余额:应用投资回报"]
|
||||||
|
Balance --> |否| Deficit["负余额:应用赤字利息"]
|
||||||
|
Invest --> Snapshot["生成年度快照"]
|
||||||
|
Deficit --> Snapshot
|
||||||
|
Snapshot --> Next{"是否到达寿命预期?"}
|
||||||
|
Next --> |否| Loop
|
||||||
|
Next --> |是| Cashflow["转换为现金流量数据"]
|
||||||
|
Cashflow --> End(["结束"])
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L15-L148)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L15-L148)
|
||||||
|
|
||||||
|
### 渲染参数构建(build_chart_data)
|
||||||
|
该函数将 PDF 提取结果输入到 FinancialSimulator,得到两条时间线并打包为 render_props.json 的 charts 字段,供前端渲染使用。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Caller as "调用方"
|
||||||
|
participant Builder as "build_chart_data"
|
||||||
|
participant Simulator as "FinancialSimulator"
|
||||||
|
Caller->>Builder : 传入 pdf_data
|
||||||
|
Builder->>Simulator : simulate_timeline(...)
|
||||||
|
Simulator-->>Builder : 年度快照列表
|
||||||
|
Builder->>Simulator : build_cashflow_data(timeline)
|
||||||
|
Simulator-->>Builder : 现金流量数据
|
||||||
|
Builder-->>Caller : 返回 charts 对象
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
|
||||||
|
### 视频分析策略(video_processor)
|
||||||
|
视频分析模块提供两种策略:
|
||||||
|
- 音频优先策略:先提取音频,再调用 Whisper 进行转录与时间戳抽取;
|
||||||
|
- 直接分析策略:直接将视频送入 Gemini 进行分析(当前未实现)。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class VideoProcessor {
|
||||||
|
+analyze_video(video_path, strategy, config) VideoAnalysisResult
|
||||||
|
}
|
||||||
|
class AudioFirstStrategy {
|
||||||
|
+analyze(video_path) VideoAnalysisResult
|
||||||
|
}
|
||||||
|
class DirectStrategy {
|
||||||
|
+analyze(video_path) VideoAnalysisResult
|
||||||
|
}
|
||||||
|
class VideoAnalysisResult {
|
||||||
|
+source_video : str
|
||||||
|
+duration : float
|
||||||
|
+strategy : str
|
||||||
|
+segments : list
|
||||||
|
+key_moments : list
|
||||||
|
+metadata : dict
|
||||||
|
+to_dict() dict
|
||||||
|
+get_high_importance_segments() list
|
||||||
|
+get_quotes() list
|
||||||
|
}
|
||||||
|
class VideoSegment {
|
||||||
|
+start_time : float
|
||||||
|
+end_time : float
|
||||||
|
+description : str
|
||||||
|
+transcript : str
|
||||||
|
+importance : str
|
||||||
|
+tags : list
|
||||||
|
+speaker : str
|
||||||
|
+duration : float
|
||||||
|
+to_dict() dict
|
||||||
|
}
|
||||||
|
class KeyMoment {
|
||||||
|
+timestamp : float
|
||||||
|
+type : str
|
||||||
|
+content : str
|
||||||
|
+context : str
|
||||||
|
+to_dict() dict
|
||||||
|
}
|
||||||
|
VideoProcessor --> AudioFirstStrategy : "音频优先"
|
||||||
|
VideoProcessor --> DirectStrategy : "直接分析"
|
||||||
|
AudioFirstStrategy --> VideoAnalysisResult
|
||||||
|
DirectStrategy --> VideoAnalysisResult
|
||||||
|
VideoAnalysisResult --> VideoSegment : "包含"
|
||||||
|
VideoAnalysisResult --> KeyMoment : "包含"
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py#L31-L60)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L12-L91)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py#L1-L60)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L1-L91)
|
||||||
|
|
||||||
|
### 主流水线中的协调机制(main.py)
|
||||||
|
main.py 的 VideoGeneratorPipeline.run 负责串联各阶段:
|
||||||
|
- Step 1:extract_pdf 调用 PDFExtractor(当前为占位),返回结构化数据;
|
||||||
|
- Step 2:extract_video 调用 VideoExtractor(当前为占位),返回转录与时间戳;
|
||||||
|
- Step 3:generate_script 调用 ScriptGenerator(当前为占位),返回脚本;
|
||||||
|
- Step 4:fetch_broll 调用 MediaFetcher(当前为占位),返回 B-Roll;
|
||||||
|
- Step 5:build_render_props 调用 data_builder.build_chart_data,生成图表数据;
|
||||||
|
- Step 6:render_video 调用 Remotion 渲染引擎。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["run()"] --> B["extract_pdf() 占位实现"]
|
||||||
|
A --> C["extract_video() 占位实现"]
|
||||||
|
A --> D["generate_script() 占位实现"]
|
||||||
|
A --> E["fetch_broll() 占位实现"]
|
||||||
|
A --> F["build_render_props() 调用 data_builder.build_chart_data()"]
|
||||||
|
F --> G["写入 render_props.json"]
|
||||||
|
A --> H["render_video() 调用 Remotion"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [main.py](file://main.py#L48-L226)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [main.py](file://main.py#L48-L226)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- 外部依赖:Google Gemini API(PDF解析、脚本生成)、OpenAI Whisper(音频转录)、Pexels API(B-Roll抓取)、FFmpeg(视频处理工具链)。
|
||||||
|
- 环境变量:GEMINI_API_KEY、OPENAI_API_KEY、PEXELS_API_KEY,以及默认财务参数(通胀率、赤字利率、投资回报率)。
|
||||||
|
- 类型契约:前端通过 render-props-types.ts 定义了严格的渲染参数结构,确保后端输出与前端消费一致。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
Env[".env.example<br/>环境变量"] --> Keys["API密钥"]
|
||||||
|
Keys --> Gemini["Google Gemini API"]
|
||||||
|
Keys --> OpenAI["OpenAI Whisper API"]
|
||||||
|
Keys --> Pexels["Pexels API"]
|
||||||
|
FF["FFmpeg"] --> VP["backend/video_processor"]
|
||||||
|
VP --> Main["main.py"]
|
||||||
|
Main --> Types["frontend/src/render-props-types.ts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [.env.example](file://.env.example#L1-L23)
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py#L1-L60)
|
||||||
|
- [main.py](file://main.py#L48-L226)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L117-L135)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [.env.example](file://.env.example#L1-L23)
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L132-L208)
|
||||||
|
|
||||||
|
## 性能考量
|
||||||
|
- API 调用成本控制:PDF 与视频提取均依赖外部 LLM 与转录服务,建议:
|
||||||
|
- 批量请求与缓存重复任务结果;
|
||||||
|
- 控制并发数量,避免触发配额限制;
|
||||||
|
- 使用本地缓存翻译与关键词映射,减少重复 API 调用。
|
||||||
|
- 视频处理优化:音频优先策略可显著降低视频上传体积,建议优先采用该策略;同时合理设置语言与分段长度以提升转录质量。
|
||||||
|
- 模拟计算优化:FinancialSimulator 的循环次数与复杂度与寿命预期成线性关系,可通过参数裁剪或并行化(如多进程)加速大规模场景。
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- API 密钥缺失
|
||||||
|
- 现象:初始化 PDFExtractor/VideoExtractor/ScriptGenerator/MediaFetcher 时抛出异常。
|
||||||
|
- 处理:复制 .env.example 到 .env 并填入有效密钥。
|
||||||
|
- PDF 提取失败
|
||||||
|
- 现象:extract_pdf 占位抛出未实现异常或外部 API 返回错误。
|
||||||
|
- 处理:检查 PDF 是否可读、网络连通性与配额;确认 Gemini API 版本与权限。
|
||||||
|
- 视频转录失败
|
||||||
|
- 现象:extract_video 占位抛出未实现异常或 Whisper 返回空结果。
|
||||||
|
- 处理:确认 FFmpeg 已安装且在 PATH 中;检查音频质量与语言设置。
|
||||||
|
- 渲染参数不匹配
|
||||||
|
- 现象:前端类型校验失败。
|
||||||
|
- 处理:对照 render-props-types.ts 的接口定义,逐项核对字段类型与必填项。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L43)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L14-L49)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L15-L42)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L218-L233)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L117-L135)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
本数据处理子系统以 extractor.py 为核心入口,结合 data_builder.py 的财务模拟能力,形成从 PDF/视频到结构化数据再到渲染参数的完整链路。当前模块多为占位实现,建议优先完成 PDF 与视频提取的提示工程与错误处理,再逐步完善脚本生成、B-Roll 抓取与视频分析策略。通过严格的类型契约与环境配置管理,可确保前后端协作稳定可靠。
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### 初学者数据提取流程图(PDF → 结构化数据)
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["输入:财务规划PDF"] --> B["上传至 Gemini API"]
|
||||||
|
B --> C["提示工程:提取客户信息/资产/收支/事件"]
|
||||||
|
C --> D["结构化JSON输出"]
|
||||||
|
D --> E["main.py 接收并传递给后续模块"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L43)
|
||||||
|
- [main.py](file://main.py#L85-L114)
|
||||||
|
|
||||||
|
### 初学者数据提取流程图(视频 → 结构化数据)
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["输入:访谈视频"] --> B["音频优先策略:提取音频"]
|
||||||
|
B --> C["Whisper 转录与时间戳抽取"]
|
||||||
|
C --> D["结构化字典:转录文本/时间戳/关键字"]
|
||||||
|
D --> E["main.py 接收并传递给脚本与素材模块"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py#L31-L60)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L45-L72)
|
||||||
|
- [main.py](file://main.py#L99-L114)
|
||||||
|
|
||||||
|
### 高级开发者:提示工程策略与数据验证模式
|
||||||
|
- 提示工程策略(PDF)
|
||||||
|
- 明确输出格式要求(JSON 字段清单),限定关键字段(客户姓名、年龄、退休年龄、寿命预期、资产、年收入、年支出、重大事件等);
|
||||||
|
- 引入上下文约束(货币单位、时间起点、生命周期范围),减少歧义;
|
||||||
|
- 使用示例与反例引导模型输出稳定结构。
|
||||||
|
- 提示工程策略(视频)
|
||||||
|
- 在音频优先策略下,先用 Whisper 获取高置信度转录,再用 Gemini 进行摘要与关键帧标注;
|
||||||
|
- 对时间戳与关键词进行一致性校验,避免漂移。
|
||||||
|
- 错误处理机制
|
||||||
|
- 初始化阶段严格校验 API 密钥是否存在;
|
||||||
|
- 对外部 API 返回进行重试与降级策略(如缓存上次结果);
|
||||||
|
- 对空结果与异常进行显式捕获与日志记录。
|
||||||
|
- 数据验证模式
|
||||||
|
- 前端使用 Zod Schema 对 render_props.json 进行运行时校验,保证字段类型与必填项一致;
|
||||||
|
- 后端在构建图表数据时进行边界值与合理性检查(如资产为负时标记 isCritical)。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L15-L72)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L14-L49)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L140-L231)
|
||||||
|
|
||||||
|
### 实际代码示例(扩展新模板/新格式)
|
||||||
|
- 扩展 PDF 模板支持
|
||||||
|
- 在 PDFExtractor.extract 中实现:
|
||||||
|
- 上传 PDF 至 Gemini API;
|
||||||
|
- 构建针对新模板的提示工程,明确字段映射;
|
||||||
|
- 对输出进行模式匹配与校验,必要时回退到二次校正。
|
||||||
|
- 示例路径参考:[PDFExtractor.extract](file://backend/extractor.py#L23-L43)
|
||||||
|
- 处理不同格式的视频文件
|
||||||
|
- 在 VideoExtractor.extract 中实现:
|
||||||
|
- 通过 FFmpeg 提取音频并转码;
|
||||||
|
- 调用 Whisper API 获取转录与时间戳;
|
||||||
|
- 对多说话人场景增加 speaker 字段与分段策略。
|
||||||
|
- 示例路径参考:[VideoExtractor.extract](file://backend/extractor.py#L53-L71)
|
||||||
|
- 视频分析策略扩展
|
||||||
|
- 在 video_processor 中新增策略类,复用 VideoAnalysisResult/VideoSegment/KeyMoment 数据结构;
|
||||||
|
- 在 analyze_video 中注册新策略入口。
|
||||||
|
- 示例路径参考:
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py#L31-L60)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L12-L91)
|
||||||
|
|
@ -0,0 +1,358 @@
|
||||||
|
# 视频数据提取
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件列表**
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py)
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py)
|
||||||
|
- [backend/video_processor/strategy_audio.py](file://backend/video_processor/strategy_audio.py)
|
||||||
|
- [backend/video_processor/strategy_direct.py](file://backend/video_processor/strategy_direct.py)
|
||||||
|
- [tests/test_video_processor.py](file://tests/test_video_processor.py)
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py)
|
||||||
|
- [requirements.txt](file://requirements.txt)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能考量](#性能考量)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录](#附录)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本文件围绕“视频数据提取”功能展开,目标是帮助用户理解如何利用 OpenAI Whisper API 从访谈视频中提取语音转录内容与关键时间戳,并将其整合进视频生成流水线。文档重点覆盖:
|
||||||
|
- VideoExtractor 类的架构设计与职责边界
|
||||||
|
- 与 FFmpeg 的音频提取集成
|
||||||
|
- 调用 Whisper 进行语音识别并生成包含时间点、文本内容与关键词的时间戳序列
|
||||||
|
- 在主流程 main.py 中的 extract_video 方法如何承接该能力,形成完整的数据输出结构(如 transcription 与 timestamps)
|
||||||
|
- 面向新手的处理流程图与面向资深开发者的优化建议(音频预处理策略、关键词提取逻辑、视频片段分割建议、与 B-Roll 匹配机制)
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
该项目采用“Python 后端 + Node.js 前端”的混合架构,视频数据提取位于 Python 后端模块中,主要涉及以下目录与文件:
|
||||||
|
- backend/extractor.py:PDF 与视频提取器的接口定义(当前视频提取仍为占位)
|
||||||
|
- backend/video_processor/:视频分析策略与工具集
|
||||||
|
- __init__.py:统一入口 analyze_video,按策略分发
|
||||||
|
- utils.py:FFmpeg 封装(音频提取、时长查询、视频切片)
|
||||||
|
- schemas.py:标准化的数据结构(VideoSegment、KeyMoment、VideoAnalysisResult)
|
||||||
|
- strategy_audio.py:音频优先策略(FFmpeg → Whisper → 文本分析)
|
||||||
|
- strategy_direct.py:直接视频分析策略(占位,后续扩展)
|
||||||
|
- tests/:独立测试脚本与单元测试
|
||||||
|
- main.py:视频生成流水线入口,负责调用 extract_video 并产出渲染所需数据
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A["main.py<br/>视频生成流水线"] --> B["backend/extractor.py<br/>PDF/视频提取器接口"]
|
||||||
|
A --> C["backend/video_processor/__init__.py<br/>analyze_video 入口"]
|
||||||
|
C --> D["backend/video_processor/strategy_audio.py<br/>音频优先策略"]
|
||||||
|
D --> E["backend/video_processor/utils.py<br/>FFmpeg 工具集"]
|
||||||
|
D --> F["backend/video_processor/schemas.py<br/>数据结构"]
|
||||||
|
C --> G["backend/video_processor/strategy_direct.py<br/>直接视频策略占位"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [main.py](file://main.py#L99-L114)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L45-L84)
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py#L31-L60)
|
||||||
|
- [backend/video_processor/strategy_audio.py](file://backend/video_processor/strategy_audio.py#L30-L187)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L49-L164)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L12-L91)
|
||||||
|
- [backend/video_processor/strategy_direct.py](file://backend/video_processor/strategy_direct.py#L27-L58)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L132-L142)
|
||||||
|
- [main.py](file://main.py#L99-L114)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- VideoExtractor(占位实现):定义了视频提取器的接口与返回结构,包含 transcription、timestamps、segments 字段,用于承载 Whisper 输出与后续脚本/字幕生成。
|
||||||
|
- AudioFirstStrategy:音频优先策略的核心实现,串联 FFmpeg 提取音频、Whisper 识别、文本段落化与关键时刻识别。
|
||||||
|
- FFmpeg 工具集:提供音频提取、视频切片、时长查询等基础能力。
|
||||||
|
- 数据结构:VideoSegment、KeyMoment、VideoAnalysisResult 统一输出格式,便于与前端渲染层对接。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L45-L84)
|
||||||
|
- [backend/video_processor/strategy_audio.py](file://backend/video_processor/strategy_audio.py#L30-L187)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L49-L164)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L12-L91)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
视频数据提取在整体流水线中的位置如下:
|
||||||
|
- 输入:PDF 报告与可选的面试视频
|
||||||
|
- 处理:若提供视频,则调用 extract_video,内部使用 analyze_video(音频优先策略),经 FFmpeg 提取音频后调用 Whisper 获取带时间戳的转录,再转换为 VideoAnalysisResult
|
||||||
|
- 输出:render_props.json 中的 script 与 assets 字段,供前端渲染
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as "用户"
|
||||||
|
participant M as "main.py<br/>VideoGeneratorPipeline"
|
||||||
|
participant E as "backend/extractor.py<br/>VideoExtractor"
|
||||||
|
participant V as "backend/video_processor/__init__.py<br/>analyze_video"
|
||||||
|
participant S as "strategy_audio.py<br/>AudioFirstStrategy"
|
||||||
|
participant U2 as "utils.py<br/>FFmpeg 工具"
|
||||||
|
participant W as "OpenAI Whisper API"
|
||||||
|
U->>M : 运行命令可带 --video
|
||||||
|
M->>M : extract_video()mock
|
||||||
|
M->>E : 调用 extract_video_data(video_path)
|
||||||
|
E->>V : analyze_video(video_path, strategy="audio")
|
||||||
|
V->>S : 初始化 AudioFirstStrategy(config)
|
||||||
|
S->>U2 : extract_audio(video_path)
|
||||||
|
U2-->>S : 返回音频路径
|
||||||
|
S->>W : 调用 Whisperverbose_json,segment 级时间戳
|
||||||
|
W-->>S : 返回转录与时间戳
|
||||||
|
S->>S : _create_segments() 与 _identify_key_moments()
|
||||||
|
S-->>V : 返回 VideoAnalysisResult
|
||||||
|
V-->>E : 返回结果
|
||||||
|
E-->>M : 返回 transcription 与 timestampsmock
|
||||||
|
M-->>U : 产出渲染所需数据
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [main.py](file://main.py#L99-L114)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L74-L84)
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py#L31-L60)
|
||||||
|
- [backend/video_processor/strategy_audio.py](file://backend/video_processor/strategy_audio.py#L54-L187)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L49-L95)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### VideoExtractor 类与接口
|
||||||
|
- 职责:定义视频提取器的统一接口,返回包含 transcription、timestamps、segments 的结构,便于后续脚本生成与字幕同步。
|
||||||
|
- 认证:通过 OPENAI_API_KEY 进行 Whisper 调用认证。
|
||||||
|
- 当前状态:占位实现,抛出未实现异常;实际功能由 analyze_video(音频优先策略)完成。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class VideoExtractor {
|
||||||
|
+api_key : str
|
||||||
|
+extract(video_path) Dict
|
||||||
|
}
|
||||||
|
class PDFExtractor {
|
||||||
|
+api_key : str
|
||||||
|
+extract(pdf_path) Dict
|
||||||
|
}
|
||||||
|
class VideoAnalysisResult {
|
||||||
|
+source_video : str
|
||||||
|
+duration : float
|
||||||
|
+strategy : str
|
||||||
|
+segments : VideoSegment[]
|
||||||
|
+key_moments : KeyMoment[]
|
||||||
|
+metadata : dict
|
||||||
|
+to_dict() dict
|
||||||
|
+get_high_importance_segments() VideoSegment[]
|
||||||
|
+get_quotes() KeyMoment[]
|
||||||
|
}
|
||||||
|
class VideoSegment {
|
||||||
|
+start_time : float
|
||||||
|
+end_time : float
|
||||||
|
+description : str
|
||||||
|
+transcript : str
|
||||||
|
+importance : str
|
||||||
|
+tags : str[]
|
||||||
|
+speaker : str
|
||||||
|
+duration() float
|
||||||
|
+to_dict() dict
|
||||||
|
}
|
||||||
|
class KeyMoment {
|
||||||
|
+timestamp : float
|
||||||
|
+type : str
|
||||||
|
+content : str
|
||||||
|
+context : str
|
||||||
|
+to_dict() dict
|
||||||
|
}
|
||||||
|
VideoExtractor --> VideoAnalysisResult : "生成"
|
||||||
|
VideoAnalysisResult --> VideoSegment : "包含"
|
||||||
|
VideoAnalysisResult --> KeyMoment : "包含"
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L45-L84)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L12-L91)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L45-L84)
|
||||||
|
|
||||||
|
### 音频优先策略(AudioFirstStrategy)
|
||||||
|
- 流程要点:
|
||||||
|
- 使用 FFmpeg 提取音频(单声道、16kHz、PCM 编码)
|
||||||
|
- 调用 Whisper API,使用 verbose_json 与 segment 级时间戳
|
||||||
|
- 将 Whisper 段落映射为 VideoSegment
|
||||||
|
- 简单启发式识别关键时刻(如首段作为 quote)
|
||||||
|
- 返回 VideoAnalysisResult,包含时长、策略、段落与关键时刻
|
||||||
|
- 关键配置:
|
||||||
|
- language:语言代码(默认日语)
|
||||||
|
- model:Whisper 模型(默认 whisper-1)
|
||||||
|
- prompt:可选提示词(预留)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["开始:analyze(video_path)"]) --> Extract["提取音频<br/>extract_audio(video_path)"]
|
||||||
|
Extract --> Transcribe["Whisper 转录<br/>verbose_json + segment 时间戳"]
|
||||||
|
Transcribe --> Segments["构建 VideoSegment 列表"]
|
||||||
|
Segments --> KeyMoments["识别关键时刻启发式"]
|
||||||
|
KeyMoments --> Duration["获取视频时长<br/>get_video_duration(video_path)"]
|
||||||
|
Duration --> Cleanup["清理临时音频文件"]
|
||||||
|
Cleanup --> Result["返回 VideoAnalysisResult"]
|
||||||
|
Result --> End(["结束"])
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/video_processor/strategy_audio.py](file://backend/video_processor/strategy_audio.py#L54-L187)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L49-L95)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L62-L91)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/video_processor/strategy_audio.py](file://backend/video_processor/strategy_audio.py#L30-L187)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L49-L164)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L12-L91)
|
||||||
|
|
||||||
|
### FFmpeg 工具集
|
||||||
|
- extract_audio:将视频解复用为 WAV 音频,单声道、16kHz、PCM 编码,适配 Whisper 推荐采样率
|
||||||
|
- get_video_duration:使用 ffprobe 获取视频时长
|
||||||
|
- slice_video:基于流复制的快速切片(不重编码),适合生成 B-Roll 片段
|
||||||
|
- check_ffmpeg_installed:检测 FFmpeg 与 ffprobe 是否可用
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["输入:video_path"] --> B{"是否指定 output_path?"}
|
||||||
|
B --> |否| C["生成默认输出路径同名.wav"]
|
||||||
|
B --> |是| D["使用指定输出路径"]
|
||||||
|
C --> E["执行 ffmpeg 命令<br/>提取音频单声道、16kHz、PCM"]
|
||||||
|
D --> E
|
||||||
|
E --> F{"执行成功?"}
|
||||||
|
F --> |是| G["返回音频路径"]
|
||||||
|
F --> |否| H["抛出运行时错误"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L49-L95)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L13-L164)
|
||||||
|
|
||||||
|
### 数据结构与输出约定
|
||||||
|
- VideoSegment:包含起止时间、描述、转录、重要性、标签、说话人等字段,并提供 to_dict 序列化
|
||||||
|
- KeyMoment:包含时间戳、类型(quote/chart/emotion/action)、内容与上下文
|
||||||
|
- VideoAnalysisResult:包含源视频、总时长、策略、段落列表、关键时刻列表与元数据,并提供过滤方法
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L12-L91)
|
||||||
|
|
||||||
|
### 在主流程中的作用与数据输出
|
||||||
|
- main.py 的 extract_video 方法在流水线第二步被调用,当前为 mock 实现,返回包含 transcription 与 timestamps 的结构,供后续脚本生成与字幕同步使用
|
||||||
|
- 正式实现应调用 backend.video_processor.analyze_video,返回 VideoAnalysisResult,其中 segments 可映射为 script 的 narration 片段,key_moments 可用于生成字幕与 B-Roll 匹配
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [main.py](file://main.py#L99-L114)
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py#L31-L60)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- 外部依赖:
|
||||||
|
- FFmpeg/ffprobe:用于音频提取与时长查询
|
||||||
|
- OpenAI Whisper:用于语音识别与时间戳提取
|
||||||
|
- requests:用于网络请求(如 B-Roll 下载)
|
||||||
|
- 内部模块耦合:
|
||||||
|
- strategy_audio 依赖 utils(FFmpeg)与 schemas(数据结构)
|
||||||
|
- __init__.py 作为统一入口,按策略导入具体实现
|
||||||
|
- tests 提供独立 CLI 与单元测试,验证依赖安装与策略执行
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "外部依赖"
|
||||||
|
FF["FFmpeg/ffprobe"]
|
||||||
|
WH["OpenAI Whisper"]
|
||||||
|
REQ["requests"]
|
||||||
|
end
|
||||||
|
subgraph "Python 模块"
|
||||||
|
U["utils.py"]
|
||||||
|
SC["schemas.py"]
|
||||||
|
SA["strategy_audio.py"]
|
||||||
|
SD["strategy_direct.py"]
|
||||||
|
INI["__init__.py"]
|
||||||
|
end
|
||||||
|
SA --> U
|
||||||
|
SA --> SC
|
||||||
|
SA --> WH
|
||||||
|
INI --> SA
|
||||||
|
INI --> SD
|
||||||
|
REQ -.-> SD
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L49-L164)
|
||||||
|
- [backend/video_processor/strategy_audio.py](file://backend/video_processor/strategy_audio.py#L30-L187)
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py#L31-L60)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py#L1-L161)
|
||||||
|
|
||||||
|
## 性能考量
|
||||||
|
- 音频采样率与编码:Whisper 推荐 16kHz 单声道 PCM,有助于降低令牌消耗与提升识别精度
|
||||||
|
- 流复制切片:slice_video 使用流复制避免重编码,显著减少 CPU 开销
|
||||||
|
- 分段策略:对于长视频,可考虑按 Whisper 段落或主题变化进行切片,便于并行处理与缓存
|
||||||
|
- API 调用成本:音频优先策略相比直接视频分析更经济,适合对话密集型视频
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- API 密钥问题
|
||||||
|
- OPENAI_API_KEY 未设置:AudioFirstStrategy 初始化会抛出异常
|
||||||
|
- GEMINI_API_KEY 未设置:PDFExtractor 初始化会抛出异常
|
||||||
|
- 依赖缺失
|
||||||
|
- FFmpeg/ffprobe 未安装:check_ffmpeg_installed 返回 False,需先安装
|
||||||
|
- OpenAI 库未安装:测试 CLI 会提示安装
|
||||||
|
- 视频处理失败
|
||||||
|
- FFmpeg 执行失败:extract_audio/slice_video 会抛出运行时错误,检查输入路径与权限
|
||||||
|
- Whisper 请求失败:检查网络与 API 配额
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/video_processor/strategy_audio.py](file://backend/video_processor/strategy_audio.py#L48-L53)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L18-L22)
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py#L70-L91)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L148-L164)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
视频数据提取功能以“音频优先策略”为核心,通过 FFmpeg 提取高质量音频并交由 Whisper 生成精确的时间戳转录,随后将结果标准化为 VideoAnalysisResult,为脚本生成与字幕同步提供可靠数据基础。尽管当前 main.py 中的 extract_video 仍为 mock,但 backend.video_processor 已具备完整的实现骨架,可无缝接入真实流水线。
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### 新手处理流程图(概念示意)
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["输入:面试视频"] --> B["提取音频FFmpeg"]
|
||||||
|
B --> C["Whisper 转录含时间戳"]
|
||||||
|
C --> D["文本段落化VideoSegment"]
|
||||||
|
D --> E["关键信息提取KeyMoment"]
|
||||||
|
E --> F["生成 VideoAnalysisResult"]
|
||||||
|
F --> G["写入 render_props.json供前端渲染"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 高级实践建议
|
||||||
|
- 音频预处理
|
||||||
|
- 降噪与静音裁剪:在 extract_audio 之后增加降噪滤波,提升 Whisper 精度
|
||||||
|
- 多语言支持:根据视频语言动态切换 Whisper language 参数
|
||||||
|
- 关键词提取
|
||||||
|
- 基于主题词典与 TF-IDF 对 Whisper 段落进行关键词抽取,增强 timestamps 的可检索性
|
||||||
|
- 片段分割
|
||||||
|
- 依据 Whisper 段落边界与情感变化进行切片,便于并行处理与缓存
|
||||||
|
- B-Roll 匹配
|
||||||
|
- 将 KeyMoment 的内容与类型(quote/chart/emotion)映射到 Pexels 关键词,自动抓取匹配素材
|
||||||
|
|
||||||
|
### 可运行示例(路径指引)
|
||||||
|
- 独立测试视频分析
|
||||||
|
- 使用测试 CLI:python tests/test_video_cli.py --input sample.mp4 --strategy audio --language ja
|
||||||
|
- 输出:控制台打印或保存为 JSON 文件
|
||||||
|
- 单元测试验证
|
||||||
|
- pytest tests/test_video_processor.py -v
|
||||||
|
- 依赖检查
|
||||||
|
- python tests/test_video_cli.py --check-deps
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py#L1-L161)
|
||||||
|
- [tests/test_video_processor.py](file://tests/test_video_processor.py#L1-L143)
|
||||||
|
|
@ -0,0 +1,455 @@
|
||||||
|
# 架构设计
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文档中引用的文件**
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [data_builder.py](file://backend/data_builder.py)
|
||||||
|
- [extractor.py](file://backend/extractor.py)
|
||||||
|
- [scripter.py](file://backend/scripter.py)
|
||||||
|
- [media_fetcher.py](file://backend/media_fetcher.py)
|
||||||
|
- [Root.tsx](file://src/Root.tsx)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心架构模式](#核心架构模式)
|
||||||
|
4. [后端处理流程](#后端处理流程)
|
||||||
|
5. [数据契约与类型定义](#数据契约与类型定义)
|
||||||
|
6. [前端渲染架构](#前端渲染架构)
|
||||||
|
7. [组件间数据流](#组件间数据流)
|
||||||
|
8. [架构优势分析](#架构优势分析)
|
||||||
|
9. [可扩展性与性能考量](#可扩展性与性能考量)
|
||||||
|
10. [结论](#结论)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本项目是一个混合式Python/Node.js系统,能够将静态的财务规划报告(PDF)和访谈视频自动转换为动态、个性化的解释性视频。系统采用管道-过滤器架构模式,将后端作为"导演"负责数据提取、AI推理和媒体处理,生成`render_props.json`数据契约,前端作为"渲染引擎"消费该数据生成最终视频。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
项目采用清晰的分层结构,将后端Python模块与前端Remotion组件分离,通过`assets`目录共享中间处理文件。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph "根目录"
|
||||||
|
main[main.py]
|
||||||
|
requirements[requirements.txt]
|
||||||
|
package[package.json]
|
||||||
|
assets[assets/]
|
||||||
|
end
|
||||||
|
subgraph "后端"
|
||||||
|
backend[backend/]
|
||||||
|
extractor[extractor.py]
|
||||||
|
scripter[scripter.py]
|
||||||
|
media_fetcher[media_fetcher.py]
|
||||||
|
data_builder[data_builder.py]
|
||||||
|
end
|
||||||
|
subgraph "前端"
|
||||||
|
frontend[frontend/src/]
|
||||||
|
Root[Root.tsx]
|
||||||
|
render_props[render-props-types.ts]
|
||||||
|
end
|
||||||
|
subgraph "资源"
|
||||||
|
input[0_input/]
|
||||||
|
processed[1_processed/]
|
||||||
|
output[2_output/]
|
||||||
|
end
|
||||||
|
main --> backend
|
||||||
|
backend --> processed
|
||||||
|
main --> frontend
|
||||||
|
processed --> frontend
|
||||||
|
input --> backend
|
||||||
|
frontend --> output
|
||||||
|
style backend fill:#f0f8ff,stroke:#333
|
||||||
|
style frontend fill:#fff0f0,stroke:#333
|
||||||
|
style assets fill:#f0fff0,stroke:#333
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [backend/](file://backend/)
|
||||||
|
- [frontend/src/](file://frontend/src/)
|
||||||
|
- [assets/](file://assets/)
|
||||||
|
|
||||||
|
## 核心架构模式
|
||||||
|
系统采用管道-过滤器架构模式,将视频生成流程分解为一系列独立的处理阶段,每个阶段专注于特定任务。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A[输入] --> B[数据提取]
|
||||||
|
B --> C[AI推理]
|
||||||
|
C --> D[媒体处理]
|
||||||
|
D --> E[数据构建]
|
||||||
|
E --> F[render_props.json]
|
||||||
|
F --> G[视频渲染]
|
||||||
|
subgraph "后端: 导演"
|
||||||
|
B
|
||||||
|
C
|
||||||
|
D
|
||||||
|
E
|
||||||
|
end
|
||||||
|
subgraph "前端: 渲染引擎"
|
||||||
|
G
|
||||||
|
end
|
||||||
|
style B fill:#e6f3ff,stroke:#333
|
||||||
|
style C fill:#e6f3ff,stroke:#333
|
||||||
|
style D fill:#e6f3ff,stroke:#333
|
||||||
|
style E fill:#e6f3ff,stroke:#333
|
||||||
|
style G fill:#ffe6e6,stroke:#333
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [main.py](file://main.py#L52-L80)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L132-L141)
|
||||||
|
|
||||||
|
## 后端处理流程
|
||||||
|
后端作为系统的"导演",负责协调整个视频生成流程,从数据提取到最终渲染。
|
||||||
|
|
||||||
|
### 主要处理步骤
|
||||||
|
后端处理流程在`main.py`中通过`VideoGeneratorPipeline`类实现,包含六个主要步骤:
|
||||||
|
|
||||||
|
1. **PDF数据提取**:使用Gemini Pro从PDF报告中提取结构化数据
|
||||||
|
2. **视频处理**:使用Whisper从访谈视频中提取转录和时间戳
|
||||||
|
3. **脚本生成**:使用Gemini Pro生成自然日语叙述脚本
|
||||||
|
4. **B-Roll获取**:从Pexels API获取相关背景视频素材
|
||||||
|
5. **渲染属性构建**:生成`render_props.json`数据契约
|
||||||
|
6. **视频渲染**:调用Remotion生成最终视频
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant CLI as 命令行
|
||||||
|
participant Pipeline as VideoGeneratorPipeline
|
||||||
|
participant Extractor as PDFExtractor
|
||||||
|
participant Scripter as ScriptGenerator
|
||||||
|
participant Fetcher as MediaFetcher
|
||||||
|
participant Builder as FinancialSimulator
|
||||||
|
participant Remotion as Remotion
|
||||||
|
CLI->>Pipeline : run()
|
||||||
|
Pipeline->>Extractor : extract_pdf()
|
||||||
|
Extractor-->>Pipeline : PDF数据
|
||||||
|
Pipeline->>Extractor : extract_video()
|
||||||
|
Extractor-->>Pipeline : 视频数据
|
||||||
|
Pipeline->>Scripter : generate_script()
|
||||||
|
Scripter-->>Pipeline : 叙述脚本
|
||||||
|
Pipeline->>Fetcher : fetch_broll()
|
||||||
|
Fetcher-->>Pipeline : B-Roll素材
|
||||||
|
Pipeline->>Builder : build_render_props()
|
||||||
|
Builder-->>Pipeline : render_props.json
|
||||||
|
Pipeline->>Remotion : render_video()
|
||||||
|
Remotion-->>CLI : 最终视频
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [main.py](file://main.py#L48-L83)
|
||||||
|
- [extractor.py](file://backend/extractor.py)
|
||||||
|
- [scripter.py](file://backend/scripter.py)
|
||||||
|
- [media_fetcher.py](file://backend/media_fetcher.py)
|
||||||
|
- [data_builder.py](file://backend/data_builder.py)
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [main.py](file://main.py#L33-L225)
|
||||||
|
- [extractor.py](file://backend/extractor.py)
|
||||||
|
- [scripter.py](file://backend/scripter.py)
|
||||||
|
- [media_fetcher.py](file://backend/media_fetcher.py)
|
||||||
|
- [data_builder.py](file://backend/data_builder.py)
|
||||||
|
|
||||||
|
## 数据契约与类型定义
|
||||||
|
`render_props.json`是连接Python后端和Remotion前端的关键数据契约,定义了前后端之间的接口规范。
|
||||||
|
|
||||||
|
### 数据契约结构
|
||||||
|
数据契约在`frontend/src/render-props-types.ts`中通过TypeScript接口定义,确保类型安全和数据一致性。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class RenderProps {
|
||||||
|
+meta : Meta
|
||||||
|
+charts : Charts
|
||||||
|
+script : ScriptSegment[]
|
||||||
|
+assets : AssetCollection
|
||||||
|
}
|
||||||
|
class Meta {
|
||||||
|
+title : string
|
||||||
|
+currencyUnit : string
|
||||||
|
+duration : number
|
||||||
|
+clientName? : string
|
||||||
|
+generatedAt? : string
|
||||||
|
}
|
||||||
|
class Charts {
|
||||||
|
+assetDepletion : AssetDepletionChartData
|
||||||
|
+cashFlow : CashFlowChartData
|
||||||
|
}
|
||||||
|
class AssetDepletionChartData {
|
||||||
|
+timeline : YearlySnapshot[]
|
||||||
|
+meta : ChartMeta
|
||||||
|
}
|
||||||
|
class CashFlowChartData {
|
||||||
|
+timeline : CashFlowData[]
|
||||||
|
+meta : ChartMeta
|
||||||
|
}
|
||||||
|
class ScriptSegment {
|
||||||
|
+id : number
|
||||||
|
+text : string
|
||||||
|
+duration : number
|
||||||
|
+type : ScriptSegmentType
|
||||||
|
+assetUrl? : string
|
||||||
|
+chartSync? : ChartSync
|
||||||
|
}
|
||||||
|
class AssetCollection {
|
||||||
|
+broll : Record~string,string~
|
||||||
|
+videoClips : VideoClipReference[]
|
||||||
|
+ttsAudio? : string
|
||||||
|
}
|
||||||
|
RenderProps --> Meta : 包含
|
||||||
|
RenderProps --> Charts : 包含
|
||||||
|
RenderProps --> ScriptSegment : 包含
|
||||||
|
RenderProps --> AssetCollection : 包含
|
||||||
|
Charts --> AssetDepletionChartData : 包含
|
||||||
|
Charts --> CashFlowChartData : 包含
|
||||||
|
ScriptSegment --> ChartSync : 可选
|
||||||
|
AssetCollection --> VideoClipReference : 包含
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [main.py](file://main.py#L152-L197)
|
||||||
|
|
||||||
|
### 运行时验证
|
||||||
|
系统使用Zod库在运行时验证数据契约的完整性,确保前后端数据的一致性。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const RenderPropsSchema = z.object({
|
||||||
|
meta: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
currencyUnit: z.string(),
|
||||||
|
duration: z.number(),
|
||||||
|
clientName: z.string().optional(),
|
||||||
|
generatedAt: z.string().optional(),
|
||||||
|
}),
|
||||||
|
charts: z.object({
|
||||||
|
assetDepletion: z.object({
|
||||||
|
timeline: z.array(YearlySnapshotSchema),
|
||||||
|
meta: z.object({
|
||||||
|
currencyUnit: z.string(),
|
||||||
|
startYear: z.number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
cashFlow: z.object({
|
||||||
|
timeline: z.array(CashFlowDataSchema),
|
||||||
|
meta: z.object({
|
||||||
|
currencyUnit: z.string(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
script: z.array(ScriptSegmentSchema),
|
||||||
|
assets: z.object({
|
||||||
|
broll: z.record(z.string()),
|
||||||
|
videoClips: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
sourcePath: z.string(),
|
||||||
|
startTime: z.number(),
|
||||||
|
duration: z.number(),
|
||||||
|
outputPath: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
ttsAudio: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L139-L231)
|
||||||
|
- [main.py](file://main.py#L73-L75)
|
||||||
|
|
||||||
|
## 前端渲染架构
|
||||||
|
前端作为"渲染引擎",消费`render_props.json`数据契约,使用Remotion框架生成最终视频。
|
||||||
|
|
||||||
|
### 组件注册与组合
|
||||||
|
前端通过`Root.tsx`文件注册所有可渲染的组件组合,每个组合对应一个视频片段。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Root[Root.tsx] --> Composition1[Composition: LifePlanSimulation]
|
||||||
|
Root --> Composition2[Composition: LifePlanBarChart]
|
||||||
|
Root --> Composition3[Composition: CashFlowChart]
|
||||||
|
Root --> Composition4[Composition: DualChartView]
|
||||||
|
Composition1 --> LifePlanChart[LifePlanChart.tsx]
|
||||||
|
Composition2 --> LifePlanBarChart[LifePlanBarChart.tsx]
|
||||||
|
Composition3 --> CashFlowChart[CashFlowChart.tsx]
|
||||||
|
Composition4 --> DualChartView[DualChartView.tsx]
|
||||||
|
DualChartView --> LifePlanChart
|
||||||
|
DualChartView --> CashFlowChart
|
||||||
|
DualChartView --> UnifiedHUD[UnifiedHUD.tsx]
|
||||||
|
style Root fill:#ffebcd,stroke:#333
|
||||||
|
style Composition1 fill:#e6f3ff,stroke:#333
|
||||||
|
style Composition2 fill:#e6f3ff,stroke:#333
|
||||||
|
style Composition3 fill:#e6f3ff,stroke:#333
|
||||||
|
style Composition4 fill:#e6f3ff,stroke:#333
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [Root.tsx](file://src/Root.tsx)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx)
|
||||||
|
|
||||||
|
### 动画与时间控制
|
||||||
|
前端组件使用Remotion的`useCurrentFrame`和`useVideoConfig`钩子实现精确的动画控制。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { durationInFrames } = useVideoConfig();
|
||||||
|
|
||||||
|
// 计算动画进度
|
||||||
|
const exactIndex = interpolate(
|
||||||
|
frame,
|
||||||
|
[0, durationInFrames - 30],
|
||||||
|
[0, timeline.length - 1],
|
||||||
|
{ extrapolateRight: "clamp" }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [Root.tsx](file://src/Root.tsx)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L78-L88)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L83-L92)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L28-L39)
|
||||||
|
|
||||||
|
## 组件间数据流
|
||||||
|
系统中的数据流遵循单向数据流原则,从后端到前端,确保数据的一致性和可预测性。
|
||||||
|
|
||||||
|
### 数据流路径
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[PDF/视频输入] --> B[后端数据提取]
|
||||||
|
B --> C[AI推理与脚本生成]
|
||||||
|
C --> D[B-Roll素材获取]
|
||||||
|
D --> E[财务模拟与数据构建]
|
||||||
|
E --> F[render_props.json]
|
||||||
|
F --> G[前端组件渲染]
|
||||||
|
G --> H[最终视频输出]
|
||||||
|
subgraph "数据转换"
|
||||||
|
B
|
||||||
|
C
|
||||||
|
D
|
||||||
|
E
|
||||||
|
end
|
||||||
|
subgraph "数据消费"
|
||||||
|
G
|
||||||
|
end
|
||||||
|
style B fill:#e6f3ff,stroke:#333
|
||||||
|
style C fill:#e6f3ff,stroke:#333
|
||||||
|
style D fill:#e6f3ff,stroke:#333
|
||||||
|
style E fill:#e6f3ff,stroke:#333
|
||||||
|
style G fill:#ffe6e6,stroke:#333
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [main.py](file://main.py#L52-L80)
|
||||||
|
- [data_builder.py](file://backend/data_builder.py)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [Root.tsx](file://src/Root.tsx)
|
||||||
|
|
||||||
|
### 双向图表同步
|
||||||
|
`DualChartView`组件展示了如何在多个图表之间同步动画状态。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 统一的动画逻辑(两个图表同步)
|
||||||
|
const exactProgress = interpolate(
|
||||||
|
frame,
|
||||||
|
[0, animationEndFrame - 30],
|
||||||
|
[0, cashflowData.timeline.length - 1],
|
||||||
|
{ extrapolateRight: "clamp" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const dataEndIndex = Math.floor(exactProgress);
|
||||||
|
const yearProgress = exactProgress - dataEndIndex;
|
||||||
|
|
||||||
|
// 将现金流数据转换为资产图表格式
|
||||||
|
const lifePlanTimeline: YearlySnapshot[] = useMemo(
|
||||||
|
() => cashflowData.timeline.map((item) => ({
|
||||||
|
year: item.year,
|
||||||
|
ages: { rep: item.age },
|
||||||
|
financials: {
|
||||||
|
income: item.income.total,
|
||||||
|
expenditure: item.totalExpense,
|
||||||
|
assetBalance: item.assetBalance,
|
||||||
|
},
|
||||||
|
events: [],
|
||||||
|
isCritical: item.assetBalance < 0,
|
||||||
|
})),
|
||||||
|
[cashflowData.timeline]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L34-L67)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx)
|
||||||
|
|
||||||
|
## 架构优势分析
|
||||||
|
这种前后端分离的架构设计带来了多项显著优势。
|
||||||
|
|
||||||
|
### 关注点分离
|
||||||
|
系统将不同的职责清晰地分配给后端和前端:
|
||||||
|
|
||||||
|
- **后端**:专注于数据处理、AI推理和业务逻辑
|
||||||
|
- **前端**:专注于视觉呈现、动画效果和用户体验
|
||||||
|
|
||||||
|
这种分离使得团队可以并行开发,提高开发效率。
|
||||||
|
|
||||||
|
### 技术栈独立演进
|
||||||
|
前后端使用不同的技术栈,可以独立演进:
|
||||||
|
|
||||||
|
- **后端**:Python + AI服务(Gemini、Whisper)
|
||||||
|
- **前端**:TypeScript + React + Remotion
|
||||||
|
|
||||||
|
这种独立性允许团队根据需求选择最适合的技术,而不受另一端的限制。
|
||||||
|
|
||||||
|
### 可测试性
|
||||||
|
分离的架构提高了系统的可测试性:
|
||||||
|
|
||||||
|
- **后端**:可以独立测试数据提取、脚本生成等逻辑
|
||||||
|
- **前端**:可以使用Remotion Studio进行可视化开发和测试
|
||||||
|
- **集成**:通过`render_props.json`契约进行集成测试
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L14-L32)
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
|
||||||
|
## 可扩展性与性能考量
|
||||||
|
系统设计考虑了未来的可扩展性和性能优化。
|
||||||
|
|
||||||
|
### 可扩展性设计
|
||||||
|
系统通过模块化设计支持未来的功能扩展:
|
||||||
|
|
||||||
|
- **新的数据源**:可以添加新的提取器(如Excel、数据库)
|
||||||
|
- **新的AI服务**:可以集成其他LLM服务
|
||||||
|
- **新的视频效果**:可以添加新的Remotion组件
|
||||||
|
|
||||||
|
### 潜在性能瓶颈
|
||||||
|
系统可能存在以下性能瓶颈:
|
||||||
|
|
||||||
|
1. **AI API调用延迟**:Gemini和Whisper的API调用可能成为瓶颈
|
||||||
|
2. **大文件处理**:长视频的处理需要大量计算资源
|
||||||
|
3. **网络带宽**:B-Roll素材的下载依赖网络速度
|
||||||
|
|
||||||
|
### 优化策略
|
||||||
|
针对潜在瓶颈,可以采取以下优化策略:
|
||||||
|
|
||||||
|
- **缓存机制**:缓存AI推理结果和下载的素材
|
||||||
|
- **并行处理**:并行执行独立的处理任务
|
||||||
|
- **流式处理**:对大文件进行分块处理
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [extractor.py](file://backend/extractor.py)
|
||||||
|
- [media_fetcher.py](file://backend/media_fetcher.py)
|
||||||
|
- [data_builder.py](file://backend/data_builder.py)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
本项目采用管道-过滤器架构模式,通过清晰的前后端分离设计,实现了从静态财务报告到动态解释视频的自动化转换。后端作为"导演"负责数据提取、AI推理和媒体处理,生成`render_props.json`数据契约;前端作为"渲染引擎"消费该数据,生成最终视频。这种架构设计实现了关注点分离、技术栈独立演进和良好的可测试性,为系统的可扩展性和维护性奠定了坚实基础。
|
||||||
|
|
@ -0,0 +1,364 @@
|
||||||
|
# 图表渲染
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件列表**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx)
|
||||||
|
- [LifePlanBarChart.tsx](file://src/LifePlanBarChart.tsx)
|
||||||
|
- [types.ts](file://src/types.ts)
|
||||||
|
- [Root.tsx](file://src/Root.tsx)
|
||||||
|
- [index.ts](file://src/index.ts)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [mock-data.json](file://src/mock-data.json)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [组件详解](#组件详解)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能考量](#性能考量)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录:数据绑定与扩展示例](#附录数据绑定与扩展示例)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本文件围绕财务可视化图表渲染展开,重点说明 LifePlanChart 与 CashFlowChart 如何基于 render_props.json 中的数据实现动态动画效果;解释 DualChartView 的双图表同步机制与 LifePlanBarChart 的时间轴展示逻辑;阐述 React 组件如何利用 Recharts 库实现平滑数据插值动画并与解说脚本的时间轴精确同步。同时给出面向初学者的数据绑定示例与面向高级开发者的性能优化策略(如 React.memo、虚拟化渲染)及响应式设计模式,并提供自定义样式与新增财务指标的方法。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
前端使用 Remotion 构建视频合成,图表组件通过 Recharts 渲染,数据来自 mock 文件与后端生成的 render_props.json。Root.tsx 将各图表注册为 Composition,供渲染器调用。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "Remotion 前端"
|
||||||
|
IDX["index.ts<br/>注册根组件"]
|
||||||
|
ROOT["Root.tsx<br/>注册 Composition"]
|
||||||
|
LIFEC["LifePlanChart.tsx"]
|
||||||
|
CASH["CashFlowChart.tsx"]
|
||||||
|
DUAL["DualChartView.tsx"]
|
||||||
|
BAR["LifePlanBarChart.tsx"]
|
||||||
|
TYPES["types.ts"]
|
||||||
|
RPT["render-props-types.ts"]
|
||||||
|
end
|
||||||
|
subgraph "数据"
|
||||||
|
MDATA["mock-data.json"]
|
||||||
|
MCASH["mock-cashflow.json"]
|
||||||
|
end
|
||||||
|
IDX --> ROOT
|
||||||
|
ROOT --> LIFEC
|
||||||
|
ROOT --> CASH
|
||||||
|
ROOT --> DUAL
|
||||||
|
ROOT --> BAR
|
||||||
|
LIFEC --> TYPES
|
||||||
|
CASH --> TYPES
|
||||||
|
DUAL --> LIFEC
|
||||||
|
DUAL --> CASH
|
||||||
|
DUAL --> TYPES
|
||||||
|
LIFEC --> MDATA
|
||||||
|
CASH --> MCASH
|
||||||
|
DUAL --> MCASH
|
||||||
|
RPT --> DUAL
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [index.ts](file://src/index.ts#L1-L6)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [LifePlanBarChart.tsx](file://src/LifePlanBarChart.tsx#L1-L258)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json#L1-L200)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [index.ts](file://src/index.ts#L1-L6)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- LifePlanChart:资产余额趋势面积图,支持 HUD 与渐变色背景,基于帧数进行平滑插值,禁用 Recharts 内部动画以保证与 Remotion 同步。
|
||||||
|
- CashFlowChart:收支明细组合图(堆叠柱状+收入线),按年内进度分段增长,禁用 Recharts 内部动画,支持固定域补零与 HUD。
|
||||||
|
- DualChartView:双图表容器,统一动画进度,将 CashFlow 数据转换为 LifePlan 格式并传入子组件,实现同步高亮与动画。
|
||||||
|
- LifePlanBarChart:资产余额柱状图,逐年的柱子线性增长,禁用 Recharts 内部动画,支持固定域补零与颜色区分正负。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [LifePlanBarChart.tsx](file://src/LifePlanBarChart.tsx#L1-L258)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
图表渲染采用“外部驱动帧率 + 内部数据切片”的模式:
|
||||||
|
- 使用 useCurrentFrame 与 interpolate 计算全局动画进度,确保两图同步。
|
||||||
|
- 子组件根据外部传入的 dataEndIndex/yearProgress 进行可见数据切片与插值点注入,避免 Recharts 自身动画导致的跳变。
|
||||||
|
- DualChartView 将 CashFlow 数据映射为 LifePlan 格式,使两条曲线在同一时间轴上对齐。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Frame as "帧计数(useCurrentFrame)"
|
||||||
|
participant Interp as "插值(interpolate)"
|
||||||
|
participant DView as "DualChartView"
|
||||||
|
participant LChart as "LifePlanChart"
|
||||||
|
participant CChart as "CashFlowChart"
|
||||||
|
Frame->>Interp : 计算 exactProgress
|
||||||
|
Interp-->>DView : 返回 dataEndIndex/yearProgress
|
||||||
|
DView->>LChart : 传入 externalDataIndex/externalFraction
|
||||||
|
DView->>CChart : 传入 externalDataIndex/externalYearProgress
|
||||||
|
LChart->>LChart : 切片可见数据 + 插值点
|
||||||
|
CChart->>CChart : 切片可见数据 + 分段增长
|
||||||
|
LChart-->>DView : HUD 当前数据
|
||||||
|
CChart-->>DView : HUD 当前数据
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L31-L77)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L81-L141)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L86-L179)
|
||||||
|
|
||||||
|
## 组件详解
|
||||||
|
|
||||||
|
### LifePlanChart 组件
|
||||||
|
- 动画与插值
|
||||||
|
- 通过 interpolate 将帧数映射到 timeline 索引,得到 dataEndIndex 与 fraction。
|
||||||
|
- 对当前年与下一年进行线性插值,生成 interpolatedPoint 并拼接到可见数据末尾,形成平滑过渡。
|
||||||
|
- 渐变与坐标轴
|
||||||
|
- gradientOffset 基于可见数据计算,确保渐变与屏幕显示一致。
|
||||||
|
- Y 轴基于全量 timeline 计算域,避免资产余额剧烈变化时 Y 轴跳变。
|
||||||
|
- X 轴在固定域(少于 10 年)时设置 domain 与 type,防止偏移。
|
||||||
|
- HUD 与样式
|
||||||
|
- HUD 展示当前年份、资产余额、收入与支出,数值颜色随正负变化。
|
||||||
|
- 禁用 Recharts 内部动画,由外部帧驱动。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["进入组件"]) --> Calc["计算 exactIndex/fraction"]
|
||||||
|
Calc --> Slice["切片 visibleData 到 dataEndIndex"]
|
||||||
|
Slice --> Interp{"fraction > 0 且有下一年?"}
|
||||||
|
Interp -- 是 --> MakePoint["生成插值点 interpolatedPoint"]
|
||||||
|
MakePoint --> Append["追加到 visibleData"]
|
||||||
|
Interp -- 否 --> Skip["跳过插值"]
|
||||||
|
Append --> Grad["计算 gradientOffset"]
|
||||||
|
Skip --> Grad
|
||||||
|
Grad --> Domain["计算 Y/X 轴 domain"]
|
||||||
|
Domain --> Render["渲染 AreaChart/HUD"]
|
||||||
|
Render --> End(["退出"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L81-L190)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
|
||||||
|
### CashFlowChart 组件
|
||||||
|
- 动画与分段增长
|
||||||
|
- 通过 interpolate 得到 exactProgress,拆分为 dataEndIndex 与 yearProgress。
|
||||||
|
- 将一年分为 6 段(5 种支出类别 + 收入线),按段起止计算 getMultiplier,仅对当前年份的支出类目应用该系数,收入线保持不变。
|
||||||
|
- 插值与固定域
|
||||||
|
- 在当前年与下一年之间插入 interpolatedPoint,X 方向延伸至 year + yearProgress,Y 方向保持当前年不变。
|
||||||
|
- 固定域模式下补零年份,避免偏移。
|
||||||
|
- HUD 与样式
|
||||||
|
- HUD 展示当前年份、年龄、总收入、总支出与结余,颜色随正负变化。
|
||||||
|
- 禁用 Recharts 内部动画,由外部帧驱动。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["进入组件"]) --> Progress["计算 exactProgress/yearProgress"]
|
||||||
|
Progress --> Slice["切片 visibleData 到 dataEndIndex"]
|
||||||
|
Slice --> Cur{"是否为当前年?"}
|
||||||
|
Cur -- 是 --> Seg["按段计算 getMultiplier"]
|
||||||
|
Seg --> Apply["对当前年支出项乘以系数"]
|
||||||
|
Cur -- 否 --> Keep["保持原值"]
|
||||||
|
Apply --> Interp["生成 interpolatedPoint"]
|
||||||
|
Keep --> Interp
|
||||||
|
Interp --> Pad{"visibleData < 10?"}
|
||||||
|
Pad -- 是 --> Fill["补零年份"]
|
||||||
|
Pad -- 否 --> Skip["跳过补零"]
|
||||||
|
Fill --> Domain["计算 Y/X 轴 domain"]
|
||||||
|
Skip --> Domain
|
||||||
|
Domain --> Render["渲染 ComposedChart/HUD"]
|
||||||
|
Render --> End(["退出"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L86-L237)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
|
||||||
|
### DualChartView 双图表同步
|
||||||
|
- 统一动画
|
||||||
|
- 使用固定动画结束帧(animationEndFrame)与 interpolate,得到 dataEndIndex 与 yearProgress。
|
||||||
|
- 数据转换
|
||||||
|
- 将 CashFlow 数据映射为 LifePlan 格式(year/ages/financials),并标记 isCritical。
|
||||||
|
- 外部参数传递
|
||||||
|
- 通过 externalDataIndex 与 externalFraction/externalYearProgress 将统一进度传递给两个子组件,确保同步高亮与动画。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant D as "DualChartView"
|
||||||
|
participant L as "LifePlanChart"
|
||||||
|
participant C as "CashFlowChart"
|
||||||
|
D->>D : 计算 exactProgress/dataEndIndex/yearProgress
|
||||||
|
D->>L : 传入 externalDataIndex/dataEndIndex
|
||||||
|
D->>L : 传入 externalFraction/yearProgress
|
||||||
|
D->>C : 传入 externalDataIndex/dataEndIndex
|
||||||
|
D->>C : 传入 externalYearProgress/yearProgress
|
||||||
|
L-->>D : HUD 当前数据
|
||||||
|
C-->>D : HUD 当前数据
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L31-L77)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
### LifePlanBarChart 时间轴展示
|
||||||
|
- 线性增长
|
||||||
|
- 每帧计算 currentYearIndex 与 yearProgress,仅对当前年份的柱子应用 growthMultiplier,实现线性生长。
|
||||||
|
- 固定域补零
|
||||||
|
- 少于 10 年时补零年份,保证坐标轴稳定。
|
||||||
|
- 颜色与 Cell
|
||||||
|
- 通过 Cell 为每个柱子设置颜色,正负值分别使用不同颜色。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanBarChart.tsx](file://src/LifePlanBarChart.tsx#L1-L258)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- 组件间依赖
|
||||||
|
- LifePlanChart 与 CashFlowChart 共同依赖 types.ts 中的 YearlySnapshot/CashFlowData 类型。
|
||||||
|
- DualChartView 同时依赖 LifePlanChart 与 CashFlowChart,并通过 useMemo 将 CashFlow 数据转换为 LifePlan 格式,减少重渲染。
|
||||||
|
- 外部依赖
|
||||||
|
- Recharts:AreaChart、ComposedChart、BarChart、XAxis、YAxis、ReferenceLine、ResponsiveContainer 等。
|
||||||
|
- Remotion:useCurrentFrame、useVideoConfig、interpolate 提供帧驱动的动画。
|
||||||
|
- 数据契约
|
||||||
|
- render-props-types.ts 定义了 render_props.json 的结构,包括 charts、script、assets 等,其中 script 中的 chartSync 字段用于与图表高亮联动。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
RPT["render-props-types.ts"] --> DUAL["DualChartView.tsx"]
|
||||||
|
DUAL --> LIFEC["LifePlanChart.tsx"]
|
||||||
|
DUAL --> CASH["CashFlowChart.tsx"]
|
||||||
|
LIFEC --> TYPES["types.ts"]
|
||||||
|
CASH --> TYPES
|
||||||
|
DUAL --> TYPES
|
||||||
|
ROOT["Root.tsx"] --> LIFEC
|
||||||
|
ROOT --> CASH
|
||||||
|
ROOT --> DUAL
|
||||||
|
ROOT --> BAR["LifePlanBarChart.tsx"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L70-L135)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
|
||||||
|
## 性能考量
|
||||||
|
- 减少重渲染
|
||||||
|
- 使用 React.memo 包裹图表组件,避免 props 未变化时的重复渲染。
|
||||||
|
- 使用 useMemo 缓存转换后的 LifePlan 数据与 meta,降低数组创建成本。
|
||||||
|
- 禁用内部动画
|
||||||
|
- 所有图表均设置 isAnimationActive=false,交由外部帧驱动,避免 Recharts 内部动画与 Remotion 帧驱动冲突。
|
||||||
|
- 坐标轴域稳定性
|
||||||
|
- Y 轴基于全量 timeline 计算域,X 轴在固定域时设置 domain 与 type,避免图表抖动。
|
||||||
|
- 虚拟化与大数据集
|
||||||
|
- 当 timeline 数据量较大时,建议:
|
||||||
|
- 仅渲染可见窗口内的数据(已通过切片实现)。
|
||||||
|
- 对 X 轴 tick 进行采样(interval 已设置)。
|
||||||
|
- 使用 ResponsiveContainer 与固定宽高,避免布局抖动。
|
||||||
|
- 样式与主题
|
||||||
|
- 使用统一的 margin、单位与字体,确保双图表视觉对齐。
|
||||||
|
|
||||||
|
[本节为通用性能建议,不直接分析具体文件]
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- 动画不同步
|
||||||
|
- 检查 DualChartView 是否使用相同的 interpolation 参数与 animationEndFrame。
|
||||||
|
- 确认子组件是否正确接收 externalDataIndex/externalFraction 或 externalYearProgress。
|
||||||
|
- Y 轴跳变
|
||||||
|
- 确保 Y 轴 domain 基于全量 timeline 计算,而非 visibleData。
|
||||||
|
- X 轴偏移
|
||||||
|
- 固定域模式下需设置 domain 与 type,避免坐标轴错位。
|
||||||
|
- HUD 数值异常
|
||||||
|
- 确认 HUD 使用的是原始数据而非已动画化的值(CashFlowChart 中已明确使用 flattenedData[dataEndIndex])。
|
||||||
|
- 解说脚本高亮
|
||||||
|
- render_props.json 的 script 中 chartSync 字段可指定 chartId、highlightYear 与 animationStart,用于在特定帧触发高亮或动画开始。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L31-L77)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L226-L237)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L73-L93)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
本项目通过“外部帧驱动 + 内部数据切片”的方式,实现了与 Remotion 帧率严格同步的财务可视化动画。LifePlanChart 与 CashFlowChart 均禁用 Recharts 内部动画,配合 DualChartView 的统一进度控制,达到双图表同步高亮与平滑过渡的效果。render-props-types.ts 明确了脚本与图表的联动契约,便于后续扩展与维护。
|
||||||
|
|
||||||
|
[本节为总结,不直接分析具体文件]
|
||||||
|
|
||||||
|
## 附录:数据绑定与扩展示例
|
||||||
|
|
||||||
|
### 初学者:数据绑定示例
|
||||||
|
- 使用 Root.tsx 注册 Composition,并将 mock 数据作为 defaultProps 传入图表组件。
|
||||||
|
- LifePlanChart 接收 timeline 与 meta,CashFlowChart 接收 timeline 与 meta。
|
||||||
|
- DualChartView 接收 cashflowData,内部将 CashFlow 数据映射为 LifePlan 格式并传入 LifePlanChart。
|
||||||
|
|
||||||
|
参考路径
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L14-L64)
|
||||||
|
- [mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json#L1-L200)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L14-L64)
|
||||||
|
|
||||||
|
### 高级开发者:性能优化策略
|
||||||
|
- 使用 React.memo 包裹图表组件,避免不必要的重渲染。
|
||||||
|
- 使用 useMemo 缓存昂贵的转换逻辑(如 DualChartView 中的映射)。
|
||||||
|
- 禁用 Recharts 内部动画,统一由外部帧驱动。
|
||||||
|
- 对大数据集进行可见窗口渲染与采样,减少 DOM 节点数量。
|
||||||
|
- 使用 ResponsiveContainer 与固定宽高,避免布局抖动。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L70-L78)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L75-L82)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L49-L67)
|
||||||
|
|
||||||
|
### 响应式设计模式
|
||||||
|
- 使用 ResponsiveContainer 适配不同分辨率。
|
||||||
|
- 通过 memoized 的 domain 与 type 控制坐标轴行为。
|
||||||
|
- 在固定域模式下补零年份,保证视觉一致性。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L166-L190)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L181-L211)
|
||||||
|
- [LifePlanBarChart.tsx](file://src/LifePlanBarChart.tsx#L105-L127)
|
||||||
|
|
||||||
|
### 自定义样式与新增财务指标
|
||||||
|
- 自定义样式
|
||||||
|
- 在 AreaChart/ComposedChart/BarChart 中设置 stroke、fill、strokeWidth、dot、connectNulls 等属性。
|
||||||
|
- 使用 ReferenceLine 添加零线等辅助线。
|
||||||
|
- 新增财务指标
|
||||||
|
- 在 CashFlowData 中增加新字段(如教育支出、保险费用等),并在 CashFlowChart 中添加对应条形与 HUD 展示。
|
||||||
|
- 在 LifePlanChart 的 financials 中加入新指标(如负债、投资收益等),并在 HUD 与图表中相应更新。
|
||||||
|
|
||||||
|
参考路径
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L382-L432)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L332-L340)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L382-L432)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L332-L340)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
# 数据契约
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文档中引用的文件**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [data_builder.py](file://backend/data_builder.py)
|
||||||
|
- [mock-data.json](file://src/mock-data.json)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json)
|
||||||
|
- [Root.tsx](file://src/Root.tsx)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构概述](#架构概述)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖分析](#依赖分析)
|
||||||
|
7. [性能考虑](#性能考虑)
|
||||||
|
8. [故障排除指南](#故障排除指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本文件详细描述了视频渲染系统的数据契约,重点是 `render-props-types.ts` 中定义的 `RenderProps` 接口及其 Zod 验证模式。该数据契约作为 Python 后端(数据生成器)与 Remotion 前端(视频渲染器)之间的通信桥梁,确保了 `render_props.json` 文件在运行时的类型安全。文档还结合 `backend/data_builder.py` 的输出结构,说明了如何同步维护前后端的数据模型。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
该项目采用前后端分离架构,前端使用 Remotion 框架进行视频渲染,后端使用 Python 生成渲染所需的数据。核心数据契约定义在 `frontend/src/render-props-types.ts` 中,而后端数据生成逻辑位于 `backend/data_builder.py`。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "前端 (Remotion)"
|
||||||
|
A[render-props-types.ts] --> B[Root.tsx]
|
||||||
|
B --> C[CashFlowChart]
|
||||||
|
B --> D[LifePlanChart]
|
||||||
|
B --> E[DualChartView]
|
||||||
|
end
|
||||||
|
subgraph "后端 (Python)"
|
||||||
|
F[data_builder.py] --> G[FinancialSimulator]
|
||||||
|
G --> H[build_chart_data]
|
||||||
|
end
|
||||||
|
F --> |生成 render_props.json| A
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L250)
|
||||||
|
- [data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L250)
|
||||||
|
- [data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
`RenderProps` 接口是整个系统的核心数据模型,它定义了渲染视频所需的所有数据结构,包括元信息、图表数据、脚本和资产引用。`YearlySnapshot` 和 `CashFlowData` 是构成财务模拟数据的基础类型,而 `ScriptSegment` 和 `AssetCollection` 则用于控制视频的叙事和媒体资源。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L18-L230)
|
||||||
|
|
||||||
|
## 架构概述
|
||||||
|
系统的数据流始于后端的 `data_builder.py`。`FinancialSimulator` 类根据用户输入的财务参数(如当前年龄、退休年龄、资产、收入和支出)模拟未来每年的财务状况,生成 `YearlySnapshot` 数据。然后,`build_cashflow_data` 函数将这些数据转换为更详细的 `CashFlowData` 格式。最终,`build_chart_data` 函数将这些数据组织成 `render_props.json` 的标准格式,供前端使用。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Backend as "后端 (data_builder.py)"
|
||||||
|
participant Frontend as "前端 (Remotion)"
|
||||||
|
participant Data as "render_props.json"
|
||||||
|
Backend->>Backend : FinancialSimulator.simulate_timeline()
|
||||||
|
Backend->>Backend : build_cashflow_data()
|
||||||
|
Backend->>Backend : build_chart_data()
|
||||||
|
Backend->>Data : 生成 render_props.json
|
||||||
|
Data->>Frontend : 加载数据
|
||||||
|
Frontend->>Frontend : validateRenderProps() 验证
|
||||||
|
Frontend->>Frontend : 渲染视频
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [data_builder.py](file://backend/data_builder.py#L15-L200)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L237-L250)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### RenderProps 接口分析
|
||||||
|
`RenderProps` 接口是数据契约的顶层结构,它包含四个主要部分:`meta`、`charts`、`script` 和 `assets`。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class RenderProps {
|
||||||
|
+meta : Meta
|
||||||
|
+charts : Charts
|
||||||
|
+script : ScriptSegment[]
|
||||||
|
+assets : AssetCollection
|
||||||
|
}
|
||||||
|
class Meta {
|
||||||
|
+title : string
|
||||||
|
+currencyUnit : string
|
||||||
|
+duration : number
|
||||||
|
+clientName? : string
|
||||||
|
+generatedAt? : string
|
||||||
|
}
|
||||||
|
class Charts {
|
||||||
|
+assetDepletion : AssetDepletionChartData
|
||||||
|
+cashFlow : CashFlowChartData
|
||||||
|
}
|
||||||
|
class AssetDepletionChartData {
|
||||||
|
+timeline : YearlySnapshot[]
|
||||||
|
}
|
||||||
|
class CashFlowChartData {
|
||||||
|
+timeline : CashFlowData[]
|
||||||
|
}
|
||||||
|
class YearlySnapshot {
|
||||||
|
+currencyUnit : string
|
||||||
|
+startYear : number
|
||||||
|
}
|
||||||
|
class CashFlowData {
|
||||||
|
+currencyUnit : string
|
||||||
|
}
|
||||||
|
RenderProps --> Meta
|
||||||
|
RenderProps --> Charts
|
||||||
|
Charts --> AssetDepletionChartData
|
||||||
|
Charts --> CashFlowChartData
|
||||||
|
AssetDepletionChartData --> YearlySnapshot
|
||||||
|
CashFlowChartData --> CashFlowData
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L117-L134)
|
||||||
|
|
||||||
|
### 核心数据类型分析
|
||||||
|
`YearlySnapshot` 和 `CashFlowData` 是系统中最核心的两个数据类型,它们分别代表了资产消耗和现金流的年度数据。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class YearlySnapshot {
|
||||||
|
+year : number
|
||||||
|
+ages_rep : number
|
||||||
|
+ages_spouse? : number
|
||||||
|
+financials_income : number
|
||||||
|
+financials_expenditure : number
|
||||||
|
+financials_assetBalance : number
|
||||||
|
+events? : string[]
|
||||||
|
+isCritical? : boolean
|
||||||
|
}
|
||||||
|
class CashFlowData {
|
||||||
|
+year : number
|
||||||
|
+age : number
|
||||||
|
+income_total : number
|
||||||
|
+expenses_living : number
|
||||||
|
+expenses_housing : number
|
||||||
|
+expenses_education : number
|
||||||
|
+expenses_tax : number
|
||||||
|
+expenses_event : number
|
||||||
|
+totalExpense? : number
|
||||||
|
+netCashFlow? : number
|
||||||
|
+assetBalance? : number
|
||||||
|
}
|
||||||
|
YearlySnapshot --> "1" AssetDepletionChartData : "包含"
|
||||||
|
CashFlowData --> "1" CashFlowChartData : "包含"
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L18-L49)
|
||||||
|
|
||||||
|
### 脚本与资产分析
|
||||||
|
`ScriptSegment` 定义了视频的叙事结构,而 `AssetCollection` 则管理了视频中使用的所有媒体资源。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class ScriptSegment {
|
||||||
|
+id : number
|
||||||
|
+text : string
|
||||||
|
+duration : number
|
||||||
|
+type : ScriptSegmentType
|
||||||
|
+assetUrl? : string
|
||||||
|
+chartSync? : ChartSync
|
||||||
|
}
|
||||||
|
class ChartSync {
|
||||||
|
+chartId : "assetDepletion" | "cashFlow"
|
||||||
|
+highlightYear? : number
|
||||||
|
+animationStart? : number
|
||||||
|
}
|
||||||
|
class AssetCollection {
|
||||||
|
+broll : Record<string, string>
|
||||||
|
+videoClips : VideoClipReference[]
|
||||||
|
+ttsAudio? : string
|
||||||
|
}
|
||||||
|
class VideoClipReference {
|
||||||
|
+id : string
|
||||||
|
+sourcePath : string
|
||||||
|
+startTime : number
|
||||||
|
+duration : number
|
||||||
|
+outputPath? : string
|
||||||
|
}
|
||||||
|
ScriptSegment --> ChartSync
|
||||||
|
AssetCollection --> VideoClipReference
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L81-L111)
|
||||||
|
|
||||||
|
## 依赖分析
|
||||||
|
该系统的关键依赖是 `zod` 库,它用于在运行时验证 `render_props.json` 的数据结构。`render-props-types.ts` 中的 `RenderPropsSchema` 确保了从后端传来的数据符合预期的格式,防止了因数据错误导致的渲染失败。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[render-props-types.ts] --> B[zod]
|
||||||
|
B --> C[RenderPropsSchema]
|
||||||
|
C --> D[validateRenderProps]
|
||||||
|
C --> E[isRenderProps]
|
||||||
|
F[data_builder.py] --> A
|
||||||
|
A --> G[Root.tsx]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L12-L250)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L12-L250)
|
||||||
|
|
||||||
|
## 性能考虑
|
||||||
|
Zod 的运行时验证虽然增加了少量开销,但极大地提高了系统的健壮性。对于大型数据集,可以考虑在开发环境中进行严格验证,在生产环境中使用 `isRenderProps` 进行轻量级检查,以平衡安全性和性能。
|
||||||
|
|
||||||
|
## 故障排除指南
|
||||||
|
当视频渲染失败时,最常见的原因是 `render_props.json` 数据格式错误。应首先使用 `validateRenderProps` 函数检查数据的有效性。如果验证失败,Zod 会提供详细的错误信息,指出具体是哪个字段不符合预期。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L241-L250)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
该视频渲染系统的数据契约设计清晰,通过 TypeScript 接口和 Zod 模式实现了前后端之间的强类型通信。`RenderProps` 接口及其子类型构成了一个完整、可扩展的数据模型,能够支持复杂的财务模拟和视频叙事。通过 `validateRenderProps` 和 `isRenderProps` 辅助函数,系统在运行时具备了强大的数据验证能力,确保了渲染过程的稳定性和可靠性。
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
# 视频合成
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文档中引用的文件**
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [Root.tsx](file://src/Root.tsx)
|
||||||
|
- [Composition.tsx](file://src/Composition.tsx)
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [types.ts](file://src/types.ts)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构概述](#架构概述)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖分析](#依赖分析)
|
||||||
|
7. [性能考虑](#性能考虑)
|
||||||
|
8. [故障排除指南](#故障排除指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本项目是一个混合型Python/Node.js系统,能够将静态的财务规划报告(PDF)和访谈视频自动转换为动态、个性化的3-5分钟解说视频。系统整合了动画图表、AI生成的解说音频、情境化的B-Roll素材以及同步的访谈片段,为用户提供直观的财务规划可视化体验。视频合成引擎的核心是Remotion框架,它通过精确的时间轴管理和分层合成技术,协调各种媒体元素的时空编排。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
该项目采用前后端分离的架构,Python后端负责数据处理和流程编排,Node.js前端使用Remotion进行视频渲染。系统通过`render_props.json`文件作为数据契约,实现前后端的解耦。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "Python Backend"
|
||||||
|
main[main.py]
|
||||||
|
backend[backend/]
|
||||||
|
end
|
||||||
|
subgraph "Data Contract"
|
||||||
|
props[render_props.json]
|
||||||
|
end
|
||||||
|
subgraph "Node.js Frontend"
|
||||||
|
frontend[src/]
|
||||||
|
remotion[Remotion]
|
||||||
|
end
|
||||||
|
main --> props
|
||||||
|
backend --> props
|
||||||
|
props --> frontend
|
||||||
|
frontend --> remotion
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [main.py](file://main.py#L1-L305)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [main.py](file://main.py#L1-L305)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L1-L246)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
视频合成引擎的核心在于`Composition`和`Root`组件如何协调B-Roll素材、解说音频、图表动画和转场效果。`Root`组件作为Remotion的入口,注册了多个`Composition`,每个`Composition`对应一个独立的视频片段或动画。`UnifiedHUD`组件则负责集成多个可视化元素,形成统一的用户界面。`main.py`中的`render_video`方法解析了Remotion渲染命令的调用流程和props传递机制,实现了从数据到视频的转换。
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [Composition.tsx](file://src/Composition.tsx#L1-L4)
|
||||||
|
- [main.py](file://main.py#L200-L225)
|
||||||
|
|
||||||
|
## 架构概述
|
||||||
|
系统架构分为数据处理层和视频渲染层。数据处理层由Python后端实现,负责从PDF和视频中提取数据、生成脚本、获取B-Roll素材并构建渲染所需的props。视频渲染层由Remotion框架实现,接收props并生成最终的视频文件。两层之间通过`render_props.json`进行数据交换,确保了系统的模块化和可维护性。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "Data Processing Layer"
|
||||||
|
A[PDF/Video Input]
|
||||||
|
B[Data Extraction]
|
||||||
|
C[Script Generation]
|
||||||
|
D[B-Roll Fetching]
|
||||||
|
E[Chart Data Simulation]
|
||||||
|
F[Build render_props.json]
|
||||||
|
end
|
||||||
|
subgraph "Video Rendering Layer"
|
||||||
|
G[Remotion]
|
||||||
|
H[Composition]
|
||||||
|
I[Root]
|
||||||
|
J[UnifiedHUD]
|
||||||
|
K[Charts]
|
||||||
|
L[Final Video Output]
|
||||||
|
end
|
||||||
|
A --> B
|
||||||
|
B --> C
|
||||||
|
C --> D
|
||||||
|
D --> E
|
||||||
|
E --> F
|
||||||
|
F --> G
|
||||||
|
G --> H
|
||||||
|
H --> I
|
||||||
|
I --> J
|
||||||
|
I --> K
|
||||||
|
J --> L
|
||||||
|
K --> L
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [main.py](file://main.py#L48-L83)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
### Composition和Root组件分析
|
||||||
|
`Root`组件是Remotion的根组件,它通过`<Composition>`标签注册了多个视频片段,每个片段对应一个独立的React组件。这些组件共享`render_props.json`中的数据,实现了数据驱动的视频合成。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class Root {
|
||||||
|
+RemotionRoot()
|
||||||
|
}
|
||||||
|
class Composition {
|
||||||
|
+id : string
|
||||||
|
+component : React.FC
|
||||||
|
+durationInFrames : number
|
||||||
|
+fps : number
|
||||||
|
+width : number
|
||||||
|
+height : number
|
||||||
|
+defaultProps : object
|
||||||
|
}
|
||||||
|
Root --> Composition : "registers"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L14-L67)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [Composition.tsx](file://src/Composition.tsx#L1-L4)
|
||||||
|
|
||||||
|
### UnifiedHUD组件分析
|
||||||
|
`UnifiedHUD`组件集成了年份、年龄、收入、支出、净现金流和资产余额等多个可视化元素,形成了统一的用户界面。该组件通过props接收实时数据,并在视频播放过程中动态更新。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class UnifiedHUD {
|
||||||
|
+year : number
|
||||||
|
+age : number
|
||||||
|
+income : number
|
||||||
|
+expense : number
|
||||||
|
+netCashFlow : number
|
||||||
|
+assetBalance : number
|
||||||
|
+currencyUnit : string
|
||||||
|
}
|
||||||
|
class MetricCard {
|
||||||
|
+label : string
|
||||||
|
+value : number
|
||||||
|
+unit : string
|
||||||
|
+color : string
|
||||||
|
+icon : string
|
||||||
|
}
|
||||||
|
UnifiedHUD --> MetricCard : "contains"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [UnifiedHUD.tsx](file://src/UnifiedHUD.tsx#L1-L164)
|
||||||
|
|
||||||
|
### 渲染流程分析
|
||||||
|
`main.py`中的`render_video`方法负责调用Remotion命令行工具进行视频渲染。该方法构建了完整的命令行参数,包括入口文件、输出路径和props文件路径。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Pipeline as VideoGeneratorPipeline
|
||||||
|
participant Render as render_video
|
||||||
|
participant Remotion as Remotion CLI
|
||||||
|
Pipeline->>Render : render_video(props_file)
|
||||||
|
Render->>Render : Build command
|
||||||
|
Render->>Remotion : Execute npx remotion render
|
||||||
|
Remotion-->>Render : Return output path
|
||||||
|
Render-->>Pipeline : Return output path
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [main.py](file://main.py#L200-L225)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [main.py](file://main.py#L200-L225)
|
||||||
|
|
||||||
|
## 依赖分析
|
||||||
|
系统依赖关系清晰,前后端通过`render_props.json`进行数据交换,避免了紧耦合。Python后端依赖Gemini Pro、Whisper和Pexels API进行数据处理,Node.js前端依赖Remotion、Recharts和Zod进行视频渲染和数据验证。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
main[main.py] --> data_builder[backend/data_builder.py]
|
||||||
|
main --> scripter[backend/scripter.py]
|
||||||
|
main --> media_fetcher[backend/media_fetcher.py]
|
||||||
|
main --> extractor[backend/extractor.py]
|
||||||
|
data_builder --> types[frontend/src/render-props-types.ts]
|
||||||
|
Root[Root.tsx] --> LifePlanChart[LifePlanChart.tsx]
|
||||||
|
Root --> CashFlowChart[CashFlowChart.tsx]
|
||||||
|
Root --> DualChartView[DualChartView.tsx]
|
||||||
|
DualChartView --> UnifiedHUD[UnifiedHUD.tsx]
|
||||||
|
LifePlanChart --> types[types.ts]
|
||||||
|
CashFlowChart --> types[types.ts]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [main.py](file://main.py#L33-L305)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [main.py](file://main.py#L1-L305)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
|
||||||
|
## 性能考虑
|
||||||
|
视频合成引擎在性能方面进行了多项优化。首先,通过`React.memo`对图表组件进行记忆化,避免不必要的重渲染。其次,使用`useMemo`对计算密集型数据进行缓存,如`DualChartView`中的时间线转换。此外,Remotion的帧级控制允许精确的动画同步,减少了运行时计算开销。
|
||||||
|
|
||||||
|
## 故障排除指南
|
||||||
|
常见问题包括API密钥缺失、FFmpeg未安装和渲染失败。解决方案包括检查`.env`文件中的API密钥、确保FFmpeg已正确安装以及验证`frontend/src/`目录结构的完整性。对于渲染问题,可以使用`--dry-run`参数生成`render_props.json`而不进行实际渲染,便于调试数据问题。
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L218-L232)
|
||||||
|
- [test_video_cli.py](file://tests/test_video_cli.py#L71-L78)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
该视频合成引擎通过精心设计的架构和组件,实现了从静态数据到动态视频的自动化转换。`Composition`和`Root`组件的协调机制确保了各种媒体素材的精确时空编排,而`UnifiedHUD`组件则提供了统一的用户界面。系统通过`render_props.json`实现了前后端的解耦,提高了可维护性和扩展性。对于初学者,系统提供了清晰的流程图和文档;对于高级开发者,其模块化设计和优化策略为性能调优和功能扩展提供了坚实基础。
|
||||||
|
|
@ -0,0 +1,430 @@
|
||||||
|
# 视频渲染
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文档引用的文件**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [index.ts](file://src/index.ts)
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx)
|
||||||
|
- [Composition.tsx](file://src/Composition.tsx)
|
||||||
|
- [Root.tsx](file://src/Root.tsx)
|
||||||
|
- [types.ts](file://src/types.ts)
|
||||||
|
- [data_builder.py](file://backend/data_builder.py)
|
||||||
|
- [mock-data.json](file://src/mock-data.json)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构概述](#架构概述)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖分析](#依赖分析)
|
||||||
|
7. [性能考虑](#性能考虑)
|
||||||
|
8. [故障排除指南](#故障排除指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录](#附录)(如有必要)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本项目是一个自动化人生规划视频生成系统,旨在将财务数据和人生事件转化为专业的可视化视频报告。系统采用前后端分离架构,后端使用Python进行数据处理和脚本生成,前端使用Remotion框架进行视频渲染。核心功能包括:从PDF报告中提取客户财务数据,通过Gemini Pro生成叙述性脚本,使用Pexels API获取B-Roll素材,模拟未来资产走势,并最终生成包含动态图表和同步解说的专业视频。该系统特别适用于金融咨询领域,能够快速为客户提供个性化的可视化人生规划报告。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
项目采用清晰的分层架构,分为前端渲染、后端处理和资源资产三大模块。前端使用Remotion框架构建视频组件,后端使用Python处理数据和生成脚本,资产目录存储输入、处理中和输出的文件。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "前端渲染"
|
||||||
|
frontend[frontend]
|
||||||
|
src[src]
|
||||||
|
index_ts[index.ts]
|
||||||
|
render_props_types[render-props-types.ts]
|
||||||
|
end
|
||||||
|
subgraph "后端处理"
|
||||||
|
backend[backend]
|
||||||
|
main_py[main.py]
|
||||||
|
data_builder[data_builder.py]
|
||||||
|
extractor[extractor.py]
|
||||||
|
scripter[scripter.py]
|
||||||
|
media_fetcher[media_fetcher.py]
|
||||||
|
end
|
||||||
|
subgraph "资源资产"
|
||||||
|
assets[assets]
|
||||||
|
input[0_input]
|
||||||
|
processed[1_processed]
|
||||||
|
output[2_output]
|
||||||
|
end
|
||||||
|
main_py --> data_builder
|
||||||
|
main_py --> extractor
|
||||||
|
main_py --> scripter
|
||||||
|
main_py --> media_fetcher
|
||||||
|
data_builder --> processed
|
||||||
|
scripter --> processed
|
||||||
|
media_fetcher --> processed
|
||||||
|
frontend --> processed
|
||||||
|
processed --> output
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [main.py](file://main.py#L1-L305)
|
||||||
|
- [data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [main.py](file://main.py#L1-L305)
|
||||||
|
- [backend](file://backend#L1-L200)
|
||||||
|
- [frontend](file://frontend#L1-L251)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
系统的核心组件包括数据契约定义、视频渲染逻辑和后端处理管道。`render-props-types.ts`文件定义了前后端通信的数据契约,确保类型安全的数据传递。`index.ts`是Remotion渲染的入口点,`main.py`是后端处理的主控脚本。这些组件协同工作,实现了从原始数据到最终视频的完整转换流程。
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [index.ts](file://src/index.ts#L1-L6)
|
||||||
|
- [main.py](file://main.py#L1-L305)
|
||||||
|
|
||||||
|
## 架构概述
|
||||||
|
系统采用端到端的自动化视频生成架构,从前端数据输入到后端处理,再到最终视频渲染,形成了一个完整的闭环。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph "输入"
|
||||||
|
PDF[PDF报告]
|
||||||
|
Video[视频采访]
|
||||||
|
end
|
||||||
|
subgraph "后端处理"
|
||||||
|
Extractor[数据提取器]
|
||||||
|
Scripter[脚本生成器]
|
||||||
|
DataBuilder[数据构建器]
|
||||||
|
MediaFetcher[媒体获取器]
|
||||||
|
RenderProps[render_props.json]
|
||||||
|
end
|
||||||
|
subgraph "前端渲染"
|
||||||
|
Remotion[Remotion渲染器]
|
||||||
|
Charts[图表组件]
|
||||||
|
BRoll[B-Roll叠加]
|
||||||
|
Transitions[转场效果]
|
||||||
|
end
|
||||||
|
subgraph "输出"
|
||||||
|
FinalVideo[最终视频]
|
||||||
|
end
|
||||||
|
PDF --> Extractor
|
||||||
|
Video --> Extractor
|
||||||
|
Extractor --> Scripter
|
||||||
|
Extractor --> DataBuilder
|
||||||
|
Scripter --> RenderProps
|
||||||
|
DataBuilder --> RenderProps
|
||||||
|
MediaFetcher --> RenderProps
|
||||||
|
RenderProps --> Remotion
|
||||||
|
Remotion --> Charts
|
||||||
|
Remotion --> BRoll
|
||||||
|
Remotion --> Transitions
|
||||||
|
Charts --> FinalVideo
|
||||||
|
BRoll --> FinalVideo
|
||||||
|
Transitions --> FinalVideo
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [main.py](file://main.py#L1-L305)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
### 数据契约与类型安全
|
||||||
|
系统通过TypeScript接口和Zod模式确保前后端数据传递的类型安全。`render-props-types.ts`文件定义了`RenderProps`接口,与后端生成的`render_props.json`结构完全匹配。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class RenderProps {
|
||||||
|
+meta : Meta
|
||||||
|
+charts : Charts
|
||||||
|
+script : ScriptSegment[]
|
||||||
|
+assets : AssetCollection
|
||||||
|
}
|
||||||
|
class Meta {
|
||||||
|
+title : string
|
||||||
|
+currencyUnit : string
|
||||||
|
+duration : number
|
||||||
|
+clientName? : string
|
||||||
|
+generatedAt? : string
|
||||||
|
}
|
||||||
|
class Charts {
|
||||||
|
+assetDepletion : AssetDepletionChartData
|
||||||
|
+cashFlow : CashFlowChartData
|
||||||
|
}
|
||||||
|
class ScriptSegment {
|
||||||
|
+id : number
|
||||||
|
+text : string
|
||||||
|
+duration : number
|
||||||
|
+type : ScriptSegmentType
|
||||||
|
+assetUrl? : string
|
||||||
|
+chartSync? : ChartSync
|
||||||
|
}
|
||||||
|
class AssetCollection {
|
||||||
|
+broll : Record~string, string~
|
||||||
|
+videoClips : VideoClipReference[]
|
||||||
|
+ttsAudio? : string
|
||||||
|
}
|
||||||
|
RenderProps --> Meta
|
||||||
|
RenderProps --> Charts
|
||||||
|
RenderProps --> ScriptSegment
|
||||||
|
RenderProps --> AssetCollection
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L117-L251)
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [main.py](file://main.py#L145-L198)
|
||||||
|
|
||||||
|
### 视频合成逻辑
|
||||||
|
`src/index.ts`文件是Remotion渲染的入口点,通过`registerRoot`函数注册根组件。实际的视频合成逻辑在`Root.tsx`中实现,定义了多个`Composition`组件,每个组件对应一个可渲染的视频片段。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Main as main.py
|
||||||
|
participant Render as Remotion
|
||||||
|
participant Index as index.ts
|
||||||
|
participant Root as Root.tsx
|
||||||
|
Main->>Main : run()
|
||||||
|
Main->>Main : build_render_props()
|
||||||
|
Main->>Main : save render_props.json
|
||||||
|
Main->>Render : render_video()
|
||||||
|
Render->>Index : npx remotion render
|
||||||
|
Index->>Root : registerRoot()
|
||||||
|
Root->>Root : Define Compositions
|
||||||
|
Root->>Charts : Pass defaultProps
|
||||||
|
Charts->>Charts : Render video frames
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [index.ts](file://src/index.ts#L1-L6)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [main.py](file://main.py#L200-L225)
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [index.ts](file://src/index.ts#L1-L6)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [main.py](file://main.py#L200-L225)
|
||||||
|
|
||||||
|
### 图表动画实现
|
||||||
|
系统实现了两种主要的财务图表:资产消耗图和现金流图。`LifePlanChart.tsx`使用Recharts库创建面积图,展示资产余额随时间的变化趋势。`CashFlowChart.tsx`使用组合图展示收入与支出的对比。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start([开始渲染]) --> Animation["动画逻辑: interpolate(frame)"]
|
||||||
|
Animation --> DataSlice["数据切片: slice(0, dataEndIndex + 1)"]
|
||||||
|
DataSlice --> Interpolation["插值计算: 平滑过渡"]
|
||||||
|
Interpolation --> Gradient["渐变逻辑: 蓝色(正) 红色(负)"]
|
||||||
|
Gradient --> Render["渲染图表"]
|
||||||
|
Render --> HUD["渲染HUD显示"]
|
||||||
|
HUD --> End([完成渲染])
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [types.ts](file://src/types.ts#L1-L30)
|
||||||
|
|
||||||
|
### 双图表视图
|
||||||
|
`DualChartView.tsx`组件实现了上下双图表布局,同时展示资产趋势和收支明细。该组件通过统一的动画逻辑确保两个图表的同步。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class DualChartView {
|
||||||
|
+cashflowData : CashFlowData
|
||||||
|
+frame : number
|
||||||
|
+durationInFrames : number
|
||||||
|
+exactProgress : number
|
||||||
|
+dataEndIndex : number
|
||||||
|
+yearProgress : number
|
||||||
|
+currentData : CashFlowData
|
||||||
|
+lifePlanTimeline : YearlySnapshot[]
|
||||||
|
+lifePlanMeta : Meta
|
||||||
|
}
|
||||||
|
class LifePlanChart {
|
||||||
|
+timeline : YearlySnapshot[]
|
||||||
|
+meta : Meta
|
||||||
|
+hideHeader : boolean
|
||||||
|
+externalDataIndex : number
|
||||||
|
+externalFraction : number
|
||||||
|
}
|
||||||
|
class CashFlowChart {
|
||||||
|
+timeline : CashFlowData[]
|
||||||
|
+meta : Meta
|
||||||
|
+hideHeader : boolean
|
||||||
|
+externalDataIndex : number
|
||||||
|
+externalYearProgress : number
|
||||||
|
}
|
||||||
|
DualChartView --> LifePlanChart
|
||||||
|
DualChartView --> CashFlowChart
|
||||||
|
DualChartView --> "useMemo() lifePlanTimeline"
|
||||||
|
DualChartView --> "useMemo() lifePlanMeta"
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
|
||||||
|
### 解说同步与B-Roll叠加
|
||||||
|
系统通过`ScriptSegment`类型实现解说与视觉元素的同步。`chartSync`字段允许在特定时间点高亮图表数据,`assetUrl`字段用于叠加B-Roll素材。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
SCRIPT_SEGMENT {
|
||||||
|
int id PK
|
||||||
|
string text
|
||||||
|
float duration
|
||||||
|
string type
|
||||||
|
string assetUrl FK
|
||||||
|
int chartSync.chartId
|
||||||
|
int chartSync.highlightYear
|
||||||
|
int chartSync.animationStart
|
||||||
|
}
|
||||||
|
ASSET_COLLECTION {
|
||||||
|
string broll.keyword PK
|
||||||
|
string broll.url
|
||||||
|
string videoClips.id PK
|
||||||
|
string videoClips.sourcePath
|
||||||
|
float videoClips.startTime
|
||||||
|
float videoClips.duration
|
||||||
|
string videoClips.outputPath
|
||||||
|
string ttsAudio
|
||||||
|
}
|
||||||
|
SCRIPT_SEGMENT ||--o{ ASSET_COLLECTION : "references"
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L74-L114)
|
||||||
|
- [main.py](file://main.py#L120-L134)
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L74-L114)
|
||||||
|
- [main.py](file://main.py#L120-L134)
|
||||||
|
|
||||||
|
### 后端渲染命令调用
|
||||||
|
`main.py`中的`render_video`方法负责调用Remotion进行视频渲染,通过`subprocess`执行Node.js命令。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Pipeline as VideoGeneratorPipeline
|
||||||
|
participant Subprocess as subprocess
|
||||||
|
participant Remotion as Remotion CLI
|
||||||
|
Pipeline->>Pipeline : render_video(props_file)
|
||||||
|
Pipeline->>Subprocess : cmd = ["npx", "remotion", "render", ...]
|
||||||
|
Subprocess->>Remotion : Execute command
|
||||||
|
Remotion->>Remotion : Load render_props.json
|
||||||
|
Remotion->>Remotion : Render video frames
|
||||||
|
Remotion-->>Subprocess : Return result
|
||||||
|
Subprocess-->>Pipeline : Return output_path
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [main.py](file://main.py#L200-L225)
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [main.py](file://main.py#L200-L225)
|
||||||
|
|
||||||
|
## 依赖分析
|
||||||
|
系统依赖关系清晰,后端模块之间耦合度低,前端组件可复用性强。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
main.VideoGeneratorPipeline[VideoGeneratorPipeline] --> main.VideoGeneratorPipeline.run[run]
|
||||||
|
main.VideoGeneratorPipeline.run --> main.VideoGeneratorPipeline.extract_pdf[extract_pdf]
|
||||||
|
main.VideoGeneratorPipeline.run --> main.VideoGeneratorPipeline.extract_video[extract_video]
|
||||||
|
main.VideoGeneratorPipeline.run --> main.VideoGeneratorPipeline.generate_script[generate_script]
|
||||||
|
main.VideoGeneratorPipeline.run --> main.VideoGeneratorPipeline.fetch_broll[fetch_broll]
|
||||||
|
main.VideoGeneratorPipeline.run --> main.VideoGeneratorPipeline.build_render_props[build_render_props]
|
||||||
|
main.VideoGeneratorPipeline.run --> main.VideoGeneratorPipeline.render_video[render_video]
|
||||||
|
main.VideoGeneratorPipeline.build_render_props --> backend.data_builder.build_chart_data[build_chart_data]
|
||||||
|
backend.data_builder.build_chart_data --> backend.data_builder.FinancialSimulator[FinancialSimulator]
|
||||||
|
backend.data_builder.FinancialSimulator --> backend.data_builder.FinancialSimulator.simulate_timeline[simulate_timeline]
|
||||||
|
backend.data_builder.FinancialSimulator.simulate_timeline --> backend.data_builder.FinancialSimulator._get_event_cost[_get_event_cost]
|
||||||
|
backend.data_builder.FinancialSimulator.simulate_timeline --> backend.data_builder.FinancialSimulator._get_event_labels[_get_event_labels]
|
||||||
|
backend.data_builder.FinancialSimulator.simulate_timeline --> backend.data_builder.FinancialSimulator.build_cashflow_data[build_cashflow_data]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图表来源**
|
||||||
|
- [main.py](file://main.py#L33-L83)
|
||||||
|
- [data_builder.py](file://backend/data_builder.py#L15-L200)
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [main.py](file://main.py#L33-L83)
|
||||||
|
- [data_builder.py](file://backend/data_builder.py#L15-L200)
|
||||||
|
|
||||||
|
## 性能考虑
|
||||||
|
系统在性能方面采用了多种优化策略。前端使用`React.memo`避免不必要的组件重新渲染,`useMemo`缓存计算结果。动画插值使用`interpolate`函数实现平滑过渡,避免了帧率波动。图表的Y轴域基于完整数据集计算,防止了Y轴的跳动。对于少于10个数据点的情况,系统会填充空年份以保持图表对齐。
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L70-L77)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L75-L82)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx#L50-L67)
|
||||||
|
|
||||||
|
## 故障排除指南
|
||||||
|
常见问题包括数据契约不匹配、渲染命令失败和图表显示异常。确保`render-props-types.ts`与后端输出完全匹配,使用Zod模式进行运行时验证。检查`render_props.json`文件是否存在且格式正确。确认Remotion CLI已正确安装,Node.js版本兼容。对于图表问题,检查数据格式是否符合预期,特别是年份和数值的类型。
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L241-L249)
|
||||||
|
- [main.py](file://main.py#L73-L76)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts#L1-L12)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
本视频渲染合成系统成功实现了从财务数据到专业视频报告的自动化转换。通过严格的类型定义和清晰的架构设计,系统确保了前后端数据传递的可靠性和类型安全性。Remotion框架的使用使得复杂的动画和视觉效果得以高效实现。未来可扩展方向包括支持更多图表类型、增强B-Roll智能匹配算法、优化渲染性能以处理更长的视频内容。
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
### 自定义视频模板示例
|
||||||
|
```typescript
|
||||||
|
// 自定义新图表组件
|
||||||
|
import React from "react";
|
||||||
|
import { LineChart, Line, XAxis, YAxis } from "recharts";
|
||||||
|
import { useCurrentFrame, useVideoConfig, interpolate } from "remotion";
|
||||||
|
|
||||||
|
interface CustomChartProps {
|
||||||
|
data: Array<{ year: number; value: number }>;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomChart: React.FC<CustomChartProps> = ({ data, title }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { durationInFrames } = useVideoConfig();
|
||||||
|
|
||||||
|
const progress = interpolate(frame, [0, durationInFrames], [0, data.length - 1]);
|
||||||
|
const visibleData = data.slice(0, Math.floor(progress) + 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: "100%", height: "100%" }}>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<LineChart data={visibleData}>
|
||||||
|
<XAxis dataKey="year" />
|
||||||
|
<YAxis dataKey="value" />
|
||||||
|
<Line type="monotone" dataKey="value" stroke="#8884d8" />
|
||||||
|
</LineChart>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 添加新可视化组件步骤
|
||||||
|
1. 在`src`目录下创建新的`.tsx`文件
|
||||||
|
2. 定义组件接口和实现
|
||||||
|
3. 在`Root.tsx`中导入并注册为新的`Composition`
|
||||||
|
4. 更新`render-props-types.ts`以包含新组件所需的数据结构
|
||||||
|
5. 在`main.py`中修改`build_render_props`方法以包含新数据
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [main.py](file://main.py#L145-L198)
|
||||||
|
|
@ -0,0 +1,351 @@
|
||||||
|
# AI解说脚本生成
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件列表**
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [scripter.py](file://backend/scripter.py)
|
||||||
|
- [extractor.py](file://backend/extractor.py)
|
||||||
|
- [schemas.py](file://backend/video_processor/schemas.py)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [requirements.txt](file://requirements.txt)
|
||||||
|
- [.env.example](file://.env.example)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md)
|
||||||
|
- [investigate_video_extraction.py](file://backend/investigate_video_extraction.py)
|
||||||
|
- [investigate_subtitle_extraction.py](file://backend/investigate_subtitle_extraction.py)
|
||||||
|
- [investigate_media_fetch.py](file://backend/investigate_media_fetch.py)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖分析](#依赖分析)
|
||||||
|
7. [性能考虑](#性能考虑)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录](#附录)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本文件面向“AI解说脚本生成”模块,聚焦于如何基于结构化财务数据(来自PDF提取)与可选的视频转写结果,通过Google Gemini Pro API生成自然流畅的日语解说词。文档从提示工程设计、API调用流程、重试与解析策略、脚本段落结构与时间轴映射、调试技巧与多语言扩展等方面进行系统化阐述,帮助开发者快速实现并优化该模块。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
当前仓库采用“Python后端 + TypeScript前端”的混合架构:
|
||||||
|
- 后端负责:PDF与视频数据抽取、财务模拟、脚本生成、B-Roll抓取、渲染参数构建
|
||||||
|
- 前端使用Remotion渲染最终视频,类型定义由Zod校验
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A["main.py<br/>主入口与流水线编排"] --> B["backend/scripter.py<br/>脚本生成器"]
|
||||||
|
A --> C["backend/extractor.py<br/>PDF/视频抽取"]
|
||||||
|
A --> D["backend/data_builder.py<br/>财务模拟与图表数据"]
|
||||||
|
A --> E["backend/media_fetcher.py<br/>B-Roll抓取"]
|
||||||
|
A --> F["frontend/src/render-props-types.ts<br/>渲染参数类型定义"]
|
||||||
|
B --> G["google-generativeai<br/>Gemini API"]
|
||||||
|
C --> H["openai/whisper<br/>音频转写"]
|
||||||
|
D --> I["FFmpeg/媒体工具"]
|
||||||
|
E --> J["Pexels API"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [main.py](file://main.py#L1-L120)
|
||||||
|
- [scripter.py](file://backend/scripter.py#L1-L69)
|
||||||
|
- [extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L52-L120)
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L1-L120)
|
||||||
|
- [main.py](file://main.py#L1-L120)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- 脚本生成器(ScriptGenerator)
|
||||||
|
- 负责构建提示词、调用Gemini Pro API、解析响应并估算每段时长
|
||||||
|
- 当前为占位实现,尚未接入真实API
|
||||||
|
- 数据抽取器(PDFExtractor/VideoExtractor)
|
||||||
|
- PDFExtractor:计划使用Gemini Pro从PDF中抽取结构化数据
|
||||||
|
- VideoExtractor:计划使用Whisper进行音频转写与关键时间戳提取
|
||||||
|
- 视频处理数据结构(VideoSegment/KeyMoment/VideoAnalysisResult)
|
||||||
|
- 定义视频片段、关键时刻与完整分析结果的数据契约
|
||||||
|
- 渲染参数类型(ScriptSegment等)
|
||||||
|
- 定义脚本段落结构、图表同步、资产引用等前端消费所需字段
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [scripter.py](file://backend/scripter.py#L1-L69)
|
||||||
|
- [extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [schemas.py](file://backend/video_processor/schemas.py#L1-L91)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L52-L120)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
脚本生成在整体流水线中的位置如下:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant CLI as "命令行(main.py)"
|
||||||
|
participant EX as "PDF/视频抽取(extractor.py)"
|
||||||
|
participant SG as "脚本生成(scripter.py)"
|
||||||
|
participant GEM as "Gemini Pro API"
|
||||||
|
participant CH as "图表数据(data_builder.py)"
|
||||||
|
participant OUT as "渲染参数(render-props-types.ts)"
|
||||||
|
CLI->>EX : 提取PDF/视频数据
|
||||||
|
EX-->>CLI : 返回结构化数据
|
||||||
|
CLI->>SG : 生成日语脚本
|
||||||
|
SG->>GEM : 发送提示词与上下文
|
||||||
|
GEM-->>SG : 返回JSON结构化脚本
|
||||||
|
SG-->>CLI : 返回脚本段落与同步点
|
||||||
|
CLI->>CH : 构建图表数据
|
||||||
|
CH-->>CLI : 返回图表数据
|
||||||
|
CLI->>OUT : 组装render_props.json
|
||||||
|
OUT-->>CLI : 类型校验通过
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [main.py](file://main.py#L80-L160)
|
||||||
|
- [scripter.py](file://backend/scripter.py#L20-L69)
|
||||||
|
- [extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L116-L140)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### 脚本生成器(ScriptGenerator)设计
|
||||||
|
- 输入
|
||||||
|
- pdf_data:结构化财务数据(年龄、收入、支出、资产、重大事件等)
|
||||||
|
- video_data:可选的视频转写与时间戳(用于个性化叙述与同步)
|
||||||
|
- style:叙述风格(友好/专业/随意),用于提示词风格约束
|
||||||
|
- 输出
|
||||||
|
- segments:脚本段落列表(包含日语文本、时长、类型、可选图表同步)
|
||||||
|
- total_duration:预估总时长(秒)
|
||||||
|
- sync_points:建议的图表/视频同步点
|
||||||
|
- 当前状态
|
||||||
|
- 占位实现,未接入Gemini Pro API;需补充提示工程、API调用、重试与解析逻辑
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class ScriptGenerator {
|
||||||
|
+api_key : string
|
||||||
|
+generate(pdf_data, video_data, style) Dict
|
||||||
|
-_build_prompt(pdf_data, style) str
|
||||||
|
}
|
||||||
|
class ScriptSegment {
|
||||||
|
+id : number
|
||||||
|
+text : string
|
||||||
|
+duration : number
|
||||||
|
+type : string
|
||||||
|
+assetUrl? : string
|
||||||
|
+chartSync? : object
|
||||||
|
}
|
||||||
|
ScriptGenerator --> ScriptSegment : "生成并返回"
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [scripter.py](file://backend/scripter.py#L14-L69)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L74-L93)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [scripter.py](file://backend/scripter.py#L14-L69)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L74-L93)
|
||||||
|
|
||||||
|
### 提示工程(Prompt Engineering)设计原理
|
||||||
|
- 系统提示词(System Prompt)
|
||||||
|
- 明确角色:作为日语AI解说员,面向普通观众,语气自然、有亲和力
|
||||||
|
- 任务目标:将结构化财务数据转化为连贯、易懂的日语解说词
|
||||||
|
- 输出格式:严格要求JSON结构,包含segments数组、total_duration、sync_points
|
||||||
|
- 长度约束:目标3-5分钟,避免冗长或过短
|
||||||
|
- 上下文注入(Context Injection)
|
||||||
|
- 将pdf_data中的关键信息(年龄、收入、支出、资产、重大事件年份)注入提示词
|
||||||
|
- 可选地注入video_data(转写文本、关键词、时间戳),用于个性化与同步
|
||||||
|
- 输出格式控制(Output Formatting Control)
|
||||||
|
- 强制要求JSON,避免自然语言混入
|
||||||
|
- 对segments的字段进行白名单约束(id、text、duration、type、chartSync等)
|
||||||
|
- 示例流程(概念性)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["开始"]) --> BuildSys["构建系统提示词<br/>角色/目标/格式/长度约束"]
|
||||||
|
BuildSys --> InjectPDF["注入PDF结构化数据"]
|
||||||
|
InjectPDF --> InjectVideo["可选:注入视频转写与时间戳"]
|
||||||
|
InjectVideo --> BuildPrompt["组合最终提示词"]
|
||||||
|
BuildPrompt --> CallAPI["调用Gemini Pro API"]
|
||||||
|
CallAPI --> ParseResp["解析响应为JSON"]
|
||||||
|
ParseResp --> Validate["校验字段完整性"]
|
||||||
|
Validate --> |通过| Estimate["估算每段时长与总时长"]
|
||||||
|
Validate --> |失败| Retry["重试/修正提示词"]
|
||||||
|
Retry --> BuildPrompt
|
||||||
|
Estimate --> SyncPts["生成图表/视频同步点"]
|
||||||
|
SyncPts --> End(["结束"])
|
||||||
|
```
|
||||||
|
|
||||||
|
[此图为概念流程图,不直接映射具体源码,故无图示来源]
|
||||||
|
|
||||||
|
### Gemini API调用流程、重试机制与响应解析
|
||||||
|
- 调用流程(参考现有视频抽取实现)
|
||||||
|
- 初始化客户端与模型(例如使用gemini-3-pro-preview)
|
||||||
|
- 准备内容:媒体文件与提示词
|
||||||
|
- 发送请求并记录token用量
|
||||||
|
- 解析响应:优先从Markdown代码块中提取JSON
|
||||||
|
- 错误处理:捕获JSON解析异常,回退到原始文本并记录元数据
|
||||||
|
- 重试机制
|
||||||
|
- 建议在请求失败或解析失败时进行指数退避重试
|
||||||
|
- 设置最大重试次数与超时阈值
|
||||||
|
- 响应解析
|
||||||
|
- 严格按预期字段解包segments、total_duration、sync_points
|
||||||
|
- 对缺失字段进行默认值填充或抛出明确错误
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant SG as "ScriptGenerator"
|
||||||
|
participant CL as "Gemini Client"
|
||||||
|
participant API as "Gemini API"
|
||||||
|
participant PARSE as "响应解析器"
|
||||||
|
SG->>CL : 初始化客户端与模型
|
||||||
|
SG->>CL : 组装contents(媒体/提示词)
|
||||||
|
CL->>API : 发送请求
|
||||||
|
API-->>CL : 返回响应
|
||||||
|
CL->>PARSE : 提取文本并尝试解析JSON
|
||||||
|
PARSE-->>SG : 返回segments/时长/同步点
|
||||||
|
alt 解析失败
|
||||||
|
PARSE-->>SG : 报错并返回原始文本
|
||||||
|
SG->>SG : 触发重试/修正提示词
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [investigate_video_extraction.py](file://backend/investigate_video_extraction.py#L1-L232)
|
||||||
|
- [investigate_subtitle_extraction.py](file://backend/investigate_subtitle_extraction.py#L228-L256)
|
||||||
|
- [investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L111-L136)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [investigate_video_extraction.py](file://backend/investigate_video_extraction.py#L1-L232)
|
||||||
|
- [investigate_subtitle_extraction.py](file://backend/investigate_subtitle_extraction.py#L228-L256)
|
||||||
|
- [investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L111-L136)
|
||||||
|
|
||||||
|
### 脚本段落(ScriptSegment)结构与时间轴映射
|
||||||
|
- 结构设计
|
||||||
|
- 字段:id、text(日语)、duration(秒)、type(narration/chart_intro/chart_highlight/video_clip/transition)、assetUrl(可选)、chartSync(可选)
|
||||||
|
- chartSync:支持chartId(assetDepletion/cashFlow)、highlightYear、animationStart等
|
||||||
|
- 时间轴映射
|
||||||
|
- 每个ScriptSegment包含duration,便于与视频时间轴对齐
|
||||||
|
- 可通过chartSync将脚本与图表高亮帧对齐,提升叙事节奏感
|
||||||
|
- 与视频时间轴的关系
|
||||||
|
- 若存在video_data,可将脚本与视频转写的时间戳对齐,形成“字幕/旁白/画面”的三线同步
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
SCRIPT_SEGMENT {
|
||||||
|
number id
|
||||||
|
string text
|
||||||
|
number duration
|
||||||
|
string type
|
||||||
|
string assetUrl
|
||||||
|
object chartSync
|
||||||
|
}
|
||||||
|
RENDER_PROPS {
|
||||||
|
array script
|
||||||
|
object charts
|
||||||
|
object assets
|
||||||
|
}
|
||||||
|
SCRIPT_SEGMENT ||--o{ RENDER_PROPS : "组成"
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L74-L120)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L74-L120)
|
||||||
|
|
||||||
|
### 多语言支持与扩展路径
|
||||||
|
- 当前目标语言为日语,提示工程已体现“自然日语”与“友好语气”
|
||||||
|
- 扩展思路
|
||||||
|
- 在提示词中加入语言切换参数(如“输出为英语/中文”)
|
||||||
|
- 为不同语言维护独立的系统提示词模板
|
||||||
|
- 通过外部翻译服务或多语言模型实现跨语言生成
|
||||||
|
- 注意事项
|
||||||
|
- 不同语言的句式结构与停顿节奏差异较大,需分别训练与校准每段时长
|
||||||
|
- 图表标注与视觉元素需配合目标语言的表达习惯
|
||||||
|
|
||||||
|
[本节为概念性扩展说明,不直接分析具体源码,故无章节来源]
|
||||||
|
|
||||||
|
## 依赖分析
|
||||||
|
- 外部依赖
|
||||||
|
- google-generativeai:Gemini Pro API调用
|
||||||
|
- openai:Whisper音频转写(视频抽取阶段)
|
||||||
|
- ffmpeg-python:媒体处理
|
||||||
|
- requests:网络请求(B-Roll抓取等)
|
||||||
|
- 内部耦合
|
||||||
|
- main.py编排脚本生成与后续步骤
|
||||||
|
- scripter.py与extractor.py、data_builder.py共同构成数据-脚本-图表闭环
|
||||||
|
- frontend的类型定义确保前后端数据契约一致
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
REQ["requirements.txt"] --> GEM["google-generativeai"]
|
||||||
|
REQ --> WHIS["openai"]
|
||||||
|
REQ --> FF["ffmpeg-python"]
|
||||||
|
REQ --> REQ2["requests"]
|
||||||
|
MAIN["main.py"] --> SCR["backend/scripter.py"]
|
||||||
|
MAIN --> EXT["backend/extractor.py"]
|
||||||
|
MAIN --> DAT["backend/data_builder.py"]
|
||||||
|
SCR --> GEM
|
||||||
|
EXT --> WHIS
|
||||||
|
DAT --> FF
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [main.py](file://main.py#L1-L120)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [main.py](file://main.py#L1-L120)
|
||||||
|
|
||||||
|
## 性能考虑
|
||||||
|
- API调用成本控制
|
||||||
|
- 通过精简提示词与上下文注入,减少token用量
|
||||||
|
- 使用缓存策略(如翻译缓存)降低重复请求
|
||||||
|
- 响应解析效率
|
||||||
|
- 优先从代码块中提取JSON,避免全量字符串扫描
|
||||||
|
- 对大响应进行分段解析与字段校验
|
||||||
|
- 时长估算与同步
|
||||||
|
- 基于平均语速估算每段时长,结合chartSync进行微调
|
||||||
|
- 通过sync_points与视频转写时间戳对齐,减少后期调整工作量
|
||||||
|
|
||||||
|
[本节提供通用指导,不直接分析具体源码,故无章节来源]
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- API密钥问题
|
||||||
|
- 确认环境变量GEMINI_API_KEY/OPENAI_API_KEY已正确设置
|
||||||
|
- 检查密钥是否过期或被限制
|
||||||
|
- API限流与超时
|
||||||
|
- 实施指数退避重试,设置合理超时阈值
|
||||||
|
- 分批发送请求,避免突发流量
|
||||||
|
- JSON解析失败
|
||||||
|
- 捕获JSONDecodeError,记录原始响应与token用量
|
||||||
|
- 回退到原始文本并人工检查提示词格式
|
||||||
|
- 时长与同步偏差
|
||||||
|
- 校验segments的duration与chartSync字段
|
||||||
|
- 与视频转写时间戳进行交叉验证
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [.env.example](file://.env.example#L1-L22)
|
||||||
|
- [investigate_video_extraction.py](file://backend/investigate_video_extraction.py#L1-L232)
|
||||||
|
- [investigate_subtitle_extraction.py](file://backend/investigate_subtitle_extraction.py#L228-L256)
|
||||||
|
- [investigate_media_fetch.py](file://backend/investigate_media_fetch.py#L111-L136)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
脚本生成模块目前处于占位实现阶段,但其设计已清晰体现了提示工程、API调用、重试与解析、以及脚本段落与时间轴映射的核心要素。结合现有视频抽取与解析实现,可快速补齐Gemini Pro集成,完成从结构化财务数据到自然日语脚本的自动化生成,并为后续多语言扩展打下基础。
|
||||||
|
|
||||||
|
[本节为总结性内容,不直接分析具体源码,故无章节来源]
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
- 环境变量与API密钥
|
||||||
|
- GEMINI_API_KEY:Gemini Pro
|
||||||
|
- OPENAI_API_KEY:Whisper
|
||||||
|
- PEXELS_API_KEY:B-Roll抓取
|
||||||
|
- 关键实现参考
|
||||||
|
- 视频抽取与JSON解析流程可作为Gemini脚本生成的参考模板
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [.env.example](file://.env.example#L1-L22)
|
||||||
|
- [investigate_video_extraction.py](file://backend/investigate_video_extraction.py#L1-L232)
|
||||||
|
- [investigate_subtitle_extraction.py](file://backend/investigate_subtitle_extraction.py#L228-L256)
|
||||||
|
|
@ -0,0 +1,309 @@
|
||||||
|
# B-Roll素材获取
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件**
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py)
|
||||||
|
- [assets/1_processed/broll_fetch_results.json](file://assets/1_processed/broll_fetch_results.json)
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [requirements.txt](file://requirements.txt)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py)
|
||||||
|
- [.gitignore](file://.gitignore)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能考量](#性能考量)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录](#附录)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本技术文档聚焦于B-Roll素材自动获取功能,围绕 backend/media_fetcher.py 展开,说明其如何通过 Pexels API 根据脚本内容智能检索匹配的背景视频片段。文档涵盖:
|
||||||
|
- 关键词提取与翻译:从日语解说脚本中识别关键主题(如“家庭”“退休”“教育”),并映射为英文搜索词。
|
||||||
|
- 视频筛选标准:分辨率、时长、版权许可等要求及下载管理机制。
|
||||||
|
- 代码示例路径:API请求构造、响应处理与本地缓存策略。
|
||||||
|
- broll_fetch_results.json 结构说明及其在后续渲染阶段的引用方式。
|
||||||
|
- 故障排查:API密钥失效、网络超时、素材质量不佳等问题的应对。
|
||||||
|
- 扩展方案:替换其他素材源(如Pixabay、Coverr等)的思路。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
该仓库采用“Python后端 + Node.js/Remotion 前端”的混合架构。与B-Roll素材获取直接相关的目录与文件如下:
|
||||||
|
- backend/media_fetcher.py:B-Roll素材抓取器,负责调用Pexels API、关键词翻译、下载与缓存。
|
||||||
|
- assets/1_processed/broll_fetch_results.json:已抓取素材的结果索引,供渲染阶段引用。
|
||||||
|
- main.py:主流程编排,串联PDF提取、脚本生成、B-Roll抓取、图表数据构建、渲染属性生成与渲染。
|
||||||
|
- frontend/src/render-props-types.ts:渲染属性的数据契约,定义了assets.broll的结构。
|
||||||
|
- requirements.txt:Python依赖清单,包含requests等网络与媒体处理相关库。
|
||||||
|
- backend/extractor.py、backend/scripter.py:分别负责PDF与视频提取、脚本生成(当前为占位实现,未来用于提供关键词)。
|
||||||
|
- .gitignore:忽略输出视频与环境变量文件,保留JSON元数据。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A["main.py<br/>主流程编排"] --> B["backend/media_fetcher.py<br/>B-Roll抓取器"]
|
||||||
|
A --> C["assets/1_processed/broll_fetch_results.json<br/>抓取结果索引"]
|
||||||
|
A --> D["frontend/src/render-props-types.ts<br/>渲染属性契约"]
|
||||||
|
B --> E["Pexels API"]
|
||||||
|
B --> F["本地缓存目录<br/>assets/1_processed/broll/"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [main.py](file://main.py#L136-L144)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
- [assets/1_processed/broll_fetch_results.json](file://assets/1_processed/broll_fetch_results.json#L1-L77)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L107-L112)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L132-L141)
|
||||||
|
- [.gitignore](file://.gitignore#L36-L58)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- MediaFetcher 类:封装Pexels API交互、关键词翻译、视频下载与缓存逻辑。
|
||||||
|
- fetch_broll 函数:便捷入口,从脚本中抽取关键词并触发抓取。
|
||||||
|
- broll_fetch_results.json:记录每个关键词对应的英文名与本地视频列表,包含时长与摄影师信息。
|
||||||
|
- 渲染属性契约:定义 assets.broll 的键值映射,供前端渲染使用。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L15-L72)
|
||||||
|
- [assets/1_processed/broll_fetch_results.json](file://assets/1_processed/broll_fetch_results.json#L1-L77)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L107-L112)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
下图展示了B-Roll抓取在整体流水线中的位置与数据流。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant CLI as "命令行入口<br/>main.py"
|
||||||
|
participant Gen as "脚本生成器<br/>backend/scripter.py"
|
||||||
|
participant MF as "B-Roll抓取器<br/>backend/media_fetcher.py"
|
||||||
|
participant PEX as "Pexels API"
|
||||||
|
participant FS as "本地存储<br/>assets/1_processed/broll/"
|
||||||
|
participant RP as "渲染属性<br/>render_props.json"
|
||||||
|
participant FE as "前端渲染<br/>Remotion"
|
||||||
|
CLI->>Gen : 生成日语脚本
|
||||||
|
CLI->>MF : 提供脚本并请求B-Roll
|
||||||
|
MF->>MF : 抽取关键词并翻译为英文
|
||||||
|
MF->>PEX : 发送搜索请求
|
||||||
|
PEX-->>MF : 返回视频资源列表
|
||||||
|
MF->>FS : 下载并保存视频
|
||||||
|
MF-->>CLI : 返回本地路径映射
|
||||||
|
CLI->>RP : 写入render_props.json
|
||||||
|
FE-->>CLI : 使用render_props.json进行渲染
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [main.py](file://main.py#L136-L144)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L1-L69)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L25-L72)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L107-L112)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### MediaFetcher 类设计与职责
|
||||||
|
- 负责初始化与校验PEXELS_API_KEY,设置基础URL。
|
||||||
|
- 提供 fetch_broll(keywords, max_per_keyword) 接口,返回关键词到视频URL的映射。
|
||||||
|
- 提供 _translate_keyword(japanese_keyword) 占位方法,未来接入Gemini进行日英翻译并缓存。
|
||||||
|
- 提供 _download_video(url, output_path) 占位方法,未来实现下载、进度条、格式校验与缓存。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class MediaFetcher {
|
||||||
|
+api_key : string
|
||||||
|
+base_url : string
|
||||||
|
+__init__(api_key)
|
||||||
|
+fetch_broll(keywords, max_per_keyword) Dict
|
||||||
|
+_translate_keyword(japanese_keyword) string
|
||||||
|
+_download_video(url, output_path) string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L15-L72)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L15-L72)
|
||||||
|
|
||||||
|
### 关键词提取与翻译策略
|
||||||
|
- 输入:来自脚本生成器的脚本对象,包含分段文本。
|
||||||
|
- 提取:遍历脚本的分段,从每段文本中抽取关键词(占位实现,未来可结合NLP或LLM)。
|
||||||
|
- 翻译:将日文关键词翻译为英文(占位实现,建议使用Gemini Pro),并缓存翻译结果以减少重复调用。
|
||||||
|
- 映射:将英文关键词作为Pexels搜索词,限制每关键词最多返回若干个视频。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["开始"]) --> LoadScript["加载脚本对象"]
|
||||||
|
LoadScript --> Iterate["遍历脚本分段"]
|
||||||
|
Iterate --> Extract["抽取关键词占位"]
|
||||||
|
Extract --> Translate["翻译为英文Gemini并缓存"]
|
||||||
|
Translate --> Search["调用Pexels搜索接口"]
|
||||||
|
Search --> Filter["按分辨率/时长/许可筛选"]
|
||||||
|
Filter --> Download["下载到本地缓存目录"]
|
||||||
|
Download --> Index["写入broll_fetch_results.json"]
|
||||||
|
Index --> End(["结束"])
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L25-L72)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L22-L48)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L25-L72)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py#L22-L48)
|
||||||
|
|
||||||
|
### 视频筛选标准与下载管理
|
||||||
|
- 分辨率与时长:建议在搜索后对返回结果按分辨率与目标时长范围进行过滤,优先选择高分辨率且时长接近目标的素材。
|
||||||
|
- 版权许可:Pexels默认许可适合商用,但应确保符合项目用途;必要时可在筛选中排除非CC0或非商业许可的素材。
|
||||||
|
- 下载与缓存:
|
||||||
|
- 将视频下载至 assets/1_processed/broll/ 目录,命名规则建议包含关键词与唯一标识,便于去重与复用。
|
||||||
|
- 在 broll_fetch_results.json 中记录每个关键词的英文名、本地文件路径、时长与摄影师信息,便于渲染阶段引用。
|
||||||
|
- 使用缓存避免重复下载同一素材,可基于哈希或URL指纹判断是否已存在。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["Pexels搜索结果"] --> B["按分辨率筛选"]
|
||||||
|
B --> C["按时长范围筛选"]
|
||||||
|
C --> D["按许可筛选"]
|
||||||
|
D --> E["下载到本地缓存"]
|
||||||
|
E --> F["更新broll_fetch_results.json"]
|
||||||
|
F --> G["返回本地路径映射"]
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [assets/1_processed/broll_fetch_results.json](file://assets/1_processed/broll_fetch_results.json#L1-L77)
|
||||||
|
- [.gitignore](file://.gitignore#L36-L58)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [assets/1_processed/broll_fetch_results.json](file://assets/1_processed/broll_fetch_results.json#L1-L77)
|
||||||
|
- [.gitignore](file://.gitignore#L36-L58)
|
||||||
|
|
||||||
|
### API请求构造与响应处理
|
||||||
|
- 请求构造:使用 requests 库发送HTTP GET请求到 Pexels 搜索接口,携带认证头与查询参数(关键词、数量上限等)。
|
||||||
|
- 响应处理:解析JSON,提取视频URL、时长、分辨率、许可等字段;对不符合条件的条目进行过滤。
|
||||||
|
- 错误处理:捕获网络异常、超时、HTTP状态码错误;对空结果或失败重试进行降级处理。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [requirements.txt](file://requirements.txt#L16-L18)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L25-L72)
|
||||||
|
|
||||||
|
### broll_fetch_results.json 结构与渲染引用
|
||||||
|
- 结构要点:
|
||||||
|
- 键为日文关键词(如“家族”“老後”“教育”)。
|
||||||
|
- 字段 english:英文关键词。
|
||||||
|
- 字段 videos:数组,包含多个视频条目,每项含 filepath、duration、photographer。
|
||||||
|
- 渲染引用:
|
||||||
|
- 渲染属性 assets.broll 期望为“关键词 -> 路径”的映射;当前broll_fetch_results.json以关键词为根节点,渲染阶段需将其转换为 assets.broll 的扁平映射,或将渲染阶段直接读取该JSON并按需转换。
|
||||||
|
- 前端契约中 assets.broll 为 Record<string, string>,即“关键词 -> 视频路径”。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
KEYWORDS {
|
||||||
|
string 日文关键词 PK
|
||||||
|
string 英文关键词
|
||||||
|
}
|
||||||
|
VIDEOS {
|
||||||
|
string filepath
|
||||||
|
number duration
|
||||||
|
string photographer
|
||||||
|
}
|
||||||
|
KEYWORDS ||--o{ VIDEOS : "包含"
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [assets/1_processed/broll_fetch_results.json](file://assets/1_processed/broll_fetch_results.json#L1-L77)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L107-L112)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [assets/1_processed/broll_fetch_results.json](file://assets/1_processed/broll_fetch_results.json#L1-L77)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L107-L112)
|
||||||
|
|
||||||
|
### 代码示例路径(不展示具体代码)
|
||||||
|
- 初始化与校验API密钥与基础URL
|
||||||
|
- 参考路径:[backend/media_fetcher.py](file://backend/media_fetcher.py#L18-L24)
|
||||||
|
- fetch_broll 接口与占位实现
|
||||||
|
- 参考路径:[backend/media_fetcher.py](file://backend/media_fetcher.py#L25-L43)
|
||||||
|
- _translate_keyword 占位方法
|
||||||
|
- 参考路径:[backend/media_fetcher.py](file://backend/media_fetcher.py#L44-L51)
|
||||||
|
- _download_video 占位方法
|
||||||
|
- 参考路径:[backend/media_fetcher.py](file://backend/media_fetcher.py#L52-L60)
|
||||||
|
- 主流程中调用 fetch_broll
|
||||||
|
- 参考路径:[main.py](file://main.py#L136-L144)
|
||||||
|
- 渲染属性契约中 assets.broll 的定义
|
||||||
|
- 参考路径:[frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L107-L112)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- 外部依赖:
|
||||||
|
- requests:用于HTTP请求与下载。
|
||||||
|
- ffmpeg-python:用于视频处理(当前媒体抓取模块为占位,未来可能在此集成)。
|
||||||
|
- 内部依赖:
|
||||||
|
- main.py 编排调用 media_fetcher.fetch_broll。
|
||||||
|
- frontend/src/render-props-types.ts 定义渲染阶段消费的数据结构。
|
||||||
|
- .gitignore 控制资产目录的版本控制行为,避免大文件入库。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
REQ["requests"] --> MF["backend/media_fetcher.py"]
|
||||||
|
FFPY["ffmpeg-python"] --> MF
|
||||||
|
MF --> MAIN["main.py"]
|
||||||
|
MF --> TYPES["frontend/src/render-props-types.ts"]
|
||||||
|
GIT[".gitignore"] --> MF
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [requirements.txt](file://requirements.txt#L16-L18)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
- [main.py](file://main.py#L136-L144)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L107-L112)
|
||||||
|
- [.gitignore](file://.gitignore#L36-L58)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [requirements.txt](file://requirements.txt#L16-L18)
|
||||||
|
- [main.py](file://main.py#L136-L144)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L107-L112)
|
||||||
|
- [.gitignore](file://.gitignore#L36-L58)
|
||||||
|
|
||||||
|
## 性能考量
|
||||||
|
- 并发与限速:Pexels搜索接口可能有限额,建议对关键词并发请求进行节流,避免触发速率限制。
|
||||||
|
- 缓存策略:对翻译结果与下载的视频建立本地缓存,命中则跳过网络请求,显著降低延迟与成本。
|
||||||
|
- 过滤前置:在下载前完成分辨率与时长过滤,减少无效IO。
|
||||||
|
- 断点续传:对于大文件下载,可考虑断点续传以提升稳定性。
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- API密钥失效
|
||||||
|
- 现象:初始化时报错或请求返回未授权。
|
||||||
|
- 处理:检查 .env 文件是否正确配置PEXELS_API_KEY;确认密钥有效且有访问权限。
|
||||||
|
- 参考路径:[backend/media_fetcher.py](file://backend/media_fetcher.py#L18-L24)
|
||||||
|
- 网络超时或连接失败
|
||||||
|
- 现象:请求抛出超时或连接异常。
|
||||||
|
- 处理:增加重试次数与指数退避;检查代理与防火墙;在网络稳定后再试。
|
||||||
|
- 素材质量不佳
|
||||||
|
- 现象:分辨率过低、时长不符、许可不满足需求。
|
||||||
|
- 处理:在筛选阶段严格限定分辨率与时长;优先选择高评分与合适许可的素材;必要时回退到备用关键词。
|
||||||
|
- 渲染阶段找不到B-Roll路径
|
||||||
|
- 现象:渲染报错提示缺少assets.broll映射。
|
||||||
|
- 处理:确认 broll_fetch_results.json 已生成且路径正确;若渲染阶段使用扁平映射,请在生成 render_props.json 时将该JSON转换为 assets.broll 的键值对。
|
||||||
|
- 参考路径:[assets/1_processed/broll_fetch_results.json](file://assets/1_processed/broll_fetch_results.json#L1-L77)
|
||||||
|
- 参考路径:[frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L107-L112)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L18-L24)
|
||||||
|
- [assets/1_processed/broll_fetch_results.json](file://assets/1_processed/broll_fetch_results.json#L1-L77)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L107-L112)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
当前 B-Roll 素材获取功能处于占位实现阶段,核心流程已在 main.py 中串联,MediaFetcher 类已预留Pexels API集成、翻译与下载的扩展点。通过明确的筛选标准与本地缓存策略,可有效提升素材质量与系统性能。broll_fetch_results.json 作为中间产物,为渲染阶段提供了稳定的素材索引;未来建议在渲染阶段直接消费该JSON或转换为扁平映射,以契合前端契约。
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
- 扩展方案:替换其他素材源
|
||||||
|
- Pixabay:提供免费与付费素材,支持相似的搜索与过滤能力;需适配其API与许可条款。
|
||||||
|
- Coverr:专注免费短视频,适合短时长B-Roll;注意其许可与可用性。
|
||||||
|
- 自建素材库:将已有素材归档到 assets/1_processed/broll/,并在 broll_fetch_results.json 中维护索引,便于统一管理与渲染引用。
|
||||||
|
- 开发建议
|
||||||
|
- 在 media_fetcher.py 中逐步实现 _translate_keyword 与 _download_video。
|
||||||
|
- 在 main.py 中启用实际的 backend.extractor 与 backend.scripter,以提供真实脚本与关键词。
|
||||||
|
- 在渲染阶段完善对 broll_fetch_results.json 的转换逻辑,确保 assets.broll 符合前端契约。
|
||||||
|
|
@ -0,0 +1,377 @@
|
||||||
|
# 数据提取
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件列表**
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py)
|
||||||
|
- [backend/investigate_video_extraction.py](file://backend/investigate_video_extraction.py)
|
||||||
|
- [backend/investigate_subtitle_extraction.py](file://backend/investigate_subtitle_extraction.py)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md)
|
||||||
|
- [requirements.txt](file://requirements.txt)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能考量](#性能考量)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录](#附录)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本文件聚焦于“数据提取”模块,系统性阐述两类输入数据的提取与结构化流程:
|
||||||
|
- PDF 报告(财务规划类):通过大模型解析并抽取结构化信息(如客户基本信息、资产/收入/支出、重大事件、保险建议等)。
|
||||||
|
- 访谈视频:通过音频转写与时间戳抽取,生成可用于视频剪辑与B-Roll素材匹配的结构化片段。
|
||||||
|
|
||||||
|
同时,文档说明数据提取后的内部表示形式、与后续脚本生成与数据构建模块的数据契约关系,并给出常见问题与扩展方法。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
数据提取相关的核心文件分布如下:
|
||||||
|
- 提取层:backend/extractor.py(待实现)、backend/video_processor/utils.py(FFmpeg工具)、backend/investigate_video_extraction.py 与 backend/investigate_subtitle_extraction.py(演示实现)
|
||||||
|
- 结构化输出与契约:backend/video_processor/schemas.py(视频分析结果结构)、frontend/src/render-props-types.ts(前端渲染契约)
|
||||||
|
- 数据构建:backend/data_builder.py(基于PDF提取参数进行财务模拟)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "输入"
|
||||||
|
PDF["PDF 报告"]
|
||||||
|
VIDEO["访谈视频"]
|
||||||
|
end
|
||||||
|
subgraph "提取层"
|
||||||
|
EX["extractor.py<br/>PDF/视频提取接口待实现"]
|
||||||
|
VU["video_processor/utils.py<br/>FFmpeg 工具"]
|
||||||
|
IVV["investigate_video_extraction.py<br/>视频提取演示"]
|
||||||
|
ISV["investigate_subtitle_extraction.py<br/>字幕/转写演示"]
|
||||||
|
end
|
||||||
|
subgraph "结构化输出"
|
||||||
|
VS["schemas.py<br/>VideoSegment/KeyMoment/VideoAnalysisResult"]
|
||||||
|
RP["render-props-types.ts<br/>前端渲染契约"]
|
||||||
|
end
|
||||||
|
subgraph "后续模块"
|
||||||
|
DB["data_builder.py<br/>财务模拟与图表数据"]
|
||||||
|
end
|
||||||
|
PDF --> EX
|
||||||
|
VIDEO --> EX
|
||||||
|
VIDEO --> VU
|
||||||
|
EX --> VS
|
||||||
|
VS --> RP
|
||||||
|
EX --> DB
|
||||||
|
DB --> RP
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L1-L164)
|
||||||
|
- [backend/investigate_video_extraction.py](file://backend/investigate_video_extraction.py#L1-L291)
|
||||||
|
- [backend/investigate_subtitle_extraction.py](file://backend/investigate_subtitle_extraction.py#L1-L413)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L1-L91)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L132-L142)
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- PDF 提取器(PDFExtractor):面向财务规划PDF,期望返回结构化数据(客户信息、财务数据、重大事件、保险建议等),当前为占位实现,需接入大模型API完成解析。
|
||||||
|
- 视频提取器(VideoExtractor):面向访谈视频,期望返回转写文本、时间戳片段、推荐B-Roll片段等,当前为占位实现,需接入Whisper与FFmpeg。
|
||||||
|
- 视频处理工具(FFmpeg封装):提供视频时长查询、音频提取、视频切片等能力,支撑视频提取流程。
|
||||||
|
- 视频结构化模式(schemas.py):定义视频片段、关键时刻、完整分析结果的标准结构,确保与前端契约一致。
|
||||||
|
- 数据构建(data_builder.py):消费PDF提取结果,进行财务模拟与图表数据转换,输出与前端契约一致的结构。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L1-L164)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L1-L91)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
下图展示了从原始文件到结构化数据再到前端渲染的整体流程。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as "用户"
|
||||||
|
participant EX as "extractor.py"
|
||||||
|
participant VU as "FFmpeg 工具"
|
||||||
|
participant IV as "视频提取演示"
|
||||||
|
participant IS as "字幕提取演示"
|
||||||
|
participant VS as "schemas.py"
|
||||||
|
participant DB as "data_builder.py"
|
||||||
|
participant RP as "render-props-types.ts"
|
||||||
|
U->>EX : 提供 PDF/视频
|
||||||
|
EX->>EX : 占位实现待完成
|
||||||
|
EX->>VU : 使用 FFmpeg 获取时长/提取音频视频流程
|
||||||
|
EX->>IV : 参考演示实现上传视频、结构化抽取
|
||||||
|
EX->>IS : 参考演示实现字幕/转写
|
||||||
|
EX-->>VS : 输出 VideoSegment/KeyMoment/VideoAnalysisResult
|
||||||
|
EX-->>DB : 输出 PDF 结构化数据
|
||||||
|
DB-->>RP : 输出图表数据资产枯竭/现金流
|
||||||
|
VS-->>RP : 输出视频片段与脚本同步点
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L1-L164)
|
||||||
|
- [backend/investigate_video_extraction.py](file://backend/investigate_video_extraction.py#L1-L291)
|
||||||
|
- [backend/investigate_subtitle_extraction.py](file://backend/investigate_subtitle_extraction.py#L1-L413)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L1-L91)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### PDF 提取流程与内部表示
|
||||||
|
- 输入:财务规划PDF(日文)。
|
||||||
|
- 处理目标:结构化关键信息,便于后续财务模拟与脚本生成。
|
||||||
|
- 当前状态:占位实现,需接入大模型API完成PDF解析与结构化输出。
|
||||||
|
- 内部表示(预期):包含客户信息、财务数据、重大事件、保险建议等字段,作为data_builder的输入。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["开始:接收 PDF 路径"]) --> CheckKey["检查环境变量<br/>GEMINI_API_KEY"]
|
||||||
|
CheckKey --> KeyOK{"密钥存在?"}
|
||||||
|
KeyOK --> |否| RaiseErr["抛出异常:缺少 API 密钥"]
|
||||||
|
KeyOK --> |是| Upload["上传 PDF 至 Gemini API"]
|
||||||
|
Upload --> Prompt["构造提示词<br/>要求结构化 JSON 输出"]
|
||||||
|
Prompt --> Parse["解析响应并校验结构"]
|
||||||
|
Parse --> Done(["返回结构化数据"])
|
||||||
|
RaiseErr --> End(["结束"])
|
||||||
|
Done --> End
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
|
||||||
|
### 视频元数据与转写流程(FFmpeg + Whisper)
|
||||||
|
- 元数据提取:使用ffprobe获取视频时长;使用ffmpeg提取音频(采样率、编码等可配置)。
|
||||||
|
- 转写与时间戳:参考演示脚本,先提取音频,再调用Whisper API进行转写与时间戳抽取。
|
||||||
|
- 片段结构化:输出VideoSegment/KeyMoment等结构,便于后续B-Roll匹配与视频剪辑。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant V as "视频文件"
|
||||||
|
participant U as "FFmpeg 工具"
|
||||||
|
participant W as "Whisper API"
|
||||||
|
participant S as "schemas.py"
|
||||||
|
participant R as "render-props-types.ts"
|
||||||
|
V->>U : get_video_duration()
|
||||||
|
U-->>V : 返回时长秒
|
||||||
|
V->>U : extract_audio()
|
||||||
|
U-->>V : 输出音频文件
|
||||||
|
V->>W : 上传音频并请求转写
|
||||||
|
W-->>V : 返回转写文本与时间戳
|
||||||
|
V->>S : 组装 VideoSegment/KeyMoment
|
||||||
|
S-->>R : 输出符合前端契约的结构
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L1-L164)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L1-L91)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L1-L164)
|
||||||
|
- [backend/investigate_video_extraction.py](file://backend/investigate_video_extraction.py#L1-L291)
|
||||||
|
- [backend/investigate_subtitle_extraction.py](file://backend/investigate_subtitle_extraction.py#L1-L413)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L1-L91)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
### 视频结构化模式(schemas.py)
|
||||||
|
- VideoSegment:视频片段,包含起止时间、描述、转写、重要性、标签、说话人等。
|
||||||
|
- KeyMoment:关键时刻,包含时间戳、类型(引述/图表/情感/动作)、内容与上下文。
|
||||||
|
- VideoAnalysisResult:完整分析结果,包含源视频路径、总时长、策略、片段列表、关键时刻列表与额外元数据。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class VideoSegment {
|
||||||
|
+float start_time
|
||||||
|
+float end_time
|
||||||
|
+string description
|
||||||
|
+string transcript
|
||||||
|
+string importance
|
||||||
|
+string[] tags
|
||||||
|
+string speaker
|
||||||
|
+float duration
|
||||||
|
+to_dict() dict
|
||||||
|
}
|
||||||
|
class KeyMoment {
|
||||||
|
+float timestamp
|
||||||
|
+string type
|
||||||
|
+string content
|
||||||
|
+string context
|
||||||
|
+to_dict() dict
|
||||||
|
}
|
||||||
|
class VideoAnalysisResult {
|
||||||
|
+string source_video
|
||||||
|
+float duration
|
||||||
|
+string strategy
|
||||||
|
+VideoSegment[] segments
|
||||||
|
+KeyMoment[] key_moments
|
||||||
|
+dict metadata
|
||||||
|
+to_dict() dict
|
||||||
|
+get_high_importance_segments() VideoSegment[]
|
||||||
|
+get_quotes() KeyMoment[]
|
||||||
|
}
|
||||||
|
VideoAnalysisResult --> VideoSegment : "包含"
|
||||||
|
VideoAnalysisResult --> KeyMoment : "包含"
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L1-L91)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L1-L91)
|
||||||
|
|
||||||
|
### 数据契约与前后端衔接
|
||||||
|
- 前端契约(render-props-types.ts):定义YearlySnapshot、CashFlowData、ScriptSegment、AssetCollection等类型,确保后端生成的数据与前端渲染一致。
|
||||||
|
- 后端数据构建(data_builder.py):将PDF提取参数转化为财务模拟时间线与图表数据,输出与前端契约一致的结构。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
YEARLYSNAPSHOT {
|
||||||
|
int year
|
||||||
|
int repAge
|
||||||
|
int spouseAge
|
||||||
|
int income
|
||||||
|
int expenditure
|
||||||
|
int assetBalance
|
||||||
|
string[] events
|
||||||
|
boolean isCritical
|
||||||
|
}
|
||||||
|
CASHFLOWDATA {
|
||||||
|
int year
|
||||||
|
int age
|
||||||
|
int totalIncome
|
||||||
|
int livingExpense
|
||||||
|
int housingExpense
|
||||||
|
int educationExpense
|
||||||
|
int taxExpense
|
||||||
|
int eventExpense
|
||||||
|
int totalExpense
|
||||||
|
int netCashFlow
|
||||||
|
int assetBalance
|
||||||
|
}
|
||||||
|
RENDERPROPS {
|
||||||
|
string title
|
||||||
|
string currencyUnit
|
||||||
|
int duration
|
||||||
|
string clientName
|
||||||
|
string generatedAt
|
||||||
|
}
|
||||||
|
RENDERPROPS ||--o{ YEARLYSNAPSHOT : "assetDepletion.timeline"
|
||||||
|
RENDERPROPS ||--o{ CASHFLOWDATA : "cashFlow.timeline"
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- 外部依赖:Google Gemini(PDF/视频理解)、OpenAI Whisper(音频转写)、FFmpeg/ffprobe(媒体处理)、Pexels(B-Roll素材检索,当前占位)。
|
||||||
|
- 内部耦合:extractor.py 与 video_processor/utils.py、video_processor/schemas.py 存在潜在协作关系;data_builder.py 依赖 PDF 提取结果;前端契约约束后端输出。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
REQ["requirements.txt"] --> GEM["google-generativeai"]
|
||||||
|
REQ --> OAI["openai"]
|
||||||
|
REQ --> FFPY["ffmpeg-python"]
|
||||||
|
REQ --> REQ["requests"]
|
||||||
|
EX["extractor.py"] --> GEM
|
||||||
|
EX --> OAI
|
||||||
|
EX --> FFPY
|
||||||
|
VU["video_processor/utils.py"] --> FFPY
|
||||||
|
VS["video_processor/schemas.py"] --> RP["render-props-types.ts"]
|
||||||
|
DB["data_builder.py"] --> RP
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L1-L164)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L1-L91)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
|
||||||
|
## 性能考量
|
||||||
|
- API成本控制:视频/音频上传与转写会产生Token/调用费用,应优先选择合适粒度(电影级摘要 vs 秒级/词级时间戳)以平衡精度与成本。
|
||||||
|
- 媒体处理优化:音频提取采用流复制(stream copy)可避免重编码,提升速度;批量处理时注意并发与资源限制。
|
||||||
|
- 缓存与复用:翻译与API调用结果可缓存,减少重复请求;视频片段与B-Roll素材可本地缓存以降低网络开销。
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- API密钥缺失
|
||||||
|
- 现象:初始化PDF/视频提取器时报错。
|
||||||
|
- 排查:确认环境变量是否正确设置;检查.env文件是否存在且包含有效密钥。
|
||||||
|
- FFmpeg/ffprobe不可用
|
||||||
|
- 现象:获取视频时长或提取音频失败。
|
||||||
|
- 排查:安装FFmpeg/ffprobe并加入PATH;使用工具函数检测可用性。
|
||||||
|
- PDF格式不兼容或加密
|
||||||
|
- 现象:PDF解析失败或返回空数据。
|
||||||
|
- 排查:确认PDF可读且未加密;若为扫描版PDF,考虑OCR预处理;优先使用大模型直接解析PDF而非本地库。
|
||||||
|
- 视频/音频质量差导致转写不准
|
||||||
|
- 现象:转写文本与实际内容不符。
|
||||||
|
- 排查:提高音频采样率、清理背景噪音;必要时人工校正关键片段。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L217-L233)
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L1-L164)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
- 当前数据提取模块处于占位阶段,PDF与视频提取逻辑尚未实现,但已明确技术栈与数据契约。
|
||||||
|
- 建议优先完成以下工作:PDF解析(Gemini API)、视频转写(Whisper)、FFmpeg工具链完善、结构化输出规范化(schemas)。
|
||||||
|
- 完成后,数据将严格遵循前端契约,无缝衔接财务模拟与视频渲染流程。
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### 接口调用方式与错误处理(基于现有占位实现)
|
||||||
|
- PDF提取
|
||||||
|
- 函数入口:[extract_pdf_data](file://backend/extractor.py#L74-L84)
|
||||||
|
- 类入口:[PDFExtractor.extract](file://backend/extractor.py#L23-L43)
|
||||||
|
- 错误处理:当前抛出未实现异常,需补充API调用与参数校验。
|
||||||
|
- 视频提取
|
||||||
|
- 函数入口:[extract_video_data](file://backend/extractor.py#L79-L84)
|
||||||
|
- 类入口:[VideoExtractor.extract](file://backend/extractor.py#L53-L72)
|
||||||
|
- 错误处理:当前抛出未实现异常,需补充FFmpeg与Whisper调用。
|
||||||
|
- FFmpeg工具
|
||||||
|
- 获取时长:[get_video_duration](file://backend/video_processor/utils.py#L13-L47)
|
||||||
|
- 提取音频:[extract_audio](file://backend/video_processor/utils.py#L49-L95)
|
||||||
|
- 切片视频:[slice_video](file://backend/video_processor/utils.py#L97-L146)
|
||||||
|
- 检测安装:[check_ffmpeg_installed](file://backend/video_processor/utils.py#L148-L164)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L1-L164)
|
||||||
|
|
||||||
|
### 扩展自定义解析器的方法
|
||||||
|
- PDF解析器扩展
|
||||||
|
- 在PDFExtractor中实现extract方法,调用Gemini API并按预期字段返回。
|
||||||
|
- 建议:增加参数校验、响应解析与schema验证。
|
||||||
|
- 视频解析器扩展
|
||||||
|
- 在VideoExtractor中实现extract方法,调用Whisper API并解析时间戳。
|
||||||
|
- 建议:支持多粒度(电影级/秒级/词级)与多策略(视频直传/音频转写)。
|
||||||
|
- 媒体处理扩展
|
||||||
|
- 在video_processor/utils.py中新增工具函数,统一管理FFmpeg命令与错误处理。
|
||||||
|
- 契约一致性
|
||||||
|
- 保持输出结构与frontend/src/render-props-types.ts一致,避免前后端不匹配。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L1-L91)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
# 核心功能
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文档中引用的文件**
|
||||||
|
- [extractor.py](file://backend/extractor.py)
|
||||||
|
- [scripter.py](file://backend/scripter.py)
|
||||||
|
- [media_fetcher.py](file://backend/media_fetcher.py)
|
||||||
|
- [data_builder.py](file://backend/data_builder.py)
|
||||||
|
- [index.ts](file://src/index.ts)
|
||||||
|
- [Root.tsx](file://src/Root.tsx)
|
||||||
|
- [types.ts](file://src/types.ts)
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构概述](#架构概述)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖分析](#依赖分析)
|
||||||
|
7. [性能考虑](#性能考虑)
|
||||||
|
8. [故障排除指南](#故障排除指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本项目是一个混合型Python/Node.js系统,能够将静态的财务规划报告(PDF)和原始访谈视频自动转换为动态、个性化的3-5分钟解说视频。系统整合了动画图表、AI生成的日语解说、情境化素材片段(B-Roll)以及同步的访谈剪辑,为用户提供直观的财务规划可视化服务。整个流程由Python后端驱动,利用AI模型进行数据提取和内容生成,再通过Remotion前端引擎完成视频渲染。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
项目采用分层架构,清晰地分离了数据处理逻辑与视频渲染逻辑。后端(Python)负责数据提取、AI生成和模拟计算,前端(Node.js/TypeScript)则专注于程序化视频渲染。两者通过一个名为`render_props.json`的数据契约文件进行通信。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph "Backend (Python)"
|
||||||
|
A[extractor.py] --> B[scripter.py]
|
||||||
|
B --> C[media_fetcher.py]
|
||||||
|
C --> D[data_builder.py]
|
||||||
|
D --> E[main.py]
|
||||||
|
end
|
||||||
|
subgraph "Frontend (Node.js/TypeScript)"
|
||||||
|
F[index.ts]
|
||||||
|
G[Root.tsx]
|
||||||
|
H[CashFlowChart.tsx]
|
||||||
|
I[LifePlanChart.tsx]
|
||||||
|
end
|
||||||
|
E --> |生成| J[render_props.json]
|
||||||
|
J --> |消费| F
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L14-L32)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L50-L79)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
系统的核心功能由五个关键模块构成:PDF/视频数据提取器、AI日语解说脚本生成器、B-Roll素材自动获取器、财务数据模拟与图表构建器以及最终的视频渲染合成器。这些模块协同工作,形成一个从原始输入到最终视频输出的完整流水线。
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [main.py](file://main.py#L5-L11)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L7-L12)
|
||||||
|
|
||||||
|
## 架构概述
|
||||||
|
系统的整体架构遵循“后端处理,前端渲染”的模式。Python后端作为“导演”,负责协调所有AI和数据处理任务;而Node.js前端作为“引擎”,专注于高质量的视频合成。`main.py`是整个流水线的入口点,它按顺序调用各个模块,最终生成`render_props.json`文件,触发Remotion进行视频渲染。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[PDF/视频输入] --> B[extractor.py]
|
||||||
|
B --> C[scripter.py]
|
||||||
|
C --> D[media_fetcher.py]
|
||||||
|
D --> E[data_builder.py]
|
||||||
|
E --> F[render_props.json]
|
||||||
|
F --> G[Remotion渲染]
|
||||||
|
G --> H[最终视频输出]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [main.py](file://main.py#L5-L11)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L16-L32)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### PDF/视频数据提取器 (extractor.py)
|
||||||
|
该模块负责解析输入的PDF报告和视频文件,从中提取结构化数据。它利用Google Gemini Pro API来解析日语财务报告,并使用OpenAI Whisper API对访谈视频进行转录,获取时间戳和关键词。
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [extractor.py](file://backend/extractor.py#L1-L84)
|
||||||
|
|
||||||
|
### AI日语解说脚本生成器 (scripter.py)
|
||||||
|
此模块是AI驱动内容生成的核心。它接收从PDF和视频中提取的数据,利用Google Gemini Pro生成自然流畅的日语解说脚本。脚本被划分为多个片段,每个片段都包含预计的持续时间,为后续的视频同步提供依据。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class ScriptGenerator {
|
||||||
|
+api_key : str
|
||||||
|
+__init__(api_key : Optional[str])
|
||||||
|
+generate(pdf_data : Dict, video_data : Optional[Dict], style : str) : Dict
|
||||||
|
-_build_prompt(pdf_data : Dict, style : str) : str
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [scripter.py](file://backend/scripter.py#L14-L69)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [scripter.py](file://backend/scripter.py#L1-L69)
|
||||||
|
|
||||||
|
### B-Roll素材自动获取器 (media_fetcher.py)
|
||||||
|
该模块根据生成的脚本内容,自动从Pexels API获取相关的背景视频素材(B-Roll)。它会从脚本中提取关键词,将日语关键词翻译成英语,然后搜索并下载匹配的视频片段,为视频增添视觉吸引力。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Script as 脚本
|
||||||
|
participant Fetcher as MediaFetcher
|
||||||
|
participant Pexels as Pexels API
|
||||||
|
Script->>Fetcher : 提供关键词列表
|
||||||
|
Fetcher->>Fetcher : 翻译关键词(日->英)
|
||||||
|
Fetcher->>Pexels : 搜索视频
|
||||||
|
Pexels-->>Fetcher : 返回视频URL
|
||||||
|
Fetcher->>Fetcher : 下载视频到本地
|
||||||
|
Fetcher-->>Script : 返回本地文件路径
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [media_fetcher.py](file://backend/media_fetcher.py#L15-L72)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
|
||||||
|
### 财务数据模拟与图表构建器 (data_builder.py)
|
||||||
|
这是数据驱动视频渲染的关键。该模块包含一个财务模拟引擎,能够基于提取的财务数据,模拟未来几十年的资产耗尽和现金流情况。它考虑了通货膨胀、投资回报和债务复利等因素,生成精确的年度财务快照,并将其格式化为Remotion图表组件所需的格式。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class FinancialSimulator {
|
||||||
|
+config : Dict
|
||||||
|
+inflation_rate : float
|
||||||
|
+deficit_interest_rate : float
|
||||||
|
+investment_return : float
|
||||||
|
+__init__(config : Optional[Dict])
|
||||||
|
+simulate_timeline(...) : List[Dict]
|
||||||
|
+build_cashflow_data(timeline : List[Dict]) : List[Dict]
|
||||||
|
-_get_event_cost(...) : float
|
||||||
|
-_get_event_labels(...) : List[str]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [data_builder.py](file://backend/data_builder.py#L15-L200)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
|
||||||
|
### 视频渲染合成器 (src/index.ts)
|
||||||
|
前端部分使用Remotion库进行程序化视频渲染。`index.ts`是入口文件,它注册了`Root.tsx`作为根组件。`Root.tsx`中定义了多个`<Composition>`,每个都对应一个视频片段(如资产耗尽图、现金流图),并将`render_props.json`中的数据作为属性传递给相应的React组件。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[index.ts] --> B[Root.tsx]
|
||||||
|
B --> C[Composition: LifePlanChart]
|
||||||
|
B --> D[Composition: CashFlowChart]
|
||||||
|
B --> E[Composition: DualChartView]
|
||||||
|
C --> F[LifePlanChart.tsx]
|
||||||
|
D --> G[CashFlowChart.tsx]
|
||||||
|
E --> H[DualChartView.tsx]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [src/index.ts](file://src/index.ts#L1-L6)
|
||||||
|
- [src/Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [src/index.ts](file://src/index.ts#L1-L6)
|
||||||
|
- [src/Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
|
||||||
|
## 依赖分析
|
||||||
|
系统的主要依赖关系清晰地体现在`main.py`的流水线设计中。`VideoGeneratorPipeline`类按顺序调用`extract_pdf`、`generate_script`、`fetch_broll`和`build_render_props`等方法,形成了一个强依赖的线性流程。外部API密钥通过环境变量注入,确保了配置的安全性。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
main.VideoGeneratorPipeline.run[run] --> main.VideoGeneratorPipeline.extract_pdf[extract_pdf]
|
||||||
|
main.VideoGeneratorPipeline.run[run] --> main.VideoGeneratorPipeline.generate_script[generate_script]
|
||||||
|
main.VideoGeneratorPipeline.run[run] --> main.VideoGeneratorPipeline.fetch_broll[fetch_broll]
|
||||||
|
main.VideoGeneratorPipeline.run[run] --> main.VideoGeneratorPipeline.build_render_props[build_render_props]
|
||||||
|
main.VideoGeneratorPipeline.run[run] --> main.VideoGeneratorPipeline.render_video[render_video]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [main.py](file://main.py#L48-L83)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [main.py](file://main.py#L33-L305)
|
||||||
|
|
||||||
|
## 性能考虑
|
||||||
|
虽然代码中包含了一些TODO注释,表明部分功能尚未完全实现,但其架构设计已考虑到性能。例如,`media_fetcher.py`中提到了翻译缓存,以避免重复的API调用。财务模拟使用了数学公式进行高效计算。对于视频渲染,Remotion支持并行渲染和缓存,可以显著缩短生成时间。
|
||||||
|
|
||||||
|
## 故障排除指南
|
||||||
|
常见问题主要集中在API密钥和环境配置上。确保`.env`文件存在且包含有效的`GEMINI_API_KEY`、`OPENAI_API_KEY`和`PEXELS_API_KEY`。如果遇到渲染失败,检查`npm install`是否成功,并确认`frontend/src/`目录结构正确。对于PDF解析失败,需确认PDF文件可读且未加密。
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L218-L231)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
该项目成功地将AI驱动的内容生成与数据驱动的视频渲染相结合,创造了一个自动化视频生成的解决方案。通过模块化的设计,每个核心功能都职责明确,易于维护和扩展。未来的工作应集中在实现`TODO`中提到的功能,如Gemini和Whisper的实际集成,并优化数据流以处理更复杂的财务场景。
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
# 视频渲染与合成
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文档引用的文件**
|
||||||
|
- [index.ts](file://src/index.ts)
|
||||||
|
- [Root.tsx](file://src/Root.tsx)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx)
|
||||||
|
- [DualChartView.tsx](file://src/DualChartView.tsx)
|
||||||
|
- [data_builder.py](file://backend/data_builder.py)
|
||||||
|
- [scripter.py](file://backend/scripter.py)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts)
|
||||||
|
- [mock-data.json](file://src/mock-data.json)
|
||||||
|
- [mock-cashflow.json](file://src/mock-cashflow.json)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构概述](#架构概述)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖分析](#依赖分析)
|
||||||
|
7. [性能考虑](#性能考虑)
|
||||||
|
8. [故障排除指南](#故障排除指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本项目是一个混合型Python/Node.js系统,能够将静态的财务规划报告(PDF)和原始访谈视频自动转换为动态、个性化的3-5分钟解说视频。系统结合了动画图表、AI生成的旁白、B-Roll素材和同步的访谈片段,为用户提供直观的财务规划可视化体验。该系统采用前后端分离架构,后端使用Python进行数据处理和AI推理,前端使用Remotion框架进行视频渲染。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
该项目采用清晰的分层架构,将后端数据处理与前端视频渲染分离。根目录包含主配置文件(如package.json、remotion.config.ts)和入口脚本(main.py)。`backend/`目录存放Python模块,负责PDF解析、脚本生成、数据模拟等核心逻辑。`frontend/src/`目录存放Remotion前端代码,包括React组件和渲染逻辑。`assets/`目录作为共享存储,分为输入、处理中和输出三个子目录,确保数据流清晰。这种结构实现了关注点分离,便于团队协作和独立开发。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "输入层"
|
||||||
|
PDF[PDF报告]
|
||||||
|
Video[访谈视频]
|
||||||
|
end
|
||||||
|
subgraph "后端处理层"
|
||||||
|
Extractor[extractor.py]
|
||||||
|
DataBuilder[data_builder.py]
|
||||||
|
Scripter[scripter.py]
|
||||||
|
MediaFetcher[media_fetcher.py]
|
||||||
|
end
|
||||||
|
subgraph "数据契约"
|
||||||
|
RenderProps[render_props.json]
|
||||||
|
end
|
||||||
|
subgraph "前端渲染层"
|
||||||
|
Remotion[Remotion]
|
||||||
|
Components[React组件]
|
||||||
|
end
|
||||||
|
subgraph "输出层"
|
||||||
|
Output[最终视频]
|
||||||
|
end
|
||||||
|
PDF --> Extractor
|
||||||
|
Video --> Extractor
|
||||||
|
Extractor --> DataBuilder
|
||||||
|
Extractor --> Scripter
|
||||||
|
DataBuilder --> RenderProps
|
||||||
|
Scripter --> RenderProps
|
||||||
|
MediaFetcher --> RenderProps
|
||||||
|
RenderProps --> Remotion
|
||||||
|
Remotion --> Output
|
||||||
|
```
|
||||||
|
|
||||||
|
**图源**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L14-L80)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
系统的核心在于`render_props.json`数据契约和Remotion渲染引擎的协同工作。后端Python模块生成包含财务数据、脚本和媒体引用的JSON文件,前端Remotion应用加载此文件并驱动React组件进行时间轴同步渲染。`LifePlanChart.tsx`和`CashFlowChart.tsx`是两个关键的可视化组件,它们接收来自`render_props.json`的数据并生成动态动画图表。`DualChartView.tsx`组件则将两个图表并排显示,实现更复杂的可视化布局。
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
|
||||||
|
## 架构概述
|
||||||
|
系统采用“导演-演员”模式,Python后端作为“导演”负责数据准备和决策,Remotion前端作为“演员”负责执行渲染。数据流始于PDF和视频输入,通过后端模块提取、模拟和生成,最终形成`render_props.json`数据契约。该文件被Remotion加载,驱动React组件根据时间轴动态渲染图表和UI元素。音视频同步通过脚本段落的`duration`字段和B-Roll的`assetUrl`精确编排,确保旁白、动画和背景素材的完美同步。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A[PDF/视频输入] --> B[Python后端]
|
||||||
|
B --> C[render_props.json]
|
||||||
|
C --> D[Remotion前端]
|
||||||
|
D --> E[动态视频输出]
|
||||||
|
F[API密钥] --> B
|
||||||
|
G[外部API] --> B
|
||||||
|
H[本地预览] --> D
|
||||||
|
```
|
||||||
|
|
||||||
|
**图源**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L14-L32)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### 渲染入口与数据加载
|
||||||
|
`src/index.ts`是Remotion应用的入口文件,它通过`registerRoot`函数注册根组件`RemotionRoot`。`RemotionRoot`在`Root.tsx`中定义,它使用`<Composition>`组件来注册多个可渲染的视频序列,如“LifePlanSimulation”和“CashFlowChart”。每个`<Composition>`都指定了一个React组件、视频时长、帧率和分辨率,并通过`defaultProps`将模拟数据(如`mockData`和`mockCashFlow`)传递给组件。在生产环境中,这些数据将被`render_props.json`中的真实数据替换。
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [index.ts](file://src/index.ts#L1-L6)
|
||||||
|
- [Root.tsx](file://src/Root.tsx#L1-L68)
|
||||||
|
|
||||||
|
### RenderPropsSchema数据契约
|
||||||
|
`frontend/src/render-props-types.ts`文件定义了`RenderProps`接口和`RenderPropsSchema`,它们是前后端之间的核心数据契约。该契约包含四个主要部分:`meta`(元数据,如标题和时长)、`charts`(资产消耗和现金流图表数据)、`script`(按时间轴排列的旁白脚本段落)和`assets`(B-Roll和访谈片段的引用)。`Zod`库用于在运行时验证`render_props.json`的结构,确保数据的完整性和正确性,防止因数据格式错误导致的渲染失败。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class RenderProps {
|
||||||
|
+meta : Meta
|
||||||
|
+charts : Charts
|
||||||
|
+script : ScriptSegment[]
|
||||||
|
+assets : AssetCollection
|
||||||
|
}
|
||||||
|
class Meta {
|
||||||
|
+title : string
|
||||||
|
+currencyUnit : string
|
||||||
|
+duration : number
|
||||||
|
}
|
||||||
|
class Charts {
|
||||||
|
+assetDepletion : AssetDepletionChartData
|
||||||
|
+cashFlow : CashFlowChartData
|
||||||
|
}
|
||||||
|
class ScriptSegment {
|
||||||
|
+id : number
|
||||||
|
+text : string
|
||||||
|
+duration : number
|
||||||
|
+type : ScriptSegmentType
|
||||||
|
+assetUrl? : string
|
||||||
|
+chartSync? : ChartSync
|
||||||
|
}
|
||||||
|
class AssetCollection {
|
||||||
|
+broll : Record~string, string~
|
||||||
|
+videoClips : VideoClipReference[]
|
||||||
|
+ttsAudio? : string
|
||||||
|
}
|
||||||
|
RenderProps --> Meta
|
||||||
|
RenderProps --> Charts
|
||||||
|
RenderProps --> ScriptSegment
|
||||||
|
RenderProps --> AssetCollection
|
||||||
|
```
|
||||||
|
|
||||||
|
**图源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L117-L251)
|
||||||
|
|
||||||
|
### 数据可视化组件
|
||||||
|
`LifePlanChart.tsx`和`CashFlowChart.tsx`是两个核心的可视化组件。`LifePlanChart`使用`recharts`的`AreaChart`来展示资产余额随时间的变化趋势,其X轴包含年份、本人年龄和配偶年龄三行信息。`CashFlowChart`使用`ComposedChart`来展示收入与支出的对比,其中收入为红线,支出为堆叠的彩色柱状图。两个组件都利用`useCurrentFrame`和`interpolate`函数实现与视频时间轴同步的平滑动画效果,数据点会随着视频播放进度逐步显现。
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
|
||||||
|
### 音视频同步机制
|
||||||
|
音视频同步由`script`数组中的`ScriptSegment`对象精确控制。每个段落包含`duration`(持续时间)和`type`(类型,如旁白、图表介绍等)。当类型为`chart_highlight`时,`chartSync`字段会指定要高亮的图表ID和年份,触发相应的动画。B-Roll视频片段通过`assetUrl`字段引用,并根据脚本段落的持续时间进行精确编排。TTS音频文件(如果生成)会作为`assets.ttsAudio`被引用,并在渲染时与视频同步播放。
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L74-L93)
|
||||||
|
- [scripter.py](file://backend/scripter.py#L14-L69)
|
||||||
|
|
||||||
|
## 依赖分析
|
||||||
|
项目依赖清晰地分为前端和后端两部分。前端依赖`remotion`、`react`和`recharts`用于视频渲染和UI构建,`zod`用于数据验证。后端依赖`google-generativeai`(Gemini)、`openai`(Whisper)和`pexels-api`用于AI处理和媒体获取。`render_props.json`作为唯一的依赖接口,确保了前后端的松耦合。这种设计允许团队独立开发和测试前后端,只需确保数据契约的稳定性。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Remotion] --> B[React]
|
||||||
|
A --> C[Recharts]
|
||||||
|
A --> D[Zod]
|
||||||
|
E[Python Backend] --> F[Gemini API]
|
||||||
|
E --> G[Whisper API]
|
||||||
|
E --> H[Pexels API]
|
||||||
|
I[render_props.json] --> A
|
||||||
|
I --> E
|
||||||
|
```
|
||||||
|
|
||||||
|
**图源**
|
||||||
|
- [package.json](file://package.json#L8-L14)
|
||||||
|
- [requirements.txt](file://requirements.txt)
|
||||||
|
|
||||||
|
## 性能考虑
|
||||||
|
为了优化渲染性能,组件使用了`React.memo`进行记忆化,防止不必要的重渲染。图表的Y轴域(`yAxisDomain`)基于完整时间线计算,而不是当前可见数据,这可以防止Y轴在播放过程中跳动。对于长视频,建议在`remotion.config.ts`中设置`Config.setOverwriteOutput(true)`以覆盖输出,并使用`jpeg`格式作为视频图像格式以平衡质量和性能。本地预览时,可以使用`npm run dev`启动Remotion Studio进行快速迭代。
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts#L1-L12)
|
||||||
|
- [LifePlanChart.tsx](file://src/LifePlanChart.tsx#L70-L77)
|
||||||
|
|
||||||
|
## 故障排除指南
|
||||||
|
常见问题包括API密钥缺失、依赖安装失败和数据格式错误。确保`.env`文件包含有效的`GEMINI_API_KEY`、`OPENAI_API_KEY`和`PEXELS_API_KEY`。运行`npm install`和`pip install -r requirements.txt`以安装所有依赖。使用`npm run type-check`和`npm run lint`检查前端代码,使用`python -m pytest`测试后端逻辑。如果渲染失败,检查`render_props.json`是否符合`RenderPropsSchema`的定义,可以使用`validateRenderProps`函数进行验证。
|
||||||
|
|
||||||
|
**本节来源**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L218-L232)
|
||||||
|
- [render-props-types.ts](file://frontend/src/render-props-types.ts#L241-L243)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
本系统成功地将复杂的财务数据和静态报告转化为生动、直观的动态视频。通过`render_props.json`这一精心设计的数据契约,实现了Python后端与Remotion前端的无缝集成。系统具备良好的可扩展性,可以通过添加新的React组件和更新数据契约来支持新的动画模板或交互式元素。未来的工作可以集中在优化AI脚本生成的质量和增加更多类型的可视化图表上。
|
||||||
|
|
@ -0,0 +1,323 @@
|
||||||
|
# 财务数据建模与图表构建
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件**
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [src/types.ts](file://src/types.ts)
|
||||||
|
- [src/CashFlowChart.tsx](file://src/CashFlowChart.tsx)
|
||||||
|
- [src/LifePlanChart.tsx](file://src/LifePlanChart.tsx)
|
||||||
|
- [src/DualChartView.tsx](file://src/DualChartView.tsx)
|
||||||
|
- [src/mock-cashflow.json](file://src/mock-cashflow.json)
|
||||||
|
- [src/mock-data.json](file://src/mock-data.json)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能考量](#性能考量)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录](#附录)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本文件面向开发者,系统性阐述财务数据建模与图表构建模块,重点解析后端 data_builder.py 如何将原始财务输入转化为可用于 Remotion 动画渲染的结构化时序数据;详解现金流模拟算法、资产耗尽预测模型与年度快照(YearlySnapshot)生成逻辑;说明 CashFlowDataSchema 与 YearlySnapshotSchema 的设计原则及与前端 Recharts 组件的映射关系;结合代码展示数据插值、单位转换与异常值处理的关键步骤;解释 render_props.json 中 charts 字段的构造流程,并提供自定义财务假设(通胀率、投资回报率、赤字利率)的配置方法;最后给出大数据量下的分段处理与性能优化建议。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
该模块位于 Python 后端与前端 Remotion 之间,通过 render_props.json 建立强类型契约,确保前后端数据一致。后端负责财务模拟与数据转换,前端负责动画渲染与交互。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A["后端 data_builder.py<br/>金融模拟与数据转换"] --> B["render_props.json<br/>数据契约"]
|
||||||
|
B --> C["前端 Remotion 组件<br/>CashFlowChart.tsx / LifePlanChart.tsx / DualChartView.tsx"]
|
||||||
|
C --> D["Recharts 图表<br/>动态可视化"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L117-L135)
|
||||||
|
- [src/CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [src/LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [src/DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L132-L208)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- FinancialSimulator:负责按年推进财务模拟,应用通胀、退休收入停止、事件成本、正负资产余额的不同收益/利息规则,生成 YearlySnapshot 列表。
|
||||||
|
- build_chart_data:从 PDF 提取参数,调用 FinancialSimulator,产出 assetDepletion 与 cashFlow 两套图表数据。
|
||||||
|
- YearlySnapshot 与 CashFlowData:前后端共同约定的数据结构,分别用于资产耗尽趋势与收支明细可视化。
|
||||||
|
- Recharts 组件:CashFlowChart 与 LifePlanChart 将上述数据结构映射到图表,支持动画与 HUD 信息同步。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L15-L115)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L18-L68)
|
||||||
|
- [src/types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [src/CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [src/LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
后端 data_builder.py 以 FinancialSimulator 为核心,将 PDF 输入转换为两条时间线:
|
||||||
|
- 年度快照(YearlySnapshot):资产余额、收入/支出、事件标记、是否临界。
|
||||||
|
- 现金流明细(CashFlowData):按类别拆分的支出、净现金流、资产余额。
|
||||||
|
|
||||||
|
前端通过 render_props.json 接收两条时间线,CashFlowChart 与 LifePlanChart 分别消费这些数据,实现双视图联动与统一动画进度。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant PDF as "PDF 输入"
|
||||||
|
participant Builder as "data_builder.py"
|
||||||
|
participant Sim as "FinancialSimulator"
|
||||||
|
participant JSON as "render_props.json"
|
||||||
|
participant CF as "CashFlowChart"
|
||||||
|
participant LP as "LifePlanChart"
|
||||||
|
PDF->>Builder : 提取年龄/退休/寿命/资产/收入/支出/事件
|
||||||
|
Builder->>Sim : 传入财务参数与生命周期
|
||||||
|
Sim-->>Builder : 返回 YearlySnapshot 列表
|
||||||
|
Builder->>Builder : 构造 CashFlowData 列表
|
||||||
|
Builder->>JSON : 写入 charts.assetDepletion 与 charts.cashFlow
|
||||||
|
JSON->>CF : 传入 CashFlowData
|
||||||
|
JSON->>LP : 传入 YearlySnapshot
|
||||||
|
CF-->>CF : 动画与 HUD 更新
|
||||||
|
LP-->>LP : 动画与 HUD 更新
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L117-L135)
|
||||||
|
- [src/CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [src/LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### 1) FinancialSimulator 与现金流模拟算法
|
||||||
|
- 财务参数默认值:通胀率、投资回报率、赤字利率,均可通过配置注入。
|
||||||
|
- 生命周期推进:从当前年龄到预期寿命,每年计算:
|
||||||
|
- 收入:退休前为年收入,退休后为 0。
|
||||||
|
- 通胀:支出按年通胀指数增长。
|
||||||
|
- 事件:匹配当年事件成本累加到支出。
|
||||||
|
- 净现金流:收入减去总支出。
|
||||||
|
- 资产更新:若资产余额非负,按投资回报率复利;否则按赤字利率复利(债务复合)。
|
||||||
|
- 输出:YearlySnapshot 列表,包含年份、年龄、收入/支出/资产余额、事件标签、是否临界。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["开始模拟"]) --> Init["初始化资产余额与年份"]
|
||||||
|
Init --> Loop{"年龄未达寿命?"}
|
||||||
|
Loop --> |是| YearCalc["计算当年收入/通胀支出/事件成本"]
|
||||||
|
YearCalc --> Net["计算净现金流"]
|
||||||
|
Net --> Balance{"资产余额>=0?"}
|
||||||
|
Balance --> |是| Invest["按投资回报率复利"]
|
||||||
|
Balance --> |否| Deficit["按赤字利率复利"]
|
||||||
|
Invest --> Snapshot["生成 YearlySnapshot"]
|
||||||
|
Deficit --> Snapshot
|
||||||
|
Snapshot --> Loop
|
||||||
|
Loop --> |否| End(["结束"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L24-L98)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L15-L115)
|
||||||
|
|
||||||
|
### 2) YearlySnapshot 与 CashFlowData 的生成与映射
|
||||||
|
- YearlySnapshot:用于资产耗尽趋势图,包含年份、代表者与配偶年龄、收入/支出/资产余额、事件与临界标记。
|
||||||
|
- CashFlowData:用于收支明细图,包含年份、年龄、收入(合计)、各类支出(生活/住房/教育/税费/事件)、总支出、净现金流、资产余额。
|
||||||
|
- 数据映射:DualChartView 将 CashFlowData 转换为 YearlySnapshot 供 LifePlanChart 使用,保持统一动画进度与 HUD。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class YearlySnapshot {
|
||||||
|
+number year
|
||||||
|
+object ages
|
||||||
|
+object financials
|
||||||
|
+string[] events
|
||||||
|
+boolean isCritical
|
||||||
|
}
|
||||||
|
class CashFlowData {
|
||||||
|
+number year
|
||||||
|
+number age
|
||||||
|
+object income
|
||||||
|
+object expenses
|
||||||
|
+number totalExpense
|
||||||
|
+number netCashFlow
|
||||||
|
+number assetBalance
|
||||||
|
}
|
||||||
|
class FinancialSimulator {
|
||||||
|
+simulate_timeline(...)
|
||||||
|
+build_cashflow_data(timeline)
|
||||||
|
}
|
||||||
|
class DualChartView {
|
||||||
|
+convert CashFlowData -> YearlySnapshot
|
||||||
|
}
|
||||||
|
FinancialSimulator --> YearlySnapshot : "生成"
|
||||||
|
FinancialSimulator --> CashFlowData : "生成"
|
||||||
|
DualChartView --> YearlySnapshot : "转换"
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L18-L68)
|
||||||
|
- [src/types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L116-L148)
|
||||||
|
- [src/DualChartView.tsx](file://src/DualChartView.tsx#L48-L67)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L18-L68)
|
||||||
|
- [src/types.ts](file://src/types.ts#L1-L30)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L116-L148)
|
||||||
|
- [src/DualChartView.tsx](file://src/DualChartView.tsx#L48-L67)
|
||||||
|
|
||||||
|
### 3) Recharts 组件与数据契约映射
|
||||||
|
- YearlySnapshotSchema:约束资产耗尽图的时间线结构,含货币单位与起始年份元数据。
|
||||||
|
- CashFlowDataSchema:约束收支明细图的时间线结构,含货币单位元数据。
|
||||||
|
- 组件职责:
|
||||||
|
- CashFlowChart:堆叠柱状图展示各类支出,折线展示收入,支持年进度插值与 HUD。
|
||||||
|
- LifePlanChart:面积图展示资产余额,支持年进度插值与渐变色(正负区间)。
|
||||||
|
- DualChartView:统一动画进度,将 CashFlowData 转 YearlySnapshot 供 LifePlanChart 使用。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant TS as "render-props-types.ts"
|
||||||
|
participant CF as "CashFlowChart"
|
||||||
|
participant LP as "LifePlanChart"
|
||||||
|
participant DCV as "DualChartView"
|
||||||
|
TS->>CF : YearlySnapshotSchema/CashFlowDataSchema
|
||||||
|
TS->>LP : YearlySnapshotSchema
|
||||||
|
DCV->>LP : 转换后的 YearlySnapshot[]
|
||||||
|
DCV->>CF : CashFlowData[]
|
||||||
|
CF-->>CF : 动画与 HUD
|
||||||
|
LP-->>LP : 动画与 HUD
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L140-L231)
|
||||||
|
- [src/CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [src/LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [src/DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L140-L231)
|
||||||
|
- [src/CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [src/LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [src/DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
### 4) 数据插值、单位转换与异常值处理
|
||||||
|
- 插值:
|
||||||
|
- LifePlanChart:在年与年之间进行线性插值,生成“当前年进度”的中间点,保证面积图平滑过渡。
|
||||||
|
- CashFlowChart:对当前年份内的支出条目按阶段顺序增长,同时生成一个“延伸点”以平滑收入线的视觉过渡。
|
||||||
|
- 单位转换:
|
||||||
|
- 元数据 currencyUnit 统一为“万円”,前端 Y 轴与 HUD 显示使用该单位。
|
||||||
|
- LifePlanChart 对大数值采用“亿”格式化显示。
|
||||||
|
- 异常值处理:
|
||||||
|
- 事件成本按年聚合,避免重复或遗漏。
|
||||||
|
- 资产余额为负时标记 isCritical,用于图表颜色与 HUD 提示。
|
||||||
|
- 当可视窗口小于固定长度时,填充空年份占位,避免坐标轴偏移。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [src/LifePlanChart.tsx](file://src/LifePlanChart.tsx#L100-L141)
|
||||||
|
- [src/LifePlanChart.tsx](file://src/LifePlanChart.tsx#L320-L341)
|
||||||
|
- [src/CashFlowChart.tsx](file://src/CashFlowChart.tsx#L156-L210)
|
||||||
|
- [src/CashFlowChart.tsx](file://src/CashFlowChart.tsx#L211-L240)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L100-L115)
|
||||||
|
|
||||||
|
### 5) render_props.json 中 charts 字段的构造过程
|
||||||
|
- 构造函数 build_chart_data 读取 PDF 参数,调用 FinancialSimulator 生成两条时间线:
|
||||||
|
- assetDepletion:timeline 为 YearlySnapshot[],meta 包含 currencyUnit 与 startYear。
|
||||||
|
- cashFlow:timeline 为 CashFlowData[],meta 包含 currencyUnit。
|
||||||
|
- 前端通过 render-props-types.ts 的 Schema 校验,确保数据一致性。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L117-L135)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L202-L216)
|
||||||
|
|
||||||
|
### 6) 自定义财务假设配置方法
|
||||||
|
- 配置入口:FinancialSimulator 的构造函数接收 config 字典,支持以下键:
|
||||||
|
- inflation_rate:通胀率,默认 2%
|
||||||
|
- investment_return:投资回报率,默认 3%
|
||||||
|
- deficit_interest_rate:赤字利率(负资产时的复合成本),默认 15%
|
||||||
|
- 使用方式:在调用 build_chart_data 前,将上述键值放入 config 传入 FinancialSimulator。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L18-L23)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L150-L200)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- 后端依赖:typing、math(用于幂运算与圆整)。
|
||||||
|
- 前端依赖:Remotion hooks(useCurrentFrame/useVideoConfig/interpolate)、Recharts(ComposedChart/AreaChart 等)、Zod(Schema 校验)。
|
||||||
|
- 类型契约:render-props-types.ts 定义 YearlySnapshotSchema 与 CashFlowDataSchema,确保前后端一致。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
DB["data_builder.py"] --> FS["FinancialSimulator"]
|
||||||
|
FS --> YS["YearlySnapshot"]
|
||||||
|
FS --> CFData["CashFlowData"]
|
||||||
|
RP["render-props-types.ts"] --> YSS["YearlySnapshotSchema"]
|
||||||
|
RP --> CFS["CashFlowDataSchema"]
|
||||||
|
CF["CashFlowChart.tsx"] --> YS
|
||||||
|
LP["LifePlanChart.tsx"] --> YS
|
||||||
|
DCV["DualChartView.tsx"] --> YS
|
||||||
|
```
|
||||||
|
|
||||||
|
**图示来源**
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L15-L148)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L140-L231)
|
||||||
|
- [src/CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [src/LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [src/DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L140-L231)
|
||||||
|
- [src/CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
- [src/LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [src/DualChartView.tsx](file://src/DualChartView.tsx#L1-L176)
|
||||||
|
|
||||||
|
## 性能考量
|
||||||
|
- 大数据量分段处理策略:
|
||||||
|
- 限制每帧可见数据长度:当可视窗口较短时,使用固定域与占位年份,避免 Recharts 重绘抖动。
|
||||||
|
- 使用 useMemo 缓存转换结果(如 DualChartView 将 CashFlowData 转 YearlySnapshot),减少组件重渲染。
|
||||||
|
- 动画插值仅在必要范围内计算,避免全量数据参与每帧运算。
|
||||||
|
- 数值稳定性:
|
||||||
|
- 对资产余额与支出进行圆整,减少浮点误差累积。
|
||||||
|
- Y 轴域基于完整时间线计算,避免动画过程中轴范围跳变。
|
||||||
|
- 渲染优化:
|
||||||
|
- 关闭内部动画(isAnimationActive=false),由外部帧进度驱动,降低 Recharts 内部开销。
|
||||||
|
- 使用稳定的配置对象(如 CHART_MARGIN)避免每次渲染创建新对象。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [src/LifePlanChart.tsx](file://src/LifePlanChart.tsx#L166-L190)
|
||||||
|
- [src/CashFlowChart.tsx](file://src/CashFlowChart.tsx#L211-L240)
|
||||||
|
- [src/DualChartView.tsx](file://src/DualChartView.tsx#L48-L67)
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- 数据不一致:
|
||||||
|
- 检查 render-props-types.ts 的 YearlySnapshotSchema 与 CashFlowDataSchema 是否与后端输出一致。
|
||||||
|
- 使用 validateRenderProps 或 isRenderProps 进行运行时校验。
|
||||||
|
- 图表显示异常:
|
||||||
|
- 确认 meta.currencyUnit 与前端 Y 轴单位一致。
|
||||||
|
- 若出现坐标轴跳变,检查 Y 轴域计算逻辑(完整时间线 vs 可见数据)。
|
||||||
|
- 动画不同步:
|
||||||
|
- 确保 DualChartView 使用统一的外部索引与年进度参数传递给两个子图。
|
||||||
|
- 事件/临界状态错误:
|
||||||
|
- 检查事件成本聚合与 isCritical 标记逻辑,确保资产余额为负时正确标注。
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L237-L251)
|
||||||
|
- [src/DualChartView.tsx](file://src/DualChartView.tsx#L31-L76)
|
||||||
|
- [src/CashFlowChart.tsx](file://src/CashFlowChart.tsx#L358-L438)
|
||||||
|
- [src/LifePlanChart.tsx](file://src/LifePlanChart.tsx#L292-L343)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
本模块通过 FinancialSimulator 将 PDF 输入转化为两条高质量时序数据,借助 YearlySnapshot 与 CashFlowData 的清晰契约,实现了资产耗尽趋势与收支明细的双视图联动渲染。前后端通过 Zod Schema 严格约束数据结构,配合 Recharts 的插值与 HUD 机制,提供了流畅且可解释的财务可视化体验。通过合理的分段处理与缓存策略,可在大数据量场景下保持稳定性能。
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
- 示例数据参考:
|
||||||
|
- [src/mock-cashflow.json](file://src/mock-cashflow.json#L1-L120)
|
||||||
|
- [src/mock-data.json](file://src/mock-data.json#L1-L61)
|
||||||
|
|
@ -0,0 +1,321 @@
|
||||||
|
# 测试策略
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件**
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py)
|
||||||
|
- [tests/test_video_processor.py](file://tests/test_video_processor.py)
|
||||||
|
- [tests/fixtures/README.md](file://tests/fixtures/README.md)
|
||||||
|
- [requirements.txt](file://requirements.txt)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md)
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py)
|
||||||
|
- [backend/video_processor/strategy_audio.py](file://backend/video_processor/strategy_audio.py)
|
||||||
|
- [backend/video_processor/strategy_direct.py](file://backend/video_processor/strategy_direct.py)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [引言](#引言)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能考量](#性能考量)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录](#附录)
|
||||||
|
|
||||||
|
## 引言
|
||||||
|
本文件系统化阐述项目的测试策略与实践,重点覆盖:
|
||||||
|
- tests/ 目录下的单元测试与集成测试设计原则
|
||||||
|
- 对 CLI 接口的独立测试工具 test_video_cli.py 的使用与验证路径
|
||||||
|
- 对视频处理模块 backend/video_processor 的单元测试与数据结构一致性校验
|
||||||
|
- 测试夹具(fixtures)的组织与生成方法
|
||||||
|
- 使用 pytest 运行测试套件的方法
|
||||||
|
- 在 CI/CD 中的测试职责与质量门禁建议
|
||||||
|
- 面向开发者的新增测试编写指南
|
||||||
|
|
||||||
|
目标是帮助开发者快速理解测试体系、可靠地扩展测试覆盖面,并确保后端逻辑的稳定性与正确性。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
测试相关的关键位置与职责如下:
|
||||||
|
- tests/test_video_cli.py:独立的 CLI 测试工具,用于直接调用视频分析模块,输出结果或进行依赖检查
|
||||||
|
- tests/test_video_processor.py:针对视频处理模块的单元测试,包含数据结构、工具函数与音频优先策略的端到端验证
|
||||||
|
- tests/fixtures/README.md:测试夹具(样本视频与期望输出)的生成与维护指南
|
||||||
|
- backend/video_processor/*:视频分析的核心实现,包括策略选择、数据结构定义与通用工具函数
|
||||||
|
- main.py:主入口脚本,展示视频分析在整体流水线中的位置(供理解测试上下文)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "测试层"
|
||||||
|
T1["tests/test_video_cli.py"]
|
||||||
|
T2["tests/test_video_processor.py"]
|
||||||
|
F["tests/fixtures/README.md"]
|
||||||
|
end
|
||||||
|
subgraph "后端视频处理"
|
||||||
|
M1["backend/video_processor/__init__.py"]
|
||||||
|
M2["backend/video_processor/schemas.py"]
|
||||||
|
M3["backend/video_processor/utils.py"]
|
||||||
|
M4["backend/video_processor/strategy_audio.py"]
|
||||||
|
M5["backend/video_processor/strategy_direct.py"]
|
||||||
|
end
|
||||||
|
subgraph "主入口"
|
||||||
|
E["main.py"]
|
||||||
|
end
|
||||||
|
T1 --> M1
|
||||||
|
T2 --> M1
|
||||||
|
T2 --> M2
|
||||||
|
T2 --> M3
|
||||||
|
T2 --> M4
|
||||||
|
T2 --> M5
|
||||||
|
E --> M1
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py#L1-L161)
|
||||||
|
- [tests/test_video_processor.py](file://tests/test_video_processor.py#L1-L143)
|
||||||
|
- [tests/fixtures/README.md](file://tests/fixtures/README.md#L1-L49)
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py#L1-L60)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L1-L91)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L1-L164)
|
||||||
|
- [backend/video_processor/strategy_audio.py](file://backend/video_processor/strategy_audio.py#L1-L187)
|
||||||
|
- [backend/video_processor/strategy_direct.py](file://backend/video_processor/strategy_direct.py#L1-L58)
|
||||||
|
- [main.py](file://main.py#L1-L305)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L143-L156)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- 视频分析入口与策略分发:backend/video_processor/__init__.py 提供 analyze_video 入口,根据 strategy 调用不同策略实现
|
||||||
|
- 数据结构契约:schemas.py 定义 VideoSegment、KeyMoment、VideoAnalysisResult 等标准输出结构,保证各策略输出一致
|
||||||
|
- 通用工具:utils.py 封装 FFmpeg/ffprobe 调用,提供时长查询、音频提取、视频切片与依赖检测
|
||||||
|
- 策略实现:
|
||||||
|
- strategy_audio.py 实现“音频优先”策略,调用 OpenAI Whisper 获取带时间戳的转录,再生成片段与关键时刻
|
||||||
|
- strategy_direct.py 为“直接视频分析”占位,当前抛出未实现异常,留待后续迭代
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py#L1-L60)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L1-L91)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L1-L164)
|
||||||
|
- [backend/video_processor/strategy_audio.py](file://backend/video_processor/strategy_audio.py#L1-L187)
|
||||||
|
- [backend/video_processor/strategy_direct.py](file://backend/video_processor/strategy_direct.py#L1-L58)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
测试与被测模块的交互关系如下:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant CLI as "CLI 测试工具<br/>tests/test_video_cli.py"
|
||||||
|
participant API as "分析入口<br/>backend/video_processor/__init__.py"
|
||||||
|
participant Strat as "音频优先策略<br/>strategy_audio.py"
|
||||||
|
participant Utils as "工具函数<br/>utils.py"
|
||||||
|
participant Schemas as "数据结构<br/>schemas.py"
|
||||||
|
CLI->>API : 调用 analyze_video(video_path, strategy, config)
|
||||||
|
API->>Strat : 选择策略并实例化
|
||||||
|
Strat->>Utils : 提取音频/获取时长
|
||||||
|
Strat->>Strat : Whisper 转录带时间戳
|
||||||
|
Strat->>Schemas : 构造 VideoSegment/KeyMoment/VideoAnalysisResult
|
||||||
|
Strat-->>API : 返回 VideoAnalysisResult
|
||||||
|
API-->>CLI : 返回结果并可转换为字典
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py#L100-L145)
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py#L31-L60)
|
||||||
|
- [backend/video_processor/strategy_audio.py](file://backend/video_processor/strategy_audio.py#L54-L100)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L13-L164)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L12-L91)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### CLI 测试工具:test_video_cli.py
|
||||||
|
- 设计目的:独立验证视频分析模块,支持依赖检查、策略选择、语言设置、对比模式与输出到 JSON 文件
|
||||||
|
- 关键行为:
|
||||||
|
- 依赖检查:验证 FFmpeg 与 OpenAI 库是否可用
|
||||||
|
- 输入校验:要求提供输入视频文件;若仅需检查依赖,可通过参数触发
|
||||||
|
- 分析执行:调用 analyze_video 并将结果转换为字典输出
|
||||||
|
- 输出控制:默认打印到控制台,也可写入 JSON 文件
|
||||||
|
- 适用场景:
|
||||||
|
- 快速验证单个视频的分析流程
|
||||||
|
- 作为回归测试的辅助工具,配合 fixtures 生成期望输出
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py#L1-L161)
|
||||||
|
|
||||||
|
### 单元测试:test_video_processor.py
|
||||||
|
- 测试范围:
|
||||||
|
- 数据结构:VideoSegment、KeyMoment、VideoAnalysisResult 的创建与序列化
|
||||||
|
- 工具函数:FFmpeg 依赖检测、视频时长获取、音频提取(含清理)
|
||||||
|
- 策略验证:音频优先策略在具备 OPENAI_API_KEY 和 sample.mp4 时的端到端验证
|
||||||
|
- 测试夹具:
|
||||||
|
- sample.mp4:短时长日语对话视频,用于测试处理链路
|
||||||
|
- expected_output.json:由 CLI 工具生成的期望输出,用于回归比对
|
||||||
|
- 标记与跳过:
|
||||||
|
- 使用 pytest.mark.skipif 条件跳过:当环境变量或文件缺失时跳过对应测试,避免非必要的失败
|
||||||
|
- 断言要点:
|
||||||
|
- 结果对象的字段完整性与类型正确性
|
||||||
|
- 分析结果中片段数量、重要片段过滤、引号类关键时刻等业务断言
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [tests/test_video_processor.py](file://tests/test_video_processor.py#L1-L143)
|
||||||
|
- [tests/fixtures/README.md](file://tests/fixtures/README.md#L1-L49)
|
||||||
|
|
||||||
|
### 数据结构与契约:schemas.py
|
||||||
|
- VideoSegment:包含起止时间、描述、转录、重要度、标签、说话人等字段,并提供 to_dict 序列化
|
||||||
|
- KeyMoment:包含时间戳、类型(quote/chart/emotion/action)、内容与上下文,并提供 to_dict 序列化
|
||||||
|
- VideoAnalysisResult:包含源视频、总时长、策略、片段列表、关键时刻列表、元数据,并提供过滤方法与 to_dict 序列化
|
||||||
|
- 契约意义:统一各策略输出格式,便于上层流水线消费与渲染
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L1-L91)
|
||||||
|
|
||||||
|
### 工具函数:utils.py
|
||||||
|
- get_video_duration:基于 ffprobe 获取视频时长,错误时抛出运行时异常
|
||||||
|
- extract_audio:基于 ffmpeg 提取音频,支持采样率与编码器配置,返回音频文件路径
|
||||||
|
- slice_video:基于 ffmpeg 进行流复制切片,不重编码,适合快速片段提取
|
||||||
|
- check_ffmpeg_installed:检查 ffmpeg/ffprobe 是否在 PATH 中
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L1-L164)
|
||||||
|
|
||||||
|
### 策略实现:strategy_audio.py
|
||||||
|
- 初始化:从配置读取语言、模型与提示词,并从环境变量读取 OPENAI_API_KEY
|
||||||
|
- analyze:按以下步骤执行
|
||||||
|
- 提取音频
|
||||||
|
- Whisper 转录(verbose_json,带段级时间戳)
|
||||||
|
- 将转录段落转换为 VideoSegment 列表
|
||||||
|
- 识别关键时刻(示例:标记首段为 quote)
|
||||||
|
- 获取视频总时长
|
||||||
|
- 清理临时音频文件
|
||||||
|
- 组装 VideoAnalysisResult 返回
|
||||||
|
- 错误处理:缺少 OPENAI_API_KEY 时抛出异常;FFmpeg/Whisper 失败时抛出运行时异常
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/video_processor/strategy_audio.py](file://backend/video_processor/strategy_audio.py#L1-L187)
|
||||||
|
|
||||||
|
### 策略实现:strategy_direct.py(占位)
|
||||||
|
- 当前状态:抛出 NotImplementedError,提示使用音频优先策略
|
||||||
|
- 后续演进:按计划拆分视频、上传至 Gemini、分析视觉与音频、合并结果
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/video_processor/strategy_direct.py](file://backend/video_processor/strategy_direct.py#L1-L58)
|
||||||
|
|
||||||
|
### 测试夹具(fixtures)管理
|
||||||
|
- 目录与用途:
|
||||||
|
- tests/fixtures/sample.mp4:测试用短视频(git-ignored),用于验证处理链路
|
||||||
|
- tests/fixtures/expected_output.json:由 CLI 工具生成的期望输出,用于回归测试
|
||||||
|
- 生成流程:
|
||||||
|
- 准备 sample.mp4(录制或下载)
|
||||||
|
- 使用 CLI 工具生成 expected_output.json
|
||||||
|
- 在测试中加载该文件进行比对(如需)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [tests/fixtures/README.md](file://tests/fixtures/README.md#L1-L49)
|
||||||
|
|
||||||
|
### 测试运行与环境
|
||||||
|
- 运行方式:
|
||||||
|
- 使用 pytest 运行 tests/test_video_processor.py
|
||||||
|
- 或在根目录使用命令行运行 pytest
|
||||||
|
- 依赖安装:
|
||||||
|
- requirements.txt 中包含 pytest 与 pytest-cov 的注释说明,可在需要时启用覆盖率统计
|
||||||
|
- 环境变量:
|
||||||
|
- OPENAI_API_KEY:音频优先策略依赖 Whisper
|
||||||
|
- FFmpeg/ffprobe:工具函数依赖
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L151-L156)
|
||||||
|
- [requirements.txt](file://requirements.txt#L31-L34)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- 模块耦合:
|
||||||
|
- analyze_video 作为入口,仅依赖策略实现与工具函数,保持良好解耦
|
||||||
|
- 策略实现依赖工具函数与数据结构,不反向依赖上层
|
||||||
|
- 外部依赖:
|
||||||
|
- FFmpeg/ffprobe:媒体处理
|
||||||
|
- OpenAI Whisper:音频转录
|
||||||
|
- 可能的循环依赖:
|
||||||
|
- 当前结构清晰,无明显循环导入
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
Init["__init__.py<br/>analyze_video"] --> SA["strategy_audio.py"]
|
||||||
|
Init --> SD["strategy_direct.py"]
|
||||||
|
SA --> U["utils.py"]
|
||||||
|
SA --> S["schemas.py"]
|
||||||
|
SD --> S
|
||||||
|
```
|
||||||
|
|
||||||
|
图表来源
|
||||||
|
- [backend/video_processor/__init__.py](file://backend/video_processor/__init__.py#L31-L60)
|
||||||
|
- [backend/video_processor/strategy_audio.py](file://backend/video_processor/strategy_audio.py#L54-L100)
|
||||||
|
- [backend/video_processor/strategy_direct.py](file://backend/video_processor/strategy_direct.py#L43-L58)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L13-L164)
|
||||||
|
- [backend/video_processor/schemas.py](file://backend/video_processor/schemas.py#L12-L91)
|
||||||
|
|
||||||
|
## 性能考量
|
||||||
|
- 音频优先策略的优势:
|
||||||
|
- 以文本替代视频帧,显著降低 Token 成本与处理时间
|
||||||
|
- Whisper 提供精确的时间戳,有利于后续片段定位
|
||||||
|
- 工具函数的性能注意:
|
||||||
|
- extract_audio 与 slice_video 使用流复制,避免重编码,提升速度
|
||||||
|
- get_video_duration 依赖 ffprobe,应确保其可用性与版本兼容
|
||||||
|
- 测试中的性能关注点:
|
||||||
|
- 使用 skipif 跳过资源不足的测试,减少无效等待
|
||||||
|
- 在本地使用小体积 sample.mp4 进行快速回归
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [backend/video_processor/strategy_audio.py](file://backend/video_processor/strategy_audio.py#L1-L187)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L13-L164)
|
||||||
|
- [tests/test_video_processor.py](file://tests/test_video_processor.py#L80-L140)
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- 依赖缺失
|
||||||
|
- FFmpeg/ffprobe 未安装:使用 CLI 的依赖检查功能确认安装
|
||||||
|
- OPENAI_API_KEY 未设置:音频优先策略会抛出异常,需在环境变量中配置
|
||||||
|
- 输入问题
|
||||||
|
- 缺少 sample.mp4:按 fixtures 说明准备测试视频
|
||||||
|
- 视频路径不存在:CLI 会报错并退出
|
||||||
|
- 工具函数异常
|
||||||
|
- get_video_duration/extract_audio/slice_video:当外部命令失败时抛出运行时异常,检查命令行输出与权限
|
||||||
|
- 测试失败
|
||||||
|
- 跳过测试:检查 skipif 条件(环境变量、文件存在性)
|
||||||
|
- 回归比对:核对 expected_output.json 与实际输出差异
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py#L70-L102)
|
||||||
|
- [tests/test_video_processor.py](file://tests/test_video_processor.py#L80-L140)
|
||||||
|
- [backend/video_processor/utils.py](file://backend/video_processor/utils.py#L13-L164)
|
||||||
|
- [tests/fixtures/README.md](file://tests/fixtures/README.md#L1-L49)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
本项目的测试策略围绕“数据结构契约 + 工具函数封装 + 策略实现”的清晰边界展开,既保证了模块内聚,又便于独立验证与扩展。通过 CLI 测试工具与 pytest 单元测试相结合,能够快速定位问题、稳定回归,并为后续引入更多策略与集成测试打下基础。建议在 CI/CD 中加入:
|
||||||
|
- 必要依赖检查(FFmpeg/ffprobe)
|
||||||
|
- 环境变量校验(OPENAI_API_KEY)
|
||||||
|
- 覆盖率统计(可选)
|
||||||
|
- 回归测试(基于 fixtures 与 expected_output.json)
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### 新增测试编写指南
|
||||||
|
- 遵循的原则
|
||||||
|
- 以数据结构为中心:先验证 schemas 的创建与序列化
|
||||||
|
- 以工具函数为边界:验证外部命令可用性与错误分支
|
||||||
|
- 以策略为场景:在具备必要凭据与资源的前提下验证端到端流程
|
||||||
|
- 夹具准备
|
||||||
|
- 在 tests/fixtures 下添加新的样本文件,并在测试中使用 skipif 保护
|
||||||
|
- 如需生成期望输出,使用 CLI 工具导出 JSON
|
||||||
|
- 运行与调试
|
||||||
|
- 使用 pytest 运行指定测试文件或模块
|
||||||
|
- 通过 -v/-k 等选项筛选与调试
|
||||||
|
- CI/CD 集成建议
|
||||||
|
- 在流水线中先行安装依赖与环境变量
|
||||||
|
- 执行 pytest 并收集覆盖率报告(可选)
|
||||||
|
- 对关键测试(如音频优先策略)进行条件执行
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [tests/test_video_processor.py](file://tests/test_video_processor.py#L1-L143)
|
||||||
|
- [tests/test_video_cli.py](file://tests/test_video_cli.py#L1-L161)
|
||||||
|
- [tests/fixtures/README.md](file://tests/fixtures/README.md#L1-L49)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L151-L156)
|
||||||
|
|
@ -0,0 +1,327 @@
|
||||||
|
# 配置管理
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文引用的文件**
|
||||||
|
- [.env.example](file://.env.example)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts)
|
||||||
|
- [tsconfig.json](file://tsconfig.json)
|
||||||
|
- [requirements.txt](file://requirements.txt)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md)
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [frontend/src/types.ts](file://src\types.ts)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构总览](#架构总览)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖关系分析](#依赖关系分析)
|
||||||
|
7. [性能考虑](#性能考虑)
|
||||||
|
8. [故障排查指南](#故障排查指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
10. [附录](#附录)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
本文件系统性梳理本项目的配置管理,覆盖以下方面:
|
||||||
|
- 环境变量与密钥:.env 中各 API 密钥的作用与设置方法
|
||||||
|
- 财务模拟参数:DEFAULT_INFLATION_RATE 等参数在数据模拟中的使用
|
||||||
|
- 视频渲染配置:remotion.config.ts 的渲染行为
|
||||||
|
- TypeScript 编译配置:tsconfig.json 的编译选项与排除规则
|
||||||
|
- Python 依赖管理:requirements.txt 的依赖策略与可选组件
|
||||||
|
- 配置与代码分离最佳实践与密钥安全建议
|
||||||
|
- 为所有用户提供清晰的配置清单与修改指南
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
本项目采用“Python 后端 + Node.js 前端(Remotion)”混合架构,配置文件分布于根目录与子模块中,便于按层分离与维护。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A[".env.example<br/>环境变量示例"] --> B["main.py<br/>主流程入口"]
|
||||||
|
B --> C["backend/data_builder.py<br/>财务模拟与图表数据构建"]
|
||||||
|
B --> D["backend/media_fetcher.py<br/>B-Roll 获取待实现"]
|
||||||
|
B --> E["frontend/src/render-props-types.ts<br/>渲染数据契约TypeScript"]
|
||||||
|
F["remotion.config.ts<br/>视频渲染配置"] --> G["Remotion 渲染引擎"]
|
||||||
|
H["tsconfig.json<br/>TypeScript 编译选项"] --> E
|
||||||
|
I["requirements.txt<br/>Python 依赖清单"] --> B
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L50-L80)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts#L1-L12)
|
||||||
|
- [tsconfig.json](file://tsconfig.json#L1-L18)
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [main.py](file://main.py#L1-L305)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L50-L80)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
- 环境变量与密钥
|
||||||
|
- .env.example 提供了 GEMINI_API_KEY、OPENAI_API_KEY、PEXELS_API_KEY 等密钥占位符,以及 DEFAULT_INFLATION_RATE、DEFAULT_DEFICIT_INTEREST_RATE、DEFAULT_INVESTMENT_RETURN 等财务模拟参数占位符。
|
||||||
|
- 运行时通过 dotenv 加载密钥,用于调用外部服务(如 Pexels、Gemini、OpenAI)。
|
||||||
|
- 财务模拟参数
|
||||||
|
- 在 backend/data_builder.py 中,FinancialSimulator 从配置字典读取通胀率、赤字利率、投资回报率等参数,驱动年金模拟与现金流计算。
|
||||||
|
- 视频渲染配置
|
||||||
|
- remotion.config.ts 设置图像格式与输出覆盖策略,影响最终视频生成行为。
|
||||||
|
- TypeScript 编译配置
|
||||||
|
- tsconfig.json 定义严格模式、JSX 处理、模块解析策略与排除项,确保前端类型检查与构建稳定性。
|
||||||
|
- Python 依赖管理
|
||||||
|
- requirements.txt 明确列出核心依赖与可选组件,指导安装与版本选择。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [.env.example](file://.env.example#L1-L23)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts#L1-L12)
|
||||||
|
- [tsconfig.json](file://tsconfig.json#L1-L18)
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
下图展示配置在整体流程中的作用与流向。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Dev as "开发者"
|
||||||
|
participant Env as ".env/.env.example"
|
||||||
|
participant Py as "main.py"
|
||||||
|
participant Sim as "backend/data_builder.py"
|
||||||
|
participant MF as "backend/media_fetcher.py"
|
||||||
|
participant TS as "frontend/src/render-props-types.ts"
|
||||||
|
participant RC as "remotion.config.ts"
|
||||||
|
Dev->>Env : 创建 .env 并填写密钥与参数
|
||||||
|
Dev->>Py : 运行主流程
|
||||||
|
Py->>Env : 读取环境变量密钥
|
||||||
|
Py->>Sim : 传入 PDF 数据与可选配置含财务参数
|
||||||
|
Sim->>Sim : 使用 DEFAULT_INFLATION_RATE 等参数进行模拟
|
||||||
|
Py->>MF : 传入脚本关键词用于 B-Roll 获取
|
||||||
|
MF->>Env : 读取 PEXELS_API_KEY
|
||||||
|
Py->>TS : 生成 render_props.json前端契约
|
||||||
|
Py->>RC : 触发 Remotion 渲染受配置影响
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [.env.example](file://.env.example#L1-L23)
|
||||||
|
- [main.py](file://main.py#L1-L305)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts#L1-L12)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### 环境变量与密钥配置(.env)
|
||||||
|
- 位置与用途
|
||||||
|
- .env.example 提供密钥占位符与财务参数占位符,用于指导用户创建 .env 文件。
|
||||||
|
- 运行时通过 dotenv 加载密钥,供后端模块使用。
|
||||||
|
- 关键条目说明
|
||||||
|
- GEMINI_API_KEY:用于 PDF 解析与脚本生成(例如 Pexels 关键词翻译、脚本生成等)。
|
||||||
|
- OPENAI_API_KEY:用于 Whisper 转录(若提供视频输入)。
|
||||||
|
- PEXELS_API_KEY:用于 B-Roll 资源检索与下载。
|
||||||
|
- GOOGLE_APPLICATION_CREDENTIALS(可选):用于 Google Cloud TTS(如启用)。
|
||||||
|
- DEFAULT_INFLATION_RATE、DEFAULT_DEFICIT_INTEREST_RATE、DEFAULT_INVESTMENT_RETURN:财务模拟参数,用于 FinancialSimulator。
|
||||||
|
- 设置步骤
|
||||||
|
- 复制 .env.example 为 .env,填入真实密钥与参数值。
|
||||||
|
- 不要将 .env 提交到版本控制。
|
||||||
|
- 安全建议
|
||||||
|
- 将 .env 放入 .gitignore;使用只读权限;在 CI/CD 中使用加密变量注入;定期轮换密钥。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [.env.example](file://.env.example#L1-L23)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L97-L111)
|
||||||
|
|
||||||
|
### 财务模拟参数与使用
|
||||||
|
- 参数来源
|
||||||
|
- .env 中的 DEFAULT_INFLATION_RATE、DEFAULT_DEFICIT_INTEREST_RATE、DEFAULT_INVESTMENT_RETURN 作为默认值。
|
||||||
|
- backend/data_builder.py 的 FinancialSimulator 会从传入配置字典中读取对应键,若未提供则使用默认值。
|
||||||
|
- 模拟逻辑要点
|
||||||
|
- 通胀调整支出:基于年份指数增长。
|
||||||
|
- 现金流平衡:收入减去支出得到净现金流。
|
||||||
|
- 资产余额更新:正余额按投资回报增长,负余额按赤字利率复利增长。
|
||||||
|
- 现金流明细:将总支出拆分为生活、住房、教育、税、事件等分项。
|
||||||
|
- 修改指南
|
||||||
|
- 若需调整模拟参数,可在运行时传入配置字典或通过环境变量映射到配置字典,再传递给 FinancialSimulator。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["开始:接收财务参数"]) --> Init["初始化资产余额与年份"]
|
||||||
|
Init --> Loop{"是否达到寿命预期?"}
|
||||||
|
Loop --> |否| Income["计算当年收入退休前为年收入,否则为0"]
|
||||||
|
Income --> Inflate["按通胀率调整支出"]
|
||||||
|
Inflate --> NetCF["计算净现金流"]
|
||||||
|
NetCF --> Balance{"资产余额是否为正?"}
|
||||||
|
Balance --> |是| Pos["按投资回报率增长资产余额"]
|
||||||
|
Balance --> |否| Neg["按赤字利率复利增长资产余额"]
|
||||||
|
Pos --> Snapshot["生成年度快照"]
|
||||||
|
Neg --> Snapshot
|
||||||
|
Snapshot --> Next["推进到下一年"]
|
||||||
|
Next --> Loop
|
||||||
|
Loop --> |是| End(["结束:返回完整时间线"])
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [.env.example](file://.env.example#L19-L23)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
|
||||||
|
### 视频渲染配置(remotion.config.ts)
|
||||||
|
- 配置项说明
|
||||||
|
- 图像格式:设置为 jpeg,影响帧导出质量与体积。
|
||||||
|
- 输出覆盖:开启覆盖输出,避免重复渲染导致的冲突。
|
||||||
|
- 影响范围
|
||||||
|
- 该配置仅对 Node.js API 生效;当使用 Node.js API 时,配置文件不生效,需直接向 API 传入选项。
|
||||||
|
- 修改建议
|
||||||
|
- 如需 PNG 或 WebP,可调整图像格式;如需保留中间产物,关闭覆盖输出。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts#L1-L12)
|
||||||
|
|
||||||
|
### TypeScript 编译配置(tsconfig.json)
|
||||||
|
- 关键选项说明
|
||||||
|
- target/module:ES2018 与 commonjs,适配 Node.js 环境。
|
||||||
|
- jsx:react-jsx,支持 React 组件 JSX 渲染。
|
||||||
|
- strict:启用严格模式,提升类型安全性。
|
||||||
|
- noEmit:仅类型检查,不生成 JS 文件(由构建工具处理)。
|
||||||
|
- lib/moduleResolution:指定库与模块解析策略。
|
||||||
|
- resolveJsonModule:允许导入 JSON 模块。
|
||||||
|
- exclude:排除 remotion.config.ts,避免其参与前端编译。
|
||||||
|
- 修改建议
|
||||||
|
- 如需支持新语法特性,可调整 target;如需切换打包器,注意 moduleResolution 与 lib 的兼容性。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [tsconfig.json](file://tsconfig.json#L1-L18)
|
||||||
|
|
||||||
|
### Python 依赖管理(requirements.txt)
|
||||||
|
- 核心依赖
|
||||||
|
- python-dotenv:加载 .env 文件中的环境变量。
|
||||||
|
- 可选组件(按需安装)
|
||||||
|
- google-generativeai:Gemini 相关功能(按官方文档选择版本)。
|
||||||
|
- openai:Whisper 转录(按官方文档选择版本)。
|
||||||
|
- ffmpeg-python:视频处理。
|
||||||
|
- requests:网络请求。
|
||||||
|
- numpy/pandas(可选):高级仿真与数据分析。
|
||||||
|
- PyPDF2/pdfplumber(可选):PDF 处理(Gemini 可直接处理 PDF)。
|
||||||
|
- tqdm/rich:进度条与 CLI 界面美化。
|
||||||
|
- pytest/pytest-cov(可选):测试与覆盖率。
|
||||||
|
- 安装建议
|
||||||
|
- 先安装核心依赖,再根据需要安装可选组件,避免不必要的依赖污染。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
|
||||||
|
### 渲染数据契约(frontend/src/render-props-types.ts)
|
||||||
|
- 作用
|
||||||
|
- 定义 render_props.json 的 TypeScript 接口与 Zod 校验,确保前后端数据一致。
|
||||||
|
- 关键结构
|
||||||
|
- YearlySnapshot、CashFlowData:年度快照与现金流数据。
|
||||||
|
- AssetDepletionChartData、CashFlowChartData:图表数据结构。
|
||||||
|
- ScriptSegment:脚本段落,包含类型与可选的图表同步信息。
|
||||||
|
- AssetCollection:B-Roll 与视频片段等资源集合。
|
||||||
|
- RenderProps:顶层渲染契约。
|
||||||
|
- 使用建议
|
||||||
|
- 保持前端类型与后端生成的数据结构一致;使用校验函数在运行时验证数据完整性。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
### 前端类型基础(src/types.ts)
|
||||||
|
- 作用
|
||||||
|
- 定义前端图表组件的基础类型(如 YearlySnapshot、LifePlanChartProps),与渲染契约互补。
|
||||||
|
- 使用建议
|
||||||
|
- 在组件中使用这些类型,确保数据消费侧的类型安全。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [frontend/src/types.ts](file://src\types.ts#L1-L30)
|
||||||
|
|
||||||
|
## 依赖关系分析
|
||||||
|
- 配置与模块耦合
|
||||||
|
- main.py 通过命令行参数与配置字典驱动后端流程;FinancialSimulator 依赖配置字典中的财务参数。
|
||||||
|
- backend/media_fetcher.py 依赖环境变量中的 PEXELS_API_KEY。
|
||||||
|
- frontend/src/render-props-types.ts 与后端生成的数据结构强绑定,需保持同步。
|
||||||
|
- 外部依赖
|
||||||
|
- Python:dotenv、requests、ffmpeg-python、google-generativeai、openai(按需)、tqdm、rich。
|
||||||
|
- Node.js:Remotion、React、Zod(类型与校验)。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
ENV[".env/.env.example"] --> PY["main.py"]
|
||||||
|
PY --> SIM["backend/data_builder.py"]
|
||||||
|
PY --> MF["backend/media_fetcher.py"]
|
||||||
|
PY --> RPT["frontend/src/render-props-types.ts"]
|
||||||
|
RC["remotion.config.ts"] --> REND["Remotion 渲染"]
|
||||||
|
TS["tsconfig.json"] --> RPT
|
||||||
|
REQ["requirements.txt"] --> PY
|
||||||
|
```
|
||||||
|
|
||||||
|
图示来源
|
||||||
|
- [.env.example](file://.env.example#L1-L23)
|
||||||
|
- [main.py](file://main.py#L1-L305)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py#L1-L72)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts#L1-L12)
|
||||||
|
- [tsconfig.json](file://tsconfig.json#L1-L18)
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
|
||||||
|
## 性能考虑
|
||||||
|
- 环境变量加载
|
||||||
|
- 使用 dotenv 一次性加载 .env,避免频繁 IO;在生产环境中建议通过进程环境变量注入,减少文件系统访问。
|
||||||
|
- 渲染配置
|
||||||
|
- 图像格式与覆盖策略直接影响输出体积与磁盘占用;在调试阶段可使用更高分辨率格式,在发布阶段使用更高效的格式。
|
||||||
|
- 类型检查
|
||||||
|
- 严格模式与 noEmit 配合,确保类型错误在开发期暴露,减少运行时开销。
|
||||||
|
- 依赖安装
|
||||||
|
- 仅安装必要依赖,避免冗余包带来的冷启动与内存占用。
|
||||||
|
|
||||||
|
## 故障排查指南
|
||||||
|
- “找不到 API 密钥”
|
||||||
|
- 确认 .env 已创建且包含有效密钥;检查密钥名称与 .env 中一致;确认未提交 .env 到版本控制。
|
||||||
|
- “Remotion 渲染失败”
|
||||||
|
- 确认已执行 npm install;检查 frontend/src 结构是否正确;确认渲染命令参数与 render_props.json 路径无误。
|
||||||
|
- “PDF 解析失败”
|
||||||
|
- 确认 PDF 可读且未加密;检查 Gemini API 配额与限制;确认网络连通性。
|
||||||
|
- “B-Roll 获取失败”
|
||||||
|
- 确认 PEXELS_API_KEY 已正确设置;检查网络与 API 返回状态;关注翻译与下载流程的实现进度。
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L217-L233)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
本项目的配置管理遵循“配置与代码分离”的原则:密钥与参数集中于 .env,财务模拟参数通过配置字典注入,渲染与编译配置分别位于 remotion.config.ts 与 tsconfig.json,依赖管理集中在 requirements.txt。通过严格的类型契约与运行时校验,前后端数据一致性得到保障。建议在团队协作中统一密钥管理策略与版本控制规范,持续优化渲染与编译配置以满足性能目标。
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### 配置清单与修改指南
|
||||||
|
- 环境变量(.env)
|
||||||
|
- 必填项:GEMINI_API_KEY、OPENAI_API_KEY、PEXELS_API_KEY
|
||||||
|
- 可选项:GOOGLE_APPLICATION_CREDENTIALS(如使用 TTS)
|
||||||
|
- 财务参数:DEFAULT_INFLATION_RATE、DEFAULT_DEFICIT_INTEREST_RATE、DEFAULT_INVESTMENT_RETURN
|
||||||
|
- 修改方式:复制 .env.example 为 .env,填入真实值
|
||||||
|
- 财务模拟参数
|
||||||
|
- 位置:backend/data_builder.py 的 FinancialSimulator
|
||||||
|
- 修改方式:在调用处传入配置字典,或通过环境变量映射到配置字典
|
||||||
|
- 视频渲染配置
|
||||||
|
- 位置:remotion.config.ts
|
||||||
|
- 修改方式:调整图像格式与覆盖策略
|
||||||
|
- TypeScript 编译配置
|
||||||
|
- 位置:tsconfig.json
|
||||||
|
- 修改方式:根据项目需求调整 target、module、lib、moduleResolution 等
|
||||||
|
- Python 依赖
|
||||||
|
- 位置:requirements.txt
|
||||||
|
- 修改方式:按需安装可选组件,避免无关依赖
|
||||||
|
|
||||||
|
章节来源
|
||||||
|
- [.env.example](file://.env.example#L1-L23)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts#L1-L12)
|
||||||
|
- [tsconfig.json](file://tsconfig.json#L1-L18)
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L97-L111)
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
# 项目概述
|
||||||
|
|
||||||
|
<cite>
|
||||||
|
**本文档引用的文件**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md)
|
||||||
|
- [main.py](file://main.py)
|
||||||
|
- [package.json](file://package.json)
|
||||||
|
- [requirements.txt](file://requirements.txt)
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts)
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py)
|
||||||
|
- [backend/scripter.py](file://backend/scripter.py)
|
||||||
|
- [backend/media_fetcher.py](file://backend/media_fetcher.py)
|
||||||
|
- [src/LifePlanChart.tsx](file://src/LifePlanChart.tsx)
|
||||||
|
- [src/CashFlowChart.tsx](file://src/CashFlowChart.tsx)
|
||||||
|
- [src/Root.tsx](file://src/Root.tsx)
|
||||||
|
- [tests/test_video_processor.py](file://tests/test_video_processor.py)
|
||||||
|
</cite>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [简介](#简介)
|
||||||
|
2. [项目结构](#项目结构)
|
||||||
|
3. [核心组件](#核心组件)
|
||||||
|
4. [架构概述](#架构概述)
|
||||||
|
5. [详细组件分析](#详细组件分析)
|
||||||
|
6. [依赖分析](#依赖分析)
|
||||||
|
7. [性能考虑](#性能考虑)
|
||||||
|
8. [故障排除指南](#故障排除指南)
|
||||||
|
9. [结论](#结论)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
|
||||||
|
AI-video-lifeplan-demo项目是一个混合式Python/Node.js系统,能够自动将静态财务规划PDF报告和访谈视频转换为动态解说视频。该系统结合了AI生成的日语脚本、财务数据可视化图表、B-Roll素材集成和视频片段同步技术,生成个性化的3-5分钟人生规划视频。系统采用前后端分离架构,通过render_props.json文件作为数据契约进行通信。
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L1-L246)
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
项目采用清晰的目录结构,分为资产文件、后端模块、前端组件和测试文件。主要目录包括:
|
||||||
|
|
||||||
|
- **assets/**: 存储共享资源,包含输入、处理中和输出三个子目录
|
||||||
|
- **backend/**: Python后端模块,负责数据处理和AI功能
|
||||||
|
- **frontend/src/**: Node.js/TypeScript前端,使用Remotion进行视频渲染
|
||||||
|
- **src/**: 核心React组件和类型定义
|
||||||
|
- **tests/**: 测试文件和测试数据
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[项目根目录] --> B[assets]
|
||||||
|
A --> C[backend]
|
||||||
|
A --> D[frontend/src]
|
||||||
|
A --> E[src]
|
||||||
|
A --> F[tests]
|
||||||
|
B --> G[0_input]
|
||||||
|
B --> H[1_processed]
|
||||||
|
B --> I[2_output]
|
||||||
|
C --> J[video_processor]
|
||||||
|
C --> K[__init__.py]
|
||||||
|
C --> L[data_builder.py]
|
||||||
|
C --> M[extractor.py]
|
||||||
|
C --> N[investigate_media_fetch.py]
|
||||||
|
C --> O[investigate_subtitle_extraction.py]
|
||||||
|
C --> P[investigate_video_extraction.py]
|
||||||
|
C --> Q[media_fetcher.py]
|
||||||
|
C --> R[scripter.py]
|
||||||
|
D --> S[index.ts]
|
||||||
|
D --> T[Root.tsx]
|
||||||
|
D --> U[render-props-types.ts]
|
||||||
|
D --> V[types.ts]
|
||||||
|
E --> W[CashFlowChart.tsx]
|
||||||
|
E --> X[Composition.tsx]
|
||||||
|
E --> Y[DualChartView.tsx]
|
||||||
|
E --> Z[LifePlanBarChart.tsx]
|
||||||
|
E --> AA[LifePlanChart.tsx]
|
||||||
|
E --> AB[Root.tsx]
|
||||||
|
E --> AC[index.ts]
|
||||||
|
E --> AD[mock-cashflow-original.json]
|
||||||
|
E --> AE[mock-cashflow.json]
|
||||||
|
E --> AF[mock-data.json]
|
||||||
|
E --> AG[types.ts]
|
||||||
|
F --> AH[fixtures]
|
||||||
|
F --> AI[test_video_cli.py]
|
||||||
|
F --> AJ[test_video_processor.py]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L50-L79)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L50-L79)
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
|
||||||
|
系统的核心组件包括Python后端的数据处理模块和Node.js前端的视频渲染组件。后端负责PDF解析、脚本生成、B-Roll获取和财务数据模拟,前端使用Remotion框架将数据渲染为视频。数据通过render_props.json文件在两个系统之间传递,确保了前后端的解耦和灵活性。
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [main.py](file://main.py#L1-L305)
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
## 架构概述
|
||||||
|
|
||||||
|
系统采用混合式Python/Node.js架构,后端作为指挥中心负责数据处理和AI功能,前端作为渲染引擎负责视频生成。这种架构设计充分利用了Python在AI和数据处理方面的优势,以及Node.js在视频渲染和前端开发方面的优势。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Python后端] --> |生成| B[render_props.json]
|
||||||
|
B --> |消费| C[Remotion前端]
|
||||||
|
A --> D[Google Gemini]
|
||||||
|
A --> E[OpenAI Whisper]
|
||||||
|
A --> F[FFmpeg]
|
||||||
|
A --> G[Pexels API]
|
||||||
|
C --> H[React 19 + TypeScript]
|
||||||
|
C --> I[Recharts]
|
||||||
|
C --> J[Zod]
|
||||||
|
D --> K[PDF解析]
|
||||||
|
D --> L[脚本生成]
|
||||||
|
E --> M[音频转录]
|
||||||
|
F --> N[视频处理]
|
||||||
|
G --> O[B-Roll素材]
|
||||||
|
H --> P[视频渲染]
|
||||||
|
I --> Q[动态图表]
|
||||||
|
J --> R[模式验证]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L16-L32)
|
||||||
|
- [package.json](file://package.json#L1-L43)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L14-L49)
|
||||||
|
|
||||||
|
## 详细组件分析
|
||||||
|
|
||||||
|
### 后端数据处理分析
|
||||||
|
|
||||||
|
后端数据处理模块负责从PDF报告中提取财务数据,并生成用于视频渲染的结构化数据。FinancialSimulator类实现了财务预测模拟,包括通货膨胀计算、赤字复利计算和投资回报计算。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class FinancialSimulator {
|
||||||
|
+inflation_rate : float
|
||||||
|
+deficit_interest_rate : float
|
||||||
|
+investment_return : float
|
||||||
|
+simulate_timeline(current_age, retirement_age, life_expectancy, current_assets, annual_income, annual_expenses) : List[Dict]
|
||||||
|
+_get_event_cost(year, life_events) : float
|
||||||
|
+_get_event_labels(year, life_events) : List[str]
|
||||||
|
+build_cashflow_data(timeline) : List[Dict]
|
||||||
|
}
|
||||||
|
class YearlySnapshot {
|
||||||
|
+year : int
|
||||||
|
+ages : Dict[str, int]
|
||||||
|
+financials : Dict[str, float]
|
||||||
|
+events : List[str]
|
||||||
|
+isCritical : bool
|
||||||
|
}
|
||||||
|
class CashFlowData {
|
||||||
|
+year : int
|
||||||
|
+age : int
|
||||||
|
+income : Dict[str, float]
|
||||||
|
+expenses : Dict[str, float]
|
||||||
|
+totalExpense : float
|
||||||
|
+netCashFlow : float
|
||||||
|
+assetBalance : float
|
||||||
|
}
|
||||||
|
FinancialSimulator --> YearlySnapshot : "生成"
|
||||||
|
FinancialSimulator --> CashFlowData : "生成"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L15-L200)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [backend/data_builder.py](file://backend/data_builder.py#L1-L200)
|
||||||
|
|
||||||
|
### 前端视频渲染分析
|
||||||
|
|
||||||
|
前端视频渲染组件使用Remotion框架将数据渲染为视频。LifePlanChart和CashFlowChart组件使用Recharts库创建动态财务图表,通过动画效果展示财务数据随时间的变化。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Backend as Python后端
|
||||||
|
participant Frontend as Remotion前端
|
||||||
|
participant Chart as 财务图表
|
||||||
|
participant Animation as 动画引擎
|
||||||
|
Backend->>Frontend : 生成render_props.json
|
||||||
|
Frontend->>Chart : 加载财务数据
|
||||||
|
Chart->>Animation : 请求动画参数
|
||||||
|
Animation->>Chart : 返回插值数据
|
||||||
|
Chart->>Chart : 渲染动态图表
|
||||||
|
Chart->>Frontend : 输出视频帧
|
||||||
|
Frontend->>Backend : 完成视频渲染
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [src/LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [src/CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [src/LifePlanChart.tsx](file://src/LifePlanChart.tsx#L1-L349)
|
||||||
|
- [src/CashFlowChart.tsx](file://src/CashFlowChart.tsx#L1-L441)
|
||||||
|
|
||||||
|
### 数据契约分析
|
||||||
|
|
||||||
|
render_props.json文件作为前后端之间的数据契约,定义了视频渲染所需的所有数据结构。TypeScript接口确保了数据类型的正确性,Zod模式用于运行时验证。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
RENDER_PROPS {
|
||||||
|
object meta
|
||||||
|
object charts
|
||||||
|
array script
|
||||||
|
object assets
|
||||||
|
}
|
||||||
|
CHARTS {
|
||||||
|
object assetDepletion
|
||||||
|
object cashFlow
|
||||||
|
}
|
||||||
|
SCRIPT {
|
||||||
|
int id
|
||||||
|
string text
|
||||||
|
float duration
|
||||||
|
string type
|
||||||
|
string assetUrl
|
||||||
|
object chartSync
|
||||||
|
}
|
||||||
|
ASSETS {
|
||||||
|
object broll
|
||||||
|
array videoClips
|
||||||
|
string ttsAudio
|
||||||
|
}
|
||||||
|
RENDER_PROPS ||--o{ CHARTS : "包含"
|
||||||
|
RENDER_PROPS ||--o{ SCRIPT : "包含"
|
||||||
|
RENDER_PROPS ||--o{ ASSETS : "包含"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [frontend/src/render-props-types.ts](file://frontend/src/render-props-types.ts#L1-L251)
|
||||||
|
|
||||||
|
## 依赖分析
|
||||||
|
|
||||||
|
项目依赖分为Python后端依赖和Node.js前端依赖。后端依赖包括AI/LLM库、媒体处理库和数据处理库,前端依赖包括Remotion、React和Recharts等。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A["AI-video-lifeplan-demo"] --> B["Python依赖"]
|
||||||
|
A --> C["Node.js依赖"]
|
||||||
|
B --> D["google-generativeai"]
|
||||||
|
B --> E["openai"]
|
||||||
|
B --> F["ffmpeg-python"]
|
||||||
|
B --> G["requests"]
|
||||||
|
B --> H["python-dotenv"]
|
||||||
|
C --> I["@remotion/cli"]
|
||||||
|
C --> J["react"]
|
||||||
|
C --> K["react-dom"]
|
||||||
|
C --> L["recharts"]
|
||||||
|
C --> M["remotion"]
|
||||||
|
C --> N["@types/react"]
|
||||||
|
C --> O["typescript"]
|
||||||
|
D --> P["PDF解析"]
|
||||||
|
D --> Q["脚本生成"]
|
||||||
|
E --> R["音频转录"]
|
||||||
|
F --> S["视频处理"]
|
||||||
|
G --> T["HTTP请求"]
|
||||||
|
H --> U["环境变量"]
|
||||||
|
I --> V["视频渲染"]
|
||||||
|
J --> W["UI组件"]
|
||||||
|
K --> X["UI组件"]
|
||||||
|
L --> Y["图表"]
|
||||||
|
M --> Z["视频渲染"]
|
||||||
|
N --> AA["类型定义"]
|
||||||
|
O --> AB["类型检查"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagram sources**
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [package.json](file://package.json#L1-L43)
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [requirements.txt](file://requirements.txt#L1-L34)
|
||||||
|
- [package.json](file://package.json#L1-L43)
|
||||||
|
|
||||||
|
## 性能考虑
|
||||||
|
|
||||||
|
系统在性能方面考虑了多个方面,包括数据处理效率、视频渲染优化和资源管理。后端使用模拟算法快速生成财务预测数据,前端使用React.memo优化组件渲染,Remotion配置优化了视频输出格式。
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [remotion.config.ts](file://remotion.config.ts#L1-L12)
|
||||||
|
- [src/LifePlanChart.tsx](file://src/LifePlanChart.tsx#L70-L349)
|
||||||
|
|
||||||
|
## 故障排除指南
|
||||||
|
|
||||||
|
常见问题包括API密钥未找到、Remotion渲染失败和PDF提取失败。解决方案包括确保.env文件存在且包含有效密钥、检查npm install是否成功完成,以及确保PDF可读且未受密码保护。
|
||||||
|
|
||||||
|
**Section sources**
|
||||||
|
- [PROJECT_README.md](file://PROJECT_README.md#L217-L232)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
AI-video-lifeplan-demo项目成功实现了将静态财务规划PDF和访谈视频自动转换为动态解说视频的功能。系统采用混合式Python/Node.js架构,通过render_props.json进行数据契约通信,结合AI生成日语脚本、财务数据可视化图表、B-Roll素材集成和视频片段同步技术,生成个性化的3-5分钟人生规划视频。该架构设计充分利用了Python在AI和数据处理方面的优势,以及Node.js在视频渲染和前端开发方面的优势,为用户提供了一种创新的财务规划展示方式。
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
お客様のライフプランを提供するために、お客様の基本情報をお願いいたします。 すいませんが、お客様のお名前からお願いいたします。 はい、私がテストテロとして、家族情報として、 生年月日の方は? はい、生年月日の方は、1980年の1月の1日です。 ということは、47歳ということでよろしいでしょうか。 はい。 現在のお勤め先はどこになりますでしょうか? IT系ですね。 IT系ですね。 はい。 すいませんが、前代の、ざっくりでよろしいので、年収はどのくらいになりますでしょうか。 年収が今700万くらいですね。 はい。 では、家族構成について、お客様、ご結婚はなされておりますか。 そうですね。 はい。 下の名前だけでいいので、奥様のお名前をお願いいたします。 花子です。 はい。奥様の生年月日を教えていただけますか。 1990年の1月の10日です。 ということは、現在37歳ということでよろしいでしょうか。 そうですね。 奥様は、ご勤めになられてますでしょうか。 そうですね。普通のサービス会ですけど、はい。 ざっくりでよろしいので、奥様の年収が600万くらいですね。 はい。 では続きまして、お子様の方はいらっしゃいますか。 はい、います。 何人いらっしゃいますか。 一人子だけです。 一人だけですね。 生年月日とかは。 2018年の3月の3日ですけど。 そうですか。 現在7歳ということで、たぶん小学校だと思いますけども、小学校の方は孤立ですでしょうか。 孤立です。 あ、孤立ですね。 ありがとうございます。
|
||||||
|
|
@ -0,0 +1,392 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"text": "お客様のライフプランを提供するために、お客様の基本情報をお願いいたします。",
|
||||||
|
"duration": 13.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_000.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"text": "すいませんが、お客様のお名前からお願いいたします。",
|
||||||
|
"duration": 5.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_001.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"text": "はい、私がテストテロとして、家族情報として、",
|
||||||
|
"duration": 11.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_002.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"text": "生年月日の方は?",
|
||||||
|
"duration": 3.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_003.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"text": "はい、生年月日の方は、1980年の1月の1日です。",
|
||||||
|
"duration": 9.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_004.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"text": "ということは、47歳ということでよろしいでしょうか。",
|
||||||
|
"duration": 3.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_005.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"text": "はい。",
|
||||||
|
"duration": 1.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_006.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"text": "現在のお勤め先はどこになりますでしょうか?",
|
||||||
|
"duration": 3.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_007.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"text": "IT系ですね。",
|
||||||
|
"duration": 2.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_008.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"text": "IT系ですね。",
|
||||||
|
"duration": 1.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_009.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"text": "はい。",
|
||||||
|
"duration": 1.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_010.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"text": "すいませんが、前代の、ざっくりでよろしいので、年収はどのくらいになりますでしょうか。",
|
||||||
|
"duration": 6.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_011.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"text": "年収が今700万くらいですね。",
|
||||||
|
"duration": 3.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_012.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"text": "はい。",
|
||||||
|
"duration": 1.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_013.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"text": "では、家族構成について、お客様、ご結婚はなされておりますか。",
|
||||||
|
"duration": 7.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_014.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"text": "そうですね。",
|
||||||
|
"duration": 1.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_015.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"text": "はい。",
|
||||||
|
"duration": 1.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_016.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"text": "下の名前だけでいいので、奥様のお名前をお願いいたします。",
|
||||||
|
"duration": 4.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_017.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"text": "花子です。",
|
||||||
|
"duration": 1.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_018.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"text": "はい。奥様の生年月日を教えていただけますか。",
|
||||||
|
"duration": 4.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_019.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"text": "1990年の1月の10日です。",
|
||||||
|
"duration": 7.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_020.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"text": "ということは、現在37歳ということでよろしいでしょうか。",
|
||||||
|
"duration": 3.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_021.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"text": "そうですね。",
|
||||||
|
"duration": 1.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_022.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 24,
|
||||||
|
"text": "奥様は、ご勤めになられてますでしょうか。",
|
||||||
|
"duration": 4.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_023.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 25,
|
||||||
|
"text": "そうですね。普通のサービス会ですけど、はい。",
|
||||||
|
"duration": 4.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_024.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 26,
|
||||||
|
"text": "ざっくりでよろしいので、奥様の年収が600万くらいですね。",
|
||||||
|
"duration": 5.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_025.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 27,
|
||||||
|
"text": "はい。",
|
||||||
|
"duration": 1.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_026.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 28,
|
||||||
|
"text": "では続きまして、お子様の方はいらっしゃいますか。",
|
||||||
|
"duration": 4.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_027.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 29,
|
||||||
|
"text": "はい、います。",
|
||||||
|
"duration": 2.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_028.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 30,
|
||||||
|
"text": "何人いらっしゃいますか。",
|
||||||
|
"duration": 2.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_029.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 31,
|
||||||
|
"text": "一人子だけです。",
|
||||||
|
"duration": 1.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_030.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 32,
|
||||||
|
"text": "一人だけですね。",
|
||||||
|
"duration": 1.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_031.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 33,
|
||||||
|
"text": "生年月日とかは。",
|
||||||
|
"duration": 2.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_032.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 34,
|
||||||
|
"text": "2018年の3月の3日ですけど。",
|
||||||
|
"duration": 5.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_033.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 35,
|
||||||
|
"text": "そうですか。",
|
||||||
|
"duration": 1.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_034.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 36,
|
||||||
|
"text": "現在7歳ということで、たぶん小学校だと思いますけども、小学校の方は孤立ですでしょうか。",
|
||||||
|
"duration": 11.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_035.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 37,
|
||||||
|
"text": "孤立です。",
|
||||||
|
"duration": 1.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_036.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 38,
|
||||||
|
"text": "あ、孤立ですね。",
|
||||||
|
"duration": 1.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_037.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 39,
|
||||||
|
"text": "ありがとうございます。",
|
||||||
|
"duration": 3.0,
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"audioPath": "public/dialogue/dialogue_seg_038.mp3",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
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,56 @@
|
||||||
"将来": "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",
|
||||||
|
"Japanese family sitting together": "family gathering",
|
||||||
|
"Japanese couple calculating budget": "budgeting couple",
|
||||||
|
"Japanese man explaining feelings": "expressing feelings",
|
||||||
|
"Asian man nodding in agreement": "nodding in agreement",
|
||||||
|
"Japanese retired couple smiling": "smiling retirees",
|
||||||
|
"Japanese family visiting a new car": "car shopping",
|
||||||
|
"Japanese consultant pointing at document": "consultant pointing",
|
||||||
|
"Japanese advisor asking about family": "advisor inquiring",
|
||||||
|
"Asian man giving his birthday": "giving birthday",
|
||||||
|
"Japanese businessman greeting warmly": "warm greeting",
|
||||||
|
"Japanese student studying hard": "studying hard",
|
||||||
|
"Japanese calendar with events marked": "marked calendar",
|
||||||
|
"Japanese financial planner verifying age": "verifying age",
|
||||||
|
"Japanese advisor bowing politely": "polite bowing",
|
||||||
|
"Japanese man introduced himself": "self-introduction",
|
||||||
|
"Japanese financial consultant showing graphs": "showing graphs",
|
||||||
|
"Japanese financial advisor consulting client": "client consulting",
|
||||||
|
"Japanese man talking in interview": "interview talking",
|
||||||
|
"日本人実業家オンライン投資": "Japanese businessman investing online",
|
||||||
|
"アジア人女性経費追跡": "Asian woman tracking expenses",
|
||||||
|
"アジア人女性保険見直し": "Asian woman reviewing insurance policy",
|
||||||
|
"Japanese man worried about finances": "financial worries",
|
||||||
|
"Japanese financial advisor discussing insurance": "insurance discussion",
|
||||||
|
"Japanese businesswoman offering consultation": "business consultation",
|
||||||
|
"Japanese business team smiling": "smiling business team",
|
||||||
|
"Japanese family discussing finances": "family finances",
|
||||||
|
"Asian businessman analyzing investment data": "investment analysis",
|
||||||
|
"Japanese couple planning vacation": "vacation planning",
|
||||||
|
"Japanese financial advisor explaining chart": "chart explanation",
|
||||||
|
"Japanese parents talking about education": "education discussion",
|
||||||
|
"Japanese senior couple enjoying retirement": "retirement enjoyment",
|
||||||
|
"Japanese woman tracking expenses": "expense tracking",
|
||||||
|
"Japanese businessman smiling": "smiling businessman"
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
{
|
||||||
|
"transcription": "お客様のライフプランを提供するために、お客様の基本情報をお願いいたします。 すいませんが、お客様のお名前からお願いいたします。 はい、私がテストテロとして、家族情報として、 生年月日の方は? はい、生年月日の方は、1980年の1月の1日です。 ということは、47歳ということでよろしいでしょうか。 はい。 現在のお勤め先はどこになりますでしょうか? IT系ですね。 IT系ですね。 はい。 すいませんが、前代の、ざっくりでよろしいので、年収はどのくらいになりますでしょうか。 年収が今700万くらいですね。 はい。 では、家族構成について、お客様、ご結婚はなされておりますか。 そうですね。 はい。 下の名前だけでいいので、奥様のお名前をお願いいたします。 花子です。 はい。奥様の生年月日を教えていただけますか。 1990年の1月の10日です。 ということは、現在37歳ということでよろしいでしょうか。 そうですね。 奥様は、ご勤めになられてますでしょうか。 そうですね。普通のサービス会ですけど、はい。 ざっくりでよろしいので、奥様の年収が600万くらいですね。 はい。 では続きまして、お子様の方はいらっしゃいますか。 はい、います。 何人いらっしゃいますか。 一人子だけです。 一人だけですね。 生年月日とかは。 2018年の3月の3日ですけど。 そうですか。 現在7歳ということで、たぶん小学校だと思いますけども、小学校の方は孤立ですでしょうか。 孤立です。 あ、孤立ですね。 ありがとうございます。",
|
||||||
|
"duration": 141.82000732421875,
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"start": 0.0,
|
||||||
|
"end": 13.0,
|
||||||
|
"text": "お客様のライフプランを提供するために、お客様の基本情報をお願いいたします。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 13.0,
|
||||||
|
"end": 18.0,
|
||||||
|
"text": "すいませんが、お客様のお名前からお願いいたします。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 18.0,
|
||||||
|
"end": 29.0,
|
||||||
|
"text": "はい、私がテストテロとして、家族情報として、"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 29.0,
|
||||||
|
"end": 32.0,
|
||||||
|
"text": "生年月日の方は?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 32.0,
|
||||||
|
"end": 41.0,
|
||||||
|
"text": "はい、生年月日の方は、1980年の1月の1日です。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 41.0,
|
||||||
|
"end": 44.0,
|
||||||
|
"text": "ということは、47歳ということでよろしいでしょうか。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 44.0,
|
||||||
|
"end": 45.0,
|
||||||
|
"text": "はい。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 45.0,
|
||||||
|
"end": 48.0,
|
||||||
|
"text": "現在のお勤め先はどこになりますでしょうか?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 48.0,
|
||||||
|
"end": 50.0,
|
||||||
|
"text": "IT系ですね。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 50.0,
|
||||||
|
"end": 51.0,
|
||||||
|
"text": "IT系ですね。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 51.0,
|
||||||
|
"end": 52.0,
|
||||||
|
"text": "はい。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 52.0,
|
||||||
|
"end": 58.0,
|
||||||
|
"text": "すいませんが、前代の、ざっくりでよろしいので、年収はどのくらいになりますでしょうか。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 58.0,
|
||||||
|
"end": 61.0,
|
||||||
|
"text": "年収が今700万くらいですね。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 61.0,
|
||||||
|
"end": 62.0,
|
||||||
|
"text": "はい。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 62.0,
|
||||||
|
"end": 69.0,
|
||||||
|
"text": "では、家族構成について、お客様、ご結婚はなされておりますか。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 69.0,
|
||||||
|
"end": 70.0,
|
||||||
|
"text": "そうですね。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 70.0,
|
||||||
|
"end": 71.0,
|
||||||
|
"text": "はい。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 71.0,
|
||||||
|
"end": 75.0,
|
||||||
|
"text": "下の名前だけでいいので、奥様のお名前をお願いいたします。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 75.0,
|
||||||
|
"end": 76.0,
|
||||||
|
"text": "花子です。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 76.0,
|
||||||
|
"end": 80.0,
|
||||||
|
"text": "はい。奥様の生年月日を教えていただけますか。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 81.0,
|
||||||
|
"end": 88.0,
|
||||||
|
"text": "1990年の1月の10日です。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 88.0,
|
||||||
|
"end": 91.0,
|
||||||
|
"text": "ということは、現在37歳ということでよろしいでしょうか。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 91.0,
|
||||||
|
"end": 92.0,
|
||||||
|
"text": "そうですね。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 92.0,
|
||||||
|
"end": 96.0,
|
||||||
|
"text": "奥様は、ご勤めになられてますでしょうか。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 96.0,
|
||||||
|
"end": 100.0,
|
||||||
|
"text": "そうですね。普通のサービス会ですけど、はい。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 100.0,
|
||||||
|
"end": 105.0,
|
||||||
|
"text": "ざっくりでよろしいので、奥様の年収が600万くらいですね。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 105.0,
|
||||||
|
"end": 106.0,
|
||||||
|
"text": "はい。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 106.0,
|
||||||
|
"end": 110.0,
|
||||||
|
"text": "では続きまして、お子様の方はいらっしゃいますか。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 110.0,
|
||||||
|
"end": 112.0,
|
||||||
|
"text": "はい、います。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 112.0,
|
||||||
|
"end": 114.0,
|
||||||
|
"text": "何人いらっしゃいますか。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 114.0,
|
||||||
|
"end": 115.0,
|
||||||
|
"text": "一人子だけです。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 115.0,
|
||||||
|
"end": 116.0,
|
||||||
|
"text": "一人だけですね。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 116.0,
|
||||||
|
"end": 118.0,
|
||||||
|
"text": "生年月日とかは。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 118.0,
|
||||||
|
"end": 123.0,
|
||||||
|
"text": "2018年の3月の3日ですけど。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 123.0,
|
||||||
|
"end": 124.0,
|
||||||
|
"text": "そうですか。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 124.0,
|
||||||
|
"end": 135.0,
|
||||||
|
"text": "現在7歳ということで、たぶん小学校だと思いますけども、小学校の方は孤立ですでしょうか。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 135.0,
|
||||||
|
"end": 136.0,
|
||||||
|
"text": "孤立です。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 136.0,
|
||||||
|
"end": 137.0,
|
||||||
|
"text": "あ、孤立ですね。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 137.0,
|
||||||
|
"end": 140.0,
|
||||||
|
"text": "ありがとうございます。"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"家族"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Analyze PPT and PDF materials to extract main content structure
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from pptx import Presentation
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
|
||||||
|
class MaterialAnalyzer:
|
||||||
|
def __init__(self):
|
||||||
|
self.project_root = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
def analyze_ppt(self):
|
||||||
|
"""Analyze PPT structure and content"""
|
||||||
|
print("=" * 80)
|
||||||
|
print("📊 PPT分析结果")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
ppt_path = self.project_root / "assets/0_input/ライフプラン情報.pptx"
|
||||||
|
prs = Presentation(str(ppt_path))
|
||||||
|
|
||||||
|
print(f"\n总页数: {len(prs.slides)}页\n")
|
||||||
|
|
||||||
|
for idx, slide in enumerate(prs.slides):
|
||||||
|
print(f"\n【第{idx + 1}页】")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
# Extract title
|
||||||
|
if slide.shapes.title:
|
||||||
|
print(f"标题: {slide.shapes.title.text}")
|
||||||
|
|
||||||
|
# Extract text content
|
||||||
|
text_content = []
|
||||||
|
for shape in slide.shapes:
|
||||||
|
if hasattr(shape, "text") and shape.text.strip():
|
||||||
|
if shape != slide.shapes.title:
|
||||||
|
text_content.append(shape.text.strip())
|
||||||
|
|
||||||
|
if text_content:
|
||||||
|
print(f"\n内容摘要:")
|
||||||
|
for i, text in enumerate(text_content[:5], 1): # Show first 5 items
|
||||||
|
preview = text[:100] + "..." if len(text) > 100 else text
|
||||||
|
print(f" {i}. {preview}")
|
||||||
|
|
||||||
|
if len(text_content) > 5:
|
||||||
|
print(f" ... (还有{len(text_content) - 5}项内容)")
|
||||||
|
|
||||||
|
print(f"\n文本框数量: {len(text_content)}")
|
||||||
|
|
||||||
|
def analyze_pdf(self):
|
||||||
|
"""Analyze PDF structure and content"""
|
||||||
|
print("\n\n" + "=" * 80)
|
||||||
|
print("📄 PDF分析结果")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||||||
|
doc = fitz.open(str(pdf_path))
|
||||||
|
|
||||||
|
print(f"\n总页数: {len(doc)}页\n")
|
||||||
|
|
||||||
|
for page_num in range(min(10, len(doc))): # Analyze first 10 pages
|
||||||
|
page = doc[page_num]
|
||||||
|
text = page.get_text()
|
||||||
|
|
||||||
|
print(f"\n【第{page_num + 1}页】")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
# Extract key information
|
||||||
|
lines = [line.strip() for line in text.split('\n') if line.strip()]
|
||||||
|
|
||||||
|
if lines:
|
||||||
|
print(f"页面内容预览:")
|
||||||
|
for i, line in enumerate(lines[:15], 1): # Show first 15 lines
|
||||||
|
preview = line[:80] + "..." if len(line) > 80 else line
|
||||||
|
print(f" {i}. {preview}")
|
||||||
|
|
||||||
|
if len(lines) > 15:
|
||||||
|
print(f" ... (还有{len(lines) - 15}行内容)")
|
||||||
|
else:
|
||||||
|
print(" (此页可能为图像或表格)")
|
||||||
|
|
||||||
|
print(f"\n文本行数: {len(lines)}")
|
||||||
|
|
||||||
|
if len(doc) > 10:
|
||||||
|
print(f"\n... (还有{len(doc) - 10}页未显示)")
|
||||||
|
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
def find_family_info_pages(self):
|
||||||
|
"""Find pages containing family information"""
|
||||||
|
print("\n\n" + "=" * 80)
|
||||||
|
print("🔍 家庭信息页面定位")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||||||
|
doc = fitz.open(str(pdf_path))
|
||||||
|
|
||||||
|
keywords = ["テスト太郎", "家族", "年齢", "年収", "生年月日", "基本情報", "家族構成"]
|
||||||
|
|
||||||
|
print(f"\n搜索关键词: {', '.join(keywords)}\n")
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
for page_num in range(len(doc)):
|
||||||
|
page = doc[page_num]
|
||||||
|
text = page.get_text()
|
||||||
|
|
||||||
|
found_keywords = [kw for kw in keywords if kw in text]
|
||||||
|
if found_keywords:
|
||||||
|
matches.append((page_num, found_keywords, text))
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
print(f"找到{len(matches)}个包含家庭信息的页面:\n")
|
||||||
|
for page_num, keywords_found, text in matches[:5]: # Show first 5
|
||||||
|
print(f"【PDF第{page_num + 1}页】(pdfPageIndex={page_num})")
|
||||||
|
print(f" 匹配关键词: {', '.join(keywords_found)}")
|
||||||
|
|
||||||
|
# Show excerpt
|
||||||
|
lines = [line.strip() for line in text.split('\n') if line.strip()]
|
||||||
|
print(f" 内容片段:")
|
||||||
|
for line in lines[:8]:
|
||||||
|
preview = line[:70] + "..." if len(line) > 70 else line
|
||||||
|
print(f" {preview}")
|
||||||
|
print()
|
||||||
|
else:
|
||||||
|
print("未找到包含家庭信息的页面")
|
||||||
|
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Execute full analysis"""
|
||||||
|
try:
|
||||||
|
self.analyze_ppt()
|
||||||
|
self.analyze_pdf()
|
||||||
|
self.find_family_info_pages()
|
||||||
|
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("✅ 分析完成")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ 分析失败: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
analyzer = MaterialAnalyzer()
|
||||||
|
analyzer.run()
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""
|
||||||
|
Audio generation module using OpenAI TTS
|
||||||
|
|
||||||
|
This module converts script text to audio using OpenAI's TTS API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
from openai import OpenAI
|
||||||
|
except ImportError:
|
||||||
|
OpenAI = None
|
||||||
|
|
||||||
|
from backend.video_processor.utils import get_video_duration
|
||||||
|
|
||||||
|
|
||||||
|
class AudioGenerator:
|
||||||
|
"""Generate narration audio using OpenAI TTS"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: Optional[str] = None):
|
||||||
|
self.api_key = api_key or os.getenv("OPENAI_API_KEY")
|
||||||
|
if not self.api_key:
|
||||||
|
raise ValueError("OPENAI_API_KEY not found in environment")
|
||||||
|
|
||||||
|
if OpenAI is None:
|
||||||
|
raise ImportError(
|
||||||
|
"openai package not installed. "
|
||||||
|
"Run: pip install openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client = OpenAI(api_key=self.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)
|
||||||
|
|
||||||
|
def generate_audio(
|
||||||
|
self,
|
||||||
|
script_segments: List[Dict[str, Any]],
|
||||||
|
voice: str = "nova",
|
||||||
|
model: str = "tts-1"
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Generate audio files for each script segment
|
||||||
|
|
||||||
|
Args:
|
||||||
|
script_segments: List of script segments with text
|
||||||
|
voice: Voice to use (alloy, echo, fable, onyx, nova, shimmer)
|
||||||
|
model: TTS model (tts-1 or tts-1-hd)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated script segments with audioPath field
|
||||||
|
"""
|
||||||
|
print(f" Generating audio with OpenAI TTS (voice: {voice})...")
|
||||||
|
|
||||||
|
updated_segments = []
|
||||||
|
|
||||||
|
for segment in script_segments:
|
||||||
|
segment_id = segment["id"]
|
||||||
|
text = segment["text"]
|
||||||
|
is_video_clip = segment.get("type") == "video_clip" or segment.get("visualType") == "video"
|
||||||
|
|
||||||
|
# Check if this segment should use existing audio (e.g. dialogue_clip)
|
||||||
|
current_audio_path = segment.get("audioPath", "")
|
||||||
|
is_manual_audio = "dialogue_clip" in current_audio_path
|
||||||
|
|
||||||
|
# Skip TTS for video clips or segments with manual audio
|
||||||
|
if is_video_clip or is_manual_audio:
|
||||||
|
if is_manual_audio:
|
||||||
|
print(f" ⏭️ Keeping manual audio for segment {segment_id}: {current_audio_path}")
|
||||||
|
else:
|
||||||
|
print(f" ⏭️ Keeping original audio for video segment {segment_id}")
|
||||||
|
|
||||||
|
# Still need to update duration if not present
|
||||||
|
if "duration" not in segment or segment["duration"] == 0:
|
||||||
|
try:
|
||||||
|
actual_duration = get_video_duration(str(current_audio_path))
|
||||||
|
segment["duration"] = actual_duration
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
updated_segments.append(segment)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Generate audio
|
||||||
|
audio_filename = f"segment_{segment_id:03d}.mp3"
|
||||||
|
audio_path = self.output_dir / audio_filename
|
||||||
|
|
||||||
|
response = self.client.audio.speech.create(
|
||||||
|
model=model,
|
||||||
|
voice=voice,
|
||||||
|
input=text
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save audio file
|
||||||
|
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
|
||||||
|
segment["audioPath"] = str(audio_path)
|
||||||
|
updated_segments.append(segment)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Failed to generate audio for segment {segment_id}: {e}")
|
||||||
|
# Keep segment without audio
|
||||||
|
updated_segments.append(segment)
|
||||||
|
|
||||||
|
print(f" ✓ Generated {len(updated_segments)} audio files")
|
||||||
|
return updated_segments
|
||||||
|
|
||||||
|
def estimate_audio_duration(self, text: str, rate: float = 150) -> float:
|
||||||
|
"""
|
||||||
|
Estimate audio duration based on text length
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to estimate
|
||||||
|
rate: Characters per minute (Japanese: ~150-200)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Estimated duration in seconds
|
||||||
|
"""
|
||||||
|
char_count = len(text)
|
||||||
|
duration = (char_count / rate) * 60
|
||||||
|
return round(duration, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_audio_for_script(
|
||||||
|
script_data: Dict[str, Any],
|
||||||
|
voice: str = "nova"
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convenience function to generate audio for a script
|
||||||
|
|
||||||
|
Args:
|
||||||
|
script_data: Script data with segments
|
||||||
|
voice: Voice to use for TTS
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated script data with audio paths
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
generator = AudioGenerator()
|
||||||
|
segments = script_data["segments"]
|
||||||
|
updated_segments = generator.generate_audio(segments, voice=voice)
|
||||||
|
|
||||||
|
return {
|
||||||
|
**script_data,
|
||||||
|
"segments": updated_segments,
|
||||||
|
}
|
||||||
|
except (ValueError, ImportError) as e:
|
||||||
|
print(f" ⚠️ Cannot generate audio: {e}")
|
||||||
|
print(" Skipping audio generation...")
|
||||||
|
return script_data
|
||||||
|
|
@ -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,7 +8,7 @@ This module:
|
||||||
- Generates data in format compatible with Remotion charts
|
- Generates data in format compatible with Remotion charts
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any, List, Optional
|
||||||
import math
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
"""
|
||||||
|
Dialogue Video Generator Module
|
||||||
|
|
||||||
|
This module extracts dialogue from video files, segments audio,
|
||||||
|
and generates render props for dialogue-style videos with speech bubbles.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python backend/dialogue_video_generator.py --video path/to/video.mp4
|
||||||
|
python backend/dialogue_video_generator.py # Auto-detect from assets/0_input/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from backend.extractor import VideoExtractor
|
||||||
|
|
||||||
|
|
||||||
|
class DialogueVideoGenerator:
|
||||||
|
"""Generate dialogue video assets from interview recordings"""
|
||||||
|
|
||||||
|
def __init__(self, video_path: str = None):
|
||||||
|
"""
|
||||||
|
Initialize the generator
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_path: Path to video file. If None, auto-detect from assets/0_input/
|
||||||
|
"""
|
||||||
|
load_dotenv()
|
||||||
|
self.project_root = Path(__file__).parent.parent
|
||||||
|
self.video_path = self._resolve_video_path(video_path)
|
||||||
|
self.segments_output = self.project_root / "assets/1_processed/video_segments.json"
|
||||||
|
self.script_output = self.project_root / "assets/1_processed/dialogue_script.json"
|
||||||
|
self.props_output = self.project_root / "assets/1_processed/render_props_dialogue.json"
|
||||||
|
self.audio_output_dir = self.project_root / "public/dialogue"
|
||||||
|
|
||||||
|
def _resolve_video_path(self, video_path: str = None) -> Path:
|
||||||
|
"""Resolve video path from input or auto-detect"""
|
||||||
|
if video_path:
|
||||||
|
return Path(video_path)
|
||||||
|
|
||||||
|
# Auto-detect from assets/0_input/
|
||||||
|
input_dir = self.project_root / "assets/0_input"
|
||||||
|
video_extensions = ["*.mp4", "*.mov", "*.avi", "*.mkv"]
|
||||||
|
|
||||||
|
for ext in video_extensions:
|
||||||
|
video_files = list(input_dir.glob(ext))
|
||||||
|
if video_files:
|
||||||
|
print(f"✓ Auto-detected video: {video_files[0].name}")
|
||||||
|
return video_files[0]
|
||||||
|
|
||||||
|
raise FileNotFoundError("No video file found. Please provide --video path or place video in assets/0_input/")
|
||||||
|
|
||||||
|
def extract_segments(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Extract audio transcription and timestamps from video
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with transcription, segments, and metadata
|
||||||
|
"""
|
||||||
|
print(f"📹 Extracting segments from: {self.video_path.name}")
|
||||||
|
|
||||||
|
if not self.video_path.exists():
|
||||||
|
raise FileNotFoundError(f"Video file not found: {self.video_path}")
|
||||||
|
|
||||||
|
extractor = VideoExtractor()
|
||||||
|
result = extractor.extract(str(self.video_path))
|
||||||
|
|
||||||
|
# Save segments
|
||||||
|
with open(self.segments_output, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"✓ Saved {len(result['segments'])} segments to {self.segments_output.name}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def slice_audio(self, input_path: str, start: float, end: float, output_path: str):
|
||||||
|
"""Slice audio segment using ffmpeg"""
|
||||||
|
duration = end - start
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-ss", str(start),
|
||||||
|
"-t", str(duration),
|
||||||
|
"-i", str(input_path),
|
||||||
|
"-c:a", "libmp3lame", "-b:a", "128k",
|
||||||
|
str(output_path)
|
||||||
|
]
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
|
|
||||||
|
def identify_speaker(self, text: str, last_speaker: str) -> str:
|
||||||
|
"""
|
||||||
|
Identify speaker based on text content
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Dialogue text
|
||||||
|
last_speaker: Previous segment's speaker (for alternation)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Speaker label: "FP" or "家族代表様"
|
||||||
|
"""
|
||||||
|
# FP (Financial Planner) keywords
|
||||||
|
fp_keywords = [
|
||||||
|
"?", "お願いいたします", "でしょうか",
|
||||||
|
"教えていただけますか", "ありがとうございます",
|
||||||
|
"いらっしゃいますか", "そうですか"
|
||||||
|
]
|
||||||
|
|
||||||
|
is_fp = any(kw in text for kw in fp_keywords)
|
||||||
|
|
||||||
|
# Short confirmations alternate between speakers
|
||||||
|
if text in ["はい。", "そうですね。"]:
|
||||||
|
return "FP" if last_speaker == "家族代表様" else "家族代表様"
|
||||||
|
|
||||||
|
return "FP" if is_fp else "家族代表様"
|
||||||
|
|
||||||
|
def generate_audio_clips(self, segments: List[Dict]) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Generate individual audio clips for each dialogue segment
|
||||||
|
|
||||||
|
Args:
|
||||||
|
segments: List of segment dictionaries with text and timestamps
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of processed segments with speaker labels and audio paths
|
||||||
|
"""
|
||||||
|
print(f"🎙️ Generating {len(segments)} audio clips...")
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
self.audio_output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Find audio file
|
||||||
|
audio_path = self.video_path.parent / f"{self.video_path.stem}_audio.wav"
|
||||||
|
if not audio_path.exists():
|
||||||
|
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
||||||
|
|
||||||
|
processed_segments = []
|
||||||
|
last_speaker = "家族代表様"
|
||||||
|
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
text = seg["text"]
|
||||||
|
speaker = self.identify_speaker(text, last_speaker)
|
||||||
|
last_speaker = speaker
|
||||||
|
|
||||||
|
# Generate audio clip
|
||||||
|
segment_filename = f"dialogue_seg_{i:03d}.mp3"
|
||||||
|
segment_path = self.audio_output_dir / segment_filename
|
||||||
|
|
||||||
|
print(f" [{i+1}/{len(segments)}] {text[:30]}...")
|
||||||
|
self.slice_audio(str(audio_path), seg["start"], seg["end"], str(segment_path))
|
||||||
|
|
||||||
|
processed_segments.append({
|
||||||
|
"id": i + 1,
|
||||||
|
"text": text,
|
||||||
|
"duration": seg["end"] - seg["start"],
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": speaker,
|
||||||
|
"audioPath": f"public/dialogue/{segment_filename}",
|
||||||
|
"brollKeyword": "Japanese interview"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Save dialogue script
|
||||||
|
with open(self.script_output, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(processed_segments, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"✓ Generated {len(processed_segments)} dialogue segments")
|
||||||
|
return processed_segments
|
||||||
|
|
||||||
|
def create_render_props(self, dialogue_script: List[Dict]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create render_props.json for dialogue video
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dialogue_script: List of dialogue segments
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Complete render props dictionary
|
||||||
|
"""
|
||||||
|
print("📝 Creating render props for dialogue video...")
|
||||||
|
|
||||||
|
# Load original render_props for meta and charts
|
||||||
|
original_props_path = self.project_root / "assets/1_processed/render_props.json"
|
||||||
|
|
||||||
|
if original_props_path.exists():
|
||||||
|
with open(original_props_path, "r", encoding="utf-8") as f:
|
||||||
|
original_props = json.load(f)
|
||||||
|
else:
|
||||||
|
# Minimal fallback structure
|
||||||
|
original_props = {
|
||||||
|
"meta": {"title": "対話動画", "currencyUnit": "万円", "duration": 180},
|
||||||
|
"charts": {},
|
||||||
|
"assets": {"broll": {}, "videoClips": [], "slides": []}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Construct new props
|
||||||
|
new_props = {
|
||||||
|
"meta": original_props.get("meta", {}),
|
||||||
|
"charts": original_props.get("charts", {}),
|
||||||
|
"script": dialogue_script,
|
||||||
|
"assets": original_props.get("assets", {})
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update title
|
||||||
|
if "title" in new_props["meta"]:
|
||||||
|
new_props["meta"]["title"] = "【対話版】" + new_props["meta"]["title"]
|
||||||
|
|
||||||
|
# Save render props
|
||||||
|
with open(self.props_output, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"✓ Saved render props to {self.props_output.name}")
|
||||||
|
return new_props
|
||||||
|
|
||||||
|
def run(self) -> str:
|
||||||
|
"""
|
||||||
|
Execute full dialogue video generation pipeline
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to generated render_props.json
|
||||||
|
"""
|
||||||
|
print("🚀 Starting Dialogue Video Generation Pipeline...")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Step 1: Extract segments
|
||||||
|
result = self.extract_segments()
|
||||||
|
segments = result["segments"]
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Step 2: Generate audio clips
|
||||||
|
processed_segments = self.generate_audio_clips(segments)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Step 3: Create render props
|
||||||
|
self.create_render_props(processed_segments)
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(f"✅ Pipeline complete! Use the following command to render:")
|
||||||
|
print(f" npx remotion render MainVideo assets/2_output/dialogue_video.mp4 \\")
|
||||||
|
print(f" --props=\"{self.props_output.relative_to(self.project_root)}\"")
|
||||||
|
|
||||||
|
return str(self.props_output)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""CLI entry point"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Dialogue Video Generator - Extract and render interview dialogues",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
python backend/dialogue_video_generator.py
|
||||||
|
python backend/dialogue_video_generator.py --video assets/0_input/interview.mp4
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--video",
|
||||||
|
type=str,
|
||||||
|
help="Path to video file (auto-detect from assets/0_input/ if not provided)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
generator = DialogueVideoGenerator(video_path=args.video)
|
||||||
|
generator.run()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Generation failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
exit(main())
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
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,109 @@ 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": 達成目標年
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"advice": [
|
||||||
|
"PDFの最後の方にある『対策とアドバイス』や『FPのコメント』から具体的な助言を箇条書きで抽出してください"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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 +170,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 +185,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()
|
||||||
|
|
@ -0,0 +1,302 @@
|
||||||
|
"""
|
||||||
|
Generate comprehensive video using PDF pages as visual anchor (NOT PPT)
|
||||||
|
|
||||||
|
CRITICAL: This script uses PDF page content as the structural backbone
|
||||||
|
- PDF pages are converted to images and used as visual materials
|
||||||
|
- PPT slides are NOT used as the main visual sequence
|
||||||
|
- Content organization follows PDF page structure
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python backend/generate_combined_video.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
|
||||||
|
|
||||||
|
class CombinedVideoGenerator:
|
||||||
|
"""Generate video with PDF pages as structural anchor"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
load_dotenv()
|
||||||
|
self.project_root = Path(__file__).parent.parent
|
||||||
|
self.api_key = os.getenv("GEMINI_API_KEY")
|
||||||
|
|
||||||
|
if not self.api_key:
|
||||||
|
raise ValueError("GEMINI_API_KEY not found in .env")
|
||||||
|
|
||||||
|
self.client = genai.Client(api_key=self.api_key)
|
||||||
|
|
||||||
|
def convert_pdf_to_images(self) -> List[str]:
|
||||||
|
"""Convert PDF pages to images for video use"""
|
||||||
|
print("📄 Converting PDF pages to images...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
except ImportError:
|
||||||
|
print("⚠️ PyMuPDF not installed, using placeholder images")
|
||||||
|
return []
|
||||||
|
|
||||||
|
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||||||
|
output_dir = self.project_root / "public" / "pdf_pages"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
doc = fitz.open(str(pdf_path))
|
||||||
|
image_paths = []
|
||||||
|
|
||||||
|
for page_num in range(len(doc)):
|
||||||
|
page = doc[page_num]
|
||||||
|
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2x resolution
|
||||||
|
|
||||||
|
image_filename = f"pdf_page_{page_num + 1:03d}.png"
|
||||||
|
image_path = output_dir / image_filename
|
||||||
|
pix.save(str(image_path))
|
||||||
|
|
||||||
|
image_paths.append(f"pdf_pages/{image_filename}")
|
||||||
|
print(f" ✓ Page {page_num + 1}/{len(doc)}")
|
||||||
|
|
||||||
|
doc.close()
|
||||||
|
print(f"✓ Converted {len(image_paths)} PDF pages")
|
||||||
|
return image_paths
|
||||||
|
|
||||||
|
def load_all_materials(self) -> Dict[str, Any]:
|
||||||
|
"""Load materials with PDF as primary visual source"""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(self.project_root))
|
||||||
|
|
||||||
|
print("📦 Loading all materials...")
|
||||||
|
|
||||||
|
# Convert PDF to images (PRIMARY VISUAL SOURCE)
|
||||||
|
pdf_images = self.convert_pdf_to_images()
|
||||||
|
|
||||||
|
# Load existing render_props for charts ONLY
|
||||||
|
props_path = self.project_root / "assets/1_processed/render_props.json"
|
||||||
|
with open(props_path, "r", encoding="utf-8") as f:
|
||||||
|
props_data = json.load(f)
|
||||||
|
print(f"✓ Loaded chart data")
|
||||||
|
|
||||||
|
# Prepare PDF summary
|
||||||
|
pdf_summary = {
|
||||||
|
"client_name": props_data["meta"].get("clientName", "お客様"),
|
||||||
|
"current_age": 47,
|
||||||
|
"spouse_age": 37,
|
||||||
|
"child_age": 7,
|
||||||
|
"annual_income": 703,
|
||||||
|
"current_assets": 836,
|
||||||
|
"annual_expenditure": 1038,
|
||||||
|
"charts": props_data["charts"],
|
||||||
|
"pdf_images": pdf_images # PRIMARY VISUAL ASSETS
|
||||||
|
}
|
||||||
|
print(f"✓ Prepared PDF data with {len(pdf_images)} page images")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"pdf": pdf_summary,
|
||||||
|
"existing_props": props_data
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate_comprehensive_script(self, materials: Dict) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Generate script using PDF page structure (NOT PPT)
|
||||||
|
|
||||||
|
Structure based on PDF pages:
|
||||||
|
1. Opening (intro)
|
||||||
|
2. Family basic info (PDF page 1)
|
||||||
|
3. Life events (PDF page 2)
|
||||||
|
4. Asset projection intro (PDF page 3)
|
||||||
|
5. Chart analysis (use chart view)
|
||||||
|
6. Education plan (PDF page 4)
|
||||||
|
7. Summary (PDF page 5)
|
||||||
|
"""
|
||||||
|
print("🤖 Generating script based on PDF structure...")
|
||||||
|
|
||||||
|
pdf_data = materials["pdf"]
|
||||||
|
pdf_images = pdf_data["pdf_images"]
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
あなたはプロのファイナンシャルプランナーであり、動画解説のナレーターです。
|
||||||
|
|
||||||
|
以下の情報に基づいて、**PDFページを視覚素材とする**解説動画のナレーション原稿を作成してください。
|
||||||
|
|
||||||
|
【重要】視覚素材はPDFページ画像を使用します(PPTスライドではありません)
|
||||||
|
|
||||||
|
【家族情報】
|
||||||
|
- 家族代表: {pdf_data["client_name"]} ({pdf_data["current_age"]}歳)
|
||||||
|
- 配偶者: {pdf_data["spouse_age"]}歳
|
||||||
|
- お子様: {pdf_data["child_age"]}歳
|
||||||
|
- 現在の年収: {pdf_data["annual_income"]}万円
|
||||||
|
- 現在の資産: {pdf_data["current_assets"]}万円
|
||||||
|
- 年間支出: {pdf_data["annual_expenditure"]}万円
|
||||||
|
|
||||||
|
【利用可能なPDFページ画像】
|
||||||
|
{len(pdf_images)}ページ分の画像が利用可能
|
||||||
|
|
||||||
|
【要件】
|
||||||
|
1. 以下の構成で原稿を作成(**家族成員紹介のみ**):
|
||||||
|
- intro: オープニング(10秒、家族紹介の導入)
|
||||||
|
- rep_intro: 家族代表紹介(20秒、PDFページ1、代表者の詳細)
|
||||||
|
- spouse_intro: 配偶者紹介(15秒、PDFページ1、配偶者の詳細)
|
||||||
|
- child_intro: お子様紹介(15秒、PDFページ1、お子様と教育プラン)
|
||||||
|
- family_summary: 家族紹介まとめ(10秒、家族全体のまとめ)
|
||||||
|
|
||||||
|
2. 各セグメントの構造:
|
||||||
|
- id: セグメントID
|
||||||
|
- text: ナレーション文(具体的な数値・年齢・職業を含む)
|
||||||
|
- duration: 推定時間(秒)
|
||||||
|
- visualType: "pdf_page"
|
||||||
|
- pdfPageIndex: PDFページ番号(0始まり)
|
||||||
|
- highlightArea: "full"
|
||||||
|
|
||||||
|
3. **重要**: 家族成員の年齢、職業、年収など具体的な情報を丁寧に紹介
|
||||||
|
4. 温かみのある親しみやすい語り口で、家族の魅力を伝える
|
||||||
|
|
||||||
|
**出力形式**: JSON配列
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.client.models.generate_content(
|
||||||
|
model="gemini-2.0-flash",
|
||||||
|
contents=prompt,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
temperature=0.7,
|
||||||
|
response_mime_type="application/json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = json.loads(response.text)
|
||||||
|
print(f"✓ Generated {len(result)} script segments")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||||||
|
"""Generate TTS audio for each segment"""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(self.project_root))
|
||||||
|
from backend.audio_generator import AudioGenerator
|
||||||
|
from backend.video_processor.utils import get_video_duration
|
||||||
|
|
||||||
|
print("🎙️ Generating audio...")
|
||||||
|
|
||||||
|
audio_gen = AudioGenerator()
|
||||||
|
output_dir = self.project_root / "public" / "pdf_narration"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
text = seg["text"]
|
||||||
|
audio_filename = f"narration_{seg['id']}.mp3"
|
||||||
|
audio_path = output_dir / audio_filename
|
||||||
|
|
||||||
|
print(f" [{i+1}/{len(segments)}] {text[:40]}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = audio_gen.client.audio.speech.create(
|
||||||
|
model="tts-1",
|
||||||
|
voice="nova",
|
||||||
|
input=text
|
||||||
|
)
|
||||||
|
response.stream_to_file(str(audio_path))
|
||||||
|
|
||||||
|
actual_duration = get_video_duration(str(audio_path))
|
||||||
|
seg["duration"] = actual_duration
|
||||||
|
seg["audioPath"] = f"public/pdf_narration/{audio_filename}"
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Failed: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"✓ Generated {len(segments)} audio files")
|
||||||
|
return segments
|
||||||
|
|
||||||
|
def create_render_props(self, segments: List[Dict], materials: Dict) -> Dict:
|
||||||
|
"""Create render_props using PDF pages as visuals"""
|
||||||
|
|
||||||
|
existing_props = materials["existing_props"]
|
||||||
|
pdf_images = materials["pdf"]["pdf_images"]
|
||||||
|
|
||||||
|
# Build PDF page assets
|
||||||
|
pdf_page_assets = [
|
||||||
|
{
|
||||||
|
"index": i,
|
||||||
|
"image_path": f"public/{img}",
|
||||||
|
"description": f"PDF Page {i+1}"
|
||||||
|
}
|
||||||
|
for i, img in enumerate(pdf_images)
|
||||||
|
]
|
||||||
|
|
||||||
|
new_props = {
|
||||||
|
"meta": {
|
||||||
|
"title": f"【PDF解説】{materials['pdf']['client_name']}様のライフプラン",
|
||||||
|
"currencyUnit": "万円",
|
||||||
|
"duration": sum(s.get("duration", 0) for s in segments),
|
||||||
|
"clientName": materials["pdf"]["client_name"]
|
||||||
|
},
|
||||||
|
"charts": existing_props["charts"],
|
||||||
|
"script": segments,
|
||||||
|
"assets": {
|
||||||
|
"pdfPages": pdf_page_assets, # PDF pages as PRIMARY visuals
|
||||||
|
"slides": [], # Empty - not using PPT
|
||||||
|
"videoClips": existing_props["assets"].get("videoClips", []),
|
||||||
|
"broll": existing_props["assets"].get("broll", {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output_path = self.project_root / "assets/1_processed/render_props_pdf_based.json"
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"✓ Saved PDF-based render props to {output_path.name}")
|
||||||
|
return new_props
|
||||||
|
|
||||||
|
def run(self) -> str:
|
||||||
|
"""Execute full pipeline"""
|
||||||
|
print("🚀 Generating PDF-based comprehensive video...\n")
|
||||||
|
|
||||||
|
# Load materials (PDF as primary)
|
||||||
|
materials = self.load_all_materials()
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Generate script
|
||||||
|
segments = self.generate_comprehensive_script(materials)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Generate audio
|
||||||
|
segments = self.generate_audio(segments)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Create render props
|
||||||
|
props = self.create_render_props(segments, materials)
|
||||||
|
print()
|
||||||
|
|
||||||
|
output_file = "assets/2_output/pdf_based_video.mp4"
|
||||||
|
print(f"✅ Pipeline complete! Render command:")
|
||||||
|
print(f" npx remotion render MainVideo {output_file} \\")
|
||||||
|
print(f" --props=\"assets/1_processed/render_props_pdf_based.json\"")
|
||||||
|
print()
|
||||||
|
print("📋 改动说明:")
|
||||||
|
print(" 1. 替换视觉素材: PPT slides → PDF page images")
|
||||||
|
print(" 2. 结构锚点: 按PDF页面组织内容,而非PPT顺序")
|
||||||
|
print(" 3. 新增assets.pdfPages字段存储PDF页面图像")
|
||||||
|
print(" 4. visualType: 'pdf_page'(替代'ppt')")
|
||||||
|
print(" 5. pdfPageIndex: 指向PDF页码(替代slideIndex)")
|
||||||
|
|
||||||
|
return output_file
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
generator = CombinedVideoGenerator()
|
||||||
|
generator.run()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Generation failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
exit(main())
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
"""
|
||||||
|
Generate detailed narration video for specific PPT slide with PDF context
|
||||||
|
|
||||||
|
This script creates a comprehensive narration video combining:
|
||||||
|
1. PPT slide content (text + visual)
|
||||||
|
2. PDF financial data context
|
||||||
|
3. AI-generated detailed explanation
|
||||||
|
4. Smooth transitions between content sections
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python backend/generate_detailed_narration.py --slide 2
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Import Gemini API
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
|
||||||
|
|
||||||
|
class DetailedNarrationGenerator:
|
||||||
|
"""Generate detailed narration video for specific slide"""
|
||||||
|
|
||||||
|
def __init__(self, slide_index: int = 2):
|
||||||
|
load_dotenv()
|
||||||
|
self.project_root = Path(__file__).parent.parent
|
||||||
|
self.slide_index = slide_index
|
||||||
|
self.api_key = os.getenv("GEMINI_API_KEY")
|
||||||
|
|
||||||
|
if not self.api_key:
|
||||||
|
raise ValueError("GEMINI_API_KEY not found in .env")
|
||||||
|
|
||||||
|
self.client = genai.Client(api_key=self.api_key)
|
||||||
|
|
||||||
|
def load_ppt_slide(self) -> Dict[str, Any]:
|
||||||
|
"""Load specific PPT slide data"""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(self.project_root))
|
||||||
|
from backend.ppt_extractor import extract_ppt_data
|
||||||
|
|
||||||
|
ppt_path = self.project_root / "assets/0_input/ライフプラン情報.pptx"
|
||||||
|
ppt_data = extract_ppt_data(str(ppt_path))
|
||||||
|
|
||||||
|
if self.slide_index >= len(ppt_data["slides"]):
|
||||||
|
raise ValueError(f"Slide {self.slide_index} not found")
|
||||||
|
|
||||||
|
return ppt_data["slides"][self.slide_index]
|
||||||
|
|
||||||
|
def load_pdf_context(self) -> Dict[str, Any]:
|
||||||
|
"""Load PDF financial data as context"""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(self.project_root))
|
||||||
|
from backend.extractor import extract_pdf_data
|
||||||
|
|
||||||
|
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||||||
|
return extract_pdf_data(str(pdf_path))
|
||||||
|
|
||||||
|
def generate_narration_script(self, slide: Dict, pdf_data: Dict) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Generate detailed narration script using Gemini
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of narration segments with text, duration, visual cues
|
||||||
|
"""
|
||||||
|
print(f"🤖 Generating detailed narration for slide {self.slide_index}...")
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
あなたはプロのファイナンシャルプランナーであり、親しみやすい動画解説者です。
|
||||||
|
|
||||||
|
以下の情報に基づいて、詳細な解説動画用のナレーション原稿を作成してください。
|
||||||
|
|
||||||
|
【PPTスライド内容】
|
||||||
|
タイトル: {slide['title']}
|
||||||
|
テキスト:
|
||||||
|
{slide['text']}
|
||||||
|
|
||||||
|
【PDFから抽出した追加情報】
|
||||||
|
- 家族代表: {pdf_data.get('client_name', 'テスト太郎')} ({pdf_data.get('current_age', 47)}歳)
|
||||||
|
- 年収: {pdf_data.get('annual_income', 703)}万円
|
||||||
|
- 現在の資産: {pdf_data.get('current_assets', 836)}万円
|
||||||
|
- 年間支出: {pdf_data.get('annual_expenditure', 1038)}万円
|
||||||
|
|
||||||
|
【要件】
|
||||||
|
1. スライドの内容を**段落ごとに分解**し、各段落を詳細に解説してください
|
||||||
|
2. 各セグメントは以下の構造で出力してください:
|
||||||
|
- intro: スライド全体の導入(15秒程度)
|
||||||
|
- section_1: 家族代表の基本情報(20秒程度)
|
||||||
|
- section_2: 配偶者情報(15秒程度)
|
||||||
|
- section_3: お子様情報と教育プラン(20秒程度)
|
||||||
|
- summary: まとめ(10秒程度)
|
||||||
|
|
||||||
|
3. 各セグメントには以下を含めてください:
|
||||||
|
- text: ナレーション文(自然な口語体、具体的な数値を含む)
|
||||||
|
- duration: 推定時間(秒)
|
||||||
|
- visualCue: 画面表示の指示("ppt" / "zoom_in" / "highlight_section")
|
||||||
|
- highlightArea: 強調する領域("top" / "middle" / "bottom" / "full")
|
||||||
|
|
||||||
|
4. 数値やデータは必ず具体的に言及し、抽象的な表現は避けてください
|
||||||
|
5. 親しみやすく、わかりやすい言葉遣いを心がけてください
|
||||||
|
|
||||||
|
**出力形式**: JSON配列として返してください
|
||||||
|
[
|
||||||
|
{{
|
||||||
|
"id": "intro",
|
||||||
|
"text": "それでは、ご家族の基本情報について詳しく見ていきましょう。...",
|
||||||
|
"duration": 15,
|
||||||
|
"visualCue": "ppt",
|
||||||
|
"highlightArea": "full"
|
||||||
|
}},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.client.models.generate_content(
|
||||||
|
model="gemini-2.0-flash",
|
||||||
|
contents=prompt,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
temperature=0.7,
|
||||||
|
response_mime_type="application/json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = json.loads(response.text)
|
||||||
|
print(f"✓ Generated {len(result)} narration segments")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def create_smooth_transitions(self, segments: List[Dict]) -> List[Dict]:
|
||||||
|
"""Add transition effects between segments"""
|
||||||
|
enhanced_segments = []
|
||||||
|
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
# Enhance segment with visual metadata
|
||||||
|
enhanced_seg = {
|
||||||
|
**seg,
|
||||||
|
"type": "narration",
|
||||||
|
"visualType": "ppt",
|
||||||
|
"visualAssetIndex": self.slide_index,
|
||||||
|
"brollKeyword": f"Japanese family planning {seg.get('highlightArea', 'full')}",
|
||||||
|
}
|
||||||
|
|
||||||
|
enhanced_segments.append(enhanced_seg)
|
||||||
|
|
||||||
|
return enhanced_segments
|
||||||
|
|
||||||
|
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||||||
|
"""Generate TTS audio for each segment"""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(self.project_root))
|
||||||
|
from backend.audio_generator import AudioGenerator
|
||||||
|
from backend.video_processor.utils import get_video_duration
|
||||||
|
|
||||||
|
print("🎙️ Generating audio for narration segments...")
|
||||||
|
|
||||||
|
audio_gen = AudioGenerator()
|
||||||
|
output_dir = self.project_root / "public" / f"slide_{self.slide_index}_narration"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
text = seg["text"]
|
||||||
|
audio_filename = f"narration_{seg['id']}.mp3"
|
||||||
|
audio_path = output_dir / audio_filename
|
||||||
|
|
||||||
|
print(f" [{i+1}] Generating: {text[:50]}...")
|
||||||
|
|
||||||
|
# Use OpenAI TTS API directly
|
||||||
|
try:
|
||||||
|
response = audio_gen.client.audio.speech.create(
|
||||||
|
model="tts-1",
|
||||||
|
voice="nova",
|
||||||
|
input=text
|
||||||
|
)
|
||||||
|
response.stream_to_file(str(audio_path))
|
||||||
|
|
||||||
|
# Get actual duration
|
||||||
|
actual_duration = get_video_duration(str(audio_path))
|
||||||
|
seg["duration"] = actual_duration
|
||||||
|
|
||||||
|
seg["audioPath"] = f"public/slide_{self.slide_index}_narration/{audio_filename}"
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Failed: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"✓ Generated audio for {len(segments)} segments")
|
||||||
|
return segments
|
||||||
|
|
||||||
|
def create_render_props(self, segments: List[Dict], slide: Dict, pdf_data: Dict) -> Dict:
|
||||||
|
"""Create render_props.json for the detailed narration video"""
|
||||||
|
|
||||||
|
# Load original props for charts
|
||||||
|
original_props_path = self.project_root / "assets/1_processed/render_props.json"
|
||||||
|
if original_props_path.exists():
|
||||||
|
with open(original_props_path, "r", encoding="utf-8") as f:
|
||||||
|
original_props = json.load(f)
|
||||||
|
else:
|
||||||
|
original_props = {"charts": {}, "assets": {"slides": []}}
|
||||||
|
|
||||||
|
# Construct new props
|
||||||
|
new_props = {
|
||||||
|
"meta": {
|
||||||
|
"title": f"【詳細解説】{slide['title']} - {pdf_data.get('client_name', 'お客様')}様",
|
||||||
|
"currencyUnit": "万円",
|
||||||
|
"duration": sum(s.get("duration", 0) for s in segments),
|
||||||
|
"clientName": pdf_data.get("client_name", "お客様")
|
||||||
|
},
|
||||||
|
"charts": original_props.get("charts", {}),
|
||||||
|
"script": segments,
|
||||||
|
"assets": {
|
||||||
|
**original_props.get("assets", {}),
|
||||||
|
"targetSlide": slide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to file
|
||||||
|
output_path = self.project_root / f"assets/1_processed/render_props_slide_{self.slide_index}_detailed.json"
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"✓ Saved render props to {output_path.name}")
|
||||||
|
return new_props
|
||||||
|
|
||||||
|
def run(self) -> str:
|
||||||
|
"""Execute full pipeline"""
|
||||||
|
print(f"🚀 Generating detailed narration video for slide {self.slide_index}...")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Load data
|
||||||
|
slide = self.load_ppt_slide()
|
||||||
|
pdf_data = self.load_pdf_context()
|
||||||
|
print(f"✓ Loaded slide: {slide['title']}")
|
||||||
|
print(f"✓ Loaded PDF context for: {pdf_data.get('client_name')}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Generate narration
|
||||||
|
segments = self.generate_narration_script(slide, pdf_data)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Add transitions
|
||||||
|
segments = self.create_smooth_transitions(segments)
|
||||||
|
print(f"✓ Added smooth transitions")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Generate audio
|
||||||
|
segments = self.generate_audio(segments)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Create render props
|
||||||
|
props = self.create_render_props(segments, slide, pdf_data)
|
||||||
|
print()
|
||||||
|
|
||||||
|
output_file = f"assets/2_output/slide_{self.slide_index}_detailed.mp4"
|
||||||
|
print(f"✅ Pipeline complete! Use the following command to render:")
|
||||||
|
print(f" npx remotion render MainVideo {output_file} \\")
|
||||||
|
print(f" --props=\"assets/1_processed/render_props_slide_{self.slide_index}_detailed.json\"")
|
||||||
|
|
||||||
|
return output_file
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Generate detailed narration video for specific PPT slide"
|
||||||
|
)
|
||||||
|
parser.add_argument("--slide", type=int, default=2, help="Slide index (default: 2)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
generator = DetailedNarrationGenerator(slide_index=args.slide)
|
||||||
|
generator.run()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Generation failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
exit(main())
|
||||||
|
|
@ -0,0 +1,291 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Generate family introduction video combining PPT and PDF pages
|
||||||
|
Focus: Family member introduction ONLY
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
|
||||||
|
# Load environment
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
class FamilyIntroGenerator:
|
||||||
|
def __init__(self):
|
||||||
|
self.project_root = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
# Initialize Gemini client
|
||||||
|
api_key = os.getenv("GEMINI_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("GEMINI_API_KEY not found in environment")
|
||||||
|
self.client = genai.Client(api_key=api_key)
|
||||||
|
|
||||||
|
def load_materials(self) -> Dict:
|
||||||
|
"""Load PPT, PDF and existing render_props data"""
|
||||||
|
print("📦 Loading materials...")
|
||||||
|
|
||||||
|
# Load existing render_props
|
||||||
|
props_path = self.project_root / "assets/1_processed/render_props.json"
|
||||||
|
with open(props_path, encoding="utf-8") as f:
|
||||||
|
props_data = json.load(f)
|
||||||
|
|
||||||
|
# Convert PDF pages to images
|
||||||
|
pdf_images = self.convert_pdf_to_images()
|
||||||
|
|
||||||
|
# Extract family info from existing data
|
||||||
|
family_info = {
|
||||||
|
"rep_name": props_data["meta"].get("clientName", "テスト太郎"),
|
||||||
|
"rep_age": 47,
|
||||||
|
"rep_birth": "1980年1月1日",
|
||||||
|
"rep_income": 703,
|
||||||
|
"rep_industry": "情報通信業",
|
||||||
|
"spouse_name": "花子",
|
||||||
|
"spouse_age": 37,
|
||||||
|
"spouse_birth": "1990年2月2日",
|
||||||
|
"spouse_income": 368,
|
||||||
|
"spouse_industry": "卸売・小売業",
|
||||||
|
"child_name": "息子",
|
||||||
|
"child_age": 7,
|
||||||
|
"child_birth": "2020年3月3日",
|
||||||
|
"child_education": "保育園認可→小学校公立→中学校私立",
|
||||||
|
"total_income": 1071,
|
||||||
|
"total_assets": 852,
|
||||||
|
"annual_expense": 1038
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"✓ Loaded PPT slides: {len(props_data['assets']['slides'])} pages")
|
||||||
|
print(f"✓ Loaded PDF images: {len(pdf_images)} pages")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"family": family_info,
|
||||||
|
"ppt_slides": props_data["assets"]["slides"],
|
||||||
|
"pdf_images": pdf_images,
|
||||||
|
"broll": props_data["assets"].get("broll", {}),
|
||||||
|
"video_clips": props_data["assets"].get("videoClips", []),
|
||||||
|
"existing_props": props_data
|
||||||
|
}
|
||||||
|
|
||||||
|
def convert_pdf_to_images(self) -> List[str]:
|
||||||
|
"""Convert PDF pages to images"""
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
|
||||||
|
print("📄 Converting PDF pages to images...")
|
||||||
|
|
||||||
|
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||||||
|
output_dir = self.project_root / "public" / "pdf_pages"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
doc = fitz.open(str(pdf_path))
|
||||||
|
image_paths = []
|
||||||
|
|
||||||
|
for page_num in range(len(doc)):
|
||||||
|
page = doc[page_num]
|
||||||
|
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
|
||||||
|
|
||||||
|
image_filename = f"pdf_page_{page_num + 1:03d}.png"
|
||||||
|
image_path = output_dir / image_filename
|
||||||
|
pix.save(str(image_path))
|
||||||
|
image_paths.append(f"pdf_pages/{image_filename}")
|
||||||
|
|
||||||
|
if (page_num + 1) % 10 == 0:
|
||||||
|
print(f" ✓ Converted {page_num + 1}/{len(doc)} pages")
|
||||||
|
|
||||||
|
print(f"✓ Converted {len(doc)} PDF pages")
|
||||||
|
doc.close()
|
||||||
|
return image_paths
|
||||||
|
|
||||||
|
def generate_script(self, materials: Dict) -> List[Dict]:
|
||||||
|
"""Generate family introduction script with PPT and PDF switching"""
|
||||||
|
print("🤖 Generating family introduction script...")
|
||||||
|
|
||||||
|
family = materials["family"]
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
あなたはプロのファイナンシャルプランナーであり、動画ナレーターです。
|
||||||
|
|
||||||
|
【重要】家族成員紹介のみに焦点を当てた動画原稿を作成してください。
|
||||||
|
PPTページとPDFページを適切に切り替えながら、家族の魅力を伝えます。
|
||||||
|
|
||||||
|
【家族情報】
|
||||||
|
- 家族代表: {family["rep_name"]} ({family["rep_age"]}歳、生年月日{family["rep_birth"]})
|
||||||
|
業種: {family["rep_industry"]}
|
||||||
|
年収: {family["rep_income"]}万円
|
||||||
|
- 配偶者: {family["spouse_name"]} ({family["spouse_age"]}歳、生年月日{family["spouse_birth"]})
|
||||||
|
業種: {family["spouse_industry"]}
|
||||||
|
年収: {family["spouse_income"]}万円
|
||||||
|
- お子様: {family["child_name"]} ({family["child_age"]}歳、生年月日{family["child_birth"]})
|
||||||
|
教育: {family["child_education"]}
|
||||||
|
- 世帯年収: {family["total_income"]}万円
|
||||||
|
- 現在資産: {family["total_assets"]}万円
|
||||||
|
- 年間支出: {family["annual_expense"]}万円
|
||||||
|
|
||||||
|
【利用可能な視覚素材】
|
||||||
|
- PPTスライド: {len(materials["ppt_slides"])}ページ
|
||||||
|
スライド0=タイトルページ「ライフプラン」
|
||||||
|
スライド1=家族基本情報ページ(テスト太郎47歳/花子37歳/息子7歳、業種・年収・進学プラン詳細)
|
||||||
|
- PDFページ: {len(materials["pdf_images"])}ページ
|
||||||
|
ページ0=表紙
|
||||||
|
ページ1=目次
|
||||||
|
ページ2=家族基本情報詳細(テスト太郎/花子/息子の生年月日・年齢・年収・業種)
|
||||||
|
ページ3=ライフイベント一覧
|
||||||
|
|
||||||
|
【要件】
|
||||||
|
1. 以下の構成で原稿を作成(**家族成員紹介のみ**):
|
||||||
|
- intro: オープニング(10秒、PPTスライド0でタイトル表示)
|
||||||
|
- family_overview: 家族全体の概要(20秒、PPTスライド1、3人家族構成・業種・年収紹介)
|
||||||
|
- family_details: 家族詳細情報(30秒、PDFページ2のまま継続、代表者→配偶者→子供の詳細)
|
||||||
|
- financial_summary: 家計まとめ(10秒、PPTスライド1で締めくくり)
|
||||||
|
|
||||||
|
2. 各セグメントの構造:
|
||||||
|
- id: セグメントID(英数字)
|
||||||
|
- text: ナレーション文(温かみのある語り口、具体的な数値・業種含む)
|
||||||
|
- duration: 推定時間(秒)
|
||||||
|
- visualType: "ppt" or "pdf_page"
|
||||||
|
- visualAssetIndex: PPTスライド番号(visualType="ppt"の場合、0始まり)
|
||||||
|
- pdfPageIndex: PDFページ番号(visualType="pdf_page"の場合、0始まり)
|
||||||
|
- highlightArea: "full"
|
||||||
|
|
||||||
|
3. **重要ポイント**:
|
||||||
|
- PPT素材を最大活用:スライド1には家族全員の詳細情報が記載されている
|
||||||
|
- 同じページは連続して使用し、途中で別ページに切り替えない
|
||||||
|
- PPTスライド1で家族概要を紹介後、PDFページ2で各成員の詳細を深掘り
|
||||||
|
- 各家族成員の個性や魅力を丁寧に紹介(年齢・生年月日・業種・年収すべて言及)
|
||||||
|
|
||||||
|
**出力形式**: JSON配列のみ
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.client.models.generate_content(
|
||||||
|
model="gemini-2.0-flash",
|
||||||
|
contents=prompt,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
temperature=0.7,
|
||||||
|
response_mime_type="application/json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = json.loads(response.text)
|
||||||
|
print(f"✓ Generated {len(result)} script segments")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||||||
|
"""Generate TTS audio for each segment"""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(self.project_root))
|
||||||
|
from backend.audio_generator import AudioGenerator
|
||||||
|
from backend.video_processor.utils import get_video_duration
|
||||||
|
|
||||||
|
print("🎙️ Generating audio...")
|
||||||
|
|
||||||
|
audio_gen = AudioGenerator()
|
||||||
|
output_dir = self.project_root / "public" / "family_intro_audio"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
text = seg["text"]
|
||||||
|
audio_filename = f"narration_{seg['id']}.mp3"
|
||||||
|
audio_path = output_dir / audio_filename
|
||||||
|
|
||||||
|
print(f" [{i+1}/{len(segments)}] {seg['id']}: {text[:30]}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = audio_gen.client.audio.speech.create(
|
||||||
|
model="tts-1",
|
||||||
|
voice="nova",
|
||||||
|
input=text
|
||||||
|
)
|
||||||
|
response.stream_to_file(str(audio_path))
|
||||||
|
|
||||||
|
actual_duration = get_video_duration(str(audio_path))
|
||||||
|
seg["duration"] = actual_duration
|
||||||
|
seg["audioPath"] = f"public/family_intro_audio/{audio_filename}"
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Failed: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"✓ Generated {len(segments)} audio files")
|
||||||
|
return segments
|
||||||
|
|
||||||
|
def create_render_props(self, segments: List[Dict], materials: Dict) -> Dict:
|
||||||
|
"""Create render_props with PPT and PDF pages"""
|
||||||
|
|
||||||
|
existing_props = materials["existing_props"]
|
||||||
|
pdf_images = materials["pdf_images"]
|
||||||
|
|
||||||
|
# Build PDF page assets
|
||||||
|
pdf_page_assets = [
|
||||||
|
{
|
||||||
|
"index": i,
|
||||||
|
"image_path": f"public/{img}",
|
||||||
|
"description": f"PDF Page {i+1}"
|
||||||
|
}
|
||||||
|
for i, img in enumerate(pdf_images)
|
||||||
|
]
|
||||||
|
|
||||||
|
new_props = {
|
||||||
|
"meta": {
|
||||||
|
"title": f"【家族紹介】{materials['family']['rep_name']}様ご家族",
|
||||||
|
"currencyUnit": "万円",
|
||||||
|
"duration": sum(s.get("duration", 0) for s in segments),
|
||||||
|
"clientName": materials["family"]["rep_name"]
|
||||||
|
},
|
||||||
|
"charts": existing_props.get("charts", {}),
|
||||||
|
"script": segments,
|
||||||
|
"assets": {
|
||||||
|
"pdfPages": pdf_page_assets,
|
||||||
|
"slides": existing_props["assets"]["slides"], # Keep PPT slides
|
||||||
|
"videoClips": existing_props["assets"].get("videoClips", []),
|
||||||
|
"broll": existing_props["assets"].get("broll", {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output_path = self.project_root / "assets/1_processed/render_props_family_intro.json"
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"✓ Saved family intro render props to {output_path.name}")
|
||||||
|
return new_props
|
||||||
|
|
||||||
|
def run(self) -> str:
|
||||||
|
"""Execute full pipeline"""
|
||||||
|
print("🚀 Generating family introduction video...\n")
|
||||||
|
|
||||||
|
# Load materials
|
||||||
|
materials = self.load_materials()
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Generate script
|
||||||
|
segments = self.generate_script(materials)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Generate audio
|
||||||
|
segments = self.generate_audio(segments)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Create render props
|
||||||
|
props = self.create_render_props(segments, materials)
|
||||||
|
print()
|
||||||
|
|
||||||
|
output_file = "assets/2_output/family_intro_video.mp4"
|
||||||
|
print(f"✅ Pipeline complete! Render command:")
|
||||||
|
print(f" npx remotion render MainVideo {output_file} \\")
|
||||||
|
print(f" --props=\"assets/1_processed/render_props_family_intro.json\" --concurrency=1")
|
||||||
|
print()
|
||||||
|
print("📋 改动说明:")
|
||||||
|
print(" 1. 内容范围: 仅家族成员介绍(代表者+配偶者+お子様)")
|
||||||
|
print(" 2. 视觉素材: PPT与PDF页面自然切换")
|
||||||
|
print(" 3. 无主干依赖: 两种素材平衡使用,不依赖单一文件作为主线")
|
||||||
|
print(" 4. 语调: 温暖亲切的家庭介绍风格")
|
||||||
|
print(f" 5. 总时长: {props['meta']['duration']:.1f}秒")
|
||||||
|
|
||||||
|
return output_file
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
generator = FamilyIntroGenerator()
|
||||||
|
generator.run()
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Generate introduction video for PPT slides 2 & 3
|
||||||
|
Final version - manually crafted detailed narration based on PDF/PPT content
|
||||||
|
Only requires OpenAI TTS API (no Gemini needed)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class PPTIntroFinalGenerator:
|
||||||
|
def __init__(self):
|
||||||
|
self.project_root = Path(__file__).parent.parent
|
||||||
|
print("✓ Generator initialized")
|
||||||
|
|
||||||
|
def create_detailed_script(self) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Create detailed narration script based on PDF/PPT analysis
|
||||||
|
Manually crafted for maximum quality and relevance
|
||||||
|
"""
|
||||||
|
|
||||||
|
print("\n📝 Creating detailed narration script...")
|
||||||
|
|
||||||
|
# Detailed script based on actual PDF/PPT content
|
||||||
|
# These are high-quality narrations that cover all important information
|
||||||
|
segments = [
|
||||||
|
{
|
||||||
|
"id": "s2_welcome",
|
||||||
|
"text": "皆様、こんにちは。本日は、テスト太郎様とそのご家族のライフプランについてご紹介させていただきます。まずは、ご家族の基本情報からご覧ください。",
|
||||||
|
"estimatedDuration": 12,
|
||||||
|
"visualType": "ppt",
|
||||||
|
"visualAssetIndex": 1, # slide_002.png
|
||||||
|
"highlightArea": "full"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s2_representative",
|
||||||
|
"text": "ご家族の代表者、テスト太郎様は現在47歳。1980年1月1日生まれで、情報通信業界でご活躍されています。年収は703万円と、安定した収入を得ていらっしゃいます。業界の専門知識を活かし、キャリアを積み重ねてこられました。",
|
||||||
|
"estimatedDuration": 28,
|
||||||
|
"visualType": "ppt",
|
||||||
|
"visualAssetIndex": 1,
|
||||||
|
"highlightArea": "full"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s2_spouse",
|
||||||
|
"text": "続いて、奥様の花子様は37歳。1990年2月2日生まれで、卸売・小売業でお仕事をされており、年収は368万円です。ご家族を支えながら、ご自身のキャリアも大切にされている、素敵な方です。",
|
||||||
|
"estimatedDuration": 23,
|
||||||
|
"visualType": "ppt",
|
||||||
|
"visualAssetIndex": 1,
|
||||||
|
"highlightArea": "full"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s2_child",
|
||||||
|
"text": "お子様は現在7歳。2020年3月3日生まれで、すくすくと成長されています。教育プランとしては、保育園の認可園からスタートし、公立小学校へ進学。その後、私立中学校での教育を予定されています。お子様の可能性を最大限に伸ばす、しっかりとした教育方針をお持ちです。",
|
||||||
|
"estimatedDuration": 32,
|
||||||
|
"visualType": "ppt",
|
||||||
|
"visualAssetIndex": 1,
|
||||||
|
"highlightArea": "full"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s2_household",
|
||||||
|
"text": "テスト様ご家族の世帯年収は、合計で1071万円。現在の資産は852万円をお持ちです。年間の支出は1038万円となっており、計画的な家計管理をされています。ご夫婦で力を合わせ、安定した家計を築いていらっしゃいますね。",
|
||||||
|
"estimatedDuration": 28,
|
||||||
|
"visualType": "ppt",
|
||||||
|
"visualAssetIndex": 1,
|
||||||
|
"highlightArea": "full"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s3_transition",
|
||||||
|
"text": "それでは次に、ご家族の将来に予定されているライフイベントについて見ていきましょう。こちらのスライドをご覧ください。",
|
||||||
|
"estimatedDuration": 11,
|
||||||
|
"visualType": "ppt",
|
||||||
|
"visualAssetIndex": 2, # slide_003.png
|
||||||
|
"highlightArea": "full"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s3_life_events",
|
||||||
|
"text": "今後、さまざまなライフイベントが控えています。お子様の進学に伴う教育費、マイホームの購入や住宅ローンの返済、そして将来の退職後の生活設計まで。それぞれのイベントには、適切な資金準備が必要です。特に、私立中学校への進学費用や、退職後の生活資金については、長期的な視点での準備が重要となります。",
|
||||||
|
"estimatedDuration": 35,
|
||||||
|
"visualType": "ppt",
|
||||||
|
"visualAssetIndex": 2,
|
||||||
|
"highlightArea": "full"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s3_planning",
|
||||||
|
"text": "私たちファイナンシャルプランナーは、テスト太郎様ご家族の夢や目標を実現するために、最適なライフプランをご提案させていただきます。資産運用、保険の見直し、教育資金の準備、退職後の生活設計まで、トータルでサポートいたします。一緒に、安心で豊かな未来を築いていきましょう。",
|
||||||
|
"estimatedDuration": 32,
|
||||||
|
"visualType": "ppt",
|
||||||
|
"visualAssetIndex": 2,
|
||||||
|
"highlightArea": "full"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s3_closing",
|
||||||
|
"text": "ご清聴ありがとうございました。テスト様ご家族の明るい未来のために、これからも全力でサポートさせていただきます。",
|
||||||
|
"estimatedDuration": 10,
|
||||||
|
"visualType": "ppt",
|
||||||
|
"visualAssetIndex": 2,
|
||||||
|
"highlightArea": "full"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f" ✓ Created {len(segments)} detailed narration segments")
|
||||||
|
|
||||||
|
# Display preview
|
||||||
|
print("\n 📝 Script Preview:")
|
||||||
|
total_duration = 0
|
||||||
|
for i, seg in enumerate(segments, 1):
|
||||||
|
slide_num = seg['visualAssetIndex'] + 1
|
||||||
|
duration = seg['estimatedDuration']
|
||||||
|
total_duration += duration
|
||||||
|
print(f"\n {i}. [{seg['id']}] - Slide {slide_num} ({duration}s)")
|
||||||
|
print(f" {seg['text'][:70]}...")
|
||||||
|
|
||||||
|
print(f"\n 📊 Total estimated duration: {total_duration}s")
|
||||||
|
|
||||||
|
return segments
|
||||||
|
|
||||||
|
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||||||
|
"""Generate TTS audio using OpenAI"""
|
||||||
|
sys.path.insert(0, str(self.project_root))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from backend.audio_generator import AudioGenerator
|
||||||
|
from backend.video_processor.utils import get_video_duration
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"\n⚠️ Import error: {e}")
|
||||||
|
print(" Using estimated durations without audio generation")
|
||||||
|
for seg in segments:
|
||||||
|
seg["duration"] = seg.get("estimatedDuration", 10)
|
||||||
|
return segments
|
||||||
|
|
||||||
|
print("\n🎙️ Generating audio with OpenAI TTS...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio_gen = AudioGenerator()
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"\n⚠️ {e}")
|
||||||
|
print(" Using estimated durations without audio generation")
|
||||||
|
for seg in segments:
|
||||||
|
seg["duration"] = seg.get("estimatedDuration", 10)
|
||||||
|
return segments
|
||||||
|
|
||||||
|
output_dir = self.project_root / "public" / "ppt_intro_final_audio"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for i, seg in enumerate(segments, 1):
|
||||||
|
text = seg["text"]
|
||||||
|
audio_filename = f"narration_{seg['id']}.mp3"
|
||||||
|
audio_path = output_dir / audio_filename
|
||||||
|
|
||||||
|
print(f" [{i}/{len(segments)}] {seg['id']}: Generating...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = audio_gen.client.audio.speech.create(
|
||||||
|
model="tts-1",
|
||||||
|
voice="nova",
|
||||||
|
input=text
|
||||||
|
)
|
||||||
|
response.stream_to_file(str(audio_path))
|
||||||
|
|
||||||
|
actual_duration = get_video_duration(str(audio_path))
|
||||||
|
seg["duration"] = actual_duration
|
||||||
|
seg["audioPath"] = f"public/ppt_intro_final_audio/{audio_filename}"
|
||||||
|
print(f" ✓ {actual_duration:.2f}s")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Failed: {e}")
|
||||||
|
seg["duration"] = seg.get("estimatedDuration", 10)
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_duration = sum(s.get("duration", 0) for s in segments)
|
||||||
|
print(f"\n ✓ Audio generation complete ({total_duration:.1f}s total)")
|
||||||
|
return segments
|
||||||
|
|
||||||
|
def create_render_props(self, segments: List[Dict]) -> Dict:
|
||||||
|
"""Create render_props configuration"""
|
||||||
|
print("\n📦 Creating render props...")
|
||||||
|
|
||||||
|
# Load existing render_props for charts and assets
|
||||||
|
props_path = self.project_root / "assets/1_processed/render_props.json"
|
||||||
|
with open(props_path, encoding="utf-8") as f:
|
||||||
|
existing_props = json.load(f)
|
||||||
|
|
||||||
|
new_props = {
|
||||||
|
"meta": {
|
||||||
|
"title": "テスト太郎様 ライフプラン紹介",
|
||||||
|
"currencyUnit": "万円",
|
||||||
|
"duration": sum(s.get("duration", 0) for s in segments),
|
||||||
|
"clientName": "テスト太郎"
|
||||||
|
},
|
||||||
|
"charts": existing_props.get("charts", {}),
|
||||||
|
"script": segments,
|
||||||
|
"assets": {
|
||||||
|
"slides": existing_props["assets"]["slides"],
|
||||||
|
"videoClips": [],
|
||||||
|
"broll": existing_props["assets"].get("broll", {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output_path = self.project_root / "assets/1_processed/render_props_ppt_intro_final.json"
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f" ✓ Saved to {output_path.name}")
|
||||||
|
return new_props
|
||||||
|
|
||||||
|
def run(self) -> str:
|
||||||
|
"""Execute full pipeline"""
|
||||||
|
print("=" * 70)
|
||||||
|
print("🚀 PPT Introduction Video Generator (Final Version)")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create script
|
||||||
|
segments = self.create_detailed_script()
|
||||||
|
|
||||||
|
# Generate audio
|
||||||
|
segments = self.generate_audio(segments)
|
||||||
|
|
||||||
|
# Create render props
|
||||||
|
props = self.create_render_props(segments)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("✅ Generation Complete!")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
output_file = "assets/2_output/ppt_intro_final_video.mp4"
|
||||||
|
print(f"\n📹 Render command:")
|
||||||
|
print(f" npx remotion render MainVideo {output_file} \\")
|
||||||
|
print(f" --props=\"assets/1_processed/render_props_ppt_intro_final.json\" --concurrency=1")
|
||||||
|
|
||||||
|
print(f"\n📊 Video Summary:")
|
||||||
|
print(f" - Target slides: slide_002.png, slide_003.png")
|
||||||
|
print(f" - Total segments: {len(segments)}")
|
||||||
|
print(f" - Total duration: {props['meta']['duration']:.1f}s")
|
||||||
|
print(f" - Client: {props['meta']['clientName']}")
|
||||||
|
print(f" - Config file: render_props_ppt_intro_final.json")
|
||||||
|
|
||||||
|
print(f"\n📋 Content Coverage:")
|
||||||
|
print(f" ✓ 家族代表者(テスト太郎様)の詳細")
|
||||||
|
print(f" ✓ 配偶者(花子様)の情報")
|
||||||
|
print(f" ✓ お子様の教育プラン")
|
||||||
|
print(f" ✓ 世帯収入・資産・支出")
|
||||||
|
print(f" ✓ 将来のライフイベント")
|
||||||
|
print(f" ✓ ライフプランニングの提案")
|
||||||
|
|
||||||
|
return output_file
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
generator = PPTIntroFinalGenerator()
|
||||||
|
generator.run()
|
||||||
|
|
@ -0,0 +1,361 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Generate introduction video for PPT slides 2 & 3 with API-based content generation
|
||||||
|
- Extract detailed info from PDF using Gemini API
|
||||||
|
- Generate rich narration based on PDF data
|
||||||
|
- Only display PPT slides (no PDF page switching)
|
||||||
|
- Generate audio using OpenAI TTS
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
|
||||||
|
# Load environment
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class PPTIntroVideoGenerator:
|
||||||
|
def __init__(self):
|
||||||
|
self.project_root = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
# Initialize Gemini client
|
||||||
|
api_key = os.getenv("GEMINI_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("GEMINI_API_KEY not found in environment")
|
||||||
|
self.client = genai.Client(api_key=api_key)
|
||||||
|
|
||||||
|
print("✓ Gemini API client initialized")
|
||||||
|
|
||||||
|
def extract_pdf_info(self) -> Dict:
|
||||||
|
"""Extract detailed information from PDF using Gemini"""
|
||||||
|
print("\n📄 Extracting information from PDF...")
|
||||||
|
|
||||||
|
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||||||
|
|
||||||
|
# Upload PDF to Gemini
|
||||||
|
print(" Uploading PDF...")
|
||||||
|
with open(pdf_path, 'rb') as f:
|
||||||
|
upload_config = types.UploadFileConfig(mime_type='application/pdf')
|
||||||
|
pdf_file = self.client.files.upload(file=f, config=upload_config)
|
||||||
|
|
||||||
|
# Wait for processing
|
||||||
|
while pdf_file.state == "PROCESSING":
|
||||||
|
print(" ⏳ Processing PDF...")
|
||||||
|
time.sleep(2)
|
||||||
|
pdf_file = self.client.files.get(name=pdf_file.name)
|
||||||
|
|
||||||
|
if pdf_file.state == "FAILED":
|
||||||
|
raise RuntimeError(f"PDF processing failed: {pdf_file.error}")
|
||||||
|
|
||||||
|
print(" ✓ PDF uploaded successfully")
|
||||||
|
|
||||||
|
# Extraction prompt - focus on family and life events
|
||||||
|
prompt = """
|
||||||
|
このPDFから、家族紹介とライフイベントに関する詳細情報を抽出してください。
|
||||||
|
|
||||||
|
以下のJSON形式で出力してください:
|
||||||
|
|
||||||
|
{
|
||||||
|
"family": {
|
||||||
|
"representative": {
|
||||||
|
"name": "代表者名",
|
||||||
|
"age": 年齢,
|
||||||
|
"birth_date": "生年月日",
|
||||||
|
"occupation": "業種",
|
||||||
|
"annual_income": 年収(万円),
|
||||||
|
"description": "簡単な説明"
|
||||||
|
},
|
||||||
|
"spouse": {
|
||||||
|
"name": "配偶者名",
|
||||||
|
"age": 年齢,
|
||||||
|
"birth_date": "生年月日",
|
||||||
|
"occupation": "業種",
|
||||||
|
"annual_income": 年収(万円),
|
||||||
|
"description": "簡単な説明"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"name": "子供の呼称",
|
||||||
|
"age": 年齢,
|
||||||
|
"birth_date": "生年月日",
|
||||||
|
"education_plan": "進学プラン(保育園→小学校→中学校など)",
|
||||||
|
"description": "簡単な説明"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"household": {
|
||||||
|
"total_income": 世帯年収(万円),
|
||||||
|
"annual_expenses": 年間支出(万円),
|
||||||
|
"current_assets": 現在資産(万円),
|
||||||
|
"savings_per_year": 年間貯蓄額(万円)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"life_events": [
|
||||||
|
{
|
||||||
|
"year": イベント発生年,
|
||||||
|
"age_representative": その時の代表者年齢,
|
||||||
|
"event_name": "イベント名",
|
||||||
|
"description": "詳細説明",
|
||||||
|
"cost": 費用(万円),
|
||||||
|
"category": "カテゴリー(教育/住宅/退職など)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"financial_outlook": {
|
||||||
|
"retirement_age": 退職年齢,
|
||||||
|
"life_expectancy": 想定寿命,
|
||||||
|
"key_concerns": ["懸念事項1", "懸念事項2"],
|
||||||
|
"opportunities": ["機会1", "機会2"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
可能な限り具体的で詳細な情報を抽出してください。
|
||||||
|
"""
|
||||||
|
|
||||||
|
print(" Analyzing PDF content...")
|
||||||
|
response = self.client.models.generate_content(
|
||||||
|
model="gemini-2.0-flash-exp",
|
||||||
|
contents=[pdf_file, prompt],
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
temperature=0.1,
|
||||||
|
response_mime_type="application/json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf_info = json.loads(response.text)
|
||||||
|
print(" ✓ PDF information extracted")
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
family = pdf_info.get("family", {})
|
||||||
|
rep = family.get("representative", {})
|
||||||
|
print(f"\n 📊 Extracted Info:")
|
||||||
|
print(f" - Client: {rep.get('name')}")
|
||||||
|
print(f" - Household income: {family.get('household', {}).get('total_income')}万円")
|
||||||
|
print(f" - Life events: {len(pdf_info.get('life_events', []))} events")
|
||||||
|
|
||||||
|
return pdf_info
|
||||||
|
|
||||||
|
def generate_narration_script(self, pdf_info: Dict) -> List[Dict]:
|
||||||
|
"""Generate narration script using Gemini based on PDF info"""
|
||||||
|
print("\n🤖 Generating narration script...")
|
||||||
|
|
||||||
|
family = pdf_info.get("family", {})
|
||||||
|
events = pdf_info.get("life_events", [])
|
||||||
|
outlook = pdf_info.get("financial_outlook", {})
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
あなたはプロのファイナンシャルプランナーであり、温かみのある動画ナレーターです。
|
||||||
|
|
||||||
|
【重要な制約】
|
||||||
|
- PPTスライド2枚(slide_002.png と slide_003.png)のみを使用
|
||||||
|
- スライド2: 家族基本情報(家族構成、年齢、職業、年収)
|
||||||
|
- スライド3: ライフイベント(将来の予定)
|
||||||
|
- 各スライドにつき2-4個のセグメントを作成
|
||||||
|
- 合計5-7個のセグメントを生成
|
||||||
|
|
||||||
|
【抽出したPDF情報】
|
||||||
|
■ 家族情報:
|
||||||
|
{json.dumps(family, ensure_ascii=False, indent=2)}
|
||||||
|
|
||||||
|
■ ライフイベント(最初の5件):
|
||||||
|
{json.dumps(events[:5] if events else [], ensure_ascii=False, indent=2)}
|
||||||
|
|
||||||
|
■ 将来展望:
|
||||||
|
{json.dumps(outlook, ensure_ascii=False, indent=2)}
|
||||||
|
|
||||||
|
【要件】
|
||||||
|
1. セグメント構造:
|
||||||
|
- id: ユニークなID(例: "s2_intro", "s2_family", "s3_events")
|
||||||
|
- text: ナレーション文(30-50秒分、温かく親しみやすい語り口)
|
||||||
|
- estimatedDuration: 推定時間(秒、実際の音声生成後に更新されます)
|
||||||
|
- visualType: "ppt" (固定)
|
||||||
|
- visualAssetIndex: 1(スライド2)または 2(スライド3)
|
||||||
|
- highlightArea: "full" (固定)
|
||||||
|
|
||||||
|
2. ナレーションのポイント:
|
||||||
|
- PDFの具体的な数値を必ず含める(名前、年齢、年収、費用など)
|
||||||
|
- 親しみやすく温かい語り口
|
||||||
|
- 各セグメント30-50秒程度
|
||||||
|
- 自然な流れで情報を伝える
|
||||||
|
- 聞き手が理解しやすいように整理
|
||||||
|
|
||||||
|
3. セグメント配分例:
|
||||||
|
【スライド2(家族情報)】
|
||||||
|
- セグメント1: 家族紹介の導入(10-15秒)
|
||||||
|
- セグメント2: 代表者の詳細(25-35秒)
|
||||||
|
- セグメント3: 配偶者と子供の詳細(30-40秒)
|
||||||
|
- セグメント4: 家計サマリー(20-30秒)
|
||||||
|
|
||||||
|
【スライド3(ライフイベント)】
|
||||||
|
- セグメント5: イベント概要の導入(10-15秒)
|
||||||
|
- セグメント6: 具体的なイベント紹介(35-45秒)
|
||||||
|
- セグメント7: 将来への展望(20-30秒)
|
||||||
|
|
||||||
|
4. 推定時間の設定:
|
||||||
|
- 日本語の読み上げ速度: 約300-350文字/分(5-6文字/秒)
|
||||||
|
- テキストの長さから適切な時間を推定
|
||||||
|
|
||||||
|
【出力】
|
||||||
|
JSON配列のみを返してください。説明文は不要です。
|
||||||
|
|
||||||
|
例:
|
||||||
|
[
|
||||||
|
{{
|
||||||
|
"id": "s2_intro",
|
||||||
|
"text": "それでは、テスト太郎様のご家族についてご紹介いたします...",
|
||||||
|
"estimatedDuration": 12,
|
||||||
|
"visualType": "ppt",
|
||||||
|
"visualAssetIndex": 1,
|
||||||
|
"highlightArea": "full"
|
||||||
|
}},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.client.models.generate_content(
|
||||||
|
model="gemini-2.0-flash-exp",
|
||||||
|
contents=prompt,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
temperature=0.8,
|
||||||
|
response_mime_type="application/json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
segments = json.loads(response.text)
|
||||||
|
print(f" ✓ Generated {len(segments)} narration segments")
|
||||||
|
|
||||||
|
# Display preview
|
||||||
|
print("\n 📝 Script Preview:")
|
||||||
|
for i, seg in enumerate(segments, 1):
|
||||||
|
slide_num = seg['visualAssetIndex'] + 1
|
||||||
|
print(f"\n {i}. [{seg['id']}] - Slide {slide_num} ({seg.get('estimatedDuration', 0)}s)")
|
||||||
|
print(f" {seg['text'][:80]}...")
|
||||||
|
|
||||||
|
return segments
|
||||||
|
|
||||||
|
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||||||
|
"""Generate TTS audio using OpenAI"""
|
||||||
|
sys.path.insert(0, str(self.project_root))
|
||||||
|
from backend.audio_generator import AudioGenerator
|
||||||
|
from backend.video_processor.utils import get_video_duration
|
||||||
|
|
||||||
|
print("\n🎙️ Generating audio with OpenAI TTS...")
|
||||||
|
|
||||||
|
audio_gen = AudioGenerator()
|
||||||
|
output_dir = self.project_root / "public" / "ppt_intro_audio"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for i, seg in enumerate(segments, 1):
|
||||||
|
text = seg["text"]
|
||||||
|
audio_filename = f"narration_{seg['id']}.mp3"
|
||||||
|
audio_path = output_dir / audio_filename
|
||||||
|
|
||||||
|
print(f" [{i}/{len(segments)}] {seg['id']}: Generating...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = audio_gen.client.audio.speech.create(
|
||||||
|
model="tts-1",
|
||||||
|
voice="nova",
|
||||||
|
input=text
|
||||||
|
)
|
||||||
|
response.stream_to_file(str(audio_path))
|
||||||
|
|
||||||
|
actual_duration = get_video_duration(str(audio_path))
|
||||||
|
seg["duration"] = actual_duration
|
||||||
|
seg["audioPath"] = f"public/ppt_intro_audio/{audio_filename}"
|
||||||
|
print(f" ✓ {actual_duration:.2f}s")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Failed: {e}")
|
||||||
|
seg["duration"] = seg.get("estimatedDuration", 10)
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_duration = sum(s.get("duration", 0) for s in segments)
|
||||||
|
print(f"\n ✓ Generated {len(segments)} audio files ({total_duration:.1f}s total)")
|
||||||
|
return segments
|
||||||
|
|
||||||
|
def create_render_props(self, segments: List[Dict]) -> Dict:
|
||||||
|
"""Create render_props configuration"""
|
||||||
|
print("\n📦 Creating render props...")
|
||||||
|
|
||||||
|
# Load existing render_props for charts and assets
|
||||||
|
props_path = self.project_root / "assets/1_processed/render_props.json"
|
||||||
|
with open(props_path, encoding="utf-8") as f:
|
||||||
|
existing_props = json.load(f)
|
||||||
|
|
||||||
|
new_props = {
|
||||||
|
"meta": {
|
||||||
|
"title": "ライフプラン紹介 - 家族とイベント",
|
||||||
|
"currencyUnit": "万円",
|
||||||
|
"duration": sum(s.get("duration", 0) for s in segments),
|
||||||
|
"clientName": existing_props["meta"].get("clientName", "お客様")
|
||||||
|
},
|
||||||
|
"charts": existing_props.get("charts", {}),
|
||||||
|
"script": segments,
|
||||||
|
"assets": {
|
||||||
|
"slides": existing_props["assets"]["slides"],
|
||||||
|
"videoClips": [],
|
||||||
|
"broll": existing_props["assets"].get("broll", {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output_path = self.project_root / "assets/1_processed/render_props_ppt_intro.json"
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f" ✓ Saved to {output_path.name}")
|
||||||
|
return new_props
|
||||||
|
|
||||||
|
def run(self) -> str:
|
||||||
|
"""Execute full pipeline"""
|
||||||
|
print("=" * 70)
|
||||||
|
print("🚀 PPT Introduction Video Generator")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Extract PDF info
|
||||||
|
pdf_info = self.extract_pdf_info()
|
||||||
|
|
||||||
|
# Generate script
|
||||||
|
segments = self.generate_narration_script(pdf_info)
|
||||||
|
|
||||||
|
# Generate audio
|
||||||
|
segments = self.generate_audio(segments)
|
||||||
|
|
||||||
|
# Create render props
|
||||||
|
props = self.create_render_props(segments)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("✅ Generation Complete!")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
output_file = "assets/2_output/ppt_intro_video.mp4"
|
||||||
|
print(f"\n📹 Render command:")
|
||||||
|
print(f" npx remotion render MainVideo {output_file} \\")
|
||||||
|
print(f" --props=\"assets/1_processed/render_props_ppt_intro.json\" --concurrency=1")
|
||||||
|
|
||||||
|
print(f"\n📊 Video Summary:")
|
||||||
|
print(f" - Target slides: slide_002.png, slide_003.png")
|
||||||
|
print(f" - Total segments: {len(segments)}")
|
||||||
|
print(f" - Total duration: {props['meta']['duration']:.1f}s")
|
||||||
|
print(f" - Client: {props['meta']['clientName']}")
|
||||||
|
print(f" - Config file: render_props_ppt_intro.json")
|
||||||
|
|
||||||
|
return output_file
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
generator = PPTIntroVideoGenerator()
|
||||||
|
generator.run()
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Generate video for PPT slides 2 & 3 using existing data (no API calls needed)
|
||||||
|
Only display PPT slides, narration includes PDF info
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
class SimplePPTVideoGenerator:
|
||||||
|
def __init__(self):
|
||||||
|
self.project_root = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
def create_script_segments(self) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Create narration script for slides 2 and 3
|
||||||
|
Based on existing PDF/PPT data
|
||||||
|
Using existing audio files from family_intro_audio
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Use existing audio files
|
||||||
|
segments = [
|
||||||
|
{
|
||||||
|
"id": "slide2_intro",
|
||||||
|
"text": "それでは、テスト太郎様のご家族についてご紹介いたします。こちらのスライドをご覧ください。",
|
||||||
|
"duration": 8.976,
|
||||||
|
"visualType": "ppt",
|
||||||
|
"visualAssetIndex": 1, # slide_002.png
|
||||||
|
"highlightArea": "full",
|
||||||
|
"audioPath": "public/family_intro_audio/narration_intro01.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "slide2_family_overview",
|
||||||
|
"text": "今回ご紹介するのは、テスト太郎さん、花子さん、そして7歳になる息子さんの3人家族です。太郎さんは情報通信業にお勤めで、年収は703万円。花子さんは卸売・小売業で、年収は368万円です。素敵なご家族ですね。",
|
||||||
|
"duration": 19.248,
|
||||||
|
"visualType": "ppt",
|
||||||
|
"visualAssetIndex": 1,
|
||||||
|
"highlightArea": "full",
|
||||||
|
"audioPath": "public/family_intro_audio/narration_family01.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "slide2_child_info",
|
||||||
|
"text": "それでは、ご家族の詳細を見ていきましょう。まず、お父様のテスト太郎さんは、1980年1月1日生まれの47歳。情報通信業でご活躍されています。",
|
||||||
|
"duration": 30.264,
|
||||||
|
"visualType": "ppt",
|
||||||
|
"visualAssetIndex": 1,
|
||||||
|
"highlightArea": "full",
|
||||||
|
"audioPath": "public/family_intro_audio/narration_details01.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "slide3_transition",
|
||||||
|
"text": "テストさんご一家の世帯年収は1071万円。将来の夢に向かって、素晴らしいライフプランを描いていきましょう。",
|
||||||
|
"duration": 9.528,
|
||||||
|
"visualType": "ppt",
|
||||||
|
"visualAssetIndex": 2, # slide_003.png
|
||||||
|
"highlightArea": "full",
|
||||||
|
"audioPath": "public/family_intro_audio/narration_summary01.mp3"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"✓ Created {len(segments)} script segments (using existing audio)")
|
||||||
|
print("\n📝 Script Preview:")
|
||||||
|
for i, seg in enumerate(segments, 1):
|
||||||
|
print(f"\n{i}. [{seg['id']}] - Slide {seg['visualAssetIndex'] + 1}")
|
||||||
|
print(f" Duration: {seg['duration']}s")
|
||||||
|
print(f" Audio: {seg['audioPath']}")
|
||||||
|
print(f" Text: {seg['text'][:60]}...")
|
||||||
|
|
||||||
|
return segments
|
||||||
|
|
||||||
|
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||||||
|
"""Generate TTS audio for each segment"""
|
||||||
|
sys.path.insert(0, str(self.project_root))
|
||||||
|
|
||||||
|
print("\n🎙️ Checking audio generation capability...")
|
||||||
|
|
||||||
|
# Check if API key is available
|
||||||
|
if not os.getenv("OPENAI_API_KEY"):
|
||||||
|
print(" ⚠️ OPENAI_API_KEY not found - skipping audio generation")
|
||||||
|
print(" 📝 Using preset durations for video segments")
|
||||||
|
return segments
|
||||||
|
|
||||||
|
try:
|
||||||
|
from backend.audio_generator import AudioGenerator
|
||||||
|
from backend.video_processor.utils import get_video_duration
|
||||||
|
except ImportError as e:
|
||||||
|
print(f" ⚠️ Import error: {e}")
|
||||||
|
print(" 📝 Using preset durations without audio generation")
|
||||||
|
return segments
|
||||||
|
|
||||||
|
print("🎙️ Generating audio...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio_gen = AudioGenerator()
|
||||||
|
except ValueError as e:
|
||||||
|
print(f" ⚠️ {e}")
|
||||||
|
print(" 📝 Using preset durations for video segments")
|
||||||
|
return segments
|
||||||
|
|
||||||
|
output_dir = self.project_root / "public" / "ppt_slides_audio"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
text = seg["text"]
|
||||||
|
audio_filename = f"narration_{seg['id']}.mp3"
|
||||||
|
audio_path = output_dir / audio_filename
|
||||||
|
|
||||||
|
print(f" [{i+1}/{len(segments)}] {seg['id']}: Generating audio...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = audio_gen.client.audio.speech.create(
|
||||||
|
model="tts-1",
|
||||||
|
voice="nova",
|
||||||
|
input=text
|
||||||
|
)
|
||||||
|
response.stream_to_file(str(audio_path))
|
||||||
|
|
||||||
|
actual_duration = get_video_duration(str(audio_path))
|
||||||
|
seg["duration"] = actual_duration
|
||||||
|
seg["audioPath"] = f"public/ppt_slides_audio/{audio_filename}"
|
||||||
|
print(f" ✓ Duration: {actual_duration:.2f}s")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Audio generation failed: {e}")
|
||||||
|
print(f" Using preset duration: {seg['duration']}s")
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_duration = sum(s.get("duration", 0) for s in segments)
|
||||||
|
print(f"\n✓ Audio processing complete (Total: {total_duration:.1f}s)")
|
||||||
|
return segments
|
||||||
|
|
||||||
|
def create_render_props(self, segments: List[Dict]) -> Dict:
|
||||||
|
"""Create render_props with only PPT slides"""
|
||||||
|
|
||||||
|
# Load existing render_props to get charts and assets
|
||||||
|
props_path = self.project_root / "assets/1_processed/render_props.json"
|
||||||
|
with open(props_path, encoding="utf-8") as f:
|
||||||
|
existing_props = json.load(f)
|
||||||
|
|
||||||
|
new_props = {
|
||||||
|
"meta": {
|
||||||
|
"title": "テスト太郎様 ライフプラン紹介",
|
||||||
|
"currencyUnit": "万円",
|
||||||
|
"duration": sum(s.get("duration", 0) for s in segments),
|
||||||
|
"clientName": "テスト太郎"
|
||||||
|
},
|
||||||
|
"charts": existing_props.get("charts", {}),
|
||||||
|
"script": segments,
|
||||||
|
"assets": {
|
||||||
|
"slides": existing_props["assets"]["slides"],
|
||||||
|
"videoClips": [],
|
||||||
|
"broll": existing_props["assets"].get("broll", {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output_path = self.project_root / "assets/1_processed/render_props_ppt_slides.json"
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"\n✓ Saved render props to {output_path.name}")
|
||||||
|
return new_props
|
||||||
|
|
||||||
|
def run(self) -> str:
|
||||||
|
"""Execute full pipeline"""
|
||||||
|
print("🚀 Generating video for PPT slides 2 & 3...\n")
|
||||||
|
|
||||||
|
# Create script segments
|
||||||
|
segments = self.create_script_segments()
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Generate audio
|
||||||
|
segments = self.generate_audio(segments)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Create render props
|
||||||
|
props = self.create_render_props(segments)
|
||||||
|
print()
|
||||||
|
|
||||||
|
output_file = "assets/2_output/ppt_slides_video.mp4"
|
||||||
|
print(f"✅ Pipeline complete!")
|
||||||
|
print(f"\n📹 Render command:")
|
||||||
|
print(f" npx remotion render MainVideo {output_file} --props=\"assets/1_processed/render_props_ppt_slides.json\" --concurrency=1")
|
||||||
|
print()
|
||||||
|
print(f"📊 Video Summary:")
|
||||||
|
print(f" - Target slides: slide_002.png, slide_003.png")
|
||||||
|
print(f" - Total segments: {len(segments)}")
|
||||||
|
print(f" - Total duration: {props['meta']['duration']:.1f}s")
|
||||||
|
print(f" - Client: {props['meta']['clientName']}")
|
||||||
|
|
||||||
|
return output_file
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
generator = SimplePPTVideoGenerator()
|
||||||
|
generator.run()
|
||||||
|
|
@ -0,0 +1,350 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Generate video for specific PPT slides (slides 2 & 3)
|
||||||
|
Extract information from PDF but only display PPT slides
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
|
||||||
|
# Load environment
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class PPTSlidesVideoGenerator:
|
||||||
|
def __init__(self, slide_indices: List[int] = [1, 2]):
|
||||||
|
"""
|
||||||
|
Initialize generator for specific PPT slides
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slide_indices: List of slide indices to generate video for (0-based)
|
||||||
|
Default: [1, 2] means slide 2 and slide 3 (slide_002.png, slide_003.png)
|
||||||
|
"""
|
||||||
|
self.project_root = Path(__file__).parent.parent
|
||||||
|
self.slide_indices = slide_indices
|
||||||
|
|
||||||
|
# Initialize Gemini client
|
||||||
|
api_key = os.getenv("GEMINI_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("GEMINI_API_KEY not found in environment")
|
||||||
|
self.client = genai.Client(api_key=api_key)
|
||||||
|
|
||||||
|
def load_materials(self) -> Dict:
|
||||||
|
"""Load PPT slides and PDF data"""
|
||||||
|
print("📦 Loading materials...")
|
||||||
|
|
||||||
|
# Load existing render_props to get PPT slides info
|
||||||
|
props_path = self.project_root / "assets/1_processed/render_props.json"
|
||||||
|
with open(props_path, encoding="utf-8") as f:
|
||||||
|
props_data = json.load(f)
|
||||||
|
|
||||||
|
all_slides = props_data["assets"]["slides"]
|
||||||
|
target_slides = [slide for slide in all_slides if slide["index"] in self.slide_indices]
|
||||||
|
|
||||||
|
print(f"✓ Loaded {len(target_slides)} PPT slides (indices: {self.slide_indices})")
|
||||||
|
|
||||||
|
# Extract PDF data using Gemini
|
||||||
|
pdf_data = self.extract_pdf_data()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ppt_slides": target_slides,
|
||||||
|
"pdf_data": pdf_data,
|
||||||
|
"existing_props": props_data
|
||||||
|
}
|
||||||
|
|
||||||
|
def extract_pdf_data(self) -> Dict:
|
||||||
|
"""Extract relevant data from PDF using Gemini"""
|
||||||
|
print("📄 Extracting data from PDF...")
|
||||||
|
|
||||||
|
pdf_path = self.project_root / "assets/0_input/帳票(お客様向け).pdf"
|
||||||
|
|
||||||
|
# Upload PDF to Gemini
|
||||||
|
with open(pdf_path, 'rb') as f:
|
||||||
|
upload_config = types.UploadFileConfig(mime_type='application/pdf')
|
||||||
|
pdf_file = self.client.files.upload(file=f, config=upload_config)
|
||||||
|
|
||||||
|
# Wait for processing
|
||||||
|
import time
|
||||||
|
while pdf_file.state == "PROCESSING":
|
||||||
|
print(" ⏳ Processing PDF...")
|
||||||
|
time.sleep(2)
|
||||||
|
pdf_file = self.client.files.get(name=pdf_file.name)
|
||||||
|
|
||||||
|
if pdf_file.state == "FAILED":
|
||||||
|
raise RuntimeError(f"PDF processing failed: {pdf_file.error}")
|
||||||
|
|
||||||
|
print(" ✓ PDF uploaded successfully")
|
||||||
|
|
||||||
|
# Extraction prompt
|
||||||
|
prompt = """
|
||||||
|
このPDFからライフプラン情報を詳細に抽出してください。
|
||||||
|
|
||||||
|
以下のJSON形式で出力してください:
|
||||||
|
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"name": "クライアント名",
|
||||||
|
"birth_date": "生年月日",
|
||||||
|
"age": 年齢,
|
||||||
|
"occupation": "業種",
|
||||||
|
"annual_income": 年収(万円)
|
||||||
|
},
|
||||||
|
"spouse_info": {
|
||||||
|
"name": "配偶者名",
|
||||||
|
"birth_date": "生年月日",
|
||||||
|
"age": 年齢,
|
||||||
|
"occupation": "業種",
|
||||||
|
"annual_income": 年収(万円)
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"name": "子供の名前",
|
||||||
|
"birth_date": "生年月日",
|
||||||
|
"age": 年齢,
|
||||||
|
"education_plan": "教育プラン(保育園→小学校→中学校など)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"financial_summary": {
|
||||||
|
"household_income": 世帯年収(万円),
|
||||||
|
"annual_expenses": 年間支出(万円),
|
||||||
|
"current_assets": 現在資産(万円),
|
||||||
|
"savings": 貯蓄額(万円)
|
||||||
|
},
|
||||||
|
"life_events": [
|
||||||
|
{
|
||||||
|
"year": イベント発生年,
|
||||||
|
"age": その時の年齢,
|
||||||
|
"description": "イベント説明",
|
||||||
|
"cost": 費用(万円)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"future_projections": {
|
||||||
|
"retirement_age": 退職年齢,
|
||||||
|
"life_expectancy": 想定寿命,
|
||||||
|
"retirement_savings_needed": 必要な老後資金(万円)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
可能な限り詳細な情報を抽出してください。数値は必ず含めてください。
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.client.models.generate_content(
|
||||||
|
model="gemini-2.0-flash",
|
||||||
|
contents=[pdf_file, prompt],
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
temperature=0.1,
|
||||||
|
response_mime_type="application/json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf_data = json.loads(response.text)
|
||||||
|
print("✓ PDF data extracted successfully")
|
||||||
|
return pdf_data
|
||||||
|
|
||||||
|
def generate_script(self, materials: Dict) -> List[Dict]:
|
||||||
|
"""Generate narration script for PPT slides based on PDF data"""
|
||||||
|
print("🤖 Generating narration script...")
|
||||||
|
|
||||||
|
ppt_slides = materials["ppt_slides"]
|
||||||
|
pdf_data = materials["pdf_data"]
|
||||||
|
|
||||||
|
# Handle case where pdf_data might be a list or dict
|
||||||
|
if isinstance(pdf_data, list):
|
||||||
|
pdf_data = pdf_data[0] if pdf_data else {}
|
||||||
|
|
||||||
|
# Build detailed context from PDF
|
||||||
|
client_info = pdf_data.get("client_info", {})
|
||||||
|
spouse_info = pdf_data.get("spouse_info", {})
|
||||||
|
children = pdf_data.get("children", [])
|
||||||
|
financial = pdf_data.get("financial_summary", {})
|
||||||
|
life_events = pdf_data.get("life_events", [])
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
あなたはプロのファイナンシャルプランナーであり、動画ナレーターです。
|
||||||
|
|
||||||
|
【重要】以下の2枚のPPTスライドについて、PDFから抽出した詳細情報を使って、
|
||||||
|
豊富で魅力的なナレーション原稿を作成してください。
|
||||||
|
|
||||||
|
【利用可能なPPTスライド】
|
||||||
|
スライド1 (slide_002.png): インデックス={ppt_slides[0]["index"]}
|
||||||
|
スライド2 (slide_003.png): インデックス={ppt_slides[1]["index"] if len(ppt_slides) > 1 else "N/A"}
|
||||||
|
|
||||||
|
【PDFから抽出した詳細情報】
|
||||||
|
■ クライアント情報:
|
||||||
|
{json.dumps(client_info, ensure_ascii=False, indent=2)}
|
||||||
|
|
||||||
|
■ 配偶者情報:
|
||||||
|
{json.dumps(spouse_info, ensure_ascii=False, indent=2)}
|
||||||
|
|
||||||
|
■ 子供情報:
|
||||||
|
{json.dumps(children, ensure_ascii=False, indent=2)}
|
||||||
|
|
||||||
|
■ 財務サマリー:
|
||||||
|
{json.dumps(financial, ensure_ascii=False, indent=2)}
|
||||||
|
|
||||||
|
■ ライフイベント (最初の5件):
|
||||||
|
{json.dumps(life_events[:5], ensure_ascii=False, indent=2)}
|
||||||
|
|
||||||
|
【要件】
|
||||||
|
1. **2枚のPPTスライドのみを使用**してください
|
||||||
|
- スライド1枚あたり2-4個のセグメントを作成
|
||||||
|
- 合計で4-6個のセグメントを生成
|
||||||
|
|
||||||
|
2. 各セグメントの構造:
|
||||||
|
- id: セグメントID (例: "slide2_intro", "slide2_family", "slide3_events")
|
||||||
|
- text: ナレーション文 (30-60秒分の自然な日本語、具体的な数値を多く含む)
|
||||||
|
- duration: 推定時間(秒、実際の音声生成後に更新されます)
|
||||||
|
- visualType: "ppt" (固定)
|
||||||
|
- visualAssetIndex: PPTスライドのインデックス (0始まり)
|
||||||
|
- highlightArea: "full" (固定)
|
||||||
|
|
||||||
|
3. **ナレーション作成のポイント**:
|
||||||
|
- PDFから抽出した具体的な数値を必ず使用(名前、年齢、生年月日、年収、業種など)
|
||||||
|
- 温かみのある親しみやすい語り口
|
||||||
|
- 視聴者が理解しやすいように、情報を整理して伝える
|
||||||
|
- 各セグメント30-60秒程度の適切な長さ
|
||||||
|
- 同じスライドを連続して使用しても良い(内容が異なれば)
|
||||||
|
|
||||||
|
4. **セグメント例**:
|
||||||
|
- スライド2の前半: 家族構成の紹介
|
||||||
|
- スライド2の後半: 各メンバーの詳細(年齢、職業、年収など)
|
||||||
|
- スライド3の前半: ライフイベントの概要
|
||||||
|
- スライド3の後半: 具体的なイベントと費用
|
||||||
|
|
||||||
|
**出力形式**: JSON配列のみを返してください。
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.client.models.generate_content(
|
||||||
|
model="gemini-2.0-flash",
|
||||||
|
contents=prompt,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
temperature=0.7,
|
||||||
|
response_mime_type="application/json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
segments = json.loads(response.text)
|
||||||
|
print(f"✓ Generated {len(segments)} script segments")
|
||||||
|
|
||||||
|
# Display preview
|
||||||
|
print("\n📝 Script Preview:")
|
||||||
|
for i, seg in enumerate(segments, 1):
|
||||||
|
print(f"\n{i}. [{seg['id']}] - Slide {seg['visualAssetIndex'] + 1}")
|
||||||
|
print(f" Text: {seg['text'][:100]}...")
|
||||||
|
|
||||||
|
return segments
|
||||||
|
|
||||||
|
def generate_audio(self, segments: List[Dict]) -> List[Dict]:
|
||||||
|
"""Generate TTS audio for each segment"""
|
||||||
|
sys.path.insert(0, str(self.project_root))
|
||||||
|
from backend.audio_generator import AudioGenerator
|
||||||
|
from backend.video_processor.utils import get_video_duration
|
||||||
|
|
||||||
|
print("\n🎙️ Generating audio...")
|
||||||
|
|
||||||
|
audio_gen = AudioGenerator()
|
||||||
|
output_dir = self.project_root / "public" / "ppt_slides_audio"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
text = seg["text"]
|
||||||
|
audio_filename = f"narration_{seg['id']}.mp3"
|
||||||
|
audio_path = output_dir / audio_filename
|
||||||
|
|
||||||
|
print(f" [{i+1}/{len(segments)}] {seg['id']}: Generating audio...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = audio_gen.client.audio.speech.create(
|
||||||
|
model="tts-1",
|
||||||
|
voice="nova",
|
||||||
|
input=text
|
||||||
|
)
|
||||||
|
response.stream_to_file(str(audio_path))
|
||||||
|
|
||||||
|
actual_duration = get_video_duration(str(audio_path))
|
||||||
|
seg["duration"] = actual_duration
|
||||||
|
seg["audioPath"] = f"public/ppt_slides_audio/{audio_filename}"
|
||||||
|
print(f" ✓ Duration: {actual_duration:.2f}s")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Failed: {e}")
|
||||||
|
seg["duration"] = 5.0 # Fallback duration
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_duration = sum(s.get("duration", 0) for s in segments)
|
||||||
|
print(f"\n✓ Generated {len(segments)} audio files (Total: {total_duration:.1f}s)")
|
||||||
|
return segments
|
||||||
|
|
||||||
|
def create_render_props(self, segments: List[Dict], materials: Dict) -> Dict:
|
||||||
|
"""Create render_props with only PPT slides"""
|
||||||
|
|
||||||
|
existing_props = materials["existing_props"]
|
||||||
|
|
||||||
|
new_props = {
|
||||||
|
"meta": {
|
||||||
|
"title": f"ライフプラン紹介 - スライド{self.slide_indices[0]+1}・{self.slide_indices[1]+1 if len(self.slide_indices) > 1 else ''}",
|
||||||
|
"currencyUnit": "万円",
|
||||||
|
"duration": sum(s.get("duration", 0) for s in segments),
|
||||||
|
"clientName": materials["pdf_data"].get("client_info", {}).get("name", "お客様")
|
||||||
|
},
|
||||||
|
"charts": existing_props.get("charts", {}),
|
||||||
|
"script": segments,
|
||||||
|
"assets": {
|
||||||
|
"slides": existing_props["assets"]["slides"], # Keep all slides
|
||||||
|
"videoClips": [],
|
||||||
|
"broll": existing_props["assets"].get("broll", {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output_path = self.project_root / "assets/1_processed/render_props_ppt_slides.json"
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"\n✓ Saved render props to {output_path.name}")
|
||||||
|
return new_props
|
||||||
|
|
||||||
|
def run(self) -> str:
|
||||||
|
"""Execute full pipeline"""
|
||||||
|
print(f"🚀 Generating video for PPT slides {[i+1 for i in self.slide_indices]}...\n")
|
||||||
|
|
||||||
|
# Load materials
|
||||||
|
materials = self.load_materials()
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Generate script
|
||||||
|
segments = self.generate_script(materials)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Generate audio
|
||||||
|
segments = self.generate_audio(segments)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Create render props
|
||||||
|
props = self.create_render_props(segments, materials)
|
||||||
|
print()
|
||||||
|
|
||||||
|
output_file = "assets/2_output/ppt_slides_video.mp4"
|
||||||
|
print(f"✅ Pipeline complete!")
|
||||||
|
print(f"\n📹 Render command:")
|
||||||
|
print(f" npx remotion render MainVideo {output_file} \\")
|
||||||
|
print(f" --props=\"assets/1_processed/render_props_ppt_slides.json\" --concurrency=1")
|
||||||
|
print()
|
||||||
|
print(f"📊 Video Summary:")
|
||||||
|
print(f" - Target slides: {[f'slide_{i+1:03d}.png' for i in self.slide_indices]}")
|
||||||
|
print(f" - Total segments: {len(segments)}")
|
||||||
|
print(f" - Total duration: {props['meta']['duration']:.1f}s")
|
||||||
|
print(f" - Client: {props['meta']['clientName']}")
|
||||||
|
|
||||||
|
return output_file
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Generate video for slides 2 and 3 (indices 1 and 2)
|
||||||
|
generator = PPTSlidesVideoGenerator(slide_indices=[1, 2])
|
||||||
|
generator.run()
|
||||||
|
|
@ -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,203 @@ 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():
|
||||||
|
# ENHANCED: Inject explicit Japanese context for every search to avoid Caucasian subjects
|
||||||
|
search_query = f"Japanese {en_keyword} people Asian"
|
||||||
|
print(f" 🔍 Searching Pexels for '{en_keyword}' (with strong Japanese/Asian priority)...")
|
||||||
|
|
||||||
|
# Search Pexels with "Japanese" added to the query for better relevance
|
||||||
|
headers = {"Authorization": self.api_key}
|
||||||
|
params = {
|
||||||
|
"query": search_query,
|
||||||
|
"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 +228,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
|
|
||||||
|
|
||||||
|
# Try to extract from video_data if available
|
||||||
|
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()
|
fetcher = MediaFetcher()
|
||||||
return fetcher.fetch_broll(keywords)
|
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,73 @@
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables from .env file
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Determine if we should keep existing audio path
|
||||||
|
current_path = segment.get("audioPath", "")
|
||||||
|
is_original_clip = "dialogue_clip" in current_path
|
||||||
|
|
||||||
|
if segment.get("visualType") == "video":
|
||||||
|
if "audioPath" in segment:
|
||||||
|
del segment["audioPath"]
|
||||||
|
elif is_original_clip:
|
||||||
|
# Keep the dialogue_clip path as is
|
||||||
|
print(f" ⏭️ Segment {new_id}: Keeping original audio path {current_path}")
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Assign standard naming for AI generated segments
|
||||||
|
segment["audioPath"] = f"public/segment_{new_id:03d}.mp3"
|
||||||
|
|
||||||
|
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, voice="nova")
|
||||||
|
# voice options: "alloy", "echo", "fable", "onyx", "nova", "shimmer"
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Regenerate missing audio files for PPT intro video
|
||||||
|
Only regenerates audio for segments that failed previously
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
def regenerate_missing_audio():
|
||||||
|
"""Regenerate audio for segments that are missing audioPath"""
|
||||||
|
project_root = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
# Load the render props
|
||||||
|
props_path = project_root / "assets/1_processed/render_props_ppt_intro_final.json"
|
||||||
|
with open(props_path, encoding="utf-8") as f:
|
||||||
|
props_data = json.load(f)
|
||||||
|
|
||||||
|
segments = props_data["script"]
|
||||||
|
|
||||||
|
# Find segments without audioPath
|
||||||
|
missing_segments = []
|
||||||
|
for seg in segments:
|
||||||
|
if "audioPath" not in seg or not seg["audioPath"]:
|
||||||
|
missing_segments.append(seg)
|
||||||
|
|
||||||
|
if not missing_segments:
|
||||||
|
print("✓ All segments already have audio files!")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"🔍 Found {len(missing_segments)} segments without audio")
|
||||||
|
|
||||||
|
# Import audio generator
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
from backend.audio_generator import AudioGenerator
|
||||||
|
from backend.video_processor.utils import get_video_duration
|
||||||
|
|
||||||
|
audio_gen = AudioGenerator()
|
||||||
|
output_dir = project_root / "public" / "ppt_intro_final_audio"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Regenerate audio
|
||||||
|
print("\n🎙️ Generating missing audio files...")
|
||||||
|
|
||||||
|
for i, seg in enumerate(missing_segments, 1):
|
||||||
|
text = seg["text"]
|
||||||
|
audio_filename = f"narration_{seg['id']}.mp3"
|
||||||
|
audio_path = output_dir / audio_filename
|
||||||
|
|
||||||
|
print(f"\n[{i}/{len(missing_segments)}] {seg['id']}")
|
||||||
|
print(f" Text: {text[:60]}...")
|
||||||
|
print(f" Generating audio...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = audio_gen.client.audio.speech.create(
|
||||||
|
model="tts-1",
|
||||||
|
voice="nova",
|
||||||
|
input=text
|
||||||
|
)
|
||||||
|
response.stream_to_file(str(audio_path))
|
||||||
|
|
||||||
|
actual_duration = get_video_duration(str(audio_path))
|
||||||
|
seg["duration"] = actual_duration
|
||||||
|
seg["audioPath"] = f"public/ppt_intro_final_audio/{audio_filename}"
|
||||||
|
print(f" ✓ Generated: {actual_duration:.2f}s")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Failed: {e}")
|
||||||
|
seg["duration"] = seg.get("estimatedDuration", 10)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update duration in props
|
||||||
|
props_data["meta"]["duration"] = sum(s.get("duration", 0) for s in segments)
|
||||||
|
|
||||||
|
# Save updated props
|
||||||
|
with open(props_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(props_data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"\n✅ Regeneration complete!")
|
||||||
|
print(f" Total duration: {props_data['meta']['duration']:.1f}s")
|
||||||
|
print(f"\n📹 Ready to render:")
|
||||||
|
print(f" npx remotion render MainVideo assets/2_output/ppt_intro_final_video.mp4 \\")
|
||||||
|
print(f" --props=\"assets/1_processed/render_props_ppt_intro_final.json\" --concurrency=1")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
regenerate_missing_audio()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
@ -8,8 +8,16 @@ This module generates natural Japanese narration scripts based on:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
|
import re
|
||||||
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 ScriptGenerator:
|
class ScriptGenerator:
|
||||||
"""Generate narration scripts using Gemini Pro"""
|
"""Generate narration scripts using Gemini Pro"""
|
||||||
|
|
@ -19,44 +27,294 @@ class ScriptGenerator:
|
||||||
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 generate(
|
def generate(
|
||||||
self,
|
self,
|
||||||
pdf_data: Dict[str, Any],
|
pdf_data: Dict[str, Any],
|
||||||
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:
|
# Build the prompt
|
||||||
Dictionary containing:
|
prompt = self._build_prompt(merged_data, style)
|
||||||
- segments: List of narration segments
|
|
||||||
- total_duration: Estimated total duration in seconds
|
|
||||||
- sync_points: Recommended chart/video sync points
|
|
||||||
"""
|
|
||||||
# TODO: Implement script generation
|
|
||||||
# 1. Build prompt with PDF data and constraints
|
|
||||||
# 2. Call Gemini Pro API
|
|
||||||
# 3. Parse and structure the response
|
|
||||||
# 4. Estimate timing for each segment
|
|
||||||
|
|
||||||
raise NotImplementedError("Script generation not yet implemented")
|
# DEBUG: Print the first part of the prompt
|
||||||
|
# print(f"--- DEBUG PROMPT START ---\n{prompt[:500]}\n--- DEBUG PROMPT END ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Call Gemini API
|
||||||
|
response = self.client.models.generate_content(
|
||||||
|
model=self.model,
|
||||||
|
contents=prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse the response
|
||||||
|
script_data = self._parse_response(response.text)
|
||||||
|
|
||||||
|
print(f" ✓ Generated {len(script_data['segments'])} script segments")
|
||||||
|
return script_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Gemini API error: {e}")
|
||||||
|
print(" Falling back to template-based script...")
|
||||||
|
return self._generate_fallback_script(pdf_data)
|
||||||
|
|
||||||
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"""
|
||||||
# TODO: Implement prompt engineering
|
client_name = pdf_data.get("client_name", "お客様")
|
||||||
# Should include:
|
current_age = pdf_data.get("current_age", 47)
|
||||||
# - PDF data summary
|
retirement_age = pdf_data.get("retirement_age", 60)
|
||||||
# - Style guidelines (natural Japanese, friendly tone)
|
current_assets = pdf_data.get("current_assets", 907)
|
||||||
# - Output format requirements (JSON with segments)
|
annual_income = pdf_data.get("annual_income", 703)
|
||||||
# - Length constraints (target 3-5 minutes)
|
annual_expenses = pdf_data.get("annual_expenses", 288)
|
||||||
|
life_events = pdf_data.get("life_events", [])
|
||||||
|
|
||||||
return ""
|
# 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"
|
||||||
|
|
||||||
|
# Advice info
|
||||||
|
advice_info = ""
|
||||||
|
advice_list = pdf_data.get("advice", [])
|
||||||
|
if advice_list:
|
||||||
|
advice_info = "\n【具体的なアドバイス内容】\n"
|
||||||
|
for adv in advice_list:
|
||||||
|
advice_info += f"- {adv}\n"
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
あなたはプロのファイナンシャルプランナーであり、親しみやすい動画解説者です。
|
||||||
|
以下の顧客情報、ライフイベント、PPTスライド、具体的なアドバイス、そしてインタビュー動画の転記内容に基づいて、解説動画用の完結なナレーション原稿を作成してください。
|
||||||
|
|
||||||
|
【厳格な指示】
|
||||||
|
1. 抽象的な表現(「〇〇」や「△△」)や、メタ発言(「スライドの内容がありません」など)は絶対に出力しないでください。
|
||||||
|
2. インタビュー内容やPDFの数値を最大限に活用し、事実に基づいた具体的な解説を行ってください。
|
||||||
|
3. 各セグメントの视覚素材(`visualType`)は、以下から適切に選択してください:
|
||||||
|
- `ppt`: PPTスライドを表示する場合。`visualAssetIndex` を指定してください(基本はこちらを優先)。
|
||||||
|
- `broll`: 抽象的なイメージ映像を表示する場合。`brollKeyword` を必ず指定してください。
|
||||||
|
- `chart`: 資産推移や収支の動的なグラフを表示する場合。シミュレーションの結果を具体的に説明するシーンで使用します。
|
||||||
|
- `video`: 本人のインタビュー映像を表示する場合。本人の肉声や想いを伝えるシーンで使用します。`visualAssetIndex` にクリップの番号を指定してください。
|
||||||
|
- `dialogue`: 文字による対話形式(インタビューの再現)を表示する場合。動画の冒端でインタビューのやり取りをテキストで再現する際に使用します。`speaker` 属性に话者名を指定してください。
|
||||||
|
4. ナレーションの中に「スライド〇〇をご覧ください」や「インタビューでもおっしゃっていた通り...」といった言葉を入れ、視聴者の視線を誘導してください。
|
||||||
|
5. 真人インタビュー(`video` タイプ)を使用する場合、ナレーションテキスト(`text` 属性)はそのクリップで本人が話している内容と一致させてください。
|
||||||
|
6. `brollKeyword` についての指示:
|
||||||
|
- 単一の単語(例:「家族」)ではなく、**セグメントの文脈を反映した具体的な英語のフレーズ**(例:「Japanese family having dinner」、「Japanese businessman planning career」)を生成してください。
|
||||||
|
- 人物が登場する場合は、必ず**「Japanese」または「Asian」**というキーワードをフレーズに含めてください。
|
||||||
|
|
||||||
|
【顧客情報】
|
||||||
|
- お名前: {client_name}様
|
||||||
|
- 現在年齢: {current_age}歳
|
||||||
|
- 退職予定年齢: {retirement_age}歳
|
||||||
|
- 現在の資産: {current_assets}万円
|
||||||
|
- 年収: {annual_income}万円
|
||||||
|
- 年間支出: {annual_expenses}万円
|
||||||
|
{events_info}
|
||||||
|
{ppt_info}
|
||||||
|
{advice_info}
|
||||||
|
{video_info}
|
||||||
|
|
||||||
|
【原稿構成のフロー】
|
||||||
|
動画全体で少なくとも 12〜18 のセグメントを作成し、以下の流れで構成してください:
|
||||||
|
1. 文字対話オープニング(visualType: dialogue):インタビュー動画から、ライフプラン相談のきっかけや想いがわかる重要なやり取りを 3〜5 セグメントほど抽出して配置してください。
|
||||||
|
2. 動画全体の導入(Slide 0):インパクトのある挨拶。
|
||||||
|
3. 自己紹介と現状(Slide 1, 2...):家計の現状をスライドに沿って。
|
||||||
|
3. ライフイベント(Slide 3, 4...):具体的なイベント内容の解説。
|
||||||
|
4. インタビュー挿入(Video):本人の想いや不安、决意を语るシーン。
|
||||||
|
5. シミュレーション詳細(Chart):動的なグラフ(資産推移・収支明細)を使い、何年後に資産がどうなるかを具体的に説明。
|
||||||
|
6. 対策とアドバイス(Slide 5, 6...):【具体的なアドバイス内容】を最大限に活用し、顧客の状況に合わせた具体的な改善策(保険の見直し、投資の検討、支出の最適化など)を 2〜3 セグメントかけて丁寧に説明してください。
|
||||||
|
- **重要**: アドバイスセグメントでは、**`visualType` を必ず `ppt`** に設定し、**`visualAssetIndex` を `5`**(本件PPTの倒数2枚目:改善策スライド)に固定して表示し続けてください。決して一言で終わらせないでください。
|
||||||
|
7. クロージング(Slide 7...):まとめと個別相談の案内。`visualAssetIndex` を `6`(最後のスライド)に設定してください。
|
||||||
|
|
||||||
|
【素材使用の鉄則 - 必ず守ること】
|
||||||
|
- **PPTスライドの全消化**: 提供されたスライド(Slide 0 から最後のリファレンスまで)を、内容に合わせて**必ず全て最低1回は表示**してください。スライドを飛ばさないでください。
|
||||||
|
- **ハイブリッド構成**: 「PPTで事実を伝え、Chartでデータを動かし、Videoで本人の声を伝える」というリズムを意識してください。
|
||||||
|
|
||||||
|
【出力形式】
|
||||||
|
純粋なJSON形式のみで返してください。コードブロックは不要です。
|
||||||
|
{{
|
||||||
|
"segments": [
|
||||||
|
{{
|
||||||
|
"id": 1,
|
||||||
|
"text": "読み上げる文章",
|
||||||
|
"insight": "画面に表示する短い要約・洞察(20文字程度)",
|
||||||
|
"targetYear": 2025,
|
||||||
|
"duration": 秒数,
|
||||||
|
"type": "narration" | "dialogue" | "chart_intro" | "chart_highlight" | "video_clip",
|
||||||
|
"visualType": "ppt" | "video" | "chart" | "dialogue" | "broll",
|
||||||
|
"visualAssetIndex": 0,
|
||||||
|
"speaker": "話者名",
|
||||||
|
"brollKeyword": "キーワード"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
def _parse_response(self, response_text: str) -> Dict[str, Any]:
|
||||||
|
"""Parse Gemini's JSON response"""
|
||||||
|
# Remove markdown code blocks and any extra text
|
||||||
|
text = response_text.strip()
|
||||||
|
|
||||||
|
# Try to find JSON block
|
||||||
|
if "{" in text and "}" in text:
|
||||||
|
start = text.find("{")
|
||||||
|
end = text.rfind("}") + 1
|
||||||
|
text = text[start:end]
|
||||||
|
|
||||||
|
# Parse JSON
|
||||||
|
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
|
||||||
|
total_duration = sum(seg.get("duration", 0) for seg in data["segments"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"segments": data["segments"],
|
||||||
|
"total_duration": total_duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _generate_fallback_script(self, pdf_data: Dict) -> Dict[str, Any]:
|
||||||
|
"""Generate a template-based script as fallback"""
|
||||||
|
client_name = pdf_data.get("client_name", "お客様")
|
||||||
|
current_age = pdf_data.get("current_age", 35)
|
||||||
|
retirement_age = pdf_data.get("retirement_age", 65)
|
||||||
|
current_year = 2025 # TODO: Make dynamic
|
||||||
|
retirement_year = current_year + (retirement_age - current_age)
|
||||||
|
|
||||||
|
segments = [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"text": f"{client_name}様、こんにちは。これから、あなたの人生設計について、詳しくご説明させていただきます。",
|
||||||
|
"duration": 5.0,
|
||||||
|
"type": "narration",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"text": f"現在{current_age}歳の{client_name}様の資産状況を見てみましょう。",
|
||||||
|
"duration": 3.5,
|
||||||
|
"type": "chart_intro",
|
||||||
|
"chartSync": {
|
||||||
|
"chartId": "cashFlow",
|
||||||
|
"highlightYear": current_year,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"text": f"年間の収入と支出のバランスを確認します。現在は安定した黒字が続いています。",
|
||||||
|
"duration": 4.5,
|
||||||
|
"type": "chart_highlight",
|
||||||
|
"chartSync": {
|
||||||
|
"chartId": "cashFlow",
|
||||||
|
"highlightYear": current_year,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"text": f"{retirement_age}歳での退職を想定した場合、将来の資産推移はこのようになります。",
|
||||||
|
"duration": 4.0,
|
||||||
|
"type": "chart_highlight",
|
||||||
|
"chartSync": {
|
||||||
|
"chartId": "assetDepletion",
|
||||||
|
"highlightYear": retirement_year,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"text": "退職後の生活資金については、計画的な準備が大切です。定期的に見直しを行いましょう。",
|
||||||
|
"duration": 5.0,
|
||||||
|
"type": "narration",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"text": "ご不明な点がございましたら、いつでもご相談ください。ありがとうございました。",
|
||||||
|
"duration": 4.0,
|
||||||
|
"type": "narration",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"segments": segments,
|
||||||
|
"total_duration": sum(s["duration"] for s in segments),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def generate_script(
|
def generate_script(
|
||||||
|
|
@ -64,5 +322,11 @@ def generate_script(
|
||||||
video_data: Optional[Dict[str, Any]] = None
|
video_data: Optional[Dict[str, Any]] = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Convenience function for script generation"""
|
"""Convenience function for script generation"""
|
||||||
|
try:
|
||||||
generator = ScriptGenerator()
|
generator = ScriptGenerator()
|
||||||
return generator.generate(pdf_data, video_data)
|
return generator.generate(pdf_data, video_data)
|
||||||
|
except (ValueError, ImportError) as e:
|
||||||
|
print(f" ⚠️ Cannot initialize ScriptGenerator: {e}")
|
||||||
|
print(" Using fallback template script...")
|
||||||
|
generator_fallback = ScriptGenerator.__new__(ScriptGenerator)
|
||||||
|
return generator_fallback._generate_fallback_script(pdf_data)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def simplify_intro():
|
||||||
|
props_path = Path("assets/1_processed/render_props.json")
|
||||||
|
if not props_path.exists():
|
||||||
|
print("Error: render_props.json not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(props_path, "r", encoding="utf-8") as f:
|
||||||
|
props = json.load(f)
|
||||||
|
|
||||||
|
script = props.get("script", [])
|
||||||
|
if len(script) < 6:
|
||||||
|
print("Script too short to simplify")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Combined FP Segment (ID 1)
|
||||||
|
# Merging content from original 1, 3, 5
|
||||||
|
fp_text = "お客様のライフプランを提供するために、お名前と生年月日などの基本情報を教えていただけますでしょうか。"
|
||||||
|
fp_segment = {
|
||||||
|
"id": 1,
|
||||||
|
"text": fp_text,
|
||||||
|
"insight": "基本情報のヒアリング",
|
||||||
|
"targetYear": 2024,
|
||||||
|
"duration": 6.5, # Estimated, rebuild_audio will fix it
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "FP",
|
||||||
|
"brollKeyword": "Japanese financial advisor consulting client",
|
||||||
|
"audioPath": "public/segment_001.mp3"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Combined Client Segment (ID 2)
|
||||||
|
# Merging content from original 2, 4, 6
|
||||||
|
client_text = "はい、テスト太郎です。1980年1月1日生まれの47歳です。"
|
||||||
|
client_segment = {
|
||||||
|
"id": 2,
|
||||||
|
"text": client_text,
|
||||||
|
"insight": "自己紹介と基本情報",
|
||||||
|
"targetYear": 2024,
|
||||||
|
"duration": 8.0, # Estimated
|
||||||
|
"type": "dialogue",
|
||||||
|
"visualType": "dialogue",
|
||||||
|
"speaker": "家族代表様",
|
||||||
|
"brollKeyword": "Japanese man introduced himself",
|
||||||
|
"audioPath": "public/segment_002.mp3" # Will use TTS for this combined one for now
|
||||||
|
}
|
||||||
|
|
||||||
|
# New script starts with these two
|
||||||
|
new_script = [fp_segment, client_segment]
|
||||||
|
|
||||||
|
# Append the rest (starting from original index 6, which is ID 7)
|
||||||
|
for i, seg in enumerate(script[6:]):
|
||||||
|
seg["id"] = i + 3
|
||||||
|
# We'll let rebuild_audio fix the paths and durations
|
||||||
|
new_script.append(seg)
|
||||||
|
|
||||||
|
props["script"] = new_script
|
||||||
|
|
||||||
|
with open(props_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(props, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print("Successfully simplified intro dialogue in render_props.json")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
simplify_intro()
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Add project root to sys.path
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
sys.path.append(str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from backend.media_fetcher import MediaFetcher
|
||||||
|
|
||||||
|
def update_broll_assets():
|
||||||
|
"""
|
||||||
|
Reads render_props.json, fetches B-Roll assets for segments that have
|
||||||
|
brollKeywords, and updates the assets.broll section.
|
||||||
|
Does NOT modify narration text.
|
||||||
|
"""
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
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 {props_path}...")
|
||||||
|
with open(props_path, "r", encoding="utf-8") as f:
|
||||||
|
props = json.load(f)
|
||||||
|
|
||||||
|
# 1. Collect all keywords from script segments
|
||||||
|
keywords = set()
|
||||||
|
for segment in props.get("script", []):
|
||||||
|
kw = segment.get("brollKeyword")
|
||||||
|
if kw:
|
||||||
|
keywords.add(kw)
|
||||||
|
|
||||||
|
if not keywords:
|
||||||
|
print("ℹ️ No B-Roll keywords found in script. Skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"🎬 Keywords found: {', '.join(keywords)}")
|
||||||
|
|
||||||
|
# 2. Fetch assets using MediaFetcher
|
||||||
|
try:
|
||||||
|
fetcher = MediaFetcher()
|
||||||
|
# Fetch 1 video per keyword
|
||||||
|
broll_results = fetcher.fetch_broll(list(keywords), max_per_keyword=1)
|
||||||
|
|
||||||
|
# 3. Update props["assets"]["broll"]
|
||||||
|
if "assets" not in props:
|
||||||
|
props["assets"] = {}
|
||||||
|
if "broll" not in props["assets"]:
|
||||||
|
props["assets"]["broll"] = {}
|
||||||
|
|
||||||
|
new_broll_map = {}
|
||||||
|
for kw, videos in broll_results.items():
|
||||||
|
if videos:
|
||||||
|
filename = Path(videos[0]["filepath"]).name
|
||||||
|
new_broll_map[kw] = filename
|
||||||
|
print(f" ✅ Mapped '{kw}' -> {filename}")
|
||||||
|
|
||||||
|
props["assets"]["broll"].update(new_broll_map)
|
||||||
|
|
||||||
|
# 4. Map brollPath in each segment for Remotion to use easily
|
||||||
|
# (Though Composition.tsx might already do this, we ensure consistency)
|
||||||
|
for segment in props.get("script", []):
|
||||||
|
kw = segment.get("brollKeyword")
|
||||||
|
if kw and kw in props["assets"]["broll"]:
|
||||||
|
filename = props["assets"]["broll"][kw]
|
||||||
|
segment["brollPath"] = f"broll/{filename}"
|
||||||
|
|
||||||
|
# 5. Save
|
||||||
|
with open(props_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(props, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"✨ Successfully updated B-Roll assets in {props_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed to fetch B-Roll: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
update_broll_assets()
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
386
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,15 +102,28 @@ 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)
|
props_file = self.processed_dir / "render_props.json"
|
||||||
|
|
||||||
|
# FEATURE: Use existing script if skip_script is enabled to preserve manual edits
|
||||||
|
if self.config.get("skip_script") and props_file.exists():
|
||||||
|
print(f" ♻️ PRESERVING existing script from {props_file.name}")
|
||||||
|
with open(props_file, "r", encoding="utf-8") as f:
|
||||||
|
existing_props = json.load(f)
|
||||||
|
script = {"segments": existing_props.get("script", [])}
|
||||||
|
else:
|
||||||
|
script = self.generate_script(pdf_data, ppt_data, video_data)
|
||||||
|
|
||||||
|
# Step 3.5: Generate audio from script (NEW)
|
||||||
|
print("\n🎙️ Step 3.5/6: Generating audio narration...")
|
||||||
|
script = self.generate_audio(script)
|
||||||
|
|
||||||
# Step 4: Fetch B-Roll assets
|
# Step 4: Fetch B-Roll assets
|
||||||
print("\n🎬 Step 4/6: Fetching B-Roll assets...")
|
print("\n🎬 Step 4/6: Fetching B-Roll assets...")
|
||||||
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"
|
||||||
|
|
@ -84,116 +140,210 @@ 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"""
|
||||||
# TODO: Implement backend.scripter.generate_script
|
print(" ✍️ Generating script with Gemini Pro...")
|
||||||
print(" [MOCK] Generating script with Gemini Pro...")
|
from backend.scripter import generate_script
|
||||||
return {
|
|
||||||
"segments": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"text": "これからあなたの将来設計について説明します。",
|
|
||||||
"duration": 3.5,
|
|
||||||
"type": "narration",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"text": "現在の資産状況を見てみましょう。",
|
|
||||||
"duration": 2.8,
|
|
||||||
"type": "chart_intro",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
def fetch_broll(self, script: Dict) -> Dict[str, Any]:
|
# Merge PPT and PDF data
|
||||||
"""Fetch B-Roll footage from Pexels API"""
|
combined_data = {**pdf_data}
|
||||||
# TODO: Implement backend.media_fetcher.fetch_broll
|
if ppt_data:
|
||||||
print(" [MOCK] Fetching B-Roll from Pexels...")
|
combined_data["presentation"] = ppt_data
|
||||||
return {
|
|
||||||
"retirement": "https://example.com/retirement.mp4",
|
try:
|
||||||
"family": "https://example.com/family.mp4",
|
script = generate_script(combined_data, video_data)
|
||||||
}
|
print(f" ✓ Generated {len(script.get('segments', []))} script segments")
|
||||||
|
return script
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Script generation failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def generate_audio(self, script_data: Dict) -> Dict[str, Any]:
|
||||||
|
"""Generate audio narration from script using OpenAI TTS"""
|
||||||
|
from backend.audio_generator import generate_audio_for_script
|
||||||
|
from backend.video_processor.utils import get_video_duration
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Generate TTS audio for non-video segments
|
||||||
|
script_data = generate_audio_for_script(script_data, voice="nova")
|
||||||
|
# voice options: "alloy", "echo", "fable", "onyx", "nova", "shimmer"
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
print(f" ⚠️ Audio generation failed: {e}")
|
||||||
|
print(" Continuing without audio...")
|
||||||
|
return script_data
|
||||||
|
|
||||||
|
def fetch_broll(self, script: Dict, video_data: Dict = None) -> Dict[str, Any]:
|
||||||
|
"""Fetch B-Roll footage from Pexels API based on script context"""
|
||||||
|
try:
|
||||||
|
from backend.media_fetcher import MediaFetcher
|
||||||
|
|
||||||
|
# 1. Extract keywords explicitly requested in script segments
|
||||||
|
keywords = set()
|
||||||
|
for segment in script.get("segments", []):
|
||||||
|
# ENHANCED: Collect keywords from ANY segment that has one,
|
||||||
|
# as our 3-column layout can show B-Roll alongside PPT/Charts
|
||||||
|
if 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"""
|
||||||
# TODO: Implement backend.data_builder.build_chart_data
|
print(" Building chart data and render props...")
|
||||||
print(" [MOCK] Building chart data and render props...")
|
|
||||||
|
# Use the financial simulator to build chart data that matches the
|
||||||
|
# TypeScript RenderProps contract (frontend/src/render-props-types.ts)
|
||||||
|
from backend.data_builder import build_chart_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": {
|
||||||
"title": f"{pdf_data.get('client_name', 'お客様')}様の人生設計",
|
"title": f"{pdf_data.get('client_name', 'お客様')}様の人生設計",
|
||||||
"currencyUnit": "万円",
|
"currencyUnit": "万円",
|
||||||
"duration": 180, # 3 minutes
|
"duration": 180, # 3 minutes
|
||||||
|
"clientName": pdf_data.get("client_name"),
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": charts,
|
||||||
"assetDepletion": {
|
|
||||||
"timeline": [
|
|
||||||
{
|
|
||||||
"year": 2025,
|
|
||||||
"ages": {"rep": 35, "spouse": 33},
|
|
||||||
"financials": {
|
|
||||||
"income": 600,
|
|
||||||
"expenditure": 450,
|
|
||||||
"assetBalance": 5000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
# ... more years
|
|
||||||
],
|
|
||||||
"meta": {"currencyUnit": "万円", "startYear": 2025},
|
|
||||||
},
|
|
||||||
"cashFlow": {
|
|
||||||
"timeline": [
|
|
||||||
{
|
|
||||||
"year": 2025,
|
|
||||||
"age": 35,
|
|
||||||
"income": {"total": 600},
|
|
||||||
"expenses": {
|
|
||||||
"living": 200,
|
|
||||||
"housing": 150,
|
|
||||||
"education": 50,
|
|
||||||
"tax": 50,
|
|
||||||
"event": 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
# ... more years
|
|
||||||
],
|
|
||||||
"meta": {"currencyUnit": "万円"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"script": script["segments"],
|
"script": script["segments"],
|
||||||
"assets": {
|
"assets": {
|
||||||
"broll": broll,
|
"broll": broll,
|
||||||
"videoClips": [],
|
"videoClips": video_clips,
|
||||||
|
"slides": ppt_slides,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,27 +351,33 @@ class VideoGeneratorPipeline:
|
||||||
"""Render video using Remotion"""
|
"""Render video using Remotion"""
|
||||||
output_file = self.output_dir / "life_plan_video.mp4"
|
output_file = self.output_dir / "life_plan_video.mp4"
|
||||||
|
|
||||||
|
# If dry-run flag is set, skip rendering step
|
||||||
|
if self.config.get("dry_run"):
|
||||||
|
print(" [SKIP] Dry run enabled - not rendering video with Remotion")
|
||||||
|
return output_file
|
||||||
|
|
||||||
# Remotion render command
|
# Remotion render command
|
||||||
|
# Use "npx.cmd" on Windows to ensure subprocess can find it
|
||||||
|
npx_executable = "npx.cmd" if sys.platform == "win32" else "npx"
|
||||||
cmd = [
|
cmd = [
|
||||||
"npx",
|
npx_executable,
|
||||||
"remotion",
|
"remotion",
|
||||||
"render",
|
"render",
|
||||||
"frontend/src/index.ts",
|
"src/index.ts",
|
||||||
"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)}")
|
||||||
|
|
||||||
# TODO: Uncomment when frontend is ready
|
try:
|
||||||
# try:
|
subprocess.run(cmd, check=True, cwd=self.project_root, shell=True)
|
||||||
# subprocess.run(cmd, check=True, cwd=self.project_root)
|
except subprocess.CalledProcessError as e:
|
||||||
# except subprocess.CalledProcessError as e:
|
print(f" ❌ Remotion render failed: {e}")
|
||||||
# print(f" ❌ Remotion render failed: {e}")
|
sys.exit(1)
|
||||||
# sys.exit(1)
|
|
||||||
|
|
||||||
print(" [MOCK] Remotion render would execute here")
|
|
||||||
return output_file
|
return output_file
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -241,7 +397,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(
|
||||||
|
|
@ -268,6 +430,18 @@ 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)",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-script",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip AI script generation and use existing script from render_props.json (preserves manual edits)",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Build configuration
|
# Build configuration
|
||||||
|
|
@ -277,15 +451,17 @@ 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
|
||||||
|
config["skip_script"] = args.skip_script
|
||||||
|
|
||||||
# Run pipeline
|
# Run pipeline
|
||||||
pipeline = VideoGeneratorPipeline(config)
|
pipeline = VideoGeneratorPipeline(config)
|
||||||
|
|
|
||||||