## 🔧 文案提取助手修复 — 抖音链接无法提取文案 (Day 25)

### 概述

文案提取助手粘贴抖音链接后无法提取文案，yt-dlp 报错 `Fresh cookies are needed`，手动回退方案也因抖音页面结构变化失效。本日完成了完整修复，并清理了不再需要的 `DOUYIN_COOKIE` 配置。

---

## 🐛 问题诊断

### 错误链路

1. **yt-dlp 失败**：`ERROR: [Douyin] Fresh cookies (not necessarily logged in) are needed`
   - yt-dlp 版本 `2025.12.08` 过旧
   - 抖音 API `aweme/v1/web/aweme/detail/` 需要签名 cookie（`s_v_web_id` 等），即使升级 yt-dlp 到最新版 + 传入 cookie 仍无法解决，属 yt-dlp 已知问题
2. **手动回退失败**：`Could not find RENDER_DATA in page`
   - 旧方案通过桌面端用户主页 + `modal_id` 访问，抖音 SSR 已不再返回 `videoDetail` 数据
3. **`.env` 中 `DOUYIN_COOKIE`**：时间戳 2024 年 12 月，早已过期

---

## ✅ 修复方案：移动端分享页 + 自动获取 ttwid

### 核心思路

放弃依赖 yt-dlp 下载抖音视频和手动维护 cookie，改为：

1. 自动从 ByteDance 公共 API 获取新鲜 `ttwid`（匿名令牌，不绑定账号）
2. 用 `ttwid` 访问移动端分享页 `m.douyin.com/share/video/{id}`
3. 从页面内嵌 JSON 中提取 `play_addr` 播放地址并下载

### 关键代码（`_download_douyin_manual` 重写）

```python
# 1. 获取新鲜 ttwid
ttwid_resp = await client.post(
    "https://ttwid.bytedance.com/ttwid/union/register/",
    json={"region": "cn", "aid": 6383, "service": "www.douyin.com", ...}
)
ttwid = ttwid_resp.cookies.get("ttwid", "")

# 2. 访问移动端分享页
page_resp = await client.get(
    f"https://m.douyin.com/share/video/{video_id}",
    headers={"cookie": f"ttwid={ttwid}", ...}
)

# 3. 提取 play_addr
addr_match = re.search(r'"play_addr":\{"uri":"([^"]+)","url_list":\["([^"]+)"', page_text)
video_url = addr_match.group(2).replace(r"\u002F", "/")
```

### 优势

- 不再依赖手动维护的 `DOUYIN_COOKIE`，ttwid 每次请求自动获取
- 不受 yt-dlp 对抖音支持状况影响
- 所有用户通用，不绑定特定账号

---

## 🧹 清理 DOUYIN_COOKIE 配置

`DOUYIN_COOKIE` 仅用于文案提取，新方案不再需要，已从以下位置删除：

| 文件 | 变更 |
|------|------|
| `backend/.env` | 删除 `DOUYIN_COOKIE` 配置项及注释 |
| `backend/app/core/config.py` | 删除 `DOUYIN_COOKIE: str = ""` 字段定义 |
| `backend/app/modules/tools/service.py` | 删除 yt-dlp 传 cookie 逻辑和 `_write_netscape_cookies` 辅助函数 |

---

## 🔤 前端文案修正

将文案提取界面中的"AI 洗稿结果"改为"AI 改写结果"。

| 文件 | 变更 |
|------|------|
| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | `AI 洗稿结果` → `AI 改写结果` |
| `backend/app/modules/tools/service.py` | 注释中"洗稿"→"改写" |
| `backend/app/services/glm_service.py` | docstring 中"洗稿"→"改写文案" |

---

## 📦 其他变更

- **yt-dlp 升级**：`2025.12.08` → `2026.2.21`
- **yt-dlp 初始化修正**：改为 `YoutubeDL(ydl_opts)` 直接传参初始化（原先空初始化后 update params 不生效）
- **User-Agent 更新**：yt-dlp 中 `Chrome/91` → `Chrome/131`

---

## 涉及文件汇总

### 后端修改

| 文件 | 变更 |
|------|------|
| `backend/app/modules/tools/service.py` | 重写 `_download_douyin_manual`（移动端分享页方案）；修正 yt-dlp 初始化；清理 cookie 相关代码；注释改写 |
| `backend/app/services/glm_service.py` | docstring "洗稿" → "改写文案" |
| `backend/app/core/config.py` | 删除 `DOUYIN_COOKIE` 字段 |
| `backend/.env` | 删除 `DOUYIN_COOKIE` 配置 |
| `backend/requirements.txt` | yt-dlp 版本升级 |

### 前端修改

| 文件 | 变更 |
|------|------|
| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | "AI 洗稿结果" → "AI 改写结果" |

---

## ✏️ AI 智能改写 — 自定义提示词功能

### 概述

文案提取助手的"AI 智能改写"原先使用硬编码 prompt，用户无法定制改写风格。本次在 checkbox 右侧新增"自定义提示词"折叠区域，用户可编辑自定义 prompt，持久化到 localStorage，后端按需替换默认 prompt。

### 后端修改

**路由层** (`router.py`)：`extract_script_tool` 新增可选 Form 参数 `custom_prompt: Optional[str] = Form(None)`，透传给 service。

**服务层** (`service.py`)：`extract_script()` 签名新增 `custom_prompt`，透传给 `glm_service.rewrite_script(script, custom_prompt)`。

**AI 层** (`glm_service.py`)：`rewrite_script(self, text, custom_prompt=None)`，若 `custom_prompt` 有值则用自定义 prompt + 原文拼接，否则保持原有默认 prompt。

```python
if custom_prompt and custom_prompt.strip():
    prompt = f"""{custom_prompt.strip()}

原始文案：
{text}"""
else:
    prompt = f"""请将以下视频文案进行改写。...（原有默认）"""
```

### 前端修改

**Hook** (`useScriptExtraction.ts`)：
- 新增 `customPrompt` / `showCustomPrompt` 状态
- 初始值从 `localStorage.getItem("vigent_rewriteCustomPrompt")` 恢复
- `customPrompt` 变化时防抖 300ms 保存到 localStorage
- `handleExtract()` 中若 `doRewrite && customPrompt.trim()` 有值，追加 `formData.append("custom_prompt", ...)`
- modal 重置时不清空 customPrompt（持久化偏好）

**UI** (`ScriptExtractionModal.tsx`)：
- checkbox 同行右侧新增"自定义提示词 ▼"按钮（仅 `doRewrite` 时显示）
- 点击展开 textarea 编辑区域，底部提示"留空则使用默认提示词"
- 取消勾选 AI 智能改写时，自定义提示词区域自动隐藏

### 涉及文件

| 文件 | 变更 |
|------|------|
| `backend/app/modules/tools/router.py` | 新增 `custom_prompt` Form 参数 |
| `backend/app/modules/tools/service.py` | `extract_script()` 透传 `custom_prompt` |
| `backend/app/services/glm_service.py` | `rewrite_script()` 支持自定义 prompt |
| `frontend/.../useScriptExtraction.ts` | 新增状态、localStorage 持久化、FormData 传参 |
| `frontend/.../ScriptExtractionModal.tsx` | UI 按钮 + 展开 textarea |

### 验证

- 后端 `python -m py_compile` 三个文件通过
- 前端 `npx tsc --noEmit` 通过

---

## 🐛 SSR 构建修复 — localStorage is not defined

### 问题

`npm run build` 报错 `ReferenceError: localStorage is not defined`，因为 `useScriptExtraction.ts` 中 `useState` 的初始化函数在 SSR（Node.js）环境下也会执行，而服务端没有 `localStorage`。

### 修复

`useState` 初始化加 `typeof window !== "undefined"` 守卫：

```typescript
const [customPrompt, setCustomPrompt] = useState(
    () => typeof window !== "undefined" ? localStorage.getItem(CUSTOM_PROMPT_KEY) || "" : ""
);
```

| 文件 | 变更 |
|------|------|
| `frontend/.../useScriptExtraction.ts` | `useState` 初始化增加 SSR 安全守卫 |

---

## 🎬 片头副标题功能

### 概述

新增片头副标题（secondary_title），显示在主标题下方，用于补充说明或悬念引导。副标题有独立的样式配置（字体、字号、颜色等），可由 AI 同时生成，20 字限制，仅在视频画面中显示，不参与发布标题。

命名约定：后端 `secondary_title`（snake_case），前端 `videoSecondaryTitle`（camelCase），用户界面"片头副标题"。

---

### 后端修改

| 文件 | 变更 |
|------|------|
| `backend/app/modules/videos/schemas.py` | `GenerateRequest` 新增 4 个可选字段：`secondary_title`、`secondary_title_style_id`、`secondary_title_font_size`、`secondary_title_top_margin` |
| `backend/app/services/glm_service.py` | AI prompt 增加副标题生成要求（不超过20字），JSON 格式新增 `secondary_title` 字段 |
| `backend/app/modules/ai/router.py` | `GenerateMetaResponse` 增加 `secondary_title: str = ""`，endpoint 返回时取 `result.get("secondary_title", "")` |
| `backend/app/modules/videos/workflow.py` | `use_remotion` 条件增加 `or req.secondary_title`；副标题样式解析复用 `get_style("title", ...)`；字号/间距覆盖；`prepare_style_for_remotion` 处理副标题字体；`remotion_service.render()` 传入 `secondary_title` + `secondary_title_style` |
| `backend/app/services/remotion_service.py` | `render()` 新增 `secondary_title` 和 `secondary_title_style` 参数，构建 CLI 参数 `--secondaryTitle` 和 `--secondaryTitleStyle` |

### Remotion 修改

| 文件 | 变更 |
|------|------|
| `remotion/render.ts` | `RenderOptions` 新增 `secondaryTitle?` + `secondaryTitleStyle?`；`parseArgs()` 新增 switch case；`inputProps` 新增两个字段 |
| `remotion/src/components/Title.tsx` | `TitleProps` 新增 `secondaryTitle?` 和 `secondaryTitleStyle?`；`AbsoluteFill` 改为 `flexDirection: 'column'` 垂直堆叠；主标题 `<h1>` 后增加副标题 `<h2>`，独立样式（默认字号 48px、字重 700），共享淡入淡出动画；副标题字体使用独立 `@font-face`（`SecondaryTitleFont`）避免与主标题冲突 |
| `remotion/src/Video.tsx` | `VideoProps` 新增 `secondaryTitle?` + `secondaryTitleStyle?`；传递给 `<Title>` 组件；渲染条件改为 `{(title \|\| secondaryTitle) && ...}` |
| `remotion/src/Root.tsx` | `defaultProps` 新增 `secondaryTitle: undefined` + `secondaryTitleStyle: undefined` |

### 前端修改

| 文件 | 变更 |
|------|------|
| `frontend/src/shared/lib/title.ts` | 新增 `SECONDARY_TITLE_MAX_LENGTH = 20` 和 `clampSecondaryTitle()` |
| `frontend/src/features/home/model/useHomeController.ts` | 新增状态 `videoSecondaryTitle`、`selectedSecondaryTitleStyleId`、`secondaryTitleFontSize`、`secondaryTitleTopMargin`、`secondaryTitleSizeLocked`；新建 `secondaryTitleInput = useTitleInput({ maxLength: 20 })`（不 sync 到发布页）；`handleGenerateMeta()` 接收并填充 `secondary_title`；`handleGenerate()` 构建 payload 增加副标题字段；return 暴露所有新状态 |
| `frontend/src/features/home/model/useHomePersistence.ts` | 新增 localStorage key：`secondaryTitle`、`secondaryTitleStyle`、`secondaryTitleFontSize`、`secondaryTitleTopMargin`；对应的恢复和保存 effect |
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | Props 新增副标题相关；主标题输入框下方添加"片头副标题（限制20个字）"输入框；副标题样式选择器（复用 titleStyles 预设）、字号滑块（30-100px）、间距滑块（0-100px） |
| `frontend/src/features/home/ui/FloatingStylePreview.tsx` | 标题预览改为 flex column 布局；主标题下方增加副标题预览行，独立样式渲染 |
| `frontend/src/features/home/ui/HomePage.tsx` | 从 `useHomeController` 解构新状态，传给 `TitleSubtitlePanel` |

---

## 🐛 参考音频上传 — 中文文件名 InvalidKey 修复

### 问题

上传中文名参考音频（如"我的声音.wav"）时，Supabase Storage 报 `InvalidKey`，因为存储路径直接使用了原始中文文件名。

### 修复

在 `ref_audios/service.py` 新增 `sanitize_filename()` 函数，将存储路径的文件名清洗为 ASCII 安全字符（仅 `A-Za-z0-9._-`）：

- NFKD 规范化 → 丢弃非 ASCII → 非法字符替换为 `_`
- 纯中文/emoji 清洗后为空时，使用 MD5 哈希兜底（如 `audio_e924b1193007`）
- 文件名限长 50 字符
- 原始中文文件名保留在 metadata 中作为展示名，前端显示不受影响

```
修复前: cbbe.../1771915755_我的声音.wav  → InvalidKey
修复后: cbbe.../1771915755_audio_xxxxxxxx.wav → 上传成功
```

| 文件 | 变更 |
|------|------|
| `backend/app/modules/ref_audios/service.py` | 新增 `sanitize_filename()` 函数，上传路径使用清洗后文件名 |
