## 🎙️ 配音前置重构 — 第一阶段 (Day 23)

### 概述

将配音从视频生成流程中独立出来，实现"先生成配音 → 选中配音 → 再选素材 → 生成视频"的新工作流。用户可以独立管理配音（生成/试听/改名/删除/选择），并在选中配音后看到时长信息，为第二阶段的素材时间轴编排奠定数据基础。

**旧流程**: 文案 + 选素材 → 一键生成（内联 TTS → Whisper → 均分 → LipSync → 合成）
**新流程**: 文案 → 配音方式 → **生成配音** → 选中配音 → 选素材 → 背景音乐 → 生成视频

---

### 一、后端：新增 `generated_audios` 模块

#### 模块结构

```
backend/app/modules/generated_audios/
├── __init__.py
├── router.py      # 5 个 API 端点
├── schemas.py     # 请求/响应模型
└── service.py     # 生成/列表/删除/改名
```

#### API 端点

| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/generated-audios/generate` | 异步生成配音（返回 task_id） |
| GET | `/api/generated-audios/tasks/{task_id}` | 轮询生成进度 |
| GET | `/api/generated-audios` | 列出用户所有配音 |
| DELETE | `/api/generated-audios/{audio_id}` | 删除配音 |
| PUT | `/api/generated-audios/{audio_id}` | 改名 |

#### 存储方案

- Supabase 存储桶：`generated-audios`（启动时自动创建）
- 音频文件：`{user_id}/{timestamp}_audio.wav`
- 元数据文件：`{user_id}/{timestamp}_audio.json`（含 display_name、text、tts_mode、duration_sec 等）

#### 生成流程

复用现有 `TTSService` / `voice_clone_service` / `task_store`：

```
POST /generate → 创建 task → BackgroundTask:
  1. edgetts → TTSService.generate_audio()
     voiceclone → 下载 ref_audio → voice_clone_service.generate_audio()
  2. ffprobe 获取时长
  3. 上传 .wav + .json 到 generated-audios 桶
  4. 更新 task(status=completed, output={audio_id, duration_sec, ...})
```

---

### 二、后端：修改视频生成 workflow

#### `GenerateRequest` 新增字段

```python
generated_audio_id: Optional[str] = None  # 预生成配音 ID（存在时跳过内联 TTS）
```

#### `workflow.py` TTS 阶段新增分支

```python
if req.generated_audio_id:
    # 下载预生成配音 + 从元数据读取 language
elif req.tts_mode == "voiceclone":
    # 原有声音克隆逻辑
else:
    # 原有 EdgeTTS 逻辑
```

向后兼容：不传 `generated_audio_id` 时，原有内联 TTS 流程不受影响。

---

### 三、前端：新增配音列表 hook + 面板

#### `useGeneratedAudios.ts`

- 状态：`generatedAudios[]`、`selectedAudio`、`isGeneratingAudio`、`audioTask`
- 方法：`fetchGeneratedAudios()`、`generateAudio()`、`deleteAudio()`、`renameAudio()`、`selectAudio()`
- 轮询：生成后 1s 轮询 task 状态，完成后自动刷新列表并选中最新配音
- 独立于视频生成的 TaskContext（不互相干扰）

#### `GeneratedAudiosPanel.tsx`

- 每条配音：播放/暂停、名称、时长、重命名、删除
- 选中态：`border-purple-500 bg-purple-500/20`
- 内嵌进度条（生成中显示）
- 底部显示选中配音的原始文案（截断）
- 播放逻辑自包含于面板内（`new Audio()` + play/pause toggle）

---

### 四、前端：UI 面板重排序

**旧顺序**: MaterialSelector → ScriptEditor → TitleSubtitle → VoiceSelector → BgmPanel → GenerateActionBar

**新顺序**:
1. ScriptEditor（文案编辑）
2. TitleSubtitlePanel（标题与字幕样式）
3. VoiceSelector（配音方式）
4. **GeneratedAudiosPanel**（配音列表）← 新增
5. MaterialSelector（视频素材）← 后移，需选中配音才解锁
6. BgmPanel（背景音乐）
7. GenerateActionBar（生成视频）

#### 素材区门控

未选中配音时，素材区显示半透明遮罩 + "请先生成并选中配音"提示。素材上传/预览/改名/删除始终可用，仅选择勾选被遮罩。

#### 时长信息

选中配音后，MaterialSelector 顶部显示：
```
当前配音: 45.2 秒 | 已选 3 个素材（自动均分每段 ~15.1 秒）
```

#### 生成按钮条件更新

```typescript
// 旧条件
disabled={isGenerating || selectedMaterials.length === 0 || (ttsMode === "voiceclone" && !selectedRefAudio)}
// 新条件
disabled={isGenerating || selectedMaterials.length === 0 || !selectedAudio}
```

---

### 五、持久化

`useHomePersistence` 新增 `selectedAudioId` 的 localStorage 读写，刷新页面后恢复选中的配音。

---

### 涉及文件汇总

#### 后端新增

| 文件 | 说明 |
|------|------|
| `backend/app/modules/generated_audios/__init__.py` | 模块标记 |
| `backend/app/modules/generated_audios/router.py` | 5 个 API 端点 |
| `backend/app/modules/generated_audios/service.py` | 生成/列表/删除/改名 |
| `backend/app/modules/generated_audios/schemas.py` | 请求/响应模型 |

#### 后端修改

| 文件 | 变更 |
|------|------|
| `backend/app/main.py` | 注册 generated_audios 路由 |
| `backend/app/services/storage.py` | 新增 `BUCKET_GENERATED_AUDIOS`，启动时自动创建桶 |
| `backend/app/modules/videos/schemas.py` | `GenerateRequest` 新增 `generated_audio_id` 字段 |
| `backend/app/modules/videos/workflow.py` | TTS 阶段新增预生成音频分支 |

#### 前端新增

| 文件 | 说明 |
|------|------|
| `frontend/src/features/home/model/useGeneratedAudios.ts` | 配音列表 hook |
| `frontend/src/features/home/ui/GeneratedAudiosPanel.tsx` | 配音列表面板 |

#### 前端修改

| 文件 | 变更 |
|------|------|
| `frontend/src/features/home/ui/HomePage.tsx` | 面板重排序 + 素材区门控 + 插入 GeneratedAudiosPanel |
| `frontend/src/features/home/ui/MaterialSelector.tsx` | 新增 `selectedAudioDuration` prop + 时长信息显示 |
| `frontend/src/features/home/ui/GenerateActionBar.tsx` | 禁用条件改为 `!selectedAudio` |
| `frontend/src/features/home/model/useHomeController.ts` | 集成 useGeneratedAudios、新增 handleGenerateAudio、修改 handleGenerate 使用 generated_audio_id |
| `frontend/src/features/home/model/useHomePersistence.ts` | 新增 selectedAudioId 持久化 |

---

## 🎞️ 素材时间轴编排 — 第二阶段 (Day 23)

### 概述

在第一阶段"配音前置"基础上，新增**时间轴编辑器**，用户可以：
1. 在音频波形上查看各素材块的时长分配
2. 拖拽分割线调整每段素材的时长（无缝铺满，调整一段自动压缩/扩展相邻段）
3. 为每段素材设置**源视频截取起点**（从视频任意位置开始，而非始终从头）

**旧行为**: 多素材时自动均分（`_split_equal`），无法控制每段时长和源视频起始点
**新行为**: 时间轴编辑器可视化分配 + 拖拽调整 + ClipTrimmer 截取设置

---

### 一、后端改动

#### 1.1 新增 `CustomAssignment` 模型

```python
# backend/app/modules/videos/schemas.py
class CustomAssignment(BaseModel):
    material_path: str
    start: float           # 音频时间轴起点
    end: float             # 音频时间轴终点
    source_start: float = 0.0  # 源视频截取起点
```

`GenerateRequest` 新增 `custom_assignments: Optional[List[CustomAssignment]] = None`。存在时跳过 Whisper 均分，直接使用用户定义的分配。

#### 1.2 `prepare_segment` 支持 `source_start`

```python
def prepare_segment(self, video_path, target_duration, output_path,
                    target_resolution=None, source_start: float = 0.0):
```

关键逻辑：
- `source_start > 0` 时使用 `-ss` 快速 seek，并强制重编码（避免 stream copy 关键帧不精确）
- 当需要循环且有 `source_start` 时，先裁剪出 `source_start` 到视频结尾的片段，再循环裁剪后的文件（避免 `stream_loop` 从视频 0s 开始循环）
- 裁剪临时文件在 `finally` 中自动清理

#### 1.3 `workflow.py` 支持 `custom_assignments`

- **多素材模式**: `custom_assignments` 存在时，直接使用用户分配（仍运行 Whisper 生成字幕），每个 `prepare_segment` 调用传入 `source_start`
- **单素材模式**: `custom_assignments` 有 1 条且 `source_start > 0` 时，先截取片段再传入 LatentSync
- **向后兼容**: `custom_assignments` 为 `None` 时完全走旧路径

---

### 二、前端新增组件

#### 2.1 `useTimelineEditor.ts` — 时间轴段管理 hook

```typescript
interface TimelineSegment {
  id: string;              // React key
  materialId: string;      // 素材 ID
  materialName: string;    // 显示名
  start: number;           // 音频时间轴开始秒数
  end: number;             // 音频时间轴结束秒数
  sourceStart: number;     // 源视频截取起点（默认 0）
  sourceEnd: number;       // 源视频截取终点（0 = 到结尾）
  color: string;           // 色块颜色
}
```

核心方法：
- `initSegments()`: selectedMaterials 变化时按数量均分 audioDuration
- `resizeSegment(id, newEnd)`: 拖拽右边界，约束每段最小 1s
- `setSourceRange(id, sourceStart, sourceEnd)`: 设置截取范围
- `toCustomAssignments()`: 转为后端 `CustomAssignment[]` 格式

#### 2.2 `TimelineEditor.tsx` — 波形 + 色块时间轴

- **wavesurfer.js** 渲染音频波形（仅展示，不播放）
- 色块层按比例排列，显示素材名 + 时长 + 截取标记
- 色块间分割线可拖拽（`onPointerDown/Move/Up` 实现连续像素拖拽）
- 点击色块打开 ClipTrimmer

#### 2.3 `ClipTrimmer.tsx` — 素材截取模态框

- HTML5 `<video>` 实时预览，拖拽滑块时 `video.currentTime` 跟随
- 双端 Range Slider（起点/终点），互锁约束 ≥ 0.5s
- 显示截取时长 vs 分配时长对比（循环补足/截断提示）
- `loadedmetadata` 获取源视频时长

---

### 三、前端整合改动

#### 3.1 `useHomeController.ts`

- 集成 `useTimelineEditor` hook
- 新增 `clipTrimmerOpen` / `clipTrimmerSegmentId` 状态
- `handleGenerate` 多素材时始终发送 `custom_assignments`；单素材 + `sourceStart > 0` 时也发送
- 移除不再使用的 `reorderMaterials` 导出

#### 3.2 `HomePage.tsx`

- 在 MaterialSelector 和 BgmPanel 之间插入 TimelineEditor（仅当有配音且已选素材时显示）
- 底部新增 ClipTrimmer 模态框
- 移除 `reorderMaterials` 和 `selectedAudioDuration` prop 传递

#### 3.3 `MaterialSelector.tsx`

- 移除配音时长信息栏（功能迁至 TimelineEditor）
- 移除拖拽排序区（SortableChip + @dnd-kit 相关代码）
- 移除 `onReorderMaterials` / `selectedAudioDuration` prop

---

### 四、审查修复的 Bug

| # | 严重程度 | 问题 | 修复 |
|---|---------|------|------|
| 1 | **中** | `prepare_segment` 使用 `source_start > 0` + stream copy 时 seek 不精确 | 添加 `source_start > 0` 到重编码条件 |
| 2 | **高** | `stream_loop + source_start` 循环时从视频 0s 开始而非从 source_start 循环 | 改为两步：先裁剪片段再循环裁剪后的文件 |
| 3 | **低** | `useHomeController` 导出已废弃的 `reorderMaterials` | 移除 |

---

### 涉及文件汇总

#### 后端修改

| 文件 | 变更 |
|------|------|
| `backend/app/modules/videos/schemas.py` | 新增 `CustomAssignment` model，`GenerateRequest` 新增 `custom_assignments` 字段 |
| `backend/app/services/video_service.py` | `prepare_segment` 新增 `source_start` 参数，循环+截取两步处理 |
| `backend/app/modules/videos/workflow.py` | 多素材/单素材流水线支持 `custom_assignments`，传递 `source_start` |

#### 前端新增

| 文件 | 说明 |
|------|------|
| `frontend/src/features/home/model/useTimelineEditor.ts` | 时间轴段管理 hook |
| `frontend/src/features/home/ui/TimelineEditor.tsx` | 波形 + 色块时间轴组件 |
| `frontend/src/features/home/ui/ClipTrimmer.tsx` | 素材截取模态框 |

#### 前端修改

| 文件 | 变更 |
|------|------|
| `frontend/src/features/home/ui/HomePage.tsx` | 插入 TimelineEditor + ClipTrimmer |
| `frontend/src/features/home/ui/MaterialSelector.tsx` | 移除时长信息 + 拖拽排序区 + 相关 prop |
| `frontend/src/features/home/model/useHomeController.ts` | 集成 useTimelineEditor，handleGenerate 发送 custom_assignments |
| `frontend/package.json` | 新增 `wavesurfer.js` 依赖 |

---

## 🎨 UI 体验优化 + TTS 稳定性修复 — 第三阶段 (Day 23)

### 概述

根据用户反馈，修复 6 项 UI 体验问题，同时修复声音克隆服务的 SoX 路径问题和显存缓存管理。

> **注**: Qwen3-TTS 已在后续被 CosyVoice 3.0 (端口 8010) 替换，以下记录为当时的修复过程。

---

### 一、Qwen3-TTS 稳定性修复 (已被 CosyVoice 3.0 替换)

#### 1.1 SoX PATH 修复

**问题**: PM2 启动 qwen-tts 时，`sox` 工具安装在 conda env 的 bin 目录中，系统 PATH 找不到，导致音频编解码走 fallback 路径（CPU 密集型），日志中出现 `SoX could not be found!` 警告。

**修复**: `run_qwen_tts.sh` 中 export conda env bin 到 PATH：

```bash
export PATH="/home/rongye/ProgramFiles/miniconda3/envs/qwen-tts/bin:$PATH"
```

#### 1.2 CUDA 缓存清理

**修复**: `qwen_tts_server.py` 每次生成完成后（无论成功或失败）调用 `torch.cuda.empty_cache()`，防止显存碎片累积。使用 `asyncio.to_thread()` 在线程池中运行推理，避免阻塞事件循环导致健康检查超时。

> **后续**: Qwen3-TTS 已停用，CosyVoice 3.0 沿用了相同的保护机制（GPU 推理锁、超时保护、显存清理、启动自检）。

---

### 二、配音列表按钮布局统一 (反馈 #1 + #6)

**问题**: `GeneratedAudiosPanel` 的试听按钮位于左侧（独立于 Edit/Delete），与 `RefAudioPanel` 的布局不一致。底部文案摘要区域不需要展示。

**修复**:
- Play/Edit/Delete 按钮统一放在右侧同组，hover 显示，顺序为 试听→重命名→删除
- 移除选中配音的文案摘要区域
- 布局与 RefAudioPanel 一致：左侧名称+时长，右侧操作按钮组

---

### 三、视频素材区域移除配音依赖遮罩 (反馈 #2)

**问题**: MaterialSelector 被 `!selectedAudio` 遮罩覆盖，必须先选配音才能操作素材。

**修复**: 移除 `HomePage.tsx` 中 MaterialSelector 外层的 disabled overlay `<div>`。素材随时可上传/预览/管理，仅 TimelineEditor 需要选中配音才显示（已有独立条件 `selectedAudio && selectedMaterials.length > 0`）。

---

### 四、时间轴拖拽排序 (反馈 #3)

**问题**: TimelineEditor 不支持调换素材顺序。

**修复**:
- `useTimelineEditor` 已有 `reorderSegments()` 方法（交换两个段的素材信息但保留时间范围）
- 通过 `useHomeController` 暴露 `reorderSegments`，传入 `TimelineEditor`
- 色块支持 HTML5 Drag & Drop：`draggable` + `onDragStart/Over/Drop/End`
- 拖拽时：源色块半透明（`opacity-50`），目标色块高亮 ring（`ring-2 ring-purple-400 scale-[1.02]`）
- 光标样式：`cursor-grab` / `active:cursor-grabbing`

---

### 五、截取设置双手柄 Range Slider (反馈 #4)

**问题**: ClipTrimmer 使用两个独立的 `<input type="range">` 滑块，起点和终点分开操作，体验不直观。

**修复**: 改为自定义双手柄 range slider：
- 单条轨道，紫色圆形手柄（起点）+ 粉色圆形手柄（终点）
- 轨道底色 `bg-white/10`，选中范围用素材对应颜色高亮
- Pointer Events 实现拖拽：`onPointerDown` 捕获手柄 → `onPointerMove` 更新位置 → `onPointerUp` 释放
- 手柄互锁约束：起点不超过终点 - 0.5s，终点不低于起点 + 0.5s
- 底部显示起点（紫色）和终点（粉色）时间标签

---

### 六、截取设置视频预览 (反馈 #5)

**问题**: ClipTrimmer 的视频只能静态查看，无法播放预览截取范围。

**修复**:
- 视频区域点击可播放/暂停（Play/Pause 图标覆盖层）
- 播放范围：从 sourceStart 播放到 sourceEnd 自动停止
- 播放结束后回到起点
- 拖拽手柄时 `video.currentTime` 实时跟随（seek 到当前位置查看画面）
- 播放进度条（白色竖线）叠加在 range slider 轨道上
- `preload="auto"` 预加载视频，确保拖拽时快速 seek

---

### 涉及文件汇总

#### 后端修改

| 文件 | 变更 |
|------|------|
| `run_qwen_tts.sh` | export conda env bin 到 PATH，修复 SoX 找不到问题 (已停用) |
| `models/Qwen3-TTS/qwen_tts_server.py` | 每次生成后 `torch.cuda.empty_cache()`，asyncio.to_thread 避免阻塞 (已停用) |

#### 前端修改

| 文件 | 变更 |
|------|------|
| `frontend/src/features/home/ui/GeneratedAudiosPanel.tsx` | 按钮布局统一（Play/Edit/Delete 右侧同组），移除文案摘要 |
| `frontend/src/features/home/ui/HomePage.tsx` | 移除 MaterialSelector 配音遮罩，传入 onReorderSegment |
| `frontend/src/features/home/ui/TimelineEditor.tsx` | 新增 HTML5 Drag & Drop 排序，新增 onReorderSegment prop |
| `frontend/src/features/home/ui/ClipTrimmer.tsx` | 双手柄 range slider + 视频播放预览 + 播放进度指示 |
| `frontend/src/features/home/model/useHomeController.ts` | 暴露 reorderSegments 方法 |

---

## 📝 历史文案保存 + 时间轴拖拽修复 — 第四阶段 (Day 23)

### 概述

新增文案手动保存与加载功能，修复时间轴拖拽排序后素材时长不跟随的 Bug，统一按钮视觉规范。

---

### 一、历史文案保存与加载

#### 功能

用户可手动保存当前文案到历史列表，随时从历史中加载恢复。只有手动保存的文案才出现在历史列表中，与自动保存（`useHomePersistence`）完全独立。

#### UI 布局

```
按钮栏: [历史文案▼] [文案提取助手] [AI多语言▼] [AI生成标题标签]
底部栏: 128 字                                    [保存文案]
```

- **历史文案下拉**: 展示已保存列表（名称 + 日期 + 删除按钮），点击条目加载文案，空列表显示"暂无保存的文案"
- **保存文案按钮**: 文案为空时 disabled，点击后 `toast.success("文案已保存")`
- **预计时长已移除**: 底部栏只保留字数 + 保存按钮

#### 实现

##### `useSavedScripts.ts`（新建）

```typescript
interface SavedScript { id: string; name: string; content: string; savedAt: number }
```

- localStorage key: `vigent_{storageKey}_savedScripts`
- `saveScript(content)`: 取前 15 字符自动命名，新条目插入列表头部，**直接写入 localStorage**
- `deleteScript(id)`: 删除指定条目，直接写入 localStorage
- `useEffect([lsKey])`: lsKey 变化时（guest → userId）重新从 localStorage 读取
- **不使用自动持久化 effect**，避免 storageKey 切换时空数组覆盖已有数据

##### 数据流

```
ScriptEditor (UI)
  ↑ savedScripts / onSaveScript / onLoadScript / onDeleteScript (纯 props + callbacks)
  │
useHomeController
  ├── useSavedScripts(storageKey) → { savedScripts, saveScript, deleteScript }
  └── handleSaveScript() → saveScript(text) + toast
  │
HomePage
  └── 传递 props 到 ScriptEditor
```

---

### 二、时间轴拖拽排序 Bug 修复

#### 问题

拖拽调换素材顺序后，各素材的时长没有跟随素材移动，而是留在原槽位。例如：素材1(3s) + 素材2(8s+4s循环)，拖拽后变成素材2(3s) + 素材1(8s+4s循环)，时长分配没变。

#### 根因

`reorderSegments` 使用**属性交换**方式：逐个拷贝 `materialId`、`sourceStart`、`sourceEnd` 等属性在两个槽位间交换，然后调用 `recalcPositions` 重算位置。

#### 修复

改为**数组移动**（splice）：将整个 segment 对象从旧位置取出，插入到新位置。segment 对象携带全部属性（materialId、sourceStart、sourceEnd、color 等）作为一个整体移动，再由 `recalcPositions` 重算位置。

```typescript
// 修复前：属性交换
const fromMat = { materialId: next[fromIdx].materialId, ... };
const toMat = { materialId: next[toIdx].materialId, ... };
next[fromIdx] = { ...next[fromIdx], ...toMat };
next[toIdx] = { ...next[toIdx], ...fromMat };

// 修复后：数组移动
const [moved] = next.splice(fromIdx, 1);
next.splice(toIdx, 0, moved);
```

附带优势：3+ 素材拖拽行为从"交换"变为"插入"，更符合用户直觉。

---

### 三、按钮视觉统一

#### 问题

历史文案、文案提取助手、AI多语言、AI生成标题标签 4 个按钮高度不一致，AI 按钮的文本被 `<span>` 嵌套包裹导致内部布局差异。

#### 修复

- 4 个按钮统一为 `h-7 px-2.5 text-xs rounded inline-flex items-center gap-1`（固定高度 28px）
- 移除 AI多语言 / AI生成标题标签 按钮内多余的 `<span>` 嵌套，改为 `<>...</>` fragment

---

### 涉及文件汇总

#### 前端新增

| 文件 | 说明 |
|------|------|
| `frontend/src/features/home/model/useSavedScripts.ts` | 历史文案 hook（localStorage 持久化） |

#### 前端修改

| 文件 | 变更 |
|------|------|
| `frontend/src/features/home/ui/ScriptEditor.tsx` | 历史文案下拉 + 保存按钮 + 移除预计时长 + 按钮高度统一 |
| `frontend/src/features/home/model/useHomeController.ts` | 集成 useSavedScripts，新增 handleSaveScript |
| `frontend/src/features/home/ui/HomePage.tsx` | 传递 savedScripts / handleSaveScript / deleteSavedScript 到 ScriptEditor |
| `frontend/src/features/home/model/useTimelineEditor.ts` | reorderSegments 从属性交换改为数组移动（splice） |

---

## 🔤 字幕语言不匹配 + 视频比例错位修复 — 第五阶段 (Day 23)

### 概述

修复两个视频生成 Bug：
1. **字幕语言不匹配**: 中文配音 + 英文翻译文案 → 字幕错误显示英文（Whisper 独立转录，忽略原文）
2. **标题字幕比例错位**: 9:16 竖屏素材生成视频后，标题/字幕按 16:9 横屏布局渲染

附带修复代码审查中发现的 `split_word_to_chars` 英文空格丢失问题。

---

### 一、字幕用原文替换 Whisper 转录文字

#### 根因

Whisper 对音频独立转录，完全忽略传入的 `text` 参数。当配音语言与编辑器文案语言不一致时（例如：用户先写中文文案 → 翻译成英文 → 生成英文配音 → 再改回中文文案），Whisper "听到"英文语音就输出英文字幕。

#### 修复思路

Whisper 仅负责检测**语音总时间范围**（`first_start` → `last_end`），字幕文字永远用配音保存的原始文案。

#### `whisper_service.py` — `align()` 新增 `original_text` 参数

```python
async def align(self, audio_path, text, output_path=None,
                language="zh", original_text=None):
```

当 `original_text` 非空时：
1. 正常运行 Whisper 转录，记录 `whisper_first_start` 和 `whisper_last_end`
2. 将 `original_text` 传入 `split_word_to_chars()` 在总时间范围上线性分布
3. 用 `split_segment_to_lines()` 按标点和字数断行
4. 替换 Whisper 的转录结果

#### `workflow.py` — 配音元数据无条件覆盖 + 传入原文

```python
# 改前（只在文案为空时覆盖）
if not req.text.strip():
    req.text = meta.get("text", req.text)

# 改后（无条件用配音元数据覆盖）
meta_text = meta.get("text", "")
if meta_text:
    req.text = meta_text
```

所有 4 处 `whisper_service.align()` 调用添加 `original_text=req.text`。

---

### 二、Remotion 动态传入视频尺寸

#### 根因

`remotion/src/Root.tsx` 硬编码 `width={1280} height={720}`。虽然 `render.ts` 用 ffprobe 检测真实尺寸后覆盖 `composition.width/height`，但 `selectComposition` 阶段组件已按 1280×720 初始化，标题和字幕定位基于错误的画布尺寸。

#### 修复

##### `Root.tsx` — `calculateMetadata` 从 props 读取尺寸

```tsx
<Composition
  id="ViGentVideo"
  component={Video}
  durationInFrames={300}
  fps={25}
  width={1080}
  height={1920}
  calculateMetadata={async ({ props }) => ({
    width: props.width || 1080,
    height: props.height || 1920,
  })}
  defaultProps={{
    videoSrc: '',
    width: 1080,
    height: 1920,
    // ...
  }}
/>
```

默认从 1280×720 改为 1080×1920（竖屏优先），`calculateMetadata` 确保 `selectComposition` 阶段使用 ffprobe 检测的真实尺寸。

##### `Video.tsx` — VideoProps 新增可选 `width/height`

仅供 `calculateMetadata` 访问，组件渲染不引用。

##### `render.ts` — inputProps 统一传入视频尺寸

```typescript
const inputProps = {
  videoSrc: videoFileName,
  captions,
  title: options.title,
  // ...
  width: videoWidth,     // ffprobe 检测值
  height: videoHeight,   // ffprobe 检测值
};
```

`selectComposition` 和 `renderMedia` 使用同一个 `inputProps`。保留显式 `composition.width/height` 覆盖作为保险。

---

### 三、代码审查修复：英文空格丢失

#### 问题

`split_word_to_chars` 原设计处理 Whisper 单个词（如 `" Hello"`），但 `original_text` 传入整段文本时，中间空格被 `continue` 跳过且不 flush `ascii_buffer`，导致 `"Hello World"` 变成 `"HelloWorld"`。

#### 执行路径追踪

```
输入: "Hello World"
  H,e,l,l,o → ascii_buffer = "Hello"
  ' '       → continue（跳过，不 flush！）
  W,o,r,l,d → ascii_buffer = "HelloWorld"
结果: tokens = ["HelloWorld"]  ← 空格丢失
```

#### 修复

遇到空格时 flush `ascii_buffer`，并用 `pending_space` 标记给下一个 token 前置空格：

```python
if not char.strip():
    if ascii_buffer:
        tokens.append(ascii_buffer)
        ascii_buffer = ""
    if tokens:
        pending_space = True
    continue
```

修复后：`"Hello World"` → tokens = `["Hello", " World"]` → 字幕正确显示。中文不受影响。

---

### 涉及文件汇总

#### 后端修改

| 文件 | 变更 |
|------|------|
| `backend/app/services/whisper_service.py` | `align()` 新增 `original_text` 参数；`split_word_to_chars` 修复英文空格丢失 |
| `backend/app/modules/videos/workflow.py` | 配音元数据无条件覆盖 text/language；4 处 `align()` 调用传入 `original_text` |

#### 前端修改（Remotion）

| 文件 | 变更 |
|------|------|
| `remotion/src/Root.tsx` | 默认尺寸改为 1080×1920，新增 `calculateMetadata` + width/height defaultProps |
| `remotion/src/Video.tsx` | VideoProps 新增可选 `width`/`height` |
| `remotion/render.ts` | inputProps 统一传入 `videoWidth`/`videoHeight`，selectComposition 和 renderMedia 共用 |

---

## 🎤 参考音频自动转写 + 语速控制 — 第六阶段 (Day 23)

### 概述

解决声音克隆 ref_text 不匹配问题：旧方案使用前端固定文字作为 ref_text，CosyVoice zero-shot 克隆要求 ref_text 必须与参考音频实际内容匹配，不匹配时模型会在生成音频开头"幻觉"出多余片段。

**改进**：上传参考音频时自动调用 Whisper 转写内容作为 ref_text，同时新增语速控制功能。

---

### 一、Whisper 自动转写参考音频

#### 1.1 `whisper_service.py` — 语言自动检测

`transcribe()` 方法原先硬编码 `language="zh"`，改为接受可选 `language` 参数（默认 `None` = 自动检测），支持多语言参考音频。

#### 1.2 `ref_audios/service.py` — 上传时自动转写

上传流程变更：转码 WAV → 检查时长(≥1s) → 超 10s 在静音点截取 → **Whisper 自动转写** → 验证非空 → 上传。

```python
try:
    transcribed = await whisper_service.transcribe(tmp_wav_path)
    if transcribed.strip():
        ref_text = transcribed.strip()
except Exception as e:
    logger.warning(f"Auto-transcribe failed: {e}")

if not ref_text or not ref_text.strip():
    raise ValueError("无法识别音频内容，请确保音频包含清晰的语音")
```

#### 1.3 `ref_audios/router.py` — ref_text 改为可选

`ref_text: str = Form("")`（不再必填），前端不再发送固定文字。

---

### 二、参考音频智能截取（10 秒上限）

CosyVoice 对 3-10 秒参考音频效果最好。

#### 2.1 静音点检测

使用 ffmpeg `silencedetect` 找 10 秒内最后一个静音结束点（阈值 -30dB，最短 0.3s），避免在字词中间硬切：

```python
def _find_silence_cut_point(file_path, max_duration):
    # silencedetect → 解析 silence_end → 找 3s~max_duration 内最后的静音点
    # 找不到则回退到 max_duration
```

#### 2.2 淡出处理

截取时末尾 0.1 秒淡出（`afade=t=out`），避免截断爆音。

---

### 三、重新识别功能（旧数据迁移）

#### 3.1 新增 API

`POST /api/ref-audios/{audio_id}/retranscribe` — 下载音频 → 超 10s 截取 → Whisper 转写 → 重新上传音频和元数据。

#### 3.2 前端 UI

- RefAudioPanel 新增 RotateCw 按钮（"重新识别文字"），转写中显示 `animate-spin`
- 旧音频 ref_text 以固定文字开头时显示 ⚠ 黄色警告

---

### 四、语速控制（CosyVoice speed 参数）

#### 4.1 全链路传递

```
前端 GeneratedAudiosPanel (速度选择器)
  → useHomeController (speed state + persistence)
  → useGeneratedAudios.generateAudio(params)
  → POST /api/generated-audios/generate { speed: 1.0 }
  → GenerateAudioRequest.speed (Pydantic)
  → generate_audio_task → voice_clone_service.generate_audio(speed=)
  → _generate_once → POST /generate { speed: "1.0" }
  → cosyvoice_server → _model.inference_zero_shot(speed=speed)
```

#### 4.2 前端 UI

声音克隆模式下，配音列表面板标题栏"生成配音"按钮左侧显示语速下拉菜单（`语速: 正常 ▼`）：

| 标签 | speed 值 |
|------|----------|
| 较慢 | 0.8 |
| 稍慢 | 0.9 |
| 正常 | 1.0 (默认) |
| 稍快 | 1.1 |
| 较快 | 1.2 |

语速选择持久化到 localStorage（`vigent_{storageKey}_speed`）。

---

### 五、缺少参考音频门控

声音克隆模式下未选参考音频时：
- "生成配音"按钮禁用 + title 提示"请先选择参考音频"
- 面板内显示黄色警告条"声音克隆模式需要先选择参考音频"

---

### 六、前端清理

- 移除 `FIXED_REF_TEXT` 常量和 `fixedRefText` prop
- 移除"请朗读以下内容"引导区块
- 上传提示简化为"上传任意语音样本（3-10秒），系统将自动识别内容并克隆声音"
- 录音区备注"建议 3-10 秒，超出将自动截取"

---

### 涉及文件汇总

#### 后端修改

| 文件 | 变更 |
|------|------|
| `backend/app/services/whisper_service.py` | `transcribe()` 增加可选 `language` 参数，默认 None (自动检测) |
| `backend/app/modules/ref_audios/service.py` | 上传自动转写 + 静音点截取 + 淡出 + retranscribe 函数 |
| `backend/app/modules/ref_audios/router.py` | `ref_text` 改为 Form("")，新增 retranscribe 端点 |
| `backend/app/modules/generated_audios/schemas.py` | `GenerateAudioRequest` 新增 `speed: float = 1.0` |
| `backend/app/modules/generated_audios/service.py` | 传递 `req.speed` 到 voice_clone_service |
| `backend/app/services/voice_clone_service.py` | `generate_audio()` / `_generate_once()` 接受并传递 speed |
| `models/CosyVoice/cosyvoice_server.py` | `/generate` 端点接受 `speed` 参数，传递到 `inference_zero_shot(speed=)` |

#### 前端修改

| 文件 | 变更 |
|------|------|
| `frontend/src/features/home/model/useHomeController.ts` | 新增 speed state，移除 FIXED_REF_TEXT，handleGenerateAudio 传 speed |
| `frontend/src/features/home/model/useHomePersistence.ts` | 新增 speed 持久化 |
| `frontend/src/features/home/model/useRefAudios.ts` | 移除 fixedRefText，新增 retranscribe |
| `frontend/src/features/home/model/useGeneratedAudios.ts` | generateAudio params 新增 speed |
| `frontend/src/features/home/ui/GeneratedAudiosPanel.tsx` | 新增语速选择器 + 缺少参考音频门控 |
| `frontend/src/features/home/ui/RefAudioPanel.tsx` | 移除朗读引导，新增重新识别按钮 + ⚠ 警告 |
| `frontend/src/features/home/ui/HomePage.tsx` | 传递 speed/setSpeed/ttsMode 到 GeneratedAudiosPanel |
