## 🐛 缺陷修复：视频生成与持久化回归 (Day 21)

### 概述
本日修复 Day 20 优化后引入的 3 个回归缺陷：Remotion 渲染崩溃容错、首页作品选择持久化、发布页作品选择持久化。

---

### 已完成修复

#### BUG-1: Remotion 渲染进程崩溃导致标题/字幕丢失
- **现象**: 视频生成后没有标题和字幕，回退到纯 FFmpeg 合成。
- **根因**: Remotion Node.js 进程在渲染完成（100%）后以 SIGABRT (code -6) 退出，Python 端将其视为失败。
- **修复**: `remotion_service.py` 在进程非零退出时，先检查输出文件是否存在且大小合理（>1KB），若存在则视为成功。
- **文件**: `backend/app/services/remotion_service.py`

```python
if process.returncode != 0:
    output_file = Path(output_path)
    if output_file.exists() and output_file.stat().st_size > 1024:
        logger.warning(
            f"Remotion process exited with code {process.returncode}, "
            f"but output file exists ({output_file.stat().st_size} bytes). Treating as success."
        )
        return output_path
    raise RuntimeError(...)
```

#### BUG-2: 首页历史作品选择刷新后不保持
- **现象**: 用户选择某个历史作品后刷新页面，总是回到第一个视频。
- **根因**: `fetchGeneratedVideos()` 在初始加载时无条件自动选中第一个视频，覆盖了 `useHomePersistence` 的恢复值。
- **修复**: `fetchGeneratedVideos` 增加 `preferVideoId` 参数，仅在明确指定时才自动选中；新增 `"__latest__"` 哨兵值用于生成完成后选中最新。
- **文件**: `frontend/src/features/home/model/useGeneratedVideos.ts`, `frontend/src/features/home/model/useHomeController.ts`

```typescript
// 任务完成 → 自动选中最新
useEffect(() => {
  if (prevIsGenerating.current && !isGenerating) {
    if (currentTask?.status === "completed") {
      void fetchGeneratedVideos("__latest__");
    } else {
      void fetchGeneratedVideos();
    }
  }
  prevIsGenerating.current = isGenerating;
}, [isGenerating, currentTask, fetchGeneratedVideos]);
```

#### BUG-3: 发布页作品选择刷新后不保持（根因：签名 URL 不稳定）
- **现象**: 发布管理页选择视频后刷新，选择丢失（无任何视频被选中）。
- **根因**: 后端 `/api/videos/generated` 返回的 `path` 是 Supabase 签名 URL，每次请求都会变化。发布页用 `path` 作为选择标识存入 localStorage，刷新后新的 `path` 与保存值永远不匹配。首页不受影响是因为使用稳定的 `video.id`。
- **修复**: 发布页全面改用 `id`（稳定标识）替代 `path`（签名 URL）进行选择、持久化和比较。
- **文件**:
  - `frontend/src/shared/types/publish.ts` — `PublishVideo` 新增 `id` 字段
  - `frontend/src/features/publish/model/usePublishController.ts` — `selectedVideo` 存储 `id`，发布时根据 `id` 查找 `path`
  - `frontend/src/features/publish/ui/PublishPage.tsx` — `key`/`onClick`/选中比较改用 `v.id`
  - `frontend/src/features/home/model/useHomeController.ts` — 预取缓存加入 `id` 字段

```typescript
// 类型定义新增 id
export interface PublishVideo {
    id: string;    // 稳定标识符
    name: string;
    path: string;  // 签名 URL（仅用于播放/发布）
}

// 发布时根据 id 查找 path
const video = videos.find(v => v.id === selectedVideo);
await api.post('/api/publish', { video_path: video.path, ... });
```

---

### 涉及文件汇总

| 文件 | 变更 |
|------|------|
| `backend/app/services/remotion_service.py` | Remotion 崩溃容错 |
| `frontend/src/features/home/model/useGeneratedVideos.ts` | 首页视频选择不自动覆盖 |
| `frontend/src/features/home/model/useHomeController.ts` | 任务完成监听 + 预取缓存加 id |
| `frontend/src/shared/types/publish.ts` | PublishVideo 新增 id 字段 |
| `frontend/src/features/publish/model/usePublishController.ts` | 选择/持久化/发布改用 id |
| `frontend/src/features/publish/ui/PublishPage.tsx` | UI 选择比较改用 id |

### 关键教训

> **签名 URL 不可作为持久化标识**。Supabase Storage 的签名 URL 包含时间戳和签名参数，每次请求都不同。任何需要跨请求/跨刷新保持的标识，必须使用后端返回的稳定 `id` 字段。

### 重启要求
```bash
pm2 restart vigent2-backend    # Remotion 容错
npm run build && pm2 restart vigent2-frontend  # 前端持久化修复
```

---

## 🎨 浮动样式预览窗口优化 (Day 21)

### 概述
标题与字幕面板中的预览区域原本是内联折叠的，展开后调节下方滑块时看不到预览效果。改为 `position: fixed` 浮动窗口，固定在视口左上角，滚动页面时预览始终可见，边调边看。

### 已完成优化

#### 1. 新建浮动预览组件 `FloatingStylePreview.tsx`
- `createPortal(jsx, document.body)` 渲染到 body 层级，脱离面板 DOM 树
- `position: fixed` + 左上角固定定位，滚动时不移动
- `z-index: 150`（低于 VideoPreviewModal 的 200）
- 顶部标题栏 + X 关闭按钮，ESC 键关闭
- 桌面端固定宽度 280px，移动端自适应（最大 360px）
- `previewScale = windowWidth / previewBaseWidth` 自行计算缩放
- `maxHeight: calc(100dvh - 32px)` 防止超出视口

#### 2. 修改 `TitleSubtitlePanel.tsx`
- 删除内联预览区域（`ref={previewContainerRef}` 整块 JSX）
- 条件渲染 `<FloatingStylePreview />`，按钮文本保持"预览样式"/"收起预览"
- 移除 `previewScale`、`previewAspectRatio`、`previewContainerRef` props
- 保留 `previewBaseWidth/Height`（浮动窗口需要原始尺寸计算 scale）

#### 3. 清理 `useHomeController.ts`
- 移除 `previewContainerWidth` 状态
- 移除 `titlePreviewContainerRef` ref
- 移除 ResizeObserver useEffect（浮动窗口自管尺寸，不再需要）

#### 4. 简化 `HomePage.tsx` 传参
- 移除 `previewContainerWidth`、`titlePreviewContainerRef` 解构
- 移除 `previewScale`、`previewAspectRatio`、`previewContainerRef` prop 传递

#### 5. 移动端适配
- `ScriptEditor.tsx`：标题行改为 `flex-wrap`，"AI生成标题标签"按钮不再溢出
- 预览默认比例从 1280×720 (16:9) 改为 1080×1920 (9:16)，符合抖音竖屏视频

### 涉及文件汇总

| 文件 | 变更 |
|------|------|
| `frontend/src/features/home/ui/FloatingStylePreview.tsx` | **新建** 浮动预览组件 |
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | 移除内联预览，渲染浮动组件 |
| `frontend/src/features/home/model/useHomeController.ts` | 移除 preview 容器相关状态和 ResizeObserver |
| `frontend/src/features/home/ui/HomePage.tsx` | 简化 props 传递，默认比例改 9:16 |
| `frontend/src/features/home/ui/ScriptEditor.tsx` | 移动端按钮换行适配 |

### 重启要求
```bash
npm run build && pm2 restart vigent2-frontend
```

---

## 🔧 多平台发布体系重构：用户隔离与抖音刷脸验证 (Day 21)

### 概述
重构发布系统的两大核心问题：① 多用户场景下 Cookie/会话缺乏隔离，② 抖音登录新增刷脸验证步骤无法处理。同时修复了平台配置混用和微信视频号发布流程问题。

---

### 一、平台配置独立化

#### 问题
所有平台（抖音、微信、B站、小红书）共用 WEIXIN_* 配置，导致 User-Agent、Headless 模式等设置不匹配。

#### 修复 — `config.py`
- 新增 `DOUYIN_*` 独立配置项：`DOUYIN_HEADLESS_MODE`、`DOUYIN_USER_AGENT`（Chrome/144）、`DOUYIN_LOCALE`、`DOUYIN_TIMEZONE_ID`、`DOUYIN_CHROME_PATH`、`DOUYIN_FORCE_SWIFTSHADER`、调试开关等
- 微信保持已有 `WEIXIN_*` 配置
- B站/小红书使用通用默认值

#### 修复 — `qr_login_service.py` 平台配置映射
```python
# 之前：所有平台都用 WEIXIN 设置
# 之后：每个平台独立配置
PLATFORM_CONFIGS = {
    "douyin": { headless, user_agent, locale, timezone... },
    "weixin": { headless, user_agent, locale, timezone... },
    "bilibili": { 通用配置 },
    "xiaohongshu": { 通用配置 },
}
```

---

### 二、用户隔离的 Cookie 管理

#### 问题
多用户共享同一套 Cookie 文件，用户 A 的登录态可能被用户 B 覆盖。

#### 修复 — `publish_service.py`
- `_get_cookies_dir(user_id)` → `backend/user_data/{uuid}/cookies/`
- `_get_cookie_path(user_id, platform)` → 按用户+平台返回独立 Cookie 文件路径
- `_get_session_key(user_id, platform)` → `"{user_id}_{platform}"` 格式的会话 key
- 登录/发布流程全链路传入 `user_id`，清理残留会话避免干扰

---

### 三、抖音刷脸验证二维码

#### 问题
抖音扫码登录后可能弹出刷脸验证窗口，内含新的二维码需要用户再次扫描，前端无法感知和展示。

#### 修复 — 后端 `qr_login_service.py`
- 扩展 QR 选择器：支持跨 iframe 搜索二维码元素
- 抖音 API 拦截：监听 `check_qrconnect` 响应，检测 `redirect_url`
- 检测 "完成验证" / "请前往APP完成验证" 文案
- 在验证弹窗内找到正方形二维码（排除头像），截图返回给前端
- API 确认后直接导航到 redirect_url（不重新加载 QR 页，避免销毁会话）

#### 修复 — 后端 `publish_service.py`
- `get_login_session_status()` 新增 `face_verify_qr` 字段返回
- 登录成功且 Cookie 保存后自动清理会话

#### 修复 — 前端
- `usePublishController.ts`：新增 `faceVerifyQr` 状态，轮询时获取 `face_verify_qr` 字段
- `PublishPage.tsx`：QR 弹窗优先展示刷脸验证二维码，附提示文案

```tsx
{faceVerifyQr ? (
  <>
    <Image src={`data:image/png;base64,${faceVerifyQr}`} />
    <p>需要身份验证，请用抖音APP扫描上方二维码完成刷脸验证</p>
  </>
) : /* 普通登录二维码 */ }
```

---

### 四、微信视频号发布流程优化

#### 修复 — `weixin_uploader.py`
- 添加 `user_id` 参数支持，发布截图目录隔离
- 新增 `post_create` API 响应监听，精准判断发布成功
- 发布结果判定：URL 离开创建页 或 API 确认提交 → 视为成功
- 标题/标签处理改为统一写入"视频描述"字段（不再单独填写 title/tags）

---

### 涉及文件汇总

| 文件 | 变更 |
|------|------|
| `backend/app/core/config.py` | 新增 DOUYIN_* 独立配置项 |
| `backend/app/services/qr_login_service.py` | 平台配置拆分、刷脸验证二维码、跨 iframe 选择器 |
| `backend/app/services/publish_service.py` | 用户隔离 Cookie 管理、刷脸验证状态返回 |
| `backend/app/services/uploader/weixin_uploader.py` | user_id 支持、post_create API 监听、描述字段合并 |
| `frontend/src/features/publish/model/usePublishController.ts` | faceVerifyQr 状态 |
| `frontend/src/features/publish/ui/PublishPage.tsx` | 刷脸验证二维码展示 |

### 重启要求
```bash
pm2 restart vigent2-backend    # 发布服务 + QR登录
npm run build && pm2 restart vigent2-frontend  # 刷脸验证UI
```

---

## 🏗️ 架构优化：前端结构微调 + 后端模块分层 (Day 21)

### 概述
根据架构审计结果，完成前端目录规范化和后端核心模块的分层补全。

### 一、前端结构微调

#### 1. ScriptExtractionModal 迁移
- `components/ScriptExtractionModal.tsx` → `features/home/ui/ScriptExtractionModal.tsx`
- 连带 `components/script-extraction/` 目录一并迁移到 `features/home/ui/script-extraction/`
- 更新 `HomePage.tsx` 的 import 路径

#### 2. contexts/ 目录归并
- `src/contexts/AuthContext.tsx` → `src/shared/contexts/AuthContext.tsx`
- `src/contexts/TaskContext.tsx` → `src/shared/contexts/TaskContext.tsx`
- 更新 6 处 import（layout.tsx, useHomeController.ts, usePublishController.ts, AccountSettingsDropdown.tsx, GlobalTaskIndicator.tsx）
- 删除空的 `src/contexts/` 目录

#### 3. 清理重构遗留空目录
- 删除 `src/lib/`、`src/components/home/`、`src/hooks/`

### 二、后端模块分层补全

将 3 个 400+ 行的 router-only 模块拆分为 `router.py + schemas.py + service.py`：

| 模块 | 改造前 | 改造后 router |
|------|--------|--------------|
| `materials/` | 416 行 | 63 行 |
| `tools/` | 417 行 | 33 行 |
| `ref_audios/` | 421 行 | 71 行 |

业务逻辑全部提取到 `service.py`，数据模型定义在 `schemas.py`，router 只做参数校验 + 调用 service + 返回响应。

### 三、开发规范更新

`BACKEND_DEV.md` 第 8 节新增渐进原则：
- 新模块**必须**包含 `router.py + schemas.py + service.py`
- 改旧模块时顺手拆涉及的部分
- 新代码高标准，旧代码逐步改

### 涉及文件汇总

| 文件 | 变更 |
|------|------|
| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | 从 components/ 迁入 |
| `frontend/src/features/home/ui/script-extraction/` | 从 components/ 迁入 |
| `frontend/src/shared/contexts/AuthContext.tsx` | 从 contexts/ 迁入 |
| `frontend/src/shared/contexts/TaskContext.tsx` | 从 contexts/ 迁入 |
| `backend/app/modules/materials/schemas.py` | **新建** |
| `backend/app/modules/materials/service.py` | **新建** |
| `backend/app/modules/materials/router.py` | 精简为薄路由 |
| `backend/app/modules/tools/schemas.py` | **新建** |
| `backend/app/modules/tools/service.py` | **新建** |
| `backend/app/modules/tools/router.py` | 精简为薄路由 |
| `backend/app/modules/ref_audios/schemas.py` | **新建** |
| `backend/app/modules/ref_audios/service.py` | **新建** |
| `backend/app/modules/ref_audios/router.py` | 精简为薄路由 |
| `Docs/BACKEND_DEV.md` | 目录结构标注分层、新增渐进原则 |
| `Docs/BACKEND_README.md` | 目录结构标注分层 |
| `Docs/FRONTEND_DEV.md` | 更新目录结构（contexts 迁移、ScriptExtractionModal 迁移） |

### 重启要求
```bash
pm2 restart vigent2-backend
npm run build && pm2 restart vigent2-frontend
```

---

## 🎬 多素材视频生成（多机位效果）

### 概述
支持用户上传多个不同角度的自拍视频，生成视频时按句子自动切换素材，最终效果类似多机位拍摄。单素材时走原有流程，无额外开销。

### 核心架构

#### 流水线变更
```
【单素材（不变）】
text → TTS → audio → LatentSync(1个素材+完整audio) → Whisper字幕 → Remotion → 成片

【多素材（新增）】
text → TTS → audio → Whisper字幕(提前) → 按素材数量均分时长(对齐字边界)
  → 对每段: 切分audio + LatentSync(素材[i]+音频片段[i])
  → FFmpeg拼接所有片段 → Remotion(完整字幕时间戳) → 成片
```

#### 素材切换逻辑（均分方案）
1. Whisper 对完整音频转录，得到字级别时间戳
2. 按素材数量**均分音频总时长**（`total_duration / N`）
3. 每个分割点对齐到最近的 Whisper 字边界，避免在字中间切分
4. 首段 start 扩展为 0.0，末段 end 扩展为音频结尾，确保完整覆盖

> **设计决策**：最初方案基于原始文案标点分句，但用户文案往往不含句号（只有逗号），导致只产生 1 段。改为均分方案后不依赖文案标点，对任何输入都能正确切分。

---

### 一、后端改动

#### 1. `backend/app/modules/videos/schemas.py`
- 新增 `material_paths: Optional[List[str]]` 字段
- 保留 `material_path: str` 向后兼容

#### 2. `backend/app/modules/videos/workflow.py`（核心改动）

**新增函数**：
- `_split_equal(segments, material_paths)`: 按素材数量均分音频时长，对齐到最近的 Whisper 字边界

**修改 `process_video_generation()`**：
- `is_multi = len(material_paths) > 1` 判断走多素材/单素材分支
- 多素材分支：Whisper 提前 → 均分切分 → 音频切分 → 逐段 LatentSync → FFmpeg 拼接

#### 3. `backend/app/services/video_service.py`
- 新增 `concat_videos()`: FFmpeg concat demuxer (`-c copy`) 拼接视频片段
- 新增 `split_audio()`: FFmpeg 按时间范围切分音频 (`-ss` + `-t` + `-c copy`)

#### 4. `backend/scripts/watchdog.py`
- 健康检查阈值从 3 次提高到 5 次（容忍期 2.5 分钟）
- 新增重启后 120 秒冷却期，避免模型加载期间被误判为故障
- 启动时给所有服务 60 秒初始冷却期

---

### 二、前端改动

#### 1. 新增依赖
```bash
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
```

#### 2. `frontend/src/features/home/model/useMaterials.ts`
- `selectedMaterial: string` → `selectedMaterials: string[]`（多选）
- 新增 `toggleMaterial(id)`: 切换选中/取消（至少保留1个）
- 新增 `reorderMaterials(activeId, overId)`: 拖拽排序
- 上传格式扩展：新增 `.mkv/.webm/.flv/.wmv/.m4v/.ts/.mts`

#### 3. `frontend/src/features/home/ui/MaterialSelector.tsx`（重写）
- 素材列表每行增加复选框 + 序号徽标（①②③）
- 选中 ≥2 个时显示拖拽排序区（@dnd-kit `SortableContext`）
- 每个排序项：拖拽把手 + 序号 + 素材名 + 移除按钮
- HTML input accept 改为 `video/*`

#### 4. `frontend/src/features/home/model/useHomeController.ts`
- 多素材 payload：`material_paths` 数组 + `material_path` 向后兼容
- `enable_subtitles` 硬编码为 `true`（移除开关）
- 验证：至少选中 1 个素材

#### 5. `frontend/src/features/home/model/useHomePersistence.ts`
- 素材持久化改为 JSON 数组，向后兼容旧格式（单字符串）
- 移除 `enableSubtitles` 持久化

#### 6. `frontend/src/features/home/ui/TitleSubtitlePanel.tsx`
- 移除"逐字高亮字幕"开关，字幕样式区始终显示

#### 7. `frontend/src/features/home/ui/HomePage.tsx`
- 更新 props 传递（`selectedMaterials`, `toggleMaterial`, `reorderMaterials`）

---

### 三、Bug 修复记录

#### BUG-1: 多素材只使用第一个视频（基于标点的分句方案失败）
- **现象**: 选了 2 个素材但生成的视频只使用第 1 个，日志显示 `Multi-material: 1 segments, 2 materials`。
- **根因 v1**: 最初通过正则 `[。！？!?]` 在 Whisper 输出中分句，但 Whisper 不输出标点。
- **修复 v1**: 改为用原始文案标点分句——但用户文案往往只含逗号（，），无句末标点（。！？），仍退化为 1 段。
- **最终修复**: 彻底放弃基于标点的分句方案，改为 `_split_equal()` **按素材数量均分音频时长**，对齐到最近的 Whisper 字边界。不依赖任何标点符号，对所有文案均有效。

#### BUG-2: 口型对不上（音频时间偏移）
- **根因**: `split_audio` 用 Whisper 的 start/end 时间（如 0.11~7.21）切分音频，但 `compose()` 用完整原始音频（0.0~结尾）合成，导致时间偏移。
- **修复**: 强制首段 start=0.0，末段 end=音频实际时长，确保切分音频完整覆盖。

#### BUG-3: min_segment_sec 过度合并导致退化（已随方案切换移除）
- **根因**: 旧方案中 2 个句子第 2 句不足 3 秒时，最短时长检查合并为 1 段，多素材退化为单素材。
- **状态**: 均分方案不存在此问题，相关代码已移除。

---

### 涉及文件汇总

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/modules/videos/schemas.py` | 修改 | 新增 material_paths 字段 |
| `backend/app/modules/videos/workflow.py` | 修改 | 多素材流水线核心逻辑 + 3个 Bug 修复 |
| `backend/app/services/video_service.py` | 修改 | 新增 concat_videos / split_audio |
| `backend/scripts/watchdog.py` | 修改 | 阈值优化 + 冷却期机制 |
| `frontend/package.json` | 修改 | 新增 @dnd-kit 依赖 |
| `frontend/src/features/home/model/useMaterials.ts` | 修改 | 多选 + 排序状态管理 |
| `frontend/src/features/home/ui/MaterialSelector.tsx` | 重写 | 多选复选框 + 拖拽排序 UI |
| `frontend/src/features/home/model/useHomeController.ts` | 修改 | 多素材 payload + 移除字幕开关 |
| `frontend/src/features/home/model/useHomePersistence.ts` | 修改 | JSON 数组持久化 |
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | 修改 | 移除字幕开关 |
| `frontend/src/features/home/ui/HomePage.tsx` | 修改 | 更新 props 传递 |

### 重启要求
```bash
pm2 restart vigent2-backend
npm run build && pm2 restart vigent2-frontend
```
