[
  {
    "path": ".dockerignore",
    "content": "# Git\n.git\n.gitignore\n.gitattributes\n\n# IDE\n.idea\n.vscode\n*.swp\n*.swo\n*~\n\n# OS\n.DS_Store\nThumbs.db\n\n# 构建产物\ndist/\nbuild/\n*.exe\n*.dll\n*.so\n*.dylib\nhuobao-drama\nbackend\n\n# 依赖\nnode_modules/\n**/node_modules/\nvendor/\n\n# 数据文件\ndata/\n*.db\n*.log\n\n# 临时文件\ntmp/\ntemp/\n\n# 前端构建缓存\nweb/.vite/\nweb/dist/\n\n# 测试\n*.test\ncoverage/\n\n# 文档\nREADME.md\ndrama.png\n*.md\n\n# Docker\nDockerfile*\ndocker-compose*.yml\n.dockerignore\n\n# CI/CD\n.github/\n.gitlab-ci.yml\n"
  },
  {
    "path": ".gitignore",
    "content": "# Binaries\nbin/\ndrama-generator\nbackend\n*.exe\n*.dll\n*.so\n*.dylib\ndrama-generator.exe\n\n# Test binary\n*.test\n\n# Output of the go coverage tool\n*.out\n\n# Dependency directories\nvendor/\n\n# Go workspace file\ngo.work\n\n# IDE\n.idea/\n.vscode/\n*.swp\n*.swo\n*~\n\n# Environment\n.env\n.env.local\n\n# Logs\n*.log\n\n# OS\n.DS_Store\nThumbs.db\n\n# Build\ndist/\nbuild/\n\n# Temporary files\ntmp/\ntemp/\n\n# Data (database and uploaded files)\ndata/drama_generator.db\ndata/storage/videos/*\n!data/storage/videos/.gitkeep\n\n# Frontend build output\nweb/dist/\nweb/node_modules/\nweb/.vite/\nweb/.env.local\n\n# Config file (use config.example.yaml as template)\nconfigs/config.yaml\n\n# Docker publish documentation (optional)\nDOCKER_PUBLISH.md\nbuild.sh\n/data/storage/\n/web/package-lock.json\n.DS_Store"
  },
  {
    "path": "DOCKER_HOST_ACCESS.md",
    "content": "# Docker 容器访问宿主机服务指南\n\n## 核心配置\n\nDocker 容器内使用 `http://host.docker.internal:端口号` 访问宿主机服务。\n\n### macOS / Windows\n\n直接使用，无需额外配置。\n\n### Linux\n\n**docker-compose** - 已在 `docker-compose.yml` 配置：\n```yaml\nextra_hosts:\n  - \"host.docker.internal:host-gateway\"\n```\n\n**docker run** - 需添加参数：\n```bash\ndocker run --add-host=host.docker.internal:host-gateway ...\n```\n\n## Ollama 配置示例\n\n### 1. 宿主机启动服务\n\n```bash\n# 监听所有接口（重要）\nexport OLLAMA_HOST=0.0.0.0:11434\nollama serve\n```\n\n### 2. 前端 AI 服务配置\n\n| 字段 | 值 |\n|------|-----|\n| Base URL | `http://host.docker.internal:11434/v1` |\n| Provider | `openai` |\n| Model | `qwen2.5:latest` |\n| API Key | `ollama` 或留空 |\n\n### 3. 其他服务端口\n\n| 服务 | 默认端口 | Base URL |\n|------|---------|----------|\n| Ollama | 11434 | `http://host.docker.internal:11434/v1` |\n| LM Studio | 1234 | `http://host.docker.internal:1234/v1` |\n| vLLM | 8000 | `http://host.docker.internal:8000/v1` |\n\n## 验证和故障排查\n\n### 测试连接\n\n```bash\n# 进入容器测试\ndocker exec -it huobao-drama sh\nwget -O- http://host.docker.internal:11434/api/tags\n\n# 查看容器日志\ndocker logs huobao-drama -f\n```\n\n### 常见问题\n\n**Connection refused**\n\n1. **宿主机服务未运行** - 检查服务状态\n   ```bash\n   curl http://localhost:11434/api/tags\n   ```\n\n2. **服务未监听 0.0.0.0** - Ollama 默认只监听 127.0.0.1\n   ```bash\n   export OLLAMA_HOST=0.0.0.0:11434\n   ollama serve\n   ```\n\n3. **防火墙阻止** - 检查防火墙规则或临时关闭测试\n"
  },
  {
    "path": "Dockerfile",
    "content": "# 多阶段构建 Dockerfile for Huobao Drama\n\n# ==================== 阶段1: 构建前端 ====================\n# 声明构建参数（支持镜像源配置）\nARG DOCKER_REGISTRY=\nARG NPM_REGISTRY=\n\nFROM ${DOCKER_REGISTRY:-}node:20-alpine AS frontend-builder\n\n# 重新声明 ARG（FROM 之后 ARG 作用域失效，需要重新声明）\nARG NPM_REGISTRY=\n\n# 配置 npm 镜像源（条件执行）\nENV NPM_REGISTRY=${NPM_REGISTRY:-}\nRUN if [ -n \"$NPM_REGISTRY\" ]; then \\\n    npm config set registry \"$NPM_REGISTRY\" || true; \\\n    fi\n\nWORKDIR /app/web\n\n# 复制前端依赖文件\nCOPY web/package*.json ./\n\n# 安装前端依赖（包括 devDependencies，构建需要）\nRUN npm install\n\n# 复制前端源码\nCOPY web/ ./\n\n# 构建前端\nRUN npm run build\n\n# ==================== 阶段2: 构建后端 ====================\n# 每个阶段前重新声明构建参数\nARG DOCKER_REGISTRY=\nARG GO_PROXY=\nARG ALPINE_MIRROR=\n\nFROM ${DOCKER_REGISTRY:-}golang:1.23-alpine AS backend-builder\n\n# 重新声明 ARG（FROM 之后 ARG 作用域失效，需要重新声明）\nARG GO_PROXY=\nARG ALPINE_MIRROR=\n\n# 配置 Alpine 镜像源（条件执行）\nENV ALPINE_MIRROR=${ALPINE_MIRROR:-}\nRUN if [ -n \"$ALPINE_MIRROR\" ]; then \\\n    sed -i \"s@dl-cdn.alpinelinux.org@$ALPINE_MIRROR@g\" /etc/apk/repositories 2>/dev/null || true; \\\n    fi\n\n# 配置 Go 代理（使用 ENV 持久化到运行时）\nENV GOPROXY=${GO_PROXY:-https://goproxy.cn,direct}\nENV GO111MODULE=on\n\n# 安装必要的构建工具（纯 Go 编译，无需 CGO）\nRUN apk add --no-cache \\\n    git \\\n    ca-certificates \\\n    tzdata\n\nWORKDIR /app\n\n# 复制 Go 模块文件\nCOPY go.mod go.sum ./\n\n# 下载依赖\nRUN go mod download\n\n# 复制后端源码\nCOPY . .\n\n# 复制前端构建产物\nCOPY --from=frontend-builder /app/web/dist ./web/dist\n\n# 构建后端可执行文件（纯 Go 编译，使用 modernc.org/sqlite）\nRUN CGO_ENABLED=0 go build -ldflags=\"-w -s\" -o huobao-drama .\n\n# 构建迁移脚本可执行文件\nRUN CGO_ENABLED=0 go build -ldflags=\"-w -s\" -o migrate cmd/migrate/main.go\n\n# ==================== 阶段3: 运行时镜像 ====================\n# 每个阶段前重新声明构建参数\nARG DOCKER_REGISTRY=\nARG ALPINE_MIRROR=\n\nFROM ${DOCKER_REGISTRY:-}alpine:latest\n\n# 重新声明 ARG（FROM 之后 ARG 作用域失效，需要重新声明）\nARG ALPINE_MIRROR=\n\n# 配置 Alpine 镜像源（条件执行）\nENV ALPINE_MIRROR=${ALPINE_MIRROR:-}\nRUN if [ -n \"$ALPINE_MIRROR\" ]; then \\\n    sed -i \"s@dl-cdn.alpinelinux.org@$ALPINE_MIRROR@g\" /etc/apk/repositories 2>/dev/null || true; \\\n    fi\n\n# 安装运行时依赖\nRUN apk add --no-cache \\\n    ca-certificates \\\n    tzdata \\\n    ffmpeg \\\n    wget \\\n    && rm -rf /var/cache/apk/*\n\n# 设置时区\nENV TZ=Asia/Shanghai\n\nWORKDIR /app\n\n# 从构建阶段复制可执行文件\nCOPY --from=backend-builder /app/huobao-drama .\nCOPY --from=backend-builder /app/migrate .\n\n# 复制前端构建产物\nCOPY --from=frontend-builder /app/web/dist ./web/dist\n\n# 复制配置文件模板并创建默认配置\nCOPY configs/config.example.yaml ./configs/\nRUN cp ./configs/config.example.yaml ./configs/config.yaml\n\n# 复制数据库迁移文件\nCOPY migrations ./migrations/\n\n# 创建数据目录（root 用户运行，无需权限设置）\nRUN mkdir -p /app/data/storage\n\n# 暴露端口\nEXPOSE 5678\n\n# 健康检查\nHEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\\n    CMD wget --no-verbose --tries=1 --spider http://localhost:5678/health || exit 1\n\n# 启动应用\nCMD [\"./huobao-drama\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License\n\nCopyright (c) 2026 火宝 (Chatfire)\n\n本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。\n\nThis work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.\n\n要查看该许可协议，可访问:\nTo view a copy of this license, visit:\nhttps://creativecommons.org/licenses/by-nc-sa/4.0/\n\n或者写信到:\nOr send a letter to:\nCreative Commons, PO Box 1866, Mountain View, CA 94042, USA.\n\n---\n\n个人使用许可 (Personal Use License)\n\n您可以自由地:\nYou are free to:\n- 分享 — 在任何媒介以任何形式复制、发行本作品\n  Share — copy and redistribute the material in any medium or format\n- 演绎 — 修改、转换或以本作品为基础进行创作\n  Adapt — remix, transform, and build upon the material\n\n惟须遵守下列条件:\nUnder the following terms:\n- 署名 — 您必须给出适当的署名，提供指向本许可协议的链接\n  Attribution — You must give appropriate credit and provide a link to the license\n- 非商业性使用 — 您不得将本作品用于商业目的\n  NonCommercial — You may not use the material for commercial purposes\n- 相同方式共享 — 如果您再混合、转换或者基于本作品进行创作，您必须基于与原先许可协议相同的许可协议分发您贡献的作品\n  ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license\n\n---\n\n商业授权 (Commercial License)\n\n如需将本项目用于商业目的，请联系作者获取商业授权:\nFor commercial use, please contact the author for a commercial license:\n\nEmail: 18550175439@163.com\nWeChat: dangbao1117\nGitHub: https://github.com/chatfire-AI\n\n---\n\n免责声明 (Disclaimer)\n\n本软件按\"原样\"提供，不提供任何形式的明示或暗示担保。\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND.\n"
  },
  {
    "path": "MIGRATE_README.md",
    "content": "# 数据清洗工具使用说明\n\n## 使用方法\n\n### 本地部署\n\n```bash\n# 在项目根目录执行\ngo run cmd/migrate/main.go\n```\n\n### Docker 部署\n\n在 Docker 容器中，迁移脚本已经被编译为可执行文件 `migrate`。\n\n```bash\n# 进入容器\ndocker exec -it huobao-drama sh\n\n# 在容器内执行迁移脚本\n./migrate\n\n# 执行完成后，退出容器\nexit\n```\n\n或者直接执行（不进入容器）：\n\n```bash\ndocker exec huobao-drama ./migrate\n```\n\n## 配置要求\n\n脚本会自动读取项目配置文件，确保以下配置正确：\n\n- 数据库连接信息（`config/config.yaml` 或环境变量）\n- 存储目录：`data/storage`（自动创建）\n\n## 输出示例\n\n```\n=== 数据清洗工具：迁移 local_path ===\n开始时间: 2026-01-27 14:30:00\n\nINFO  初始化日志系统...\nINFO  配置加载成功\nINFO  数据库连接成功\nINFO  开始数据清洗：迁移 local_path 为空的数据\nINFO  存储目录创建成功  root=data/storage\n\nINFO  开始迁移 assets 数据...\nINFO  找到需要迁移的 assets  数量=5\nINFO  处理 asset  id=1 name=背景图 type=image url=https://...\nINFO  开始下载文件  url=https://... filepath=data/storage/images/asset_1_1738048200.jpg\nINFO  文件下载成功  filepath=data/storage/images/asset_1_1738048200.jpg size=245678\nINFO  已缓存 URL 映射  url=https://... local_path=images/asset_1_1738048200.jpg\nINFO  asset 迁移成功  asset_id=1 local_path=images/asset_1_1738048200.jpg\n\nINFO  开始迁移 character_libraries 数据...\nINFO  找到需要迁移的 character_libraries  数量=3\nINFO  使用缓存的本地路径  url=https://... local_path=characters/charlib_2_1738048201.jpg\n\nINFO  开始迁移 image_generations 数据...\nINFO  找到需要迁移的 image_generations  数量=10\nINFO  处理 image_generation  id=15 image_type=character image_url=https://...\nINFO  image_generation 迁移成功  imggen_id=15 local_path=characters/imggen_15_1738048205.jpg\n\nINFO  数据清洗完成\n      总耗时=25.5s\n      URL映射缓存数=8\n      Assets成功=5 Assets失败=0\n      角色库成功=3 角色库失败=0\n      角色成功=4 角色失败=0\n      图片生成成功=10 图片生成失败=0\n      场景成功=6 场景失败=0\n      视频成功=2 视频失败=0\n\n=== 数据清洗完成 ===\n结束时间: 2026-01-27 14:30:25\n```\n\n## 注意事项\n\n1. **运行前确保**：\n   - 数据库可访问\n   - 有足够的磁盘空间\n   - 网络连接正常\n\n2. **安全提示**：\n   - 脚本会修改数据库中的 `local_path` 字段\n   - 建议先在测试环境运行\n   - 可以多次运行，已处理的数据会自动跳过\n\n3. **性能优化**：\n   - URL 缓存机制避免重复下载\n   - 下载失败会跳过，不影响其他数据\n   - 超时时间设置为 60 秒\n\n## 常见问题\n\n### Q: 脚本可以重复运行吗？\n\nA: 可以。脚本只处理 `local_path` 为空的记录，已处理的数据会自动跳过。\n\n### Q: 下载失败怎么办？\n\nA: 单个文件下载失败会记录错误日志并继续处理其他文件。可以查看日志定位问题后重新运行。\n\n### Q: 如何查看详细日志？\n\nA: 日志会实时输出到控制台，包含每个文件的处理状态和最终统计信息。\n\n### Q: 存储路径可以修改吗？\n\nA: 可以。修改脚本中的 `storageRoot` 变量（默认为 `data/storage`）。\n\n## 技术支持\n\n如有问题，请查看日志输出或联系开发团队。\n"
  },
  {
    "path": "README-CN.md",
    "content": "# 🎬 Huobao Drama - AI 短剧生成平台\n\n<div align=\"center\">\n\n**基于 Go + Vue3 的全栈 AI 短剧自动化生产平台**\n\n[![Go Version](https://img.shields.io/badge/Go-1.23+-00ADD8?style=flat&logo=go)](https://golang.org)\n[![Vue Version](https://img.shields.io/badge/Vue-3.x-4FC08D?style=flat&logo=vue.js)](https://vuejs.org)\n[![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/)\n\n[功能特性](#功能特性) • [快速开始](#快速开始) • [部署指南](#部署指南)\n\n[简体中文](README-CN.md) | [English](README.md) | [日本語](README-JA.md)\n\n</div>\n\n---\n\n## 📖 项目简介\n\nHuobao Drama 是一个基于 AI 的短剧自动化生产平台，实现从剧本生成、角色设计、分镜制作到视频合成的全流程自动化。\n\n火宝短剧商业版地址：[火宝短剧商业版](https://drama.chatfire.site/shortvideo)\n\n火宝小说生成：[火宝小说生成](https://marketing.chatfire.site/huobao-novel/)\n\n### 🎯 核心价值\n\n- **🤖 AI 驱动**：使用大语言模型解析剧本，提取角色、场景和分镜信息\n- **🎨 智能创作**：AI 绘图生成角色形象和场景背景\n- **📹 视频生成**：基于文生视频和图生视频模型自动生成分镜视频\n- **🔄 工作流**：完整的短剧制作工作流，从创意到成片一站式完成\n\n### 🛠️ 技术架构\n\n采用**DDD 领域驱动设计**，清晰分层：\n\n```\n├── API层 (Gin HTTP)\n├── 应用服务层 (Business Logic)\n├── 领域层 (Domain Models)\n└── 基础设施层 (Database, External Services)\n```\n\n### 🎥 作品展示 / Demo Videos\n\n体验 AI 短剧生成效果：\n\n<div align=\"center\">\n\n**示例作品 1**\n\n<video src=\"https://ffile.chatfire.site/cf/public/20260114094337396.mp4\" controls width=\"640\"></video>\n\n**示例作品 2**\n\n<video src=\"https://ffile.chatfire.site/cf/public/fcede75e8aeafe22031dbf78f86285b8.mp4\" controls width=\"640\"></video>\n\n[点击观看视频 1](https://ffile.chatfire.site/cf/public/20260114094337396.mp4) | [点击观看视频 2](https://ffile.chatfire.site/cf/public/fcede75e8aeafe22031dbf78f86285b8.mp4)\n\n</div>\n\n---\n\n## ✨ 功能特性\n\n### 🎭 角色管理\n\n- ✅ AI 生成角色形象\n- ✅ 批量角色生成\n- ✅ 角色图片上传和管理\n\n### 🎬 分镜制作\n\n- ✅ 自动生成分镜脚本\n- ✅ 场景描述和镜头设计\n- ✅ 分镜图片生成（文生图）\n- ✅ 帧类型选择（首帧/关键帧/尾帧/分镜板）\n\n### 🎥 视频生成\n\n- ✅ 图生视频自动生成\n- ✅ 视频合成和剪辑\n- ✅ 转场效果\n\n### 📦 资源管理\n\n- ✅ 素材库统一管理\n- ✅ 本地存储支持\n- ✅ 资源导入导出\n- ✅ 任务进度追踪\n\n---\n\n## 🚀 快速开始\n\n### 📋 环境要求\n\n| 软件        | 版本要求 | 说明                 |\n| ----------- | -------- | -------------------- |\n| **Go**      | 1.23+    | 后端运行环境         |\n| **Node.js** | 18+      | 前端构建环境         |\n| **npm**     | 9+       | 包管理工具           |\n| **FFmpeg**  | 4.0+     | 视频处理（**必需**） |\n| **SQLite**  | 3.x      | 数据库（已内置）     |\n\n#### 安装 FFmpeg\n\n**macOS:**\n\n```bash\nbrew install ffmpeg\n```\n\n**Ubuntu/Debian:**\n\n```bash\nsudo apt update\nsudo apt install ffmpeg\n```\n\n**Windows:**\n从 [FFmpeg 官网](https://ffmpeg.org/download.html) 下载并配置环境变量\n\n验证安装：\n\n```bash\nffmpeg -version\n```\n\n### ⚙️ 配置文件\n\n复制并编辑配置文件：\n\n```bash\ncp configs/config.example.yaml configs/config.yaml\nvim configs/config.yaml\n```\n\n配置文件格式（`configs/config.yaml`）：\n\n```yaml\napp:\n  name: \"Huobao Drama API\"\n  version: \"1.0.0\"\n  debug: true # 开发环境设为true，生产环境设为false\n\nserver:\n  port: 5678\n  host: \"0.0.0.0\"\n  cors_origins:\n    - \"http://localhost:3012\"\n  read_timeout: 600\n  write_timeout: 600\n\ndatabase:\n  type: \"sqlite\"\n  path: \"./data/drama_generator.db\"\n  max_idle: 10\n  max_open: 100\n\nstorage:\n  type: \"local\"\n  local_path: \"./data/storage\"\n  base_url: \"http://localhost:5678/static\"\n\nai:\n  default_text_provider: \"openai\"\n  default_image_provider: \"openai\"\n  default_video_provider: \"doubao\"\n```\n\n**重要配置项：**\n\n- `app.debug`: 调试模式开关（开发环境建议设为 true）\n- `server.port`: 服务运行端口\n- `server.cors_origins`: 允许跨域访问的前端地址\n- `database.path`: SQLite 数据库文件路径\n- `storage.local_path`: 本地文件存储路径\n- `storage.base_url`: 静态资源访问 URL\n- `ai.default_*_provider`: AI 服务提供商配置（在 Web 界面中配置具体的 API Key）\n\n### 📥 安装依赖\n\n```bash\n# 克隆项目\ngit clone https://github.com/chatfire-AI/huobao-drama.git\ncd huobao-drama\n\n# 安装Go依赖\ngo mod download\n\n# 安装前端依赖\ncd web\nnpm install\ncd ..\n```\n\n### 🎯 启动项目\n\n#### 方式一：开发模式（推荐）\n\n**前后端分离，支持热重载**\n\n```bash\n# 终端1：启动后端服务\ngo run main.go\n\n# 终端2：启动前端开发服务器\ncd web\nnpm run dev\n```\n\n- 前端地址: `http://localhost:3012`\n- 后端 API: `http://localhost:5678/api/v1`\n- 前端自动代理 API 请求到后端\n\n#### 方式二：单服务模式\n\n**后端同时提供 API 和前端静态文件**\n\n```bash\n# 1. 构建前端\ncd web\nnpm run build\ncd ..\n\n# 2. 启动服务\ngo run main.go\n```\n\n访问: `http://localhost:5678`\n\n### 🗄️ 数据库初始化\n\n数据库表会在首次启动时自动创建（使用 GORM AutoMigrate），无需手动迁移。\n\n---\n\n## 📦 部署指南\n\n### ☁️ 云端一键部署（推荐 3080Ti）\n\n👉 [优云智算，一键部署](https://www.compshare.cn/images/fScvzK95NUk5?referral_code=8hUJOaWz3YzG64FI2OlCiB&ytag=GPU_YY_YX_GitHub_huobaoai)\n\n> ⚠️ **注意**：云端部署方案数据请及时存储到本地\n\n---\n\n### 🐳 Docker 部署（推荐）\n\n#### 方式一：Docker Compose（推荐）\n\n#### 🚀 国内网络加速（可选）\n\n如果您在国内网络环境下，Docker 拉取镜像和安装依赖可能较慢。可以通过配置镜像源加速构建过程。\n\n**步骤 1：创建环境变量文件**\n\n```bash\ncp .env.example .env\n```\n\n**步骤 2：编辑 `.env` 文件，取消注释需要的镜像源**\n\n```bash\n# 启用 Docker Hub 镜像（推荐）\nDOCKER_REGISTRY=docker.1ms.run/\n\n# 启用 npm 镜像\nNPM_REGISTRY=https://registry.npmmirror.com/\n\n# 启用 Go 代理\nGO_PROXY=https://goproxy.cn,direct\n\n# 启用 Alpine 镜像\nALPINE_MIRROR=mirrors.aliyun.com\n```\n\n**步骤 3：使用 docker compose 构建（必须）**\n\n```bash\ndocker compose build\n```\n\n> **重要说明**：\n>\n> - ⚠️ 必须使用 `docker compose build` 才能自动加载 `.env` 文件中的镜像源配置\n> - ❌ 如果使用 `docker build` 命令，需要手动传递 `--build-arg` 参数\n> - ✅ 推荐始终使用 `docker compose build` 进行构建\n\n**效果对比**：\n\n| 操作          | 不配置镜像源 | 配置镜像源后 |\n| ------------- | ------------ | ------------ |\n| 拉取基础镜像  | 5-30 分钟    | 1-5 分钟     |\n| 安装 npm 依赖 | 可能失败     | 快速成功     |\n| 下载 Go 依赖  | 5-10 分钟    | 30 秒-1 分钟 |\n\n> **注意**：国外用户请勿配置镜像源，使用默认配置即可。\n\n```bash\n# 启动服务\ndocker-compose up -d\n\n# 查看日志\ndocker-compose logs -f\n\n# 停止服务\ndocker-compose down\n```\n\n#### 方式二：Docker 命令\n\n> **注意**：Linux 用户需添加 `--add-host=host.docker.internal:host-gateway` 以访问宿主机服务\n\n```bash\n# 从 Docker Hub 运行\ndocker run -d \\\n  --name huobao-drama \\\n  -p 5678:5678 \\\n  -v $(pwd)/data:/app/data \\\n  --restart unless-stopped \\\n  huobao/huobao-drama:latest\n\n# 查看日志\ndocker logs -f huobao-drama\n```\n\n**本地构建**（可选）：\n\n```bash\ndocker build -t huobao-drama:latest .\ndocker run -d --name huobao-drama -p 5678:5678 -v $(pwd)/data:/app/data huobao-drama:latest\n```\n\n**Docker 部署优势：**\n\n- ✅ 开箱即用，内置默认配置\n- ✅ 环境一致性，避免依赖问题\n- ✅ 一键启动，无需安装 Go、Node.js、FFmpeg\n- ✅ 易于迁移和扩展\n- ✅ 自动健康检查和重启\n- ✅ 自动处理文件权限，无需手动配置\n\n#### 🔗 访问宿主机服务（Ollama/本地模型）\n\n容器已配置支持访问宿主机服务，直接使用 `http://host.docker.internal:端口号` 即可。\n\n**配置步骤：**\n\n1. **宿主机启动服务（监听所有接口）**\n\n   ```bash\n   export OLLAMA_HOST=0.0.0.0:11434 && ollama serve\n   ```\n\n2. **前端 AI 服务配置**\n   - Base URL: `http://host.docker.internal:11434/v1`\n   - Provider: `openai`\n   - Model: `qwen2.5:latest`\n\n---\n\n### 🏭 传统部署方式\n\n#### 1. 编译构建\n\n```bash\n# 1. 构建前端\ncd web\nnpm run build\ncd ..\n\n# 2. 编译后端\ngo build -o huobao-drama .\n```\n\n生成文件：\n\n- `huobao-drama` - 后端可执行文件\n- `web/dist/` - 前端静态文件（已嵌入后端）\n\n#### 2. 准备部署文件\n\n需要上传到服务器的文件：\n\n```\nhuobao-drama            # 后端可执行文件\nconfigs/config.yaml     # 配置文件\ndata/                   # 数据目录（可选，首次运行自动创建）\n```\n\n#### 3. 服务器配置\n\n```bash\n# 上传文件到服务器\nscp huobao-drama user@server:/opt/huobao-drama/\nscp configs/config.yaml user@server:/opt/huobao-drama/configs/\n\n# SSH登录服务器\nssh user@server\n\n# 修改配置文件\ncd /opt/huobao-drama\nvim configs/config.yaml\n# 设置mode为production\n# 配置域名和存储路径\n\n# 创建数据目录并设置权限（重要！）\n# 注意：将 YOUR_USER 替换为实际运行服务的用户名（如 www-data、ubuntu、deploy 等）\nsudo mkdir -p /opt/huobao-drama/data/storage\nsudo chown -R YOUR_USER:YOUR_USER /opt/huobao-drama/data\nsudo chmod -R 755 /opt/huobao-drama/data\n\n# 赋予执行权限\nchmod +x huobao-drama\n\n# 启动服务\n./huobao-drama\n```\n\n#### 4. 使用 systemd 管理服务\n\n创建服务文件 `/etc/systemd/system/huobao-drama.service`:\n\n```ini\n[Unit]\nDescription=Huobao Drama Service\nAfter=network.target\n\n[Service]\nType=simple\nUser=YOUR_USER\nWorkingDirectory=/opt/huobao-drama\nExecStart=/opt/huobao-drama/huobao-drama\nRestart=on-failure\nRestartSec=10\n\n# 环境变量（可选）\n# Environment=\"GIN_MODE=release\"\n\n[Install]\nWantedBy=multi-user.target\n```\n\n启动服务：\n\n```bash\nsudo systemctl daemon-reload\nsudo systemctl enable huobao-drama\nsudo systemctl start huobao-drama\nsudo systemctl status huobao-drama\n```\n\n**⚠️ 常见问题：SQLite 写权限错误**\n\n如果遇到 `attempt to write a readonly database` 错误：\n\n```bash\n# 1. 确认当前运行服务的用户\nsudo systemctl status huobao-drama | grep \"Main PID\"\nps aux | grep huobao-drama\n\n# 2. 修复权限（将 YOUR_USER 替换为实际用户名）\nsudo chown -R YOUR_USER:YOUR_USER /opt/huobao-drama/data\nsudo chmod -R 755 /opt/huobao-drama/data\n\n# 3. 验证权限\nls -la /opt/huobao-drama/data\n# 应该显示所有者为运行服务的用户\n\n# 4. 重启服务\nsudo systemctl restart huobao-drama\n```\n\n**原因说明**：\n\n- SQLite 需要对数据库文件 **和** 所在目录都有写权限\n- 需要在目录中创建临时文件（如 `-wal`、`-journal`）\n- **关键**：确保 systemd 配置中的 `User` 与数据目录所有者一致\n\n**常用用户名**：\n\n- Ubuntu/Debian: `www-data`、`ubuntu`\n- CentOS/RHEL: `nginx`、`apache`\n- 自定义部署: `deploy`、`app`、当前登录用户\n\n#### 5. Nginx 反向代理\n\n```nginx\nserver {\n    listen 80;\n    server_name your-domain.com;\n\n    location / {\n        proxy_pass http://localhost:5678;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    }\n\n    # 静态文件直接访问\n    location /static/ {\n        alias /opt/huobao-drama/data/storage/;\n    }\n}\n```\n\n---\n\n## 🎨 技术栈\n\n### 后端技术\n\n- **语言**: Go 1.23+\n- **Web 框架**: Gin 1.9+\n- **ORM**: GORM\n- **数据库**: SQLite\n- **日志**: Zap\n- **视频处理**: FFmpeg\n- **AI 服务**: OpenAI、Gemini、火山等\n\n### 前端技术\n\n- **框架**: Vue 3.4+\n- **语言**: TypeScript 5+\n- **构建工具**: Vite 5\n- **UI 组件**: Element Plus\n- **CSS 框架**: TailwindCSS\n- **状态管理**: Pinia\n- **路由**: Vue Router 4\n\n### 开发工具\n\n- **包管理**: Go Modules, npm\n- **代码规范**: ESLint, Prettier\n- **版本控制**: Git\n\n---\n\n## 📝 常见问题\n\n### Q: Docker 容器如何访问宿主机的 Ollama？\n\nA: 使用 `http://host.docker.internal:11434/v1` 作为 Base URL。注意两点：\n\n1. 宿主机 Ollama 需监听 `0.0.0.0`：`export OLLAMA_HOST=0.0.0.0:11434 && ollama serve`\n2. Linux 用户使用 `docker run` 需添加：`--add-host=host.docker.internal:host-gateway`\n\n详见：[DOCKER_HOST_ACCESS.md](docs/DOCKER_HOST_ACCESS.md)\n\n### Q: FFmpeg 未安装或找不到？\n\nA: 确保 FFmpeg 已安装并在 PATH 环境变量中。运行 `ffmpeg -version` 验证。\n\n### Q: 前端无法连接后端 API？\n\nA: 检查后端是否启动，端口是否正确。开发模式下前端代理配置在 `web/vite.config.ts`。\n\n### Q: 数据库表未创建？\n\nA: GORM 会在首次启动时自动创建表，检查日志确认迁移是否成功。\n\n---\n\n## 📋 更新日志 / Changelog\n\n### v1.0.5 (2026-02-06)\n\n#### 🎨 重大功能\n\n- **🎭 全局风格系统**：引入了项目级别的风格选择支持。用户现在可以在剧本层面定义自定义视觉风格，该风格将自动应用于所有 AI 生成的内容，包括角色、场景和分镜图像，确保整个制作过程中的艺术风格一致性。\n\n- **✂️ 九宫格序列图裁剪**：新增裁剪工具，支持从动作序列图（3x3 网格布局）中提取单个帧，并将其指定为首帧、尾帧或关键帧用于视频生成，为镜头构图和连续性提供更大的灵活性。\n\n#### 🚀 功能增强\n\n- **📐 优化动作序列网格**：改进了九宫格动作序列图的视觉质量和布局，优化了间距、对齐和帧过渡效果。\n\n- **🔧 手动网格拼接**：引入手动网格组合工具，支持 2x2（四宫格）、2x3（六宫格）和 3x3（九宫格）布局，允许用户从单个帧创建自定义动作序列。\n\n- **🗑️ 内容管理**：新增图片和视频的删除功能，实现更好的素材组织和存储管理。\n\n### v1.0.4 (2026-01-27)\n\n#### 🚀 重大更新\n\n- 引入本地存储策略，实现生成内容的本地化缓存管理，有效规避外部资源链接失效风险\n- 采用 Base64 编码方案进行参考图像的嵌入式传输\n- 修复镜头切换时镜头图片提示词状态未重置问题\n- 修复视频添加素材库视频时长显示为0的问题\n- 添加场景迁移至章节内\n\n#### 历史数据清洗\n\n- 增加清洗脚本，用于处理历史数据，具体操作请参考 [MIGRATE_README.md](MIGRATE_README.md)\n\n### v1.0.3 (2026-01-16)\n\n#### 🚀 重大更新\n\n- SQLite 纯 Go 驱动（`modernc.org/sqlite`），支持 `CGO_ENABLED=0` 跨平台编译\n- 优化并发性能（WAL 模式），解决 \"database is locked\" 错误\n- Docker 跨平台支持 `host.docker.internal` 访问宿主机服务\n- 精简文档和部署指南\n\n### v1.0.2 (2026-01-14)\n\n#### 🐛 Bug Fixes / 🔧 Improvements\n\n- 修复视频生成 API 响应解析问题\n- 添加 OpenAI Sora 视频端点配置\n- 优化错误处理和日志输出\n\n---\n\n## 🤝 贡献指南\n\n欢迎提交 Issue 和 Pull Request！\n\n1. Fork 本项目\n2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)\n3. 提交改动 (`git commit -m 'Add some AmazingFeature'`)\n4. 推送到分支 (`git push origin feature/AmazingFeature`)\n5. 开启 Pull Request\n\n---\n\n## API 配置站点\n\n2 分钟完成配置：[API 聚合站点](https://api.chatfire.site/models)\n\n---\n\n## 👨‍💻 关于我们\n\n**AI 火宝 - AI 工作室创业中**\n\n- 🏠 **位置**: 中国南京\n- 🚀 **状态**: 创业中\n- 📧 **Email**: [18550175439@163.com](mailto:18550175439@163.com)\n- 🐙 **GitHub**: [https://github.com/chatfire-AI/huobao-drama](https://github.com/chatfire-AI/huobao-drama)\n\n> _\"让 AI 帮我们做更有创造力的事\"_\n\n### 📢 招聘信息\n\n#### 全栈高级开发工程师（Base 南京）\n\n**关于岗位：**\n\n我们是创业团队，致力于打造 AI 驱动的终端应用。如果你热爱技术、追求极致，渴望在 AI 领域有所作为，欢迎加入我们，一起做有意义的事！\n\n**岗位要求：**\n\n1. 参与 AI 驱动的终端应用全栈开发，覆盖 Web/移动端前后端系统搭建，主导核心业务模块的设计、开发、测试与上线迭代。\n2. 负责核心功能模块开发，包括：前端交互实现（React/Vue.js + TypeScript）、后端服务开发（Python/Node.js + Flask/FastAPI），确保模块高效、稳定、可扩展。\n3. 负责 AI 模型服务接口设计与对接，参与 AI 能力落地终端场景的技术方案设计，推动 AI 技术与业务场景的深度融合。\n4. 深度践行 vibe coding 开发模式，注重代码质量、开发效率与技术美感，搭建规范的开发流程，推动团队研发模式优化。\n\n**加入我们：**\n\n如果你对 AI 技术充满热情，喜欢挑战和创新，欢迎投递简历至 [18550175439@163.com](mailto:18550175439@163.com)\n\n期待志同道合的你一起探索 AI 的无限可能！\n\n## 项目交流群\n\n![项目交流群](drama.png)\n\n- 提交 [Issue](../../issues)\n- 发送邮件至项目维护者\n\n---\n\n<div align=\"center\">\n\n**⭐ 如果这个项目对你有帮助，请给一个 Star！**\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=chatfire-AI/huobao-drama&type=date&legend=top-left)](https://www.star-history.com/#chatfire-AI/huobao-drama&type=date&legend=top-left)\nMade with ❤️ by Huobao Team\n\n</div>\n"
  },
  {
    "path": "README-JA.md",
    "content": "# 🎬 Huobao Drama - AI ショートドラマ制作プラットフォーム\n\n<div align=\"center\">\n\n**Go + Vue3 ベースのフルスタック AI ショートドラマ自動化プラットフォーム**\n\n[![Go Version](https://img.shields.io/badge/Go-1.23+-00ADD8?style=flat&logo=go)](https://golang.org)\n[![Vue Version](https://img.shields.io/badge/Vue-3.x-4FC08D?style=flat&logo=vue.js)](https://vuejs.org)\n[![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/)\n\n[機能](#機能) • [クイックスタート](#クイックスタート) • [デプロイ](#デプロイ)\n\n[简体中文](README-CN.md) | [English](README.md) | [日本語](README-JA.md)\n\n</div>\n\n---\n\n## 📖 概要\n\nHuobao Drama は、脚本生成、キャラクターデザイン、絵コンテ作成から動画合成までの全ワークフローを自動化する AI 駆動のショートドラマ制作プラットフォームです。\n\n火宝短剧商业版地址：[火宝短剧商业版](https://drama.chatfire.site/shortvideo)\n\n火宝小说生成：[火宝小说生成](https://marketing.chatfire.site/huobao-novel/)\n\n### 🎯 主要機能\n\n- **🤖 AI 駆動**: 大規模言語モデルを使用して脚本を解析し、キャラクター、シーン、絵コンテ情報を抽出\n- **🎨 インテリジェント創作**: AI によるキャラクターポートレートとシーン背景の生成\n- **📹 動画生成**: テキストから動画、画像から動画モデルによる絵コンテ動画の自動生成\n- **🔄 完全なワークフロー**: アイデアから完成動画までのエンドツーエンド制作ワークフロー\n\n### 🛠️ 技術アーキテクチャ\n\n**DDD（ドメイン駆動設計）** に基づく明確なレイヤー構造：\n\n```\n├── APIレイヤー (Gin HTTP)\n├── アプリケーションサービスレイヤー (ビジネスロジック)\n├── ドメインレイヤー (ドメインモデル)\n└── インフラストラクチャレイヤー (データベース、外部サービス)\n```\n\n### 🎥 デモ動画\n\nAI ショートドラマ生成を体験：\n\n<div align=\"center\">\n\n**サンプル作品 1**\n\n<video src=\"https://ffile.chatfire.site/cf/public/20260114094337396.mp4\" controls width=\"640\"></video>\n\n**サンプル作品 2**\n\n<video src=\"https://ffile.chatfire.site/cf/public/fcede75e8aeafe22031dbf78f86285b8.mp4\" controls width=\"640\"></video>\n\n[動画 1 を見る](https://ffile.chatfire.site/cf/public/20260114094337396.mp4) | [動画 2 を見る](https://ffile.chatfire.site/cf/public/fcede75e8aeafe22031dbf78f86285b8.mp4)\n\n</div>\n\n---\n\n## ✨ 機能\n\n### 🎭 キャラクター管理\n\n- ✅ AI 生成キャラクターポートレート\n- ✅ バッチキャラクター生成\n- ✅ キャラクター画像のアップロードと管理\n\n### 🎬 絵コンテ制作\n\n- ✅ 自動絵コンテスクリプト生成\n- ✅ シーン説明とショットデザイン\n- ✅ 絵コンテ画像生成（テキストから画像）\n- ✅ フレームタイプ選択（先頭フレーム/キーフレーム/末尾フレーム/パネル）\n\n### 🎥 動画生成\n\n- ✅ 画像から動画の自動生成\n- ✅ 動画合成と編集\n- ✅ トランジション効果\n\n### 📦 アセット管理\n\n- ✅ 統合アセットライブラリ管理\n- ✅ ローカルストレージサポート\n- ✅ アセットのインポート/エクスポート\n- ✅ タスク進捗トラッキング\n\n---\n\n## 🚀 クイックスタート\n\n### 📋 前提条件\n\n| ソフトウェア | バージョン | 説明                     |\n| ------------ | ---------- | ------------------------ |\n| **Go**       | 1.23+      | バックエンドランタイム   |\n| **Node.js**  | 18+        | フロントエンドビルド環境 |\n| **npm**      | 9+         | パッケージマネージャー   |\n| **FFmpeg**   | 4.0+       | 動画処理（**必須**）     |\n| **SQLite**   | 3.x        | データベース（内蔵）     |\n\n#### FFmpeg のインストール\n\n**macOS:**\n\n```bash\nbrew install ffmpeg\n```\n\n**Ubuntu/Debian:**\n\n```bash\nsudo apt update\nsudo apt install ffmpeg\n```\n\n**Windows:**\n[FFmpeg 公式サイト](https://ffmpeg.org/download.html)からダウンロードし、環境変数を設定\n\nインストール確認：\n\n```bash\nffmpeg -version\n```\n\n### ⚙️ 設定\n\n設定ファイルをコピーして編集：\n\n```bash\ncp configs/config.example.yaml configs/config.yaml\nvim configs/config.yaml\n```\n\n設定ファイル形式（`configs/config.yaml`）：\n\n```yaml\napp:\n  name: \"Huobao Drama API\"\n  version: \"1.0.0\"\n  debug: true # 開発環境ではtrue、本番環境ではfalseに設定\n\nserver:\n  port: 5678\n  host: \"0.0.0.0\"\n  cors_origins:\n    - \"http://localhost:3012\"\n  read_timeout: 600\n  write_timeout: 600\n\ndatabase:\n  type: \"sqlite\"\n  path: \"./data/drama_generator.db\"\n  max_idle: 10\n  max_open: 100\n\nstorage:\n  type: \"local\"\n  local_path: \"./data/storage\"\n  base_url: \"http://localhost:5678/static\"\n\nai:\n  default_text_provider: \"openai\"\n  default_image_provider: \"openai\"\n  default_video_provider: \"doubao\"\n```\n\n**主要設定項目：**\n\n- `app.debug`: デバッグモードスイッチ（開発環境では true を推奨）\n- `server.port`: サービスポート\n- `server.cors_origins`: フロントエンドの許可 CORS オリジン\n- `database.path`: SQLite データベースファイルパス\n- `storage.local_path`: ローカルファイルストレージパス\n- `storage.base_url`: 静的リソースアクセス URL\n- `ai.default_*_provider`: AI サービスプロバイダー設定（API キーは Web UI で設定）\n\n### 📥 インストール\n\n```bash\n# プロジェクトをクローン\ngit clone https://github.com/chatfire-AI/huobao-drama.git\ncd huobao-drama\n\n# Go依存関係をインストール\ngo mod download\n\n# フロントエンド依存関係をインストール\ncd web\nnpm install\ncd ..\n```\n\n### 🎯 プロジェクトの起動\n\n#### 方法 1: 開発モード（推奨）\n\n**フロントエンドとバックエンドを分離、ホットリロード対応**\n\n```bash\n# ターミナル1: バックエンドサービスを起動\ngo run main.go\n\n# ターミナル2: フロントエンド開発サーバーを起動\ncd web\nnpm run dev\n```\n\n- フロントエンド: `http://localhost:3012`\n- バックエンド API: `http://localhost:5678/api/v1`\n- フロントエンドは API リクエストを自動的にバックエンドにプロキシ\n\n#### 方法 2: シングルサービスモード\n\n**バックエンドが API とフロントエンド静的ファイルの両方を提供**\n\n```bash\n# 1. フロントエンドをビルド\ncd web\nnpm run build\ncd ..\n\n# 2. サービスを起動\ngo run main.go\n```\n\nアクセス: `http://localhost:5678`\n\n### 🗄️ データベース初期化\n\nデータベーステーブルは初回起動時に自動作成されます（GORM AutoMigrate を使用）。手動マイグレーションは不要です。\n\n---\n\n## 📦 デプロイ\n\n### ☁️ クラウドワンクリックデプロイ（推奨 3080Ti）\n\n👉 [优云智算，一键部署](https://www.compshare.cn/images/fScvzK95NUk5?referral_code=8hUJOaWz3YzG64FI2OlCiB&ytag=GPU_YY_YX_GitHub_huobaoai)\n\n> ⚠️ **注意**：クラウドデプロイを使用する場合は、データを速やかにローカルストレージに保存してください\n\n---\n\n### 🐳 Docker デプロイ（推奨）\n\n#### 方法 1: Docker Compose（推奨）\n\n#### 🚀 中国国内ネットワーク高速化（オプション）\n\n中国国内のネットワーク環境では、Docker イメージのプルや依存関係のインストールが遅い場合があります。ミラーソースを設定することでビルドプロセスを高速化できます。\n\n**ステップ 1: 環境変数ファイルを作成**\n\n```bash\ncp .env.example .env\n```\n\n**ステップ 2: `.env` ファイルを編集し、必要なミラーソースのコメントを解除**\n\n```bash\n# Docker Hub ミラーを有効化（推奨）\nDOCKER_REGISTRY=docker.1ms.run/\n\n# npm ミラーを有効化\nNPM_REGISTRY=https://registry.npmmirror.com/\n\n# Go プロキシを有効化\nGO_PROXY=https://goproxy.cn,direct\n\n# Alpine ミラーを有効化\nALPINE_MIRROR=mirrors.aliyun.com\n```\n\n**ステップ 3: docker compose でビルド（必須）**\n\n```bash\ndocker compose build\n```\n\n> **重要な注意事項**:\n>\n> - ⚠️ `.env` ファイルのミラーソース設定を自動的に読み込むには `docker compose build` を使用する必要があります\n> - ❌ `docker build` コマンドを使用する場合は、手動で `--build-arg` パラメータを渡す必要があります\n> - ✅ 常に `docker compose build` を使用してビルドすることを推奨\n\n**パフォーマンス比較**:\n\n| 操作                     | ミラー未設定   | ミラー設定後 |\n| ------------------------ | -------------- | ------------ |\n| ベースイメージのプル     | 5-30 分        | 1-5 分       |\n| npm 依存関係インストール | 失敗する可能性 | 高速成功     |\n| Go 依存関係ダウンロード  | 5-10 分        | 30 秒-1 分   |\n\n> **注意**: 中国国外のユーザーはミラーソースを設定せず、デフォルト設定を使用してください。\n\n```bash\n# サービスを起動\ndocker-compose up -d\n\n# ログを表示\ndocker-compose logs -f\n\n# サービスを停止\ndocker-compose down\n```\n\n#### 方法 2: Docker コマンド\n\n> **注意**: Linux ユーザーはホストサービスにアクセスするために `--add-host=host.docker.internal:host-gateway` を追加する必要があります\n\n```bash\n# Docker Hubから実行\ndocker run -d \\\n  --name huobao-drama \\\n  -p 5678:5678 \\\n  -v $(pwd)/data:/app/data \\\n  --restart unless-stopped \\\n  huobao/huobao-drama:latest\n\n# ログを表示\ndocker logs -f huobao-drama\n```\n\n**ローカルビルド**（オプション）：\n\n```bash\ndocker build -t huobao-drama:latest .\ndocker run -d --name huobao-drama -p 5678:5678 -v $(pwd)/data:/app/data huobao-drama:latest\n```\n\n**Docker デプロイの利点：**\n\n- ✅ デフォルト設定ですぐに使用可能\n- ✅ 環境の一貫性、依存関係の問題を回避\n- ✅ ワンクリック起動、Go、Node.js、FFmpeg のインストール不要\n- ✅ 移行とスケーリングが容易\n- ✅ 自動ヘルスチェックと再起動\n- ✅ ファイル権限の自動処理\n\n#### 🔗 ホストサービスへのアクセス（Ollama/ローカルモデル）\n\nコンテナは `http://host.docker.internal:ポート番号` を使用してホストサービスにアクセスするよう設定されています。\n\n**設定手順：**\n\n1. **ホストでサービスを起動（全インターフェースでリッスン）**\n\n   ```bash\n   export OLLAMA_HOST=0.0.0.0:11434 && ollama serve\n   ```\n\n2. **フロントエンド AI サービス設定**\n   - Base URL: `http://host.docker.internal:11434/v1`\n   - Provider: `openai`\n   - Model: `qwen2.5:latest`\n\n---\n\n### 🏭 従来のデプロイ方法\n\n#### 1. ビルド\n\n```bash\n# 1. フロントエンドをビルド\ncd web\nnpm run build\ncd ..\n\n# 2. バックエンドをコンパイル\ngo build -o huobao-drama .\n```\n\n生成ファイル：\n\n- `huobao-drama` - バックエンド実行ファイル\n- `web/dist/` - フロントエンド静的ファイル（バックエンドに埋め込み）\n\n#### 2. デプロイファイルの準備\n\nサーバーにアップロードするファイル：\n\n```\nhuobao-drama            # バックエンド実行ファイル\nconfigs/config.yaml     # 設定ファイル\ndata/                   # データディレクトリ（オプション、初回実行時に自動作成）\n```\n\n#### 3. サーバー設定\n\n```bash\n# ファイルをサーバーにアップロード\nscp huobao-drama user@server:/opt/huobao-drama/\nscp configs/config.yaml user@server:/opt/huobao-drama/configs/\n\n# サーバーにSSH接続\nssh user@server\n\n# 設定ファイルを編集\ncd /opt/huobao-drama\nvim configs/config.yaml\n# modeをproductionに設定\n# ドメインとストレージパスを設定\n\n# データディレクトリを作成し権限を設定（重要！）\n# 注意: YOUR_USERを実際にサービスを実行するユーザー名に置き換え（例: www-data、ubuntu、deploy）\nsudo mkdir -p /opt/huobao-drama/data/storage\nsudo chown -R YOUR_USER:YOUR_USER /opt/huobao-drama/data\nsudo chmod -R 755 /opt/huobao-drama/data\n\n# 実行権限を付与\nchmod +x huobao-drama\n\n# サービスを起動\n./huobao-drama\n```\n\n#### 4. systemd でサービス管理\n\nサービスファイル `/etc/systemd/system/huobao-drama.service` を作成：\n\n```ini\n[Unit]\nDescription=Huobao Drama Service\nAfter=network.target\n\n[Service]\nType=simple\nUser=YOUR_USER\nWorkingDirectory=/opt/huobao-drama\nExecStart=/opt/huobao-drama/huobao-drama\nRestart=on-failure\nRestartSec=10\n\n# 環境変数（オプション）\n# Environment=\"GIN_MODE=release\"\n\n[Install]\nWantedBy=multi-user.target\n```\n\nサービスを起動：\n\n```bash\nsudo systemctl daemon-reload\nsudo systemctl enable huobao-drama\nsudo systemctl start huobao-drama\nsudo systemctl status huobao-drama\n```\n\n**⚠️ よくある問題: SQLite 書き込み権限エラー**\n\n`attempt to write a readonly database` エラーが発生した場合：\n\n```bash\n# 1. サービスを実行中のユーザーを確認\nsudo systemctl status huobao-drama | grep \"Main PID\"\nps aux | grep huobao-drama\n\n# 2. 権限を修正（YOUR_USERを実際のユーザー名に置き換え）\nsudo chown -R YOUR_USER:YOUR_USER /opt/huobao-drama/data\nsudo chmod -R 755 /opt/huobao-drama/data\n\n# 3. 権限を確認\nls -la /opt/huobao-drama/data\n# サービスを実行するユーザーが所有者として表示されるはず\n\n# 4. サービスを再起動\nsudo systemctl restart huobao-drama\n```\n\n**原因：**\n\n- SQLite はデータベースファイル**と**そのディレクトリの両方に書き込み権限が必要\n- ディレクトリ内に一時ファイル（例: `-wal`、`-journal`）を作成する必要がある\n- **重要**: systemd の`User`がデータディレクトリの所有者と一致していることを確認\n\n**一般的なユーザー名：**\n\n- Ubuntu/Debian: `www-data`、`ubuntu`\n- CentOS/RHEL: `nginx`、`apache`\n- カスタムデプロイ: `deploy`、`app`、現在ログインしているユーザー\n\n#### 5. Nginx リバースプロキシ\n\n```nginx\nserver {\n    listen 80;\n    server_name your-domain.com;\n\n    location / {\n        proxy_pass http://localhost:5678;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    }\n\n    # 静的ファイルへの直接アクセス\n    location /static/ {\n        alias /opt/huobao-drama/data/storage/;\n    }\n}\n```\n\n---\n\n## 🎨 技術スタック\n\n### バックエンド\n\n- **言語**: Go 1.23+\n- **Web フレームワーク**: Gin 1.9+\n- **ORM**: GORM\n- **データベース**: SQLite\n- **ログ**: Zap\n- **動画処理**: FFmpeg\n- **AI サービス**: OpenAI、Gemini、Doubao など\n\n### フロントエンド\n\n- **フレームワーク**: Vue 3.4+\n- **言語**: TypeScript 5+\n- **ビルドツール**: Vite 5\n- **UI コンポーネント**: Element Plus\n- **CSS フレームワーク**: TailwindCSS\n- **状態管理**: Pinia\n- **ルーター**: Vue Router 4\n\n### 開発ツール\n\n- **パッケージ管理**: Go Modules、npm\n- **コード規約**: ESLint、Prettier\n- **バージョン管理**: Git\n\n---\n\n## 📝 よくある質問\n\n### Q: Docker コンテナからホストの Ollama にアクセスするには？\n\nA: Base URL として `http://host.docker.internal:11434/v1` を使用します。注意点：\n\n1. ホストの Ollama は `0.0.0.0` でリッスンする必要があります: `export OLLAMA_HOST=0.0.0.0:11434 && ollama serve`\n2. `docker run` を使用する Linux ユーザーは追加が必要: `--add-host=host.docker.internal:host-gateway`\n\n詳細: [DOCKER_HOST_ACCESS.md](docs/DOCKER_HOST_ACCESS.md)\n\n### Q: FFmpeg がインストールされていない、または見つからない？\n\nA: FFmpeg がインストールされ、PATH 環境変数に含まれていることを確認してください。`ffmpeg -version` で確認。\n\n### Q: フロントエンドがバックエンド API に接続できない？\n\nA: バックエンドが実行中で、ポートが正しいか確認してください。開発モードでは、フロントエンドプロキシ設定は `web/vite.config.ts` にあります。\n\n### Q: データベーステーブルが作成されない？\n\nA: GORM は初回起動時にテーブルを自動作成します。ログでマイグレーション成功を確認してください。\n\n---\n\n## 📋 更新履歴\n\n### v1.0.5 (2026-02-06)\n\n#### 🎨 主要機能\n\n- **🎭 グローバルスタイルシステム**：プロジェクト全体でスタイル選択をサポートする包括的なシステムを導入しました。ユーザーはドラマレベルでカスタムビジュアルスタイルを定義でき、キャラクター、シーン、ストーリーボードを含むすべてのAI生成コンテンツに自動的に適用され、制作全体で一貫した芸術的方向性を確保します。\n\n- **✂️ 9グリッドシーケンス画像クロップ**：アクションシーケンス画像用のクロップツールを追加しました。3x3グリッドレイアウトから個別のフレームを抽出し、ビデオ生成用のファーストフレーム、ラストフレーム、またはキーフレームとして指定できるようになり、ショット構成と連続性においてより大きな柔軟性を提供します。\n\n#### 🚀 機能強化\n\n- **📐 アクションシーケンスグリッドの最適化**：9グリッドアクションシーケンス画像の視覚品質とレイアウトを改善し、間隔、配置、フレーム遷移を最適化しました。\n\n- **🔧 手動グリッド組み立て**：2x2（4グリッド）、2x3（6グリッド）、3x3（9グリッド）レイアウトをサポートする手動グリッド構成ツールを導入し、個別のフレームからカスタムアクションシーケンスを作成できるようになりました。\n\n- **🗑️ コンテンツ管理**：生成された画像とビデオの両方に削除機能を追加し、より良いアセット整理とストレージ管理を実現しました。\n\n### v1.0.4 (2026-01-27)\n\n#### 🚀 主要アップデート\n\n- 生成コンテンツのローカルストレージ戦略を導入し、外部リソースリンクの有効期限切れリスクを効果的に軽減\n- 参照画像の埋め込み転送用 Base64 エンコーディング方式を実装\n- ショット切り替え時にショット画像プロンプト状態がリセットされない問題を修正\n- ライブラリ動画追加時に動画の長さが 0 と表示される問題を修正\n- シーンのエピソードへの移行機能を追加\n\n#### 履歴データクリーニング\n\n- 履歴データ処理用のマイグレーションスクリプトを追加。詳細な手順については [MIGRATE_README.md](MIGRATE_README.md) を参照してください\n\n### v1.0.3 (2026-01-16)\n\n#### 🚀 主要アップデート\n\n- 純粋な Go SQLite ドライバー（`modernc.org/sqlite`）、`CGO_ENABLED=0` クロスプラットフォームコンパイルをサポート\n- 並行性能を最適化（WAL モード）、\"database is locked\" エラーを解決\n- ホストサービスへのアクセス用 `host.docker.internal` の Docker クロスプラットフォームサポート\n- ドキュメントとデプロイガイドの簡素化\n\n### v1.0.2 (2026-01-14)\n\n#### 🐛 バグ修正 / 🔧 改善\n\n- 動画生成 API レスポンスのパース問題を修正\n- OpenAI Sora 動画エンドポイント設定を追加\n- エラー処理とログ出力を最適化\n\n---\n\n## 🤝 コントリビューション\n\nIssue と Pull Request を歓迎します！\n\n1. このプロジェクトをフォーク\n2. フィーチャーブランチを作成 (`git checkout -b feature/AmazingFeature`)\n3. 変更をコミット (`git commit -m 'Add some AmazingFeature'`)\n4. ブランチにプッシュ (`git push origin feature/AmazingFeature`)\n5. Pull Request を作成\n\n---\n\n## API 設定サイト\n\n2 分で設定完了: [API 集約サイト](https://api.chatfire.site/models)\n\n---\n\n## 👨‍💻 私たちについて\n\n**AI 火宝 - AI スタジオ起業中**\n\n- 🏠 **所在地**: 中国南京\n- 🚀 **ステータス**: 起業中\n- 📧 **Email**: [18550175439@163.com](mailto:18550175439@163.com)\n- 💬 **WeChat**: dangbao1117 （個人 WeChat - 技術的な質問には対応しません）\n- 🐙 **GitHub**: [https://github.com/chatfire-AI/huobao-drama](https://github.com/chatfire-AI/huobao-drama)\n\n> _「AI に私たちのより創造的なことを手伝ってもらおう」_\n\n## コミュニティグループ\n\n![コミュニティグループ](drama.png)\n\n- [Issue](../../issues)を提出\n- プロジェクトメンテナにメール\n\n---\n\n<div align=\"center\">\n\n**⭐ このプロジェクトが役に立ったら、Star をお願いします！**\n\n## Star 履歴\n\n[![Star History Chart](https://api.star-history.com/svg?repos=chatfire-AI/huobao-drama&type=date&legend=top-left)](https://www.star-history.com/#chatfire-AI/huobao-drama&type=date&legend=top-left)\n\nMade with ❤️ by Huobao Team\n\n</div>\n"
  },
  {
    "path": "README.md",
    "content": "# 🎬 Huobao Drama - AI Short Drama Production Platform\n\n<div align=\"center\">\n\n**Full-stack AI Short Drama Automation Platform Based on Go + Vue3**\n\n[![Go Version](https://img.shields.io/badge/Go-1.23+-00ADD8?style=flat&logo=go)](https://golang.org)\n[![Vue Version](https://img.shields.io/badge/Vue-3.x-4FC08D?style=flat&logo=vue.js)](https://vuejs.org)\n[![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/)\n\n[Features](#features) • [Quick Start](#quick-start) • [Deployment](#deployment)\n\n[简体中文](README-CN.md) | [English](README.md) | [日本語](README-JA.md)\n\n</div>\n\n---\n\n## 📖 About\n\nHuobao Drama is an AI-powered short drama production platform that automates the entire workflow from script generation, character design, storyboarding to video composition.\n\n火宝短剧商业版地址：[火宝短剧商业版](https://drama.chatfire.site/shortvideo)\n\n火宝小说生成：[火宝小说生成](https://marketing.chatfire.site/huobao-novel/)\n\n### 🎯 Core Features\n\n- **🤖 AI-Driven**: Parse scripts using large language models to extract characters, scenes, and storyboards\n- **🎨 Intelligent Creation**: AI-generated character portraits and scene backgrounds\n- **📹 Video Generation**: Automatic storyboard video generation using text-to-video and image-to-video models\n- **🔄 Complete Workflow**: End-to-end production workflow from idea to final video。\n\n### 🛠️ Technical Architecture\n\nBased on **DDD (Domain-Driven Design)** with clear layering:\n\n```\n├── API Layer (Gin HTTP)\n├── Application Service Layer (Business Logic)\n├── Domain Layer (Domain Models)\n└── Infrastructure Layer (Database, External Services)\n```\n\n### 🎥 Demo Videos\n\nExperience AI short drama generation:\n\n<div align=\"center\">\n\n**Sample Work 1**\n\n<video src=\"https://ffile.chatfire.site/cf/public/20260114094337396.mp4\" controls width=\"640\"></video>\n\n**Sample Work 2**\n\n<video src=\"https://ffile.chatfire.site/cf/public/fcede75e8aeafe22031dbf78f86285b8.mp4\" controls width=\"640\"></video>\n\n[Watch Video 1](https://ffile.chatfire.site/cf/public/20260114094337396.mp4) | [Watch Video 2](https://ffile.chatfire.site/cf/public/fcede75e8aeafe22031dbf78f86285b8.mp4)\n\n</div>\n\n---\n\n## ✨ Features\n\n### 🎭 Character Management\n\n- ✅ AI-generated character portraits\n- ✅ Batch character generation\n- ✅ Character image upload and management\n\n### 🎬 Storyboard Production\n\n- ✅ Automatic storyboard script generation\n- ✅ Scene descriptions and shot design\n- ✅ Storyboard image generation (text-to-image)\n- ✅ Frame type selection (first frame/key frame/last frame/panel)\n\n### 🎥 Video Generation\n\n- ✅ Automatic image-to-video generation\n- ✅ Video composition and editing\n- ✅ Transition effects\n\n### 📦 Asset Management\n\n- ✅ Unified asset library management\n- ✅ Local storage support\n- ✅ Asset import/export\n- ✅ Task progress tracking\n\n---\n\n## 🚀 Quick Start\n\n### 📋 Prerequisites\n\n| Software    | Version | Description                     |\n| ----------- | ------- | ------------------------------- |\n| **Go**      | 1.23+   | Backend runtime                 |\n| **Node.js** | 18+     | Frontend build environment      |\n| **npm**     | 9+      | Package manager                 |\n| **FFmpeg**  | 4.0+    | Video processing (**Required**) |\n| **SQLite**  | 3.x     | Database (built-in)             |\n\n#### Installing FFmpeg\n\n**macOS:**\n\n```bash\nbrew install ffmpeg\n```\n\n**Ubuntu/Debian:**\n\n```bash\nsudo apt update\nsudo apt install ffmpeg\n```\n\n**Windows:**\nDownload from [FFmpeg Official Site](https://ffmpeg.org/download.html) and configure environment variables\n\nVerify installation:\n\n```bash\nffmpeg -version\n```\n\n### ⚙️ Configuration\n\nCopy and edit the configuration file:\n\n```bash\ncp configs/config.example.yaml configs/config.yaml\nvim configs/config.yaml\n```\n\nConfiguration file format (`configs/config.yaml`):\n\n```yaml\napp:\n  name: \"Huobao Drama API\"\n  version: \"1.0.0\"\n  debug: true # Set to true for development, false for production\n\nserver:\n  port: 5678\n  host: \"0.0.0.0\"\n  cors_origins:\n    - \"http://localhost:3012\"\n  read_timeout: 600\n  write_timeout: 600\n\ndatabase:\n  type: \"sqlite\"\n  path: \"./data/drama_generator.db\"\n  max_idle: 10\n  max_open: 100\n\nstorage:\n  type: \"local\"\n  local_path: \"./data/storage\"\n  base_url: \"http://localhost:5678/static\"\n\nai:\n  default_text_provider: \"openai\"\n  default_image_provider: \"openai\"\n  default_video_provider: \"doubao\"\n```\n\n**Key Configuration Items:**\n\n- `app.debug`: Debug mode switch (recommended true for development)\n- `server.port`: Service port\n- `server.cors_origins`: Allowed CORS origins for frontend\n- `database.path`: SQLite database file path\n- `storage.local_path`: Local file storage path\n- `storage.base_url`: Static resource access URL\n- `ai.default_*_provider`: AI service provider configuration (API keys configured in Web UI)\n\n### 📥 Installation\n\n```bash\n# Clone the project\ngit clone https://github.com/chatfire-AI/huobao-drama.git\ncd huobao-drama\n\n# Install Go dependencies\ngo mod download\n\n# Install frontend dependencies\ncd web\nnpm install\ncd ..\n```\n\n### 🎯 Starting the Project\n\n#### Method 1: Development Mode (Recommended)\n\n**Frontend and backend separation with hot reload**\n\n```bash\n# Terminal 1: Start backend service\ngo run main.go\n\n# Terminal 2: Start frontend dev server\ncd web\nnpm run dev\n```\n\n- Frontend: `http://localhost:3012`\n- Backend API: `http://localhost:5678/api/v1`\n- Frontend automatically proxies API requests to backend\n\n#### Method 2: Single Service Mode\n\n**Backend serves both API and frontend static files**\n\n```bash\n# 1. Build frontend\ncd web\nnpm run build\ncd ..\n\n# 2. Start service\ngo run main.go\n```\n\nAccess: `http://localhost:5678`\n\n### 🗄️ Database Initialization\n\nDatabase tables are automatically created on first startup (using GORM AutoMigrate), no manual migration needed.\n\n---\n\n## 📦 Deployment\n\n### ☁️ Cloud One-Click Deployment (Recommended 3080Ti)\n\n👉 [优云智算，一键部署](https://www.compshare.cn/images/fScvzK95NUk5?referral_code=8hUJOaWz3YzG64FI2OlCiB&ytag=GPU_YY_YX_GitHub_huobaoai)\n\n> ⚠️ **Note**: Please save your data to local storage promptly when using cloud deployment\n\n---\n\n### 🐳 Docker Deployment (Recommended)\n\n#### Method 1: Docker Compose (Recommended)\n\n#### 🚀 China Network Acceleration (Optional)\n\nIf you are in China, pulling Docker images and installing dependencies may be slow. You can speed up the build process by configuring mirror sources.\n\n**Step 1: Create environment variable file**\n\n```bash\ncp .env.example .env\n```\n\n**Step 2: Edit `.env` file and uncomment the mirror sources you need**\n\n```bash\n# Enable Docker Hub mirror (recommended)\nDOCKER_REGISTRY=docker.1ms.run/\n\n# Enable npm mirror\nNPM_REGISTRY=https://registry.npmmirror.com/\n\n# Enable Go proxy\nGO_PROXY=https://goproxy.cn,direct\n\n# Enable Alpine mirror\nALPINE_MIRROR=mirrors.aliyun.com\n```\n\n**Step 3: Build with docker compose (required)**\n\n```bash\ndocker compose build\n```\n\n> **Important Note**:\n>\n> - ⚠️ You must use `docker compose build` to automatically load mirror source configurations from the `.env` file\n> - ❌ If using `docker build` command, you need to manually pass `--build-arg` parameters\n> - ✅ Always recommended to use `docker compose build` for building\n\n**Performance Comparison**:\n\n| Operation        | Without Mirrors | With Mirrors |\n| ---------------- | --------------- | ------------ |\n| Pull base images | 5-30 minutes    | 1-5 minutes  |\n| Install npm deps | May fail        | Fast success |\n| Download Go deps | 5-10 minutes    | 30s-1 minute |\n\n> **Note**: Users outside China should not configure mirror sources, use default settings.\n\n```bash\n# Start services\ndocker-compose up -d\n\n# View logs\ndocker-compose logs -f\n\n# Stop services\ndocker-compose down\n```\n\n#### Method 2: Docker Command\n\n> **Note**: Linux users need to add `--add-host=host.docker.internal:host-gateway` to access host services\n\n```bash\n# Run from Docker Hub\ndocker run -d \\\n  --name huobao-drama \\\n  -p 5678:5678 \\\n  -v $(pwd)/data:/app/data \\\n  --restart unless-stopped \\\n  huobao/huobao-drama:latest\n\n# View logs\ndocker logs -f huobao-drama\n```\n\n**Local Build** (optional):\n\n```bash\ndocker build -t huobao-drama:latest .\ndocker run -d --name huobao-drama -p 5678:5678 -v $(pwd)/data:/app/data huobao-drama:latest\n```\n\n**Docker Deployment Advantages:**\n\n- ✅ Ready to use with default configuration\n- ✅ Environment consistency, avoiding dependency issues\n- ✅ One-click start, no need to install Go, Node.js, FFmpeg\n- ✅ Easy to migrate and scale\n- ✅ Automatic health checks and restarts\n- ✅ Automatic file permission handling\n\n#### 🔗 Accessing Host Services (Ollama/Local Models)\n\nThe container is configured to access host services using `http://host.docker.internal:PORT`.\n\n**Configuration Steps:**\n\n1. **Start service on host (listen on all interfaces)**\n\n   ```bash\n   export OLLAMA_HOST=0.0.0.0:11434 && ollama serve\n   ```\n\n2. **Frontend AI Service Configuration**\n   - Base URL: `http://host.docker.internal:11434/v1`\n   - Provider: `openai`\n   - Model: `qwen2.5:latest`\n\n---\n\n### 🏭 Traditional Deployment\n\n#### 1. Build\n\n```bash\n# 1. Build frontend\ncd web\nnpm run build\ncd ..\n\n# 2. Compile backend\ngo build -o huobao-drama .\n```\n\nGenerated files:\n\n- `huobao-drama` - Backend executable\n- `web/dist/` - Frontend static files (embedded in backend)\n\n#### 2. Prepare Deployment Files\n\nFiles to upload to server:\n\n```\nhuobao-drama            # Backend executable\nconfigs/config.yaml     # Configuration file\ndata/                   # Data directory (optional, auto-created on first run)\n```\n\n#### 3. Server Configuration\n\n```bash\n# Upload files to server\nscp huobao-drama user@server:/opt/huobao-drama/\nscp configs/config.yaml user@server:/opt/huobao-drama/configs/\n\n# SSH to server\nssh user@server\n\n# Modify configuration file\ncd /opt/huobao-drama\nvim configs/config.yaml\n# Set mode to production\n# Configure domain and storage path\n\n# Create data directory and set permissions (Important!)\n# Note: Replace YOUR_USER with actual user running the service (e.g., www-data, ubuntu, deploy)\nsudo mkdir -p /opt/huobao-drama/data/storage\nsudo chown -R YOUR_USER:YOUR_USER /opt/huobao-drama/data\nsudo chmod -R 755 /opt/huobao-drama/data\n\n# Grant execute permission\nchmod +x huobao-drama\n\n# Start service\n./huobao-drama\n```\n\n#### 4. Manage Service with systemd\n\nCreate service file `/etc/systemd/system/huobao-drama.service`:\n\n```ini\n[Unit]\nDescription=Huobao Drama Service\nAfter=network.target\n\n[Service]\nType=simple\nUser=YOUR_USER\nWorkingDirectory=/opt/huobao-drama\nExecStart=/opt/huobao-drama/huobao-drama\nRestart=on-failure\nRestartSec=10\n\n# Environment variables (optional)\n# Environment=\"GIN_MODE=release\"\n\n[Install]\nWantedBy=multi-user.target\n```\n\nStart service:\n\n```bash\nsudo systemctl daemon-reload\nsudo systemctl enable huobao-drama\nsudo systemctl start huobao-drama\nsudo systemctl status huobao-drama\n```\n\n**⚠️ Common Issue: SQLite Write Permission Error**\n\nIf you encounter `attempt to write a readonly database` error:\n\n```bash\n# 1. Check current user running the service\nsudo systemctl status huobao-drama | grep \"Main PID\"\nps aux | grep huobao-drama\n\n# 2. Fix permissions (replace YOUR_USER with actual username)\nsudo chown -R YOUR_USER:YOUR_USER /opt/huobao-drama/data\nsudo chmod -R 755 /opt/huobao-drama/data\n\n# 3. Verify permissions\nls -la /opt/huobao-drama/data\n# Should show owner as the user running the service\n\n# 4. Restart service\nsudo systemctl restart huobao-drama\n```\n\n**Reason:**\n\n- SQLite requires write permission on both the database file **and** its directory\n- Needs to create temporary files in the directory (e.g., `-wal`, `-journal`)\n- **Key**: Ensure systemd `User` matches data directory owner\n\n**Common Usernames:**\n\n- Ubuntu/Debian: `www-data`, `ubuntu`\n- CentOS/RHEL: `nginx`, `apache`\n- Custom deployment: `deploy`, `app`, current logged-in user\n\n#### 5. Nginx Reverse Proxy\n\n```nginx\nserver {\n    listen 80;\n    server_name your-domain.com;\n\n    location / {\n        proxy_pass http://localhost:5678;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    }\n\n    # Direct access to static files\n    location /static/ {\n        alias /opt/huobao-drama/data/storage/;\n    }\n}\n```\n\n---\n\n## 🎨 Tech Stack\n\n### Backend\n\n- **Language**: Go 1.23+\n- **Web Framework**: Gin 1.9+\n- **ORM**: GORM\n- **Database**: SQLite\n- **Logging**: Zap\n- **Video Processing**: FFmpeg\n- **AI Services**: OpenAI, Gemini, Doubao, etc.\n\n### Frontend\n\n- **Framework**: Vue 3.4+\n- **Language**: TypeScript 5+\n- **Build Tool**: Vite 5\n- **UI Components**: Element Plus\n- **CSS Framework**: TailwindCSS\n- **State Management**: Pinia\n- **Router**: Vue Router 4\n\n### Development Tools\n\n- **Package Management**: Go Modules, npm\n- **Code Standards**: ESLint, Prettier\n- **Version Control**: Git\n\n---\n\n## 📝 FAQ\n\n### Q: How can Docker containers access Ollama on the host?\n\nA: Use `http://host.docker.internal:11434/v1` as Base URL. Note two things:\n\n1. Host Ollama needs to listen on `0.0.0.0`: `export OLLAMA_HOST=0.0.0.0:11434 && ollama serve`\n2. Linux users using `docker run` need to add: `--add-host=host.docker.internal:host-gateway`\n\nSee: [DOCKER_HOST_ACCESS.md](docs/DOCKER_HOST_ACCESS.md)\n\n### Q: FFmpeg not installed or not found?\n\nA: Ensure FFmpeg is installed and in the PATH environment variable. Verify with `ffmpeg -version`.\n\n### Q: Frontend cannot connect to backend API?\n\nA: Check if backend is running and port is correct. In development mode, frontend proxy config is in `web/vite.config.ts`.\n\n### Q: Database tables not created?\n\nA: GORM automatically creates tables on first startup, check logs to confirm migration success.\n\n---\n\n## 📋 Changelog\n\n### v1.0.5 (2026-02-06)\n\n#### 🎨 Major Features\n\n- **🎭 Global Style System**: Introduced comprehensive style selection support across the entire project. Users can now define custom visual styles at the drama level, which automatically applies to all AI-generated content including characters, scenes, and storyboards, ensuring consistent artistic direction throughout the production.\n\n- **✂️ Nine-Grid Sequence Image Cropping**: Added cropping tool for action sequence images. Users can now extract individual frames from 3x3 grid layouts and designate them as first frames, last frames, or keyframes for video generation, providing greater flexibility in shot composition and continuity.\n\n#### 🚀 Enhancements\n\n- **📐 Optimized Action Sequence Grid**: Enhanced the visual quality and layout of nine-grid action sequence images with improved spacing, alignment, and frame transitions.\n\n- **🔧 Manual Grid Assembly**: Introduced manual grid composition tools supporting 2x2 (four-grid), 2x3 (six-grid), and 3x3 (nine-grid) layouts, allowing users to create custom action sequences from individual frames.\n\n- **🗑️ Content Management**: Added delete functionality for both generated images and videos, enabling better asset organization and storage management.\n\n### v1.0.4 (2026-01-27)\n\n#### 🚀 Major Updates\n\n- Introduced local storage strategy for generated content caching, effectively mitigating external resource link expiration risks\n- Implemented Base64 encoding for embedded reference image transmission\n- Fixed issue where shot image prompt state was not reset when switching shots\n- Fixed issue where video duration displayed as 0 when adding library videos\n- Added scene migration to episodes\n\n#### Historical Data Migration\n\n- Added migration script for processing historical data. For detailed instructions, please refer to [MIGRATE_README.md](MIGRATE_README.md)\n\n### v1.0.3 (2026-01-16)\n\n#### 🚀 Major Updates\n\n- Pure Go SQLite driver (`modernc.org/sqlite`), supports `CGO_ENABLED=0` cross-platform compilation\n- Optimized concurrency performance (WAL mode), resolved \"database is locked\" errors\n- Docker cross-platform support for `host.docker.internal` to access host services\n- Streamlined documentation and deployment guides\n\n### v1.0.2 (2026-01-14)\n\n#### 🐛 Bug Fixes / 🔧 Improvements\n\n- Fixed video generation API response parsing issues\n- Added OpenAI Sora video endpoint configuration\n- Optimized error handling and logging\n\n---\n\n## 🤝 Contributing\n\nIssues and Pull Requests are welcome!\n\n1. Fork this project\n2. Create a feature branch (`git checkout -b feature/AmazingFeature`)\n3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)\n4. Push to the branch (`git push origin feature/AmazingFeature`)\n5. Open a Pull Request\n\n---\n\n## API Configuration Site\n\nConfigure in 2 minutes: [API Aggregation Site](https://api.chatfire.site/models)\n\n---\n\n## 👨‍💻 About Us\n\n**AI Huobao - AI Studio Startup**\n\n- 🏠 **Location**: Nanjing, China\n- 🚀 **Status**: Startup in Progress\n- 📧 **Email**: [18550175439@163.com](mailto:18550175439@163.com)\n- 🐙 **GitHub**: [https://github.com/chatfire-AI/huobao-drama](https://github.com/chatfire-AI/huobao-drama)\n\n> _\"Let AI help us do more creative things\"_\n\n## Community Group\n\n![Community Group](drama.png)\n\n- Submit [Issue](../../issues)\n- Email project maintainers\n\n---\n\n<div align=\"center\">\n\n**⭐ If this project helps you, please give it a Star!**\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=chatfire-AI/huobao-drama&type=date&legend=top-left)](https://www.star-history.com/#chatfire-AI/huobao-drama&type=date&legend=top-left)\n\nMade with ❤️ by Huobao Team\n\n</div>\n"
  },
  {
    "path": "api/handlers/ai_config.go",
    "content": "package handlers\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/drama-generator/backend/application/services\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n\t\"gorm.io/gorm\"\n)\n\ntype AIConfigHandler struct {\n\taiService *services.AIService\n\tlog       *logger.Logger\n}\n\nfunc NewAIConfigHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *AIConfigHandler {\n\treturn &AIConfigHandler{\n\t\taiService: services.NewAIService(db, log),\n\t\tlog:       log,\n\t}\n}\n\nfunc (h *AIConfigHandler) CreateConfig(c *gin.Context) {\n\tvar req services.CreateAIConfigRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tconfig, err := h.aiService.CreateConfig(&req)\n\tif err != nil {\n\t\tresponse.InternalError(c, \"创建失败\")\n\t\treturn\n\t}\n\n\tresponse.Created(c, config)\n}\n\nfunc (h *AIConfigHandler) GetConfig(c *gin.Context) {\n\n\tconfigID, err := strconv.ParseUint(c.Param(\"id\"), 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"无效的配置ID\")\n\t\treturn\n\t}\n\n\tconfig, err := h.aiService.GetConfig(uint(configID))\n\tif err != nil {\n\t\tif err.Error() == \"config not found\" {\n\t\t\tresponse.NotFound(c, \"配置不存在\")\n\t\t\treturn\n\t\t}\n\t\tresponse.InternalError(c, \"获取失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, config)\n}\n\nfunc (h *AIConfigHandler) ListConfigs(c *gin.Context) {\n\n\tserviceType := c.Query(\"service_type\")\n\n\tconfigs, err := h.aiService.ListConfigs(serviceType)\n\tif err != nil {\n\t\tresponse.InternalError(c, \"获取列表失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, configs)\n}\n\nfunc (h *AIConfigHandler) UpdateConfig(c *gin.Context) {\n\n\tconfigID, err := strconv.ParseUint(c.Param(\"id\"), 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"无效的配置ID\")\n\t\treturn\n\t}\n\n\tvar req services.UpdateAIConfigRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tconfig, err := h.aiService.UpdateConfig(uint(configID), &req)\n\tif err != nil {\n\t\tif err.Error() == \"config not found\" {\n\t\t\tresponse.NotFound(c, \"配置不存在\")\n\t\t\treturn\n\t\t}\n\t\tresponse.InternalError(c, \"更新失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, config)\n}\n\nfunc (h *AIConfigHandler) DeleteConfig(c *gin.Context) {\n\n\tconfigID, err := strconv.ParseUint(c.Param(\"id\"), 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"无效的配置ID\")\n\t\treturn\n\t}\n\n\tif err := h.aiService.DeleteConfig(uint(configID)); err != nil {\n\t\tif err.Error() == \"config not found\" {\n\t\t\tresponse.NotFound(c, \"配置不存在\")\n\t\t\treturn\n\t\t}\n\t\tresponse.InternalError(c, \"删除失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"删除成功\"})\n}\n\nfunc (h *AIConfigHandler) TestConnection(c *gin.Context) {\n\tvar req services.TestConnectionRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tif err := h.aiService.TestConnection(&req); err != nil {\n\t\tresponse.BadRequest(c, \"连接测试失败: \"+err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"连接测试成功\"})\n}\n"
  },
  {
    "path": "api/handlers/asset.go",
    "content": "package handlers\n\nimport (\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/drama-generator/backend/application/services\"\n\t\"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n\t\"gorm.io/gorm\"\n)\n\ntype AssetHandler struct {\n\tassetService *services.AssetService\n\tlog          *logger.Logger\n}\n\nfunc NewAssetHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *AssetHandler {\n\treturn &AssetHandler{\n\t\tassetService: services.NewAssetService(db, log),\n\t\tlog:          log,\n\t}\n}\n\nfunc (h *AssetHandler) CreateAsset(c *gin.Context) {\n\n\tvar req services.CreateAssetRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tasset, err := h.assetService.CreateAsset(&req)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to create asset\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, asset)\n}\n\nfunc (h *AssetHandler) UpdateAsset(c *gin.Context) {\n\n\tassetID, err := strconv.ParseUint(c.Param(\"id\"), 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"无效的ID\")\n\t\treturn\n\t}\n\n\tvar req services.UpdateAssetRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tasset, err := h.assetService.UpdateAsset(uint(assetID), &req)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to update asset\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, asset)\n}\n\nfunc (h *AssetHandler) GetAsset(c *gin.Context) {\n\n\tassetID, err := strconv.ParseUint(c.Param(\"id\"), 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"无效的ID\")\n\t\treturn\n\t}\n\n\tasset, err := h.assetService.GetAsset(uint(assetID))\n\tif err != nil {\n\t\tresponse.NotFound(c, \"素材不存在\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, asset)\n}\n\nfunc (h *AssetHandler) ListAssets(c *gin.Context) {\n\n\tvar dramaID *string\n\tif dramaIDStr := c.Query(\"drama_id\"); dramaIDStr != \"\" {\n\t\tdramaID = &dramaIDStr\n\t}\n\n\tvar episodeID *uint\n\tif episodeIDStr := c.Query(\"episode_id\"); episodeIDStr != \"\" {\n\t\tif id, err := strconv.ParseUint(episodeIDStr, 10, 32); err == nil {\n\t\t\tuid := uint(id)\n\t\t\tepisodeID = &uid\n\t\t}\n\t}\n\n\tvar storyboardID *uint\n\tif storyboardIDStr := c.Query(\"storyboard_id\"); storyboardIDStr != \"\" {\n\t\tif id, err := strconv.ParseUint(storyboardIDStr, 10, 32); err == nil {\n\t\t\tuid := uint(id)\n\t\t\tstoryboardID = &uid\n\t\t}\n\t}\n\n\tvar assetType *models.AssetType\n\tif typeStr := c.Query(\"type\"); typeStr != \"\" {\n\t\tt := models.AssetType(typeStr)\n\t\tassetType = &t\n\t}\n\n\tvar isFavorite *bool\n\tif favoriteStr := c.Query(\"is_favorite\"); favoriteStr != \"\" {\n\t\tif favoriteStr == \"true\" {\n\t\t\tfav := true\n\t\t\tisFavorite = &fav\n\t\t} else if favoriteStr == \"false\" {\n\t\t\tfav := false\n\t\t\tisFavorite = &fav\n\t\t}\n\t}\n\n\tvar tagIDs []uint\n\tif tagIDsStr := c.Query(\"tag_ids\"); tagIDsStr != \"\" {\n\t\tfor _, idStr := range strings.Split(tagIDsStr, \",\") {\n\t\t\tif id, err := strconv.ParseUint(strings.TrimSpace(idStr), 10, 32); err == nil {\n\t\t\t\ttagIDs = append(tagIDs, uint(id))\n\t\t\t}\n\t\t}\n\t}\n\n\tpage, _ := strconv.Atoi(c.DefaultQuery(\"page\", \"1\"))\n\tpageSize, _ := strconv.Atoi(c.DefaultQuery(\"page_size\", \"20\"))\n\n\tif page < 1 {\n\t\tpage = 1\n\t}\n\tif pageSize < 1 || pageSize > 100 {\n\t\tpageSize = 20\n\t}\n\n\treq := &services.ListAssetsRequest{\n\t\tDramaID:      dramaID,\n\t\tEpisodeID:    episodeID,\n\t\tStoryboardID: storyboardID,\n\t\tType:         assetType,\n\t\tCategory:     c.Query(\"category\"),\n\t\tTagIDs:       tagIDs,\n\t\tIsFavorite:   isFavorite,\n\t\tSearch:       c.Query(\"search\"),\n\t\tPage:         page,\n\t\tPageSize:     pageSize,\n\t}\n\n\tassets, total, err := h.assetService.ListAssets(req)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to list assets\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.SuccessWithPagination(c, assets, total, page, pageSize)\n}\n\nfunc (h *AssetHandler) DeleteAsset(c *gin.Context) {\n\n\tassetID, err := strconv.ParseUint(c.Param(\"id\"), 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"无效的ID\")\n\t\treturn\n\t}\n\n\tif err := h.assetService.DeleteAsset(uint(assetID)); err != nil {\n\t\th.log.Errorw(\"Failed to delete asset\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, nil)\n}\n\nfunc (h *AssetHandler) ImportFromImageGen(c *gin.Context) {\n\n\timageGenID, err := strconv.ParseUint(c.Param(\"image_gen_id\"), 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"无效的ID\")\n\t\treturn\n\t}\n\n\tasset, err := h.assetService.ImportFromImageGen(uint(imageGenID))\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to import from image gen\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, asset)\n}\n\nfunc (h *AssetHandler) ImportFromVideoGen(c *gin.Context) {\n\n\tvideoGenID, err := strconv.ParseUint(c.Param(\"video_gen_id\"), 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"无效的ID\")\n\t\treturn\n\t}\n\n\tasset, err := h.assetService.ImportFromVideoGen(uint(videoGenID))\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to import from video gen\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, asset)\n}\n"
  },
  {
    "path": "api/handlers/audio_extraction.go",
    "content": "package handlers\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/drama-generator/backend/application/services\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/gin-gonic/gin\"\n)\n\ntype AudioExtractionHandler struct {\n\tservice *services.AudioExtractionService\n\tlog     *logger.Logger\n\tdataDir string\n}\n\nfunc NewAudioExtractionHandler(log *logger.Logger, dataDir string) *AudioExtractionHandler {\n\treturn &AudioExtractionHandler{\n\t\tservice: services.NewAudioExtractionService(log),\n\t\tlog:     log,\n\t\tdataDir: dataDir,\n\t}\n}\n\n// ExtractAudio 提取单个视频的音频\n// @Summary 提取视频音频\n// @Description 从视频URL中提取音频轨道\n// @Tags Audio\n// @Accept json\n// @Produce json\n// @Param request body services.ExtractAudioRequest true \"提取请求\"\n// @Success 200 {object} services.ExtractAudioResponse\n// @Router /api/audio/extract [post]\nfunc (h *AudioExtractionHandler) ExtractAudio(c *gin.Context) {\n\tvar req services.ExtractAudioRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\th.log.Errorw(\"Invalid request body\", \"error\", err)\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Invalid request body\"})\n\t\treturn\n\t}\n\n\th.log.Infow(\"Received audio extraction request\", \"video_url\", req.VideoURL)\n\n\tresult, err := h.service.ExtractAudio(req.VideoURL, h.dataDir)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to extract audio\", \"error\", err, \"video_url\", req.VideoURL)\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, result)\n}\n\ntype BatchExtractAudioRequest struct {\n\tVideoURLs []string `json:\"video_urls\" binding:\"required,min=1\"`\n}\n\n// BatchExtractAudio 批量提取音频\n// @Summary 批量提取视频音频\n// @Description 从多个视频URL中提取音频轨道\n// @Tags Audio\n// @Accept json\n// @Produce json\n// @Param request body BatchExtractAudioRequest true \"批量提取请求\"\n// @Success 200 {array} services.ExtractAudioResponse\n// @Router /api/audio/extract/batch [post]\nfunc (h *AudioExtractionHandler) BatchExtractAudio(c *gin.Context) {\n\tvar req BatchExtractAudioRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\th.log.Errorw(\"Invalid request body\", \"error\", err)\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Invalid request body\"})\n\t\treturn\n\t}\n\n\th.log.Infow(\"Received batch audio extraction request\", \"count\", len(req.VideoURLs))\n\n\tresults, err := h.service.BatchExtractAudio(req.VideoURLs, h.dataDir)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to batch extract audio\", \"error\", err)\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"results\": results,\n\t\t\"total\":   len(results),\n\t})\n}\n"
  },
  {
    "path": "api/handlers/character_batch.go",
    "content": "package handlers\n\nimport (\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// BatchGenerateCharacterImages 批量生成角色图片\nfunc (h *CharacterLibraryHandler) BatchGenerateCharacterImages(c *gin.Context) {\n\n\tvar req struct {\n\t\tCharacterIDs []string `json:\"character_ids\" binding:\"required,min=1\"`\n\t\tModel        string   `json:\"model\"`\n\t}\n\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\t// 限制批量生成数量\n\tif len(req.CharacterIDs) > 10 {\n\t\tresponse.BadRequest(c, \"单次最多生成10个角色\")\n\t\treturn\n\t}\n\n\t// 异步批量生成\n\tgo h.libraryService.BatchGenerateCharacterImages(req.CharacterIDs, h.imageService, req.Model)\n\n\tresponse.Success(c, gin.H{\n\t\t\"message\": \"批量生成任务已提交\",\n\t\t\"count\":   len(req.CharacterIDs),\n\t})\n}\n"
  },
  {
    "path": "api/handlers/character_library.go",
    "content": "package handlers\n\nimport (\n\t\"strconv\"\n\n\tservices2 \"github.com/drama-generator/backend/application/services\"\n\t\"github.com/drama-generator/backend/infrastructure/storage\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n\t\"gorm.io/gorm\"\n)\n\ntype CharacterLibraryHandler struct {\n\tlibraryService *services2.CharacterLibraryService\n\timageService   *services2.ImageGenerationService\n\tlog            *logger.Logger\n}\n\nfunc NewCharacterLibraryHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, transferService *services2.ResourceTransferService, localStorage *storage.LocalStorage) *CharacterLibraryHandler {\n\treturn &CharacterLibraryHandler{\n\t\tlibraryService: services2.NewCharacterLibraryService(db, log, cfg),\n\t\timageService:   services2.NewImageGenerationService(db, cfg, transferService, localStorage, log),\n\t\tlog:            log,\n\t}\n}\n\n// ListLibraryItems 获取角色库列表\nfunc (h *CharacterLibraryHandler) ListLibraryItems(c *gin.Context) {\n\n\tvar query services2.CharacterLibraryQuery\n\tif err := c.ShouldBindQuery(&query); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tif query.Page < 1 {\n\t\tquery.Page = 1\n\t}\n\tif query.PageSize < 1 || query.PageSize > 100 {\n\t\tquery.PageSize = 20\n\t}\n\n\titems, total, err := h.libraryService.ListLibraryItems(&query)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to list library items\", \"error\", err)\n\t\tresponse.InternalError(c, \"获取角色库失败\")\n\t\treturn\n\t}\n\n\tresponse.SuccessWithPagination(c, items, total, query.Page, query.PageSize)\n}\n\n// CreateLibraryItem 添加到角色库\nfunc (h *CharacterLibraryHandler) CreateLibraryItem(c *gin.Context) {\n\n\tvar req services2.CreateLibraryItemRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\titem, err := h.libraryService.CreateLibraryItem(&req)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to create library item\", \"error\", err)\n\t\tresponse.InternalError(c, \"添加到角色库失败\")\n\t\treturn\n\t}\n\n\tresponse.Created(c, item)\n}\n\n// GetLibraryItem 获取角色库项详情\nfunc (h *CharacterLibraryHandler) GetLibraryItem(c *gin.Context) {\n\n\titemID := c.Param(\"id\")\n\n\titem, err := h.libraryService.GetLibraryItem(itemID)\n\tif err != nil {\n\t\tif err.Error() == \"library item not found\" {\n\t\t\tresponse.NotFound(c, \"角色库项不存在\")\n\t\t\treturn\n\t\t}\n\t\th.log.Errorw(\"Failed to get library item\", \"error\", err)\n\t\tresponse.InternalError(c, \"获取失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, item)\n}\n\n// DeleteLibraryItem 删除角色库项\nfunc (h *CharacterLibraryHandler) DeleteLibraryItem(c *gin.Context) {\n\n\titemID := c.Param(\"id\")\n\n\tif err := h.libraryService.DeleteLibraryItem(itemID); err != nil {\n\t\tif err.Error() == \"library item not found\" {\n\t\t\tresponse.NotFound(c, \"角色库项不存在\")\n\t\t\treturn\n\t\t}\n\t\th.log.Errorw(\"Failed to delete library item\", \"error\", err)\n\t\tresponse.InternalError(c, \"删除失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"删除成功\"})\n}\n\n// UploadCharacterImage 上传角色图片\nfunc (h *CharacterLibraryHandler) UploadCharacterImage(c *gin.Context) {\n\n\tcharacterID := c.Param(\"id\")\n\n\t// TODO: 处理文件上传\n\t// 这里需要实现文件上传逻辑，保存到OSS或本地\n\t// 暂时使用简单的实现\n\tvar req struct {\n\t\tImageURL string `json:\"image_url\" binding:\"required\"`\n\t}\n\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tif err := h.libraryService.UploadCharacterImage(characterID, req.ImageURL); err != nil {\n\t\tif err.Error() == \"character not found\" {\n\t\t\tresponse.NotFound(c, \"角色不存在\")\n\t\t\treturn\n\t\t}\n\t\tif err.Error() == \"unauthorized\" {\n\t\t\tresponse.Forbidden(c, \"无权限\")\n\t\t\treturn\n\t\t}\n\t\th.log.Errorw(\"Failed to upload character image\", \"error\", err)\n\t\tresponse.InternalError(c, \"上传失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"上传成功\"})\n}\n\n// ApplyLibraryItemToCharacter 从角色库应用形象\nfunc (h *CharacterLibraryHandler) ApplyLibraryItemToCharacter(c *gin.Context) {\n\n\tcharacterID := c.Param(\"id\")\n\n\tvar req struct {\n\t\tLibraryItemID string `json:\"library_item_id\" binding:\"required\"`\n\t}\n\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tif err := h.libraryService.ApplyLibraryItemToCharacter(characterID, req.LibraryItemID); err != nil {\n\t\tif err.Error() == \"library item not found\" {\n\t\t\tresponse.NotFound(c, \"角色库项不存在\")\n\t\t\treturn\n\t\t}\n\t\tif err.Error() == \"character not found\" {\n\t\t\tresponse.NotFound(c, \"角色不存在\")\n\t\t\treturn\n\t\t}\n\t\tif err.Error() == \"unauthorized\" {\n\t\t\tresponse.Forbidden(c, \"无权限\")\n\t\t\treturn\n\t\t}\n\t\th.log.Errorw(\"Failed to apply library item\", \"error\", err)\n\t\tresponse.InternalError(c, \"应用失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"应用成功\"})\n}\n\n// AddCharacterToLibrary 将角色添加到角色库\nfunc (h *CharacterLibraryHandler) AddCharacterToLibrary(c *gin.Context) {\n\n\tcharacterID := c.Param(\"id\")\n\n\tvar req struct {\n\t\tCategory *string `json:\"category\"`\n\t}\n\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t// 允许空body\n\t\treq.Category = nil\n\t}\n\n\titem, err := h.libraryService.AddCharacterToLibrary(characterID, req.Category)\n\tif err != nil {\n\t\tif err.Error() == \"character not found\" {\n\t\t\tresponse.NotFound(c, \"角色不存在\")\n\t\t\treturn\n\t\t}\n\t\tif err.Error() == \"unauthorized\" {\n\t\t\tresponse.Forbidden(c, \"无权限\")\n\t\t\treturn\n\t\t}\n\t\tif err.Error() == \"character has no image\" {\n\t\t\tresponse.BadRequest(c, \"角色还没有形象图片\")\n\t\t\treturn\n\t\t}\n\t\th.log.Errorw(\"Failed to add character to library\", \"error\", err)\n\t\tresponse.InternalError(c, \"添加失败\")\n\t\treturn\n\t}\n\n\tresponse.Created(c, item)\n}\n\n// UpdateCharacter 更新角色信息\nfunc (h *CharacterLibraryHandler) UpdateCharacter(c *gin.Context) {\n\n\tcharacterID := c.Param(\"id\")\n\n\tvar req services2.UpdateCharacterRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tif err := h.libraryService.UpdateCharacter(characterID, &req); err != nil {\n\t\tif err.Error() == \"character not found\" {\n\t\t\tresponse.NotFound(c, \"角色不存在\")\n\t\t\treturn\n\t\t}\n\t\tif err.Error() == \"unauthorized\" {\n\t\t\tresponse.Forbidden(c, \"无权限\")\n\t\t\treturn\n\t\t}\n\t\th.log.Errorw(\"Failed to update character\", \"error\", err)\n\t\tresponse.InternalError(c, \"更新失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"更新成功\"})\n}\n\n// DeleteCharacter 删除单个角色\nfunc (h *CharacterLibraryHandler) DeleteCharacter(c *gin.Context) {\n\n\tcharacterIDStr := c.Param(\"id\")\n\tcharacterID, err := strconv.ParseUint(characterIDStr, 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"无效的角色ID\")\n\t\treturn\n\t}\n\n\tif err := h.libraryService.DeleteCharacter(uint(characterID)); err != nil {\n\t\th.log.Errorw(\"Failed to delete character\", \"error\", err, \"id\", characterID)\n\t\tif err.Error() == \"character not found\" {\n\t\t\tresponse.NotFound(c, \"角色不存在\")\n\t\t\treturn\n\t\t}\n\t\tif err.Error() == \"unauthorized\" {\n\t\t\tresponse.Forbidden(c, \"无权删除此角色\")\n\t\t\treturn\n\t\t}\n\t\tresponse.InternalError(c, \"删除失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"角色已删除\"})\n}\n\n// ExtractCharacters 从剧本提取角色\nfunc (h *CharacterLibraryHandler) ExtractCharacters(c *gin.Context) {\n\tepisodeIDStr := c.Param(\"episode_id\")\n\tepisodeID, err := strconv.ParseUint(episodeIDStr, 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"Invalid episode_id\")\n\t\treturn\n\t}\n\n\ttaskID, err := h.libraryService.ExtractCharactersFromScript(uint(episodeID))\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to extract characters\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"task_id\": taskID, \"message\": \"角色提取任务已提交\"})\n}\n"
  },
  {
    "path": "api/handlers/character_library_gen.go",
    "content": "package handlers\n\nimport (\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// GenerateCharacterImage AI生成角色形象\nfunc (h *CharacterLibraryHandler) GenerateCharacterImage(c *gin.Context) {\n\n\tcharacterID := c.Param(\"id\")\n\n\t// 获取请求体中的model和style参数\n\tvar req struct {\n\t\tModel string `json:\"model\"`\n\t\tStyle string `json:\"style\"`\n\t}\n\tc.ShouldBindJSON(&req)\n\n\timageGen, err := h.libraryService.GenerateCharacterImage(characterID, h.imageService, req.Model, req.Style)\n\tif err != nil {\n\t\tif err.Error() == \"character not found\" {\n\t\t\tresponse.NotFound(c, \"角色不存在\")\n\t\t\treturn\n\t\t}\n\t\tif err.Error() == \"unauthorized\" {\n\t\t\tresponse.Forbidden(c, \"无权限\")\n\t\t\treturn\n\t\t}\n\t\th.log.Errorw(\"Failed to generate character image\", \"error\", err)\n\t\tresponse.InternalError(c, \"生成失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\n\t\t\"message\":          \"角色图片生成已启动\",\n\t\t\"image_generation\": imageGen,\n\t})\n}\n"
  },
  {
    "path": "api/handlers/drama.go",
    "content": "package handlers\n\nimport (\n\t\"encoding/json\"\n\n\t\"github.com/drama-generator/backend/application/services\"\n\t\"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n\t\"gorm.io/gorm\"\n)\n\ntype DramaHandler struct {\n\tdb                *gorm.DB\n\tdramaService      *services.DramaService\n\tvideoMergeService *services.VideoMergeService\n\tlog               *logger.Logger\n}\n\nfunc NewDramaHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, transferService *services.ResourceTransferService) *DramaHandler {\n\treturn &DramaHandler{\n\t\tdb:                db,\n\t\tdramaService:      services.NewDramaService(db, cfg, log),\n\t\tvideoMergeService: services.NewVideoMergeService(db, transferService, cfg.Storage.LocalPath, cfg.Storage.BaseURL, log),\n\t\tlog:               log,\n\t}\n}\n\nfunc (h *DramaHandler) CreateDrama(c *gin.Context) {\n\n\tvar req services.CreateDramaRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tdrama, err := h.dramaService.CreateDrama(&req)\n\tif err != nil {\n\t\tresponse.InternalError(c, \"创建失败\")\n\t\treturn\n\t}\n\n\tresponse.Created(c, drama)\n}\n\nfunc (h *DramaHandler) GetDrama(c *gin.Context) {\n\n\tdramaID := c.Param(\"id\")\n\n\tdrama, err := h.dramaService.GetDrama(dramaID)\n\tif err != nil {\n\t\tif err.Error() == \"drama not found\" {\n\t\t\tresponse.NotFound(c, \"剧本不存在\")\n\t\t\treturn\n\t\t}\n\t\tresponse.InternalError(c, \"获取失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, drama)\n}\n\nfunc (h *DramaHandler) ListDramas(c *gin.Context) {\n\n\tvar query services.DramaListQuery\n\tif err := c.ShouldBindQuery(&query); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tif query.Page < 1 {\n\t\tquery.Page = 1\n\t}\n\tif query.PageSize < 1 || query.PageSize > 100 {\n\t\tquery.PageSize = 20\n\t}\n\n\tdramas, total, err := h.dramaService.ListDramas(&query)\n\tif err != nil {\n\t\tresponse.InternalError(c, \"获取列表失败\")\n\t\treturn\n\t}\n\n\tresponse.SuccessWithPagination(c, dramas, total, query.Page, query.PageSize)\n}\n\nfunc (h *DramaHandler) UpdateDrama(c *gin.Context) {\n\n\tdramaID := c.Param(\"id\")\n\n\tvar req services.UpdateDramaRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tdrama, err := h.dramaService.UpdateDrama(dramaID, &req)\n\tif err != nil {\n\t\tif err.Error() == \"drama not found\" {\n\t\t\tresponse.NotFound(c, \"剧本不存在\")\n\t\t\treturn\n\t\t}\n\t\tresponse.InternalError(c, \"更新失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, drama)\n}\n\nfunc (h *DramaHandler) DeleteDrama(c *gin.Context) {\n\n\tdramaID := c.Param(\"id\")\n\n\tif err := h.dramaService.DeleteDrama(dramaID); err != nil {\n\t\tif err.Error() == \"drama not found\" {\n\t\t\tresponse.NotFound(c, \"剧本不存在\")\n\t\t\treturn\n\t\t}\n\t\tresponse.InternalError(c, \"删除失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"删除成功\"})\n}\n\nfunc (h *DramaHandler) GetDramaStats(c *gin.Context) {\n\n\tstats, err := h.dramaService.GetDramaStats()\n\tif err != nil {\n\t\tresponse.InternalError(c, \"获取统计失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, stats)\n}\n\nfunc (h *DramaHandler) SaveOutline(c *gin.Context) {\n\n\tdramaID := c.Param(\"id\")\n\n\tvar req services.SaveOutlineRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tif err := h.dramaService.SaveOutline(dramaID, &req); err != nil {\n\t\tif err.Error() == \"drama not found\" {\n\t\t\tresponse.NotFound(c, \"剧本不存在\")\n\t\t\treturn\n\t\t}\n\t\tresponse.InternalError(c, \"保存失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"保存成功\"})\n}\n\nfunc (h *DramaHandler) GetCharacters(c *gin.Context) {\n\n\tdramaID := c.Param(\"id\")\n\tepisodeID := c.Query(\"episode_id\") // 可选：如果提供则只返回该章节的角色\n\n\tvar episodeIDPtr *string\n\tif episodeID != \"\" {\n\t\tepisodeIDPtr = &episodeID\n\t}\n\n\tcharacters, err := h.dramaService.GetCharacters(dramaID, episodeIDPtr)\n\tif err != nil {\n\t\tif err.Error() == \"drama not found\" {\n\t\t\tresponse.NotFound(c, \"剧本不存在\")\n\t\t\treturn\n\t\t}\n\t\tif err.Error() == \"episode not found\" {\n\t\t\tresponse.NotFound(c, \"章节不存在\")\n\t\t\treturn\n\t\t}\n\t\tresponse.InternalError(c, \"获取角色失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, characters)\n}\n\nfunc (h *DramaHandler) SaveCharacters(c *gin.Context) {\n\tdramaID := c.Param(\"id\")\n\n\tvar req services.SaveCharactersRequest\n\n\t// 先尝试正常绑定JSON\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t// 如果绑定失败，检查是否是因为characters字段是字符串而不是数组\n\t\tvar rawReq map[string]interface{}\n\t\tif err := c.ShouldBindJSON(&rawReq); err != nil {\n\t\t\t// 如果连rawReq都绑定失败，直接返回错误\n\t\t\tresponse.BadRequest(c, err.Error())\n\t\t\treturn\n\t\t}\n\n\t\t// 检查characters字段类型\n\t\tif charField, ok := rawReq[\"characters\"]; ok {\n\t\t\tif charStr, ok := charField.(string); ok {\n\t\t\t\t// 如果characters是字符串，尝试解析为JSON数组\n\t\t\t\tvar characters []models.Character\n\t\t\t\tif err := json.Unmarshal([]byte(charStr), &characters); err != nil {\n\t\t\t\t\t// 解析失败，返回错误\n\t\t\t\t\tresponse.BadRequest(c, \"characters字段格式错误，需要JSON数组或字符串格式的JSON数组\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// 手动构造请求对象\n\t\t\t\treq.Characters = characters\n\n\t\t\t\t// 处理episode_id字段\n\t\t\t\tif epID, ok := rawReq[\"episode_id\"]; ok {\n\t\t\t\t\tif epIDStr, ok := epID.(float64); ok {\n\t\t\t\t\t\tepIDUint := uint(epIDStr)\n\t\t\t\t\t\treq.EpisodeID = &epIDUint\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// 如果characters不是字符串，直接返回原始错误\n\t\t\t\tresponse.BadRequest(c, err.Error())\n\t\t\t\treturn\n\t\t\t}\n\t\t} else {\n\t\t\t// 如果没有characters字段，返回原始错误\n\t\t\tresponse.BadRequest(c, err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tif err := h.dramaService.SaveCharacters(dramaID, &req); err != nil {\n\t\tif err.Error() == \"drama not found\" {\n\t\t\tresponse.NotFound(c, \"剧本不存在\")\n\t\t\treturn\n\t\t}\n\t\tresponse.InternalError(c, \"保存失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"保存成功\"})\n}\n\nfunc (h *DramaHandler) SaveEpisodes(c *gin.Context) {\n\n\tdramaID := c.Param(\"id\")\n\n\tvar req services.SaveEpisodesRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tif err := h.dramaService.SaveEpisodes(dramaID, &req); err != nil {\n\t\tif err.Error() == \"drama not found\" {\n\t\t\tresponse.NotFound(c, \"剧本不存在\")\n\t\t\treturn\n\t\t}\n\t\tresponse.InternalError(c, \"保存失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"保存成功\"})\n}\n\nfunc (h *DramaHandler) SaveProgress(c *gin.Context) {\n\n\tdramaID := c.Param(\"id\")\n\n\tvar req services.SaveProgressRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tif err := h.dramaService.SaveProgress(dramaID, &req); err != nil {\n\t\tif err.Error() == \"drama not found\" {\n\t\t\tresponse.NotFound(c, \"剧本不存在\")\n\t\t\treturn\n\t\t}\n\t\tresponse.InternalError(c, \"保存失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"保存成功\"})\n}\n\n// FinalizeEpisode 完成集数制作（触发视频合成）\nfunc (h *DramaHandler) FinalizeEpisode(c *gin.Context) {\n\n\tepisodeID := c.Param(\"episode_id\")\n\tif episodeID == \"\" {\n\t\tresponse.BadRequest(c, \"episode_id不能为空\")\n\t\treturn\n\t}\n\n\t// 尝试读取时间线数据（可选）\n\tvar timelineData *services.FinalizeEpisodeRequest\n\tif err := c.ShouldBindJSON(&timelineData); err != nil {\n\t\t// 如果没有请求体或解析失败，使用nil（将使用默认场景顺序）\n\t\th.log.Warnw(\"No timeline data provided, will use default scene order\", \"error\", err)\n\t\ttimelineData = nil\n\t} else if timelineData != nil {\n\t\th.log.Infow(\"Received timeline data\", \"clips_count\", len(timelineData.Clips), \"episode_id\", episodeID)\n\t}\n\n\t// 触发视频合成任务\n\tresult, err := h.videoMergeService.FinalizeEpisode(episodeID, timelineData)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to finalize episode\", \"error\", err, \"episode_id\", episodeID)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, result)\n}\n\n// DownloadEpisodeVideo 下载剧集视频\nfunc (h *DramaHandler) DownloadEpisodeVideo(c *gin.Context) {\n\n\tepisodeID := c.Param(\"episode_id\")\n\tif episodeID == \"\" {\n\t\tresponse.BadRequest(c, \"episode_id不能为空\")\n\t\treturn\n\t}\n\n\t// 查询episode\n\tvar episode models.Episode\n\tif err := h.db.Preload(\"Drama\").Where(\"id = ?\", episodeID).First(&episode).Error; err != nil {\n\t\tresponse.NotFound(c, \"剧集不存在\")\n\t\treturn\n\t}\n\n\t// 检查是否有视频\n\tif episode.VideoURL == nil || *episode.VideoURL == \"\" {\n\t\tresponse.BadRequest(c, \"该剧集还没有生成视频\")\n\t\treturn\n\t}\n\n\t// 返回视频URL，让前端重定向下载\n\tc.JSON(200, gin.H{\n\t\t\"video_url\":      *episode.VideoURL,\n\t\t\"title\":          episode.Title,\n\t\t\"episode_number\": episode.EpisodeNum,\n\t})\n}\n"
  },
  {
    "path": "api/handlers/frame_prompt.go",
    "content": "package handlers\n\nimport (\n\t\"github.com/drama-generator/backend/application/services\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// FramePromptHandler 处理帧提示词生成请求\ntype FramePromptHandler struct {\n\tframePromptService *services.FramePromptService\n\tlog                *logger.Logger\n}\n\n// NewFramePromptHandler 创建帧提示词处理器\nfunc NewFramePromptHandler(framePromptService *services.FramePromptService, log *logger.Logger) *FramePromptHandler {\n\treturn &FramePromptHandler{\n\t\tframePromptService: framePromptService,\n\t\tlog:                log,\n\t}\n}\n\n// GenerateFramePrompt 生成指定类型的帧提示词\n// POST /api/v1/storyboards/:id/frame-prompt\nfunc (h *FramePromptHandler) GenerateFramePrompt(c *gin.Context) {\n\tstoryboardID := c.Param(\"id\")\n\n\tvar req struct {\n\t\tFrameType  string `json:\"frame_type\"`\n\t\tPanelCount int    `json:\"panel_count\"`\n\t\tModel      string `json:\"model\"`\n\t}\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tserviceReq := services.GenerateFramePromptRequest{\n\t\tStoryboardID: storyboardID,\n\t\tFrameType:    services.FrameType(req.FrameType),\n\t\tPanelCount:   req.PanelCount,\n\t}\n\n\t// 直接调用服务层的异步方法，该方法会创建任务并返回任务ID\n\ttaskID, err := h.framePromptService.GenerateFramePrompt(serviceReq, req.Model)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to generate frame prompt\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\t// 立即返回任务ID\n\tresponse.Success(c, gin.H{\n\t\t\"task_id\": taskID,\n\t\t\"status\":  \"pending\",\n\t\t\"message\": \"帧提示词生成任务已创建，正在后台处理...\",\n\t})\n}\n"
  },
  {
    "path": "api/handlers/frame_prompt_query.go",
    "content": "package handlers\n\nimport (\n\t\"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n\t\"gorm.io/gorm\"\n)\n\n// GetStoryboardFramePrompts 查询镜头的所有帧提示词\n// GET /api/v1/storyboards/:id/frame-prompts\nfunc GetStoryboardFramePrompts(db *gorm.DB, log *logger.Logger) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstoryboardID := c.Param(\"id\")\n\n\t\tvar framePrompts []models.FramePrompt\n\t\tif err := db.Where(\"storyboard_id = ?\", storyboardID).\n\t\t\tOrder(\"created_at DESC\").\n\t\t\tFind(&framePrompts).Error; err != nil {\n\t\t\tlog.Errorw(\"Failed to query frame prompts\", \"error\", err)\n\t\t\tresponse.InternalError(c, err.Error())\n\t\t\treturn\n\t\t}\n\n\t\tresponse.Success(c, gin.H{\n\t\t\t\"frame_prompts\": framePrompts,\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "api/handlers/image_generation.go",
    "content": "package handlers\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/drama-generator/backend/application/services\"\n\t\"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/infrastructure/storage\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n\t\"gorm.io/gorm\"\n)\n\ntype ImageGenerationHandler struct {\n\timageService *services.ImageGenerationService\n\ttaskService  *services.TaskService\n\tlog          *logger.Logger\n\tconfig       *config.Config\n\tdb           *gorm.DB\n}\n\nfunc NewImageGenerationHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, transferService *services.ResourceTransferService, localStorage *storage.LocalStorage) *ImageGenerationHandler {\n\treturn &ImageGenerationHandler{\n\t\timageService: services.NewImageGenerationService(db, cfg, transferService, localStorage, log),\n\t\ttaskService:  services.NewTaskService(db, log),\n\t\tlog:          log,\n\t\tconfig:       cfg,\n\t\tdb:           db,\n\t}\n}\n\nfunc (h *ImageGenerationHandler) GenerateImage(c *gin.Context) {\n\n\tvar req services.GenerateImageRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\timageGen, err := h.imageService.GenerateImage(&req)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to generate image\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, imageGen)\n}\n\nfunc (h *ImageGenerationHandler) GenerateImagesForScene(c *gin.Context) {\n\n\tsceneID := c.Param(\"scene_id\")\n\n\timages, err := h.imageService.GenerateImagesForScene(sceneID)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to generate images for scene\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, images)\n}\n\nfunc (h *ImageGenerationHandler) GetBackgroundsForEpisode(c *gin.Context) {\n\n\tepisodeID := c.Param(\"episode_id\")\n\n\tbackgrounds, err := h.imageService.GetScencesForEpisode(episodeID)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to get backgrounds\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, backgrounds)\n}\n\nfunc (h *ImageGenerationHandler) ExtractBackgroundsForEpisode(c *gin.Context) {\n\tepisodeID := c.Param(\"episode_id\")\n\n\t// 接收可选的 model 和 style 参数\n\tvar req struct {\n\t\tModel string `json:\"model\"`\n\t\tStyle string `json:\"style\"`\n\t}\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t// 如果没有提供body或者解析失败，使用空字符串（使用默认模型和风格）\n\t\treq.Model = \"\"\n\t\treq.Style = \"\"\n\t}\n\t// 如果style为空，从episode获取drama的style\n\tif req.Style == \"\" {\n\t\tvar episode models.Episode\n\t\tif err := h.db.Preload(\"Drama\").First(&episode, episodeID).Error; err == nil {\n\t\t\treq.Style = episode.Drama.Style\n\t\t}\n\t}\n\n\t// 直接调用服务层的异步方法，该方法会创建任务并返回任务ID\n\ttaskID, err := h.imageService.ExtractBackgroundsForEpisode(episodeID, req.Model, req.Style)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to extract backgrounds\", \"error\", err, \"episode_id\", episodeID)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\t// 立即返回任务ID\n\tresponse.Success(c, gin.H{\n\t\t\"task_id\": taskID,\n\t\t\"status\":  \"pending\",\n\t\t\"message\": \"场景提取任务已创建，正在后台处理...\",\n\t})\n}\n\nfunc (h *ImageGenerationHandler) BatchGenerateForEpisode(c *gin.Context) {\n\n\tepisodeID := c.Param(\"episode_id\")\n\n\timages, err := h.imageService.BatchGenerateImagesForEpisode(episodeID)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to batch generate images\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, images)\n}\n\nfunc (h *ImageGenerationHandler) GetImageGeneration(c *gin.Context) {\n\n\timageGenID, err := strconv.ParseUint(c.Param(\"id\"), 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"无效的ID\")\n\t\treturn\n\t}\n\n\timageGen, err := h.imageService.GetImageGeneration(uint(imageGenID))\n\tif err != nil {\n\t\tresponse.NotFound(c, \"图片生成记录不存在\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, imageGen)\n}\n\nfunc (h *ImageGenerationHandler) ListImageGenerations(c *gin.Context) {\n\tvar sceneID *uint\n\tif sceneIDStr := c.Query(\"scene_id\"); sceneIDStr != \"\" {\n\t\tid, err := strconv.ParseUint(sceneIDStr, 10, 32)\n\t\tif err == nil {\n\t\t\tuid := uint(id)\n\t\t\tsceneID = &uid\n\t\t}\n\t}\n\n\tvar storyboardID *uint\n\tif storyboardIDStr := c.Query(\"storyboard_id\"); storyboardIDStr != \"\" {\n\t\tid, err := strconv.ParseUint(storyboardIDStr, 10, 32)\n\t\tif err == nil {\n\t\t\tuid := uint(id)\n\t\t\tstoryboardID = &uid\n\t\t}\n\t}\n\n\tframeType := c.Query(\"frame_type\")\n\tstatus := c.Query(\"status\")\n\tpage, _ := strconv.Atoi(c.DefaultQuery(\"page\", \"1\"))\n\tpageSize, _ := strconv.Atoi(c.DefaultQuery(\"page_size\", \"20\"))\n\n\tif page < 1 {\n\t\tpage = 1\n\t}\n\tif pageSize < 1 || pageSize > 100 {\n\t\tpageSize = 20\n\t}\n\n\tvar dramaIDUint *uint\n\tif dramaIDStr := c.Query(\"drama_id\"); dramaIDStr != \"\" {\n\t\tdid, _ := strconv.ParseUint(dramaIDStr, 10, 32)\n\t\tdidUint := uint(did)\n\t\tdramaIDUint = &didUint\n\t}\n\n\timages, total, err := h.imageService.ListImageGenerations(dramaIDUint, sceneID, storyboardID, frameType, status, page, pageSize)\n\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to list images\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.SuccessWithPagination(c, images, total, page, pageSize)\n}\n\nfunc (h *ImageGenerationHandler) DeleteImageGeneration(c *gin.Context) {\n\n\timageGenID, err := strconv.ParseUint(c.Param(\"id\"), 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"无效的ID\")\n\t\treturn\n\t}\n\n\tif err := h.imageService.DeleteImageGeneration(uint(imageGenID)); err != nil {\n\t\th.log.Errorw(\"Failed to delete image\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, nil)\n}\n\n// UploadImage 上传图片并创建图片生成记录\nfunc (h *ImageGenerationHandler) UploadImage(c *gin.Context) {\n\tvar req struct {\n\t\tStoryboardID uint   `json:\"storyboard_id\" binding:\"required\"`\n\t\tDramaID      uint   `json:\"drama_id\" binding:\"required\"`\n\t\tFrameType    string `json:\"frame_type\" binding:\"required\"`\n\t\tImageURL     string `json:\"image_url\" binding:\"required\"`\n\t\tPrompt       string `json:\"prompt\"`\n\t}\n\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\timageGen, err := h.imageService.CreateImageFromUpload(&services.UploadImageRequest{\n\t\tStoryboardID: req.StoryboardID,\n\t\tDramaID:      req.DramaID,\n\t\tFrameType:    req.FrameType,\n\t\tImageURL:     req.ImageURL,\n\t\tPrompt:       req.Prompt,\n\t})\n\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to create image from upload\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, imageGen)\n}\n"
  },
  {
    "path": "api/handlers/prop.go",
    "content": "package handlers\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/drama-generator/backend/application/services\"\n\t\"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n\t\"gorm.io/gorm\"\n)\n\ntype PropHandler struct {\n\tpropService *services.PropService\n\tlog         *logger.Logger\n}\n\nfunc NewPropHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, aiService *services.AIService, imageGenerationService *services.ImageGenerationService) *PropHandler {\n\treturn &PropHandler{\n\t\tpropService: services.NewPropService(db, aiService, services.NewTaskService(db, log), imageGenerationService, log, cfg),\n\t\tlog:         log,\n\t}\n}\n\n// ListProps 获取道具列表\nfunc (h *PropHandler) ListProps(c *gin.Context) {\n\tdramaIDStr := c.Query(\"drama_id\")\n\tif dramaIDStr == \"\" {\n\t\tresponse.BadRequest(c, \"drama_id is required\")\n\t\treturn\n\t}\n\n\tdramaID, err := strconv.ParseUint(dramaIDStr, 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"Invalid drama_id\")\n\t\treturn\n\t}\n\n\tprops, err := h.propService.ListProps(uint(dramaID))\n\tif err != nil {\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, props)\n}\n\n// CreateProp 创建道具\nfunc (h *PropHandler) CreateProp(c *gin.Context) {\n\tvar prop models.Prop\n\tif err := c.ShouldBindJSON(&prop); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tif err := h.propService.CreateProp(&prop); err != nil {\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Created(c, prop)\n}\n\n// UpdateProp 更新道具\nfunc (h *PropHandler) UpdateProp(c *gin.Context) {\n\tidStr := c.Param(\"id\")\n\tid, err := strconv.ParseUint(idStr, 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"Invalid ID\")\n\t\treturn\n\t}\n\n\tvar updates map[string]interface{}\n\tif err := c.ShouldBindJSON(&updates); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tif err := h.propService.UpdateProp(uint(id), updates); err != nil {\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, nil)\n}\n\n// DeleteProp 删除道具\nfunc (h *PropHandler) DeleteProp(c *gin.Context) {\n\tidStr := c.Param(\"id\")\n\tid, err := strconv.ParseUint(idStr, 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"Invalid ID\")\n\t\treturn\n\t}\n\n\tif err := h.propService.DeleteProp(uint(id)); err != nil {\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, nil)\n}\n\n// ExtractProps 提取道具\nfunc (h *PropHandler) ExtractProps(c *gin.Context) {\n\tepisodeIDStr := c.Param(\"episode_id\")\n\tepisodeID, err := strconv.ParseUint(episodeIDStr, 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"Invalid episode_id\")\n\t\treturn\n\t}\n\n\ttaskID, err := h.propService.ExtractPropsFromScript(uint(episodeID))\n\tif err != nil {\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"task_id\": taskID})\n}\n\n// GenerateImage 生成道具图片\nfunc (h *PropHandler) GenerateImage(c *gin.Context) {\n\tidStr := c.Param(\"id\")\n\tid, err := strconv.ParseUint(idStr, 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"Invalid ID\")\n\t\treturn\n\t}\n\n\ttaskID, err := h.propService.GeneratePropImage(uint(id))\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to generate prop image\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"task_id\": taskID, \"message\": \"图片生成任务已提交\"})\n}\n\n// AssociateProps 关联道具\nfunc (h *PropHandler) AssociateProps(c *gin.Context) {\n\tstoryboardIDStr := c.Param(\"id\")\n\tstoryboardID, err := strconv.ParseUint(storyboardIDStr, 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"Invalid storyboard_id\")\n\t\treturn\n\t}\n\n\tvar req struct {\n\t\tPropIDs []uint `json:\"prop_ids\"`\n\t}\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tif err := h.propService.AssociatePropsWithStoryboard(uint(storyboardID), req.PropIDs); err != nil {\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, nil)\n}\n"
  },
  {
    "path": "api/handlers/scene.go",
    "content": "package handlers\n\nimport (\n\tservices2 \"github.com/drama-generator/backend/application/services\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n\t\"gorm.io/gorm\"\n)\n\ntype SceneHandler struct {\n\tsceneService *services2.StoryboardCompositionService\n\tlog          *logger.Logger\n}\n\nfunc NewSceneHandler(db *gorm.DB, log *logger.Logger, imageGenService *services2.ImageGenerationService) *SceneHandler {\n\treturn &SceneHandler{\n\t\tsceneService: services2.NewStoryboardCompositionService(db, log, imageGenService),\n\t\tlog:          log,\n\t}\n}\n\nfunc (h *SceneHandler) GetStoryboardsForEpisode(c *gin.Context) {\n\tepisodeID := c.Param(\"episode_id\")\n\n\tstoryboards, err := h.sceneService.GetScenesForEpisode(episodeID)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to get storyboards for episode\", \"error\", err, \"episode_id\", episodeID)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\n\t\t\"storyboards\": storyboards,\n\t\t\"total\":       len(storyboards),\n\t})\n}\n\nfunc (h *SceneHandler) UpdateScene(c *gin.Context) {\n\tsceneID := c.Param(\"scene_id\")\n\n\tvar req services2.UpdateSceneInfoRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, \"Invalid request\")\n\t\treturn\n\t}\n\n\tif err := h.sceneService.UpdateSceneInfo(sceneID, &req); err != nil {\n\t\th.log.Errorw(\"Failed to update scene\", \"error\", err, \"scene_id\", sceneID)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"Scene updated successfully\"})\n}\n\nfunc (h *SceneHandler) GenerateSceneImage(c *gin.Context) {\n\tvar req services2.GenerateSceneImageRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, \"Invalid request\")\n\t\treturn\n\t}\n\n\timageGen, err := h.sceneService.GenerateSceneImage(&req)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to generate scene image\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\n\t\t\"message\":          \"Scene image generation started\",\n\t\t\"image_generation\": imageGen,\n\t})\n}\n\nfunc (h *SceneHandler) UpdateScenePrompt(c *gin.Context) {\n\tsceneID := c.Param(\"scene_id\")\n\n\tvar req services2.UpdateScenePromptRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, \"Invalid request\")\n\t\treturn\n\t}\n\n\tif err := h.sceneService.UpdateScenePrompt(sceneID, &req); err != nil {\n\t\th.log.Errorw(\"Failed to update scene prompt\", \"error\", err, \"scene_id\", sceneID)\n\t\tif err.Error() == \"scene not found\" {\n\t\t\tresponse.NotFound(c, \"场景不存在\")\n\t\t\treturn\n\t\t}\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"场景提示词已更新\"})\n}\n\nfunc (h *SceneHandler) DeleteScene(c *gin.Context) {\n\tsceneID := c.Param(\"scene_id\")\n\n\tif err := h.sceneService.DeleteScene(sceneID); err != nil {\n\t\th.log.Errorw(\"Failed to delete scene\", \"error\", err, \"scene_id\", sceneID)\n\t\tif err.Error() == \"scene not found\" {\n\t\t\tresponse.NotFound(c, \"场景不存在\")\n\t\t\treturn\n\t\t}\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"场景已删除\"})\n}\n\nfunc (h *SceneHandler) CreateScene(c *gin.Context) {\n\tvar req services2.CreateSceneRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, \"Invalid request\")\n\t\treturn\n\t}\n\n\tif req.DramaID == 0 {\n\t\tresponse.BadRequest(c, \"drama_id is required\")\n\t\treturn\n\t}\n\n\tscene, err := h.sceneService.CreateScene(&req)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to create scene\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, scene)\n}\n"
  },
  {
    "path": "api/handlers/script_generation.go",
    "content": "package handlers\n\nimport (\n\t\"github.com/drama-generator/backend/application/services\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n\t\"gorm.io/gorm\"\n)\n\ntype ScriptGenerationHandler struct {\n\tscriptService *services.ScriptGenerationService\n\ttaskService   *services.TaskService\n\tlog           *logger.Logger\n}\n\nfunc NewScriptGenerationHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *ScriptGenerationHandler {\n\treturn &ScriptGenerationHandler{\n\t\tscriptService: services.NewScriptGenerationService(db, cfg, log),\n\t\ttaskService:   services.NewTaskService(db, log),\n\t\tlog:           log,\n\t}\n}\n\nfunc (h *ScriptGenerationHandler) GenerateCharacters(c *gin.Context) {\n\tvar req services.GenerateCharactersRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\t// 直接调用服务层的异步方法，该方法会创建任务并返回任务ID\n\ttaskID, err := h.scriptService.GenerateCharacters(&req)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to generate characters\", \"error\", err, \"drama_id\", req.DramaID)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\t// 立即返回任务ID\n\tresponse.Success(c, gin.H{\n\t\t\"task_id\": taskID,\n\t\t\"status\":  \"pending\",\n\t\t\"message\": \"角色生成任务已创建，正在后台处理...\",\n\t})\n}\n"
  },
  {
    "path": "api/handlers/settings.go",
    "content": "package handlers\n\nimport (\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/spf13/viper\"\n)\n\ntype SettingsHandler struct {\n\tconfig *config.Config\n\tlog    *logger.Logger\n}\n\nfunc NewSettingsHandler(cfg *config.Config, log *logger.Logger) *SettingsHandler {\n\treturn &SettingsHandler{\n\t\tconfig: cfg,\n\t\tlog:    log,\n\t}\n}\n\n// GetLanguage 获取当前系统语言\nfunc (h *SettingsHandler) GetLanguage(c *gin.Context) {\n\tlanguage := h.config.App.Language\n\tif language == \"\" {\n\t\tlanguage = \"zh\" // 默认中文\n\t}\n\n\tresponse.Success(c, gin.H{\n\t\t\"language\": language,\n\t})\n}\n\n// UpdateLanguage 更新系统语言\nfunc (h *SettingsHandler) UpdateLanguage(c *gin.Context) {\n\tvar req struct {\n\t\tLanguage string `json:\"language\" binding:\"required,oneof=zh en\"`\n\t}\n\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, \"语言参数错误，只支持 zh 或 en\")\n\t\treturn\n\t}\n\n\t// 更新内存中的配置\n\th.config.App.Language = req.Language\n\n\t// 更新配置文件\n\tviper.Set(\"app.language\", req.Language)\n\tif err := viper.WriteConfig(); err != nil {\n\t\th.log.Warnw(\"Failed to write config file\", \"error\", err)\n\t\t// 即使写入文件失败，内存配置也已更新，仍然可用\n\t}\n\n\th.log.Infow(\"System language updated\", \"language\", req.Language)\n\n\tmessage := \"语言已切换为中文\"\n\tif req.Language == \"en\" {\n\t\tmessage = \"Language switched to English\"\n\t}\n\n\tresponse.Success(c, gin.H{\n\t\t\"message\":  message,\n\t\t\"language\": req.Language,\n\t})\n}\n"
  },
  {
    "path": "api/handlers/storyboard.go",
    "content": "package handlers\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/drama-generator/backend/application/services\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n\t\"gorm.io/gorm\"\n)\n\ntype StoryboardHandler struct {\n\tstoryboardService *services.StoryboardService\n\ttaskService       *services.TaskService\n\tlog               *logger.Logger\n}\n\nfunc NewStoryboardHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *StoryboardHandler {\n\treturn &StoryboardHandler{\n\t\tstoryboardService: services.NewStoryboardService(db, cfg, log),\n\t\ttaskService:       services.NewTaskService(db, log),\n\t\tlog:               log,\n\t}\n}\n\n// GenerateStoryboard 生成分镜头（异步）\nfunc (h *StoryboardHandler) GenerateStoryboard(c *gin.Context) {\n\tepisodeID := c.Param(\"episode_id\")\n\n\t// 接收可选的 model 参数\n\tvar req struct {\n\t\tModel string `json:\"model\"`\n\t}\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t// 如果没有提供body或者解析失败，使用空字符串（使用默认模型）\n\t\treq.Model = \"\"\n\t}\n\n\t// 调用生成服务，该服务已经是异步的，会返回任务ID\n\ttaskID, err := h.storyboardService.GenerateStoryboard(episodeID, req.Model)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to generate storyboard\", \"error\", err, \"episode_id\", episodeID)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\t// 立即返回任务ID\n\tresponse.Success(c, gin.H{\n\t\t\"task_id\": taskID,\n\t\t\"status\":  \"pending\",\n\t\t\"message\": \"分镜头生成任务已创建，正在后台处理...\",\n\t})\n}\n\n// UpdateStoryboard 更新分镜\nfunc (h *StoryboardHandler) UpdateStoryboard(c *gin.Context) {\n\tstoryboardID := c.Param(\"id\")\n\n\tvar req map[string]interface{}\n\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, \"Invalid request body\")\n\t\treturn\n\t}\n\n\terr := h.storyboardService.UpdateStoryboard(storyboardID, req)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to update storyboard\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"Storyboard updated successfully\"})\n}\n\n// CreateStoryboard 创建分镜\nfunc (h *StoryboardHandler) CreateStoryboard(c *gin.Context) {\n\tvar req services.CreateStoryboardRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tsb, err := h.storyboardService.CreateStoryboard(&req)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to create storyboard\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Created(c, sb)\n}\n\n// DeleteStoryboard 删除分镜\nfunc (h *StoryboardHandler) DeleteStoryboard(c *gin.Context) {\n\tstoryboardIDStr := c.Param(\"id\")\n\tstoryboardID, err := strconv.ParseUint(storyboardIDStr, 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"Invalid ID\")\n\t\treturn\n\t}\n\n\tif err := h.storyboardService.DeleteStoryboard(uint(storyboardID)); err != nil {\n\t\th.log.Errorw(\"Failed to delete storyboard\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, nil)\n}\n"
  },
  {
    "path": "api/handlers/task.go",
    "content": "package handlers\n\nimport (\n\t\"github.com/drama-generator/backend/application/services\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n\t\"gorm.io/gorm\"\n)\n\ntype TaskHandler struct {\n\ttaskService *services.TaskService\n\tlog         *logger.Logger\n}\n\nfunc NewTaskHandler(db *gorm.DB, log *logger.Logger) *TaskHandler {\n\treturn &TaskHandler{\n\t\ttaskService: services.NewTaskService(db, log),\n\t\tlog:         log,\n\t}\n}\n\n// GetTaskStatus 获取任务状态\nfunc (h *TaskHandler) GetTaskStatus(c *gin.Context) {\n\ttaskID := c.Param(\"task_id\")\n\n\ttask, err := h.taskService.GetTask(taskID)\n\tif err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\tresponse.NotFound(c, \"任务不存在\")\n\t\t\treturn\n\t\t}\n\t\th.log.Errorw(\"Failed to get task\", \"error\", err, \"task_id\", taskID)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, task)\n}\n\n// GetResourceTasks 获取资源相关的所有任务\nfunc (h *TaskHandler) GetResourceTasks(c *gin.Context) {\n\tresourceID := c.Query(\"resource_id\")\n\tif resourceID == \"\" {\n\t\tresponse.BadRequest(c, \"缺少resource_id参数\")\n\t\treturn\n\t}\n\n\ttasks, err := h.taskService.GetTasksByResource(resourceID)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to get resource tasks\", \"error\", err, \"resource_id\", resourceID)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, tasks)\n}\n"
  },
  {
    "path": "api/handlers/upload.go",
    "content": "package handlers\n\nimport (\n\tservices2 \"github.com/drama-generator/backend/application/services\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n)\n\ntype UploadHandler struct {\n\tuploadService           *services2.UploadService\n\tcharacterLibraryService *services2.CharacterLibraryService\n\tlog                     *logger.Logger\n}\n\nfunc NewUploadHandler(cfg *config.Config, log *logger.Logger, characterLibraryService *services2.CharacterLibraryService) (*UploadHandler, error) {\n\tuploadService, err := services2.NewUploadService(cfg, log)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &UploadHandler{\n\t\tuploadService:           uploadService,\n\t\tcharacterLibraryService: characterLibraryService,\n\t\tlog:                     log,\n\t}, nil\n}\n\n// UploadImage 上传图片\nfunc (h *UploadHandler) UploadImage(c *gin.Context) {\n\t// 获取上传的文件\n\tfile, header, err := c.Request.FormFile(\"file\")\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"请选择文件\")\n\t\treturn\n\t}\n\tdefer file.Close()\n\n\t// 检查文件类型\n\tcontentType := header.Header.Get(\"Content-Type\")\n\tif contentType == \"\" {\n\t\tcontentType = \"application/octet-stream\"\n\t}\n\n\t// 验证是图片类型\n\tallowedTypes := map[string]bool{\n\t\t\"image/jpeg\": true,\n\t\t\"image/jpg\":  true,\n\t\t\"image/png\":  true,\n\t\t\"image/gif\":  true,\n\t\t\"image/webp\": true,\n\t}\n\n\tif !allowedTypes[contentType] {\n\t\tresponse.BadRequest(c, \"只支持图片格式 (jpg, png, gif, webp)\")\n\t\treturn\n\t}\n\n\t// 检查文件大小 (10MB)\n\tif header.Size > 10*1024*1024 {\n\t\tresponse.BadRequest(c, \"文件大小不能超过10MB\")\n\t\treturn\n\t}\n\n\t// 上传到本地存储\n\tresult, err := h.uploadService.UploadCharacterImage(file, header.Filename, contentType)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to upload image\", \"error\", err)\n\t\tresponse.InternalError(c, \"上传失败\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\n\t\t\"url\":        result.URL,\n\t\t\"local_path\": result.LocalPath,\n\t\t\"filename\":   header.Filename,\n\t\t\"size\":       header.Size,\n\t})\n}\n\n// UploadCharacterImage 上传角色图片（带角色ID）\nfunc (h *UploadHandler) UploadCharacterImage(c *gin.Context) {\n\tcharacterID := c.Param(\"id\")\n\n\t// 获取上传的文件\n\tfile, header, err := c.Request.FormFile(\"file\")\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"请选择文件\")\n\t\treturn\n\t}\n\tdefer file.Close()\n\n\t// 检查文件类型\n\tcontentType := header.Header.Get(\"Content-Type\")\n\tif contentType == \"\" {\n\t\tcontentType = \"application/octet-stream\"\n\t}\n\n\t// 验证是图片类型\n\tallowedTypes := map[string]bool{\n\t\t\"image/jpeg\": true,\n\t\t\"image/jpg\":  true,\n\t\t\"image/png\":  true,\n\t\t\"image/gif\":  true,\n\t\t\"image/webp\": true,\n\t}\n\n\tif !allowedTypes[contentType] {\n\t\tresponse.BadRequest(c, \"只支持图片格式 (jpg, png, gif, webp)\")\n\t\treturn\n\t}\n\n\t// 检查文件大小 (10MB)\n\tif header.Size > 10*1024*1024 {\n\t\tresponse.BadRequest(c, \"文件大小不能超过10MB\")\n\t\treturn\n\t}\n\n\t// 上传到本地存储\n\tresult, err := h.uploadService.UploadCharacterImage(file, header.Filename, contentType)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to upload character image\", \"error\", err)\n\t\tresponse.InternalError(c, \"上传失败\")\n\t\treturn\n\t}\n\n\t// 更新角色的image_url字段到数据库\n\terr = h.characterLibraryService.UploadCharacterImage(characterID, result.URL)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to update character image_url\", \"error\", err, \"character_id\", characterID)\n\t\tresponse.InternalError(c, \"更新角色图片失败\")\n\t\treturn\n\t}\n\n\th.log.Infow(\"Character image uploaded and saved\", \"character_id\", characterID, \"url\", result.URL, \"local_path\", result.LocalPath)\n\n\tresponse.Success(c, gin.H{\n\t\t\"url\":        result.URL,\n\t\t\"local_path\": result.LocalPath,\n\t\t\"filename\":   header.Filename,\n\t\t\"size\":       header.Size,\n\t})\n}\n"
  },
  {
    "path": "api/handlers/video_generation.go",
    "content": "package handlers\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/drama-generator/backend/application/services\"\n\t\"github.com/drama-generator/backend/infrastructure/storage\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n\t\"gorm.io/gorm\"\n)\n\ntype VideoGenerationHandler struct {\n\tvideoService *services.VideoGenerationService\n\tlog          *logger.Logger\n}\n\nfunc NewVideoGenerationHandler(db *gorm.DB, transferService *services.ResourceTransferService, localStorage *storage.LocalStorage, aiService *services.AIService, log *logger.Logger, promptI18n *services.PromptI18n) *VideoGenerationHandler {\n\treturn &VideoGenerationHandler{\n\t\tvideoService: services.NewVideoGenerationService(db, transferService, localStorage, aiService, log, promptI18n),\n\t\tlog:          log,\n\t}\n}\n\nfunc (h *VideoGenerationHandler) GenerateVideo(c *gin.Context) {\n\n\tvar req services.GenerateVideoRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, err.Error())\n\t\treturn\n\t}\n\n\tvideoGen, err := h.videoService.GenerateVideo(&req)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to generate video\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, videoGen)\n}\n\nfunc (h *VideoGenerationHandler) GenerateVideoFromImage(c *gin.Context) {\n\n\timageGenID, err := strconv.ParseUint(c.Param(\"image_gen_id\"), 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"无效的图片ID\")\n\t\treturn\n\t}\n\n\tvideoGen, err := h.videoService.GenerateVideoFromImage(uint(imageGenID))\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to generate video from image\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, videoGen)\n}\n\nfunc (h *VideoGenerationHandler) BatchGenerateForEpisode(c *gin.Context) {\n\n\tepisodeID := c.Param(\"episode_id\")\n\n\tvideos, err := h.videoService.BatchGenerateVideosForEpisode(episodeID)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to batch generate videos\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, videos)\n}\n\nfunc (h *VideoGenerationHandler) GetVideoGeneration(c *gin.Context) {\n\n\tvideoGenID, err := strconv.ParseUint(c.Param(\"id\"), 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"无效的ID\")\n\t\treturn\n\t}\n\n\tvideoGen, err := h.videoService.GetVideoGeneration(uint(videoGenID))\n\tif err != nil {\n\t\tresponse.NotFound(c, \"视频生成记录不存在\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, videoGen)\n}\n\nfunc (h *VideoGenerationHandler) ListVideoGenerations(c *gin.Context) {\n\tvar storyboardID *uint\n\t// 优先使用storyboard_id参数\n\tif storyboardIDStr := c.Query(\"storyboard_id\"); storyboardIDStr != \"\" {\n\t\tid, err := strconv.ParseUint(storyboardIDStr, 10, 32)\n\t\tif err == nil {\n\t\t\tuid := uint(id)\n\t\t\tstoryboardID = &uid\n\t\t}\n\t}\n\tstatus := c.Query(\"status\")\n\tpage, _ := strconv.Atoi(c.DefaultQuery(\"page\", \"1\"))\n\tpageSize, _ := strconv.Atoi(c.DefaultQuery(\"page_size\", \"20\"))\n\n\tif page < 1 {\n\t\tpage = 1\n\t}\n\tif pageSize < 1 || pageSize > 100 {\n\t\tpageSize = 20\n\t}\n\n\tvar dramaIDUint *uint\n\tif dramaIDStr := c.Query(\"drama_id\"); dramaIDStr != \"\" {\n\t\tdid, _ := strconv.ParseUint(dramaIDStr, 10, 32)\n\t\tdidUint := uint(did)\n\t\tdramaIDUint = &didUint\n\t}\n\n\t// 计算offset：(page - 1) * pageSize\n\toffset := (page - 1) * pageSize\n\tvideos, total, err := h.videoService.ListVideoGenerations(dramaIDUint, storyboardID, status, pageSize, offset)\n\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to list videos\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.SuccessWithPagination(c, videos, total, page, pageSize)\n}\n\nfunc (h *VideoGenerationHandler) DeleteVideoGeneration(c *gin.Context) {\n\n\tvideoGenID, err := strconv.ParseUint(c.Param(\"id\"), 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"无效的ID\")\n\t\treturn\n\t}\n\n\tif err := h.videoService.DeleteVideoGeneration(uint(videoGenID)); err != nil {\n\t\th.log.Errorw(\"Failed to delete video\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, nil)\n}\n"
  },
  {
    "path": "api/handlers/video_merge.go",
    "content": "package handlers\n\nimport (\n\t\"strconv\"\n\n\tservices2 \"github.com/drama-generator/backend/application/services\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n\t\"gorm.io/gorm\"\n)\n\ntype VideoMergeHandler struct {\n\tmergeService *services2.VideoMergeService\n\tlog          *logger.Logger\n}\n\nfunc NewVideoMergeHandler(db *gorm.DB, transferService *services2.ResourceTransferService, storagePath, baseURL string, log *logger.Logger) *VideoMergeHandler {\n\treturn &VideoMergeHandler{\n\t\tmergeService: services2.NewVideoMergeService(db, transferService, storagePath, baseURL, log),\n\t\tlog:          log,\n\t}\n}\n\nfunc (h *VideoMergeHandler) MergeVideos(c *gin.Context) {\n\tvar req services2.MergeVideoRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.BadRequest(c, \"Invalid request\")\n\t\treturn\n\t}\n\n\tmerge, err := h.mergeService.MergeVideos(&req)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to merge videos\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\n\t\t\"message\": \"Video merge task created\",\n\t\t\"merge\":   merge,\n\t})\n}\n\nfunc (h *VideoMergeHandler) GetMerge(c *gin.Context) {\n\tmergeIDStr := c.Param(\"merge_id\")\n\tmergeID, err := strconv.ParseUint(mergeIDStr, 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"Invalid merge ID\")\n\t\treturn\n\t}\n\n\tmerge, err := h.mergeService.GetMerge(uint(mergeID))\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to get merge\", \"error\", err)\n\t\tresponse.NotFound(c, \"Merge not found\")\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"merge\": merge})\n}\n\nfunc (h *VideoMergeHandler) ListMerges(c *gin.Context) {\n\tepisodeID := c.Query(\"episode_id\")\n\tstatus := c.Query(\"status\")\n\tpage, _ := strconv.Atoi(c.DefaultQuery(\"page\", \"1\"))\n\tpageSize, _ := strconv.Atoi(c.DefaultQuery(\"page_size\", \"20\"))\n\n\tvar episodeIDPtr *string\n\tif episodeID != \"\" {\n\t\tepisodeIDPtr = &episodeID\n\t}\n\n\tmerges, total, err := h.mergeService.ListMerges(episodeIDPtr, status, page, pageSize)\n\tif err != nil {\n\t\th.log.Errorw(\"Failed to list merges\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\n\t\t\"merges\":    merges,\n\t\t\"total\":     total,\n\t\t\"page\":      page,\n\t\t\"page_size\": pageSize,\n\t})\n}\n\nfunc (h *VideoMergeHandler) DeleteMerge(c *gin.Context) {\n\tmergeIDStr := c.Param(\"merge_id\")\n\tmergeID, err := strconv.ParseUint(mergeIDStr, 10, 32)\n\tif err != nil {\n\t\tresponse.BadRequest(c, \"Invalid merge ID\")\n\t\treturn\n\t}\n\n\tif err := h.mergeService.DeleteMerge(uint(mergeID)); err != nil {\n\t\th.log.Errorw(\"Failed to delete merge\", \"error\", err)\n\t\tresponse.InternalError(c, err.Error())\n\t\treturn\n\t}\n\n\tresponse.Success(c, gin.H{\"message\": \"Merge deleted successfully\"})\n}\n"
  },
  {
    "path": "api/middlewares/cors.go",
    "content": "package middlewares\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc CORSMiddleware(allowedOrigins []string) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\torigin := c.Request.Header.Get(\"Origin\")\n\t\tpath := c.Request.URL.Path\n\n\t\t// 检查是否是静态文件路径（/static 或 /assets）\n\t\tisStaticPath := len(path) >= 7 && (path[:7] == \"/static\" || path[:7] == \"/assets\")\n\n\t\tallowed := false\n\t\tfor _, o := range allowedOrigins {\n\t\t\tif o == \"*\" || o == origin {\n\t\t\t\tallowed = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// 对于静态文件，如果有 Origin 头，总是允许跨域访问\n\t\tif isStaticPath && origin != \"\" {\n\t\t\tallowed = true\n\t\t}\n\n\t\tif allowed && origin != \"\" {\n\t\t\tc.Writer.Header().Set(\"Access-Control-Allow-Origin\", origin)\n\t\t} else if allowed && origin == \"\" {\n\t\t\t// 如果没有 Origin 头但是允许的请求，设置为 *\n\t\t\tc.Writer.Header().Set(\"Access-Control-Allow-Origin\", \"*\")\n\t\t}\n\n\t\tc.Writer.Header().Set(\"Access-Control-Allow-Credentials\", \"true\")\n\t\tc.Writer.Header().Set(\"Access-Control-Allow-Headers\", \"Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With\")\n\t\tc.Writer.Header().Set(\"Access-Control-Allow-Methods\", \"POST, OPTIONS, GET, PUT, DELETE, PATCH\")\n\t\tc.Writer.Header().Set(\"Access-Control-Expose-Headers\", \"Content-Length, Content-Type, Content-Disposition\")\n\n\t\tif c.Request.Method == \"OPTIONS\" {\n\t\t\tc.AbortWithStatus(204)\n\t\t\treturn\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "api/middlewares/logger.go",
    "content": "package middlewares\n\nimport (\n\t\"time\"\n\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc LoggerMiddleware(log *logger.Logger) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tquery := c.Request.URL.RawQuery\n\n\t\tc.Next()\n\n\t\tduration := time.Since(start)\n\n\t\tlog.Infow(\"HTTP Request\",\n\t\t\t\"method\", c.Request.Method,\n\t\t\t\"path\", path,\n\t\t\t\"query\", query,\n\t\t\t\"status\", c.Writer.Status(),\n\t\t\t\"duration\", duration.Milliseconds(),\n\t\t\t\"ip\", c.ClientIP(),\n\t\t\t\"user_agent\", c.Request.UserAgent(),\n\t\t)\n\t}\n}\n"
  },
  {
    "path": "api/middlewares/ratelimit.go",
    "content": "package middlewares\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/drama-generator/backend/pkg/response\"\n\t\"github.com/gin-gonic/gin\"\n)\n\ntype rateLimiter struct {\n\tmu       sync.Mutex\n\trequests map[string][]time.Time\n\tlimit    int\n\twindow   time.Duration\n}\n\nvar limiter = &rateLimiter{\n\trequests: make(map[string][]time.Time),\n\tlimit:    2000, // 每分钟最多 2000 次请求\n\twindow:   time.Minute,\n}\n\nfunc RateLimitMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tip := c.ClientIP()\n\n\t\tlimiter.mu.Lock()\n\t\tdefer limiter.mu.Unlock()\n\n\t\tnow := time.Now()\n\t\trequests := limiter.requests[ip]\n\n\t\tvar validRequests []time.Time\n\t\tfor _, t := range requests {\n\t\t\tif now.Sub(t) < limiter.window {\n\t\t\t\tvalidRequests = append(validRequests, t)\n\t\t\t}\n\t\t}\n\n\t\tif len(validRequests) >= limiter.limit {\n\t\t\tresponse.Error(c, 429, \"RATE_LIMIT_EXCEEDED\", \"请求过于频繁，请稍后再试\")\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tvalidRequests = append(validRequests, now)\n\t\tlimiter.requests[ip] = validRequests\n\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "api/routes/routes.go",
    "content": "package routes\n\nimport (\n\thandlers2 \"github.com/drama-generator/backend/api/handlers\"\n\tmiddlewares2 \"github.com/drama-generator/backend/api/middlewares\"\n\tservices2 \"github.com/drama-generator/backend/application/services\"\n\tstorage2 \"github.com/drama-generator/backend/infrastructure/storage\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/gin-gonic/gin\"\n\t\"gorm.io/gorm\"\n)\n\nfunc SetupRouter(cfg *config.Config, db *gorm.DB, log *logger.Logger, localStorage interface{}) *gin.Engine {\n\tr := gin.New()\n\n\tr.Use(gin.Recovery())\n\tr.Use(middlewares2.LoggerMiddleware(log))\n\tr.Use(middlewares2.CORSMiddleware(cfg.Server.CORSOrigins))\n\n\t// 静态文件服务（用户上传的文件）\n\tr.Static(\"/static\", cfg.Storage.LocalPath)\n\n\tr.GET(\"/health\", func(c *gin.Context) {\n\t\tc.JSON(200, gin.H{\n\t\t\t\"status\":  \"ok\",\n\t\t\t\"app\":     cfg.App.Name,\n\t\t\t\"version\": cfg.App.Version,\n\t\t})\n\t})\n\n\taiService := services2.NewAIService(db, log)\n\tlocalStoragePtr := localStorage.(*storage2.LocalStorage)\n\ttransferService := services2.NewResourceTransferService(db, log)\n\tpromptI18n := services2.NewPromptI18n(cfg)\n\tdramaHandler := handlers2.NewDramaHandler(db, cfg, log, nil)\n\taiConfigHandler := handlers2.NewAIConfigHandler(db, cfg, log)\n\tscriptGenHandler := handlers2.NewScriptGenerationHandler(db, cfg, log)\n\timageGenService := services2.NewImageGenerationService(db, cfg, transferService, localStoragePtr, log)\n\timageGenHandler := handlers2.NewImageGenerationHandler(db, cfg, log, transferService, localStoragePtr)\n\tvideoGenHandler := handlers2.NewVideoGenerationHandler(db, transferService, localStoragePtr, aiService, log, promptI18n)\n\tvideoMergeHandler := handlers2.NewVideoMergeHandler(db, nil, cfg.Storage.LocalPath, cfg.Storage.BaseURL, log)\n\tassetHandler := handlers2.NewAssetHandler(db, cfg, log)\n\tcharacterLibraryService := services2.NewCharacterLibraryService(db, log, cfg)\n\tcharacterLibraryHandler := handlers2.NewCharacterLibraryHandler(db, cfg, log, transferService, localStoragePtr)\n\tuploadHandler, err := handlers2.NewUploadHandler(cfg, log, characterLibraryService)\n\tif err != nil {\n\t\tlog.Fatalw(\"Failed to create upload handler\", \"error\", err)\n\t}\n\tstoryboardHandler := handlers2.NewStoryboardHandler(db, cfg, log)\n\tsceneHandler := handlers2.NewSceneHandler(db, log, imageGenService)\n\ttaskHandler := handlers2.NewTaskHandler(db, log)\n\tframePromptService := services2.NewFramePromptService(db, cfg, log)\n\tframePromptHandler := handlers2.NewFramePromptHandler(framePromptService, log)\n\taudioExtractionHandler := handlers2.NewAudioExtractionHandler(log, cfg.Storage.LocalPath)\n\tsettingsHandler := handlers2.NewSettingsHandler(cfg, log)\n\tpropHandler := handlers2.NewPropHandler(db, cfg, log, aiService, imageGenService)\n\n\tapi := r.Group(\"/api/v1\")\n\t{\n\t\tapi.Use(middlewares2.RateLimitMiddleware())\n\n\t\tdramas := api.Group(\"/dramas\")\n\t\t{\n\t\t\tdramas.GET(\"\", dramaHandler.ListDramas)\n\t\t\tdramas.POST(\"\", dramaHandler.CreateDrama)\n\t\t\tdramas.GET(\"/stats\", dramaHandler.GetDramaStats) // 统计接口放在/:id之前\n\t\t\tdramas.GET(\"/:id\", dramaHandler.GetDrama)\n\t\t\tdramas.PUT(\"/:id\", dramaHandler.UpdateDrama)\n\t\t\tdramas.DELETE(\"/:id\", dramaHandler.DeleteDrama)\n\n\t\t\tdramas.PUT(\"/:id/outline\", dramaHandler.SaveOutline)\n\t\t\tdramas.GET(\"/:id/characters\", dramaHandler.GetCharacters)\n\t\t\tdramas.PUT(\"/:id/characters\", dramaHandler.SaveCharacters)\n\t\t\tdramas.PUT(\"/:id/episodes\", dramaHandler.SaveEpisodes)\n\t\t\tdramas.PUT(\"/:id/progress\", dramaHandler.SaveProgress)\n\t\t\tdramas.GET(\"/:id/props\", propHandler.ListProps) // Added prop list route\n\t\t}\n\n\t\taiConfigs := api.Group(\"/ai-configs\")\n\t\t{\n\t\t\taiConfigs.GET(\"\", aiConfigHandler.ListConfigs)\n\t\t\taiConfigs.POST(\"\", aiConfigHandler.CreateConfig)\n\t\t\taiConfigs.POST(\"/test\", aiConfigHandler.TestConnection)\n\t\t\taiConfigs.GET(\"/:id\", aiConfigHandler.GetConfig)\n\t\t\taiConfigs.PUT(\"/:id\", aiConfigHandler.UpdateConfig)\n\t\t\taiConfigs.DELETE(\"/:id\", aiConfigHandler.DeleteConfig)\n\t\t}\n\n\t\tgeneration := api.Group(\"/generation\")\n\t\t{\n\t\t\tgeneration.POST(\"/characters\", scriptGenHandler.GenerateCharacters)\n\t\t}\n\n\t\t// 角色库路由\n\t\tcharacterLibrary := api.Group(\"/character-library\")\n\t\t{\n\t\t\tcharacterLibrary.GET(\"\", characterLibraryHandler.ListLibraryItems)\n\t\t\tcharacterLibrary.POST(\"\", characterLibraryHandler.CreateLibraryItem)\n\t\t\tcharacterLibrary.GET(\"/:id\", characterLibraryHandler.GetLibraryItem)\n\t\t\tcharacterLibrary.DELETE(\"/:id\", characterLibraryHandler.DeleteLibraryItem)\n\t\t}\n\n\t\t// 角色图片相关路由\n\t\tcharacters := api.Group(\"/characters\")\n\t\t{\n\t\t\tcharacters.PUT(\"/:id\", characterLibraryHandler.UpdateCharacter)\n\t\t\tcharacters.DELETE(\"/:id\", characterLibraryHandler.DeleteCharacter)\n\t\t\tcharacters.POST(\"/batch-generate-images\", characterLibraryHandler.BatchGenerateCharacterImages)\n\t\t\tcharacters.POST(\"/:id/generate-image\", characterLibraryHandler.GenerateCharacterImage)\n\t\t\tcharacters.POST(\"/:id/upload-image\", uploadHandler.UploadCharacterImage)\n\t\t\tcharacters.PUT(\"/:id/image\", characterLibraryHandler.UploadCharacterImage)\n\t\t\tcharacters.PUT(\"/:id/image-from-library\", characterLibraryHandler.ApplyLibraryItemToCharacter)\n\t\t\tcharacters.POST(\"/:id/add-to-library\", characterLibraryHandler.AddCharacterToLibrary)\n\t\t}\n\n\t\tprops := api.Group(\"/props\")\n\t\t{\n\t\t\tprops.POST(\"\", propHandler.CreateProp)\n\t\t\tprops.PUT(\"/:id\", propHandler.UpdateProp)\n\t\t\tprops.DELETE(\"/:id\", propHandler.DeleteProp)\n\t\t\tprops.POST(\"/:id/generate\", propHandler.GenerateImage)\n\t\t}\n\n\t\t// 文件上传路由\n\t\tupload := api.Group(\"/upload\")\n\t\t{\n\t\t\tupload.POST(\"/image\", uploadHandler.UploadImage)\n\t\t}\n\n\t\t// 分镜头路由\n\t\tepisodes := api.Group(\"/episodes\")\n\t\t{\n\t\t\t// 分镜头\n\t\t\tepisodes.POST(\"/:episode_id/storyboards\", storyboardHandler.GenerateStoryboard)\n\t\t\tepisodes.POST(\"/:episode_id/props/extract\", propHandler.ExtractProps)\n\t\t\tepisodes.POST(\"/:episode_id/characters/extract\", characterLibraryHandler.ExtractCharacters)\n\t\t\tepisodes.GET(\"/:episode_id/storyboards\", sceneHandler.GetStoryboardsForEpisode)\n\t\t\tepisodes.POST(\"/:episode_id/finalize\", dramaHandler.FinalizeEpisode)\n\t\t\tepisodes.GET(\"/:episode_id/download\", dramaHandler.DownloadEpisodeVideo)\n\t\t}\n\n\t\t// 任务路由\n\t\ttasks := api.Group(\"/tasks\")\n\t\t{\n\t\t\ttasks.GET(\"/:task_id\", taskHandler.GetTaskStatus)\n\t\t\ttasks.GET(\"\", taskHandler.GetResourceTasks)\n\t\t}\n\n\t\t// 场景路由\n\t\tscenes := api.Group(\"/scenes\")\n\t\t{\n\t\t\tscenes.PUT(\"/:scene_id\", sceneHandler.UpdateScene)\n\t\t\tscenes.PUT(\"/:scene_id/prompt\", sceneHandler.UpdateScenePrompt)\n\t\t\tscenes.DELETE(\"/:scene_id\", sceneHandler.DeleteScene)\n\n\t\t\tscenes.POST(\"/generate-image\", sceneHandler.GenerateSceneImage)\n\t\t\tscenes.POST(\"\", sceneHandler.CreateScene)\n\t\t}\n\n\t\timages := api.Group(\"/images\")\n\t\t{\n\t\t\timages.GET(\"\", imageGenHandler.ListImageGenerations)\n\t\t\timages.POST(\"\", imageGenHandler.GenerateImage)\n\t\t\timages.GET(\"/:id\", imageGenHandler.GetImageGeneration)\n\t\t\timages.DELETE(\"/:id\", imageGenHandler.DeleteImageGeneration)\n\t\t\timages.POST(\"/scene/:scene_id\", imageGenHandler.GenerateImagesForScene)\n\t\t\timages.POST(\"/upload\", imageGenHandler.UploadImage)\n\t\t\timages.GET(\"/episode/:episode_id/backgrounds\", imageGenHandler.GetBackgroundsForEpisode)\n\t\t\timages.POST(\"/episode/:episode_id/backgrounds/extract\", imageGenHandler.ExtractBackgroundsForEpisode)\n\t\t\timages.POST(\"/episode/:episode_id/batch\", imageGenHandler.BatchGenerateForEpisode)\n\t\t}\n\n\t\tvideos := api.Group(\"/videos\")\n\t\t{\n\t\t\tvideos.GET(\"\", videoGenHandler.ListVideoGenerations)\n\t\t\tvideos.POST(\"\", videoGenHandler.GenerateVideo)\n\t\t\tvideos.GET(\"/:id\", videoGenHandler.GetVideoGeneration)\n\t\t\tvideos.DELETE(\"/:id\", videoGenHandler.DeleteVideoGeneration)\n\t\t\tvideos.POST(\"/image/:image_gen_id\", videoGenHandler.GenerateVideoFromImage)\n\t\t\tvideos.POST(\"/episode/:episode_id/batch\", videoGenHandler.BatchGenerateForEpisode)\n\t\t}\n\n\t\tvideoMerges := api.Group(\"/video-merges\")\n\t\t{\n\t\t\tvideoMerges.GET(\"\", videoMergeHandler.ListMerges)\n\t\t\tvideoMerges.POST(\"\", videoMergeHandler.MergeVideos)\n\t\t\tvideoMerges.GET(\"/:merge_id\", videoMergeHandler.GetMerge)\n\t\t\tvideoMerges.DELETE(\"/:merge_id\", videoMergeHandler.DeleteMerge)\n\t\t}\n\n\t\tassets := api.Group(\"/assets\")\n\t\t{\n\t\t\tassets.GET(\"\", assetHandler.ListAssets)\n\t\t\tassets.POST(\"\", assetHandler.CreateAsset)\n\t\t\tassets.GET(\"/:id\", assetHandler.GetAsset)\n\t\t\tassets.PUT(\"/:id\", assetHandler.UpdateAsset)\n\t\t\tassets.DELETE(\"/:id\", assetHandler.DeleteAsset)\n\t\t\tassets.POST(\"/import/image/:image_gen_id\", assetHandler.ImportFromImageGen)\n\t\t\tassets.POST(\"/import/video/:video_gen_id\", assetHandler.ImportFromVideoGen)\n\t\t}\n\n\t\tstoryboards := api.Group(\"/storyboards\")\n\t\t{\n\t\t\tstoryboards.GET(\"/episode/:episode_id/generate\", storyboardHandler.GenerateStoryboard)\n\t\t\tstoryboards.POST(\"\", storyboardHandler.CreateStoryboard)\n\t\t\tstoryboards.PUT(\"/:id\", storyboardHandler.UpdateStoryboard)\n\t\t\tstoryboards.DELETE(\"/:id\", storyboardHandler.DeleteStoryboard)\n\t\t\tstoryboards.POST(\"/:id/props\", propHandler.AssociateProps)\n\t\t\tstoryboards.POST(\"/:id/frame-prompt\", framePromptHandler.GenerateFramePrompt)\n\t\t\tstoryboards.GET(\"/:id/frame-prompts\", handlers2.GetStoryboardFramePrompts(db, log))\n\t\t}\n\n\t\taudio := api.Group(\"/audio\")\n\t\t{\n\t\t\taudio.POST(\"/extract\", audioExtractionHandler.ExtractAudio)\n\t\t\taudio.POST(\"/extract/batch\", audioExtractionHandler.BatchExtractAudio)\n\t\t}\n\n\t\tsettings := api.Group(\"/settings\")\n\t\t{\n\t\t\tsettings.GET(\"/language\", settingsHandler.GetLanguage)\n\t\t\tsettings.PUT(\"/language\", settingsHandler.UpdateLanguage)\n\t\t}\n\t}\n\n\t// 前端静态文件服务（放在API路由之后，避免冲突）\n\t// 服务前端构建产物\n\tr.Static(\"/assets\", \"./web/dist/assets\")\n\tr.StaticFile(\"/favicon.ico\", \"./web/dist/favicon.ico\")\n\n\t// NoRoute处理：对于所有未匹配的路由\n\tr.NoRoute(func(c *gin.Context) {\n\t\tpath := c.Request.URL.Path\n\n\t\t// 如果是API路径，返回404\n\t\tif len(path) >= 4 && path[:4] == \"/api\" {\n\t\t\tc.JSON(404, gin.H{\"error\": \"API endpoint not found\"})\n\t\t\treturn\n\t\t}\n\n\t\t// SPA fallback - 返回index.html\n\t\tc.File(\"./web/dist/index.html\")\n\t})\n\n\treturn r\n}\n"
  },
  {
    "path": "application/services/ai_service.go",
    "content": "package services\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/pkg/ai\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"gorm.io/gorm\"\n)\n\ntype AIService struct {\n\tdb  *gorm.DB\n\tlog *logger.Logger\n}\n\nfunc NewAIService(db *gorm.DB, log *logger.Logger) *AIService {\n\treturn &AIService{\n\t\tdb:  db,\n\t\tlog: log,\n\t}\n}\n\ntype CreateAIConfigRequest struct {\n\tServiceType   string            `json:\"service_type\" binding:\"required,oneof=text image video\"`\n\tName          string            `json:\"name\" binding:\"required,min=1,max=100\"`\n\tProvider      string            `json:\"provider\" binding:\"required\"`\n\tBaseURL       string            `json:\"base_url\" binding:\"required,url\"`\n\tAPIKey        string            `json:\"api_key\" binding:\"required\"`\n\tModel         models.ModelField `json:\"model\" binding:\"required\"`\n\tEndpoint      string            `json:\"endpoint\"`\n\tQueryEndpoint string            `json:\"query_endpoint\"`\n\tPriority      int               `json:\"priority\"`\n\tIsDefault     bool              `json:\"is_default\"`\n\tSettings      string            `json:\"settings\"`\n}\n\ntype UpdateAIConfigRequest struct {\n\tName          string             `json:\"name\" binding:\"omitempty,min=1,max=100\"`\n\tProvider      string             `json:\"provider\"`\n\tBaseURL       string             `json:\"base_url\" binding:\"omitempty,url\"`\n\tAPIKey        string             `json:\"api_key\"`\n\tModel         *models.ModelField `json:\"model\"`\n\tEndpoint      string             `json:\"endpoint\"`\n\tQueryEndpoint string             `json:\"query_endpoint\"`\n\tPriority      *int               `json:\"priority\"`\n\tIsDefault     bool               `json:\"is_default\"`\n\tIsActive      bool               `json:\"is_active\"`\n\tSettings      string             `json:\"settings\"`\n}\n\ntype TestConnectionRequest struct {\n\tBaseURL  string            `json:\"base_url\" binding:\"required,url\"`\n\tAPIKey   string            `json:\"api_key\" binding:\"required\"`\n\tModel    models.ModelField `json:\"model\" binding:\"required\"`\n\tProvider string            `json:\"provider\"`\n\tEndpoint string            `json:\"endpoint\"`\n}\n\nfunc (s *AIService) CreateConfig(req *CreateAIConfigRequest) (*models.AIServiceConfig, error) {\n\t// 根据 provider 和 service_type 自动设置 endpoint\n\tendpoint := req.Endpoint\n\tqueryEndpoint := req.QueryEndpoint\n\n\tif endpoint == \"\" {\n\t\tswitch req.Provider {\n\t\tcase \"gemini\", \"google\":\n\t\t\tif req.ServiceType == \"text\" {\n\t\t\t\tendpoint = \"/v1beta/models/{model}:generateContent\"\n\t\t\t} else if req.ServiceType == \"image\" {\n\t\t\t\tendpoint = \"/v1beta/models/{model}:generateContent\"\n\t\t\t}\n\t\tcase \"openai\":\n\t\t\tif req.ServiceType == \"text\" {\n\t\t\t\tendpoint = \"/chat/completions\"\n\t\t\t} else if req.ServiceType == \"image\" {\n\t\t\t\tendpoint = \"/images/generations\"\n\t\t\t} else if req.ServiceType == \"video\" {\n\t\t\t\tendpoint = \"/videos\"\n\t\t\t\tif queryEndpoint == \"\" {\n\t\t\t\t\tqueryEndpoint = \"/videos/{taskId}\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"chatfire\":\n\t\t\tif req.ServiceType == \"text\" {\n\t\t\t\tendpoint = \"/chat/completions\"\n\t\t\t} else if req.ServiceType == \"image\" {\n\t\t\t\tendpoint = \"/images/generations\"\n\t\t\t} else if req.ServiceType == \"video\" {\n\t\t\t\tendpoint = \"/video/generations\"\n\t\t\t\tif queryEndpoint == \"\" {\n\t\t\t\t\tqueryEndpoint = \"/video/task/{taskId}\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"doubao\", \"volcengine\", \"volces\":\n\t\t\tif req.ServiceType == \"video\" {\n\t\t\t\tendpoint = \"/contents/generations/tasks\"\n\t\t\t\tif queryEndpoint == \"\" {\n\t\t\t\t\tqueryEndpoint = \"/generations/tasks/{taskId}\"\n\t\t\t\t}\n\t\t\t}\n\t\tdefault:\n\t\t\t// 默认使用 OpenAI 格式\n\t\t\tif req.ServiceType == \"text\" {\n\t\t\t\tendpoint = \"/chat/completions\"\n\t\t\t} else if req.ServiceType == \"image\" {\n\t\t\t\tendpoint = \"/images/generations\"\n\t\t\t}\n\t\t}\n\t}\n\n\tconfig := &models.AIServiceConfig{\n\t\tServiceType:   req.ServiceType,\n\t\tName:          req.Name,\n\t\tProvider:      req.Provider,\n\t\tBaseURL:       req.BaseURL,\n\t\tAPIKey:        req.APIKey,\n\t\tModel:         req.Model,\n\t\tEndpoint:      endpoint,\n\t\tQueryEndpoint: queryEndpoint,\n\t\tPriority:      req.Priority,\n\t\tIsDefault:     req.IsDefault,\n\t\tIsActive:      true,\n\t\tSettings:      req.Settings,\n\t}\n\n\tif err := s.db.Create(config).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to create AI config\", \"error\", err)\n\t\treturn nil, err\n\t}\n\n\ts.log.Infow(\"AI config created\", \"config_id\", config.ID, \"provider\", req.Provider, \"endpoint\", endpoint)\n\treturn config, nil\n}\n\nfunc (s *AIService) GetConfig(configID uint) (*models.AIServiceConfig, error) {\n\tvar config models.AIServiceConfig\n\terr := s.db.Where(\"id = ? \", configID).First(&config).Error\n\tif err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, errors.New(\"config not found\")\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn &config, nil\n}\n\nfunc (s *AIService) ListConfigs(serviceType string) ([]models.AIServiceConfig, error) {\n\tvar configs []models.AIServiceConfig\n\tquery := s.db\n\n\tif serviceType != \"\" {\n\t\tquery = query.Where(\"service_type = ?\", serviceType)\n\t}\n\n\terr := query.Order(\"priority DESC, created_at DESC\").Find(&configs).Error\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to list AI configs\", \"error\", err)\n\t\treturn nil, err\n\t}\n\n\treturn configs, nil\n}\n\nfunc (s *AIService) UpdateConfig(configID uint, req *UpdateAIConfigRequest) (*models.AIServiceConfig, error) {\n\tvar config models.AIServiceConfig\n\tif err := s.db.Where(\"id = ? \", configID).First(&config).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, errors.New(\"config not found\")\n\t\t}\n\t\treturn nil, err\n\t}\n\n\ttx := s.db.Begin()\n\n\t// 不再需要is_default独占逻辑\n\n\tupdates := make(map[string]interface{})\n\tif req.Name != \"\" {\n\t\tupdates[\"name\"] = req.Name\n\t}\n\tif req.Provider != \"\" {\n\t\tupdates[\"provider\"] = req.Provider\n\t}\n\tif req.BaseURL != \"\" {\n\t\tupdates[\"base_url\"] = req.BaseURL\n\t}\n\tif req.APIKey != \"\" {\n\t\tupdates[\"api_key\"] = req.APIKey\n\t}\n\tif req.Model != nil && len(*req.Model) > 0 {\n\t\tupdates[\"model\"] = *req.Model\n\t}\n\tif req.Priority != nil {\n\t\tupdates[\"priority\"] = *req.Priority\n\t}\n\n\t// 如果提供了 provider，根据 provider 和 service_type 自动设置 endpoint\n\tif req.Provider != \"\" && req.Endpoint == \"\" {\n\t\tprovider := req.Provider\n\t\tserviceType := config.ServiceType\n\n\t\tswitch provider {\n\t\tcase \"gemini\", \"google\":\n\t\t\tif serviceType == \"text\" || serviceType == \"image\" {\n\t\t\t\tupdates[\"endpoint\"] = \"/v1beta/models/{model}:generateContent\"\n\t\t\t}\n\t\tcase \"openai\":\n\t\t\tif serviceType == \"text\" {\n\t\t\t\tupdates[\"endpoint\"] = \"/chat/completions\"\n\t\t\t} else if serviceType == \"image\" {\n\t\t\t\tupdates[\"endpoint\"] = \"/images/generations\"\n\t\t\t} else if serviceType == \"video\" {\n\t\t\t\tupdates[\"endpoint\"] = \"/videos\"\n\t\t\t\tupdates[\"query_endpoint\"] = \"/videos/{taskId}\"\n\t\t\t}\n\t\tcase \"chatfire\":\n\t\t\tif serviceType == \"text\" {\n\t\t\t\tupdates[\"endpoint\"] = \"/chat/completions\"\n\t\t\t} else if serviceType == \"image\" {\n\t\t\t\tupdates[\"endpoint\"] = \"/images/generations\"\n\t\t\t} else if serviceType == \"video\" {\n\t\t\t\tupdates[\"endpoint\"] = \"/video/generations\"\n\t\t\t\tupdates[\"query_endpoint\"] = \"/video/task/{taskId}\"\n\t\t\t}\n\t\t}\n\t} else if req.Endpoint != \"\" {\n\t\tupdates[\"endpoint\"] = req.Endpoint\n\t}\n\n\t// 允许清空query_endpoint，所以不检查是否为空\n\tupdates[\"query_endpoint\"] = req.QueryEndpoint\n\tif req.Settings != \"\" {\n\t\tupdates[\"settings\"] = req.Settings\n\t}\n\tupdates[\"is_default\"] = req.IsDefault\n\tupdates[\"is_active\"] = req.IsActive\n\n\tif err := tx.Model(&config).Updates(updates).Error; err != nil {\n\t\ttx.Rollback()\n\t\ts.log.Errorw(\"Failed to update AI config\", \"error\", err)\n\t\treturn nil, err\n\t}\n\n\tif err := tx.Commit().Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\ts.log.Infow(\"AI config updated\", \"config_id\", configID)\n\treturn &config, nil\n}\n\nfunc (s *AIService) DeleteConfig(configID uint) error {\n\tresult := s.db.Where(\"id = ? \", configID).Delete(&models.AIServiceConfig{})\n\n\tif result.Error != nil {\n\t\ts.log.Errorw(\"Failed to delete AI config\", \"error\", result.Error)\n\t\treturn result.Error\n\t}\n\n\tif result.RowsAffected == 0 {\n\t\treturn errors.New(\"config not found\")\n\t}\n\n\ts.log.Infow(\"AI config deleted\", \"config_id\", configID)\n\treturn nil\n}\n\nfunc (s *AIService) TestConnection(req *TestConnectionRequest) error {\n\ts.log.Infow(\"TestConnection called\", \"baseURL\", req.BaseURL, \"provider\", req.Provider, \"endpoint\", req.Endpoint, \"modelCount\", len(req.Model))\n\n\t// 使用第一个模型进行测试\n\tmodel := \"\"\n\tif len(req.Model) > 0 {\n\t\tmodel = req.Model[0]\n\t}\n\ts.log.Infow(\"Using model for test\", \"model\", model, \"provider\", req.Provider)\n\n\t// 根据 provider 参数选择客户端\n\tvar client ai.AIClient\n\tvar endpoint string\n\n\tswitch req.Provider {\n\tcase \"gemini\", \"google\":\n\t\t// Gemini\n\t\ts.log.Infow(\"Using Gemini client\", \"baseURL\", req.BaseURL)\n\t\tendpoint = \"/v1beta/models/{model}:generateContent\"\n\t\tclient = ai.NewGeminiClient(req.BaseURL, req.APIKey, model, endpoint)\n\tcase \"openai\", \"chatfire\":\n\t\t// OpenAI 格式（包括 chatfire 等）\n\t\ts.log.Infow(\"Using OpenAI-compatible client\", \"baseURL\", req.BaseURL, \"provider\", req.Provider)\n\t\tendpoint = req.Endpoint\n\t\tif endpoint == \"\" {\n\t\t\tendpoint = \"/chat/completions\"\n\t\t}\n\t\tclient = ai.NewOpenAIClient(req.BaseURL, req.APIKey, model, endpoint)\n\tdefault:\n\t\t// 默认使用 OpenAI 格式\n\t\ts.log.Infow(\"Using default OpenAI-compatible client\", \"baseURL\", req.BaseURL)\n\t\tendpoint = req.Endpoint\n\t\tif endpoint == \"\" {\n\t\t\tendpoint = \"/chat/completions\"\n\t\t}\n\t\tclient = ai.NewOpenAIClient(req.BaseURL, req.APIKey, model, endpoint)\n\t}\n\n\ts.log.Infow(\"Calling TestConnection on client\", \"endpoint\", endpoint)\n\terr := client.TestConnection()\n\tif err != nil {\n\t\ts.log.Errorw(\"TestConnection failed\", \"error\", err)\n\t} else {\n\t\ts.log.Infow(\"TestConnection succeeded\")\n\t}\n\treturn err\n}\n\nfunc (s *AIService) GetDefaultConfig(serviceType string) (*models.AIServiceConfig, error) {\n\tvar config models.AIServiceConfig\n\t// 按优先级降序获取第一个激活的配置\n\terr := s.db.Where(\"service_type = ? AND is_active = ?\", serviceType, true).\n\t\tOrder(\"priority DESC, created_at DESC\").\n\t\tFirst(&config).Error\n\n\tif err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, errors.New(\"no active config found\")\n\t\t}\n\t\treturn nil, err\n\t}\n\n\treturn &config, nil\n}\n\n// GetConfigForModel 根据服务类型和模型名称获取优先级最高的激活配置\nfunc (s *AIService) GetConfigForModel(serviceType string, modelName string) (*models.AIServiceConfig, error) {\n\tvar configs []models.AIServiceConfig\n\terr := s.db.Where(\"service_type = ? AND is_active = ?\", serviceType, true).\n\t\tOrder(\"priority DESC, created_at DESC\").\n\t\tFind(&configs).Error\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 查找包含指定模型的配置\n\tfor _, config := range configs {\n\t\tfor _, model := range config.Model {\n\t\t\tif model == modelName {\n\t\t\t\treturn &config, nil\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil, errors.New(\"no active config found for model: \" + modelName)\n}\n\nfunc (s *AIService) GetAIClient(serviceType string) (ai.AIClient, error) {\n\tconfig, err := s.GetDefaultConfig(serviceType)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 使用第一个模型\n\tmodel := \"\"\n\tif len(config.Model) > 0 {\n\t\tmodel = config.Model[0]\n\t}\n\n\t// 使用数据库配置中的 endpoint，如果为空则根据 provider 设置默认值\n\tendpoint := config.Endpoint\n\tif endpoint == \"\" {\n\t\tswitch config.Provider {\n\t\tcase \"gemini\", \"google\":\n\t\t\tendpoint = \"/v1beta/models/{model}:generateContent\"\n\t\tdefault:\n\t\t\tendpoint = \"/chat/completions\"\n\t\t}\n\t}\n\n\t// 根据 provider 创建对应的客户端\n\tswitch config.Provider {\n\tcase \"gemini\", \"google\":\n\t\treturn ai.NewGeminiClient(config.BaseURL, config.APIKey, model, endpoint), nil\n\tdefault:\n\t\t// openai, chatfire 等其他厂商都使用 OpenAI 格式\n\t\treturn ai.NewOpenAIClient(config.BaseURL, config.APIKey, model, endpoint), nil\n\t}\n}\n\n// GetAIClientForModel 根据服务类型和模型名称获取对应的AI客户端\nfunc (s *AIService) GetAIClientForModel(serviceType string, modelName string) (ai.AIClient, error) {\n\tconfig, err := s.GetConfigForModel(serviceType, modelName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 使用数据库配置中的 endpoint，如果为空则根据 provider 设置默认值\n\tendpoint := config.Endpoint\n\tif endpoint == \"\" {\n\t\tswitch config.Provider {\n\t\tcase \"gemini\", \"google\":\n\t\t\tendpoint = \"/v1beta/models/{model}:generateContent\"\n\t\tdefault:\n\t\t\tendpoint = \"/chat/completions\"\n\t\t}\n\t}\n\n\t// 根据 provider 创建对应的客户端\n\tswitch config.Provider {\n\tcase \"gemini\", \"google\":\n\t\treturn ai.NewGeminiClient(config.BaseURL, config.APIKey, modelName, endpoint), nil\n\tdefault:\n\t\t// openai, chatfire 等其他厂商都使用 OpenAI 格式\n\t\treturn ai.NewOpenAIClient(config.BaseURL, config.APIKey, modelName, endpoint), nil\n\t}\n}\n\nfunc (s *AIService) GenerateText(prompt string, systemPrompt string, options ...func(*ai.ChatCompletionRequest)) (string, error) {\n\tclient, err := s.GetAIClient(\"text\")\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to get AI client: %w\", err)\n\t}\n\n\treturn client.GenerateText(prompt, systemPrompt, options...)\n}\n\nfunc (s *AIService) GenerateImage(prompt string, size string, n int) ([]string, error) {\n\tclient, err := s.GetAIClient(\"image\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get AI client for image: %w\", err)\n\t}\n\n\treturn client.GenerateImage(prompt, size, n)\n}\n"
  },
  {
    "path": "application/services/asset_duration_update.go",
    "content": "package services\n\nimport (\n\t\"fmt\"\n\n\tmodels \"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/infrastructure/storage\"\n)\n\n// UpdateAssetDurationFromFile 从本地文件探测并更新视频Asset的时长\nfunc (s *AssetService) UpdateAssetDurationFromFile(assetID uint, localFilePath string) error {\n\tvar asset models.Asset\n\tif err := s.db.Where(\"id = ?\", assetID).First(&asset).Error; err != nil {\n\t\treturn fmt.Errorf(\"asset not found\")\n\t}\n\n\tif asset.Type != models.AssetTypeVideo {\n\t\treturn fmt.Errorf(\"asset is not a video\")\n\t}\n\n\tif s.ffmpeg == nil {\n\t\treturn fmt.Errorf(\"ffmpeg not available\")\n\t}\n\n\tduration, err := s.ffmpeg.GetVideoDuration(localFilePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to probe video duration: %w\", err)\n\t}\n\n\tdurationInt := int(duration + 0.5)\n\tif err := s.db.Model(&asset).Update(\"duration\", durationInt).Error; err != nil {\n\t\treturn fmt.Errorf(\"failed to update duration: %w\", err)\n\t}\n\n\ts.log.Infow(\"Updated asset duration from file\",\n\t\t\"asset_id\", assetID,\n\t\t\"duration\", durationInt,\n\t\t\"file\", localFilePath)\n\n\treturn nil\n}\n\n// UpdateAssetDurationFromURL 下载视频并探测时长\nfunc (s *AssetService) UpdateAssetDurationFromURL(assetID uint, localStorage *storage.LocalStorage) error {\n\tvar asset models.Asset\n\tif err := s.db.Where(\"id = ?\", assetID).First(&asset).Error; err != nil {\n\t\treturn fmt.Errorf(\"asset not found\")\n\t}\n\n\tif asset.Type != models.AssetTypeVideo {\n\t\treturn fmt.Errorf(\"asset is not a video\")\n\t}\n\n\tif localStorage == nil {\n\t\treturn fmt.Errorf(\"local storage not available\")\n\t}\n\n\t// 下载视频到本地\n\tlocalPath, err := localStorage.DownloadFromURL(asset.URL, \"videos\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to download video: %w\", err)\n\t}\n\n\t// 探测时长\n\treturn s.UpdateAssetDurationFromFile(assetID, localPath)\n}\n"
  },
  {
    "path": "application/services/asset_service.go",
    "content": "package services\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\tmodels \"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/infrastructure/external/ffmpeg\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"gorm.io/gorm\"\n)\n\ntype AssetService struct {\n\tdb     *gorm.DB\n\tlog    *logger.Logger\n\tffmpeg *ffmpeg.FFmpeg\n}\n\nfunc NewAssetService(db *gorm.DB, log *logger.Logger) *AssetService {\n\treturn &AssetService{\n\t\tdb:     db,\n\t\tlog:    log,\n\t\tffmpeg: ffmpeg.NewFFmpeg(log),\n\t}\n}\n\ntype CreateAssetRequest struct {\n\tDramaID      *string          `json:\"drama_id\"`\n\tName         string           `json:\"name\" binding:\"required\"`\n\tDescription  *string          `json:\"description\"`\n\tType         models.AssetType `json:\"type\" binding:\"required\"`\n\tCategory     *string          `json:\"category\"`\n\tURL          string           `json:\"url\" binding:\"required\"`\n\tThumbnailURL *string          `json:\"thumbnail_url\"`\n\tLocalPath    *string          `json:\"local_path\"`\n\tFileSize     *int64           `json:\"file_size\"`\n\tMimeType     *string          `json:\"mime_type\"`\n\tWidth        *int             `json:\"width\"`\n\tHeight       *int             `json:\"height\"`\n\tDuration     *int             `json:\"duration\"`\n\tFormat       *string          `json:\"format\"`\n\tImageGenID   *uint            `json:\"image_gen_id\"`\n\tVideoGenID   *uint            `json:\"video_gen_id\"`\n\tTagIDs       []uint           `json:\"tag_ids\"`\n}\n\ntype UpdateAssetRequest struct {\n\tName         *string `json:\"name\"`\n\tDescription  *string `json:\"description\"`\n\tCategory     *string `json:\"category\"`\n\tThumbnailURL *string `json:\"thumbnail_url\"`\n\tTagIDs       []uint  `json:\"tag_ids\"`\n\tIsFavorite   *bool   `json:\"is_favorite\"`\n}\n\ntype ListAssetsRequest struct {\n\tDramaID      *string           `json:\"drama_id\"`\n\tEpisodeID    *uint             `json:\"episode_id\"`\n\tStoryboardID *uint             `json:\"storyboard_id\"`\n\tType         *models.AssetType `json:\"type\"`\n\tCategory     string            `json:\"category\"`\n\tTagIDs       []uint            `json:\"tag_ids\"`\n\tIsFavorite   *bool             `json:\"is_favorite\"`\n\tSearch       string            `json:\"search\"`\n\tPage         int               `json:\"page\"`\n\tPageSize     int               `json:\"page_size\"`\n}\n\nfunc (s *AssetService) CreateAsset(req *CreateAssetRequest) (*models.Asset, error) {\n\tvar dramaID *uint\n\tif req.DramaID != nil && *req.DramaID != \"\" {\n\t\tid, err := strconv.ParseUint(*req.DramaID, 10, 32)\n\t\tif err == nil {\n\t\t\tuid := uint(id)\n\t\t\tdramaID = &uid\n\t\t}\n\t}\n\n\tif dramaID != nil {\n\t\tvar drama models.Drama\n\t\tif err := s.db.Where(\"id = ?\", *dramaID).First(&drama).Error; err != nil {\n\t\t\treturn nil, fmt.Errorf(\"drama not found\")\n\t\t}\n\t}\n\n\tasset := &models.Asset{\n\t\tDramaID:      dramaID,\n\t\tName:         req.Name,\n\t\tDescription:  req.Description,\n\t\tType:         req.Type,\n\t\tCategory:     req.Category,\n\t\tURL:          req.URL,\n\t\tThumbnailURL: req.ThumbnailURL,\n\t\tLocalPath:    req.LocalPath,\n\t\tFileSize:     req.FileSize,\n\t\tMimeType:     req.MimeType,\n\t\tWidth:        req.Width,\n\t\tHeight:       req.Height,\n\t\tDuration:     req.Duration,\n\t\tFormat:       req.Format,\n\t\tImageGenID:   req.ImageGenID,\n\t\tVideoGenID:   req.VideoGenID,\n\t}\n\n\tif err := s.db.Create(asset).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create asset: %w\", err)\n\t}\n\n\treturn asset, nil\n}\n\nfunc (s *AssetService) UpdateAsset(assetID uint, req *UpdateAssetRequest) (*models.Asset, error) {\n\tvar asset models.Asset\n\tif err := s.db.Where(\"id = ?\", assetID).First(&asset).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"asset not found\")\n\t}\n\n\tupdates := make(map[string]interface{})\n\tif req.Name != nil {\n\t\tupdates[\"name\"] = *req.Name\n\t}\n\tif req.Description != nil {\n\t\tupdates[\"description\"] = *req.Description\n\t}\n\tif req.Category != nil {\n\t\tupdates[\"category\"] = *req.Category\n\t}\n\tif req.ThumbnailURL != nil {\n\t\tupdates[\"thumbnail_url\"] = *req.ThumbnailURL\n\t}\n\tif req.IsFavorite != nil {\n\t\tupdates[\"is_favorite\"] = *req.IsFavorite\n\t}\n\n\tif len(updates) > 0 {\n\t\tif err := s.db.Model(&asset).Updates(updates).Error; err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to update asset: %w\", err)\n\t\t}\n\t}\n\n\tif err := s.db.First(&asset, assetID).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &asset, nil\n}\n\nfunc (s *AssetService) GetAsset(assetID uint) (*models.Asset, error) {\n\tvar asset models.Asset\n\tif err := s.db.Where(\"id = ? \", assetID).First(&asset).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\ts.db.Model(&asset).UpdateColumn(\"view_count\", gorm.Expr(\"view_count + ?\", 1))\n\n\treturn &asset, nil\n}\n\nfunc (s *AssetService) ListAssets(req *ListAssetsRequest) ([]models.Asset, int64, error) {\n\tquery := s.db.Model(&models.Asset{})\n\n\tif req.DramaID != nil {\n\t\tvar dramaID uint64\n\t\tdramaID, _ = strconv.ParseUint(*req.DramaID, 10, 32)\n\t\tquery = query.Where(\"drama_id = ?\", uint(dramaID))\n\t}\n\n\tif req.EpisodeID != nil {\n\t\tquery = query.Where(\"episode_id = ?\", *req.EpisodeID)\n\t}\n\n\tif req.StoryboardID != nil {\n\t\tquery = query.Where(\"storyboard_id = ?\", *req.StoryboardID)\n\t}\n\n\tif req.Type != nil {\n\t\tquery = query.Where(\"type = ?\", *req.Type)\n\t}\n\n\tif req.Category != \"\" {\n\t\tquery = query.Where(\"category = ?\", req.Category)\n\t}\n\n\tif req.IsFavorite != nil {\n\t\tquery = query.Where(\"is_favorite = ?\", *req.IsFavorite)\n\t}\n\n\tif req.Search != \"\" {\n\t\tsearchTerm := \"%\" + strings.ToLower(req.Search) + \"%\"\n\t\tquery = query.Where(\"LOWER(name) LIKE ? OR LOWER(description) LIKE ?\", searchTerm, searchTerm)\n\t}\n\n\tvar total int64\n\tif err := query.Count(&total).Error; err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tvar assets []models.Asset\n\toffset := (req.Page - 1) * req.PageSize\n\tif err := query.Order(\"created_at DESC\").\n\t\tOffset(offset).Limit(req.PageSize).Find(&assets).Error; err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\treturn assets, total, nil\n}\n\nfunc (s *AssetService) DeleteAsset(assetID uint) error {\n\tresult := s.db.Where(\"id = ?\", assetID).Delete(&models.Asset{})\n\tif result.Error != nil {\n\t\treturn result.Error\n\t}\n\tif result.RowsAffected == 0 {\n\t\treturn fmt.Errorf(\"asset not found\")\n\t}\n\treturn nil\n}\n\nfunc (s *AssetService) ImportFromImageGen(imageGenID uint) (*models.Asset, error) {\n\tvar imageGen models.ImageGeneration\n\tif err := s.db.Where(\"id = ? \", imageGenID).First(&imageGen).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"image generation not found\")\n\t}\n\n\tif imageGen.Status != models.ImageStatusCompleted || imageGen.ImageURL == nil {\n\t\treturn nil, fmt.Errorf(\"image is not ready\")\n\t}\n\n\tdramaID := imageGen.DramaID\n\tasset := &models.Asset{\n\t\tName:       fmt.Sprintf(\"Image_%d\", imageGen.ID),\n\t\tType:       models.AssetTypeImage,\n\t\tURL:        *imageGen.ImageURL,\n\t\tDramaID:    &dramaID,\n\t\tImageGenID: &imageGenID,\n\t\tWidth:      imageGen.Width,\n\t\tHeight:     imageGen.Height,\n\t}\n\n\tif err := s.db.Create(asset).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create asset: %w\", err)\n\t}\n\n\treturn asset, nil\n}\n\nfunc (s *AssetService) ImportFromVideoGen(videoGenID uint) (*models.Asset, error) {\n\tvar videoGen models.VideoGeneration\n\tif err := s.db.Preload(\"Storyboard.Episode\").Where(\"id = ? \", videoGenID).First(&videoGen).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"video generation not found\")\n\t}\n\n\tif videoGen.Status != models.VideoStatusCompleted || videoGen.VideoURL == nil {\n\t\treturn nil, fmt.Errorf(\"video is not ready\")\n\t}\n\n\tdramaID := videoGen.DramaID\n\n\tvar episodeID *uint\n\tvar storyboardNum *int\n\tif videoGen.Storyboard != nil {\n\t\tepisodeID = &videoGen.Storyboard.Episode.ID\n\t\tstoryboardNum = &videoGen.Storyboard.StoryboardNumber\n\t}\n\n\tasset := &models.Asset{\n\t\tName:          fmt.Sprintf(\"Video_%d\", videoGen.ID),\n\t\tType:          models.AssetTypeVideo,\n\t\tURL:           *videoGen.VideoURL,\n\t\tLocalPath:     videoGen.LocalPath, // 同步 local_path 到 assets 表\n\t\tDramaID:       &dramaID,\n\t\tEpisodeID:     episodeID,\n\t\tStoryboardID:  videoGen.StoryboardID,\n\t\tStoryboardNum: storyboardNum,\n\t\tVideoGenID:    &videoGenID,\n\t\tDuration:      videoGen.Duration,\n\t\tWidth:         videoGen.Width,\n\t\tHeight:        videoGen.Height,\n\t}\n\n\tif videoGen.FirstFrameURL != nil {\n\t\tasset.ThumbnailURL = videoGen.FirstFrameURL\n\t}\n\n\tif err := s.db.Create(asset).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create asset: %w\", err)\n\t}\n\n\treturn asset, nil\n}\n"
  },
  {
    "path": "application/services/audio_extraction_service.go",
    "content": "package services\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/drama-generator/backend/infrastructure/external/ffmpeg\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n)\n\ntype AudioExtractionService struct {\n\tffmpeg *ffmpeg.FFmpeg\n\tlog    *logger.Logger\n}\n\nfunc NewAudioExtractionService(log *logger.Logger) *AudioExtractionService {\n\treturn &AudioExtractionService{\n\t\tffmpeg: ffmpeg.NewFFmpeg(log),\n\t\tlog:    log,\n\t}\n}\n\ntype ExtractAudioRequest struct {\n\tVideoURL string `json:\"video_url\" binding:\"required\"`\n}\n\ntype ExtractAudioResponse struct {\n\tAudioURL string  `json:\"audio_url\"`\n\tDuration float64 `json:\"duration\"`\n}\n\n// ExtractAudio 从视频URL提取音频并返回音频文件URL\nfunc (s *AudioExtractionService) ExtractAudio(videoURL string, dataDir string) (*ExtractAudioResponse, error) {\n\ts.log.Infow(\"Starting audio extraction\", \"video_url\", videoURL)\n\n\t// 生成输出文件名\n\ttimestamp := time.Now().Unix()\n\taudioFileName := fmt.Sprintf(\"audio_%d.aac\", timestamp)\n\taudioOutputPath := filepath.Join(dataDir, \"audios\", audioFileName)\n\n\t// 提取音频\n\textractedPath, err := s.ffmpeg.ExtractAudio(videoURL, audioOutputPath)\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to extract audio\", \"error\", err, \"video_url\", videoURL)\n\t\treturn nil, fmt.Errorf(\"failed to extract audio: %w\", err)\n\t}\n\n\t// 获取音频时长（使用提取后的本地文件路径）\n\tduration, err := s.ffmpeg.GetVideoDuration(extractedPath)\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to get audio duration\", \"error\", err, \"path\", extractedPath)\n\t\treturn nil, fmt.Errorf(\"failed to get audio duration: %w\", err)\n\t}\n\n\tif duration <= 0 {\n\t\ts.log.Errorw(\"Invalid audio duration\", \"duration\", duration, \"path\", extractedPath)\n\t\treturn nil, fmt.Errorf(\"invalid audio duration: %.2f\", duration)\n\t}\n\n\t// 构建音频URL（相对于data目录）\n\taudioURL := fmt.Sprintf(\"/data/audios/%s\", audioFileName)\n\n\ts.log.Infow(\"Audio extraction completed\",\n\t\t\"video_url\", videoURL,\n\t\t\"audio_url\", audioURL,\n\t\t\"duration\", duration,\n\t\t\"local_path\", extractedPath)\n\n\treturn &ExtractAudioResponse{\n\t\tAudioURL: audioURL,\n\t\tDuration: duration,\n\t}, nil\n}\n\n// BatchExtractAudio 批量提取音频\nfunc (s *AudioExtractionService) BatchExtractAudio(videoURLs []string, dataDir string) ([]*ExtractAudioResponse, error) {\n\ts.log.Infow(\"Starting batch audio extraction\", \"count\", len(videoURLs))\n\n\tresults := make([]*ExtractAudioResponse, 0, len(videoURLs))\n\n\tfor i, videoURL := range videoURLs {\n\t\ts.log.Infow(\"Extracting audio\", \"index\", i+1, \"total\", len(videoURLs), \"video_url\", videoURL)\n\n\t\tresult, err := s.ExtractAudio(videoURL, dataDir)\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"Failed to extract audio in batch\", \"index\", i, \"video_url\", videoURL, \"error\", err)\n\t\t\t// 继续处理其他视频，但记录错误\n\t\t\treturn nil, fmt.Errorf(\"failed to extract audio at index %d: %w\", i, err)\n\t\t}\n\n\t\tresults = append(results, result)\n\t}\n\n\ts.log.Infow(\"Batch audio extraction completed\", \"successful_count\", len(results))\n\treturn results, nil\n}\n"
  },
  {
    "path": "application/services/character_library_service.go",
    "content": "package services\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\tmodels \"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/pkg/ai\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/utils\"\n\t\"gorm.io/gorm\"\n)\n\ntype CharacterLibraryService struct {\n\tdb          *gorm.DB\n\tlog         *logger.Logger\n\tconfig      *config.Config\n\taiService   *AIService\n\ttaskService *TaskService\n\tpromptI18n  *PromptI18n\n}\n\nfunc NewCharacterLibraryService(db *gorm.DB, log *logger.Logger, cfg *config.Config) *CharacterLibraryService {\n\treturn &CharacterLibraryService{\n\t\tdb:          db,\n\t\tlog:         log,\n\t\tconfig:      cfg,\n\t\taiService:   NewAIService(db, log),\n\t\ttaskService: NewTaskService(db, log),\n\t\tpromptI18n:  NewPromptI18n(cfg),\n\t}\n}\n\ntype CreateLibraryItemRequest struct {\n\tName        string  `json:\"name\" binding:\"required,min=1,max=100\"`\n\tCategory    *string `json:\"category\"`\n\tImageURL    string  `json:\"image_url\" binding:\"required\"`\n\tLocalPath   *string `json:\"local_path\"`\n\tDescription *string `json:\"description\"`\n\tTags        *string `json:\"tags\"`\n\tSourceType  string  `json:\"source_type\"`\n}\n\ntype CharacterLibraryQuery struct {\n\tPage       int    `form:\"page,default=1\"`\n\tPageSize   int    `form:\"page_size,default=20\"`\n\tCategory   string `form:\"category\"`\n\tSourceType string `form:\"source_type\"`\n\tKeyword    string `form:\"keyword\"`\n}\n\n// ListLibraryItems 获取用户角色库列表\nfunc (s *CharacterLibraryService) ListLibraryItems(query *CharacterLibraryQuery) ([]models.CharacterLibrary, int64, error) {\n\tvar items []models.CharacterLibrary\n\tvar total int64\n\n\tdb := s.db.Model(&models.CharacterLibrary{})\n\n\t// 筛选条件\n\tif query.Category != \"\" {\n\t\tdb = db.Where(\"category = ?\", query.Category)\n\t}\n\n\tif query.SourceType != \"\" {\n\t\tdb = db.Where(\"source_type = ?\", query.SourceType)\n\t}\n\n\tif query.Keyword != \"\" {\n\t\tdb = db.Where(\"name LIKE ? OR description LIKE ?\", \"%\"+query.Keyword+\"%\", \"%\"+query.Keyword+\"%\")\n\t}\n\n\t// 获取总数\n\tif err := db.Count(&total).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to count character library\", \"error\", err)\n\t\treturn nil, 0, err\n\t}\n\n\t// 分页查询\n\toffset := (query.Page - 1) * query.PageSize\n\terr := db.Order(\"created_at DESC\").\n\t\tOffset(offset).\n\t\tLimit(query.PageSize).\n\t\tFind(&items).Error\n\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to list character library\", \"error\", err)\n\t\treturn nil, 0, err\n\t}\n\n\treturn items, total, nil\n}\n\n// CreateLibraryItem 添加到角色库\nfunc (s *CharacterLibraryService) CreateLibraryItem(req *CreateLibraryItemRequest) (*models.CharacterLibrary, error) {\n\tsourceType := req.SourceType\n\tif sourceType == \"\" {\n\t\tsourceType = \"generated\"\n\t}\n\n\titem := &models.CharacterLibrary{\n\t\tName:        req.Name,\n\t\tCategory:    req.Category,\n\t\tImageURL:    req.ImageURL,\n\t\tLocalPath:   req.LocalPath,\n\t\tDescription: req.Description,\n\t\tTags:        req.Tags,\n\t\tSourceType:  sourceType,\n\t}\n\n\tif err := s.db.Create(item).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to create library item\", \"error\", err)\n\t\treturn nil, err\n\t}\n\n\ts.log.Infow(\"Library item created\", \"item_id\", item.ID)\n\treturn item, nil\n}\n\n// GetLibraryItem 获取角色库项\nfunc (s *CharacterLibraryService) GetLibraryItem(itemID string) (*models.CharacterLibrary, error) {\n\tvar item models.CharacterLibrary\n\terr := s.db.Where(\"id = ? \", itemID).First(&item).Error\n\n\tif err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, errors.New(\"library item not found\")\n\t\t}\n\t\ts.log.Errorw(\"Failed to get library item\", \"error\", err)\n\t\treturn nil, err\n\t}\n\n\treturn &item, nil\n}\n\n// DeleteLibraryItem 删除角色库项\nfunc (s *CharacterLibraryService) DeleteLibraryItem(itemID string) error {\n\tresult := s.db.Where(\"id = ? \", itemID).Delete(&models.CharacterLibrary{})\n\n\tif result.Error != nil {\n\t\ts.log.Errorw(\"Failed to delete library item\", \"error\", result.Error)\n\t\treturn result.Error\n\t}\n\n\tif result.RowsAffected == 0 {\n\t\treturn errors.New(\"library item not found\")\n\t}\n\n\ts.log.Infow(\"Library item deleted\", \"item_id\", itemID)\n\treturn nil\n}\n\n// ApplyLibraryItemToCharacter 将角色库形象应用到角色\nfunc (s *CharacterLibraryService) ApplyLibraryItemToCharacter(characterID string, libraryItemID string) error {\n\t// 验证角色库项存在且属于该用户\n\tvar libraryItem models.CharacterLibrary\n\tif err := s.db.Where(\"id = ? \", libraryItemID).First(&libraryItem).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn errors.New(\"library item not found\")\n\t\t}\n\t\treturn err\n\t}\n\n\t// 查找角色\n\tvar character models.Character\n\tif err := s.db.Where(\"id = ?\", characterID).First(&character).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn errors.New(\"character not found\")\n\t\t}\n\t\treturn err\n\t}\n\n\t// 查询Drama验证权限\n\tvar drama models.Drama\n\tif err := s.db.Where(\"id = ? \", character.DramaID).First(&drama).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn errors.New(\"unauthorized\")\n\t\t}\n\t\treturn err\n\t}\n\n\t// 更新角色的 local_path 和 image_url\n\tupdates := map[string]interface{}{}\n\tif libraryItem.LocalPath != nil && *libraryItem.LocalPath != \"\" {\n\t\tupdates[\"local_path\"] = libraryItem.LocalPath\n\t}\n\tif libraryItem.ImageURL != \"\" {\n\t\tupdates[\"image_url\"] = libraryItem.ImageURL\n\t}\n\tif len(updates) > 0 {\n\t\tif err := s.db.Model(&character).Updates(updates).Error; err != nil {\n\t\t\ts.log.Errorw(\"Failed to update character image\", \"error\", err)\n\t\t\treturn err\n\t\t}\n\t}\n\n\ts.log.Infow(\"Library item applied to character\", \"character_id\", characterID, \"library_item_id\", libraryItemID)\n\treturn nil\n}\n\n// UploadCharacterImage 上传角色图片\nfunc (s *CharacterLibraryService) UploadCharacterImage(characterID string, imageURL string) error {\n\t// 查找角色\n\tvar character models.Character\n\tif err := s.db.Where(\"id = ?\", characterID).First(&character).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn errors.New(\"character not found\")\n\t\t}\n\t\treturn err\n\t}\n\n\t// 查询Drama验证权限\n\tvar drama models.Drama\n\tif err := s.db.Where(\"id = ? \", character.DramaID).First(&drama).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn errors.New(\"unauthorized\")\n\t\t}\n\t\treturn err\n\t}\n\n\t// 更新图片URL\n\tif err := s.db.Model(&character).Update(\"image_url\", imageURL).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to update character image\", \"error\", err)\n\t\treturn err\n\t}\n\n\ts.log.Infow(\"Character image uploaded\", \"character_id\", characterID)\n\treturn nil\n}\n\n// AddCharacterToLibrary 将角色添加到角色库\nfunc (s *CharacterLibraryService) AddCharacterToLibrary(characterID string, category *string) (*models.CharacterLibrary, error) {\n\t// 查找角色\n\tvar character models.Character\n\tif err := s.db.Where(\"id = ?\", characterID).First(&character).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, errors.New(\"character not found\")\n\t\t}\n\t\treturn nil, err\n\t}\n\n\t// 查询Drama验证权限\n\tvar drama models.Drama\n\tif err := s.db.Where(\"id = ? \", character.DramaID).First(&drama).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, errors.New(\"unauthorized\")\n\t\t}\n\t\treturn nil, err\n\t}\n\n\t// 检查是否有图片\n\tif character.ImageURL == nil || *character.ImageURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"角色还没有形象图片\")\n\t}\n\n\t// 创建角色库项\n\tcharLibrary := &models.CharacterLibrary{\n\t\tName:        character.Name,\n\t\tImageURL:    *character.ImageURL,\n\t\tLocalPath:   character.LocalPath,\n\t\tDescription: character.Description,\n\t\tSourceType:  \"character\",\n\t}\n\n\tif err := s.db.Create(charLibrary).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to add character to library\", \"error\", err)\n\t\treturn nil, err\n\t}\n\n\ts.log.Infow(\"Character added to library\", \"character_id\", characterID, \"library_item_id\", charLibrary.ID)\n\treturn charLibrary, nil\n}\n\n// DeleteCharacter 删除单个角色\nfunc (s *CharacterLibraryService) DeleteCharacter(characterID uint) error {\n\t// 查找角色\n\tvar character models.Character\n\tif err := s.db.Where(\"id = ?\", characterID).First(&character).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn errors.New(\"character not found\")\n\t\t}\n\t\treturn err\n\t}\n\n\t// 验证权限：检查角色所属的drama是否属于当前用户\n\tvar drama models.Drama\n\tif err := s.db.Where(\"id = ? \", character.DramaID).First(&drama).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn errors.New(\"unauthorized\")\n\t\t}\n\t\treturn err\n\t}\n\n\t// 删除角色\n\tif err := s.db.Delete(&character).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to delete character\", \"error\", err, \"id\", characterID)\n\t\treturn err\n\t}\n\n\ts.log.Infow(\"Character deleted\", \"id\", characterID)\n\treturn nil\n}\n\n// GenerateCharacterImage AI生成角色形象\nfunc (s *CharacterLibraryService) GenerateCharacterImage(characterID string, imageService *ImageGenerationService, modelName string, style string) (*models.ImageGeneration, error) {\n\t// 查找角色\n\tvar character models.Character\n\tif err := s.db.Where(\"id = ?\", characterID).First(&character).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, errors.New(\"character not found\")\n\t\t}\n\t\treturn nil, err\n\t}\n\n\t// 查询Drama验证权限\n\tvar drama models.Drama\n\tif err := s.db.Where(\"id = ? \", character.DramaID).First(&drama).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, errors.New(\"unauthorized\")\n\t\t}\n\t\treturn nil, err\n\t}\n\n\t// 构建生成提示词 - 使用详细的外貌描述，添加干净背景要求\n\tprompt := \"\"\n\n\t// 优先使用appearance字段，它包含了最详细的外貌描述\n\tif character.Appearance != nil && *character.Appearance != \"\" {\n\t\tprompt = *character.Appearance\n\t} else if character.Description != nil && *character.Description != \"\" {\n\t\tprompt = *character.Description\n\t} else {\n\t\tprompt = character.Name\n\t}\n\n\t// 使用已经加载的 drama 的 style 信息\n\tif drama.Style != \"\" && drama.Style != \"realistic\" {\n\t\tprompt += \", \" + drama.Style\n\t}\n\t// 调用图片生成服务\n\tdramaIDStr := fmt.Sprintf(\"%d\", character.DramaID)\n\timageType := \"character\"\n\treq := &GenerateImageRequest{\n\t\tDramaID:     dramaIDStr,\n\t\tCharacterID: &character.ID,\n\t\tImageType:   imageType,\n\t\tPrompt:      prompt,\n\t\tProvider:    \"openai\",    // 或从配置读取\n\t\tModel:       modelName,   // 使用用户指定的模型\n\t\tSize:        \"2560x1440\", // 3,686,400像素，满足API最低要求（16:9比例）\n\t\tQuality:     \"standard\",\n\t}\n\n\timageGen, err := imageService.GenerateImage(req)\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to generate character image\", \"error\", err)\n\t\treturn nil, fmt.Errorf(\"图片生成失败: %w\", err)\n\t}\n\n\t// 异步处理：在后台监听图片生成完成，然后更新角色image_url\n\tgo s.waitAndUpdateCharacterImage(character.ID, imageGen.ID)\n\n\t// 立即返回ImageGeneration对象，让前端可以轮询状态\n\ts.log.Infow(\"Character image generation started\", \"character_id\", characterID, \"image_gen_id\", imageGen.ID)\n\treturn imageGen, nil\n}\n\n// waitAndUpdateCharacterImage 后台异步等待图片生成完成并更新角色image_url\nfunc (s *CharacterLibraryService) waitAndUpdateCharacterImage(characterID uint, imageGenID uint) {\n\tmaxAttempts := 60\n\tpollInterval := 5 * time.Second\n\n\tfor i := 0; i < maxAttempts; i++ {\n\t\ttime.Sleep(pollInterval)\n\n\t\t// 查询图片生成状态\n\t\tvar imageGen models.ImageGeneration\n\t\tif err := s.db.First(&imageGen, imageGenID).Error; err != nil {\n\t\t\ts.log.Errorw(\"Failed to query image generation status\", \"error\", err, \"image_gen_id\", imageGenID)\n\t\t\tcontinue\n\t\t}\n\n\t\t// 检查是否完成\n\t\tif imageGen.Status == models.ImageStatusCompleted && imageGen.ImageURL != nil && *imageGen.ImageURL != \"\" {\n\t\t\t// 更新角色的image_url\n\t\t\tif err := s.db.Model(&models.Character{}).Where(\"id = ?\", characterID).Update(\"image_url\", *imageGen.ImageURL).Error; err != nil {\n\t\t\t\ts.log.Errorw(\"Failed to update character image_url\", \"error\", err, \"character_id\", characterID)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ts.log.Infow(\"Character image updated successfully\", \"character_id\", characterID, \"image_url\", *imageGen.ImageURL)\n\t\t\treturn\n\t\t}\n\n\t\t// 检查是否失败\n\t\tif imageGen.Status == models.ImageStatusFailed {\n\t\t\ts.log.Errorw(\"Character image generation failed\", \"character_id\", characterID, \"image_gen_id\", imageGenID, \"error\", imageGen.ErrorMsg)\n\t\t\treturn\n\t\t}\n\t}\n\n\ts.log.Warnw(\"Character image generation timeout\", \"character_id\", characterID, \"image_gen_id\", imageGenID)\n}\n\ntype UpdateCharacterRequest struct {\n\tName        *string `json:\"name\"`\n\tRole        *string `json:\"role\"`\n\tAppearance  *string `json:\"appearance\"`\n\tPersonality *string `json:\"personality\"`\n\tDescription *string `json:\"description\"`\n\tImageURL    *string `json:\"image_url\"`\n\tLocalPath   *string `json:\"local_path\"`\n}\n\n// UpdateCharacter 更新角色信息\nfunc (s *CharacterLibraryService) UpdateCharacter(characterID string, req *UpdateCharacterRequest) error {\n\t// 查找角色\n\tvar character models.Character\n\tif err := s.db.Where(\"id = ?\", characterID).First(&character).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn errors.New(\"character not found\")\n\t\t}\n\t\treturn err\n\t}\n\n\t// 验证权限：查询角色所属的drama是否属于该用户\n\tvar drama models.Drama\n\tif err := s.db.Where(\"id = ? \", character.DramaID).First(&drama).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn errors.New(\"unauthorized\")\n\t\t}\n\t\treturn err\n\t}\n\n\t// 构建更新数据\n\tupdates := make(map[string]interface{})\n\n\tif req.Name != nil && *req.Name != \"\" {\n\t\tupdates[\"name\"] = *req.Name\n\t}\n\tif req.Role != nil {\n\t\tupdates[\"role\"] = *req.Role\n\t}\n\tif req.Appearance != nil {\n\t\tupdates[\"appearance\"] = *req.Appearance\n\t}\n\tif req.Personality != nil {\n\t\tupdates[\"personality\"] = *req.Personality\n\t}\n\tif req.Description != nil {\n\t\tupdates[\"description\"] = *req.Description\n\t}\n\tif req.ImageURL != nil {\n\t\tupdates[\"image_url\"] = *req.ImageURL\n\t}\n\tif req.LocalPath != nil {\n\t\tupdates[\"local_path\"] = *req.LocalPath\n\t}\n\n\tif len(updates) == 0 {\n\t\treturn errors.New(\"no fields to update\")\n\t}\n\n\t// 更新角色信息\n\tif err := s.db.Model(&character).Updates(updates).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to update character\", \"error\", err, \"character_id\", characterID)\n\t\treturn err\n\t}\n\n\ts.log.Infow(\"Character updated\", \"character_id\", characterID, \"updates\", updates)\n\treturn nil\n}\n\n// BatchGenerateCharacterImages 批量生成角色图片（并发执行）\nfunc (s *CharacterLibraryService) BatchGenerateCharacterImages(characterIDs []string, imageService *ImageGenerationService, modelName string) {\n\ts.log.Infow(\"Starting batch character image generation\",\n\t\t\"count\", len(characterIDs),\n\t\t\"model\", modelName)\n\n\t// 使用 goroutine 并发生成所有角色图片\n\tfor _, characterID := range characterIDs {\n\t\t// 为每个角色启动单独的 goroutine\n\t\tgo func(charID string) {\n\t\t\timageGen, err := s.GenerateCharacterImage(charID, imageService, modelName, \"\") // 批量生成暂不支持自定义风格，使用默认值\n\t\t\tif err != nil {\n\t\t\t\ts.log.Errorw(\"Failed to generate character image in batch\",\n\t\t\t\t\t\"character_id\", charID,\n\t\t\t\t\t\"error\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ts.log.Infow(\"Character image generated in batch\",\n\t\t\t\t\"character_id\", charID,\n\t\t\t\t\"image_gen_id\", imageGen.ID)\n\t\t}(characterID)\n\t}\n\n\ts.log.Infow(\"Batch character image generation tasks submitted\",\n\t\t\"total\", len(characterIDs))\n}\n\n// ExtractCharactersFromScript 从分集剧本中提取角色\nfunc (s *CharacterLibraryService) ExtractCharactersFromScript(episodeID uint) (string, error) {\n\tvar episode models.Episode\n\tif err := s.db.First(&episode, episodeID).Error; err != nil {\n\t\treturn \"\", fmt.Errorf(\"episode not found\")\n\t}\n\n\tif episode.ScriptContent == nil || *episode.ScriptContent == \"\" {\n\t\treturn \"\", fmt.Errorf(\"剧本内容为空\")\n\t}\n\n\ttask, err := s.taskService.CreateTask(\"character_extraction\", fmt.Sprintf(\"%d\", episode.DramaID))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"创建任务失败: %w\", err)\n\t}\n\n\tgo s.processCharacterExtraction(task.ID, episode)\n\n\treturn task.ID, nil\n}\n\nfunc (s *CharacterLibraryService) processCharacterExtraction(taskID string, episode models.Episode) {\n\ts.taskService.UpdateTaskStatus(taskID, \"processing\", 0, \"正在分析剧本...\")\n\n\tscript := \"\"\n\tif episode.ScriptContent != nil {\n\t\tscript = *episode.ScriptContent\n\t}\n\n\t// 获取 drama 的 style 信息\n\tvar drama models.Drama\n\tif err := s.db.First(&drama, episode.DramaID).Error; err != nil {\n\t\ts.log.Warnw(\"Failed to load drama\", \"error\", err, \"drama_id\", episode.DramaID)\n\t}\n\n\tprompt := s.promptI18n.GetCharacterExtractionPrompt(drama.Style)\n\tuserPrompt := fmt.Sprintf(\"【剧本内容】\\n%s\", script)\n\n\tresponse, err := s.aiService.GenerateText(userPrompt, prompt, ai.WithMaxTokens(3000))\n\tif err != nil {\n\t\ts.taskService.UpdateTaskError(taskID, err)\n\t\treturn\n\t}\n\n\ts.taskService.UpdateTaskStatus(taskID, \"processing\", 50, \"正在整理角色数据...\")\n\n\tvar extractedCharacters []struct {\n\t\tName        string `json:\"name\"`\n\t\tRole        string `json:\"role\"`\n\t\tAppearance  string `json:\"appearance\"`\n\t\tPersonality string `json:\"personality\"`\n\t\tDescription string `json:\"description\"`\n\t}\n\n\tif err := utils.SafeParseAIJSON(response, &extractedCharacters); err != nil {\n\t\ts.log.Errorw(\"Failed to parse AI response for characters\", \"error\", err, \"response\", response)\n\t\ts.taskService.UpdateTaskError(taskID, fmt.Errorf(\"解析AI响应失败\"))\n\t\treturn\n\t}\n\n\tvar savedCharacters []models.Character\n\tfor _, charData := range extractedCharacters {\n\t\t// 检查是否已存在同名角色\n\t\tvar existingCharacter models.Character\n\t\terr := s.db.Where(\"drama_id = ? AND name = ?\", episode.DramaID, charData.Name).First(&existingCharacter).Error\n\n\t\tif err == nil {\n\t\t\t// 如果存在，只关联，不更新（或者可以选更新，这里暂不更新）\n\t\t\tif err := s.db.Model(&episode).Association(\"Characters\").Append(&existingCharacter); err != nil {\n\t\t\t\ts.log.Warnw(\"Failed to associate existing character\", \"error\", err)\n\t\t\t}\n\t\t\tsavedCharacters = append(savedCharacters, existingCharacter)\n\t\t} else {\n\t\t\t// 创建新角色\n\t\t\tnewCharacter := models.Character{\n\t\t\t\tDramaID:     episode.DramaID,\n\t\t\t\tName:        charData.Name,\n\t\t\t\tRole:        &charData.Role,\n\t\t\t\tAppearance:  &charData.Appearance,\n\t\t\t\tPersonality: &charData.Personality,\n\t\t\t\tDescription: &charData.Description,\n\t\t\t}\n\t\t\tif err := s.db.Create(&newCharacter).Error; err != nil {\n\t\t\t\ts.log.Errorw(\"Failed to create extracted character\", \"error\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 关联到分集\n\t\t\tif err := s.db.Model(&episode).Association(\"Characters\").Append(&newCharacter); err != nil {\n\t\t\t\ts.log.Warnw(\"Failed to associate new character\", \"error\", err)\n\t\t\t}\n\t\t\tsavedCharacters = append(savedCharacters, newCharacter)\n\t\t}\n\t}\n\n\ts.taskService.UpdateTaskResult(taskID, map[string]interface{}{\n\t\t\"characters\": savedCharacters,\n\t\t\"count\":      len(savedCharacters),\n\t})\n}\n"
  },
  {
    "path": "application/services/data_migration_service.go",
    "content": "package services\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype DataMigrationService struct {\n\tdb          *gorm.DB\n\tlog         *logger.Logger\n\tstorageRoot string\n\turlMapping  map[string]string // 原始URL -> 本地路径的映射\n}\n\nfunc NewDataMigrationService(db *gorm.DB, log *logger.Logger) *DataMigrationService {\n\treturn &DataMigrationService{\n\t\tdb:          db,\n\t\tlog:         log,\n\t\tstorageRoot: \"data/storage\",\n\t\turlMapping:  make(map[string]string),\n\t}\n}\n\n// MigrateLocalPaths 迁移所有表中 local_path 为空的数据\nfunc (s *DataMigrationService) MigrateLocalPaths() error {\n\ts.log.Info(\"开始数据清洗：迁移 local_path 为空的数据\")\n\tstartTime := time.Now()\n\n\t// 确保存储目录存在\n\tif err := s.ensureStorageDirectories(); err != nil {\n\t\treturn fmt.Errorf(\"创建存储目录失败: %w\", err)\n\t}\n\n\t// 迁移各个表的数据（按指定顺序）\n\tstats := &MigrationStats{}\n\n\t// 1. 迁移 assets 表\n\tif err := s.migrateAssets(stats); err != nil {\n\t\ts.log.Errorw(\"迁移 assets 数据失败\", \"error\", err)\n\t}\n\n\t// 2. 迁移 character_libraries 表\n\tif err := s.migrateCharacterLibraries(stats); err != nil {\n\t\ts.log.Errorw(\"迁移 character_libraries 数据失败\", \"error\", err)\n\t}\n\n\t// 3. 迁移 characters 表\n\tif err := s.migrateCharacters(stats); err != nil {\n\t\ts.log.Errorw(\"迁移 characters 数据失败\", \"error\", err)\n\t}\n\n\t// 4. 迁移 image_generations 表\n\tif err := s.migrateImageGenerations(stats); err != nil {\n\t\ts.log.Errorw(\"迁移 image_generations 数据失败\", \"error\", err)\n\t}\n\n\t// 5. 迁移 scenes 表\n\tif err := s.migrateScenes(stats); err != nil {\n\t\ts.log.Errorw(\"迁移 scenes 数据失败\", \"error\", err)\n\t}\n\n\t// 6. 迁移 video_generations 表\n\tif err := s.migrateVideoGenerations(stats); err != nil {\n\t\ts.log.Errorw(\"迁移 video_generations 数据失败\", \"error\", err)\n\t}\n\n\tduration := time.Since(startTime)\n\ts.log.Infow(\"数据清洗完成\",\n\t\t\"总耗时\", duration.String(),\n\t\t\"URL映射缓存数\", len(s.urlMapping),\n\t\t\"Assets成功\", stats.AssetsSuccess,\n\t\t\"Assets失败\", stats.AssetsFailed,\n\t\t\"角色库成功\", stats.CharacterLibrariesSuccess,\n\t\t\"角色库失败\", stats.CharacterLibrariesFailed,\n\t\t\"角色成功\", stats.CharactersSuccess,\n\t\t\"角色失败\", stats.CharactersFailed,\n\t\t\"图片生成成功\", stats.ImageGenerationsSuccess,\n\t\t\"图片生成失败\", stats.ImageGenerationsFailed,\n\t\t\"场景成功\", stats.ScenesSuccess,\n\t\t\"场景失败\", stats.ScenesFailed,\n\t\t\"视频成功\", stats.VideosSuccess,\n\t\t\"视频失败\", stats.VideosFailed,\n\t)\n\n\treturn nil\n}\n\n// MigrationStats 迁移统计信息\ntype MigrationStats struct {\n\tAssetsSuccess               int\n\tAssetsFailed                int\n\tCharacterLibrariesSuccess   int\n\tCharacterLibrariesFailed    int\n\tCharactersSuccess           int\n\tCharactersFailed            int\n\tImageGenerationsSuccess     int\n\tImageGenerationsFailed      int\n\tScenesSuccess               int\n\tScenesFailed                int\n\tVideosSuccess               int\n\tVideosFailed                int\n}\n\n// ensureStorageDirectories 确保存储目录存在\nfunc (s *DataMigrationService) ensureStorageDirectories() error {\n\tdirs := []string{\n\t\tfilepath.Join(s.storageRoot, \"images\"),\n\t\tfilepath.Join(s.storageRoot, \"characters\"),\n\t\tfilepath.Join(s.storageRoot, \"videos\"),\n\t}\n\n\tfor _, dir := range dirs {\n\t\tif err := os.MkdirAll(dir, 0755); err != nil {\n\t\t\treturn fmt.Errorf(\"创建目录 %s 失败: %w\", dir, err)\n\t\t}\n\t}\n\n\ts.log.Infow(\"存储目录创建成功\", \"root\", s.storageRoot)\n\treturn nil\n}\n\n// migrateAssets 迁移 assets 表数据\nfunc (s *DataMigrationService) migrateAssets(stats *MigrationStats) error {\n\ts.log.Info(\"开始迁移 assets 数据...\")\n\n\tvar assets []models.Asset\n\t// 查询 local_path 为空但 url 不为空的资源\n\tif err := s.db.Where(\"(local_path IS NULL OR local_path = '') AND url IS NOT NULL AND url != ''\").Find(&assets).Error; err != nil {\n\t\treturn fmt.Errorf(\"查询 assets 数据失败: %w\", err)\n\t}\n\n\ts.log.Infow(\"找到需要迁移的 assets\", \"数量\", len(assets))\n\n\tfor _, asset := range assets {\n\t\ts.log.Infow(\"处理 asset\", \"id\", asset.ID, \"name\", asset.Name, \"type\", asset.Type, \"url\", asset.URL)\n\n\t\t// 根据类型选择存储目录\n\t\tsubDir := \"images\"\n\t\tif asset.Type == models.AssetTypeVideo {\n\t\t\tsubDir = \"videos\"\n\t\t}\n\n\t\tlocalPath, err := s.downloadOrGetCached(asset.URL, subDir, fmt.Sprintf(\"asset_%d\", asset.ID))\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"下载 asset 失败\", \"asset_id\", asset.ID, \"error\", err)\n\t\t\tstats.AssetsFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\t// 更新 local_path\n\t\tif err := s.db.Model(&asset).Update(\"local_path\", localPath).Error; err != nil {\n\t\t\ts.log.Errorw(\"更新 asset local_path 失败\", \"asset_id\", asset.ID, \"error\", err)\n\t\t\tstats.AssetsFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\ts.log.Infow(\"asset 迁移成功\", \"asset_id\", asset.ID, \"local_path\", localPath)\n\t\tstats.AssetsSuccess++\n\t}\n\n\treturn nil\n}\n\n// migrateCharacterLibraries 迁移 character_libraries 表数据\nfunc (s *DataMigrationService) migrateCharacterLibraries(stats *MigrationStats) error {\n\ts.log.Info(\"开始迁移 character_libraries 数据...\")\n\n\tvar charLibs []models.CharacterLibrary\n\t// 查询 local_path 为空但 image_url 不为空的角色库\n\tif err := s.db.Where(\"(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''\").Find(&charLibs).Error; err != nil {\n\t\treturn fmt.Errorf(\"查询 character_libraries 数据失败: %w\", err)\n\t}\n\n\ts.log.Infow(\"找到需要迁移的 character_libraries\", \"数量\", len(charLibs))\n\n\tfor _, charLib := range charLibs {\n\t\ts.log.Infow(\"处理 character_library\", \"id\", charLib.ID, \"name\", charLib.Name, \"image_url\", charLib.ImageURL)\n\n\t\tlocalPath, err := s.downloadOrGetCached(charLib.ImageURL, \"characters\", fmt.Sprintf(\"charlib_%d\", charLib.ID))\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"下载 character_library 图片失败\", \"charlib_id\", charLib.ID, \"error\", err)\n\t\t\tstats.CharacterLibrariesFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\t// 更新 local_path\n\t\tif err := s.db.Model(&charLib).Update(\"local_path\", localPath).Error; err != nil {\n\t\t\ts.log.Errorw(\"更新 character_library local_path 失败\", \"charlib_id\", charLib.ID, \"error\", err)\n\t\t\tstats.CharacterLibrariesFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\ts.log.Infow(\"character_library 迁移成功\", \"charlib_id\", charLib.ID, \"local_path\", localPath)\n\t\tstats.CharacterLibrariesSuccess++\n\t}\n\n\treturn nil\n}\n\n// migrateImageGenerations 迁移 image_generations 表数据\nfunc (s *DataMigrationService) migrateImageGenerations(stats *MigrationStats) error {\n\ts.log.Info(\"开始迁移 image_generations 数据...\")\n\n\tvar imageGens []models.ImageGeneration\n\t// 查询 local_path 为空但 image_url 不为空的图片生成记录\n\tif err := s.db.Where(\"(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''\").Find(&imageGens).Error; err != nil {\n\t\treturn fmt.Errorf(\"查询 image_generations 数据失败: %w\", err)\n\t}\n\n\ts.log.Infow(\"找到需要迁移的 image_generations\", \"数量\", len(imageGens))\n\n\tfor _, imageGen := range imageGens {\n\t\tif imageGen.ImageURL == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\timageTypeStr := string(imageGen.ImageType)\n\t\ts.log.Infow(\"处理 image_generation\", \"id\", imageGen.ID, \"image_type\", imageTypeStr, \"image_url\", *imageGen.ImageURL)\n\n\t\t// 根据图片类型选择存储目录\n\t\tsubDir := \"images\"\n\t\tif imageGen.ImageType == \"character\" {\n\t\t\tsubDir = \"characters\"\n\t\t}\n\n\t\tlocalPath, err := s.downloadOrGetCached(*imageGen.ImageURL, subDir, fmt.Sprintf(\"imggen_%d\", imageGen.ID))\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"下载 image_generation 图片失败\", \"imggen_id\", imageGen.ID, \"error\", err)\n\t\t\tstats.ImageGenerationsFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\t// 更新 local_path\n\t\tif err := s.db.Model(&imageGen).Update(\"local_path\", localPath).Error; err != nil {\n\t\t\ts.log.Errorw(\"更新 image_generation local_path 失败\", \"imggen_id\", imageGen.ID, \"error\", err)\n\t\t\tstats.ImageGenerationsFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\ts.log.Infow(\"image_generation 迁移成功\", \"imggen_id\", imageGen.ID, \"local_path\", localPath)\n\t\tstats.ImageGenerationsSuccess++\n\t}\n\n\treturn nil\n}\n\n// migrateScenes 迁移场景数据\nfunc (s *DataMigrationService) migrateScenes(stats *MigrationStats) error {\n\ts.log.Info(\"开始迁移场景数据...\")\n\n\tvar scenes []models.Scene\n\t// 查询 local_path 为空但 image_url 不为空的场景\n\tif err := s.db.Where(\"(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''\").Find(&scenes).Error; err != nil {\n\t\treturn fmt.Errorf(\"查询场景数据失败: %w\", err)\n\t}\n\n\ts.log.Infow(\"找到需要迁移的场景\", \"数量\", len(scenes))\n\n\tfor _, scene := range scenes {\n\t\tif scene.ImageURL == nil {\n\t\t\tcontinue\n\t\t}\n\t\ts.log.Infow(\"处理场景\", \"id\", scene.ID, \"location\", scene.Location, \"image_url\", *scene.ImageURL)\n\n\t\tlocalPath, err := s.downloadOrGetCached(*scene.ImageURL, \"images\", fmt.Sprintf(\"scene_%d\", scene.ID))\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"下载场景图片失败\", \"scene_id\", scene.ID, \"error\", err)\n\t\t\tstats.ScenesFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\t// 更新 local_path\n\t\tif err := s.db.Model(&scene).Update(\"local_path\", localPath).Error; err != nil {\n\t\t\ts.log.Errorw(\"更新场景 local_path 失败\", \"scene_id\", scene.ID, \"error\", err)\n\t\t\tstats.ScenesFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\ts.log.Infow(\"场景迁移成功\", \"scene_id\", scene.ID, \"local_path\", localPath)\n\t\tstats.ScenesSuccess++\n\t}\n\n\treturn nil\n}\n\n// migrateCharacters 迁移角色数据\nfunc (s *DataMigrationService) migrateCharacters(stats *MigrationStats) error {\n\ts.log.Info(\"开始迁移角色数据...\")\n\n\tvar characters []models.Character\n\t// 查询 local_path 为空但 image_url 不为空的角色\n\tif err := s.db.Where(\"(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''\").Find(&characters).Error; err != nil {\n\t\treturn fmt.Errorf(\"查询角色数据失败: %w\", err)\n\t}\n\n\ts.log.Infow(\"找到需要迁移的角色\", \"数量\", len(characters))\n\n\tfor _, character := range characters {\n\t\tif character.ImageURL == nil {\n\t\t\tcontinue\n\t\t}\n\t\ts.log.Infow(\"处理角色\", \"id\", character.ID, \"name\", character.Name, \"image_url\", *character.ImageURL)\n\n\t\tlocalPath, err := s.downloadOrGetCached(*character.ImageURL, \"characters\", fmt.Sprintf(\"character_%d\", character.ID))\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"下载角色图片失败\", \"character_id\", character.ID, \"error\", err)\n\t\t\tstats.CharactersFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\t// 更新 local_path\n\t\tif err := s.db.Model(&character).Update(\"local_path\", localPath).Error; err != nil {\n\t\t\ts.log.Errorw(\"更新角色 local_path 失败\", \"character_id\", character.ID, \"error\", err)\n\t\t\tstats.CharactersFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\ts.log.Infow(\"角色迁移成功\", \"character_id\", character.ID, \"local_path\", localPath)\n\t\tstats.CharactersSuccess++\n\t}\n\n\treturn nil\n}\n\n// migrateVideoGenerations 迁移视频生成数据\nfunc (s *DataMigrationService) migrateVideoGenerations(stats *MigrationStats) error {\n\ts.log.Info(\"开始迁移视频生成数据...\")\n\n\tvar videoGens []models.VideoGeneration\n\t// 查询 local_path 为空但 video_url 不为空的视频\n\tif err := s.db.Where(\"(local_path IS NULL OR local_path = '') AND video_url IS NOT NULL AND video_url != ''\").Find(&videoGens).Error; err != nil {\n\t\treturn fmt.Errorf(\"查询视频生成数据失败: %w\", err)\n\t}\n\n\ts.log.Infow(\"找到需要迁移的视频\", \"数量\", len(videoGens))\n\n\tfor _, videoGen := range videoGens {\n\t\tif videoGen.VideoURL == nil {\n\t\t\tcontinue\n\t\t}\n\t\ts.log.Infow(\"处理视频\", \"id\", videoGen.ID, \"video_url\", *videoGen.VideoURL)\n\n\t\tlocalPath, err := s.downloadOrGetCached(*videoGen.VideoURL, \"videos\", fmt.Sprintf(\"video_%d\", videoGen.ID))\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"下载视频失败\", \"video_gen_id\", videoGen.ID, \"error\", err)\n\t\t\tstats.VideosFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\t// 更新 local_path\n\t\tif err := s.db.Model(&videoGen).Update(\"local_path\", localPath).Error; err != nil {\n\t\t\ts.log.Errorw(\"更新视频 local_path 失败\", \"video_gen_id\", videoGen.ID, \"error\", err)\n\t\t\tstats.VideosFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\ts.log.Infow(\"视频迁移成功\", \"video_gen_id\", videoGen.ID, \"local_path\", localPath)\n\t\tstats.VideosSuccess++\n\t}\n\n\treturn nil\n}\n\n// downloadOrGetCached 下载文件或从缓存获取本地路径\nfunc (s *DataMigrationService) downloadOrGetCached(url, subDir, prefix string) (string, error) {\n\t// 1. 检查 URL 映射缓存\n\tif localPath, exists := s.urlMapping[url]; exists {\n\t\ts.log.Infow(\"使用缓存的本地路径\", \"url\", url, \"local_path\", localPath)\n\t\treturn localPath, nil\n\t}\n\n\t// 2. 如果缓存中没有，则下载文件\n\tvar localPath string\n\tvar err error\n\n\t// 根据子目录判断是图片还是视频\n\tif subDir == \"videos\" {\n\t\tlocalPath, err = s.downloadAndSaveVideo(url, subDir, prefix)\n\t} else {\n\t\tlocalPath, err = s.downloadAndSaveImage(url, subDir, prefix)\n\t}\n\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// 3. 将 URL 和本地路径的映射关系存入缓存\n\ts.urlMapping[url] = localPath\n\ts.log.Infow(\"已缓存 URL 映射\", \"url\", url, \"local_path\", localPath)\n\n\treturn localPath, nil\n}\n\n// downloadAndSaveImage 下载并保存图片\nfunc (s *DataMigrationService) downloadAndSaveImage(imageURL, subDir, prefix string) (string, error) {\n\tif imageURL == \"\" {\n\t\treturn \"\", fmt.Errorf(\"图片 URL 为空\")\n\t}\n\n\t// 如果已经是本地路径，直接返回\n\tif strings.HasPrefix(imageURL, \"/static/\") || strings.HasPrefix(imageURL, \"data/\") {\n\t\treturn imageURL, nil\n\t}\n\n\t// 从 URL 中提取文件扩展名（去掉查询参数）\n\text := s.extractFileExtension(imageURL)\n\n\t// 生成文件名\n\ttimestamp := time.Now().Unix()\n\tfilename := fmt.Sprintf(\"%s_%d%s\", prefix, timestamp, ext)\n\trelativePath := filepath.Join(subDir, filename)\n\tfullPath := filepath.Join(s.storageRoot, relativePath)\n\n\t// 下载文件\n\tif err := s.downloadFile(imageURL, fullPath); err != nil {\n\t\treturn \"\", fmt.Errorf(\"下载文件失败: %w\", err)\n\t}\n\n\t// 返回相对路径（用于存储到数据库）\n\treturn relativePath, nil\n}\n\n// downloadAndSaveVideo 下载并保存视频\nfunc (s *DataMigrationService) downloadAndSaveVideo(videoURL, subDir, prefix string) (string, error) {\n\tif videoURL == \"\" {\n\t\treturn \"\", fmt.Errorf(\"视频 URL 为空\")\n\t}\n\n\t// 如果已经是本地路径，直接返回\n\tif strings.HasPrefix(videoURL, \"/static/\") || strings.HasPrefix(videoURL, \"data/\") {\n\t\treturn videoURL, nil\n\t}\n\n\t// 从 URL 中提取文件扩展名（去掉查询参数）\n\text := s.extractFileExtension(videoURL)\n\tif ext == \"\" || ext == \".jpeg\" || ext == \".jpg\" || ext == \".png\" {\n\t\text = \".mp4\" // 视频默认扩展名\n\t}\n\n\t// 生成文件名\n\ttimestamp := time.Now().Unix()\n\tfilename := fmt.Sprintf(\"%s_%d%s\", prefix, timestamp, ext)\n\trelativePath := filepath.Join(subDir, filename)\n\tfullPath := filepath.Join(s.storageRoot, relativePath)\n\n\t// 下载文件\n\tif err := s.downloadFile(videoURL, fullPath); err != nil {\n\t\treturn \"\", fmt.Errorf(\"下载文件失败: %w\", err)\n\t}\n\n\t// 返回相对路径（用于存储到数据库）\n\treturn relativePath, nil\n}\n\n// extractFileExtension 从 URL 中提取文件扩展名（去掉查询参数）\nfunc (s *DataMigrationService) extractFileExtension(url string) string {\n\t// 去掉查询参数\n\tif idx := strings.Index(url, \"?\"); idx != -1 {\n\t\turl = url[:idx]\n\t}\n\t\n\t// 去掉 fragment\n\tif idx := strings.Index(url, \"#\"); idx != -1 {\n\t\turl = url[:idx]\n\t}\n\t\n\t// 获取文件扩展名\n\text := filepath.Ext(url)\n\tif ext == \"\" {\n\t\t// 如果没有扩展名，默认返回 .jpg\n\t\treturn \".jpg\"\n\t}\n\t\n\t// 转换为小写\n\text = strings.ToLower(ext)\n\t\n\t// 验证扩展名是否合理（限制长度）\n\tif len(ext) > 10 {\n\t\treturn \".jpg\"\n\t}\n\t\n\treturn ext\n}\n\n// downloadFile 下载文件到指定路径\nfunc (s *DataMigrationService) downloadFile(url, filepath string) error {\n\ts.log.Infow(\"开始下载文件\", \"url\", url, \"filepath\", filepath)\n\n\t// 创建 HTTP 请求\n\tclient := &http.Client{\n\t\tTimeout: 60 * time.Second,\n\t}\n\n\tresp, err := client.Get(url)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"HTTP 请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"HTTP 状态码错误: %d\", resp.StatusCode)\n\t}\n\n\t// 创建文件\n\tout, err := os.Create(filepath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"创建文件失败: %w\", err)\n\t}\n\tdefer out.Close()\n\n\t// 复制内容\n\twritten, err := io.Copy(out, resp.Body)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"写入文件失败: %w\", err)\n\t}\n\n\ts.log.Infow(\"文件下载成功\", \"filepath\", filepath, \"size\", written)\n\treturn nil\n}\n"
  },
  {
    "path": "application/services/drama_service.go",
    "content": "package services\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"gorm.io/gorm\"\n)\n\ntype DramaService struct {\n\tdb      *gorm.DB\n\tlog     *logger.Logger\n\tbaseURL string\n}\n\nfunc NewDramaService(db *gorm.DB, cfg *config.Config, log *logger.Logger) *DramaService {\n\treturn &DramaService{\n\t\tdb:      db,\n\t\tlog:     log,\n\t\tbaseURL: cfg.Storage.BaseURL,\n\t}\n}\n\ntype CreateDramaRequest struct {\n\tTitle       string `json:\"title\" binding:\"required,min=1,max=100\"`\n\tDescription string `json:\"description\"`\n\tGenre       string `json:\"genre\"`\n\tStyle       string `json:\"style\"`\n\tTags        string `json:\"tags\"`\n}\n\ntype UpdateDramaRequest struct {\n\tTitle       string `json:\"title\" binding:\"omitempty,min=1,max=100\"`\n\tDescription string `json:\"description\"`\n\tGenre       string `json:\"genre\"`\n\tStyle       string `json:\"style\"`\n\tTags        string `json:\"tags\"`\n\tStatus      string `json:\"status\" binding:\"omitempty,oneof=draft planning production completed archived\"`\n}\n\ntype DramaListQuery struct {\n\tPage     int    `form:\"page,default=1\"`\n\tPageSize int    `form:\"page_size,default=20\"`\n\tStatus   string `form:\"status\"`\n\tGenre    string `form:\"genre\"`\n\tKeyword  string `form:\"keyword\"`\n}\n\nfunc (s *DramaService) CreateDrama(req *CreateDramaRequest) (*models.Drama, error) {\n\tdrama := &models.Drama{\n\t\tTitle:  req.Title,\n\t\tStatus: \"draft\",\n\t\tStyle:  \"ghibli\", // 默认风格\n\t}\n\n\tif req.Description != \"\" {\n\t\tdrama.Description = &req.Description\n\t}\n\tif req.Genre != \"\" {\n\t\tdrama.Genre = &req.Genre\n\t}\n\tif req.Style != \"\" {\n\t\tdrama.Style = req.Style\n\t}\n\n\tif err := s.db.Create(drama).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to create drama\", \"error\", err)\n\t\treturn nil, err\n\t}\n\n\ts.log.Infow(\"Drama created\", \"drama_id\", drama.ID)\n\treturn drama, nil\n}\n\nfunc (s *DramaService) GetDrama(dramaID string) (*models.Drama, error) {\n\tvar drama models.Drama\n\terr := s.db.Where(\"id = ? \", dramaID).\n\t\tPreload(\"Characters\").          // 加载Drama级别的角色\n\t\tPreload(\"Scenes\").              // 加载Drama级别的场景\n\t\tPreload(\"Props\").               // 加载Drama级别的道具\n\t\tPreload(\"Episodes.Characters\"). // 加载每个章节关联的角色\n\t\tPreload(\"Episodes.Scenes\").     // 加载每个章节关联的场景\n\t\tPreload(\"Episodes.Storyboards\", func(db *gorm.DB) *gorm.DB {\n\t\t\treturn db.Order(\"storyboards.storyboard_number ASC\")\n\t\t}).\n\t\tPreload(\"Episodes.Storyboards.Props\"). // 加载分镜关联的道具\n\t\tFirst(&drama).Error\n\n\tif err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, errors.New(\"drama not found\")\n\t\t}\n\t\ts.log.Errorw(\"Failed to get drama\", \"error\", err)\n\t\treturn nil, err\n\t}\n\n\t// 统计每个剧集的时长（基于场景时长之和）\n\tfor i := range drama.Episodes {\n\t\ttotalDuration := 0\n\t\tfor _, scene := range drama.Episodes[i].Storyboards {\n\t\t\ttotalDuration += scene.Duration\n\t\t}\n\t\t// 更新剧集时长（秒转分钟，向上取整）\n\t\tdurationMinutes := (totalDuration + 59) / 60\n\t\tdrama.Episodes[i].Duration = durationMinutes\n\n\t\t// 如果数据库中的时长与计算的不一致，更新数据库\n\t\tif drama.Episodes[i].Duration != durationMinutes {\n\t\t\ts.db.Model(&models.Episode{}).Where(\"id = ?\", drama.Episodes[i].ID).Update(\"duration\", durationMinutes)\n\t\t}\n\n\t\t// 查询角色的图片生成状态\n\t\tfor j := range drama.Episodes[i].Characters {\n\t\t\tvar imageGen models.ImageGeneration\n\t\t\t// 查询进行中或失败的任务状态\n\t\t\terr := s.db.Where(\"character_id = ? AND (status = ? OR status = ?)\",\n\t\t\t\tdrama.Episodes[i].Characters[j].ID, \"pending\", \"processing\").\n\t\t\t\tOrder(\"created_at DESC\").\n\t\t\t\tFirst(&imageGen).Error\n\n\t\t\tif err == nil {\n\t\t\t\t// 找到生成中的记录，设置状态\n\t\t\t\tstatusStr := string(imageGen.Status)\n\t\t\t\tdrama.Episodes[i].Characters[j].ImageGenerationStatus = &statusStr\n\t\t\t\tif imageGen.ErrorMsg != nil {\n\t\t\t\t\tdrama.Episodes[i].Characters[j].ImageGenerationError = imageGen.ErrorMsg\n\t\t\t\t}\n\t\t\t} else if errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\t\t// 检查是否有失败的记录\n\t\t\t\terr := s.db.Where(\"character_id = ? AND status = ?\",\n\t\t\t\t\tdrama.Episodes[i].Characters[j].ID, \"failed\").\n\t\t\t\t\tOrder(\"created_at DESC\").\n\t\t\t\t\tFirst(&imageGen).Error\n\n\t\t\t\tif err == nil {\n\t\t\t\t\tstatusStr := string(imageGen.Status)\n\t\t\t\t\tdrama.Episodes[i].Characters[j].ImageGenerationStatus = &statusStr\n\t\t\t\t\tif imageGen.ErrorMsg != nil {\n\t\t\t\t\t\tdrama.Episodes[i].Characters[j].ImageGenerationError = imageGen.ErrorMsg\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 查询场景的图片生成状态\n\t\tfor j := range drama.Episodes[i].Scenes {\n\t\t\tvar imageGen models.ImageGeneration\n\t\t\t// 查询进行中或失败的任务状态\n\t\t\terr := s.db.Where(\"scene_id = ? AND (status = ? OR status = ?)\",\n\t\t\t\tdrama.Episodes[i].Scenes[j].ID, \"pending\", \"processing\").\n\t\t\t\tOrder(\"created_at DESC\").\n\t\t\t\tFirst(&imageGen).Error\n\n\t\t\tif err == nil {\n\t\t\t\t// 找到生成中的记录，设置状态\n\t\t\t\tstatusStr := string(imageGen.Status)\n\t\t\t\tdrama.Episodes[i].Scenes[j].ImageGenerationStatus = &statusStr\n\t\t\t\tif imageGen.ErrorMsg != nil {\n\t\t\t\t\tdrama.Episodes[i].Scenes[j].ImageGenerationError = imageGen.ErrorMsg\n\t\t\t\t}\n\t\t\t} else if errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\t\t// 检查是否有失败的记录\n\t\t\t\terr := s.db.Where(\"scene_id = ? AND status = ?\",\n\t\t\t\t\tdrama.Episodes[i].Scenes[j].ID, \"failed\").\n\t\t\t\t\tOrder(\"created_at DESC\").\n\t\t\t\t\tFirst(&imageGen).Error\n\n\t\t\t\tif err == nil {\n\t\t\t\t\tstatusStr := string(imageGen.Status)\n\t\t\t\t\tdrama.Episodes[i].Scenes[j].ImageGenerationStatus = &statusStr\n\t\t\t\t\tif imageGen.ErrorMsg != nil {\n\t\t\t\t\t\tdrama.Episodes[i].Scenes[j].ImageGenerationError = imageGen.ErrorMsg\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 整合所有剧集的场景到Drama级别的Scenes字段\n\tsceneMap := make(map[uint]*models.Scene) // 用于去重\n\tfor i := range drama.Episodes {\n\t\tfor j := range drama.Episodes[i].Scenes {\n\t\t\tscene := &drama.Episodes[i].Scenes[j]\n\t\t\tsceneMap[scene.ID] = scene\n\t\t}\n\t}\n\n\t// 将整合的场景添加到drama.Scenes\n\tdrama.Scenes = make([]models.Scene, 0, len(sceneMap))\n\tfor _, scene := range sceneMap {\n\t\tdrama.Scenes = append(drama.Scenes, *scene)\n\t}\n\n\t// 为所有场景的 local_path 添加 base_url 前缀\n\t// s.addBaseURLToScenes(&drama)\n\n\treturn &drama, nil\n}\n\nfunc (s *DramaService) ListDramas(query *DramaListQuery) ([]models.Drama, int64, error) {\n\tvar dramas []models.Drama\n\tvar total int64\n\n\tdb := s.db.Model(&models.Drama{})\n\n\tif query.Status != \"\" {\n\t\tdb = db.Where(\"status = ?\", query.Status)\n\t}\n\n\tif query.Genre != \"\" {\n\t\tdb = db.Where(\"genre = ?\", query.Genre)\n\t}\n\n\tif query.Keyword != \"\" {\n\t\tdb = db.Where(\"title LIKE ? OR description LIKE ?\", \"%\"+query.Keyword+\"%\", \"%\"+query.Keyword+\"%\")\n\t}\n\n\tif err := db.Count(&total).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to count dramas\", \"error\", err)\n\t\treturn nil, 0, err\n\t}\n\n\toffset := (query.Page - 1) * query.PageSize\n\terr := db.Order(\"updated_at DESC\").\n\t\tOffset(offset).\n\t\tLimit(query.PageSize).\n\t\tPreload(\"Episodes.Storyboards\", func(db *gorm.DB) *gorm.DB {\n\t\t\treturn db.Order(\"storyboards.storyboard_number ASC\")\n\t\t}).\n\t\tFind(&dramas).Error\n\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to list dramas\", \"error\", err)\n\t\treturn nil, 0, err\n\t}\n\n\t// 统计每个剧本的每个剧集的时长（基于场景时长之和）\n\tfor i := range dramas {\n\t\tfor j := range dramas[i].Episodes {\n\t\t\ttotalDuration := 0\n\t\t\tfor _, scene := range dramas[i].Episodes[j].Storyboards {\n\t\t\t\ttotalDuration += scene.Duration\n\t\t\t}\n\t\t\t// 更新剧集时长（秒转分钟，向上取整）\n\t\t\tdurationMinutes := (totalDuration + 59) / 60\n\t\t\tdramas[i].Episodes[j].Duration = durationMinutes\n\t\t}\n\t}\n\n\treturn dramas, total, nil\n}\n\nfunc (s *DramaService) UpdateDrama(dramaID string, req *UpdateDramaRequest) (*models.Drama, error) {\n\tvar drama models.Drama\n\tif err := s.db.Where(\"id = ? \", dramaID).First(&drama).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, errors.New(\"drama not found\")\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tupdates := make(map[string]interface{})\n\n\tif req.Title != \"\" {\n\t\tupdates[\"title\"] = req.Title\n\t}\n\tif req.Description != \"\" {\n\t\tupdates[\"description\"] = req.Description\n\t}\n\tif req.Genre != \"\" {\n\t\tupdates[\"genre\"] = req.Genre\n\t}\n\tif req.Style != \"\" {\n\t\tupdates[\"style\"] = req.Style\n\t}\n\tif req.Tags != \"\" {\n\t\tupdates[\"tags\"] = req.Tags\n\t}\n\tif req.Status != \"\" {\n\t\tupdates[\"status\"] = req.Status\n\t}\n\n\tupdates[\"updated_at\"] = time.Now()\n\n\tif err := s.db.Model(&drama).Updates(updates).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to update drama\", \"error\", err)\n\t\treturn nil, err\n\t}\n\n\ts.log.Infow(\"Drama updated\", \"drama_id\", dramaID)\n\treturn &drama, nil\n}\n\nfunc (s *DramaService) DeleteDrama(dramaID string) error {\n\tresult := s.db.Where(\"id = ? \", dramaID).Delete(&models.Drama{})\n\n\tif result.Error != nil {\n\t\ts.log.Errorw(\"Failed to delete drama\", \"error\", result.Error)\n\t\treturn result.Error\n\t}\n\n\tif result.RowsAffected == 0 {\n\t\treturn errors.New(\"drama not found\")\n\t}\n\n\ts.log.Infow(\"Drama deleted\", \"drama_id\", dramaID)\n\treturn nil\n}\n\nfunc (s *DramaService) GetDramaStats() (map[string]interface{}, error) {\n\tvar total int64\n\tvar byStatus []struct {\n\t\tStatus string\n\t\tCount  int64\n\t}\n\n\tif err := s.db.Model(&models.Drama{}).Count(&total).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := s.db.Model(&models.Drama{}).\n\t\tSelect(\"status, count(*) as count\").\n\t\tGroup(\"status\").\n\t\tScan(&byStatus).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\tstats := map[string]interface{}{\n\t\t\"total\":     total,\n\t\t\"by_status\": byStatus,\n\t}\n\n\treturn stats, nil\n}\n\ntype SaveOutlineRequest struct {\n\tTitle   string   `json:\"title\" binding:\"required\"`\n\tSummary string   `json:\"summary\" binding:\"required\"`\n\tGenre   string   `json:\"genre\"`\n\tTags    []string `json:\"tags\"`\n}\n\ntype SaveCharactersRequest struct {\n\tCharacters []models.Character `json:\"characters\" binding:\"required\"`\n\tEpisodeID  *uint              `json:\"episode_id\"` // 可选：如果提供则关联到指定章节\n}\n\ntype SaveProgressRequest struct {\n\tCurrentStep string                 `json:\"current_step\" binding:\"required\"`\n\tStepData    map[string]interface{} `json:\"step_data\"`\n}\n\ntype SaveEpisodesRequest struct {\n\tEpisodes []models.Episode `json:\"episodes\" binding:\"required\"`\n}\n\nfunc (s *DramaService) SaveOutline(dramaID string, req *SaveOutlineRequest) error {\n\tvar drama models.Drama\n\tif err := s.db.Where(\"id = ? \", dramaID).First(&drama).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn errors.New(\"drama not found\")\n\t\t}\n\t\treturn err\n\t}\n\n\tupdates := map[string]interface{}{\n\t\t\"title\":       req.Title,\n\t\t\"description\": req.Summary,\n\t\t\"updated_at\":  time.Now(),\n\t}\n\n\tif req.Genre != \"\" {\n\t\tupdates[\"genre\"] = req.Genre\n\t}\n\n\tif len(req.Tags) > 0 {\n\t\ttagsJSON, err := json.Marshal(req.Tags)\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"Failed to marshal tags\", \"error\", err)\n\t\t\treturn err\n\t\t}\n\t\tupdates[\"tags\"] = tagsJSON\n\t}\n\n\tif err := s.db.Model(&drama).Updates(updates).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to save outline\", \"error\", err)\n\t\treturn err\n\t}\n\n\ts.log.Infow(\"Outline saved\", \"drama_id\", dramaID)\n\treturn nil\n}\n\nfunc (s *DramaService) GetCharacters(dramaID string, episodeID *string) ([]models.Character, error) {\n\tvar drama models.Drama\n\tif err := s.db.Where(\"id = ? \", dramaID).First(&drama).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, errors.New(\"drama not found\")\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tvar characters []models.Character\n\n\t// 如果指定了episodeID，只获取该章节关联的角色\n\tif episodeID != nil {\n\t\tvar episode models.Episode\n\t\tif err := s.db.Preload(\"Characters\").Where(\"id = ? AND drama_id = ?\", *episodeID, dramaID).First(&episode).Error; err != nil {\n\t\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\t\treturn nil, errors.New(\"episode not found\")\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tcharacters = episode.Characters\n\t} else {\n\t\t// 如果没有指定episodeID，获取项目的所有角色\n\t\tif err := s.db.Where(\"drama_id = ?\", dramaID).Find(&characters).Error; err != nil {\n\t\t\ts.log.Errorw(\"Failed to get characters\", \"error\", err)\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// 查询每个角色的图片生成任务状态\n\tfor i := range characters {\n\t\t// 查询该角色最新的图片生成任务\n\t\tvar imageGen models.ImageGeneration\n\t\terr := s.db.Where(\"character_id = ?\", characters[i].ID).\n\t\t\tOrder(\"created_at DESC\").\n\t\t\tFirst(&imageGen).Error\n\n\t\tif err == nil {\n\t\t\t// 如果有进行中的任务，填充状态信息\n\t\t\tif imageGen.Status == models.ImageStatusPending || imageGen.Status == models.ImageStatusProcessing {\n\t\t\t\tstatusStr := string(imageGen.Status)\n\t\t\t\tcharacters[i].ImageGenerationStatus = &statusStr\n\t\t\t} else if imageGen.Status == models.ImageStatusFailed {\n\t\t\t\tstatusStr := \"failed\"\n\t\t\t\tcharacters[i].ImageGenerationStatus = &statusStr\n\t\t\t\tif imageGen.ErrorMsg != nil {\n\t\t\t\t\tcharacters[i].ImageGenerationError = imageGen.ErrorMsg\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn characters, nil\n}\n\nfunc (s *DramaService) SaveCharacters(dramaID string, req *SaveCharactersRequest) error {\n\t// 转换dramaID\n\tid, err := strconv.ParseUint(dramaID, 10, 32)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid drama ID\")\n\t}\n\tdramaIDUint := uint(id)\n\n\tvar drama models.Drama\n\tif err := s.db.Where(\"id = ? \", dramaIDUint).First(&drama).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn errors.New(\"drama not found\")\n\t\t}\n\t\treturn err\n\t}\n\n\t// 如果指定了EpisodeID，验证章节存在性\n\tif req.EpisodeID != nil {\n\t\tvar episode models.Episode\n\t\tif err := s.db.Where(\"id = ? AND drama_id = ?\", *req.EpisodeID, dramaIDUint).First(&episode).Error; err != nil {\n\t\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\t\treturn errors.New(\"episode not found\")\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// 获取该项目已存在的所有角色\n\tvar existingCharacters []models.Character\n\tif err := s.db.Where(\"drama_id = ?\", dramaIDUint).Find(&existingCharacters).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to get existing characters\", \"error\", err)\n\t\treturn err\n\t}\n\n\t// 创建角色名称到角色的映射\n\texistingCharMap := make(map[string]*models.Character)\n\tfor i := range existingCharacters {\n\t\texistingCharMap[existingCharacters[i].Name] = &existingCharacters[i]\n\t}\n\n\t// 收集需要关联到章节的角色ID\n\tvar characterIDs []uint\n\n\t// 创建新角色或复用/更新已有角色\n\tfor _, char := range req.Characters {\n\t\t// 1. 如果提供了ID，尝试更新已有角色\n\t\tif char.ID > 0 {\n\t\t\tvar existing models.Character\n\t\t\tif err := s.db.Where(\"id = ? AND drama_id = ?\", char.ID, dramaIDUint).First(&existing).Error; err == nil {\n\t\t\t\t// 更新角色信息\n\t\t\t\tupdates := map[string]interface{}{\n\t\t\t\t\t\"name\":        char.Name,\n\t\t\t\t\t\"role\":        char.Role,\n\t\t\t\t\t\"description\": char.Description,\n\t\t\t\t\t\"personality\": char.Personality,\n\t\t\t\t\t\"appearance\":  char.Appearance,\n\t\t\t\t\t\"image_url\":   char.ImageURL,\n\t\t\t\t}\n\t\t\t\tif err := s.db.Model(&existing).Updates(updates).Error; err != nil {\n\t\t\t\t\ts.log.Errorw(\"Failed to update character\", \"error\", err, \"id\", char.ID)\n\t\t\t\t}\n\t\t\t\tcharacterIDs = append(characterIDs, existing.ID)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\t// 2. 如果没有ID但名字已存在，直接复用（可选：也可以选择更新）\n\t\tif existingChar, exists := existingCharMap[char.Name]; exists {\n\t\t\ts.log.Infow(\"Character already exists, reusing\", \"name\", char.Name, \"character_id\", existingChar.ID)\n\t\t\tcharacterIDs = append(characterIDs, existingChar.ID)\n\t\t\tcontinue\n\t\t}\n\n\t\t// 3. 角色不存在，创建新角色\n\t\tcharacter := models.Character{\n\t\t\tDramaID:     dramaIDUint,\n\t\t\tName:        char.Name,\n\t\t\tRole:        char.Role,\n\t\t\tDescription: char.Description,\n\t\t\tPersonality: char.Personality,\n\t\t\tAppearance:  char.Appearance,\n\t\t\tImageURL:    char.ImageURL,\n\t\t}\n\n\t\tif err := s.db.Create(&character).Error; err != nil {\n\t\t\ts.log.Errorw(\"Failed to create character\", \"error\", err, \"name\", char.Name)\n\t\t\tcontinue\n\t\t}\n\n\t\ts.log.Infow(\"New character created\", \"character_id\", character.ID, \"name\", char.Name)\n\t\tcharacterIDs = append(characterIDs, character.ID)\n\t}\n\n\t// 如果指定了EpisodeID，建立角色与章节的关联\n\tif req.EpisodeID != nil && len(characterIDs) > 0 {\n\t\tvar episode models.Episode\n\t\tif err := s.db.First(&episode, *req.EpisodeID).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// 获取角色对象\n\t\tvar characters []models.Character\n\t\tif err := s.db.Where(\"id IN ?\", characterIDs).Find(&characters).Error; err != nil {\n\t\t\ts.log.Errorw(\"Failed to get characters\", \"error\", err)\n\t\t\treturn err\n\t\t}\n\n\t\t// 使用GORM的Association API建立多对多关系（会自动去重）\n\t\tif err := s.db.Model(&episode).Association(\"Characters\").Append(&characters); err != nil {\n\t\t\ts.log.Errorw(\"Failed to associate characters with episode\", \"error\", err)\n\t\t\treturn err\n\t\t}\n\n\t\ts.log.Infow(\"Characters associated with episode\", \"episode_id\", *req.EpisodeID, \"character_count\", len(characterIDs))\n\t}\n\n\tif err := s.db.Model(&drama).Update(\"updated_at\", time.Now()).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to update drama timestamp\", \"error\", err)\n\t}\n\n\ts.log.Infow(\"Characters saved\", \"drama_id\", dramaID, \"count\", len(req.Characters))\n\treturn nil\n}\n\nfunc (s *DramaService) SaveEpisodes(dramaID string, req *SaveEpisodesRequest) error {\n\t// 转换dramaID\n\tid, err := strconv.ParseUint(dramaID, 10, 32)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid drama ID\")\n\t}\n\tdramaIDUint := uint(id)\n\n\tvar drama models.Drama\n\tif err := s.db.Where(\"id = ? \", dramaIDUint).First(&drama).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn errors.New(\"drama not found\")\n\t\t}\n\t\treturn err\n\t}\n\n\t// 删除旧剧集\n\tif err := s.db.Where(\"drama_id = ?\", dramaIDUint).Delete(&models.Episode{}).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to delete old episodes\", \"error\", err)\n\t\treturn err\n\t}\n\n\t// 创建新剧集（不包含场景，场景由后续步骤生成）\n\tfor _, ep := range req.Episodes {\n\t\tepisode := models.Episode{\n\t\t\tDramaID:       dramaIDUint,\n\t\t\tEpisodeNum:    ep.EpisodeNum,\n\t\t\tTitle:         ep.Title,\n\t\t\tDescription:   ep.Description,\n\t\t\tScriptContent: ep.ScriptContent,\n\t\t\tDuration:      ep.Duration,\n\t\t\tStatus:        \"draft\",\n\t\t}\n\n\t\tif err := s.db.Create(&episode).Error; err != nil {\n\t\t\ts.log.Errorw(\"Failed to create episode\", \"error\", err, \"episode\", ep.EpisodeNum)\n\t\t\tcontinue\n\t\t}\n\t}\n\n\tif err := s.db.Model(&drama).Update(\"updated_at\", time.Now()).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to update drama timestamp\", \"error\", err)\n\t}\n\n\ts.log.Infow(\"Episodes saved\", \"drama_id\", dramaID, \"count\", len(req.Episodes))\n\treturn nil\n}\n\nfunc (s *DramaService) SaveProgress(dramaID string, req *SaveProgressRequest) error {\n\tvar drama models.Drama\n\tif err := s.db.Where(\"id = ? \", dramaID).First(&drama).Error; err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn errors.New(\"drama not found\")\n\t\t}\n\t\treturn err\n\t}\n\n\t// 构建metadata对象\n\tmetadata := make(map[string]interface{})\n\n\t// 保留现有metadata\n\tif drama.Metadata != nil {\n\t\tif err := json.Unmarshal(drama.Metadata, &metadata); err != nil {\n\t\t\ts.log.Warnw(\"Failed to unmarshal existing metadata\", \"error\", err)\n\t\t}\n\t}\n\n\t// 更新progress信息\n\tmetadata[\"current_step\"] = req.CurrentStep\n\tif req.StepData != nil {\n\t\tmetadata[\"step_data\"] = req.StepData\n\t}\n\n\t// 序列化metadata\n\tmetadataJSON, err := json.Marshal(metadata)\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to marshal metadata\", \"error\", err)\n\t\treturn err\n\t}\n\n\tupdates := map[string]interface{}{\n\t\t\"metadata\":   metadataJSON,\n\t\t\"updated_at\": time.Now(),\n\t}\n\n\tif err := s.db.Model(&drama).Updates(updates).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to save progress\", \"error\", err)\n\t\treturn err\n\t}\n\n\ts.log.Infow(\"Progress saved\", \"drama_id\", dramaID, \"step\", req.CurrentStep)\n\treturn nil\n}\n\n// addBaseURLToScenes 为剧本中所有场景的 local_path 添加 base_url 前缀\nfunc (s *DramaService) addBaseURLToScenes(drama *models.Drama) {\n\t// 处理 drama.Scenes\n\tfor i := range drama.Scenes {\n\t\tif drama.Scenes[i].LocalPath != nil && *drama.Scenes[i].LocalPath != \"\" {\n\t\t\tfullPath := fmt.Sprintf(\"%s/%s\", s.baseURL, *drama.Scenes[i].LocalPath)\n\t\t\tdrama.Scenes[i].LocalPath = &fullPath\n\t\t}\n\t}\n\n\t// 处理 drama.Episodes[].Scenes\n\tfor i := range drama.Episodes {\n\t\tfor j := range drama.Episodes[i].Scenes {\n\t\t\tif drama.Episodes[i].Scenes[j].LocalPath != nil && *drama.Episodes[i].Scenes[j].LocalPath != \"\" {\n\t\t\t\tfullPath := fmt.Sprintf(\"%s/%s\", s.baseURL, *drama.Episodes[i].Scenes[j].LocalPath)\n\t\t\t\tdrama.Episodes[i].Scenes[j].LocalPath = &fullPath\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "application/services/frame_prompt_helper.go",
    "content": "package services\n\nimport (\n\t\"encoding/json\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// parseFramePromptJSON 解析AI返回的JSON格式提示词\nfunc (s *FramePromptService) parseFramePromptJSON(aiResponse string) *SingleFramePrompt {\n\t// 清理可能的markdown代码块标记\n\tcleaned := strings.TrimSpace(aiResponse)\n\n\t// 移除 ```json 和 ``` 标记\n\tre := regexp.MustCompile(\"(?s)```json\\\\s*(.+?)\\\\s*```\")\n\tif matches := re.FindStringSubmatch(cleaned); len(matches) > 1 {\n\t\tcleaned = strings.TrimSpace(matches[1])\n\t} else {\n\t\t// 移除单独的 ``` 标记\n\t\tcleaned = strings.Trim(cleaned, \"`\")\n\t\tcleaned = strings.TrimSpace(cleaned)\n\t}\n\n\t// 尝试解析JSON\n\tvar result SingleFramePrompt\n\tif err := json.Unmarshal([]byte(cleaned), &result); err != nil {\n\t\ts.log.Warnw(\"Failed to parse JSON\", \"error\", err, \"cleaned_response\", cleaned)\n\t\treturn nil\n\t}\n\n\t// 验证必需字段\n\tif result.Prompt == \"\" {\n\t\ts.log.Warnw(\"Parsed JSON missing prompt field\", \"response\", cleaned)\n\t\treturn nil\n\t}\n\n\treturn &result\n}\n"
  },
  {
    "path": "application/services/frame_prompt_service.go",
    "content": "package services\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"gorm.io/gorm\"\n)\n\n// FramePromptService 处理帧提示词生成\ntype FramePromptService struct {\n\tdb          *gorm.DB\n\taiService   *AIService\n\tlog         *logger.Logger\n\tconfig      *config.Config\n\tpromptI18n  *PromptI18n\n\ttaskService *TaskService\n}\n\n// NewFramePromptService 创建帧提示词服务\nfunc NewFramePromptService(db *gorm.DB, cfg *config.Config, log *logger.Logger) *FramePromptService {\n\treturn &FramePromptService{\n\t\tdb:          db,\n\t\taiService:   NewAIService(db, log),\n\t\tlog:         log,\n\t\tconfig:      cfg,\n\t\tpromptI18n:  NewPromptI18n(cfg),\n\t\ttaskService: NewTaskService(db, log),\n\t}\n}\n\n// FrameType 帧类型\ntype FrameType string\n\nconst (\n\tFrameTypeFirst  FrameType = \"first\"  // 首帧\n\tFrameTypeKey    FrameType = \"key\"    // 关键帧\n\tFrameTypeLast   FrameType = \"last\"   // 尾帧\n\tFrameTypePanel  FrameType = \"panel\"  // 分镜板（3格组合）\n\tFrameTypeAction FrameType = \"action\" // 动作序列（5格）\n)\n\n// GenerateFramePromptRequest 生成帧提示词请求\ntype GenerateFramePromptRequest struct {\n\tStoryboardID string    `json:\"storyboard_id\"`\n\tFrameType    FrameType `json:\"frame_type\"`\n\t// 可选参数\n\tPanelCount int `json:\"panel_count,omitempty\"` // 分镜板格数，默认3\n}\n\n// FramePromptResponse 帧提示词响应\ntype FramePromptResponse struct {\n\tFrameType   FrameType          `json:\"frame_type\"`\n\tSingleFrame *SingleFramePrompt `json:\"single_frame,omitempty\"` // 单帧提示词\n\tMultiFrame  *MultiFramePrompt  `json:\"multi_frame,omitempty\"`  // 多帧提示词\n}\n\n// SingleFramePrompt 单帧提示词\ntype SingleFramePrompt struct {\n\tPrompt      string `json:\"prompt\"`\n\tDescription string `json:\"description\"`\n}\n\n// MultiFramePrompt 多帧提示词\ntype MultiFramePrompt struct {\n\tLayout string              `json:\"layout\"` // horizontal_3, grid_2x2 等\n\tFrames []SingleFramePrompt `json:\"frames\"`\n}\n\n// GenerateFramePrompt 生成指定类型的帧提示词并保存到frame_prompts表\nfunc (s *FramePromptService) GenerateFramePrompt(req GenerateFramePromptRequest, model string) (string, error) {\n\t// 查询分镜信息\n\tvar storyboard models.Storyboard\n\tif err := s.db.Preload(\"Characters\").First(&storyboard, req.StoryboardID).Error; err != nil {\n\t\treturn \"\", fmt.Errorf(\"storyboard not found: %w\", err)\n\t}\n\n\t// 创建任务\n\ttask, err := s.taskService.CreateTask(\"frame_prompt_generation\", req.StoryboardID)\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to create frame prompt generation task\", \"error\", err, \"storyboard_id\", req.StoryboardID)\n\t\treturn \"\", fmt.Errorf(\"创建任务失败: %w\", err)\n\t}\n\n\t// 异步处理帧提示词生成\n\tgo s.processFramePromptGeneration(task.ID, req, model)\n\n\ts.log.Infow(\"Frame prompt generation task created\", \"task_id\", task.ID, \"storyboard_id\", req.StoryboardID, \"frame_type\", req.FrameType)\n\treturn task.ID, nil\n}\n\n// processFramePromptGeneration 异步处理帧提示词生成\nfunc (s *FramePromptService) processFramePromptGeneration(taskID string, req GenerateFramePromptRequest, model string) {\n\t// 更新任务状态为处理中\n\ts.taskService.UpdateTaskStatus(taskID, \"processing\", 0, \"正在生成帧提示词...\")\n\n\t// 查询分镜信息\n\tvar storyboard models.Storyboard\n\tif err := s.db.Preload(\"Characters\").First(&storyboard, req.StoryboardID).Error; err != nil {\n\t\ts.log.Errorw(\"Storyboard not found during frame prompt generation\", \"error\", err, \"storyboard_id\", req.StoryboardID)\n\t\ts.taskService.UpdateTaskStatus(taskID, \"failed\", 0, \"分镜信息不存在\")\n\t\treturn\n\t}\n\n\t// 获取场景信息\n\tvar scene *models.Scene\n\tif storyboard.SceneID != nil {\n\t\tscene = &models.Scene{}\n\t\tif err := s.db.First(scene, *storyboard.SceneID).Error; err != nil {\n\t\t\ts.log.Warnw(\"Scene not found during frame prompt generation\", \"scene_id\", *storyboard.SceneID, \"task_id\", taskID)\n\t\t\tscene = nil\n\t\t}\n\t}\n\n\t// 获取 drama 的 style 信息\n\tvar episode models.Episode\n\tif err := s.db.Preload(\"Drama\").First(&episode, storyboard.EpisodeID).Error; err != nil {\n\t\ts.log.Warnw(\"Failed to load episode and drama\", \"error\", err, \"episode_id\", storyboard.EpisodeID)\n\t}\n\tdramaStyle := episode.Drama.Style\n\n\tresponse := &FramePromptResponse{\n\t\tFrameType: req.FrameType,\n\t}\n\n\t// 生成提示词\n\tswitch req.FrameType {\n\tcase FrameTypeFirst:\n\t\tresponse.SingleFrame = s.generateFirstFrame(storyboard, scene, dramaStyle, model)\n\t\t// 保存单帧提示词\n\t\ts.saveFramePrompt(req.StoryboardID, string(req.FrameType), response.SingleFrame.Prompt, response.SingleFrame.Description, \"\")\n\tcase FrameTypeKey:\n\t\tresponse.SingleFrame = s.generateKeyFrame(storyboard, scene, dramaStyle, model)\n\t\ts.saveFramePrompt(req.StoryboardID, string(req.FrameType), response.SingleFrame.Prompt, response.SingleFrame.Description, \"\")\n\tcase FrameTypeLast:\n\t\tresponse.SingleFrame = s.generateLastFrame(storyboard, scene, dramaStyle, model)\n\t\ts.saveFramePrompt(req.StoryboardID, string(req.FrameType), response.SingleFrame.Prompt, response.SingleFrame.Description, \"\")\n\tcase FrameTypePanel:\n\t\tcount := req.PanelCount\n\t\tif count == 0 {\n\t\t\tcount = 3\n\t\t}\n\t\tresponse.MultiFrame = s.generatePanelFrames(storyboard, scene, count, dramaStyle, model)\n\t\t// 保存多帧提示词（合并为一条记录）\n\t\tvar prompts []string\n\t\tfor _, frame := range response.MultiFrame.Frames {\n\t\t\tprompts = append(prompts, frame.Prompt)\n\t\t}\n\t\tcombinedPrompt := strings.Join(prompts, \"\\n---\\n\")\n\t\ts.saveFramePrompt(req.StoryboardID, string(req.FrameType), combinedPrompt, \"分镜板组合提示词\", response.MultiFrame.Layout)\n\tcase FrameTypeAction:\n\t\tresponse.MultiFrame = s.generateActionSequence(storyboard, scene, dramaStyle, model)\n\t\tvar prompts []string\n\t\tfor _, frame := range response.MultiFrame.Frames {\n\t\t\tprompts = append(prompts, frame.Prompt)\n\t\t}\n\t\tcombinedPrompt := strings.Join(prompts, \"\\n---\\n\")\n\t\ts.saveFramePrompt(req.StoryboardID, string(req.FrameType), combinedPrompt, \"动作序列组合提示词\", response.MultiFrame.Layout)\n\tdefault:\n\t\ts.log.Errorw(\"Unsupported frame type during frame prompt generation\", \"frame_type\", req.FrameType, \"task_id\", taskID)\n\t\ts.taskService.UpdateTaskStatus(taskID, \"failed\", 0, \"不支持的帧类型\")\n\t\treturn\n\t}\n\n\t// 更新任务状态为完成\n\ts.taskService.UpdateTaskResult(taskID, map[string]interface{}{\n\t\t\"response\":      response,\n\t\t\"storyboard_id\": req.StoryboardID,\n\t\t\"frame_type\":    string(req.FrameType),\n\t})\n\n\ts.log.Infow(\"Frame prompt generation completed\", \"task_id\", taskID, \"storyboard_id\", req.StoryboardID, \"frame_type\", req.FrameType)\n}\n\n// saveFramePrompt 保存帧提示词到数据库\nfunc (s *FramePromptService) saveFramePrompt(storyboardID, frameType, prompt, description, layout string) {\n\tframePrompt := models.FramePrompt{\n\t\tStoryboardID: uint(mustParseUint(storyboardID)),\n\t\tFrameType:    frameType,\n\t\tPrompt:       prompt,\n\t}\n\n\tif description != \"\" {\n\t\tframePrompt.Description = &description\n\t}\n\tif layout != \"\" {\n\t\tframePrompt.Layout = &layout\n\t}\n\n\t// 先删除同类型的旧记录（保持最新）\n\ts.db.Where(\"storyboard_id = ? AND frame_type = ?\", storyboardID, frameType).Delete(&models.FramePrompt{})\n\n\t// 插入新记录\n\tif err := s.db.Create(&framePrompt).Error; err != nil {\n\t\ts.log.Warnw(\"Failed to save frame prompt\", \"error\", err, \"storyboard_id\", storyboardID, \"frame_type\", frameType)\n\t}\n}\n\n// mustParseUint 辅助函数\nfunc mustParseUint(s string) uint64 {\n\tvar result uint64\n\tfmt.Sscanf(s, \"%d\", &result)\n\treturn result\n}\n\n// generateFirstFrame 生成首帧提示词\nfunc (s *FramePromptService) generateFirstFrame(sb models.Storyboard, scene *models.Scene, dramaStyle string, model string) *SingleFramePrompt {\n\t// 构建上下文信息\n\tcontextInfo := s.buildStoryboardContext(sb, scene)\n\n\t// 使用国际化提示词\n\tsystemPrompt := s.promptI18n.GetFirstFramePrompt(dramaStyle)\n\tuserPrompt := s.promptI18n.FormatUserPrompt(\"frame_info\", contextInfo)\n\n\t// 调用AI生成（如果指定了模型则使用指定的模型）\n\tvar aiResponse string\n\tvar err error\n\tif model != \"\" {\n\t\tclient, getErr := s.aiService.GetAIClientForModel(\"text\", model)\n\t\tif getErr != nil {\n\t\t\ts.log.Warnw(\"Failed to get client for specified model, using default\", \"model\", model, \"error\", getErr)\n\t\t\taiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt)\n\t\t} else {\n\t\t\taiResponse, err = client.GenerateText(userPrompt, systemPrompt)\n\t\t}\n\t} else {\n\t\taiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt)\n\t}\n\tif err != nil {\n\t\ts.log.Warnw(\"AI generation failed, using fallback\", \"error\", err)\n\t\t// 降级方案：使用简单拼接\n\t\tfallbackPrompt := s.buildFallbackPrompt(sb, scene, \"first frame, static shot\")\n\t\treturn &SingleFramePrompt{\n\t\t\tPrompt:      fallbackPrompt,\n\t\t\tDescription: \"镜头开始的静态画面，展示初始状态\",\n\t\t}\n\t}\n\n\t// 解析AI返回的JSON\n\tresult := s.parseFramePromptJSON(aiResponse)\n\tif result == nil {\n\t\t// JSON解析失败，使用降级方案\n\t\ts.log.Warnw(\"Failed to parse AI JSON response, using fallback\", \"storyboard_id\", sb.ID, \"response\", aiResponse)\n\t\tfallbackPrompt := s.buildFallbackPrompt(sb, scene, \"first frame, static shot\")\n\t\treturn &SingleFramePrompt{\n\t\t\tPrompt:      fallbackPrompt,\n\t\t\tDescription: \"镜头开始的静态画面，展示初始状态\",\n\t\t}\n\t}\n\n\treturn result\n}\n\n// generateKeyFrame 生成关键帧提示词\nfunc (s *FramePromptService) generateKeyFrame(sb models.Storyboard, scene *models.Scene, dramaStyle string, model string) *SingleFramePrompt {\n\t// 构建上下文信息\n\tcontextInfo := s.buildStoryboardContext(sb, scene)\n\n\t// 使用国际化提示词\n\tsystemPrompt := s.promptI18n.GetKeyFramePrompt(dramaStyle)\n\tuserPrompt := s.promptI18n.FormatUserPrompt(\"key_frame_info\", contextInfo)\n\n\t// 调用AI生成（如果指定了模型则使用指定的模型）\n\tvar aiResponse string\n\tvar err error\n\tif model != \"\" {\n\t\tclient, getErr := s.aiService.GetAIClientForModel(\"text\", model)\n\t\tif getErr != nil {\n\t\t\ts.log.Warnw(\"Failed to get client for specified model, using default\", \"model\", model, \"error\", getErr)\n\t\t\taiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt)\n\t\t} else {\n\t\t\taiResponse, err = client.GenerateText(userPrompt, systemPrompt)\n\t\t}\n\t} else {\n\t\taiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt)\n\t}\n\tif err != nil {\n\t\ts.log.Warnw(\"AI generation failed, using fallback\", \"error\", err)\n\t\tfallbackPrompt := s.buildFallbackPrompt(sb, scene, \"key frame, dynamic action\")\n\t\treturn &SingleFramePrompt{\n\t\t\tPrompt:      fallbackPrompt,\n\t\t\tDescription: \"动作高潮瞬间，展示关键动作\",\n\t\t}\n\t}\n\n\t// 解析AI返回的JSON\n\tresult := s.parseFramePromptJSON(aiResponse)\n\tif result == nil {\n\t\t// JSON解析失败，使用降级方案\n\t\ts.log.Warnw(\"Failed to parse AI JSON response, using fallback\", \"storyboard_id\", sb.ID, \"response\", aiResponse)\n\t\tfallbackPrompt := s.buildFallbackPrompt(sb, scene, \"key frame, dynamic action\")\n\t\treturn &SingleFramePrompt{\n\t\t\tPrompt:      fallbackPrompt,\n\t\t\tDescription: \"动作高潮瞬间，展示关键动作\",\n\t\t}\n\t}\n\n\treturn result\n}\n\n// generateLastFrame 生成尾帧提示词\nfunc (s *FramePromptService) generateLastFrame(sb models.Storyboard, scene *models.Scene, dramaStyle string, model string) *SingleFramePrompt {\n\t// 构建上下文信息\n\tcontextInfo := s.buildStoryboardContext(sb, scene)\n\n\t// 使用国际化提示词\n\tsystemPrompt := s.promptI18n.GetLastFramePrompt(dramaStyle)\n\tuserPrompt := s.promptI18n.FormatUserPrompt(\"last_frame_info\", contextInfo)\n\n\t// 调用AI生成（如果指定了模型则使用指定的模型）\n\tvar aiResponse string\n\tvar err error\n\tif model != \"\" {\n\t\tclient, getErr := s.aiService.GetAIClientForModel(\"text\", model)\n\t\tif getErr != nil {\n\t\t\ts.log.Warnw(\"Failed to get client for specified model, using default\", \"model\", model, \"error\", getErr)\n\t\t\taiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt)\n\t\t} else {\n\t\t\taiResponse, err = client.GenerateText(userPrompt, systemPrompt)\n\t\t}\n\t} else {\n\t\taiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt)\n\t}\n\tif err != nil {\n\t\ts.log.Warnw(\"AI generation failed, using fallback\", \"error\", err)\n\t\tfallbackPrompt := s.buildFallbackPrompt(sb, scene, \"last frame, final state\")\n\t\treturn &SingleFramePrompt{\n\t\t\tPrompt:      fallbackPrompt,\n\t\t\tDescription: \"镜头结束画面，展示最终状态和结果\",\n\t\t}\n\t}\n\n\t// 解析AI返回的JSON\n\tresult := s.parseFramePromptJSON(aiResponse)\n\tif result == nil {\n\t\t// JSON解析失败，使用降级方案\n\t\ts.log.Warnw(\"Failed to parse AI JSON response, using fallback\", \"storyboard_id\", sb.ID, \"response\", aiResponse)\n\t\tfallbackPrompt := s.buildFallbackPrompt(sb, scene, \"last frame, final state\")\n\t\treturn &SingleFramePrompt{\n\t\t\tPrompt:      fallbackPrompt,\n\t\t\tDescription: \"镜头结束画面，展示最终状态和结果\",\n\t\t}\n\t}\n\n\treturn result\n}\n\n// generatePanelFrames 生成分镜板提示词（多格组合）\nfunc (s *FramePromptService) generatePanelFrames(sb models.Storyboard, scene *models.Scene, count int, dramaStyle string, model string) *MultiFramePrompt {\n\tlayout := fmt.Sprintf(\"horizontal_%d\", count)\n\n\tframes := make([]SingleFramePrompt, count)\n\n\t// 固定生成：首帧 -> 关键帧 -> 尾帧\n\tif count == 3 {\n\t\tframes[0] = *s.generateFirstFrame(sb, scene, dramaStyle, model)\n\t\tframes[0].Description = \"第1格：初始状态\"\n\n\t\tframes[1] = *s.generateKeyFrame(sb, scene, dramaStyle, model)\n\t\tframes[1].Description = \"第2格：动作高潮\"\n\n\t\tframes[2] = *s.generateLastFrame(sb, scene, dramaStyle, model)\n\t\tframes[2].Description = \"第3格：最终状态\"\n\t} else if count == 4 {\n\t\t// 4格：首帧 -> 中间帧1 -> 中间帧2 -> 尾帧\n\t\tframes[0] = *s.generateFirstFrame(sb, scene, dramaStyle, model)\n\t\tframes[1] = *s.generateKeyFrame(sb, scene, dramaStyle, model)\n\t\tframes[2] = *s.generateKeyFrame(sb, scene, dramaStyle, model)\n\t\tframes[3] = *s.generateLastFrame(sb, scene, dramaStyle, model)\n\t}\n\n\treturn &MultiFramePrompt{\n\t\tLayout: layout,\n\t\tFrames: frames,\n\t}\n}\n\n// generateActionSequence 生成动作序列提示词（3x3宫格）\nfunc (s *FramePromptService) generateActionSequence(sb models.Storyboard, scene *models.Scene, dramaStyle string, model string) *MultiFramePrompt {\n\t// 构建上下文信息\n\tcontextInfo := s.buildStoryboardContext(sb, scene)\n\n\t// 使用国际化提示词 - 专门为动作序列设计的提示词\n\tsystemPrompt := s.promptI18n.GetActionSequenceFramePrompt(dramaStyle)\n\tuserPrompt := s.promptI18n.FormatUserPrompt(\"frame_info\", contextInfo)\n\n\t// 调用AI生成（如果指定了模型则使用指定的模型）\n\tvar aiResponse string\n\tvar err error\n\tif model != \"\" {\n\t\tclient, getErr := s.aiService.GetAIClientForModel(\"text\", model)\n\t\tif getErr != nil {\n\t\t\ts.log.Warnw(\"Failed to get client for specified model, using default\", \"model\", model, \"error\", getErr)\n\t\t\taiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt)\n\t\t} else {\n\t\t\taiResponse, err = client.GenerateText(userPrompt, systemPrompt)\n\t\t}\n\t} else {\n\t\taiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt)\n\t}\n\n\tif err != nil {\n\t\ts.log.Warnw(\"AI generation failed for action sequence, using fallback\", \"error\", err)\n\t\t// 降级方案：使用简单拼接\n\t\tfallbackPrompt := s.buildFallbackPrompt(sb, scene, \"3x3 storyboard grid action sequence, character consistency, continuous movement progression\")\n\t\treturn &MultiFramePrompt{\n\t\t\tLayout: \"grid_3x3\",\n\t\t\tFrames: []SingleFramePrompt{\n\t\t\t\t{\n\t\t\t\t\tPrompt:      fallbackPrompt,\n\t\t\t\t\tDescription: \"3x3宫格动作序列，展示连贯的动作演进\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t}\n\n\t// 解析AI返回的JSON\n\tresult := s.parseFramePromptJSON(aiResponse)\n\tif result == nil {\n\t\t// JSON解析失败，使用降级方案\n\t\ts.log.Warnw(\"Failed to parse AI JSON response for action sequence, using fallback\", \"storyboard_id\", sb.ID, \"response\", aiResponse)\n\t\tfallbackPrompt := s.buildFallbackPrompt(sb, scene, \"3x3 storyboard grid action sequence, character consistency, continuous movement progression\")\n\t\treturn &MultiFramePrompt{\n\t\t\tLayout: \"grid_3x3\",\n\t\t\tFrames: []SingleFramePrompt{\n\t\t\t\t{\n\t\t\t\t\tPrompt:      fallbackPrompt,\n\t\t\t\t\tDescription: \"3x3宫格动作序列，展示连贯的动作演进\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t}\n\n\t// 动作序列是一个整体的3x3宫格图片，所以只返回一个prompt\n\treturn &MultiFramePrompt{\n\t\tLayout: \"grid_3x3\",\n\t\tFrames: []SingleFramePrompt{*result},\n\t}\n}\n\n// buildStoryboardContext 构建镜头上下文信息\nfunc (s *FramePromptService) buildStoryboardContext(sb models.Storyboard, scene *models.Scene) string {\n\tvar parts []string\n\n\t// 镜头描述（最重要）\n\tif sb.Description != nil && *sb.Description != \"\" {\n\t\tparts = append(parts, s.promptI18n.FormatUserPrompt(\"shot_description_label\", *sb.Description))\n\t}\n\n\t// 场景信息\n\tif scene != nil {\n\t\tparts = append(parts, s.promptI18n.FormatUserPrompt(\"scene_label\", scene.Location, scene.Time))\n\t} else if sb.Location != nil && sb.Time != nil {\n\t\tparts = append(parts, s.promptI18n.FormatUserPrompt(\"scene_label\", *sb.Location, *sb.Time))\n\t}\n\n\t// 角色\n\tif len(sb.Characters) > 0 {\n\t\tvar charNames []string\n\t\tfor _, char := range sb.Characters {\n\t\t\tcharNames = append(charNames, char.Name)\n\t\t}\n\t\tparts = append(parts, s.promptI18n.FormatUserPrompt(\"characters_label\", strings.Join(charNames, \", \")))\n\t}\n\n\t// 动作\n\tif sb.Action != nil && *sb.Action != \"\" {\n\t\tparts = append(parts, s.promptI18n.FormatUserPrompt(\"action_label\", *sb.Action))\n\t}\n\n\t// 结果\n\tif sb.Result != nil && *sb.Result != \"\" {\n\t\tparts = append(parts, s.promptI18n.FormatUserPrompt(\"result_label\", *sb.Result))\n\t}\n\n\t// 对白\n\tif sb.Dialogue != nil && *sb.Dialogue != \"\" {\n\t\tparts = append(parts, s.promptI18n.FormatUserPrompt(\"dialogue_label\", *sb.Dialogue))\n\t}\n\n\t// 氛围\n\tif sb.Atmosphere != nil && *sb.Atmosphere != \"\" {\n\t\tparts = append(parts, s.promptI18n.FormatUserPrompt(\"atmosphere_label\", *sb.Atmosphere))\n\t}\n\n\t// 镜头参数\n\tif sb.ShotType != nil {\n\t\tparts = append(parts, s.promptI18n.FormatUserPrompt(\"shot_type_label\", *sb.ShotType))\n\t}\n\tif sb.Angle != nil {\n\t\tparts = append(parts, s.promptI18n.FormatUserPrompt(\"angle_label\", *sb.Angle))\n\t}\n\tif sb.Movement != nil {\n\t\tparts = append(parts, s.promptI18n.FormatUserPrompt(\"movement_label\", *sb.Movement))\n\t}\n\n\treturn strings.Join(parts, \"\\n\")\n}\n\n// buildFallbackPrompt 构建降级提示词（AI失败时使用）\nfunc (s *FramePromptService) buildFallbackPrompt(sb models.Storyboard, scene *models.Scene, suffix string) string {\n\tvar parts []string\n\n\t// 场景\n\tif scene != nil {\n\t\tparts = append(parts, fmt.Sprintf(\"%s, %s\", scene.Location, scene.Time))\n\t}\n\n\t// 角色\n\tif len(sb.Characters) > 0 {\n\t\tfor _, char := range sb.Characters {\n\t\t\tparts = append(parts, char.Name)\n\t\t}\n\t}\n\n\t// 氛围\n\tif sb.Atmosphere != nil {\n\t\tparts = append(parts, *sb.Atmosphere)\n\t}\n\n\tparts = append(parts, \"anime style\", suffix)\n\treturn strings.Join(parts, \", \")\n}\n"
  },
  {
    "path": "application/services/image_generation_service.go",
    "content": "package services\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\tmodels \"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/infrastructure/storage\"\n\t\"github.com/drama-generator/backend/pkg/ai\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/image\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/utils\"\n\t\"gorm.io/gorm\"\n)\n\ntype ImageGenerationService struct {\n\tdb              *gorm.DB\n\taiService       *AIService\n\ttransferService *ResourceTransferService\n\tlocalStorage    *storage.LocalStorage\n\tlog             *logger.Logger\n\tconfig          *config.Config\n\tpromptI18n      *PromptI18n\n\ttaskService     *TaskService\n}\n\n// truncateImageURL 截断图片 URL，避免 base64 格式的 URL 占满日志\nfunc truncateImageURL(url string) string {\n\tif url == \"\" {\n\t\treturn \"\"\n\t}\n\t// 如果是 data URI 格式（base64），只显示前缀\n\tif strings.HasPrefix(url, \"data:\") {\n\t\tif len(url) > 50 {\n\t\t\treturn url[:50] + \"...[base64 data]\"\n\t\t}\n\t}\n\t// 普通 URL 如果过长也截断\n\tif len(url) > 100 {\n\t\treturn url[:100] + \"...\"\n\t}\n\treturn url\n}\n\nfunc NewImageGenerationService(db *gorm.DB, cfg *config.Config, transferService *ResourceTransferService, localStorage *storage.LocalStorage, log *logger.Logger) *ImageGenerationService {\n\treturn &ImageGenerationService{\n\t\tdb:              db,\n\t\taiService:       NewAIService(db, log),\n\t\ttransferService: transferService,\n\t\tlocalStorage:    localStorage,\n\t\tconfig:          cfg,\n\t\tpromptI18n:      NewPromptI18n(cfg),\n\t\tlog:             log,\n\t\ttaskService:     NewTaskService(db, log),\n\t}\n}\n\n// GetDB 获取数据库连接\nfunc (s *ImageGenerationService) GetDB() *gorm.DB {\n\treturn s.db\n}\n\ntype GenerateImageRequest struct {\n\tStoryboardID    *uint    `json:\"storyboard_id\"`\n\tDramaID         string   `json:\"drama_id\" binding:\"required\"`\n\tSceneID         *uint    `json:\"scene_id\"`\n\tCharacterID     *uint    `json:\"character_id\"`\n\tPropID          *uint    `json:\"prop_id\"`\n\tImageType       string   `json:\"image_type\"` // character, scene, storyboard\n\tFrameType       *string  `json:\"frame_type\"` // first, key, last, panel, action\n\tPrompt          string   `json:\"prompt\" binding:\"required,min=5,max=2000\"`\n\tNegativePrompt  *string  `json:\"negative_prompt\"`\n\tProvider        string   `json:\"provider\"`\n\tModel           string   `json:\"model\"`\n\tSize            string   `json:\"size\"`\n\tQuality         string   `json:\"quality\"`\n\tStyle           *string  `json:\"style\"`\n\tSteps           *int     `json:\"steps\"`\n\tCfgScale        *float64 `json:\"cfg_scale\"`\n\tSeed            *int64   `json:\"seed\"`\n\tWidth           *int     `json:\"width\"`\n\tHeight          *int     `json:\"height\"`\n\tImageLocalPath  *string  `json:\"image_local_path\"` // 本地图片路径，用于图生图\n\tReferenceImages []string `json:\"reference_images\"` // 参考图片URL列表\n}\n\nfunc (s *ImageGenerationService) GenerateImage(request *GenerateImageRequest) (*models.ImageGeneration, error) {\n\tvar drama models.Drama\n\tif err := s.db.Where(\"id = ? \", request.DramaID).First(&drama).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"drama not found\")\n\t}\n\t// 注意：SceneID可能指向Scene或Storyboard表，调用方已经做过权限验证，这里不再重复验证\n\n\tprovider := request.Provider\n\tif provider == \"\" {\n\t\tprovider = \"openai\"\n\t}\n\n\t// 序列化参考图片\n\tvar referenceImagesJSON []byte\n\tif len(request.ReferenceImages) > 0 {\n\t\treferenceImagesJSON, _ = json.Marshal(request.ReferenceImages)\n\t}\n\n\t// 转换DramaID\n\tdramaIDParsed, err := strconv.ParseUint(request.DramaID, 10, 32)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid drama ID\")\n\t}\n\n\t// 设置默认图片类型\n\timageType := request.ImageType\n\tif imageType == \"\" {\n\t\timageType = string(models.ImageTypeStoryboard)\n\t}\n\n\timageGen := &models.ImageGeneration{\n\t\tStoryboardID:    request.StoryboardID,\n\t\tDramaID:         uint(dramaIDParsed),\n\t\tSceneID:         request.SceneID,\n\t\tCharacterID:     request.CharacterID,\n\t\tPropID:          request.PropID,\n\t\tImageType:       imageType,\n\t\tFrameType:       request.FrameType,\n\t\tProvider:        provider,\n\t\tPrompt:          request.Prompt,\n\t\tNegPrompt:       request.NegativePrompt,\n\t\tModel:           request.Model,\n\t\tSize:            request.Size,\n\t\tReferenceImages: referenceImagesJSON,\n\t\tQuality:         request.Quality,\n\t\tStyle:           request.Style,\n\t\tSteps:           request.Steps,\n\t\tCfgScale:        request.CfgScale,\n\t\tSeed:            request.Seed,\n\t\tWidth:           request.Width,\n\t\tHeight:          request.Height,\n\t\tLocalPath:       request.ImageLocalPath,\n\t\tStatus:          models.ImageStatusPending,\n\t}\n\n\tif err := s.db.Create(imageGen).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create record: %w\", err)\n\t}\n\n\tgo s.ProcessImageGeneration(imageGen.ID)\n\n\treturn imageGen, nil\n}\n\nfunc (s *ImageGenerationService) ProcessImageGeneration(imageGenID uint) {\n\tvar imageGen models.ImageGeneration\n\timageRatio := \"16:9\"\n\tif err := s.db.First(&imageGen, imageGenID).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to load image generation\", \"error\", err, \"id\", imageGenID)\n\t\treturn\n\t}\n\n\t// 获取drama的style信息\n\tvar drama models.Drama\n\tif err := s.db.First(&drama, imageGen.DramaID).Error; err != nil {\n\t\ts.log.Warnw(\"Failed to load drama for style\", \"error\", err, \"drama_id\", imageGen.DramaID)\n\t}\n\n\ts.db.Model(&imageGen).Update(\"status\", models.ImageStatusProcessing)\n\n\t// 如果关联了background，同步更新background为generating状态\n\tif imageGen.StoryboardID != nil {\n\t\tif err := s.db.Model(&models.Scene{}).Where(\"id = ?\", *imageGen.StoryboardID).Update(\"status\", \"generating\").Error; err != nil {\n\t\t\ts.log.Warnw(\"Failed to update background status to generating\", \"scene_id\", *imageGen.StoryboardID, \"error\", err)\n\t\t} else {\n\t\t\ts.log.Infow(\"Background status updated to generating\", \"scene_id\", *imageGen.StoryboardID)\n\t\t}\n\t}\n\n\tclient, err := s.getImageClientWithModel(imageGen.Provider, imageGen.Model)\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to get image client\", \"error\", err, \"provider\", imageGen.Provider, \"model\", imageGen.Model)\n\t\ts.updateImageGenError(imageGenID, err.Error())\n\t\treturn\n\t}\n\n\t// 解析参考图片\n\tvar referenceImagePaths []string\n\tif len(imageGen.ReferenceImages) > 0 {\n\t\tif err := json.Unmarshal(imageGen.ReferenceImages, &referenceImagePaths); err == nil {\n\t\t\ts.log.Infow(\"Using reference images for generation\",\n\t\t\t\t\"id\", imageGenID,\n\t\t\t\t\"reference_count\", len(referenceImagePaths),\n\t\t\t\t\"references\", referenceImagePaths)\n\t\t}\n\t}\n\n\t// 如果有 local_path，添加到参考图片列表的开头\n\tif imageGen.LocalPath != nil && *imageGen.LocalPath != \"\" {\n\t\treferenceImagePaths = append([]string{*imageGen.LocalPath}, referenceImagePaths...)\n\t}\n\n\t// 将所有参考图片路径转换为 base64（如果是本地路径）或保持原样（如果是 URL）\n\tvar referenceImages []string\n\tfor _, imgPath := range referenceImagePaths {\n\t\t// 判断是否为 HTTP/HTTPS URL\n\t\tif strings.HasPrefix(imgPath, \"http://\") || strings.HasPrefix(imgPath, \"https://\") {\n\t\t\t// 保持 URL 原样\n\t\t\treferenceImages = append(referenceImages, imgPath)\n\t\t} else {\n\t\t\t// 视为本地路径，转换为 base64\n\t\t\tbase64Image, err := s.loadImageAsBase64(imgPath)\n\t\t\tif err != nil {\n\t\t\t\ts.log.Warnw(\"Failed to load local image as base64\",\n\t\t\t\t\t\"error\", err,\n\t\t\t\t\t\"id\", imageGenID,\n\t\t\t\t\t\"local_path\", imgPath)\n\t\t\t} else {\n\t\t\t\treferenceImages = append(referenceImages, base64Image)\n\t\t\t\ts.log.Infow(\"Loaded local image for generation\",\n\t\t\t\t\t\"id\", imageGenID,\n\t\t\t\t\t\"local_path\", imgPath)\n\t\t\t}\n\t\t}\n\t}\n\n\ts.log.Infow(\"Starting image generation\", \"id\", imageGenID, \"prompt\", imageGen.Prompt, \"provider\", imageGen.Provider)\n\n\tvar opts []image.ImageOption\n\tif imageGen.NegPrompt != nil && *imageGen.NegPrompt != \"\" {\n\t\topts = append(opts, image.WithNegativePrompt(*imageGen.NegPrompt))\n\t}\n\tif imageGen.Size != \"\" {\n\t\topts = append(opts, image.WithSize(imageGen.Size))\n\t}\n\tif imageGen.Quality != \"\" {\n\t\topts = append(opts, image.WithQuality(imageGen.Quality))\n\t}\n\tif imageGen.Style != nil && *imageGen.Style != \"\" {\n\t\topts = append(opts, image.WithStyle(*imageGen.Style))\n\t}\n\tif imageGen.Steps != nil {\n\t\topts = append(opts, image.WithSteps(*imageGen.Steps))\n\t}\n\tif imageGen.CfgScale != nil {\n\t\topts = append(opts, image.WithCfgScale(*imageGen.CfgScale))\n\t}\n\tif imageGen.Seed != nil {\n\t\topts = append(opts, image.WithSeed(*imageGen.Seed))\n\t}\n\tif imageGen.Model != \"\" {\n\t\topts = append(opts, image.WithModel(imageGen.Model))\n\t}\n\tif imageGen.Width != nil && imageGen.Height != nil {\n\t\topts = append(opts, image.WithDimensions(*imageGen.Width, *imageGen.Height))\n\t}\n\t// 添加参考图片\n\tif len(referenceImages) > 0 {\n\t\topts = append(opts, image.WithReferenceImages(referenceImages))\n\t}\n\n\t// 构建完整的提示词：风格提示词 + 用户提示词\n\tprompt := imageGen.Prompt\n\n\t// 如果drama有风格设置，添加风格提示词\n\tif drama.Style != \"\" && drama.Style != \"realistic\" {\n\t\tstylePrompt := s.promptI18n.GetStylePrompt(drama.Style)\n\t\tif stylePrompt != \"\" {\n\t\t\t// 将风格提示词作为系统级约束添加到提示词前面\n\t\t\tprompt = stylePrompt + \"\\n\\n\" + prompt\n\t\t\ts.log.Infow(\"Added style prompt to image generation\",\n\t\t\t\t\"id\", imageGenID,\n\t\t\t\t\"style\", drama.Style,\n\t\t\t\t\"style_prompt_length\", len(stylePrompt))\n\t\t}\n\t}\n\n\tprompt += \", imageRatio:\" + imageRatio\n\n\t// 如果有参考图，在提示词末尾添加参考图一致性说明\n\tif len(referenceImages) > 0 {\n\t\tprompt += \"\\n\\n**重要：**\\n**必须严格**遵守参考图内的内容元素，保持场景和角色的**一致性**\"\n\t\ts.log.Infow(\"Added reference image consistency instruction to prompt\",\n\t\t\t\"id\", imageGenID,\n\t\t\t\"reference_count\", len(referenceImages))\n\t}\n\tresult, err := client.GenerateImage(prompt, opts...)\n\tif err != nil {\n\t\ts.log.Errorw(\"Image generation API call failed\", \"error\", err, \"id\", imageGenID, \"prompt\", imageGen.Prompt)\n\t\ts.updateImageGenError(imageGenID, err.Error())\n\t\treturn\n\t}\n\n\ts.log.Infow(\"Image generation API call completed\", \"id\", imageGenID, \"completed\", result.Completed, \"has_url\", result.ImageURL != \"\")\n\n\tif !result.Completed {\n\t\ts.db.Model(&imageGen).Updates(map[string]interface{}{\n\t\t\t\"status\":  models.ImageStatusProcessing,\n\t\t\t\"task_id\": result.TaskID,\n\t\t})\n\t\tgo s.pollTaskStatus(imageGenID, client, result.TaskID)\n\t\treturn\n\t}\n\n\ts.completeImageGeneration(imageGenID, result)\n}\n\nfunc (s *ImageGenerationService) pollTaskStatus(imageGenID uint, client image.ImageClient, taskID string) {\n\tmaxAttempts := 60\n\tpollInterval := 5 * time.Second\n\n\tfor i := 0; i < maxAttempts; i++ {\n\t\ttime.Sleep(pollInterval)\n\n\t\tresult, err := client.GetTaskStatus(taskID)\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"Failed to get task status\", \"error\", err, \"task_id\", taskID)\n\t\t\tcontinue\n\t\t}\n\n\t\tif result.Completed {\n\t\t\ts.completeImageGeneration(imageGenID, result)\n\t\t\treturn\n\t\t}\n\n\t\tif result.Error != \"\" {\n\t\t\ts.updateImageGenError(imageGenID, result.Error)\n\t\t\treturn\n\t\t}\n\t}\n\n\ts.updateImageGenError(imageGenID, \"timeout: image generation took too long\")\n}\n\nfunc (s *ImageGenerationService) completeImageGeneration(imageGenID uint, result *image.ImageResult) {\n\tnow := time.Now()\n\n\t// 下载图片到本地存储并保存相对路径到数据库\n\tvar localPath *string\n\tif s.localStorage != nil && result.ImageURL != \"\" &&\n\t\t(strings.HasPrefix(result.ImageURL, \"http://\") || strings.HasPrefix(result.ImageURL, \"https://\")) {\n\t\tdownloadResult, err := s.localStorage.DownloadFromURLWithPath(result.ImageURL, \"images\")\n\t\tif err != nil {\n\t\t\terrStr := err.Error()\n\t\t\tif len(errStr) > 200 {\n\t\t\t\terrStr = errStr[:200] + \"...\"\n\t\t\t}\n\t\t\ts.log.Warnw(\"Failed to download image to local storage\",\n\t\t\t\t\"error\", errStr,\n\t\t\t\t\"id\", imageGenID,\n\t\t\t\t\"original_url\", truncateImageURL(result.ImageURL))\n\t\t} else {\n\t\t\tlocalPath = &downloadResult.RelativePath\n\t\t\ts.log.Infow(\"Image downloaded to local storage\",\n\t\t\t\t\"id\", imageGenID,\n\t\t\t\t\"original_url\", truncateImageURL(result.ImageURL),\n\t\t\t\t\"local_path\", downloadResult.RelativePath)\n\t\t}\n\t}\n\n\t// 数据库中保存原始URL和本地路径\n\tupdates := map[string]interface{}{\n\t\t\"status\":       models.ImageStatusCompleted,\n\t\t\"image_url\":    result.ImageURL,\n\t\t\"local_path\":   localPath,\n\t\t\"completed_at\": now,\n\t}\n\n\tif result.Width > 0 {\n\t\tupdates[\"width\"] = result.Width\n\t}\n\tif result.Height > 0 {\n\t\tupdates[\"height\"] = result.Height\n\t}\n\n\t// 更新image_generation记录\n\tvar imageGen models.ImageGeneration\n\tif err := s.db.Where(\"id = ?\", imageGenID).First(&imageGen).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to load image generation\", \"error\", err, \"id\", imageGenID)\n\t\treturn\n\t}\n\n\t// 使用 Updates 更新基本字段\n\tif err := s.db.Model(&models.ImageGeneration{}).Where(\"id = ?\", imageGenID).Updates(updates).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to update image generation\", \"error\", err, \"id\", imageGenID)\n\t\treturn\n\t}\n\n\t// 单独更新 local_path 字段（即使为 nil 也要更新）\n\tif err := s.db.Model(&models.ImageGeneration{}).Where(\"id = ?\", imageGenID).Update(\"local_path\", localPath).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to update local_path\", \"error\", err, \"id\", imageGenID)\n\t}\n\n\ts.log.Infow(\"Image generation completed\", \"id\", imageGenID)\n\n\t// 如果关联了storyboard，同步更新storyboard的composed_image\n\tif imageGen.StoryboardID != nil {\n\t\tif err := s.db.Model(&models.Storyboard{}).Where(\"id = ?\", *imageGen.StoryboardID).Update(\"composed_image\", result.ImageURL).Error; err != nil {\n\t\t\ts.log.Errorw(\"Failed to update storyboard composed_image\", \"error\", err, \"storyboard_id\", *imageGen.StoryboardID)\n\t\t} else {\n\t\t\ts.log.Infow(\"Storyboard updated with composed image\",\n\t\t\t\t\"storyboard_id\", *imageGen.StoryboardID,\n\t\t\t\t\"composed_image\", truncateImageURL(result.ImageURL))\n\t\t}\n\t}\n\n\t// 如果关联了scene，同步更新scene的image_url、local_path和status（仅当ImageType是scene时）\n\tif imageGen.SceneID != nil && imageGen.ImageType == string(models.ImageTypeScene) {\n\t\tsceneUpdates := map[string]interface{}{\n\t\t\t\"status\":    \"generated\",\n\t\t\t\"image_url\": result.ImageURL,\n\t\t}\n\t\tif localPath != nil {\n\t\t\tsceneUpdates[\"local_path\"] = localPath\n\t\t}\n\t\tif err := s.db.Model(&models.Scene{}).Where(\"id = ?\", *imageGen.SceneID).Updates(sceneUpdates).Error; err != nil {\n\t\t\ts.log.Errorw(\"Failed to update scene\", \"error\", err, \"scene_id\", *imageGen.SceneID)\n\t\t} else {\n\t\t\ts.log.Infow(\"Scene updated with generated image\",\n\t\t\t\t\"scene_id\", *imageGen.SceneID,\n\t\t\t\t\"image_url\", truncateImageURL(result.ImageURL),\n\t\t\t\t\"local_path\", localPath)\n\t\t}\n\t}\n\n\t// 如果关联了角色，同步更新角色的image_url和local_path\n\tif imageGen.CharacterID != nil {\n\t\tcharacterUpdates := map[string]interface{}{\n\t\t\t\"image_url\": result.ImageURL,\n\t\t}\n\t\tif localPath != nil {\n\t\t\tcharacterUpdates[\"local_path\"] = localPath\n\t\t}\n\t\tif err := s.db.Model(&models.Character{}).Where(\"id = ?\", *imageGen.CharacterID).Updates(characterUpdates).Error; err != nil {\n\t\t\ts.log.Errorw(\"Failed to update character\", \"error\", err, \"character_id\", *imageGen.CharacterID)\n\t\t} else {\n\t\t\ts.log.Infow(\"Character updated with generated image\",\n\t\t\t\t\"character_id\", *imageGen.CharacterID,\n\t\t\t\t\"image_url\", truncateImageURL(result.ImageURL),\n\t\t\t\t\"local_path\", localPath)\n\t\t}\n\t}\n\n\t// 如果关联了道具，同步更新道具的image_url和local_path\n\tif imageGen.PropID != nil {\n\t\tpropUpdates := map[string]interface{}{\n\t\t\t\"image_url\": result.ImageURL,\n\t\t}\n\t\tif localPath != nil {\n\t\t\tpropUpdates[\"local_path\"] = localPath\n\t\t}\n\t\tif err := s.db.Model(&models.Prop{}).Where(\"id = ?\", *imageGen.PropID).Updates(propUpdates).Error; err != nil {\n\t\t\ts.log.Errorw(\"Failed to update prop\", \"error\", err, \"prop_id\", *imageGen.PropID)\n\t\t} else {\n\t\t\ts.log.Infow(\"Prop updated with generated image\",\n\t\t\t\t\"prop_id\", *imageGen.PropID,\n\t\t\t\t\"image_url\", truncateImageURL(result.ImageURL),\n\t\t\t\t\"local_path\", localPath)\n\t\t}\n\t}\n}\n\nfunc (s *ImageGenerationService) updateImageGenError(imageGenID uint, errorMsg string) {\n\t// 先获取image_generation记录\n\tvar imageGen models.ImageGeneration\n\tif err := s.db.Where(\"id = ?\", imageGenID).First(&imageGen).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to load image generation\", \"error\", err, \"id\", imageGenID)\n\t\treturn\n\t}\n\n\t// 更新image_generation状态\n\ts.db.Model(&models.ImageGeneration{}).Where(\"id = ?\", imageGenID).Updates(map[string]interface{}{\n\t\t\"status\":    models.ImageStatusFailed,\n\t\t\"error_msg\": errorMsg,\n\t})\n\ts.log.Errorw(\"Image generation failed\", \"id\", imageGenID, \"error\", errorMsg)\n\n\t// 如果关联了scene，同步更新scene为失败状态\n\tif imageGen.SceneID != nil {\n\t\ts.db.Model(&models.Scene{}).Where(\"id = ?\", *imageGen.SceneID).Update(\"status\", \"failed\")\n\t\ts.log.Warnw(\"Scene marked as failed\", \"scene_id\", *imageGen.SceneID)\n\t}\n}\n\nfunc (s *ImageGenerationService) getImageClient(provider string) (image.ImageClient, error) {\n\tconfig, err := s.aiService.GetDefaultConfig(\"image\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"no image AI config found: %w\", err)\n\t}\n\n\t// 使用第一个模型\n\tmodel := \"\"\n\tif len(config.Model) > 0 {\n\t\tmodel = config.Model[0]\n\t}\n\n\t// 使用配置中的 provider，如果没有则使用传入的 provider\n\tactualProvider := config.Provider\n\tif actualProvider == \"\" {\n\t\tactualProvider = provider\n\t}\n\n\t// 根据 provider 自动设置默认端点\n\tvar endpoint string\n\tvar queryEndpoint string\n\n\tswitch actualProvider {\n\tcase \"openai\", \"dalle\":\n\t\tendpoint = \"/images/generations\"\n\t\treturn image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil\n\tcase \"chatfire\":\n\t\tendpoint = \"/images/generations\"\n\t\treturn image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil\n\tcase \"volcengine\", \"volces\", \"doubao\":\n\t\tendpoint = \"/images/generations\"\n\t\tqueryEndpoint = \"\"\n\t\treturn image.NewVolcEngineImageClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil\n\tcase \"gemini\", \"google\":\n\t\tendpoint = \"/v1beta/models/{model}:generateContent\"\n\t\treturn image.NewGeminiImageClient(config.BaseURL, config.APIKey, model, endpoint), nil\n\tdefault:\n\t\tendpoint = \"/images/generations\"\n\t\treturn image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil\n\t}\n}\n\n// getImageClientWithModel 根据模型名称获取图片客户端\nfunc (s *ImageGenerationService) getImageClientWithModel(provider string, modelName string) (image.ImageClient, error) {\n\tvar config *models.AIServiceConfig\n\tvar err error\n\n\t// 如果指定了模型，尝试获取对应的配置\n\tif modelName != \"\" {\n\t\tconfig, err = s.aiService.GetConfigForModel(\"image\", modelName)\n\t\tif err != nil {\n\t\t\ts.log.Warnw(\"Failed to get config for model, using default\", \"model\", modelName, \"error\", err)\n\t\t\tconfig, err = s.aiService.GetDefaultConfig(\"image\")\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"no image AI config found: %w\", err)\n\t\t\t}\n\t\t}\n\t} else {\n\t\tconfig, err = s.aiService.GetDefaultConfig(\"image\")\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"no image AI config found: %w\", err)\n\t\t}\n\t}\n\n\t// 使用指定的模型或配置中的第一个模型\n\tmodel := modelName\n\tif model == \"\" && len(config.Model) > 0 {\n\t\tmodel = config.Model[0]\n\t}\n\n\t// 使用配置中的 provider，如果没有则使用传入的 provider\n\tactualProvider := config.Provider\n\tif actualProvider == \"\" {\n\t\tactualProvider = provider\n\t}\n\n\t// 根据 provider 自动设置默认端点\n\tvar endpoint string\n\tvar queryEndpoint string\n\n\tswitch actualProvider {\n\tcase \"openai\", \"dalle\":\n\t\tendpoint = \"/images/generations\"\n\t\treturn image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil\n\tcase \"chatfire\":\n\t\tendpoint = \"/images/generations\"\n\t\treturn image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil\n\tcase \"volcengine\", \"volces\", \"doubao\":\n\t\tendpoint = \"/images/generations\"\n\t\tqueryEndpoint = \"\"\n\t\treturn image.NewVolcEngineImageClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil\n\tcase \"gemini\", \"google\":\n\t\tendpoint = \"/v1beta/models/{model}:generateContent\"\n\t\treturn image.NewGeminiImageClient(config.BaseURL, config.APIKey, model, endpoint), nil\n\tdefault:\n\t\tendpoint = \"/images/generations\"\n\t\treturn image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil\n\t}\n}\n\nfunc (s *ImageGenerationService) GetImageGeneration(imageGenID uint) (*models.ImageGeneration, error) {\n\tvar imageGen models.ImageGeneration\n\tif err := s.db.Where(\"id = ? \", imageGenID).First(&imageGen).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn &imageGen, nil\n}\n\nfunc (s *ImageGenerationService) ListImageGenerations(dramaID *uint, sceneID *uint, storyboardID *uint, frameType string, status string, page, pageSize int) ([]models.ImageGeneration, int64, error) {\n\tquery := s.db.Model(&models.ImageGeneration{})\n\n\tif dramaID != nil {\n\t\tquery = query.Where(\"drama_id = ?\", *dramaID)\n\t}\n\n\tif sceneID != nil {\n\t\tquery = query.Where(\"scene_id = ?\", *sceneID)\n\t}\n\n\tif storyboardID != nil {\n\t\tquery = query.Where(\"storyboard_id = ?\", *storyboardID)\n\t}\n\n\tif frameType != \"\" {\n\t\tquery = query.Where(\"frame_type = ?\", frameType)\n\t}\n\n\tif status != \"\" {\n\t\tquery = query.Where(\"status = ?\", status)\n\t}\n\n\tvar total int64\n\tif err := query.Count(&total).Error; err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tvar images []models.ImageGeneration\n\toffset := (page - 1) * pageSize\n\tif err := query.Order(\"created_at DESC\").Offset(offset).Limit(pageSize).Find(&images).Error; err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\treturn images, total, nil\n}\n\nfunc (s *ImageGenerationService) DeleteImageGeneration(imageGenID uint) error {\n\tresult := s.db.Where(\"id = ? \", imageGenID).Delete(&models.ImageGeneration{})\n\tif result.Error != nil {\n\t\treturn result.Error\n\t}\n\tif result.RowsAffected == 0 {\n\t\treturn fmt.Errorf(\"image generation not found\")\n\t}\n\treturn nil\n}\n\n// UploadImageRequest 上传图片请求\ntype UploadImageRequest struct {\n\tStoryboardID uint   `json:\"storyboard_id\"`\n\tDramaID      uint   `json:\"drama_id\"`\n\tFrameType    string `json:\"frame_type\"`\n\tImageURL     string `json:\"image_url\"`\n\tPrompt       string `json:\"prompt\"`\n}\n\n// CreateImageFromUpload 从上传的图片URL创建图片生成记录\nfunc (s *ImageGenerationService) CreateImageFromUpload(req *UploadImageRequest) (*models.ImageGeneration, error) {\n\t// 验证storyboard存在\n\tvar storyboard models.Storyboard\n\tif err := s.db.First(&storyboard, req.StoryboardID).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"storyboard not found\")\n\t}\n\n\t// 验证drama存在\n\tvar drama models.Drama\n\tif err := s.db.First(&drama, req.DramaID).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"drama not found\")\n\t}\n\n\tprompt := req.Prompt\n\tif prompt == \"\" {\n\t\tprompt = \"用户上传图片\"\n\t}\n\n\tnow := time.Now()\n\timageGen := &models.ImageGeneration{\n\t\tStoryboardID: &req.StoryboardID,\n\t\tDramaID:      req.DramaID,\n\t\tImageType:    string(models.ImageTypeStoryboard),\n\t\tFrameType:    &req.FrameType,\n\t\tProvider:     \"upload\",\n\t\tPrompt:       prompt,\n\t\tModel:        \"upload\",\n\t\tImageURL:     &req.ImageURL,\n\t\tStatus:       models.ImageStatusCompleted,\n\t\tCompletedAt:  &now,\n\t}\n\n\tif err := s.db.Create(imageGen).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create image record: %w\", err)\n\t}\n\n\ts.log.Infow(\"Image created from upload\",\n\t\t\"id\", imageGen.ID,\n\t\t\"storyboard_id\", req.StoryboardID,\n\t\t\"frame_type\", req.FrameType)\n\n\treturn imageGen, nil\n}\n\nfunc (s *ImageGenerationService) GenerateImagesForScene(sceneID string) ([]*models.ImageGeneration, error) {\n\t// 转换sceneID\n\tsid, err := strconv.ParseUint(sceneID, 10, 32)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid scene ID\")\n\t}\n\tsceneIDUint := uint(sid)\n\n\tvar scene models.Scene\n\tif err := s.db.Where(\"id = ?\", sceneIDUint).First(&scene).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"scene not found\")\n\t}\n\n\t// 构建场景图片生成提示词\n\tprompt := scene.Prompt\n\tif prompt == \"\" {\n\t\t// 如果Prompt为空，使用Location和Time构建\n\t\tprompt = fmt.Sprintf(\"%s场景，%s\", scene.Location, scene.Time)\n\t}\n\n\treq := &GenerateImageRequest{\n\t\tSceneID:   &sceneIDUint,\n\t\tDramaID:   fmt.Sprintf(\"%d\", scene.DramaID),\n\t\tImageType: string(models.ImageTypeScene),\n\t\tPrompt:    prompt,\n\t}\n\n\timageGen, err := s.GenerateImage(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn []*models.ImageGeneration{imageGen}, nil\n}\n\n// BackgroundInfo 背景信息结构\ntype BackgroundInfo struct {\n\tLocation          string `json:\"location\"`\n\tTime              string `json:\"time\"`\n\tAtmosphere        string `json:\"atmosphere\"`\n\tPrompt            string `json:\"prompt\"`\n\tStoryboardNumbers []int  `json:\"storyboard_numbers\"`\n\tSceneIDs          []uint `json:\"scene_ids\"`\n\tStoryboardCount   int    `json:\"scene_count\"`\n}\n\nfunc (s *ImageGenerationService) BatchGenerateImagesForEpisode(episodeID string) ([]*models.ImageGeneration, error) {\n\tvar ep models.Episode\n\tif err := s.db.Preload(\"Drama\").Where(\"id = ?\", episodeID).First(&ep).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"episode not found\")\n\t}\n\t// 从数据库读取已保存的场景\n\tvar scenes []models.Storyboard\n\tif err := s.db.Where(\"episode_id = ?\", episodeID).Find(&scenes).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get scenes: %w\", err)\n\t}\n\n\tbackgrounds := s.extractUniqueBackgrounds(scenes)\n\ts.log.Infow(\"Extracted unique backgrounds\",\n\t\t\"episode_id\", episodeID,\n\t\t\"background_count\", len(backgrounds))\n\n\t// 为每个背景生成图片\n\tvar results []*models.ImageGeneration\n\tfor _, bg := range scenes {\n\t\tif bg.ImagePrompt == nil || *bg.ImagePrompt == \"\" {\n\t\t\ts.log.Warnw(\"Background has no prompt, skipping\", \"scene_id\", bg.ID)\n\t\t\tcontinue\n\t\t}\n\n\t\t// 更新背景状态为处理中\n\t\ts.db.Model(bg).Update(\"status\", \"generating\")\n\n\t\treq := &GenerateImageRequest{\n\t\t\tStoryboardID: &bg.ID,\n\t\t\tDramaID:      fmt.Sprintf(\"%d\", ep.DramaID),\n\t\t\tPrompt:       *bg.ImagePrompt,\n\t\t}\n\n\t\timageGen, err := s.GenerateImage(req)\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"Failed to generate image for background\",\n\t\t\t\t\"scene_id\", bg.ID,\n\t\t\t\t\"location\", bg.Location,\n\t\t\t\t\"error\", err)\n\t\t\ts.db.Model(bg).Update(\"status\", \"failed\")\n\t\t\tcontinue\n\t\t}\n\n\t\ts.log.Infow(\"Background image generation started\",\n\t\t\t\"scene_id\", bg.ID,\n\t\t\t\"image_gen_id\", imageGen.ID,\n\t\t\t\"location\", bg.Location,\n\t\t\t\"time\", bg.Time)\n\n\t\tresults = append(results, imageGen)\n\t}\n\n\treturn results, nil\n}\n\n// GetScencesForEpisode 获取项目的场景列表（项目级）\nfunc (s *ImageGenerationService) GetScencesForEpisode(episodeID string) ([]*models.Scene, error) {\n\tvar episode models.Episode\n\tif err := s.db.Preload(\"Drama\").Where(\"id = ?\", episodeID).First(&episode).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"episode not found\")\n\t}\n\n\t// 场景是项目级的，通过drama_id查询\n\tvar scenes []*models.Scene\n\tif err := s.db.Where(\"drama_id = ?\", episode.DramaID).Order(\"location ASC, time ASC\").Find(&scenes).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to load scenes: %w\", err)\n\t}\n\n\treturn scenes, nil\n}\n\n// ExtractBackgroundsForEpisode 从剧本内容中提取场景并保存到项目级别数据库\nfunc (s *ImageGenerationService) ExtractBackgroundsForEpisode(episodeID string, model string, style string) (string, error) {\n\tvar episode models.Episode\n\tif err := s.db.Preload(\"Storyboards\").First(&episode, episodeID).Error; err != nil {\n\t\treturn \"\", fmt.Errorf(\"episode not found\")\n\t}\n\n\t// 如果没有剧本内容，无法提取场景\n\tif episode.ScriptContent == nil || *episode.ScriptContent == \"\" {\n\t\treturn \"\", fmt.Errorf(\"episode has no script content\")\n\t}\n\n\t// 创建任务\n\ttask, err := s.taskService.CreateTask(\"background_extraction\", episodeID)\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to create background extraction task\", \"error\", err, \"episode_id\", episodeID)\n\t\treturn \"\", fmt.Errorf(\"创建任务失败: %w\", err)\n\t}\n\n\t// 异步处理场景提取\n\tgo s.processBackgroundExtraction(task.ID, episodeID, model, style)\n\n\ts.log.Infow(\"Background extraction task created\", \"task_id\", task.ID, \"episode_id\", episodeID)\n\treturn task.ID, nil\n}\n\n// processBackgroundExtraction 异步处理场景提取\nfunc (s *ImageGenerationService) processBackgroundExtraction(taskID string, episodeID string, model string, style string) {\n\t// 更新任务状态为处理中\n\ts.taskService.UpdateTaskStatus(taskID, \"processing\", 0, \"正在提取场景信息...\")\n\n\tvar episode models.Episode\n\tif err := s.db.Preload(\"Storyboards\").First(&episode, episodeID).Error; err != nil {\n\t\ts.log.Errorw(\"Episode not found during background extraction\", \"error\", err, \"episode_id\", episodeID)\n\t\ts.taskService.UpdateTaskStatus(taskID, \"failed\", 0, \"剧集信息不存在\")\n\t\treturn\n\t}\n\n\tif episode.ScriptContent == nil || *episode.ScriptContent == \"\" {\n\t\ts.log.Errorw(\"Episode has no script content during background extraction\", \"episode_id\", episodeID)\n\t\ts.taskService.UpdateTaskStatus(taskID, \"failed\", 0, \"剧本内容为空\")\n\t\treturn\n\t}\n\n\ts.log.Infow(\"Extracting backgrounds from script\", \"episode_id\", episodeID, \"model\", model, \"task_id\", taskID)\n\tdramaID := episode.DramaID\n\n\t// 使用AI从剧本内容中提取场景\n\tbackgroundsInfo, err := s.extractBackgroundsFromScript(*episode.ScriptContent, dramaID, model, style)\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to extract backgrounds from script\", \"error\", err, \"task_id\", taskID)\n\t\ts.taskService.UpdateTaskStatus(taskID, \"failed\", 0, \"AI提取场景失败: \"+err.Error())\n\t\treturn\n\t}\n\n\t// 保存到数据库（不涉及Storyboard关联，因为此时还没有生成分镜）\n\tvar scenes []*models.Scene\n\terr = s.db.Transaction(func(tx *gorm.DB) error {\n\t\t// 先删除该章节的所有场景（实现重新提取覆盖功能）\n\t\tif err := tx.Where(\"episode_id = ?\", episode.ID).Delete(&models.Scene{}).Error; err != nil {\n\t\t\ts.log.Errorw(\"Failed to delete old scenes\", \"error\", err, \"task_id\", taskID)\n\t\t\treturn err\n\t\t}\n\t\ts.log.Infow(\"Deleted old scenes for re-extraction\", \"episode_id\", episode.ID, \"task_id\", taskID)\n\n\t\t// 创建新提取的场景\n\t\tfor _, bgInfo := range backgroundsInfo {\n\t\t\t// 保存新场景到数据库（章节级）\n\t\t\tepisodeIDVal := episode.ID\n\t\t\tscene := &models.Scene{\n\t\t\t\tDramaID:         dramaID,\n\t\t\t\tEpisodeID:       &episodeIDVal,\n\t\t\t\tLocation:        bgInfo.Location,\n\t\t\t\tTime:            bgInfo.Time,\n\t\t\t\tPrompt:          bgInfo.Prompt,\n\t\t\t\tStoryboardCount: 1, // 默认为1\n\t\t\t\tStatus:          \"pending\",\n\t\t\t}\n\t\t\tif err := tx.Create(scene).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tscenes = append(scenes, scene)\n\n\t\t\ts.log.Infow(\"Created new scene from script\",\n\t\t\t\t\"scene_id\", scene.ID,\n\t\t\t\t\"location\", scene.Location,\n\t\t\t\t\"time\", scene.Time,\n\t\t\t\t\"task_id\", taskID)\n\t\t}\n\n\t\treturn nil\n\t})\n\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to save scenes to database\", \"error\", err, \"task_id\", taskID)\n\t\ts.taskService.UpdateTaskStatus(taskID, \"failed\", 0, \"保存场景信息失败: \"+err.Error())\n\t\treturn\n\t}\n\n\t// 更新任务状态为完成\n\tresultData := map[string]interface{}{\n\t\t\"scenes\":     scenes,\n\t\t\"count\":      len(scenes),\n\t\t\"episode_id\": episodeID,\n\t\t\"drama_id\":   dramaID,\n\t}\n\ts.taskService.UpdateTaskResult(taskID, resultData)\n\n\ts.log.Infow(\"Background extraction completed\",\n\t\t\"task_id\", taskID,\n\t\t\"episode_id\", episodeID,\n\t\t\"total_storyboards\", len(episode.Storyboards),\n\t\t\"unique_scenes\", len(scenes))\n}\n\n// extractBackgroundsFromScript 从剧本内容中使用AI提取场景信息\nfunc (s *ImageGenerationService) extractBackgroundsFromScript(scriptContent string, dramaID uint, model string, style string) ([]BackgroundInfo, error) {\n\tif scriptContent == \"\" {\n\t\treturn []BackgroundInfo{}, nil\n\t}\n\n\t// 获取AI客户端（如果指定了模型则使用指定的模型）\n\tvar client ai.AIClient\n\tvar err error\n\tif model != \"\" {\n\t\ts.log.Infow(\"Using specified model for background extraction\", \"model\", model)\n\t\tclient, err = s.aiService.GetAIClientForModel(\"text\", model)\n\t\tif err != nil {\n\t\t\ts.log.Warnw(\"Failed to get client for specified model, using default\", \"model\", model, \"error\", err)\n\t\t\tclient, err = s.aiService.GetAIClient(\"text\")\n\t\t}\n\t} else {\n\t\tclient, err = s.aiService.GetAIClient(\"text\")\n\t}\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get AI client: %w\", err)\n\t}\n\n\t// 使用国际化提示词\n\tsystemPrompt := s.promptI18n.GetSceneExtractionPrompt(style)\n\tcontentLabel := s.promptI18n.FormatUserPrompt(\"script_content_label\")\n\n\t// 根据语言构建不同的格式说明\n\tvar formatInstructions string\n\tif s.promptI18n.IsEnglish() {\n\t\tformatInstructions = `[Output JSON Format]\n{\n  \"backgrounds\": [\n    {\n      \"location\": \"Location name (English)\",\n      \"time\": \"Time description (English)\",\n      \"atmosphere\": \"Atmosphere description (English)\",\n      \"prompt\": \"A cinematic anime-style pure background scene depicting [location description] at [time]. The scene shows [environment details, architecture, objects, lighting, no characters]. Style: rich details, high quality, atmospheric lighting. Mood: [environment mood description].\"\n    }\n  ]\n}\n\n[Example]\nCorrect example (note: no characters):\n{\n  \"backgrounds\": [\n    {\n      \"location\": \"Repair Shop Interior\",\n      \"time\": \"Late Night\",\n      \"atmosphere\": \"Dim, lonely, industrial\",\n      \"prompt\": \"A cinematic anime-style pure background scene depicting a messy repair shop interior at late night. Under dim fluorescent lights, the workbench is scattered with various wrenches, screwdrivers and mechanical parts, oil-stained tool boards and faded posters hang on walls, oil stains on the floor, used tires piled in corners. Style: rich details, high quality, dim atmosphere. Mood: lonely, industrial.\"\n    },\n    {\n      \"location\": \"City Street\",\n      \"time\": \"Dusk\",\n      \"atmosphere\": \"Warm, busy, lively\",\n      \"prompt\": \"A cinematic anime-style pure background scene depicting a bustling city street at dusk. Sunset afterglow shines on the asphalt road, neon lights of shops on both sides begin to light up, bicycle racks and bus stops on the street, high-rise buildings in the distance, sky showing orange-red gradient. Style: rich details, high quality, warm atmosphere. Mood: lively, busy.\"\n    }\n  ]\n}\n\n[Wrong Examples (containing characters, forbidden)]:\n❌ \"Depicting protagonist standing on the street\" - contains character\n❌ \"People hurrying by\" - contains characters\n❌ \"Character moving in the room\" - contains character\n\nPlease strictly follow the JSON format and ensure all fields use English.`\n\t} else {\n\t\tformatInstructions = `【输出JSON格式】\n{\n  \"backgrounds\": [\n    {\n      \"location\": \"地点名称（中文）\",\n      \"time\": \"时间描述（中文）\",\n      \"atmosphere\": \"氛围描述（中文）\",\n      \"prompt\": \"一个电影感的动漫风格纯背景场景，展现[地点描述]在[时间]的环境。画面呈现[环境细节、建筑、物品、光线等，不包含人物]。风格：细节丰富，高质量，氛围光照。情绪：[环境情绪描述]。\"\n    }\n  ]\n}\n\n【示例】\n正确示例（注意：不包含人物）：\n{\n  \"backgrounds\": [\n    {\n      \"location\": \"维修店内部\",\n      \"time\": \"深夜\",\n      \"atmosphere\": \"昏暗、孤独、工业感\",\n      \"prompt\": \"一个电影感的动漫风格纯背景场景，展现凌乱的维修店内部在深夜的环境。昏暗的日光灯照射下，工作台上散落着各种扳手、螺丝刀和机械零件，墙上挂着油污斑斑的工具挂板和褪色海报，地面有油渍痕迹，角落堆放着废旧轮胎。风格：细节丰富，高质量，昏暗氛围。情绪：孤独、工业感。\"\n    },\n    {\n      \"location\": \"城市街道\",\n      \"time\": \"黄昏\",\n      \"atmosphere\": \"温暖、繁忙、生活气息\",\n      \"prompt\": \"一个电影感的动漫风格纯背景场景，展现繁华的城市街道在黄昏时分的环境。夕阳的余晖洒在街道的沥青路面上，两旁的商铺霓虹灯开始点亮，街边有自行车停靠架和公交站牌，远处高楼林立，天空呈现橙红色渐变。风格：细节丰富，高质量，温暖氛围。情绪：生活气息、繁忙。\"\n    }\n  ]\n}\n\n【错误示例（包含人物，禁止）】：\n❌ \"展现主角站在街道上的场景\" - 包含人物\n❌ \"人们匆匆而过\" - 包含人物\n❌ \"角色在房间里活动\" - 包含人物\n\n请严格按照JSON格式输出，确保所有字段都使用中文。`\n\t}\n\n\tprompt := fmt.Sprintf(`%s\n\n%s\n%s\n\n%s`, systemPrompt, contentLabel, scriptContent, formatInstructions)\n\n\t// 打印完整提示词用于调试\n\ts.log.Infow(\"=== AI Prompt for Background Extraction (extractBackgroundsFromScript) ===\",\n\t\t\"language\", s.promptI18n.GetLanguage(),\n\t\t\"prompt_length\", len(prompt),\n\t\t\"full_prompt\", prompt)\n\n\tresponse, err := client.GenerateText(prompt, \"\", ai.WithTemperature(0.7))\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to extract backgrounds with AI\", \"error\", err)\n\t\treturn nil, fmt.Errorf(\"AI提取场景失败: %w\", err)\n\t}\n\n\t// 打印AI返回的原始响应\n\ts.log.Infow(\"=== AI Response for Background Extraction (extractBackgroundsFromScript) ===\",\n\t\t\"response_length\", len(response),\n\t\t\"raw_response\", response)\n\n\t// 解析AI返回的JSON\n\tvar backgrounds []BackgroundInfo\n\n\t// 先尝试解析为数组格式\n\tif err := utils.SafeParseAIJSON(response, &backgrounds); err == nil {\n\t\ts.log.Infow(\"Parsed backgrounds as array format\", \"count\", len(backgrounds))\n\t} else {\n\t\t// 尝试解析为对象格式\n\t\tvar result struct {\n\t\t\tBackgrounds []BackgroundInfo `json:\"backgrounds\"`\n\t\t}\n\t\tif err := utils.SafeParseAIJSON(response, &result); err != nil {\n\t\t\ts.log.Errorw(\"Failed to parse AI response in both formats\", \"error\", err, \"response\", response[:min(len(response), 500)])\n\t\t\treturn nil, fmt.Errorf(\"解析AI响应失败: %w\", err)\n\t\t}\n\t\tbackgrounds = result.Backgrounds\n\t\ts.log.Infow(\"Parsed backgrounds as object format\", \"count\", len(backgrounds))\n\t}\n\n\ts.log.Infow(\"Extracted backgrounds from script\",\n\t\t\"drama_id\", dramaID,\n\t\t\"backgrounds_count\", len(backgrounds))\n\n\treturn backgrounds, nil\n}\n\n// extractBackgroundsWithAI 使用AI智能分析场景并提取唯一背景\nfunc (s *ImageGenerationService) extractBackgroundsWithAI(storyboards []models.Storyboard, style string) ([]BackgroundInfo, error) {\n\tif len(storyboards) == 0 {\n\t\treturn []BackgroundInfo{}, nil\n\t}\n\n\t// 构建场景列表文本，使用SceneNumber而不是索引\n\tvar scenesText string\n\tfor _, storyboard := range storyboards {\n\t\tlocation := \"\"\n\t\tif storyboard.Location != nil {\n\t\t\tlocation = *storyboard.Location\n\t\t}\n\t\ttime := \"\"\n\t\tif storyboard.Time != nil {\n\t\t\ttime = *storyboard.Time\n\t\t}\n\t\taction := \"\"\n\t\tif storyboard.Action != nil {\n\t\t\taction = *storyboard.Action\n\t\t}\n\t\tdescription := \"\"\n\t\tif storyboard.Description != nil {\n\t\t\tdescription = *storyboard.Description\n\t\t}\n\n\t\tscenesText += fmt.Sprintf(\"镜头%d:\\n地点: %s\\n时间: %s\\n动作: %s\\n描述: %s\\n\\n\",\n\t\t\tstoryboard.StoryboardNumber, location, time, action, description)\n\t}\n\n\t// 使用国际化提示词\n\tsystemPrompt := s.promptI18n.GetSceneExtractionPrompt(style)\n\tstoryboardLabel := s.promptI18n.FormatUserPrompt(\"storyboard_list_label\")\n\n\t// 根据语言构建不同的提示词\n\tvar formatInstructions string\n\tif s.promptI18n.IsEnglish() {\n\t\tformatInstructions = `[Output JSON Format]\n{\n  \"backgrounds\": [\n    {\n      \"location\": \"Location name (English)\",\n      \"time\": \"Time description (English)\",\n      \"prompt\": \"A cinematic anime-style background depicting [location description] at [time]. The scene shows [detail description]. Style: rich details, high quality, atmospheric lighting. Mood: [mood description].\",\n      \"scene_numbers\": [1, 2, 3]\n    }\n  ]\n}\n\n[Example]\nCorrect example:\n{\n  \"backgrounds\": [\n    {\n      \"location\": \"Repair Shop\",\n      \"time\": \"Late Night\",\n      \"prompt\": \"A cinematic anime-style background depicting a messy repair shop interior at late night. Under dim lighting, the workbench is scattered with various tools and parts, with greasy posters hanging on the walls. Style: rich details, high quality, dim atmosphere. Mood: lonely, industrial.\",\n      \"scene_numbers\": [1, 5, 6, 10, 15]\n    },\n    {\n      \"location\": \"City Panorama\",\n      \"time\": \"Late Night with Acid Rain\",\n      \"prompt\": \"A cinematic anime-style background depicting a coastal city panorama in late night acid rain. Neon lights blur in the rain, skyscrapers shrouded in gray-green rain curtain, streets reflecting colorful lights. Style: rich details, high quality, cyberpunk atmosphere. Mood: oppressive, sci-fi, apocalyptic.\",\n      \"scene_numbers\": [2, 7]\n    }\n  ]\n}\n\nPlease strictly follow the JSON format and ensure:\n1. prompt field uses English\n2. scene_numbers includes all scene numbers using this background\n3. All scenes are assigned to a background`\n\t} else {\n\t\tformatInstructions = `【输出JSON格式】\n{\n  \"backgrounds\": [\n    {\n      \"location\": \"地点名称（中文）\",\n      \"time\": \"时间描述（中文）\",\n      \"prompt\": \"一个电影感的动漫风格背景，展现[地点描述]在[时间]的场景。画面呈现[细节描述]。风格：细节丰富，高质量，氛围光照。情绪：[情绪描述]。\",\n      \"scene_numbers\": [1, 2, 3]\n    }\n  ]\n}\n\n【示例】\n正确示例：\n{\n  \"backgrounds\": [\n    {\n      \"location\": \"维修店\",\n      \"time\": \"深夜\",\n      \"prompt\": \"一个电影感的动漫风格背景，展现凌乱的维修店内部在深夜的场景。昏暗的灯光下，工作台上散落着各种工具和零件，墙上挂着油污的海报。风格：细节丰富，高质量，昏暗氛围。情绪：孤独、工业感。\",\n      \"scene_numbers\": [1, 5, 6, 10, 15]\n    },\n    {\n      \"location\": \"城市全景\",\n      \"time\": \"深夜·酸雨\",\n      \"prompt\": \"一个电影感的动漫风格背景，展现沿海城市全景在深夜酸雨中的场景。霓虹灯在雨中模糊，高楼大厦笼罩在灰绿色的雨幕中，街道反射着五颜六色的光。风格：细节丰富，高质量，赛博朋克氛围。情绪：压抑、科幻、末世感。\",\n      \"scene_numbers\": [2, 7]\n    }\n  ]\n}\n\n请严格按照JSON格式输出，确保：\n1. prompt字段使用中文\n2. scene_numbers包含所有使用该背景的场景编号\n3. 所有场景都被分配到某个背景`\n\t}\n\n\tprompt := fmt.Sprintf(`%s\n\n%s\n%s\n\n%s`, systemPrompt, storyboardLabel, scenesText, formatInstructions)\n\n\t// 打印完整提示词用于调试\n\ts.log.Infow(\"=== AI Prompt for Background Extraction (extractBackgroundsWithAI) ===\",\n\t\t\"language\", s.promptI18n.GetLanguage(),\n\t\t\"prompt_length\", len(prompt),\n\t\t\"full_prompt\", prompt)\n\n\t// 调用AI服务\n\ttext, err := s.aiService.GenerateText(prompt, \"\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"AI analysis failed: %w\", err)\n\t}\n\n\t// 打印AI返回的原始响应\n\ts.log.Infow(\"=== AI Response for Background Extraction ===\",\n\t\t\"response_length\", len(text),\n\t\t\"raw_response\", text)\n\n\t// 解析AI返回的JSON\n\tvar result struct {\n\t\tScenes []struct {\n\t\t\tLocation         string `json:\"location\"`\n\t\t\tTime             string `json:\"time\"`\n\t\t\tPrompt           string `json:\"prompt\"`\n\t\t\tStoryboardNumber []int  `json:\"storyboard_number\"`\n\t\t} `json:\"backgrounds\"`\n\t}\n\n\tif err := utils.SafeParseAIJSON(text, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse AI response: %w\", err)\n\t}\n\n\t// 构建场景编号到场景ID的映射\n\tstoryboardNumberToID := make(map[int]uint)\n\tfor _, scene := range storyboards {\n\t\tstoryboardNumberToID[scene.StoryboardNumber] = scene.ID\n\t}\n\n\t// 转换为BackgroundInfo\n\tvar backgrounds []BackgroundInfo\n\tfor _, bg := range result.Scenes {\n\t\t// 将场景编号转换为场景ID\n\t\tvar sceneIDs []uint\n\t\tfor _, storyboardNum := range bg.StoryboardNumber {\n\t\t\tif storyboardID, ok := storyboardNumberToID[storyboardNum]; ok {\n\t\t\t\tsceneIDs = append(sceneIDs, storyboardID)\n\t\t\t}\n\t\t}\n\n\t\tbackgrounds = append(backgrounds, BackgroundInfo{\n\t\t\tLocation:          bg.Location,\n\t\t\tTime:              bg.Time,\n\t\t\tPrompt:            bg.Prompt,\n\t\t\tStoryboardNumbers: bg.StoryboardNumber,\n\t\t\tSceneIDs:          sceneIDs,\n\t\t\tStoryboardCount:   len(sceneIDs),\n\t\t})\n\t}\n\n\ts.log.Infow(\"AI extracted backgrounds\",\n\t\t\"total_scenes\", len(storyboards),\n\t\t\"extracted_backgrounds\", len(backgrounds))\n\n\treturn backgrounds, nil\n}\n\n// extractUniqueBackgrounds 从分镜头中提取唯一背景（代码逻辑，作为AI提取的备份）\nfunc (s *ImageGenerationService) extractUniqueBackgrounds(scenes []models.Storyboard) []BackgroundInfo {\n\tbackgroundMap := make(map[string]*BackgroundInfo)\n\n\tfor _, scene := range scenes {\n\t\tif scene.Location == nil || scene.Time == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 使用 location + time 作为唯一标识\n\t\tkey := *scene.Location + \"|\" + *scene.Time\n\n\t\tif bg, exists := backgroundMap[key]; exists {\n\t\t\t// 背景已存在，添加scene ID\n\t\t\tbg.SceneIDs = append(bg.SceneIDs, scene.ID)\n\t\t\tbg.StoryboardCount++\n\t\t} else {\n\t\t\t// 新背景 - 使用ImagePrompt构建背景提示词\n\t\t\tprompt := \"\"\n\t\t\tif scene.ImagePrompt != nil {\n\t\t\t\tprompt = *scene.ImagePrompt\n\t\t\t}\n\t\t\tbackgroundMap[key] = &BackgroundInfo{\n\t\t\t\tLocation:        *scene.Location,\n\t\t\t\tTime:            *scene.Time,\n\t\t\t\tPrompt:          prompt,\n\t\t\t\tSceneIDs:        []uint{scene.ID},\n\t\t\t\tStoryboardCount: 1,\n\t\t\t}\n\t\t}\n\t}\n\n\t// 转换为切片\n\tvar backgrounds []BackgroundInfo\n\tfor _, bg := range backgroundMap {\n\t\tbackgrounds = append(backgrounds, *bg)\n\t}\n\n\treturn backgrounds\n}\n\n// loadImageAsBase64 读取本地图片文件并转换为 base64 格式的 data URI\nfunc (s *ImageGenerationService) loadImageAsBase64(localPath string) (string, error) {\n\t// 构建完整的文件路径\n\tvar fullPath string\n\tif filepath.IsAbs(localPath) {\n\t\tfullPath = localPath\n\t} else {\n\t\t// 如果是相对路径，拼接存储根目录\n\t\tif s.localStorage != nil {\n\t\t\tfullPath = s.localStorage.GetAbsolutePath(localPath)\n\t\t} else {\n\t\t\tfullPath = filepath.Join(s.config.Storage.LocalPath, localPath)\n\t\t}\n\t}\n\n\t// 读取文件\n\tfileData, err := os.ReadFile(fullPath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to read image file: %w\", err)\n\t}\n\n\t// 根据文件扩展名确定 MIME 类型\n\text := strings.ToLower(filepath.Ext(fullPath))\n\tmimeType := \"image/jpeg\" // 默认\n\tswitch ext {\n\tcase \".png\":\n\t\tmimeType = \"image/png\"\n\tcase \".jpg\", \".jpeg\":\n\t\tmimeType = \"image/jpeg\"\n\tcase \".gif\":\n\t\tmimeType = \"image/gif\"\n\tcase \".webp\":\n\t\tmimeType = \"image/webp\"\n\t}\n\n\t// 转换为 base64\n\tbase64Data := base64.StdEncoding.EncodeToString(fileData)\n\n\t// 构建 data URI\n\tdataURI := fmt.Sprintf(\"data:%s;base64,%s\", mimeType, base64Data)\n\n\treturn dataURI, nil\n}\n"
  },
  {
    "path": "application/services/prompt_i18n.go",
    "content": "package services\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/drama-generator/backend/pkg/config\"\n)\n\n// PromptI18n 提示词国际化工具\ntype PromptI18n struct {\n\tconfig *config.Config\n}\n\n// NewPromptI18n 创建提示词国际化工具\nfunc NewPromptI18n(cfg *config.Config) *PromptI18n {\n\treturn &PromptI18n{config: cfg}\n}\n\n// GetLanguage 获取当前语言设置\nfunc (p *PromptI18n) GetLanguage() string {\n\tlang := p.config.App.Language\n\tif lang == \"\" {\n\t\treturn \"zh\" // 默认中文\n\t}\n\treturn lang\n}\n\n// IsEnglish 判断是否为英文模式（动态读取配置）\nfunc (p *PromptI18n) IsEnglish() bool {\n\treturn p.GetLanguage() == \"en\"\n}\n\n// GetStoryboardSystemPrompt 获取分镜生成系统提示词\nfunc (p *PromptI18n) GetStoryboardSystemPrompt() string {\n\tif p.IsEnglish() {\n\t\treturn `[Role] You are a senior film storyboard artist, proficient in Robert McKee's shot breakdown theory, skilled at building emotional rhythm.\n\n[Task] Break down the novel script into storyboard shots based on **independent action units**.\n\n[Shot Breakdown Principles]\n1. **Action Unit Division**: Each shot must correspond to a complete and independent action\n   - One action = one shot (character stands up, walks over, speaks a line, reacts with an expression, etc.)\n   - Do NOT merge multiple actions (standing up + walking over should be split into 2 shots)\n\n2. **Shot Type Standards** (choose based on storytelling needs):\n   - Extreme Long Shot (ELS): Environment, atmosphere building\n   - Long Shot (LS): Full body action, spatial relationships\n   - Medium Shot (MS): Interactive dialogue, emotional communication\n   - Close-Up (CU): Detail display, emotional expression\n   - Extreme Close-Up (ECU): Key props, intense emotions\n\n3. **Camera Movement Requirements**:\n   - Fixed Shot: Stable focus on one subject\n   - Push In: Approaching subject, increasing tension\n   - Pull Out: Expanding field of view, revealing context\n   - Pan: Horizontal camera movement, spatial transitions\n   - Follow: Following subject movement\n   - Tracking: Linear movement with subject\n\n4. **Emotion & Intensity Markers**:\n   - Emotion: Brief description (excited, sad, nervous, happy, etc.)\n   - Intensity: Emotion level using arrows\n     * Extremely strong ↑↑↑ (3): Emotional peak, high tension\n     * Strong ↑↑ (2): Significant emotional fluctuation\n     * Moderate ↑ (1): Noticeable emotional change\n     * Stable → (0): Emotion remains unchanged\n     * Weak ↓ (-1): Emotion subsiding\n\n[Output Requirements]\n1. Generate an array, each element is a shot containing:\n   - shot_number: Shot number\n   - scene_description: Scene (location + time, e.g., \"bedroom interior, morning\")\n   - shot_type: Shot type (extreme long shot/long shot/medium shot/close-up/extreme close-up)\n   - camera_angle: Camera angle (eye-level/low-angle/high-angle/side/back)\n   - camera_movement: Camera movement (fixed/push/pull/pan/follow/tracking)\n   - action: Action description\n   - result: Visual result of the action\n   - dialogue: Character dialogue or narration (if any)\n   - emotion: Current emotion\n   - emotion_intensity: Emotion intensity level (3/2/1/0/-1)\n\n**CRITICAL: Return ONLY a valid JSON array. Do NOT include any markdown code blocks, explanations, or other text. Start directly with [ and end with ].**\n\n[Important Notes]\n- Shot count must match number of independent actions in the script (not allowed to merge or reduce)\n- Each shot must have clear action and result\n- Shot types must match storytelling rhythm (don't use same shot type continuously)\n- Emotion intensity must accurately reflect script atmosphere changes`\n\t}\n\n\treturn `【角色】你是一位资深影视分镜师，精通罗伯特·麦基的镜头拆解理论，擅长构建情绪节奏。\n\n【任务】将小说剧本按**独立动作单元**拆解为分镜头方案。\n\n【分镜拆解原则】\n1. **动作单元划分**：每个镜头必须对应一个完整且独立的动作\n   - 一个动作 = 一个镜头（角色站起来、走过去、说一句话、做一个反应表情等）\n   - 禁止合并多个动作（站起+走过去应拆分为2个镜头）\n\n2. **景别标准**（根据叙事需要选择）：\n   - 大远景：环境、氛围营造\n   - 远景：全身动作、空间关系\n   - 中景：交互对话、情感交流\n   - 近景：细节展示、情绪表达\n   - 特写：关键道具、强烈情绪\n\n3. **运镜要求**：\n   - 固定镜头：稳定聚焦于一个主体\n   - 推镜：接近主体，增强紧张感\n   - 拉镜：扩大视野，交代环境\n   - 摇镜：水平移动摄像机，空间转换\n   - 跟镜：跟随主体移动\n   - 移镜：摄像机与主体同向移动\n\n4. **情绪与强度标记**：\n   - emotion：简短描述（兴奋、悲伤、紧张、愉快等）\n   - emotion_intensity：用箭头表示情绪等级\n     * 极强 ↑↑↑ (3)：情绪高峰、高度紧张\n     * 强 ↑↑ (2)：情绪明显波动\n     * 中 ↑ (1)：情绪有所变化\n     * 平稳 → (0)：情绪不变\n     * 弱 ↓ (-1)：情绪回落\n\n【输出要求】\n1. 生成一个数组，每个元素是一个镜头，包含：\n   - shot_number：镜头号\n   - scene_description：场景（地点+时间，如\"卧室内，早晨\"）\n   - shot_type：景别（大远景/远景/中景/近景/特写）\n   - camera_angle：机位角度（平视/仰视/俯视/侧面/背面）\n   - camera_movement：运镜方式（固定/推镜/拉镜/摇镜/跟镜/移镜）\n   - action：动作描述\n   - result：动作完成后的画面结果\n   - dialogue：角色对话或旁白（如有）\n   - emotion：当前情绪\n   - emotion_intensity：情绪强度等级（3/2/1/0/-1）\n\n**重要：必须只返回纯JSON数组，不要包含任何markdown代码块、说明文字或其他内容。直接以 [ 开头，以 ] 结尾。**\n\n【重要提示】\n- 镜头数量必须与剧本中的独立动作数量匹配（不允许合并或减少）\n- 每个镜头必须有明确的动作和结果\n- 景别选择必须符合叙事节奏（不要连续使用同一景别）\n- 情绪强度必须准确反映剧本氛围变化`\n}\n\n// GetSceneExtractionPrompt 获取场景提取提示词\nfunc (p *PromptI18n) GetSceneExtractionPrompt(style string) string {\n\t// 默认图片比例\n\timageRatio := \"16:9\"\n\n\tif p.IsEnglish() {\n\t\treturn fmt.Sprintf(`[Task] Extract all unique scene backgrounds from the script\n\n[Requirements]\n1. Identify all different scenes (location + time combinations) in the script\n2. Generate detailed **English** image generation prompts for each scene\n3. **Important**: Scene descriptions must be **pure backgrounds** without any characters, people, or actions\n4. Prompt requirements:\n   - Must use **English**, no Chinese characters\n   - Detailed description of scene, time, atmosphere, style\n   - Must explicitly specify \"no people, no characters, empty scene\"\n   - Must match the drama's genre and tone\n   - **Style Requirement**: %s\n   - **Image Ratio**: %s\n\n\n[Output Format]\n**CRITICAL: Return ONLY a valid JSON array. Do NOT include any markdown code blocks, explanations, or other text. Start directly with [ and end with ].**\n\nEach element containing:\n- location: Location (e.g., \"luxurious office\")\n- time: Time period (e.g., \"afternoon\")\n- prompt: Complete English image generation prompt (pure background, explicitly stating no people)`, style, imageRatio)\n\t}\n\n\treturn fmt.Sprintf(`【任务】从剧本中提取所有唯一的场景背景\n\n【要求】\n1. 识别剧本中所有不同的场景（地点+时间组合）\n2. 为每个场景生成详细的**中文**图片生成提示词（Prompt）\n3. **重要**：场景描述必须是**纯背景**，不能包含人物、角色、动作等元素\n4. Prompt要求：\n   - **必须使用中文**，不能包含英文字符\n   - 详细描述场景、时间、氛围、风格\n   - 必须明确说明\"无人物、无角色、空场景\"\n   - 要符合剧本的题材和氛围\n   - **风格要求**：%s\n   - **图片比例**：%s\n\n【输出格式】\n**重要：必须只返回纯JSON数组，不要包含任何markdown代码块、说明文字或其他内容。直接以 [ 开头，以 ] 结尾。**\n\n每个元素包含：\n- location：地点（如\"豪华办公室\"）\n- time：时间（如\"下午\"）\n- prompt：完整的中文图片生成提示词（纯背景，明确说明无人物）`, style, imageRatio)\n}\n\n// GetFirstFramePrompt 获取首帧提示词\nfunc (p *PromptI18n) GetFirstFramePrompt(style string) string {\n\timageRatio := \"16:9\"\n\tif p.IsEnglish() {\n\t\treturn fmt.Sprintf(`You are a professional image generation prompt expert. Please generate prompts suitable for AI image generation based on the provided shot information.\n\nImportant: This is the first frame of the shot - a completely static image showing the initial state before the action begins.\n\nKey Points:\n1. Focus on the initial static state - the moment before the action\n2. Must NOT include any action or movement\n3. Describe the character's initial posture, position, and expression\n4. Can include scene atmosphere and environmental details\n5. Shot type determines composition and framing\n- **Style Requirement**: %s\n- **Image Ratio**: %s\nOutput Format:\nReturn a JSON object containing:\n- prompt: Complete English image generation prompt (detailed description, suitable for AI image generation)\n- description: Simplified Chinese description (for reference)`, style, imageRatio)\n\t}\n\n\treturn fmt.Sprintf(`你是一个专业的图像生成提示词专家。请根据提供的镜头信息，生成适合用于AI图像生成的提示词。\n\n重要：这是镜头的首帧 - 一个完全静态的画面，展示动作发生之前的初始状态。\n\n关键要点：\n1. 聚焦初始静态状态 - 动作发生之前的那一瞬间\n2. 必须不包含任何动作或运动\n3. 描述角色的初始姿态、位置和表情\n4. 可以包含场景氛围和环境细节\n5. 景别决定构图和取景范围\n- **风格要求**：%s\n- **图片比例**：%s\n输出格式：\n返回一个JSON对象，包含：\n- prompt：完整的中文图片生成提示词（详细描述，适合AI图像生成）\n- description：简化的中文描述（供参考）`, style, imageRatio)\n}\n\n// GetKeyFramePrompt 获取关键帧提示词\nfunc (p *PromptI18n) GetKeyFramePrompt(style string) string {\n\timageRatio := \"16:9\"\n\tif p.IsEnglish() {\n\t\treturn fmt.Sprintf(`You are a professional image generation prompt expert. Please generate prompts suitable for AI image generation based on the provided shot information.\n\nImportant: This is the key frame of the shot - capturing the most intense and exciting moment of the action.\n\nKey Points:\n1. Focus on the most exciting moment of the action\n2. Capture peak emotional expression\n3. Emphasize dynamic tension\n4. Show character actions and expressions at their climax\n5. Can include motion blur or dynamic effects\n- **Style Requirement**: %s\n- **Image Ratio**: %s\nOutput Format:\nReturn a JSON object containing:\n- prompt: Complete English image generation prompt (detailed description, suitable for AI image generation)\n- description: Simplified Chinese description (for reference)`, style, imageRatio)\n\t}\n\n\treturn fmt.Sprintf(`你是一个专业的图像生成提示词专家。请根据提供的镜头信息，生成适合用于AI图像生成的提示词。\n\n重要：这是镜头的关键帧 - 捕捉动作最激烈、最精彩的瞬间。\n\n关键要点：\n1. 聚焦动作最精彩的时刻\n2. 捕捉情绪表达的顶点\n3. 强调动态张力\n4. 展示角色动作和表情的高潮状态\n5. 可以包含动作模糊或动态效果\n- **风格要求**：%s\n- **图片比例**：%s\n输出格式：\n返回一个JSON对象，包含：\n- prompt：完整的中文图片生成提示词（详细描述，适合AI图像生成）\n- description：简化的中文描述（供参考）`, style, imageRatio)\n}\n\n// GetActionSequenceFramePrompt 获取动作序列提示词\nfunc (p *PromptI18n) GetActionSequenceFramePrompt(style string) string {\n\timageRatio := \"16:9\"\n\tif p.IsEnglish() {\n\t\treturn fmt.Sprintf(`**Role:** You are an expert in visual storytelling and image generation prompting. You need to generate a single prompt that describes a 3x3 grid action sequence.\n\n**Core Logic:**\n\n1. **Holistic Integration:** This is a single, complete image containing a 3x3 grid layout, showcasing 9 sequential actions of the same subject.\n2. **Visual Anchoring:** The subject, clothing, art style, and character consistency must be identical across all 9 frames.\n3. **Action Evolution:** From Frame 1 to Frame 9, display a complete action sequence (e.g., Standing → Walking → Running → Jumping → Landing).\n4. **Prompt Engineering:** Use high-quality visual vocabulary (lighting, textures, composition, depth of field).\n\n**Important:**\n\nYou must generate **ONE** comprehensive prompt to describe the entire 3x3 grid image, rather than 9 independent prompts.\n\nEach frame **must** follow these specific rules:\n\n- **Frame 1:** Preparation/Initial stance\n- **Frame 2:** Anticipation/Body adjustment\n- **Frame 3:** Initiation/Beginning of movement\n- **Frame 4:** Acceleration/Power building\n- **Frame 5:** Peak of tension/Just before the burst\n- **Frame 6:** Action burst/The climax moment\n- **Frame 7:** Power release/Inertia continuation\n- **Frame 8:** Deceleration/Follow-through\n- **Frame 9:** Complete conclusion/Return to stillness\n\n**Aspect Ratio:** * %s\n\n**Output Specification:**\n\nYou must return a **JSON object** with the following structure:\n\n- **prompt**: A **complete English image generation prompt** (describing the 3x3 grid layout, subject features, the evolution of the 9 actions, environment, and lighting details to ensure the AI generates one single image containing 9 frames).\n- **description**: A **simplified English description** (summarizing the core content of the action sequence).\n\n**Example Format:**\n\n{\n  \"prompt\": \"Action sequence layout, 3x3 grid composition\\n [Frame 1]: [Subject] standing naturally in [Setting], feet shoulder-width apart...\\n---\\n [Frame 2]: [Subject] locking eyes forward, leaning body slightly...\\n---\\n [Frame 3]: [Subject's legs] bending slightly, center of gravity lowering...\\n---\\n [Frame 4]: [Subject] pushing off with back leg, body moving forward, dust rising from [Setting's ground]...\\n---\\n [Frame 5]: [Subject's clothing] fluttering, body leaning deep, fist charging power...\\n---\\n [Frame 6]: [Subject] sprinting at full speed, fist striking out...\\n---\\n [Frame 7]: [Subject] impact moment, body lunging forward...\\n---\\n [Frame 8]: [Subject] slowing down, pulling back the fist...\\n---\\n [Frame 9]: [Subject's full appearance] standing firm in [Setting], recovering original stance.\",\n  \"description\": \"Complete action sequence of a swordsman in black from drawing a blade to striking.\"\n}\n\n`, style, imageRatio)\n\t}\n\n\treturn fmt.Sprintf(`**Role:** 你是一位精通视觉叙事与图像生成提示词的专家。你需要生成一个描述 3x3 九宫格动作序列的提示词。\n\n**Core Logic:**\n\n1. **整体性:** 这是一张完整的图片,包含 3x3 九宫格布局,展示同一主体的 9 个连续动作。\n2. **视觉锚定:** 所有 9 个格子中的主体、服装、画风必须高度一致。\n3. **动作演进:** 从格子 1 到格子 9,展示一个完整的动作序列(如:从站立→行走→奔跑→跳跃→落地)。\n4. **提示词工程:** 使用高质量的视觉词汇(光影、材质、构图、景深)。\n\n**重要:** \n你需要生成 **一个** 完整的提示词来描述整个 3x3 九宫格图片,而不是 9 个独立的提示词。\n每一格要求**必须**遵守如下规则：\n- **第1格**：动作准备/初始姿态\n- **第2格**：预备动作/身体调整\n- **第3格**：动作启动/开始移动\n- **第4格**：加速阶段/力量积蓄\n- **第5格**：蓄力顶点/即将爆发\n- **第6格**：动作爆发/高潮瞬间\n- **第7格**：力量释放/惯性延续\n- **第8格**：动作缓冲/逐渐收势\n- **第9格**：完全收尾/回归静止\n\n**Aspect Ratio:** \n* %s\n\n**Output Specification:**\n必须返回一个 **JSON 对象**,其结构如下:\n* prompt: **完整的中文图片生成提示词**(描述整个 3x3 九宫格的布局、主体特征、9 个动作的演进过程、环境、光影细节,确保 AI 能直接生成一张包含 9 个格子的完整图像)。\n* description: **简化的中文描述**(概括这个动作序列的核心内容)。\n\n**示例格式:**\n{\n  \"prompt\": \"动作序列布局，3x3方格布局\\n [第1格]: [角色参考图2] 在 [场景参考图1] 中自然站立，双脚分开...\\n---\\n [第2格]: [角色参考图2] 眼神锁定，身体前倾...\\n---\\n [第3格]: [角色参考图2的腿部] 双腿微屈，重心下沉...\\n---\\n [第4格]: [角色参考图2] 后腿蹬地，身体前移，[场景参考图1的地面] 扬起尘土...\\n---\\n [第5格]: [角色参考图2的服装] 身体前倾，拳头蓄力...\\n---\\n [第6格]: [角色参考图2] 全速冲刺，拳头击出...\\n---\\n [第7格]: [角色参考图2] 拳头击中，身体前冲...\\n---\\n [第8格]: [角色参考图2] 减速收拳...\\n---\\n [第9格]: [角色参考图2的完整外观] 在 [场景参考图1] 中站稳，恢复姿态。\\n\",\n  \"description\": \"黑衣剑客从拔剑到攻击的完整动作序列\"\n}`, imageRatio)\n}\n\n// GetLastFramePrompt 获取尾帧提示词\nfunc (p *PromptI18n) GetLastFramePrompt(style string) string {\n\timageRatio := \"16:9\"\n\tif p.IsEnglish() {\n\t\treturn fmt.Sprintf(`You are a professional image generation prompt expert. Please generate prompts suitable for AI image generation based on the provided shot information.\n\nImportant: This is the last frame of the shot - a static image showing the final state and result after the action ends.\n\nKey Points:\n1. Focus on the final state after action completion\n2. Show the result of the action\n3. Describe character's final posture and expression after action\n4. Emphasize emotional state after action\n5. Capture the calm moment after action ends\n- **Style Requirement**: %s\n- **Image Ratio**: %s\nOutput Format:\nReturn a JSON object containing:\n- prompt: Complete English image generation prompt (detailed description, suitable for AI image generation)\n- description: Simplified Chinese description (for reference)`, style, imageRatio)\n\t}\n\n\treturn fmt.Sprintf(`你是一个专业的图像生成提示词专家。请根据提供的镜头信息，生成适合用于AI图像生成的提示词。\n\n重要：这是镜头的尾帧 - 一个静态画面，展示动作结束后的最终状态和结果。\n\n关键要点：\n1. 聚焦动作完成后的最终状态\n2. 展示动作的结果\n3. 描述角色在动作完成后的姿态和表情\n4. 强调动作后的情绪状态\n5. 捕捉动作结束后的平静瞬间\n- **风格要求**：%s\n- **图片比例**：%s\n输出格式：\n返回一个JSON对象，包含：\n- prompt：完整的中文图片生成提示词（详细描述，适合AI图像生成）\n- description：简化的中文描述（供参考）`, style, imageRatio)\n}\n\n// GetOutlineGenerationPrompt 获取大纲生成提示词\nfunc (p *PromptI18n) GetOutlineGenerationPrompt() string {\n\tif p.IsEnglish() {\n\t\treturn `You are a professional short drama screenwriter. Based on the theme and number of episodes, create a complete short drama outline and plan the plot direction for each episode.\n\nRequirements:\n1. Compact plot with strong conflicts and fast pace\n2. Each episode should have independent conflicts while connecting the main storyline\n3. Clear character arcs and growth\n4. Cliffhanger endings to hook viewers\n5. Clear theme and emotional core\n\nOutput Format:\nReturn a JSON object containing:\n- title: Drama title (creative and attractive)\n- episodes: Episode list, each containing:\n  - episode_number: Episode number\n  - title: Episode title\n  - summary: Episode content summary (50-100 words)\n  - conflict: Main conflict point\n  - cliffhanger: Cliffhanger ending (if any)`\n\t}\n\n\treturn `你是专业短剧编剧。根据主题和剧集数量，创作完整的短剧大纲，规划好每一集的剧情走向。\n\n要求：\n1. 剧情紧凑，矛盾冲突强烈，节奏快\n2. 每集都有独立的矛盾冲突，同时推进主线\n3. 角色弧光清晰，成长变化明显\n4. 悬念设置合理，吸引观众继续观看\n5. 主题明确，情感内核清晰\n\n输出格式：\n返回一个JSON对象，包含：\n- title: 剧名（富有创意和吸引力）\n- episodes: 分集列表，每集包含：\n  - episode_number: 集数\n  - title: 本集标题\n  - summary: 本集内容概要（50-100字）\n  - conflict: 主要矛盾点\n  - cliffhanger: 悬念结尾（如有）`\n}\n\n// GetCharacterExtractionPrompt 获取角色提取提示词\nfunc (p *PromptI18n) GetCharacterExtractionPrompt(style string) string {\n\timageRatio := \"16:9\"\n\tif p.IsEnglish() {\n\t\treturn fmt.Sprintf(`You are a professional character analyst, skilled at extracting and analyzing character information from scripts.\n\nYour task is to extract and organize detailed character settings for all characters appearing in the script based on the provided script content.\n\nRequirements:\n1. Extract all characters with names (ignore unnamed passersby or background characters)\n2. For each character, extract:\n   - name: Character name\n   - role: Character role (main/supporting/minor)\n   - appearance: Physical appearance description (150-300 words)\n   - personality: Personality traits (100-200 words)\n   - description: Background story and character relationships (100-200 words)\n3. Appearance must be detailed enough for AI image generation, including: gender, age, body type, facial features, hairstyle, clothing style, etc. but do not include any scene, background, environment information\n4. Main characters require more detailed descriptions, supporting characters can be simplified\n- **Style Requirement**: %s\n- **Image Ratio**: %s\nOutput Format:\n**CRITICAL: Return ONLY a valid JSON array. Do NOT include any markdown code blocks, explanations, or other text. Start directly with [ and end with ].**\nEach element is a character object containing the above fields.`, style, imageRatio)\n\t}\n\n\treturn fmt.Sprintf(`你是一个专业的角色分析师，擅长从剧本中提取和分析角色信息。\n\n你的任务是根据提供的剧本内容，提取并整理剧中出现的所有角色的详细设定。\n\n要求：\n1. 提取所有有名字的角色（忽略无名路人或背景角色）\n2. 对每个角色，提取以下信息：\n   - name: 角色名字\n   - role: 角色类型（main/supporting/minor）\n   - appearance: 外貌描述（150-300字）\n   - personality: 性格特点（100-200字）\n   - description: 背景故事和角色关系（100-200字）\n3. 外貌描述要足够详细，适合AI生成图片，包括：性别、年龄、体型、面部特征、发型、服装风格等,但不要包含任何场景、背景、环境等信息\n4. 主要角色需要更详细的描述，次要角色可以简化\n- **风格要求**：%s\n- **图片比例**：%s\n输出格式：\n**重要：必须只返回纯JSON数组，不要包含任何markdown代码块、说明文字或其他内容。直接以 [ 开头，以 ] 结尾。**\n每个元素是一个角色对象，包含上述字段。`, style, imageRatio)\n}\n\n// GetPropExtractionPrompt 获取道具提取提示词\nfunc (p *PromptI18n) GetPropExtractionPrompt(style string) string {\n\timageRatio := \"1:1\"\n\n\tif p.IsEnglish() {\n\t\treturn fmt.Sprintf(`Please extract key props from the following script.\n    \n[Script Content]\n%%s\n\n[Requirements]\n1. Extract ONLY key props that are important to the plot or have special visual characteristics.\n2. Do NOT extract common daily items (e.g., normal cups, pens) unless they have special plot significance.\n3. If a prop has a clear owner, please note it in the description.\n4. \"image_prompt\" field is for AI image generation, must describe the prop's appearance, material, color, and style in detail.\n- **Style Requirement**: %s\n- **Image Ratio**: %s\n\n[Output Format]\nJSON array, each object containing:\n- name: Prop Name\n- type: Type (e.g., Weapon/Key Item/Daily Item/Special Device)\n- description: Role in the drama and visual description\n- image_prompt: English image generation prompt (Focus on the object, isolated, detailed, cinematic lighting, high quality)\n\nPlease return JSON array directly.`, style, imageRatio)\n\t}\n\n\treturn fmt.Sprintf(`请从以下剧本中提取关键道具。\n    \n【剧本内容】\n%%s\n\n【要求】\n1. 只提取对剧情发展有重要作用、或有特殊视觉特征的关键道具。\n2. 普通的生活用品（如普通的杯子、笔）如果无特殊剧情意义不需要提取。\n3. 如果道具有明确的归属者，请在描述中注明。\n4. \"image_prompt\"字段是用于AI生成图片的英文提示词，必须详细描述道具的外观、材质、颜色、风格。\n- **风格要求**：%s\n- **图片比例**：%s\n\n【输出格式】\nJSON数组，每个对象包含：\n- name: 道具名称\n- type: 类型 (如：武器/关键证物/日常用品/特殊装置)\n- description: 在剧中的作用和中文外观描述\n- image_prompt: 英文图片生成提示词 (Focus on the object, isolated, detailed, cinematic lighting, high quality)\n\n请直接返回JSON数组。`, style, imageRatio)\n}\n\n// GetEpisodeScriptPrompt 获取分集剧本生成提示词\nfunc (p *PromptI18n) GetEpisodeScriptPrompt() string {\n\tif p.IsEnglish() {\n\t\treturn `You are a professional short drama screenwriter. You excel at creating detailed plot content based on episode plans.\n\nYour task is to expand the summary in the outline into detailed plot narratives for each episode. Each episode is about 180 seconds (3 minutes) and requires substantial content.\n\nRequirements:\n1. Expand the outline summary into detailed plot development\n2. Write character dialogue and actions, not just description\n3. Highlight conflict progression and emotional changes\n4. Add scene transitions and atmosphere descriptions\n5. Control rhythm, with climax at 2/3 point, resolution at the end\n6. Each episode 800-1200 words, dialogue-rich\n7. Keep consistent with character settings\n\nOutput Format:\n**CRITICAL: Return ONLY a valid JSON object. Do NOT include any markdown code blocks, explanations, or other text. Start directly with { and end with }.**\n\n- episodes: Episode list, each containing:\n  - episode_number: Episode number\n  - title: Episode title\n  - script_content: Detailed script content (800-1200 words)`\n\t}\n\n\treturn `你是一个专业的短剧编剧。你擅长根据分集规划创作详细的剧情内容。\n\n你的任务是根据大纲中的分集规划，将每一集的概要扩展为详细的剧情叙述。每集约180秒（3分钟），需要充实的内容。\n\n要求：\n1. 将大纲中的概要扩展为具体的剧情发展\n2. 写出角色的对话和动作，不是简单描述\n3. 突出冲突的递进和情感的变化\n4. 增加场景转换和氛围描写\n5. 控制节奏，高潮在2/3处，结尾有收束\n6. 每集800-1200字，对话丰富\n7. 与角色设定保持一致\n\n输出格式：\n**重要：必须只返回纯JSON对象，不要包含任何markdown代码块、说明文字或其他内容。直接以 { 开头，以 } 结尾。**\n\n- episodes: 分集列表，每集包含：\n  - episode_number: 集数\n  - title: 本集标题\n  - script_content: 详细剧本内容（800-1200字）`\n}\n\n// FormatUserPrompt 格式化用户提示词的通用文本\nfunc (p *PromptI18n) FormatUserPrompt(key string, args ...interface{}) string {\n\ttemplates := map[string]map[string]string{\n\t\t\"en\": {\n\n\t\t\t\"outline_request\":        \"Please create a short drama outline for the following theme:\\n\\nTheme: %s\",\n\t\t\t\"genre_preference\":       \"\\nGenre preference: %s\",\n\t\t\t\"style_requirement\":      \"\\nStyle requirement: %s\",\n\t\t\t\"episode_count\":          \"\\nNumber of episodes: %d episodes\",\n\t\t\t\"episode_importance\":     \"\\n\\n**Important: Must plan complete storylines for all %d episodes in the episodes array, each with clear story content!**\",\n\t\t\t\"character_request\":      \"Script content:\\n%s\\n\\nPlease extract and organize detailed character profiles for up to %d main characters from the script.\",\n\t\t\t\"episode_script_request\": \"Drama outline:\\n%s\\n%s\\nPlease create detailed scripts for %d episodes based on the above outline and characters.\\n\\n**Important requirements:**\\n- Must generate all %d episodes, from episode 1 to episode %d, cannot skip any\\n- Each episode is about 3-5 minutes (150-300 seconds)\\n- The duration field for each episode should be set reasonably based on script content length, not all the same value\\n- The episodes array in the returned JSON must contain %d elements\",\n\t\t\t\"frame_info\":             \"Shot information:\\n%s\\n\\nPlease directly generate the image prompt for the first frame without any explanation:\",\n\t\t\t\"key_frame_info\":         \"Shot information:\\n%s\\n\\nPlease directly generate the image prompt for the key frame without any explanation:\",\n\t\t\t\"last_frame_info\":        \"Shot information:\\n%s\\n\\nPlease directly generate the image prompt for the last frame without any explanation:\",\n\t\t\t\"script_content_label\":   \"【Script Content】\",\n\t\t\t\"storyboard_list_label\":  \"【Storyboard List】\",\n\t\t\t\"task_label\":             \"【Task】\",\n\t\t\t\"character_list_label\":   \"【Available Character List】\",\n\t\t\t\"scene_list_label\":       \"【Extracted Scene Backgrounds】\",\n\t\t\t\"task_instruction\":       \"Break down the novel script into storyboard shots based on **independent action units**.\",\n\t\t\t\"character_constraint\":   \"**Important**: In the characters field, only use character IDs (numbers) from the above character list. Do not create new characters or use other IDs.\",\n\t\t\t\"scene_constraint\":       \"**Important**: In the scene_id field, select the most matching background ID (number) from the above background list. If no suitable background exists, use null.\",\n\t\t\t\"shot_description_label\": \"Shot description: %s\",\n\t\t\t\"scene_label\":            \"Scene: %s, %s\",\n\t\t\t\"characters_label\":       \"Characters: %s\",\n\t\t\t\"action_label\":           \"Action: %s\",\n\t\t\t\"result_label\":           \"Result: %s\",\n\t\t\t\"dialogue_label\":         \"Dialogue: %s\",\n\t\t\t\"atmosphere_label\":       \"Atmosphere: %s\",\n\t\t\t\"shot_type_label\":        \"Shot type: %s\",\n\t\t\t\"angle_label\":            \"Angle: %s\",\n\t\t\t\"movement_label\":         \"Movement: %s\",\n\t\t\t\"drama_info_template\":    \"Title: %s\\nSummary: %s\\nGenre: %s\",\n\t\t},\n\t\t\"zh\": {\n\t\t\t\"outline_request\":        \"请为以下主题创作短剧大纲：\\n\\n主题：%s\",\n\t\t\t\"genre_preference\":       \"\\n类型偏好：%s\",\n\t\t\t\"style_requirement\":      \"\\n风格要求：%s\",\n\t\t\t\"episode_count\":          \"\\n剧集数量：%d集\",\n\t\t\t\"episode_importance\":     \"\\n\\n**重要：必须在episodes数组中规划完整的%d集剧情，每集都要有明确的故事内容！**\",\n\t\t\t\"character_request\":      \"剧本内容：\\n%s\\n\\n请从剧本中提取并整理最多 %d 个主要角色的详细设定。\",\n\t\t\t\"episode_script_request\": \"剧本大纲：\\n%s\\n%s\\n请基于以上大纲和角色，创作 %d 集的详细剧本。\\n\\n**重要要求：**\\n- 必须生成完整的 %d 集，从第1集到第%d集，不能遗漏\\n- 每集约3-5分钟（150-300秒）\\n- 每集的duration字段要根据剧本内容长度合理设置，不要都设置为同一个值\\n- 返回的JSON中episodes数组必须包含 %d 个元素\",\n\t\t\t\"frame_info\":             \"镜头信息：\\n%s\\n\\n请直接生成首帧的图像提示词，不要任何解释：\",\n\t\t\t\"key_frame_info\":         \"镜头信息：\\n%s\\n\\n请直接生成关键帧的图像提示词，不要任何解释：\",\n\t\t\t\"last_frame_info\":        \"镜头信息：\\n%s\\n\\n请直接生成尾帧的图像提示词，不要任何解释：\",\n\t\t\t\"script_content_label\":   \"【剧本内容】\",\n\t\t\t\"storyboard_list_label\":  \"【分镜头列表】\",\n\t\t\t\"task_label\":             \"【任务】\",\n\t\t\t\"character_list_label\":   \"【本剧可用角色列表】\",\n\t\t\t\"scene_list_label\":       \"【本剧已提取的场景背景列表】\",\n\t\t\t\"task_instruction\":       \"将小说剧本按**独立动作单元**拆解为分镜头方案。\",\n\t\t\t\"character_constraint\":   \"**重要**：在characters字段中，只能使用上述角色列表中的角色ID（数字），不得自创角色或使用其他ID。\",\n\t\t\t\"scene_constraint\":       \"**重要**：在scene_id字段中，必须从上述背景列表中选择最匹配的背景ID（数字）。如果没有合适的背景，则填null。\",\n\t\t\t\"shot_description_label\": \"镜头描述: %s\",\n\t\t\t\"scene_label\":            \"场景: %s, %s\",\n\t\t\t\"characters_label\":       \"角色: %s\",\n\t\t\t\"action_label\":           \"动作: %s\",\n\t\t\t\"result_label\":           \"结果: %s\",\n\t\t\t\"dialogue_label\":         \"对白: %s\",\n\t\t\t\"atmosphere_label\":       \"氛围: %s\",\n\t\t\t\"shot_type_label\":        \"景别: %s\",\n\t\t\t\"angle_label\":            \"角度: %s\",\n\t\t\t\"movement_label\":         \"运镜: %s\",\n\t\t\t\"drama_info_template\":    \"剧名：%s\\n简介：%s\\n类型：%s\",\n\t\t},\n\t}\n\n\tlang := \"zh\"\n\tif p.IsEnglish() {\n\t\tlang = \"en\"\n\t}\n\n\ttemplate, ok := templates[lang][key]\n\tif !ok {\n\t\treturn \"\"\n\t}\n\n\tif len(args) > 0 {\n\t\treturn fmt.Sprintf(template, args...)\n\t}\n\treturn template\n}\n\n// GetStylePrompt 获取风格提示词\nfunc (p *PromptI18n) GetStylePrompt(style string) string {\n\tif style == \"\" {\n\t\treturn \"\"\n\t}\n\n\tstylePrompts := map[string]map[string]string{\n\t\t\"zh\": {\n\t\t\t\"ghibli\": `**[专家角色定位]**\n你现在是一位吉卜力工作室顶级美术指导与背景画师，擅长捕捉\"宏大自然与微观生活\"的平衡感，深谙宫崎骏式的色彩心理学。\n\n**[风格核心逻辑]**\n- **视觉流派与质感**：采用经典的吉卜力风格。画面具有浓郁的水彩晕染质感（Watercolor texture），拒绝冰冷的3D渲染，强调温暖且有呼吸感的笔触。线条清晰且细腻，呈现出赛璐珞（Cel-shading）上色的明快感。\n- **色彩与光影美学**：使用**\"高调色彩美学\"**。主色调明亮、通透、高饱和度但色相柔和。光影模拟\"夏日午后\"的自然采光，光线如同浸透在空气中，具有极佳的明度。阴影部分带有微妙的蓝紫色调，增加画面的通透感。\n- **氛围意向**：怀旧、宁静、牧歌式的（Pastoral）、微风感。画面要传达出一种\"世界依然美好\"的宁静感和探索欲。`,\n\n\t\t\t\"guoman\": `**[专家角色定位]**\n你是一位顶尖的数字插画艺术家，擅长将传统东方韵味与现代游戏美术的华丽视觉特效（VFX）相结合，是\"东方幻想主义\"构图的大师。\n\n**[风格核心逻辑]**\n- **视觉流派与质感**：融合了**新国风数字艺术（Modern Zen Illustration）**与**史诗级奇幻渲染**。画面质感细腻且带有微微的丝滑感，类似高精度的2D数字绘画。强调光影的体积感，画面中包含大量微小的粒子效果和发光氛围。\n- **核心色彩与发光美学**：使用**\"撞色与内生光影\"**。主色调通常是冷暖色调的剧烈碰撞（如靛青色与金橙色）。画面逻辑的核心在于**\"局部发光\"**：暗部点缀着发光的荧光元素（如荧光植物、灯火或水晶质感），这种对比营造了强烈的魔法感和神秘感。\n- **装饰性元素逻辑**：强调**\"线条的流动感\"**。画面中充斥着优美的曲线，这些线条通常由发光带、飘带或自然界的纹理（如流水的走势）组成，增强了整体的装饰性和节奏感。`,\n\n\t\t\t\"wasteland\": `**[专家角色定位]**\n你是一位专注于\"末世叙事\"的视觉艺术家，擅长运用**硬核线条（Hard Line-art）**和**复古平面印刷感**来营造史诗般的荒凉氛围，深受让·吉罗（Moebius）和现代废土科幻插画的影响。\n\n**[风格核心逻辑]**\n- **视觉流派与笔触质感**：采用**硬缘线条绘图风格（Hard-edged Line Art）**。画面强调清晰的黑色轮廓线，具有强烈的漫画插图感。质感上呈现出一种**颗粒状的平面印刷感（Grainy textures）**或类似旧报纸、复古海报的纹理，拒绝平滑的渐变，倾向于使用排线或点阵来表现阴影。\n- **色彩美学逻辑**：采用**\"低频限色色调（Limited Palette）\"**。画面通常被一种压抑且统一的色调统治（如灰土色、铁锈橙、荒漠黄）。核心视觉冲击力来自于**一个强烈的对比色点**（如此处巨大的红色落日），这种\"单点高亮\"的逻辑在灰暗的废土背景中能瞬间抓住视线。\n- **光影表现手法**：使用**\"高对比度强侧光（High-contrast Side Lighting）\"**。模拟黄昏或黎明的低角度光线，产生极长的投影。光影逻辑极其简化，明暗交界线生硬且明确，营造出一种干枯、灼热且寂静的戏剧张力。`,\n\n\t\t\t\"nostalgia\": `**[专家角色定位]**\n你是一位专注于**\"怀旧赛璐珞（Nostalgic Cel-shading）\"**风格的视觉艺术家，擅长模拟20世纪80-90年代手绘动画的质感，利用色彩与噪点营造一种温和、感性且略带忧郁的都市氛围。\n\n**[风格核心逻辑]**\n- **视觉流派与画面质感**：采用经典的**90年代复古动画风格（90s Retro Anime Style）**。画面具有明显的**胶片颗粒感（Film grain）**和微弱的**色散效果（Chromatic aberration）**，模拟旧式电视或磁带的播放质感。质感上强调\"不完美的细腻\"，即线条略显柔和，不像现代矢量图那样锐利，给人一种手工绘制的温度感。\n- **色彩美学逻辑**：使用**\"低对比度粉紫色调（Muted Pastel Palette）\"**。画面被一种柔和的、如梦境般的暮色统治，通常以淡紫色、藕粉色或灰蓝色为主基调。色彩逻辑的核心在于**\"弱化的黑场\"**：没有纯黑，所有深色都带有紫色或蓝色的倾向。这种色调能瞬间勾勒出一种孤独但温馨的\"都市黄昏\"感。\n- **光影表现手法**：强调**\"弥散的点光源（Diffuse Point Lights）\"**。光线不是硬性的投射，而是呈晕染状。例如，路灯、车灯或月亮周围有一圈柔和的朦胧光晕（Glow effect）。地面通常带有微弱的雨后反光或湿润感，增加光影的层次感和梦幻感。`,\n\n\t\t\t\"pixel\": `**[专家角色定位]**\n你是一位资深的**8位/16位像素艺术家 (Pixel Art Consultant)**，擅长利用受限的分辨率和调色盘来构建具有极强代入感的虚拟世界，模拟早期电子游戏（如《星露谷物语》或经典RPG）的视觉美学。\n\n**[风格核心逻辑]**\n- **视觉流派与画面质感**：采用纯正的**像素艺术风格 (Pixel Art)**。画面由清晰可见的方格（Pixels）组成，强调**\"阶梯状线条 (Aliased lines)\"**。质感上完全摒弃平滑的渐变和模糊，追求一种数码化的、网格化的块状美感。\n- **色彩美学逻辑**：使用**\"受限调色盘 (Limited Color Palette)\"**。色彩选择极度精简，不追求自然的过渡，而是通过大面积的色块叠加。色彩逻辑的核心在于**\"抖动算法思维 (Dithering logic)\"**：通过不同颜色方格的交替排列来模拟明暗变化，色调通常饱和度中等，呈现出一种清爽、明快的电子游戏感。\n- **光影表现手法**：强调**\"色块式阴影 (Flat Shading)\"**。光影表现不使用羽化或软光，而是通过增加一层更深的同色系像素块来表示投影。光线通常是恒定的，没有复杂的反射或折射，太阳或光源本身也被处理成一个规则的像素圆点。`,\n\n\t\t\t\"voxel\": `**[专家角色定位]**\n你是一位顶尖的**3D体素建模师 (Voxel Artist)**，擅长利用统一规格的立方体单位构建充满童趣、模块化且具有高度秩序感的微缩世界。你的视觉风格强调**低多边形（Low-poly）的纯粹性**与**现代实时光影渲染**的结合。\n\n**[风格核心逻辑]**\n- **视觉流派与质感**：采用**三维体素风格 (3D Voxel Style)**。画面由无数等比例的立方体单元（Voxels）堆叠而成，呈现出一种强烈的模块化感。质感上具有明显的**\"方块化线条\"**，物体表面是平整的色块，这种简化的几何语言创造了一种独特的数字美感。\n- **色彩美学逻辑**：使用**\"自然饱和度与渐变光影\"**。色彩通常根据环境属性进行大块划分（如草地的绿、土地的褐），但关键在于**色彩的微小扰动 (Color Jitter)**：同一区域的方块颜色会有微妙的深浅差异，模拟真实环境的随机感。色调通常明亮、清新，充满活力感。\n- **光影表现手法**：强调**\"全局光照渲染 (Global Illumination)\"**。这是体素艺术升华的关键：尽管物体是方块状的，但光影必须是**电影级的写实渲染**。光线具有温暖的体积感（如耶稣光），阴影边缘柔和且带有环境遮蔽（AO）效果，方块边缘会被高亮勾勒，使画面看起来像是一个精致的现实微缩模型。`,\n\n\t\t\t\"urban\": `**[专家角色定位]**\n你是一位顶尖的**网漫主笔（Lead Webtoon Artist）**，擅长创作具有现代都市感的人物立绘。你的视觉风格强调**锐利的轮廓线**、**利落的穿搭逻辑**以及**冷色调的都市氛围**，旨在营造一种\"高冷、精致、工业化美感\"的视觉冲击。\n\n**[风格核心逻辑]**\n- **视觉流派与画面质感**：采用**现代韩漫数字绘图风格 (Modern Webtoon Art Style)**。画面具有极干净的**矢量线条 (Crisp line art)**，没有任何多余的笔触。质感上呈现出一种平滑的数字皮肤质感，强调色彩的整洁度，避免了复杂的笔触叠加。\n- **色彩美学逻辑**：使用**\"冷调都市灰（Muted Urban Tones）\"**。画面以黑、白、灰、深蓝等中性色为主色调。色彩逻辑的核心在于**\"高对比度的荧光色反差\"**：整体处于清冷的低饱和度环境下，但利用背景中的**霓虹灯（Neon glow）**或电子屏产生高亮的粉、蓝、紫偏色，营造出一种深夜都市的疏离感。\n- **光影表现手法**：强调**\"硬边赛璐珞阴影 (Hard Cel-shading)\"**。阴影边缘极其干脆，没有渐变。光影逻辑模仿**\"环境侧光\"**：光线通常来自侧方的霓虹招牌，在人物一侧留下窄长的亮边（Rim lighting），增强了人物的轮廓感和立体感。`,\n\n\t\t\t\"guoman3d\": `**[专家角色定位]**\n你是一位顶级**次世代游戏美术总监 (Lead Technical Artist)**，擅长使用虚幻引擎 5 (UE5) 创作高精度的 3D 仙侠角色。你的风格以**物理渲染 (PBR)** 的极高真实度、复杂的服饰层次感以及极具东方美学的全局光照处理著称。\n\n**[风格核心逻辑]**\n- **视觉流派与画面质感**：采用**高精细 3D 写实渲染风格 (High-fidelity 3D Rendering)**。画面具有极强的**次世代游戏质感 (Next-gen game aesthetic)**，强调皮肤的次表面散射 (SSS) 效果和极其真实的服饰纹理（如丝绸的平滑感、皮革的磨损感、金属的拉丝质感）。整体呈现出一种细腻的数码雕琢美，边缘锐利且细节丰富。\n- **色彩美学逻辑**：使用**\"素雅沉稳的中性色调 (Sophisticated Neutral Palette)\"**。不同于高饱和度的动漫风格，这种逻辑倾向于使用低饱和、高明度的色彩（如米白、石青、灰褐），并配以小面积的暗红色或金色作为高级感点缀。光影色彩通常偏向**清晨或傍晚的自然日光**，给人一种宁静、肃穆且大气的东方韵味。\n- **光影表现手法**：强调**\"电影级动态光影 (Cinematic Lighting)\"**。光源方向明确（通常是明亮的侧逆光），在人物边缘勾勒出一层淡淡的金边 (Rim Light)，将主体与背景完美分离。同时利用环境遮蔽 (AO) 增加细节深度，让服饰的每一个褶皱都清晰可见，呈现出一种沉浸式的戏剧张力。`,\n\n\t\t\t\"chibi3d\": `**[专家角色定位]**\n你是一位顶尖的 **3D 玩具设计师与灯光渲染师**，擅长创作高精细度的数字手办。你的视觉风格结合了 **Q 版二头身比例 (Chibi proportions)** 与 **超写实材质渲染 (PBR Rendering)**，旨在营造一种精致、可爱且具有高级触感的\"数字潮流玩具\"视觉效果。\n\n**[风格核心逻辑]**\n- **视觉流派与画面质感**：采用 **3D 盲盒艺术风格 (Blind Box / Toy Art Style)**。画面具有极强的 **类塑料与树脂质感 (Plastic and Resin texture)**，表面圆润、平滑，边缘带有微妙的倒角。主体呈现出明显的 **Q 版比例**（大头小身），增强了亲和力。\n- **色彩美学逻辑**：使用 **\"温和的高饱和调色盘 (Muted Vibrant Palette)\"**。色彩鲜艳但并不刺眼。色彩分布遵循\"主次分明\"原则，利用大面积的自然底色（如森林绿、泥土褐）衬托主体鲜明的服饰色彩。\n- **光影表现手法**：光源通常柔和且均匀。**顶光/面光**：均匀照亮主体正面，突出五官和服饰细节。**环境遮蔽 (Ambient Occlusion)**：在缝隙和接触面产生细腻的阴影，增强物体的重量感和真实感。`,\n\t\t},\n\t\t\"en\": {\n\t\t\t\"ghibli\": `**[Expert Role]**\nYou are a top Art Director and Background Artist from Studio Ghibli. You excel at capturing the balance between \"grand nature and microscopic life,\" and you possess a deep understanding of Hayao Miyazaki's color psychology.\n\n**[Core Style Logic]**\n- **Visual Genre & Texture**: Adopts the classic Ghibli style. The imagery features a rich **watercolor texture**, rejecting cold 3D rendering in favor of warm, \"breathing\" brushstrokes. Lines are clear yet delicate, presenting the vibrant feel of **cel-shading**.\n- **Color & Lighting Aesthetics**: Utilizes **\"High-key Color Aesthetics.\"** The palette is bright, transparent, and high-saturated but with soft hues. Lighting simulates the natural light of a \"summer afternoon,\" where light feels soaked into the air with excellent luminosity. Shadows contain subtle blue-purple tones to enhance the transparency of the frame.\n- **Atmospheric Intent**: Nostalgic, serene, **pastoral**, and breezy. The image should convey a sense of tranquility and a desire for exploration—a feeling that \"the world is still beautiful.\"`,\n\n\t\t\t\"guoman\": `**[Expert Role]**\nYou are a top-tier digital illustration artist, skilled at merging traditional Eastern charm with the magnificent Visual Effects (VFX) of modern game art. You are a master of \"Oriental Fantasy\" composition.\n\n**[Core Style Logic]**\n- **Visual Genre & Texture**: A fusion of **Modern Zen Illustration (New Guofeng)** and epic fantasy rendering. The texture is delicate with a silky feel, similar to high-precision 2D digital painting. It emphasizes volumetric lighting and includes a large amount of tiny particle effects and glowing atmospheres.\n- **Core Color & Luminous Aesthetics**: Employs **\"Contrasting Colors & Endogenous Lighting.\"** The main palette usually features intense collisions of cool and warm tones (e.g., indigo and golden orange). The core logic lies in **\"Local Luminescence\"**: dark areas are dotted with bioluminescent elements (like fluorescent plants, lanterns, or crystal textures), creating a strong sense of magic and mystery.\n- **Decorative Element Logic**: Emphasizes the **\"Flow of Lines.\"** The frame is filled with elegant curves, often composed of light trails, ribbons, or natural textures (like the flow of water), enhancing the overall decorativeness and rhythm.`,\n\n\t\t\t\"wasteland\": `**[Expert Role]**\nYou are a visual artist focused on \"Post-Apocalyptic Narrative,\" skilled at using **Hard Line-art** and a **retro print feel** to create epic, desolate atmospheres, heavily influenced by Moebius and modern wasteland sci-fi illustrations.\n\n**[Core Style Logic]**\n- **Visual Genre & Brushwork Texture**: Adopts a **Hard-edged Line Art** style. The image emphasizes bold black outlines with a strong comic illustration feel. The texture presents a **grainy, flat-print quality**, similar to old newspapers or retro posters, rejecting smooth gradients in favor of hatching or stippling for shadows.\n- **Color Aesthetic Logic**: Employs a **\"Limited Palette.\"** The frame is typically dominated by an oppressive, unified tone (e.g., dusty earth, rust orange, desert yellow). The core visual impact comes from a **single strong contrast point** (such as a massive red setting sun), a \"single-point highlight\" logic that instantly grabs attention against the gloomy background.\n- **Lighting Technique**: Uses **\"High-contrast Side Lighting.\"** Simulates the low-angle light of dusk or dawn, producing extremely long shadows. The lighting logic is highly simplified with sharp, distinct terminators, creating a dry, scorching, and silent dramatic tension.`,\n\n\t\t\t\"nostalgia\": `**[Expert Role]**\nYou are a visual artist specializing in the **\"Nostalgic Cel-shading\"** style, expert at simulating the texture of 1980s-90s hand-drawn animation. You use color and noise to create a gentle, emotional, and slightly melancholic urban atmosphere.\n\n**[Core Style Logic]**\n- **Visual Genre & Frame Texture**: Adopts the classic **90s Retro Anime Style**. The image features obvious **film grain** and slight **chromatic aberration**, simulating the playback quality of old TVs or VHS tapes. The texture emphasizes \"imperfect delicacy\"—lines are soft rather than sharp like modern vectors, giving a sense of handcrafted warmth.\n- **Color Aesthetic Logic**: Uses a **\"Muted Pastel Palette.\"** The frame is dominated by a soft, dreamlike twilight, usually featuring lavender, lotus pink, or grayish-blue. The core logic is the **\"Weakened Black Point\"**: there are no pure blacks; all dark colors lean toward purple or blue. This tone instantly outlines a lonely but cozy \"urban dusk\" feel.\n- **Lighting Technique**: Emphasizes **\"Diffuse Point Lights.\"** Light is not a hard projection but a bleeding glow. For example, streetlights, car headlights, or the moon have a soft, hazy halo (Glow effect). Surfaces often have a slight post-rain reflection or dampness, increasing the layers and dreaminess of the light.`,\n\n\t\t\t\"pixel\": `**[Expert Role]**\nYou are a senior **Pixel Art Consultant (8-bit/16-bit)**, skilled at using restricted resolutions and palettes to build highly immersive virtual worlds, simulating the aesthetics of early video games like *Stardew Valley* or classic RPGs.\n\n**[Core Style Logic]**\n- **Visual Genre & Frame Texture**: Adopts a pure **Pixel Art** style. The image consists of clearly visible squares (pixels), emphasizing **\"Aliased lines.\"** It completely discards smooth gradients and blurring, pursuing a digital, grid-based blocky beauty.\n- **Color Aesthetic Logic**: Uses a **\"Limited Color Palette.\"** Color choices are extremely streamlined, avoiding natural transitions in favor of large color block overlays. The core logic is **\"Dithering logic\"**: alternating pixel patterns of different colors to simulate shading. Tones are usually medium saturation, presenting a crisp, bright video game feel.\n- **Lighting Technique**: Emphasizes **\"Flat Shading.\"** Lighting does not use feathering or soft light; instead, it uses a layer of darker pixels from the same color family to represent shadows. Light sources are constant without complex reflections, and even the sun or light sources are treated as regular pixel circles.`,\n\n\t\t\t\"voxel\": `**[Expert Role]**\nYou are a top-tier **3D Voxel Artist**, skilled at using uniform cube units to build whimsical, modular, and highly ordered miniature worlds. Your style combines the purity of **Low-poly** geometry with modern real-time lighting rendering.\n\n**[Core Style Logic]**\n- **Visual Genre & Texture**: Adopts a **3D Voxel Style**. The image is composed of countless proportional cubes (voxels) stacked together, presenting a strong modular feel. The texture features obvious **\"blocky lines\"** and flat color surfaces; this simplified geometric language creates a unique digital aesthetic.\n- **Color Aesthetic Logic**: Uses **\"Natural Saturation & Gradient Lighting.\"** Colors are divided into large blocks based on environmental attributes (green for grass, brown for soil), but the key lies in **\"Color Jitter\"**: subtle shade variations between blocks in the same area to simulate the randomness of real environments. Tones are bright, fresh, and full of vitality.\n- **Lighting Technique**: Emphasizes **\"Global Illumination Rendering.\"** This is the key to elevating voxel art: while objects are blocky, the lighting must be **cinematic and realistic**. Light has warm volumetric qualities (e.g., God rays), shadows are soft with Ambient Occlusion (AO) effects, and voxel edges are highlighted, making the scene look like an exquisite real-life miniature model.`,\n\n\t\t\t\"urban\": `**[Expert Role]**\nYou are a leading **Webtoon Artist**, specializing in modern urban character illustrations. Your visual style emphasizes **sharp outlines**, **slick fashion logic**, and a **cool-toned urban atmosphere**, aiming to create a \"high-cold, sophisticated, industrial-chic\" visual impact.\n\n**[Core Style Logic]**\n- **Visual Genre & Frame Texture**: Adopts the **Modern Webtoon Art Style**. The image features extremely clean **crisp line art** (vector-like) without any redundant strokes. The texture presents a smooth digital skin quality, emphasizing color cleanliness and avoiding complex brushwork layering.\n- **Color Aesthetic Logic**: Uses **\"Muted Urban Tones.\"** The palette is dominated by neutral colors like black, white, gray, and deep blue. The core logic is **\"High-contrast Neon Accents\"**: while the overall environment is cool and low-saturation, highlights from **neon glows** or electronic screens (pink, blue, purple) create a sense of late-night urban detachment.\n- **Lighting Technique**: Emphasizes **\"Hard Cel-shading.\"** Shadow edges are extremely crisp with no gradients. The logic mimics **\"Environmental Rim Lighting\"**: light usually comes from side neon signs, leaving a narrow bright edge (Rim lighting) on one side of the character, enhancing their silhouette and 3D feel.`,\n\n\t\t\t\"guoman3d\": `**[Expert Role]**\nYou are a top-tier **Next-gen Lead Technical Artist**, skilled in using Unreal Engine 5 (UE5) to create high-precision 3D Xianxia (Immortal Hero) characters. Your style is known for high-fidelity **Physically Based Rendering (PBR)**, complex clothing layers, and global illumination with an Eastern aesthetic.\n\n**[Core Style Logic]**\n- **Visual Genre & Frame Texture**: Adopts a **High-fidelity 3D Rendering style**. The image has a strong **next-gen game aesthetic**, emphasizing Subsurface Scattering (SSS) for skin and realistic fabric textures (smoothness of silk, wear on leather, brushed metal). The overall look is a delicate digital sculpture with sharp edges and rich details.\n- **Color Aesthetic Logic**: Uses a **\"Sophisticated Neutral Palette.\"** Unlike high-saturation anime styles, this logic leans toward low-saturation, high-brightness colors (off-white, stone green, gray-brown), accented with small areas of dark red or gold for a premium feel. Lighting colors typically mimic **natural morning or evening sunlight**, giving an air of tranquility, solemnity, and grand Eastern charm.\n- **Lighting Technique**: Emphasizes **\"Cinematic Lighting.\"** Light directions are clear (usually bright side-backlighting), creating a faint golden **Rim Light** that perfectly separates the subject from the background. Ambient Occlusion (AO) is used to increase detail depth, making every fold in the clothing visible and creating immersive dramatic tension.`,\n\n\t\t\t\"chibi3d\": `**[Expert Role]**\nYou are a top-tier **3D Toy Designer and Rendering Artist**, specializing in high-precision digital figurines. Your visual style combines **Chibi proportions** with **Ultra-realistic PBR rendering**, aiming to create a sophisticated, cute, and tactile \"Art Toy\" visual effect.\n\n**[Core Style Logic]**\n- **Visual Genre & Frame Texture**: Adopts a **3D Blind Box / Toy Art Style**. The image features strong **plastic and resin textures**; surfaces are rounded and smooth with subtle beveled edges. The subject uses **Chibi proportions** (large head, small body) to enhance appeal.\n- **Color Aesthetic Logic**: Uses a **\"Muted Vibrant Palette.\"** Colors are vivid but not piercing. Color distribution follows a \"primary-secondary\" principle, using large areas of natural base colors (forest green, earth brown) to set off the bright colors of the character's outfit.\n- **Lighting Technique**: Light sources are typically soft and even: **Top/Key Light**: Evenly illuminates the subject's front, highlighting facial features and clothing details. **Ambient Occlusion (AO)**: Produces delicate shadows in crevices and contact points, enhancing the object's sense of weight and realism.`,\n\t\t},\n\t}\n\n\tlang := \"zh\"\n\tif p.IsEnglish() {\n\t\tlang = \"en\"\n\t}\n\n\tif prompts, ok := stylePrompts[lang]; ok {\n\t\tif prompt, exists := prompts[style]; exists {\n\t\t\treturn prompt\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\n// GetVideoConstraintPrompt 获取视频生成的约束提示词\n// referenceMode: \"single\" (单图), \"first_last\" (首尾帧), \"multiple\" (多图), \"action_sequence\" (动作序列)\nfunc (p *PromptI18n) GetVideoConstraintPrompt(referenceMode string) string {\n\t// 动作序列图（九宫格）的约束提示词\n\tactionSequencePrompts := map[string]string{\n\t\t\"zh\": `### 角色定义\n\n你是一个极高精度的视频生成专家，擅长将九宫格（3x3）序列图转化为具有电影质感的连贯视频。你的核心任务是解析图像中的时空逻辑，并严格遵守首尾帧约束。\n\n### 核心执行逻辑\n\n1. **首尾帧锚定：** 必须提取九宫格的第一格（左上角）作为视频的起始帧（Frame 0），提取第九格（右下角）作为视频的结束帧（Final Frame）。\n2. **序列插值（Interpolation）：** 九宫格的第 2 至 第 8 格定义了动作的关键路径。你需分析这些关键帧之间的逻辑位移、光影变化和物体形变。\n3. **一致性维护：** 确保角色特征（面部、服装）、场景细节、艺术风格在全视频中保持 100% 的时空稳定性。\n4. **动态补充：** 在九宫格定义的关键动作之间，自动补全流畅的过渡帧，确保视频动作频率自然（建议 24fps 或 30fps）。\n\n### 结构化约束指令\n\n* **输入解析：** 识别用户提供的场景描述词（Prompt）与九宫格参考图。\n* **动作矢量化：** 计算物体从 Grid 1 到 Grid 9 的运动矢量。如果九宫格展示的是缩放或平移，请在视频中还原精准的运镜。\n* **严禁幻觉：** 禁止引入九宫格和提示词中未提及的新元素或背景切换。`,\n\n\t\t\"en\": `### Role Definition\n\nYou are an ultra-high-precision video generation expert, specializing in transforming 9-grid (3x3) sequential images into coherent videos with cinematic quality. Your core task is to parse the spatiotemporal logic within the images and strictly adhere to first-and-last frame constraints.\n\n### Core Execution Logic\n\n1. **First-Last Frame Anchoring:** You must extract Grid 1 (top-left corner) as the video's starting frame (Frame 0) and Grid 9 (bottom-right corner) as the ending frame (Final Frame).\n2. **Sequence Interpolation:** Grids 2 through 8 define the key action path. You need to analyze the logical displacement, lighting changes, and object deformations between these keyframes.\n3. **Consistency Maintenance:** Ensure that character features (face, clothing), scene details, and artistic style maintain 100% spatiotemporal stability throughout the entire video.\n4. **Dynamic Supplementation:** Automatically fill in smooth transition frames between the keyframes defined by the 9-grid, ensuring natural video motion frequency (recommended 24fps or 30fps).\n\n### Structured Constraint Instructions\n\n* **Input Parsing:** Identify the scene description (Prompt) and 9-grid reference images provided by the user.\n* **Motion Vectorization:** Calculate the motion vectors of objects from Grid 1 to Grid 9. If the 9-grid shows scaling or panning, restore precise camera movements in the video.\n* **Hallucination Prohibition:** Do not introduce new elements or background switches not mentioned in the 9-grid and prompt.`,\n\t}\n\n\t// 通用约束提示词（单图、首尾帧、多图）\n\tgeneralPrompts := map[string]string{\n\t\t\"zh\": `### 角色定义\n\n你是一个顶级的视频动态分析师与合成专家。你能够仅凭一张静态图或一组起始/结束帧，精准识别画面中的物理属性、光影流向及潜在的运动趋势，生成符合物理定律的高质量视频。\n\n### 核心执行逻辑\n\n1. **模式识别：**\n* **单图模式（Single Image）：** 将输入图视为 Frame 0。分析画面中的\"张力点\"（如倾斜的身体、流动的液体、眼神的方向），并向该方向延续动作。\n* **双图模式（First & Last Frames）：** 严格锚定第一张图为起始，第二张图为终点。通过**语义插值算法**，计算两图之间所有元素的位移轨迹。\n\n2. **物理一致性（Physics Preservation）：**\n* **质量守恒：** 确保物体在运动过程中体积、密度和材质质感不发生突变。\n* **运动惯性：** 遵循经典力学，起步平稳，加速自然，停止时不应有生硬的切断感。\n\n3. **环境外推：** 自动补充主画面之外的背景延伸，确保运镜（Pan/Tilt/Zoom）时不会出现画面空洞或黑边。`,\n\n\t\t\"en\": `### Role Definition\n\nYou are a top-tier video dynamics analyst and synthesis expert. You can accurately identify physical properties, light flow, and potential motion trends in a static image or a set of start/end frames, generating high-quality videos that comply with physical laws.\n\n### Core Execution Logic\n\n1. **Mode Recognition:**\n* **Single Image Mode:** Treat the input image as Frame 0. Analyze \"tension points\" in the frame (such as tilted bodies, flowing liquids, eye direction) and extend the action in that direction.\n* **First & Last Frames Mode:** Strictly anchor the first image as the start and the second image as the endpoint. Use **semantic interpolation algorithms** to calculate the displacement trajectories of all elements between the two images.\n\n2. **Physics Preservation:**\n* **Mass Conservation:** Ensure that objects do not undergo sudden changes in volume, density, or material texture during motion.\n* **Motion Inertia:** Follow classical mechanics with smooth starts, natural acceleration, and no abrupt stops.\n\n3. **Environment Extrapolation:** Automatically supplement background extensions beyond the main frame to ensure no voids or black edges appear during camera movements (Pan/Tilt/Zoom).`,\n\t}\n\n\tlang := \"zh\"\n\tif p.IsEnglish() {\n\t\tlang = \"en\"\n\t}\n\n\t// 如果是动作序列模式，返回九宫格约束提示词\n\tif referenceMode == \"action_sequence\" {\n\t\tif prompt, ok := actionSequencePrompts[lang]; ok {\n\t\t\treturn prompt\n\t\t}\n\t}\n\n\t// 其他模式返回通用约束提示词\n\tif prompt, ok := generalPrompts[lang]; ok {\n\t\treturn prompt\n\t}\n\n\treturn \"\"\n}\n"
  },
  {
    "path": "application/services/prop_service.go",
    "content": "package services\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t// Added missing import\n\tmodels \"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/pkg/ai\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/utils\"\n\t\"gorm.io/gorm\"\n)\n\ntype PropService struct {\n\tdb                     *gorm.DB\n\taiService              *AIService\n\ttaskService            *TaskService\n\timageGenerationService *ImageGenerationService\n\tlog                    *logger.Logger\n\tconfig                 *config.Config\n\tpromptI18n             *PromptI18n\n}\n\nfunc NewPropService(db *gorm.DB, aiService *AIService, taskService *TaskService, imageGenerationService *ImageGenerationService, log *logger.Logger, cfg *config.Config) *PropService {\n\treturn &PropService{\n\t\tdb:                     db,\n\t\taiService:              aiService,\n\t\ttaskService:            taskService,\n\t\timageGenerationService: imageGenerationService,\n\t\tlog:                    log,\n\t\tconfig:                 cfg,\n\t\tpromptI18n:             NewPromptI18n(cfg),\n\t}\n}\n\n// ListProps 获取剧本的道具列表\nfunc (s *PropService) ListProps(dramaID uint) ([]models.Prop, error) {\n\tvar props []models.Prop\n\tif err := s.db.Where(\"drama_id = ?\", dramaID).Find(&props).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn props, nil\n}\n\n// CreateProp 创建道具\nfunc (s *PropService) CreateProp(prop *models.Prop) error {\n\treturn s.db.Create(prop).Error\n}\n\n// UpdateProp 更新道具\nfunc (s *PropService) UpdateProp(id uint, updates map[string]interface{}) error {\n\treturn s.db.Model(&models.Prop{}).Where(\"id = ?\", id).Updates(updates).Error\n}\n\n// DeleteProp 删除道具\nfunc (s *PropService) DeleteProp(id uint) error {\n\treturn s.db.Delete(&models.Prop{}, id).Error\n}\n\n// ExtractPropsFromScript 从剧本提取道具（异步）\nfunc (s *PropService) ExtractPropsFromScript(episodeID uint) (string, error) {\n\tvar episode models.Episode\n\tif err := s.db.First(&episode, episodeID).Error; err != nil {\n\t\treturn \"\", fmt.Errorf(\"episode not found: %w\", err)\n\t}\n\n\ttask, err := s.taskService.CreateTask(\"prop_extraction\", fmt.Sprintf(\"%d\", episodeID))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tgo s.processPropExtraction(task.ID, episode)\n\n\treturn task.ID, nil\n}\n\nfunc (s *PropService) processPropExtraction(taskID string, episode models.Episode) {\n\ts.taskService.UpdateTaskStatus(taskID, \"processing\", 0, \"正在分析剧本...\")\n\n\tscript := \"\"\n\tif episode.ScriptContent != nil {\n\t\tscript = *episode.ScriptContent\n\t}\n\n\t// 获取 drama 的 style 信息\n\tvar drama models.Drama\n\tif err := s.db.First(&drama, episode.DramaID).Error; err != nil {\n\t\ts.log.Warnw(\"Failed to load drama\", \"error\", err, \"drama_id\", episode.DramaID)\n\t}\n\n\tpromptTemplate := s.promptI18n.GetPropExtractionPrompt(drama.Style)\n\tprompt := fmt.Sprintf(promptTemplate, script)\n\n\tresponse, err := s.aiService.GenerateText(prompt, \"\", ai.WithMaxTokens(2000))\n\tif err != nil {\n\t\ts.taskService.UpdateTaskError(taskID, err)\n\t\treturn\n\t}\n\n\tvar extractedProps []struct {\n\t\tName        string `json:\"name\"`\n\t\tType        string `json:\"type\"`\n\t\tDescription string `json:\"description\"`\n\t\tImagePrompt string `json:\"image_prompt\"`\n\t}\n\n\tif err := utils.SafeParseAIJSON(response, &extractedProps); err != nil {\n\t\ts.taskService.UpdateTaskError(taskID, fmt.Errorf(\"解析AI结果失败: %w\", err))\n\t\treturn\n\t}\n\n\ts.taskService.UpdateTaskStatus(taskID, \"processing\", 50, \"正在保存道具...\")\n\n\tvar createdProps []models.Prop\n\tfor _, p := range extractedProps {\n\t\tprop := models.Prop{\n\t\t\tDramaID:     episode.DramaID,\n\t\t\tName:        p.Name,\n\t\t\tType:        &p.Type,\n\t\t\tDescription: &p.Description,\n\t\t\tPrompt:      &p.ImagePrompt,\n\t\t}\n\t\t// 检查是否已存在同名道具（避免重复）\n\t\tvar count int64\n\t\ts.db.Model(&models.Prop{}).Where(\"drama_id = ? AND name = ?\", episode.DramaID, p.Name).Count(&count)\n\t\tif count == 0 {\n\t\t\tif err := s.db.Create(&prop).Error; err == nil {\n\t\t\t\tcreatedProps = append(createdProps, prop)\n\t\t\t}\n\t\t}\n\t}\n\n\ts.taskService.UpdateTaskResult(taskID, createdProps)\n}\n\n// GeneratePropImage 生成道具图片\n// 这里可以复用 ImageGenerationService，或者直接调用 AI Service\n// 简单起见，这里直接调用 ImageGenerationService 如果可以，或者 AI Service.\n// 为了保持架构一致性，应该创建一个 ImageGeneration 记录，然后复用现有的图片生成流程？\n// 但为了简单快速实现，这里先写一个专用的方法，或者更好的方式是：\n// 创建一个 ImageGeneration 记录，类型设为 \"prop\"，然后复用 ImageGenerationService 的逻辑。\n// 但 ImageGenerationService 目前绑定了 Storyboard/Scene ID 等。\n// 所以这里实现一个简化的直接生成逻辑，或者扩展 ImageGenerationService。\n// 鉴于时间，我实现一个简化的直接生成并保存图片的方法。\n\nfunc (s *PropService) GeneratePropImage(propID uint) (string, error) {\n\t// 1. 获取道具信息\n\tvar prop models.Prop\n\tif err := s.db.First(&prop, propID).Error; err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif prop.Prompt == nil || *prop.Prompt == \"\" {\n\t\treturn \"\", fmt.Errorf(\"道具没有图片提示词\")\n\t}\n\n\t// 2. 创建任务\n\ttask, err := s.taskService.CreateTask(\"prop_image_generation\", fmt.Sprintf(\"%d\", propID))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tgo s.processPropImageGeneration(task.ID, prop)\n\treturn task.ID, nil\n}\n\nfunc (s *PropService) processPropImageGeneration(taskID string, prop models.Prop) {\n\ts.taskService.UpdateTaskStatus(taskID, \"processing\", 0, \"正在生成图片...\")\n\n\t// 准备生成参数\n\timageStyle := \"Modern Japanese anime style\"\n\timageSize := \"1024x1024\"\n\n\t// 创建生成请求\n\treq := &GenerateImageRequest{\n\t\tDramaID:   fmt.Sprintf(\"%d\", prop.DramaID),\n\t\tPropID:    &prop.ID,\n\t\tImageType: string(models.ImageTypeProp),\n\t\tPrompt:    *prop.Prompt,\n\t\tSize:      imageSize,\n\t\tStyle:     &imageStyle,\n\t\tProvider:  s.config.AI.DefaultImageProvider, // 使用默认配置\n\t}\n\n\t// 调用 ImageGenerationService\n\timageGen, err := s.imageGenerationService.GenerateImage(req)\n\tif err != nil {\n\t\ts.taskService.UpdateTaskError(taskID, err)\n\t\treturn\n\t}\n\n\t// 轮询 ImageGeneration 状态直到完成\n\tmaxAttempts := 60\n\tpollInterval := 2 * time.Second\n\n\tfor i := 0; i < maxAttempts; i++ {\n\t\ttime.Sleep(pollInterval)\n\n\t\t// 重新加载 imageGen\n\t\tvar currentImageGen models.ImageGeneration\n\t\tif err := s.db.First(&currentImageGen, imageGen.ID).Error; err != nil {\n\t\t\ts.log.Errorw(\"Failed to poll image generation\", \"error\", err, \"id\", imageGen.ID)\n\t\t\tcontinue\n\t\t}\n\n\t\tif currentImageGen.Status == models.ImageStatusCompleted {\n\t\t\tif currentImageGen.ImageURL != nil {\n\t\t\t\t// 任务成功\n\t\t\t\t// ImageGenerationService 已经更新了 Prop.ImageURL，这里只需要更新 TaskService\n\t\t\t\ts.taskService.UpdateTaskResult(taskID, map[string]string{\"image_url\": *currentImageGen.ImageURL})\n\t\t\t\treturn\n\t\t\t}\n\t\t} else if currentImageGen.Status == models.ImageStatusFailed {\n\t\t\terrMsg := \"图片生成失败\"\n\t\t\tif currentImageGen.ErrorMsg != nil {\n\t\t\t\terrMsg = *currentImageGen.ErrorMsg\n\t\t\t}\n\t\t\ts.taskService.UpdateTaskError(taskID, fmt.Errorf(errMsg))\n\t\t\treturn\n\t\t}\n\n\t\t// 更新进度（可选）\n\t\ts.taskService.UpdateTaskStatus(taskID, \"processing\", 10+i, \"正在生成图片...\")\n\t}\n\n\ts.taskService.UpdateTaskError(taskID, fmt.Errorf(\"生成超时\"))\n}\n\n// AssociatePropsWithStoryboard 关联道具到分镜\nfunc (s *PropService) AssociatePropsWithStoryboard(storyboardID uint, propIDs []uint) error {\n\tvar storyboard models.Storyboard\n\tif err := s.db.First(&storyboard, storyboardID).Error; err != nil {\n\t\treturn err\n\t}\n\n\tvar props []models.Prop\n\tif len(propIDs) > 0 {\n\t\tif err := s.db.Where(\"id IN ?\", propIDs).Find(&props).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn s.db.Model(&storyboard).Association(\"Props\").Replace(props)\n}\n"
  },
  {
    "path": "application/services/resource_transfer_service.go",
    "content": "package services\n\nimport (\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"gorm.io/gorm\"\n)\n\ntype ResourceTransferService struct {\n\tdb  *gorm.DB\n\tlog *logger.Logger\n}\n\nfunc NewResourceTransferService(db *gorm.DB, log *logger.Logger) *ResourceTransferService {\n\treturn &ResourceTransferService{\n\t\tdb:  db,\n\t\tlog: log,\n\t}\n}\n\n// ResourceTransferService 现在只保留基本结构，MinIO相关功能已移除\n// 如需资源转存功能，请使用本地存储\n"
  },
  {
    "path": "application/services/script_generation_service.go",
    "content": "package services\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/pkg/ai\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/utils\"\n\t\"gorm.io/gorm\"\n)\n\ntype ScriptGenerationService struct {\n\tdb          *gorm.DB\n\taiService   *AIService\n\tlog         *logger.Logger\n\tconfig      *config.Config\n\tpromptI18n  *PromptI18n\n\ttaskService *TaskService\n}\n\nfunc NewScriptGenerationService(db *gorm.DB, cfg *config.Config, log *logger.Logger) *ScriptGenerationService {\n\treturn &ScriptGenerationService{\n\t\tdb:          db,\n\t\taiService:   NewAIService(db, log),\n\t\tlog:         log,\n\t\tconfig:      cfg,\n\t\tpromptI18n:  NewPromptI18n(cfg),\n\t\ttaskService: NewTaskService(db, log),\n\t}\n}\n\ntype GenerateCharactersRequest struct {\n\tDramaID     string  `json:\"drama_id\" binding:\"required\"`\n\tEpisodeID   uint    `json:\"episode_id\"`\n\tOutline     string  `json:\"outline\"`\n\tCount       int     `json:\"count\"`\n\tTemperature float64 `json:\"temperature\"`\n\tModel       string  `json:\"model\"` // 指定使用的文本模型\n}\n\nfunc (s *ScriptGenerationService) GenerateCharacters(req *GenerateCharactersRequest) (string, error) {\n\tvar drama models.Drama\n\tif err := s.db.Where(\"id = ? \", req.DramaID).First(&drama).Error; err != nil {\n\t\treturn \"\", fmt.Errorf(\"drama not found\")\n\t}\n\n\t// 创建任务\n\ttask, err := s.taskService.CreateTask(\"character_generation\", req.DramaID)\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to create character generation task\", \"error\", err)\n\t\treturn \"\", fmt.Errorf(\"创建任务失败: %w\", err)\n\t}\n\n\t// 异步处理角色生成\n\tgo s.processCharacterGeneration(task.ID, req)\n\n\ts.log.Infow(\"Character generation task created\", \"task_id\", task.ID, \"drama_id\", req.DramaID)\n\treturn task.ID, nil\n}\n\n// processCharacterGeneration 异步处理角色生成\nfunc (s *ScriptGenerationService) processCharacterGeneration(taskID string, req *GenerateCharactersRequest) {\n\t// 更新任务状态为处理中\n\ts.taskService.UpdateTaskStatus(taskID, \"processing\", 0, \"正在生成角色...\")\n\n\tcount := req.Count\n\tif count == 0 {\n\t\tcount = 5\n\t}\n\n\t// 获取 drama 的 style 信息\n\tvar drama models.Drama\n\tif err := s.db.Where(\"id = ? \", req.DramaID).First(&drama).Error; err != nil {\n\t\ts.log.Errorw(\"Drama not found during character generation\", \"error\", err, \"drama_id\", req.DramaID)\n\t\ts.taskService.UpdateTaskStatus(taskID, \"failed\", 0, \"剧本信息不存在\")\n\t\treturn\n\t}\n\n\tsystemPrompt := s.promptI18n.GetCharacterExtractionPrompt(drama.Style)\n\n\toutlineText := req.Outline\n\tif outlineText == \"\" {\n\t\toutlineText = s.promptI18n.FormatUserPrompt(\"drama_info_template\", drama.Title, drama.Description, drama.Genre)\n\t}\n\n\tuserPrompt := s.promptI18n.FormatUserPrompt(\"character_request\", outlineText, count)\n\n\ttemperature := req.Temperature\n\tif temperature == 0 {\n\t\ttemperature = 0.7\n\t}\n\n\t// 如果指定了模型，使用指定的模型；否则使用默认配置\n\tvar text string\n\tvar err error\n\tif req.Model != \"\" {\n\t\ts.log.Infow(\"Using specified model for character generation\", \"model\", req.Model, \"task_id\", taskID)\n\t\tclient, getErr := s.aiService.GetAIClientForModel(\"text\", req.Model)\n\t\tif getErr != nil {\n\t\t\ts.log.Warnw(\"Failed to get client for specified model, using default\", \"model\", req.Model, \"error\", getErr, \"task_id\", taskID)\n\t\t\ttext, err = s.aiService.GenerateText(userPrompt, systemPrompt, ai.WithTemperature(temperature))\n\t\t} else {\n\t\t\ttext, err = client.GenerateText(userPrompt, systemPrompt, ai.WithTemperature(temperature))\n\t\t}\n\t} else {\n\t\ttext, err = s.aiService.GenerateText(userPrompt, systemPrompt, ai.WithTemperature(temperature))\n\t}\n\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to generate characters\", \"error\", err, \"task_id\", taskID)\n\t\ts.taskService.UpdateTaskStatus(taskID, \"failed\", 0, \"AI生成失败: \"+err.Error())\n\t\treturn\n\t}\n\n\ts.log.Infow(\"AI response received for character generation\", \"length\", len(text), \"preview\", text[:minInt(200, len(text))], \"task_id\", taskID)\n\n\t// AI直接返回数组格式\n\tvar result []struct {\n\t\tName        string `json:\"name\"`\n\t\tRole        string `json:\"role\"`\n\t\tDescription string `json:\"description\"`\n\t\tPersonality string `json:\"personality\"`\n\t\tAppearance  string `json:\"appearance\"`\n\t\tVoiceStyle  string `json:\"voice_style\"`\n\t}\n\n\tif err := utils.SafeParseAIJSON(text, &result); err != nil {\n\t\ts.log.Errorw(\"Failed to parse characters JSON\", \"error\", err, \"raw_response\", text[:minInt(500, len(text))], \"task_id\", taskID)\n\t\ts.taskService.UpdateTaskStatus(taskID, \"failed\", 0, \"解析AI返回结果失败\")\n\t\treturn\n\t}\n\n\tvar characters []models.Character\n\tfor _, char := range result {\n\t\t// 检查角色是否已存在\n\t\tvar existingChar models.Character\n\t\terr := s.db.Where(\"drama_id = ? AND name = ?\", req.DramaID, char.Name).First(&existingChar).Error\n\t\tif err == nil {\n\t\t\t// 角色已存在，直接使用已存在的角色，不覆盖\n\t\t\ts.log.Infow(\"Character already exists, skipping\", \"drama_id\", req.DramaID, \"name\", char.Name, \"task_id\", taskID)\n\t\t\tcharacters = append(characters, existingChar)\n\t\t\tcontinue\n\t\t}\n\n\t\t// 角色不存在，创建新角色\n\t\tdramaID, _ := strconv.ParseUint(req.DramaID, 10, 32)\n\t\tcharacter := models.Character{\n\t\t\tDramaID:     uint(dramaID),\n\t\t\tName:        char.Name,\n\t\t\tRole:        &char.Role,\n\t\t\tDescription: &char.Description,\n\t\t\tPersonality: &char.Personality,\n\t\t\tAppearance:  &char.Appearance,\n\t\t\tVoiceStyle:  &char.VoiceStyle,\n\t\t}\n\n\t\tif err := s.db.Create(&character).Error; err != nil {\n\t\t\ts.log.Errorw(\"Failed to create character\", \"error\", err, \"task_id\", taskID)\n\t\t\tcontinue\n\t\t}\n\n\t\tcharacters = append(characters, character)\n\t}\n\n\t// 如果提供了 EpisodeID，建立 episode_characters 关联关系\n\tif req.EpisodeID > 0 {\n\t\tvar episode models.Episode\n\t\tif err := s.db.First(&episode, req.EpisodeID).Error; err == nil {\n\t\t\t// 使用 GORM 的 Association 建立多对多关联\n\t\t\tif err := s.db.Model(&episode).Association(\"Characters\").Append(characters); err != nil {\n\t\t\t\ts.log.Errorw(\"Failed to associate characters with episode\", \"error\", err, \"episode_id\", req.EpisodeID, \"task_id\", taskID)\n\t\t\t} else {\n\t\t\t\ts.log.Infow(\"Characters associated with episode\", \"episode_id\", req.EpisodeID, \"character_count\", len(characters), \"task_id\", taskID)\n\t\t\t}\n\t\t} else {\n\t\t\ts.log.Errorw(\"Episode not found for association\", \"episode_id\", req.EpisodeID, \"error\", err, \"task_id\", taskID)\n\t\t}\n\t}\n\n\t// 更新任务状态为完成\n\tresultData := map[string]interface{}{\n\t\t\"characters\": characters,\n\t\t\"count\":      len(characters),\n\t}\n\ts.taskService.UpdateTaskResult(taskID, resultData)\n\n\ts.log.Infow(\"Character generation completed\", \"task_id\", taskID, \"drama_id\", req.DramaID, \"character_count\", len(characters))\n}\n\n// GenerateScenesForEpisode 已废弃，使用 StoryboardService.GenerateStoryboard 替代\n// ParseScript 已废弃，使用 GenerateCharacters 替代\n\n// minInt 返回两个整数中较小的一个\nfunc minInt(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}\n"
  },
  {
    "path": "application/services/storyboard_composition_service.go",
    "content": "package services\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\n\tmodels \"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"gorm.io/gorm\"\n)\n\ntype StoryboardCompositionService struct {\n\tdb       *gorm.DB\n\tlog      *logger.Logger\n\timageGen *ImageGenerationService\n}\n\nfunc NewStoryboardCompositionService(db *gorm.DB, log *logger.Logger, imageGen *ImageGenerationService) *StoryboardCompositionService {\n\treturn &StoryboardCompositionService{\n\t\tdb:       db,\n\t\tlog:      log,\n\t\timageGen: imageGen,\n\t}\n}\n\ntype SceneCharacterInfo struct {\n\tID        uint    `json:\"id\"`\n\tName      string  `json:\"name\"`\n\tImageURL  *string `json:\"image_url,omitempty\"`\n\tLocalPath *string `json:\"local_path,omitempty\"`\n}\n\ntype SceneBackgroundInfo struct {\n\tID        uint    `json:\"id\"`\n\tLocation  string  `json:\"location\"`\n\tTime      string  `json:\"time\"`\n\tImageURL  *string `json:\"image_url,omitempty\"`\n\tLocalPath *string `json:\"local_path,omitempty\"`\n\tStatus    string  `json:\"status\"`\n}\n\ntype SceneCompositionInfo struct {\n\tID                    uint                 `json:\"id\"`\n\tStoryboardNumber      int                  `json:\"storyboard_number\"`\n\tTitle                 *string              `json:\"title\"`\n\tDescription           *string              `json:\"description\"`\n\tShotType              *string              `json:\"shot_type\"`\n\tAngle                 *string              `json:\"angle\"`\n\tMovement              *string              `json:\"movement\"`\n\tLocation              *string              `json:\"location\"`\n\tTime                  *string              `json:\"time\"`\n\tDuration              int                  `json:\"duration\"`\n\tDialogue              *string              `json:\"dialogue\"`\n\tAction                *string              `json:\"action\"`\n\tResult                *string              `json:\"result\"`\n\tAtmosphere            *string              `json:\"atmosphere\"`\n\tBgmPrompt             *string              `json:\"bgm_prompt,omitempty\"`\n\tSoundEffect           *string              `json:\"sound_effect,omitempty\"`\n\tImagePrompt           *string              `json:\"image_prompt,omitempty\"`\n\tVideoPrompt           *string              `json:\"video_prompt,omitempty\"`\n\tCharacters            []SceneCharacterInfo `json:\"characters\"`\n\tBackground            *SceneBackgroundInfo `json:\"background\"`\n\tSceneID               *uint                `json:\"scene_id\"`\n\tComposedImage         *string              `json:\"composed_image,omitempty\"`\n\tVideoURL              *string              `json:\"video_url,omitempty\"`\n\tImageGenerationID     *uint                `json:\"image_generation_id,omitempty\"`\n\tImageGenerationStatus *string              `json:\"image_generation_status,omitempty\"`\n\tVideoGenerationID     *uint                `json:\"video_generation_id,omitempty\"`\n\tVideoGenerationStatus *string              `json:\"video_generation_status,omitempty\"`\n}\n\nfunc (s *StoryboardCompositionService) GetScenesForEpisode(episodeID string) ([]SceneCompositionInfo, error) {\n\t// 验证权限\n\tvar episode models.Episode\n\terr := s.db.Preload(\"Drama\").Where(\"id = ?\", episodeID).First(&episode).Error\n\tif err != nil {\n\t\ts.log.Errorw(\"Episode not found\", \"episode_id\", episodeID, \"error\", err)\n\t\treturn nil, fmt.Errorf(\"episode not found\")\n\t}\n\n\ts.log.Infow(\"GetScenesForEpisode auth check\",\n\t\t\"episode_id\", episodeID,\n\t\t\"drama_id\", episode.DramaID)\n\n\t// 获取分镜列表\n\tvar storyboards []models.Storyboard\n\tif err := s.db.Where(\"episode_id = ?\", episodeID).\n\t\tPreload(\"Characters\").\n\t\tOrder(\"storyboard_number ASC\").\n\t\tFind(&storyboards).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to load storyboards: %w\", err)\n\t}\n\n\t// 获取所有角色（用于匹配角色信息）\n\tvar characters []models.Character\n\tif err := s.db.Where(\"drama_id = ?\", episode.DramaID).Find(&characters).Error; err != nil {\n\t\ts.log.Warnw(\"Failed to load characters\", \"error\", err)\n\t}\n\n\t// 创建角色ID到角色信息的映射\n\tcharIDToInfo := make(map[uint]*models.Character)\n\tfor i := range characters {\n\t\tcharIDToInfo[characters[i].ID] = &characters[i]\n\t}\n\n\t// 获取所有场景ID\n\tvar sceneIDs []uint\n\tfor _, storyboard := range storyboards {\n\t\tif storyboard.SceneID != nil {\n\t\t\tsceneIDs = append(sceneIDs, *storyboard.SceneID)\n\t\t}\n\t}\n\n\t// 批量获取场景信息\n\tvar scenes []models.Scene\n\tsceneMap := make(map[uint]*models.Scene)\n\tif len(sceneIDs) > 0 {\n\t\tif err := s.db.Where(\"id IN ?\", sceneIDs).Find(&scenes).Error; err == nil {\n\t\t\tfor i := range scenes {\n\t\t\t\tsceneMap[scenes[i].ID] = &scenes[i]\n\t\t\t}\n\t\t}\n\t}\n\n\t// 获取分镜的合成图片（从 image_generations 表）\n\tstoryboardIDs := make([]uint, len(storyboards))\n\tfor i, storyboard := range storyboards {\n\t\tstoryboardIDs[i] = storyboard.ID\n\t}\n\n\timageGenMap := make(map[uint]string)                      // storyboard_id -> image_url\n\timageGenTaskMap := make(map[uint]*models.ImageGeneration) // storyboard_id -> processing task\n\tif len(storyboardIDs) > 0 {\n\t\tvar imageGens []models.ImageGeneration\n\t\t// 查询已完成的图片生成记录，每个镜头只取最新的一条\n\t\tif err := s.db.Where(\"storyboard_id IN ? AND status = ?\", storyboardIDs, models.ImageStatusCompleted).\n\t\t\tOrder(\"created_at DESC\").\n\t\t\tFind(&imageGens).Error; err == nil {\n\t\t\t// 为每个镜头保留最新的一条记录\n\t\t\tfor _, ig := range imageGens {\n\t\t\t\tif ig.StoryboardID != nil {\n\t\t\t\t\tif _, exists := imageGenMap[*ig.StoryboardID]; !exists {\n\t\t\t\t\t\tif ig.ImageURL != nil {\n\t\t\t\t\t\t\timageGenMap[*ig.StoryboardID] = *ig.ImageURL\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 查询进行中的图片生成任务\n\t\tvar processingImageGens []models.ImageGeneration\n\t\tif err := s.db.Where(\"storyboard_id IN ? AND status = ?\", storyboardIDs, models.ImageStatusProcessing).\n\t\t\tOrder(\"created_at DESC\").\n\t\t\tFind(&processingImageGens).Error; err == nil {\n\t\t\tfor _, ig := range processingImageGens {\n\t\t\t\tif ig.StoryboardID != nil {\n\t\t\t\t\tif _, exists := imageGenTaskMap[*ig.StoryboardID]; !exists {\n\t\t\t\t\t\tigCopy := ig\n\t\t\t\t\t\timageGenTaskMap[*ig.StoryboardID] = &igCopy\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 批量查询进行中的视频生成任务\n\tvideoGenTaskMap := make(map[uint]*models.VideoGeneration) // storyboard_id -> processing task\n\tif len(storyboardIDs) > 0 {\n\t\tvar processingVideoGens []models.VideoGeneration\n\t\tif err := s.db.Where(\"scene_id IN ? AND status = ?\", storyboardIDs, models.VideoStatusProcessing).\n\t\t\tOrder(\"created_at DESC\").\n\t\t\tFind(&processingVideoGens).Error; err == nil {\n\t\t\tfor _, vg := range processingVideoGens {\n\t\t\t\tif vg.StoryboardID != nil {\n\t\t\t\t\tif _, exists := videoGenTaskMap[*vg.StoryboardID]; !exists {\n\t\t\t\t\t\tvgCopy := vg\n\t\t\t\t\t\tvideoGenTaskMap[*vg.StoryboardID] = &vgCopy\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 构建返回结果\n\tvar result []SceneCompositionInfo\n\tfor _, storyboard := range storyboards {\n\t\tstoryboardInfo := SceneCompositionInfo{\n\t\t\tID:               storyboard.ID,\n\t\t\tStoryboardNumber: storyboard.StoryboardNumber,\n\t\t\tTitle:            storyboard.Title,\n\t\t\tDescription:      storyboard.Description,\n\t\t\tShotType:         storyboard.ShotType,\n\t\t\tAngle:            storyboard.Angle,\n\t\t\tMovement:         storyboard.Movement,\n\t\t\tLocation:         storyboard.Location,\n\t\t\tTime:             storyboard.Time,\n\t\t\tDuration:         storyboard.Duration,\n\t\t\tAction:           storyboard.Action,\n\t\t\tDialogue:         storyboard.Dialogue,\n\t\t\tResult:           storyboard.Result,\n\t\t\tAtmosphere:       storyboard.Atmosphere,\n\t\t\tBgmPrompt:        storyboard.BgmPrompt,\n\t\t\tSoundEffect:      storyboard.SoundEffect,\n\t\t\tImagePrompt:      storyboard.ImagePrompt,\n\t\t\tVideoPrompt:      storyboard.VideoPrompt,\n\t\t\tSceneID:          storyboard.SceneID,\n\t\t}\n\n\t\t// 直接使用关联的角色信息\n\t\tif len(storyboard.Characters) > 0 {\n\t\t\tfor _, char := range storyboard.Characters {\n\t\t\t\tstoryboardChar := SceneCharacterInfo{\n\t\t\t\t\tID:        char.ID,\n\t\t\t\t\tName:      char.Name,\n\t\t\t\t\tImageURL:  char.ImageURL,\n\t\t\t\t\tLocalPath: char.LocalPath,\n\t\t\t\t}\n\t\t\t\tstoryboardInfo.Characters = append(storyboardInfo.Characters, storyboardChar)\n\t\t\t}\n\t\t}\n\n\t\t// 添加场景信息\n\t\tif storyboard.SceneID != nil {\n\t\t\tif scene, ok := sceneMap[*storyboard.SceneID]; ok {\n\t\t\t\tstoryboardInfo.Background = &SceneBackgroundInfo{\n\t\t\t\t\tID:        scene.ID,\n\t\t\t\t\tLocation:  scene.Location,\n\t\t\t\t\tTime:      scene.Time,\n\t\t\t\t\tImageURL:  scene.ImageURL,\n\t\t\t\t\tLocalPath: scene.LocalPath,\n\t\t\t\t\tStatus:    scene.Status,\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 添加合成图片\n\t\tif imageURL, ok := imageGenMap[storyboard.ID]; ok {\n\t\t\tstoryboardInfo.ComposedImage = &imageURL\n\t\t}\n\n\t\t// 添加视频URL\n\t\tif storyboard.VideoURL != nil {\n\t\t\tstoryboardInfo.VideoURL = storyboard.VideoURL\n\t\t}\n\n\t\t// 添加进行中的图片生成任务信息\n\t\tif imageTask, ok := imageGenTaskMap[storyboard.ID]; ok {\n\t\t\tstoryboardInfo.ImageGenerationID = &imageTask.ID\n\t\t\tstatusStr := string(imageTask.Status)\n\t\t\tstoryboardInfo.ImageGenerationStatus = &statusStr\n\t\t}\n\n\t\t// 添加进行中的视频生成任务信息\n\t\tif videoTask, ok := videoGenTaskMap[storyboard.ID]; ok {\n\t\t\tstoryboardInfo.VideoGenerationID = &videoTask.ID\n\t\t\tstatusStr := string(videoTask.Status)\n\t\t\tstoryboardInfo.VideoGenerationStatus = &statusStr\n\t\t}\n\n\t\tresult = append(result, storyboardInfo)\n\t}\n\n\treturn result, nil\n}\n\ntype UpdateSceneRequest struct {\n\tSceneID     *uint   `json:\"scene_id\"`\n\tCharacters  []uint  `json:\"characters\"` // 改为存储角色ID数组\n\tLocation    *string `json:\"location\"`\n\tTime        *string `json:\"time\"`\n\tAction      *string `json:\"action\"`\n\tDialogue    *string `json:\"dialogue\"`\n\tDescription *string `json:\"description\"`\n\tDuration    *int    `json:\"duration\"`\n\tImageURL    *string `json:\"image_url\"`\n\tLocalPath   *string `json:\"local_path\"`\n\tImagePrompt *string `json:\"image_prompt\"`\n\tVideoPrompt *string `json:\"video_prompt\"`\n}\n\nfunc (s *StoryboardCompositionService) UpdateScene(sceneID string, req *UpdateSceneRequest) error {\n\t// 获取分镜并验证权限\n\tvar storyboard models.Storyboard\n\terr := s.db.Preload(\"Episode.Drama\").Where(\"id = ?\", sceneID).First(&storyboard).Error\n\tif err != nil {\n\t\treturn fmt.Errorf(\"scene not found\")\n\t}\n\n\t// 构建更新数据\n\tupdates := make(map[string]interface{})\n\n\t// 更新背景ID\n\tif req.SceneID != nil {\n\t\tupdates[\"scene_id\"] = req.SceneID\n\t}\n\n\t// 更新角色列表（直接存储ID数组）\n\tif req.Characters != nil {\n\t\tcharactersJSON, err := json.Marshal(req.Characters)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to serialize characters: %w\", err)\n\t\t}\n\t\tupdates[\"characters\"] = charactersJSON\n\t}\n\n\t// 更新场景信息字段\n\tif req.Location != nil {\n\t\tupdates[\"location\"] = req.Location\n\t}\n\tif req.Time != nil {\n\t\tupdates[\"time\"] = req.Time\n\t}\n\tif req.Action != nil {\n\t\tupdates[\"action\"] = req.Action\n\t}\n\tif req.Dialogue != nil {\n\t\tupdates[\"dialogue\"] = req.Dialogue\n\t}\n\tif req.Description != nil {\n\t\tupdates[\"description\"] = req.Description\n\t}\n\tif req.Duration != nil {\n\t\tupdates[\"duration\"] = *req.Duration\n\t}\n\tif req.ImageURL != nil {\n\t\tupdates[\"image_url\"] = req.ImageURL\n\t}\n\tif req.LocalPath != nil {\n\t\tupdates[\"local_path\"] = req.LocalPath\n\t}\n\tif req.ImagePrompt != nil {\n\t\tupdates[\"image_prompt\"] = req.ImagePrompt\n\t}\n\tif req.VideoPrompt != nil {\n\t\tupdates[\"video_prompt\"] = req.VideoPrompt\n\t}\n\n\t// 执行更新\n\tif len(updates) > 0 {\n\t\tif err := s.db.Model(&models.Storyboard{}).Where(\"id = ?\", sceneID).Updates(updates).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"failed to update scene: %w\", err)\n\t\t}\n\t}\n\n\ts.log.Infow(\"Scene updated\", \"scene_id\", sceneID, \"updates\", updates)\n\treturn nil\n}\n\ntype GenerateSceneImageRequest struct {\n\tSceneID uint   `json:\"scene_id\"`\n\tPrompt  string `json:\"prompt\"`\n\tModel   string `json:\"model\"`\n}\n\nfunc (s *StoryboardCompositionService) GenerateSceneImage(req *GenerateSceneImageRequest) (*models.ImageGeneration, error) {\n\t// 获取场景并验证权限\n\tvar scene models.Scene\n\terr := s.db.Where(\"id = ?\", req.SceneID).First(&scene).Error\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"scene not found\")\n\t}\n\n\t// 验证权限：通过DramaID查询Drama\n\tvar drama models.Drama\n\tif err := s.db.Where(\"id = ? \", scene.DramaID).First(&drama).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"unauthorized\")\n\t}\n\n\t// 构建场景图片生成提示词\n\tprompt := req.Prompt\n\tif prompt == \"\" {\n\t\t// 使用场景的Prompt字段\n\t\tprompt = scene.Prompt\n\t\tif prompt == \"\" {\n\t\t\t// 如果Prompt为空，使用Location和Time构建\n\t\t\tprompt = fmt.Sprintf(\"%s场景，%s\", scene.Location, scene.Time)\n\t\t}\n\t\ts.log.Infow(\"Using scene prompt\", \"scene_id\", req.SceneID, \"prompt\", prompt)\n\t}\n\n\t// 使用imageGen服务直接生成\n\tif s.imageGen != nil {\n\t\tgenReq := &GenerateImageRequest{\n\t\t\tSceneID:   &req.SceneID,\n\t\t\tDramaID:   fmt.Sprintf(\"%d\", scene.DramaID),\n\t\t\tImageType: string(models.ImageTypeScene),\n\t\t\tPrompt:    prompt,\n\t\t\tModel:     req.Model,   // 使用用户指定的模型\n\t\t\tSize:      \"2560x1440\", // 3,686,400像素，满足doubao模型最低要求（16:9比例）\n\t\t\tQuality:   \"standard\",\n\t\t}\n\t\timageGen, err := s.imageGen.GenerateImage(genReq)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to generate image: %w\", err)\n\t\t}\n\n\t\t// 更新场景的image_url\n\t\tif imageGen.ImageURL != nil {\n\t\t\tscene.ImageURL = imageGen.ImageURL\n\t\t\tscene.Status = \"generated\"\n\t\t\tif err := s.db.Save(&scene).Error; err != nil {\n\t\t\t\ts.log.Errorw(\"Failed to update scene image url\", \"error\", err)\n\t\t\t}\n\t\t}\n\n\t\ts.log.Infow(\"Scene image generation created\", \"scene_id\", req.SceneID, \"image_gen_id\", imageGen.ID)\n\t\treturn imageGen, nil\n\t}\n\n\treturn nil, fmt.Errorf(\"image generation service not available\")\n}\n\ntype UpdateScenePromptRequest struct {\n\tPrompt string `json:\"prompt\"`\n}\n\nfunc (s *StoryboardCompositionService) UpdateScenePrompt(sceneID string, req *UpdateScenePromptRequest) error {\n\tvar scene models.Scene\n\tif err := s.db.Where(\"id = ?\", sceneID).First(&scene).Error; err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\treturn fmt.Errorf(\"scene not found\")\n\t\t}\n\t\treturn fmt.Errorf(\"failed to find scene: %w\", err)\n\t}\n\n\tscene.Prompt = req.Prompt\n\tif err := s.db.Save(&scene).Error; err != nil {\n\t\treturn fmt.Errorf(\"failed to update scene prompt: %w\", err)\n\t}\n\n\ts.log.Infow(\"Scene prompt updated\", \"scene_id\", sceneID, \"prompt\", req.Prompt)\n\treturn nil\n}\n\ntype UpdateSceneInfoRequest struct {\n\tLocation    *string `json:\"location\"`\n\tTime        *string `json:\"time\"`\n\tPrompt      *string `json:\"prompt\"`\n\tDescription *string `json:\"description\"`\n\tImageURL    *string `json:\"image_url\"`\n\tLocalPath   *string `json:\"local_path\"`\n}\n\nfunc (s *StoryboardCompositionService) UpdateSceneInfo(sceneID string, req *UpdateSceneInfoRequest) error {\n\tvar scene models.Scene\n\tif err := s.db.Where(\"id = ?\", sceneID).First(&scene).Error; err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\treturn fmt.Errorf(\"scene not found\")\n\t\t}\n\t\treturn fmt.Errorf(\"failed to find scene: %w\", err)\n\t}\n\n\tupdates := make(map[string]interface{})\n\tif req.Location != nil {\n\t\tupdates[\"location\"] = *req.Location\n\t}\n\tif req.Time != nil {\n\t\tupdates[\"time\"] = *req.Time\n\t}\n\tif req.Prompt != nil {\n\t\tupdates[\"prompt\"] = *req.Prompt\n\t}\n\tif req.Description != nil {\n\t\tupdates[\"description\"] = *req.Description\n\t}\n\tif req.ImageURL != nil {\n\t\tupdates[\"image_url\"] = *req.ImageURL\n\t}\n\tif req.LocalPath != nil {\n\t\tupdates[\"local_path\"] = *req.LocalPath\n\t}\n\n\tif len(updates) > 0 {\n\t\tif err := s.db.Model(&scene).Updates(updates).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"failed to update scene: %w\", err)\n\t\t}\n\t}\n\n\ts.log.Infow(\"Scene info updated\", \"scene_id\", sceneID, \"updates\", updates)\n\treturn nil\n}\n\nfunc (s *StoryboardCompositionService) DeleteScene(sceneID string) error {\n\tvar scene models.Scene\n\tif err := s.db.Where(\"id = ?\", sceneID).First(&scene).Error; err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\treturn fmt.Errorf(\"scene not found\")\n\t\t}\n\t\treturn fmt.Errorf(\"failed to find scene: %w\", err)\n\t}\n\n\t// 删除场景\n\tif err := s.db.Delete(&scene).Error; err != nil {\n\t\treturn fmt.Errorf(\"failed to delete scene: %w\", err)\n\t}\n\n\ts.log.Infow(\"Scene deleted successfully\", \"scene_id\", sceneID)\n\treturn nil\n}\n\nfunc getStringValue(s *string) string {\n\tif s != nil {\n\t\treturn *s\n\t}\n\treturn \"\"\n}\n\ntype CreateSceneRequest struct {\n\tDramaID     uint   `json:\"drama_id\"`\n\tEpisodeID   *uint  `json:\"episode_id\"` // 添加章节ID字段\n\tLocation    string `json:\"location\"`\n\tTime        string `json:\"time\"`\n\tPrompt      string `json:\"prompt\"`\n\tImageURL    string `json:\"image_url\"`\n\tLocalPath   string `json:\"local_path\"`\n\tDescription string `json:\"description\"`\n}\n\nfunc (s *StoryboardCompositionService) CreateScene(req *CreateSceneRequest) (*models.Scene, error) {\n\tscene := &models.Scene{\n\t\tDramaID:   req.DramaID,\n\t\tEpisodeID: req.EpisodeID, // 设置章节ID\n\t\tLocation:  req.Location,\n\t\tTime:      req.Time,\n\t\tPrompt:    req.Prompt,\n\t\tStatus:    \"draft\",\n\t}\n\n\tif req.ImageURL != \"\" {\n\t\tscene.ImageURL = &req.ImageURL\n\t\tscene.Status = \"completed\"\n\t}\n\tif req.LocalPath != \"\" {\n\t\tscene.LocalPath = &req.LocalPath\n\t}\n\n\tif err := s.db.Create(scene).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create scene: %w\", err)\n\t}\n\n\ts.log.Infow(\"Scene created successfully\", \"scene_id\", scene.ID, \"drama_id\", scene.DramaID, \"episode_id\", req.EpisodeID)\n\treturn scene, nil\n}\n"
  },
  {
    "path": "application/services/storyboard_service.go",
    "content": "package services\n\nimport (\n\t\"strconv\"\n\n\t\"fmt\"\n\t\"strings\"\n\n\tmodels \"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/pkg/ai\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/utils\"\n\t\"github.com/gin-gonic/gin\"\n\t\"gorm.io/gorm\"\n)\n\ntype StoryboardService struct {\n\tdb          *gorm.DB\n\taiService   *AIService\n\ttaskService *TaskService\n\tlog         *logger.Logger\n\tconfig      *config.Config\n\tpromptI18n  *PromptI18n\n}\n\nfunc NewStoryboardService(db *gorm.DB, cfg *config.Config, log *logger.Logger) *StoryboardService {\n\treturn &StoryboardService{\n\t\tdb:          db,\n\t\taiService:   NewAIService(db, log),\n\t\ttaskService: NewTaskService(db, log),\n\t\tlog:         log,\n\t\tconfig:      cfg,\n\t\tpromptI18n:  NewPromptI18n(cfg),\n\t}\n}\n\ntype Storyboard struct {\n\tShotNumber  int    `json:\"shot_number\"`\n\tTitle       string `json:\"title\"`        // 镜头标题\n\tShotType    string `json:\"shot_type\"`    // 景别\n\tAngle       string `json:\"angle\"`        // 镜头角度\n\tTime        string `json:\"time\"`         // 时间\n\tLocation    string `json:\"location\"`     // 地点\n\tSceneID     *uint  `json:\"scene_id\"`     // 背景ID（AI直接返回，可为null）\n\tMovement    string `json:\"movement\"`     // 运镜\n\tAction      string `json:\"action\"`       // 动作\n\tDialogue    string `json:\"dialogue\"`     // 对话/独白\n\tResult      string `json:\"result\"`       // 画面结果\n\tAtmosphere  string `json:\"atmosphere\"`   // 环境氛围\n\tEmotion     string `json:\"emotion\"`      // 情绪\n\tDuration    int    `json:\"duration\"`     // 时长（秒）\n\tBgmPrompt   string `json:\"bgm_prompt\"`   // 配乐提示词\n\tSoundEffect string `json:\"sound_effect\"` // 音效描述\n\tCharacters  []uint `json:\"characters\"`   // 涉及的角色ID列表\n\tIsPrimary   bool   `json:\"is_primary\"`   // 是否主镜\n}\n\ntype GenerateStoryboardResult struct {\n\tStoryboards []Storyboard `json:\"storyboards\"`\n\tTotal       int          `json:\"total\"`\n}\n\nfunc (s *StoryboardService) GenerateStoryboard(episodeID string, model string) (string, error) {\n\t// 从数据库获取剧集信息\n\tvar episode struct {\n\t\tID            string\n\t\tScriptContent *string\n\t\tDescription   *string\n\t\tDramaID       string\n\t}\n\n\terr := s.db.Table(\"episodes\").\n\t\tSelect(\"episodes.id, episodes.script_content, episodes.description, episodes.drama_id\").\n\t\tJoins(\"INNER JOIN dramas ON dramas.id = episodes.drama_id\").\n\t\tWhere(\"episodes.id = ?\", episodeID).\n\t\tFirst(&episode).Error\n\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"剧集不存在或无权限访问\")\n\t}\n\n\t// 获取剧本内容\n\tvar scriptContent string\n\tif episode.ScriptContent != nil && *episode.ScriptContent != \"\" {\n\t\tscriptContent = *episode.ScriptContent\n\t} else if episode.Description != nil && *episode.Description != \"\" {\n\t\tscriptContent = *episode.Description\n\t} else {\n\t\treturn \"\", fmt.Errorf(\"剧本内容为空，请先生成剧集内容\")\n\t}\n\n\t// 获取该剧本的所有角色\n\tvar characters []models.Character\n\tif err := s.db.Where(\"drama_id = ?\", episode.DramaID).Order(\"name ASC\").Find(&characters).Error; err != nil {\n\t\treturn \"\", fmt.Errorf(\"获取角色列表失败: %w\", err)\n\t}\n\n\t// 构建角色列表字符串（包含ID和名称）\n\tcharacterList := \"无角色\"\n\tif len(characters) > 0 {\n\t\tvar charInfoList []string\n\t\tfor _, char := range characters {\n\t\t\tcharInfoList = append(charInfoList, fmt.Sprintf(`{\"id\": %d, \"name\": \"%s\"}`, char.ID, char.Name))\n\t\t}\n\t\tcharacterList = fmt.Sprintf(\"[%s]\", strings.Join(charInfoList, \", \"))\n\t}\n\n\t// 获取该项目已提取的场景列表（项目级）\n\tvar scenes []models.Scene\n\tif err := s.db.Where(\"drama_id = ?\", episode.DramaID).Order(\"location ASC, time ASC\").Find(&scenes).Error; err != nil {\n\t\ts.log.Warnw(\"Failed to get scenes\", \"error\", err)\n\t}\n\n\t// 构建场景列表字符串（包含ID、地点、时间）\n\tsceneList := \"无场景\"\n\tif len(scenes) > 0 {\n\t\tvar sceneInfoList []string\n\t\tfor _, bg := range scenes {\n\t\t\tsceneInfoList = append(sceneInfoList, fmt.Sprintf(`{\"id\": %d, \"location\": \"%s\", \"time\": \"%s\"}`, bg.ID, bg.Location, bg.Time))\n\t\t}\n\t\tsceneList = fmt.Sprintf(\"[%s]\", strings.Join(sceneInfoList, \", \"))\n\t}\n\n\t// 使用国际化提示词\n\tsystemPrompt := s.promptI18n.GetStoryboardSystemPrompt()\n\n\tscriptLabel := s.promptI18n.FormatUserPrompt(\"script_content_label\")\n\ttaskLabel := s.promptI18n.FormatUserPrompt(\"task_label\")\n\ttaskInstruction := s.promptI18n.FormatUserPrompt(\"task_instruction\")\n\tcharListLabel := s.promptI18n.FormatUserPrompt(\"character_list_label\")\n\tcharConstraint := s.promptI18n.FormatUserPrompt(\"character_constraint\")\n\tsceneListLabel := s.promptI18n.FormatUserPrompt(\"scene_list_label\")\n\tsceneConstraint := s.promptI18n.FormatUserPrompt(\"scene_constraint\")\n\n\tprompt := fmt.Sprintf(`%s\n\n%s\n%s\n\n%s%s\n\n%s\n%s\n\n%s\n\n%s\n%s\n\n%s\n\n【剧本原文】\n%s\n\n【分镜要素】每个镜头聚焦单一动作，描述要详尽具体：\n1. **镜头标题(title)**：用3-5个字概括该镜头的核心内容或情绪\n   - 例如：\"噩梦惊醒\"、\"对视沉思\"、\"逃离现场\"、\"意外发现\"\n2. **时间**：[清晨/午后/深夜/具体时分+详细光线描述]\n   - 例如：\"深夜22:30·月光从破窗斜射入室内，形成明暗分界\"\n3. **地点**：[场景完整描述+空间布局+环境细节]\n   - 例如：\"废弃码头仓库·锈蚀货架林立，地面积水反射微弱灯光，墙角堆放腐朽木箱\"\n4. **镜头设计**：\n   - **景别(shot_type)**：[远景/全景/中景/近景/特写]\n   - **镜头角度(angle)**：[平视/仰视/俯视/侧面/背面]\n   - **运镜方式(movement)**：[固定镜头/推镜/拉镜/摇镜/跟镜/移镜]\n5. **人物行为**：**详细动作描述**，包含[谁+具体怎么做+肢体细节+表情状态]\n   - 例如：\"陈峥弯腰用撬棍撬动保险箱门，手臂青筋暴起，眉头紧锁，汗水滑落脸颊\"\n6. **对话/独白**：提取该镜头中的完整对话或独白内容（如无对话则为空字符串）\n7. **画面结果**：动作的即时后果+视觉细节+氛围变化\n   - 例如：\"保险箱门弹开发出金属碰撞声，扬起灰尘在光束中飘散，箱内空无一物只有陈旧报纸，陈峥表情从期待转为失望\"\n8. **环境氛围**：光线质感+色调+声音环境+整体氛围\n   - 例如：\"昏暗冷色调，只有手电筒光束晃动，远处传来海浪拍打声，压抑沉闷\"\n9. **配乐提示(bgm_prompt)**：描述该镜头配乐的氛围、节奏、情绪（如无特殊要求则为空字符串）\n   - 例如：\"低沉紧张的弦乐，节奏缓慢，营造压抑氛围\"\n10. **音效描述(sound_effect)**：描述该镜头的关键音效（如无特殊音效则为空字符串）\n    - 例如：\"金属碰撞声、脚步声、海浪拍打声\"\n11. **观众情绪**：[情绪类型]（[强度：↑↑↑/↑↑/↑/→/↓] + [落点：悬置/释放/反转]）\n\n【输出格式】请以JSON格式输出，每个镜头包含以下字段（**所有描述性字段都要详细完整**）：\n{\n  \"storyboards\": [\n    {\n      \"shot_number\": 1,\n      \"title\": \"噩梦惊醒\",\n      \"shot_type\": \"全景\",\n      \"angle\": \"俯视45度角\",\n      \"time\": \"深夜22:30·月光从破窗斜射入仓库，在地面积水中形成银白色反光，墙角昏暗不清\",\n      \"location\": \"废弃码头仓库·锈蚀货架林立，地面积水反射微弱灯光，墙角堆放腐朽木箱和渔网，空气中弥漫潮湿霉味\",\n      \"scene_id\": 1,\n      \"movement\": \"固定镜头\",\n      \"action\": \"陈峥弯腰双手握住撬棍用力撬动保险箱门，手臂青筋暴起，眉头紧锁，汗水从额头滑落脸颊，呼吸急促\",\n      \"dialogue\": \"（独白）这么多年了，里面到底藏着什么秘密？\",\n      \"result\": \"保险箱门突然弹开发出刺耳金属声，扬起灰尘在手电筒光束中飘散，箱内空无一物只有几张发黄的旧报纸，陈峥表情从期待转为震惊和失望，瞳孔放大\",\n      \"atmosphere\": \"昏暗冷色调·青灰色为主，只有手电筒光束在黑暗中晃动，远处传来海浪拍打码头的沉闷声，整体氛围压抑沉重\",\n      \"emotion\": \"好奇感↑↑转失望↓（情绪反转）\",\n      \"duration\": 9,\n      \"bgm_prompt\": \"低沉紧张的弦乐，节奏缓慢，营造压抑悬疑氛围\",\n      \"sound_effect\": \"金属碰撞声、灰尘飘散声、海浪拍打声\",\n      \"characters\": [159],\n      \"is_primary\": true\n    },\n    {\n      \"shot_number\": 2,\n      \"title\": \"对视沉思\",\n      \"shot_type\": \"近景\",\n      \"angle\": \"平视\",\n      \"time\": \"深夜22:31·仓库内光线昏暗，只有手电筒光从侧面照亮两人脸部轮廓\",\n      \"location\": \"废弃码头仓库·保险箱旁，背景是模糊的货架剪影\",\n      \"scene_id\": 1,\n      \"movement\": \"推镜\",\n      \"action\": \"陈峥缓缓转身，目光与身后的李芳对视，李芳手握手电筒，光束在两人之间晃动，眼神中透露疑惑和警惕\",\n      \"dialogue\": \"陈峥：\\\"我们被耍了，这里根本没有我们要找的东西。\\\" 李芳：\\\"现在怎么办？我们的时间不多了。\\\"\",\n      \"result\": \"两人站在昏暗中陷入沉思，手电筒光束照在地面形成圆形光斑，背景传来微弱的金属摩擦声，气氛紧张凝重\",\n      \"atmosphere\": \"低调光线·暗部占画面70%，侧面硬光勾勒人物轮廓，冷暖光对比强烈，海风吹过产生呼啸声，营造紧迫感\",\n      \"emotion\": \"紧张感↑↑·警惕↑↑（悬置）\",\n      \"duration\": 7,\n      \"bgm_prompt\": \"紧张感逐渐升级的音效，低频持续音\",\n      \"sound_effect\": \"呼吸声、金属摩擦声、海风呼啸声\",\n      \"characters\": [159, 160],\n      \"is_primary\": true\n    }\n  ]\n}\n\n**dialogue字段说明**：\n- 如果有对话，格式为：角色名：\"台词内容\"\n- 多人对话用空格分隔：角色A：\"...\" 角色B：\"...\"\n- 独白格式为：（独白）内容\n- 旁白格式为：（旁白）内容\n- 无对话时填写空字符串：\"\"\n- **对话内容必须从原剧本中提取，保持原汁原味**\n\n**角色和背景要求**：\n- characters字段必须包含该镜头中出现的所有角色ID（数字数组格式）\n- 只提取实际出现的角色ID，不出现角色则为空数组[]\n- **角色ID必须严格使用【本剧可用角色列表】中的id字段（数字），不得使用其他ID或自创角色**\n- 例如：如果镜头中出现李明(id:159)和王芳(id:160)，则characters字段应为[159, 160]\n- scene_id字段必须从【本剧已提取的场景背景列表】中选择最匹配的背景ID（数字）\n- 如果列表中没有合适的背景，则scene_id填null\n- 例如：如果镜头发生在\"城市公寓卧室·凌晨\"，应选择id为1的场景背景\n\n**duration时长估算规则（秒）**：\n- **所有镜头时长必须在4-12秒范围内**，确保节奏合理流畅\n- **综合估算原则**：时长由对话内容、动作复杂度、情绪节奏三方面综合决定\n\n**估算步骤**：\n1. **基础时长**（从场景内容判断）：\n   - 纯对话场景（无明显动作）：基础4秒\n   - 纯动作场景（无对话）：基础5秒\n   - 对话+动作混合场景：基础6秒\n\n2. **对话调整**（根据台词字数增加时长）：\n   - 无对话：+0秒\n   - 短对话（1-20字）：+1-2秒\n   - 中等对话（21-50字）：+2-4秒\n   - 长对话（51字以上）：+4-6秒\n\n3. **动作调整**（根据动作复杂度增加时长）：\n   - 无动作/静态：+0秒\n   - 简单动作（表情、转身、拿物品）：+0-1秒\n   - 一般动作（走动、开门、坐下）：+1-2秒\n   - 复杂动作（打斗、追逐、大幅度移动）：+2-4秒\n   - 环境展示（全景扫描、氛围营造）：+2-5秒\n\n4. **最终时长** = 基础时长 + 对话调整 + 动作调整，确保结果在4-12秒范围内\n\n**示例**：\n- \"陈峥转身离开\"（简单动作，无对话）：5 + 0 + 1 = 6秒\n- \"李芳：\\\"你要去哪里？\\\"\"（短对话，无动作）：4 + 2 + 0 = 6秒  \n- \"陈峥推开房门，李芳：\\\"终于找到你了，这些年你去哪了？\\\"\"（一般动作+中等对话）：6 + 3 + 2 = 11秒\n- \"两人在雨中激烈搏斗，陈峥：\\\"住手！\\\"\"（复杂动作+短对话）：6 + 2 + 4 = 12秒\n\n**重要**：准确估算每个镜头时长，所有分镜时长之和将作为剧集总时长\n\n**特别要求**：\n- **【极其重要】必须100%%完整拆解整个剧本，不得省略、跳过、压缩任何剧情内容**\n- **从剧本第一个字到最后一个字，逐句逐段转换为分镜**\n- **每个对话、每个动作、每个场景转换都必须有对应的分镜**\n- 剧本越长，分镜数量越多（短剧本15-30个，中等剧本30-60个，长剧本60-100个甚至更多）\n- **宁可分镜多，也不要遗漏剧情**：一个长场景可拆分为多个连续分镜\n- 每个镜头只描述一个主要动作\n- 区分主镜（is_primary: true）和链接镜（is_primary: false）\n- 确保情绪节奏有变化\n- **duration字段至关重要**：准确估算每个镜头时长，这将用于计算整集时长\n- 严格按照JSON格式输出\n\n**【禁止行为】**：\n- ❌ 禁止用一个镜头概括多个场景\n- ❌ 禁止跳过任何对话或独白\n- ❌ 禁止省略剧情发展过程\n- ❌ 禁止合并本应分开的镜头\n- ✅ 正确做法：剧本有多少内容，就拆解出对应数量的分镜，确保观众看完所有分镜能完整了解剧情\n\n**【关键】场景描述详细度要求**（这些描述将直接用于视频生成模型）：\n1. **时间(time)字段**：必须包含≥15字的详细描述\n   - ✓ 好例子：\"深夜22:30·月光从破窗斜射入仓库，在地面积水中形成银白色反光，墙角昏暗不清\"\n   - ✗ 差例子：\"深夜\"\n\n2. **地点(location)字段**：必须包含≥20字的详细场景描述\n   - ✓ 好例子：\"废弃码头仓库·锈蚀货架林立，地面积水反射微弱灯光，墙角堆放腐朽木箱和渔网，空气中弥漫潮湿霉味\"\n   - ✗ 差例子：\"仓库\"\n\n3. **动作(action)字段**：必须包含≥25字的详细动作描述，包括肢体细节和表情\n   - ✓ 好例子：\"陈峥弯腰双手握住撬棍用力撬动保险箱门，手臂青筋暴起，眉头紧锁，汗水从额头滑落脸颊，呼吸急促\"\n   - ✗ 差例子：\"陈峥打开保险箱\"\n\n4. **结果(result)字段**：必须包含≥25字的详细视觉结果描述\n   - ✓ 好例子：\"保险箱门突然弹开发出刺耳金属声，扬起灰尘在手电筒光束中飘散，箱内空无一物只有几张发黄的旧报纸，陈峥表情从期待转为震惊和失望，瞳孔放大\"\n   - ✗ 差例子：\"门打开了\"\n\n5. **氛围(atmosphere)字段**：必须包含≥20字的环境氛围描述，包括光线、色调、声音\n   - ✓ 好例子：\"昏暗冷色调·青灰色为主，只有手电筒光束在黑暗中晃动，远处传来海浪拍打码头的沉闷声，整体氛围压抑沉重\"\n   - ✗ 差例子：\"昏暗\"\n\n**描述原则**：\n- 所有描述性字段要像为盲人讲述画面一样详细\n- 包含感官细节：视觉、听觉、触觉、嗅觉\n- 描述光线、色彩、质感、动态\n- 为视频生成AI提供足够的画面构建信息\n- 避免抽象词汇，使用具象的视觉化描述`, systemPrompt, scriptLabel, scriptContent, taskLabel, taskInstruction, charListLabel, characterList, charConstraint, sceneListLabel, sceneList, sceneConstraint)\n\n\t// 创建异步任务\n\ttask, err := s.taskService.CreateTask(\"storyboard_generation\", episodeID)\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to create task\", \"error\", err)\n\t\treturn \"\", fmt.Errorf(\"创建任务失败: %w\", err)\n\t}\n\n\ts.log.Infow(\"Generating storyboard asynchronously\",\n\t\t\"task_id\", task.ID,\n\t\t\"episode_id\", episodeID,\n\t\t\"drama_id\", episode.DramaID,\n\t\t\"script_length\", len(scriptContent),\n\t\t\"character_count\", len(characters),\n\t\t\"characters\", characterList,\n\t\t\"scene_count\", len(scenes),\n\t\t\"scenes\", sceneList)\n\n\t// 启动后台goroutine处理AI调用和后续逻辑\n\tgo s.processStoryboardGeneration(task.ID, episodeID, model, prompt)\n\n\t// 立即返回任务ID\n\treturn task.ID, nil\n}\n\n// processStoryboardGeneration 后台处理故事板生成\nfunc (s *StoryboardService) processStoryboardGeneration(taskID, episodeID, model, prompt string) {\n\t// 更新任务状态为处理中\n\tif err := s.taskService.UpdateTaskStatus(taskID, \"processing\", 10, \"开始生成分镜头...\"); err != nil {\n\t\ts.log.Errorw(\"Failed to update task status\", \"error\", err, \"task_id\", taskID)\n\t\treturn\n\t}\n\n\ts.log.Infow(\"Processing storyboard generation\", \"task_id\", taskID, \"episode_id\", episodeID)\n\n\t// 调用AI服务生成（如果指定了模型则使用指定的模型）\n\t// 设置较大的max_tokens以确保完整返回所有分镜的JSON\n\tvar text string\n\tvar err error\n\tif model != \"\" {\n\t\ts.log.Infow(\"Using specified model for storyboard generation\", \"model\", model, \"task_id\", taskID)\n\t\tclient, getErr := s.aiService.GetAIClientForModel(\"text\", model)\n\t\tif getErr != nil {\n\t\t\ts.log.Warnw(\"Failed to get client for specified model, using default\", \"model\", model, \"error\", getErr, \"task_id\", taskID)\n\t\t\ttext, err = s.aiService.GenerateText(prompt, \"\", ai.WithMaxTokens(16000))\n\t\t} else {\n\t\t\ttext, err = client.GenerateText(prompt, \"\", ai.WithMaxTokens(16000))\n\t\t}\n\t} else {\n\t\ttext, err = s.aiService.GenerateText(prompt, \"\", ai.WithMaxTokens(16000))\n\t}\n\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to generate storyboard\", \"error\", err, \"task_id\", taskID)\n\t\tif updateErr := s.taskService.UpdateTaskError(taskID, fmt.Errorf(\"生成分镜头失败: %w\", err)); updateErr != nil {\n\t\t\ts.log.Errorw(\"Failed to update task error\", \"error\", updateErr, \"task_id\", taskID)\n\t\t}\n\t\treturn\n\t}\n\n\t// 更新任务进度\n\tif err := s.taskService.UpdateTaskStatus(taskID, \"processing\", 50, \"分镜头生成完成，正在解析结果...\"); err != nil {\n\t\ts.log.Errorw(\"Failed to update task status\", \"error\", err, \"task_id\", taskID)\n\t\treturn\n\t}\n\n\t// 解析JSON结果\n\t// AI可能返回两种格式：\n\t// 1. 数组格式: [{...}, {...}]\n\t// 2. 对象格式: {\"storyboards\": [{...}, {...}]}\n\tvar result GenerateStoryboardResult\n\n\t// 先尝试解析为数组格式\n\tvar storyboards []Storyboard\n\tif err := utils.SafeParseAIJSON(text, &storyboards); err == nil {\n\t\t// 成功解析为数组，包装为对象\n\t\tresult.Storyboards = storyboards\n\t\tresult.Total = len(storyboards)\n\t\ts.log.Infow(\"Parsed storyboard as array format\", \"count\", len(storyboards), \"task_id\", taskID)\n\t} else {\n\t\t// 尝试解析为对象格式\n\t\tif err := utils.SafeParseAIJSON(text, &result); err != nil {\n\t\t\ts.log.Errorw(\"Failed to parse storyboard JSON in both formats\", \"error\", err, \"response\", text[:min(500, len(text))], \"task_id\", taskID)\n\t\t\tif updateErr := s.taskService.UpdateTaskError(taskID, fmt.Errorf(\"解析分镜头结果失败: %w\", err)); updateErr != nil {\n\t\t\t\ts.log.Errorw(\"Failed to update task error\", \"error\", updateErr, \"task_id\", taskID)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tresult.Total = len(result.Storyboards)\n\t\ts.log.Infow(\"Parsed storyboard as object format\", \"count\", len(result.Storyboards), \"task_id\", taskID)\n\t}\n\n\t// 计算总时长（所有分镜时长之和）\n\ttotalDuration := 0\n\tfor _, sb := range result.Storyboards {\n\t\ttotalDuration += sb.Duration\n\t}\n\n\ts.log.Infow(\"Storyboard generated\",\n\t\t\"task_id\", taskID,\n\t\t\"episode_id\", episodeID,\n\t\t\"count\", result.Total,\n\t\t\"total_duration_seconds\", totalDuration)\n\n\t// 更新任务进度\n\tif err := s.taskService.UpdateTaskStatus(taskID, \"processing\", 70, \"正在保存分镜头...\"); err != nil {\n\t\ts.log.Errorw(\"Failed to update task status\", \"error\", err, \"task_id\", taskID)\n\t\treturn\n\t}\n\n\t// 保存分镜头到数据库\n\tif err := s.saveStoryboards(episodeID, result.Storyboards); err != nil {\n\t\ts.log.Errorw(\"Failed to save storyboards\", \"error\", err, \"task_id\", taskID)\n\t\tif updateErr := s.taskService.UpdateTaskError(taskID, fmt.Errorf(\"保存分镜头失败: %w\", err)); updateErr != nil {\n\t\t\ts.log.Errorw(\"Failed to update task error\", \"error\", updateErr, \"task_id\", taskID)\n\t\t}\n\t\treturn\n\t}\n\n\t// 更新任务进度\n\tif err := s.taskService.UpdateTaskStatus(taskID, \"processing\", 90, \"正在更新剧集时长...\"); err != nil {\n\t\ts.log.Errorw(\"Failed to update task status\", \"error\", err, \"task_id\", taskID)\n\t\treturn\n\t}\n\n\t// 更新剧集时长（秒转分钟，向上取整）\n\tdurationMinutes := (totalDuration + 59) / 60\n\tif err := s.db.Model(&models.Episode{}).Where(\"id = ?\", episodeID).Update(\"duration\", durationMinutes).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to update episode duration\", \"error\", err, \"task_id\", taskID)\n\t\t// 不中断流程，只记录错误\n\t} else {\n\t\ts.log.Infow(\"Episode duration updated\",\n\t\t\t\"task_id\", taskID,\n\t\t\t\"episode_id\", episodeID,\n\t\t\t\"duration_seconds\", totalDuration,\n\t\t\t\"duration_minutes\", durationMinutes)\n\t}\n\n\t// 更新任务结果\n\tresultData := gin.H{\n\t\t\"storyboards\":      result.Storyboards,\n\t\t\"total\":            result.Total,\n\t\t\"total_duration\":   totalDuration,\n\t\t\"duration_minutes\": durationMinutes,\n\t}\n\n\tif err := s.taskService.UpdateTaskResult(taskID, resultData); err != nil {\n\t\ts.log.Errorw(\"Failed to update task result\", \"error\", err, \"task_id\", taskID)\n\t\treturn\n\t}\n\n\ts.log.Infow(\"Storyboard generation completed\", \"task_id\", taskID, \"episode_id\", episodeID)\n}\n\n// generateImagePrompt 生成专门用于图片生成的提示词（首帧静态画面）\nfunc (s *StoryboardService) generateImagePrompt(sb Storyboard) string {\n\tvar parts []string\n\n\t// 1. 完整的场景背景描述\n\tif sb.Location != \"\" {\n\t\tlocationDesc := sb.Location\n\t\tif sb.Time != \"\" {\n\t\t\tlocationDesc += \", \" + sb.Time\n\t\t}\n\t\tparts = append(parts, locationDesc)\n\t}\n\n\t// 2. 角色初始静态姿态（去除动作过程，只保留起始状态）\n\tif sb.Action != \"\" {\n\t\tinitialPose := extractInitialPose(sb.Action)\n\t\tif initialPose != \"\" {\n\t\t\tparts = append(parts, initialPose)\n\t\t}\n\t}\n\n\t// 3. 情绪氛围\n\tif sb.Emotion != \"\" {\n\t\tparts = append(parts, sb.Emotion)\n\t}\n\n\t// 4. 动漫风格\n\tparts = append(parts, \"anime style, first frame\")\n\n\tif len(parts) > 0 {\n\t\treturn strings.Join(parts, \", \")\n\t}\n\treturn \"anime scene\"\n}\n\n// extractInitialPose 提取初始静态姿态（去除动作过程）\nfunc extractInitialPose(action string) string {\n\t// 去除动作过程关键词，保留初始状态描述\n\tprocessWords := []string{\n\t\t\"然后\", \"接着\", \"接下来\", \"随后\", \"紧接着\",\n\t\t\"向下\", \"向上\", \"向前\", \"向后\", \"向左\", \"向右\",\n\t\t\"开始\", \"继续\", \"逐渐\", \"慢慢\", \"快速\", \"突然\", \"猛然\",\n\t}\n\n\tresult := action\n\tfor _, word := range processWords {\n\t\tif idx := strings.Index(result, word); idx > 0 {\n\t\t\t// 在动作过程词之前截断\n\t\t\tresult = result[:idx]\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// 清理末尾标点\n\tresult = strings.TrimRight(result, \"，。,. \")\n\treturn strings.TrimSpace(result)\n}\n\n// extractSimpleLocation 提取简化的场景地点（去除详细描述）\nfunc extractSimpleLocation(location string) string {\n\t// 在\"·\"符号处截断，只保留主场景名称\n\tif idx := strings.Index(location, \"·\"); idx > 0 {\n\t\treturn strings.TrimSpace(location[:idx])\n\t}\n\n\t// 如果有逗号，只保留第一部分\n\tif idx := strings.Index(location, \"，\"); idx > 0 {\n\t\treturn strings.TrimSpace(location[:idx])\n\t}\n\tif idx := strings.Index(location, \",\"); idx > 0 {\n\t\treturn strings.TrimSpace(location[:idx])\n\t}\n\n\t// 限制长度不超过15个字符\n\tmaxLen := 15\n\tif len(location) > maxLen {\n\t\treturn strings.TrimSpace(location[:maxLen])\n\t}\n\n\treturn strings.TrimSpace(location)\n}\n\n// extractSimplePose 提取简单的核心姿态关键词（不超过10个字）\nfunc extractSimplePose(action string) string {\n\t// 只提取前面最多10个字符作为核心姿态\n\trunes := []rune(action)\n\tmaxLen := 10\n\tif len(runes) > maxLen {\n\t\t// 在标点符号处截断\n\t\ttruncated := runes[:maxLen]\n\t\tfor i := maxLen - 1; i >= 0; i-- {\n\t\t\tif truncated[i] == '，' || truncated[i] == '。' || truncated[i] == ',' || truncated[i] == '.' {\n\t\t\t\ttruncated = runes[:i]\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\treturn strings.TrimSpace(string(truncated))\n\t}\n\treturn strings.TrimSpace(action)\n}\n\n// extractFirstFramePose 从动作描述中提取首帧静态姿态\nfunc extractFirstFramePose(action string) string {\n\t// 去除表示动作过程的关键词，保留初始状态\n\tprocessWords := []string{\n\t\t\"然后\", \"接着\", \"向下\", \"向前\", \"走向\", \"冲向\", \"转身\",\n\t\t\"开始\", \"继续\", \"逐渐\", \"慢慢\", \"快速\", \"突然\",\n\t}\n\n\tpose := action\n\tfor _, word := range processWords {\n\t\t// 简单处理：在这些词之前截断\n\t\tif idx := strings.Index(pose, word); idx > 0 {\n\t\t\tpose = pose[:idx]\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// 清理末尾标点\n\tpose = strings.TrimRight(pose, \"，。,.\")\n\treturn strings.TrimSpace(pose)\n}\n\n// extractCompositionType 从镜头类型中提取构图类型（去除运镜）\nfunc extractCompositionType(shotType string) string {\n\t// 去除运镜相关描述\n\tcameraMovements := []string{\n\t\t\"晃动\", \"摇晃\", \"推进\", \"拉远\", \"跟随\", \"环绕\",\n\t\t\"运镜\", \"摄影\", \"移动\", \"旋转\",\n\t}\n\n\tcomp := shotType\n\tfor _, movement := range cameraMovements {\n\t\tcomp = strings.ReplaceAll(comp, movement, \"\")\n\t}\n\n\t// 清理多余的标点和空格\n\tcomp = strings.ReplaceAll(comp, \"··\", \"·\")\n\tcomp = strings.ReplaceAll(comp, \"·\", \" \")\n\tcomp = strings.TrimSpace(comp)\n\n\treturn comp\n}\n\n// generateVideoPrompt 生成专门用于视频生成的提示词（包含运镜和动态元素）\nfunc (s *StoryboardService) generateVideoPrompt(sb Storyboard) string {\n\tvar parts []string\n\tvideoRatio := \"16:9\"\n\t// 1. 人物动作\n\tif sb.Action != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(\"Action: %s\", sb.Action))\n\t}\n\n\t// 2. 对话\n\tif sb.Dialogue != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(\"Dialogue: %s\", sb.Dialogue))\n\t}\n\n\t// 3. 镜头运动（视频特有）\n\tif sb.Movement != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(\"Camera movement: %s\", sb.Movement))\n\t}\n\n\t// 4. 镜头类型和角度\n\tif sb.ShotType != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(\"Shot type: %s\", sb.ShotType))\n\t}\n\tif sb.Angle != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(\"Camera angle: %s\", sb.Angle))\n\t}\n\n\t// 5. 场景环境\n\tif sb.Location != \"\" {\n\t\tlocationDesc := sb.Location\n\t\tif sb.Time != \"\" {\n\t\t\tlocationDesc += \", \" + sb.Time\n\t\t}\n\t\tparts = append(parts, fmt.Sprintf(\"Scene: %s\", locationDesc))\n\t}\n\n\t// 6. 环境氛围\n\tif sb.Atmosphere != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(\"Atmosphere: %s\", sb.Atmosphere))\n\t}\n\n\t// 7. 情绪和结果\n\tif sb.Emotion != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(\"Mood: %s\", sb.Emotion))\n\t}\n\tif sb.Result != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(\"Result: %s\", sb.Result))\n\t}\n\n\t// 8. 音频元素\n\tif sb.BgmPrompt != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(\"BGM: %s\", sb.BgmPrompt))\n\t}\n\tif sb.SoundEffect != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(\"Sound effects: %s\", sb.SoundEffect))\n\t}\n\n\t// 9. 视频比例\n\tparts = append(parts, fmt.Sprintf(\"=VideoRatio: %s\", videoRatio))\n\tif len(parts) > 0 {\n\t\treturn strings.Join(parts, \". \")\n\t}\n\treturn \"Anime style video scene\"\n}\n\nfunc (s *StoryboardService) saveStoryboards(episodeID string, storyboards []Storyboard) error {\n\t// 验证 episodeID\n\tepID, err := strconv.ParseUint(episodeID, 10, 32)\n\tif err != nil {\n\t\ts.log.Errorw(\"Invalid episode ID\", \"episode_id\", episodeID, \"error\", err)\n\t\treturn fmt.Errorf(\"无效的章节ID: %s\", episodeID)\n\t}\n\n\t// 防御性检查：如果AI返回的分镜数量为0，不应该删除旧分镜\n\tif len(storyboards) == 0 {\n\t\ts.log.Errorw(\"AI返回的分镜数量为0，拒绝保存以避免删除现有分镜\", \"episode_id\", episodeID)\n\t\treturn fmt.Errorf(\"AI生成分镜失败：返回的分镜数量为0\")\n\t}\n\n\ts.log.Infow(\"开始保存分镜头\",\n\t\t\"episode_id\", episodeID,\n\t\t\"episode_id_uint\", uint(epID),\n\t\t\"storyboard_count\", len(storyboards))\n\n\t// 开启事务\n\treturn s.db.Transaction(func(tx *gorm.DB) error {\n\t\t// 验证该章节是否存在\n\t\tvar episode models.Episode\n\t\tif err := tx.First(&episode, epID).Error; err != nil {\n\t\t\ts.log.Errorw(\"Episode not found\", \"episode_id\", episodeID, \"error\", err)\n\t\t\treturn fmt.Errorf(\"章节不存在: %s\", episodeID)\n\t\t}\n\n\t\ts.log.Infow(\"找到章节信息\",\n\t\t\t\"episode_id\", episode.ID,\n\t\t\t\"episode_number\", episode.EpisodeNum,\n\t\t\t\"drama_id\", episode.DramaID,\n\t\t\t\"title\", episode.Title)\n\n\t\t// 获取该剧集所有的分镜ID（使用 uint 类型）\n\t\tvar storyboardIDs []uint\n\t\tif err := tx.Model(&models.Storyboard{}).\n\t\t\tWhere(\"episode_id = ?\", uint(epID)).\n\t\t\tPluck(\"id\", &storyboardIDs).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ts.log.Infow(\"查询到现有分镜\",\n\t\t\t\"episode_id_string\", episodeID,\n\t\t\t\"episode_id_uint\", uint(epID),\n\t\t\t\"existing_storyboard_count\", len(storyboardIDs),\n\t\t\t\"storyboard_ids\", storyboardIDs)\n\n\t\t// 如果有分镜，先清理关联的image_generations的storyboard_id\n\t\tif len(storyboardIDs) > 0 {\n\t\t\tif err := tx.Model(&models.ImageGeneration{}).\n\t\t\t\tWhere(\"storyboard_id IN ?\", storyboardIDs).\n\t\t\t\tUpdate(\"storyboard_id\", nil).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\ts.log.Infow(\"已清理关联的图片生成记录\", \"count\", len(storyboardIDs))\n\t\t}\n\n\t\t// 删除该剧集已有的分镜头（使用 uint 类型确保类型匹配）\n\t\ts.log.Warnw(\"准备删除分镜数据\",\n\t\t\t\"episode_id_string\", episodeID,\n\t\t\t\"episode_id_uint\", uint(epID),\n\t\t\t\"episode_id_from_db\", episode.ID,\n\t\t\t\"will_delete_count\", len(storyboardIDs))\n\n\t\tresult := tx.Where(\"episode_id = ?\", uint(epID)).Delete(&models.Storyboard{})\n\t\tif result.Error != nil {\n\t\t\ts.log.Errorw(\"删除旧分镜失败\", \"episode_id\", uint(epID), \"error\", result.Error)\n\t\t\treturn result.Error\n\t\t}\n\n\t\ts.log.Infow(\"已删除旧分镜头\",\n\t\t\t\"episode_id\", uint(epID),\n\t\t\t\"deleted_count\", result.RowsAffected)\n\n\t\t// 注意：不删除背景，因为背景是在分镜拆解前就提取好的\n\t\t// AI会直接返回scene_id，不需要在这里做字符串匹配\n\n\t\t// 保存新的分镜头\n\t\tfor _, sb := range storyboards {\n\t\t\t// 构建描述信息，包含对话\n\t\t\tdescription := fmt.Sprintf(\"【镜头类型】%s\\n【运镜】%s\\n【动作】%s\\n【对话】%s\\n【结果】%s\\n【情绪】%s\",\n\t\t\t\tsb.ShotType, sb.Movement, sb.Action, sb.Dialogue, sb.Result, sb.Emotion)\n\n\t\t\t// 生成两种专用提示词\n\t\t\timagePrompt := s.generateImagePrompt(sb) // 专用于图片生成\n\t\t\tvideoPrompt := s.generateVideoPrompt(sb) // 专用于视频生成\n\n\t\t\t// 处理 dialogue 字段\n\t\t\tvar dialoguePtr *string\n\t\t\tif sb.Dialogue != \"\" {\n\t\t\t\tdialoguePtr = &sb.Dialogue\n\t\t\t}\n\n\t\t\t// 使用AI直接返回的SceneID\n\t\t\tif sb.SceneID != nil {\n\t\t\t\ts.log.Infow(\"Background ID from AI\",\n\t\t\t\t\t\"shot_number\", sb.ShotNumber,\n\t\t\t\t\t\"scene_id\", *sb.SceneID)\n\t\t\t}\n\n\t\t\t// 处理 title 字段\n\t\t\tvar titlePtr *string\n\t\t\tif sb.Title != \"\" {\n\t\t\t\ttitlePtr = &sb.Title\n\t\t\t}\n\n\t\t\t// 处理shot_type、angle、movement字段\n\t\t\tvar shotTypePtr, anglePtr, movementPtr *string\n\t\t\tif sb.ShotType != \"\" {\n\t\t\t\tshotTypePtr = &sb.ShotType\n\t\t\t}\n\t\t\tif sb.Angle != \"\" {\n\t\t\t\tanglePtr = &sb.Angle\n\t\t\t}\n\t\t\tif sb.Movement != \"\" {\n\t\t\t\tmovementPtr = &sb.Movement\n\t\t\t}\n\n\t\t\t// 处理bgm_prompt、sound_effect字段\n\t\t\tvar bgmPromptPtr, soundEffectPtr *string\n\t\t\tif sb.BgmPrompt != \"\" {\n\t\t\t\tbgmPromptPtr = &sb.BgmPrompt\n\t\t\t}\n\t\t\tif sb.SoundEffect != \"\" {\n\t\t\t\tsoundEffectPtr = &sb.SoundEffect\n\t\t\t}\n\n\t\t\t// 处理result、atmosphere字段\n\t\t\tvar resultPtr, atmospherePtr *string\n\t\t\tif sb.Result != \"\" {\n\t\t\t\tresultPtr = &sb.Result\n\t\t\t}\n\t\t\tif sb.Atmosphere != \"\" {\n\t\t\t\tatmospherePtr = &sb.Atmosphere\n\t\t\t}\n\n\t\t\tscene := models.Storyboard{\n\t\t\t\tEpisodeID:        uint(epID),\n\t\t\t\tSceneID:          sb.SceneID,\n\t\t\t\tStoryboardNumber: sb.ShotNumber,\n\t\t\t\tTitle:            titlePtr,\n\t\t\t\tLocation:         &sb.Location,\n\t\t\t\tTime:             &sb.Time,\n\t\t\t\tShotType:         shotTypePtr,\n\t\t\t\tAngle:            anglePtr,\n\t\t\t\tMovement:         movementPtr,\n\t\t\t\tDescription:      &description,\n\t\t\t\tAction:           &sb.Action,\n\t\t\t\tResult:           resultPtr,\n\t\t\t\tAtmosphere:       atmospherePtr,\n\t\t\t\tDialogue:         dialoguePtr,\n\t\t\t\tImagePrompt:      &imagePrompt,\n\t\t\t\tVideoPrompt:      &videoPrompt,\n\t\t\t\tBgmPrompt:        bgmPromptPtr,\n\t\t\t\tSoundEffect:      soundEffectPtr,\n\t\t\t\tDuration:         sb.Duration,\n\t\t\t}\n\n\t\t\tif err := tx.Create(&scene).Error; err != nil {\n\t\t\t\ts.log.Errorw(\"Failed to create scene\", \"error\", err, \"shot_number\", sb.ShotNumber)\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// 关联角色\n\t\t\tif len(sb.Characters) > 0 {\n\t\t\t\tvar characters []models.Character\n\t\t\t\tif err := tx.Where(\"id IN ?\", sb.Characters).Find(&characters).Error; err != nil {\n\t\t\t\t\ts.log.Warnw(\"Failed to load characters for association\", \"error\", err, \"character_ids\", sb.Characters)\n\t\t\t\t} else if len(characters) > 0 {\n\t\t\t\t\tif err := tx.Model(&scene).Association(\"Characters\").Append(characters); err != nil {\n\t\t\t\t\t\ts.log.Warnw(\"Failed to associate characters\", \"error\", err, \"shot_number\", sb.ShotNumber)\n\t\t\t\t\t} else {\n\t\t\t\t\t\ts.log.Infow(\"Characters associated successfully\",\n\t\t\t\t\t\t\t\"shot_number\", sb.ShotNumber,\n\t\t\t\t\t\t\t\"character_ids\", sb.Characters,\n\t\t\t\t\t\t\t\"count\", len(characters))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\ts.log.Infow(\"Storyboards saved successfully\", \"episode_id\", episodeID, \"count\", len(storyboards))\n\t\treturn nil\n\t})\n}\n\n// CreateStoryboardRequest 创建分镜请求\ntype CreateStoryboardRequest struct {\n\tEpisodeID        uint    `json:\"episode_id\"`\n\tSceneID          *uint   `json:\"scene_id\"`\n\tStoryboardNumber int     `json:\"storyboard_number\"`\n\tTitle            *string `json:\"title\"`\n\tLocation         *string `json:\"location\"`\n\tTime             *string `json:\"time\"`\n\tShotType         *string `json:\"shot_type\"`\n\tAngle            *string `json:\"angle\"`\n\tMovement         *string `json:\"movement\"`\n\tDescription      *string `json:\"description\"`\n\tAction           *string `json:\"action\"`\n\tResult           *string `json:\"result\"`\n\tAtmosphere       *string `json:\"atmosphere\"`\n\tDialogue         *string `json:\"dialogue\"`\n\tBgmPrompt        *string `json:\"bgm_prompt\"`\n\tSoundEffect      *string `json:\"sound_effect\"`\n\tDuration         int     `json:\"duration\"`\n\tCharacters       []uint  `json:\"characters\"`\n}\n\n// CreateStoryboard 创建单个分镜\nfunc (s *StoryboardService) CreateStoryboard(req *CreateStoryboardRequest) (*models.Storyboard, error) {\n\t// 构建Storyboard对象\n\tsb := Storyboard{\n\t\tShotNumber:  req.StoryboardNumber,\n\t\tShotType:    getString(req.ShotType),\n\t\tAngle:       getString(req.Angle),\n\t\tTime:        getString(req.Time),\n\t\tLocation:    getString(req.Location),\n\t\tSceneID:     req.SceneID,\n\t\tMovement:    getString(req.Movement),\n\t\tAction:      getString(req.Action),\n\t\tDialogue:    getString(req.Dialogue),\n\t\tResult:      getString(req.Result),\n\t\tAtmosphere:  getString(req.Atmosphere),\n\t\tEmotion:     \"\", // 可以后续添加\n\t\tDuration:    req.Duration,\n\t\tBgmPrompt:   getString(req.BgmPrompt),\n\t\tSoundEffect: getString(req.SoundEffect),\n\t\tCharacters:  req.Characters,\n\t}\n\tif req.Title != nil {\n\t\tsb.Title = *req.Title\n\t}\n\n\t// 生成提示词\n\timagePrompt := s.generateImagePrompt(sb)\n\tvideoPrompt := s.generateVideoPrompt(sb)\n\n\t// 构建 description\n\tdesc := \"\"\n\tif req.Description != nil {\n\t\tdesc = *req.Description\n\t}\n\n\tmodelSB := &models.Storyboard{\n\t\tEpisodeID:        req.EpisodeID,\n\t\tSceneID:          req.SceneID,\n\t\tStoryboardNumber: req.StoryboardNumber,\n\t\tTitle:            req.Title,\n\t\tLocation:         req.Location,\n\t\tTime:             req.Time,\n\t\tShotType:         req.ShotType,\n\t\tAngle:            req.Angle,\n\t\tMovement:         req.Movement,\n\t\tDescription:      &desc,\n\t\tAction:           req.Action,\n\t\tResult:           req.Result,\n\t\tAtmosphere:       req.Atmosphere,\n\t\tDialogue:         req.Dialogue,\n\t\tImagePrompt:      &imagePrompt,\n\t\tVideoPrompt:      &videoPrompt,\n\t\tBgmPrompt:        req.BgmPrompt,\n\t\tSoundEffect:      req.SoundEffect,\n\t\tDuration:         req.Duration,\n\t}\n\n\tif err := s.db.Create(modelSB).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create storyboard: %w\", err)\n\t}\n\n\t// 关联角色\n\tif len(req.Characters) > 0 {\n\t\tvar characters []models.Character\n\t\tif err := s.db.Where(\"id IN ?\", req.Characters).Find(&characters).Error; err != nil {\n\t\t\ts.log.Warnw(\"Failed to find characters for new storyboard\", \"error\", err)\n\t\t} else if len(characters) > 0 {\n\t\t\ts.db.Model(modelSB).Association(\"Characters\").Append(characters)\n\t\t}\n\t}\n\n\ts.log.Infow(\"Storyboard created\", \"id\", modelSB.ID, \"episode_id\", req.EpisodeID)\n\treturn modelSB, nil\n}\n\n// DeleteStoryboard 删除分镜\nfunc (s *StoryboardService) DeleteStoryboard(storyboardID uint) error {\n\tresult := s.db.Where(\"id = ? \", storyboardID).Delete(&models.Storyboard{})\n\tif result.Error != nil {\n\t\treturn result.Error\n\t}\n\tif result.RowsAffected == 0 {\n\t\treturn fmt.Errorf(\"storyboard not found\")\n\t}\n\treturn nil\n}\n\nfunc min(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}\n\nfunc getString(s *string) string {\n\tif s == nil {\n\t\treturn \"\"\n\t}\n\treturn *s\n}\n"
  },
  {
    "path": "application/services/storyboard_update_full.go",
    "content": "package services\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/drama-generator/backend/domain/models\"\n)\n\n// UpdateStoryboard 更新分镜的所有字段，并重新生成提示词\nfunc (s *StoryboardService) UpdateStoryboard(storyboardID string, updates map[string]interface{}) error {\n\t// 查找分镜\n\tvar storyboard models.Storyboard\n\tif err := s.db.First(&storyboard, storyboardID).Error; err != nil {\n\t\treturn fmt.Errorf(\"storyboard not found: %w\", err)\n\t}\n\n\t// 构建用于重新生成提示词的Storyboard结构\n\tsb := Storyboard{\n\t\tShotNumber: storyboard.StoryboardNumber,\n\t}\n\n\t// 从updates中提取字段并更新\n\tupdateData := make(map[string]interface{})\n\n\tif val, ok := updates[\"title\"].(string); ok && val != \"\" {\n\t\tupdateData[\"title\"] = val\n\t\tsb.Title = val\n\t}\n\tif val, ok := updates[\"shot_type\"].(string); ok && val != \"\" {\n\t\tupdateData[\"shot_type\"] = val\n\t\tsb.ShotType = val\n\t}\n\tif val, ok := updates[\"angle\"].(string); ok && val != \"\" {\n\t\tupdateData[\"angle\"] = val\n\t\tsb.Angle = val\n\t}\n\tif val, ok := updates[\"movement\"].(string); ok && val != \"\" {\n\t\tupdateData[\"movement\"] = val\n\t\tsb.Movement = val\n\t}\n\tif val, ok := updates[\"location\"].(string); ok && val != \"\" {\n\t\tupdateData[\"location\"] = val\n\t\tsb.Location = val\n\t}\n\tif val, ok := updates[\"time\"].(string); ok && val != \"\" {\n\t\tupdateData[\"time\"] = val\n\t\tsb.Time = val\n\t}\n\tif val, ok := updates[\"action\"].(string); ok && val != \"\" {\n\t\tupdateData[\"action\"] = val\n\t\tsb.Action = val\n\t}\n\tif val, ok := updates[\"dialogue\"].(string); ok && val != \"\" {\n\t\tupdateData[\"dialogue\"] = val\n\t\tsb.Dialogue = val\n\t}\n\tif val, ok := updates[\"result\"].(string); ok && val != \"\" {\n\t\tupdateData[\"result\"] = val\n\t\tsb.Result = val\n\t}\n\tif val, ok := updates[\"atmosphere\"].(string); ok && val != \"\" {\n\t\tupdateData[\"atmosphere\"] = val\n\t\tsb.Atmosphere = val\n\t}\n\tif val, ok := updates[\"description\"].(string); ok && val != \"\" {\n\t\tupdateData[\"description\"] = val\n\t}\n\tif val, ok := updates[\"bgm_prompt\"].(string); ok && val != \"\" {\n\t\tupdateData[\"bgm_prompt\"] = val\n\t\tsb.BgmPrompt = val\n\t}\n\tif val, ok := updates[\"sound_effect\"].(string); ok && val != \"\" {\n\t\tupdateData[\"sound_effect\"] = val\n\t\tsb.SoundEffect = val\n\t}\n\tif val, ok := updates[\"duration\"].(float64); ok {\n\t\tupdateData[\"duration\"] = int(val)\n\t\tsb.Duration = int(val)\n\t}\n\tif val, ok := updates[\"scene_id\"].(float64); ok {\n\t\tsceneID := uint(val)\n\t\tupdateData[\"scene_id\"] = sceneID\n\t}\n\n\t// 使用当前数据库值填充缺失字段（用于生成提示词）\n\tif sb.Title == \"\" && storyboard.Title != nil {\n\t\tsb.Title = *storyboard.Title\n\t}\n\tif sb.ShotType == \"\" && storyboard.ShotType != nil {\n\t\tsb.ShotType = *storyboard.ShotType\n\t}\n\tif sb.Angle == \"\" && storyboard.Angle != nil {\n\t\tsb.Angle = *storyboard.Angle\n\t}\n\tif sb.Movement == \"\" && storyboard.Movement != nil {\n\t\tsb.Movement = *storyboard.Movement\n\t}\n\tif sb.Location == \"\" && storyboard.Location != nil {\n\t\tsb.Location = *storyboard.Location\n\t}\n\tif sb.Time == \"\" && storyboard.Time != nil {\n\t\tsb.Time = *storyboard.Time\n\t}\n\tif sb.Action == \"\" && storyboard.Action != nil {\n\t\tsb.Action = *storyboard.Action\n\t}\n\tif sb.Dialogue == \"\" && storyboard.Dialogue != nil {\n\t\tsb.Dialogue = *storyboard.Dialogue\n\t}\n\tif sb.Result == \"\" && storyboard.Result != nil {\n\t\tsb.Result = *storyboard.Result\n\t}\n\tif sb.Atmosphere == \"\" && storyboard.Atmosphere != nil {\n\t\tsb.Atmosphere = *storyboard.Atmosphere\n\t}\n\tif sb.BgmPrompt == \"\" && storyboard.BgmPrompt != nil {\n\t\tsb.BgmPrompt = *storyboard.BgmPrompt\n\t}\n\tif sb.SoundEffect == \"\" && storyboard.SoundEffect != nil {\n\t\tsb.SoundEffect = *storyboard.SoundEffect\n\t}\n\tif sb.Duration == 0 {\n\t\tsb.Duration = storyboard.Duration\n\t}\n\n\t// 只重新生成video_prompt\n\t// image_prompt不自动更新，因为可能对应多张已生成的帧图片\n\tvideoPrompt := s.generateVideoPrompt(sb)\n\n\tupdateData[\"video_prompt\"] = videoPrompt\n\n\t// 更新数据库\n\tif err := s.db.Model(&storyboard).Updates(updateData).Error; err != nil {\n\t\treturn fmt.Errorf(\"failed to update storyboard: %w\", err)\n\t}\n\n\ts.log.Infow(\"Storyboard updated successfully\",\n\t\t\"storyboard_id\", storyboardID,\n\t\t\"fields_updated\", len(updateData))\n\n\treturn nil\n}\n"
  },
  {
    "path": "application/services/task_service.go",
    "content": "package services\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n)\n\ntype TaskService struct {\n\tdb  *gorm.DB\n\tlog *logger.Logger\n}\n\nfunc NewTaskService(db *gorm.DB, log *logger.Logger) *TaskService {\n\treturn &TaskService{\n\t\tdb:  db,\n\t\tlog: log,\n\t}\n}\n\n// CreateTask 创建新任务\nfunc (s *TaskService) CreateTask(taskType, resourceID string) (*models.AsyncTask, error) {\n\ttask := &models.AsyncTask{\n\t\tID:         uuid.New().String(),\n\t\tType:       taskType,\n\t\tStatus:     \"pending\",\n\t\tProgress:   0,\n\t\tResourceID: resourceID,\n\t}\n\n\tif err := s.db.Create(task).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create task: %w\", err)\n\t}\n\n\treturn task, nil\n}\n\n// UpdateTaskStatus 更新任务状态\nfunc (s *TaskService) UpdateTaskStatus(taskID, status string, progress int, message string) error {\n\tupdates := map[string]interface{}{\n\t\t\"status\":     status,\n\t\t\"progress\":   progress,\n\t\t\"message\":    message,\n\t\t\"updated_at\": time.Now(),\n\t}\n\n\tif status == \"completed\" || status == \"failed\" {\n\t\tnow := time.Now()\n\t\tupdates[\"completed_at\"] = &now\n\t}\n\n\treturn s.db.Model(&models.AsyncTask{}).\n\t\tWhere(\"id = ?\", taskID).\n\t\tUpdates(updates).Error\n}\n\n// UpdateTaskError 更新任务错误\nfunc (s *TaskService) UpdateTaskError(taskID string, err error) error {\n\tnow := time.Now()\n\treturn s.db.Model(&models.AsyncTask{}).\n\t\tWhere(\"id = ?\", taskID).\n\t\tUpdates(map[string]interface{}{\n\t\t\t\"status\":       \"failed\",\n\t\t\t\"error\":        err.Error(),\n\t\t\t\"progress\":     0,\n\t\t\t\"completed_at\": &now,\n\t\t\t\"updated_at\":   time.Now(),\n\t\t}).Error\n}\n\n// UpdateTaskResult 更新任务结果\nfunc (s *TaskService) UpdateTaskResult(taskID string, result interface{}) error {\n\tresultJSON, err := json.Marshal(result)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal result: %w\", err)\n\t}\n\n\tnow := time.Now()\n\treturn s.db.Model(&models.AsyncTask{}).\n\t\tWhere(\"id = ?\", taskID).\n\t\tUpdates(map[string]interface{}{\n\t\t\t\"status\":       \"completed\",\n\t\t\t\"progress\":     100,\n\t\t\t\"result\":       string(resultJSON),\n\t\t\t\"completed_at\": &now,\n\t\t\t\"updated_at\":   time.Now(),\n\t\t}).Error\n}\n\n// GetTask 获取任务信息\nfunc (s *TaskService) GetTask(taskID string) (*models.AsyncTask, error) {\n\tvar task models.AsyncTask\n\tif err := s.db.Where(\"id = ?\", taskID).First(&task).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn &task, nil\n}\n\n// GetTasksByResource 获取资源相关的所有任务\nfunc (s *TaskService) GetTasksByResource(resourceID string) ([]*models.AsyncTask, error) {\n\tvar tasks []*models.AsyncTask\n\tif err := s.db.Where(\"resource_id = ?\", resourceID).\n\t\tOrder(\"created_at DESC\").\n\t\tFind(&tasks).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn tasks, nil\n}\n"
  },
  {
    "path": "application/services/upload_service.go",
    "content": "package services\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/google/uuid\"\n)\n\ntype UploadService struct {\n\tstoragePath string\n\tbaseURL     string\n\tlog         *logger.Logger\n}\n\nfunc NewUploadService(cfg *config.Config, log *logger.Logger) (*UploadService, error) {\n\t// 确保存储目录存在\n\tif err := os.MkdirAll(cfg.Storage.LocalPath, 0755); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create storage directory: %w\", err)\n\t}\n\n\treturn &UploadService{\n\t\tstoragePath: cfg.Storage.LocalPath,\n\t\tbaseURL:     cfg.Storage.BaseURL,\n\t\tlog:         log,\n\t}, nil\n}\n\n// UploadResult 上传结果\ntype UploadResult struct {\n\tURL       string // 完整访问URL\n\tLocalPath string // 相对路径（相对于 storage 根目录）\n}\n\n// UploadFile 上传文件到本地存储\nfunc (s *UploadService) UploadFile(file io.Reader, fileName, contentType string, category string) (*UploadResult, error) {\n\t// 创建分类目录\n\tcategoryPath := filepath.Join(s.storagePath, category)\n\tif err := os.MkdirAll(categoryPath, 0755); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create category directory: %w\", err)\n\t}\n\n\t// 生成唯一文件名\n\text := filepath.Ext(fileName)\n\tuniqueID := uuid.New().String()\n\ttimestamp := time.Now().Format(\"20060102_150405\")\n\tnewFileName := fmt.Sprintf(\"%s_%s%s\", timestamp, uniqueID, ext)\n\tfilePath := filepath.Join(categoryPath, newFileName)\n\n\t// 创建文件\n\tdst, err := os.Create(filePath)\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to create file\", \"error\", err, \"path\", filePath)\n\t\treturn nil, fmt.Errorf(\"创建文件失败: %w\", err)\n\t}\n\tdefer dst.Close()\n\n\t// 写入文件\n\tif _, err := io.Copy(dst, file); err != nil {\n\t\ts.log.Errorw(\"Failed to write file\", \"error\", err, \"path\", filePath)\n\t\treturn nil, fmt.Errorf(\"写入文件失败: %w\", err)\n\t}\n\n\t// 构建访问URL和相对路径\n\tfileURL := fmt.Sprintf(\"%s/%s/%s\", s.baseURL, category, newFileName)\n\tlocalPath := fmt.Sprintf(\"%s/%s\", category, newFileName)\n\n\ts.log.Infow(\"File uploaded successfully\", \"path\", filePath, \"url\", fileURL, \"local_path\", localPath)\n\treturn &UploadResult{\n\t\tURL:       fileURL,\n\t\tLocalPath: localPath,\n\t}, nil\n}\n\n// UploadCharacterImage 上传角色图片\nfunc (s *UploadService) UploadCharacterImage(file io.Reader, fileName, contentType string) (*UploadResult, error) {\n\treturn s.UploadFile(file, fileName, contentType, \"characters\")\n}\n\n// DeleteFile 删除本地文件\nfunc (s *UploadService) DeleteFile(fileURL string) error {\n\t// 从URL中提取相对路径\n\t// URL格式: http://localhost:8080/static/characters/20060102_150405_uuid.jpg\n\trelPath := s.extractRelativePathFromURL(fileURL)\n\tif relPath == \"\" {\n\t\treturn fmt.Errorf(\"invalid file URL\")\n\t}\n\n\tfilePath := filepath.Join(s.storagePath, relPath)\n\terr := os.Remove(filePath)\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to delete file\", \"error\", err, \"path\", filePath)\n\t\treturn fmt.Errorf(\"删除文件失败: %w\", err)\n\t}\n\n\ts.log.Infow(\"File deleted successfully\", \"path\", filePath)\n\treturn nil\n}\n\n// extractRelativePathFromURL 从URL中提取相对路径\nfunc (s *UploadService) extractRelativePathFromURL(fileURL string) string {\n\t// 从baseURL后面提取路径\n\t// 例如: http://localhost:8080/static/characters/xxx.jpg -> characters/xxx.jpg\n\tif len(fileURL) <= len(s.baseURL) {\n\t\treturn \"\"\n\t}\n\treturn fileURL[len(s.baseURL)+1:] // +1 for the '/'\n}\n\n// GetPresignedURL 本地存储不需要预签名URL，直接返回原URL\nfunc (s *UploadService) GetPresignedURL(objectName string, expiry time.Duration) (string, error) {\n\t// 本地存储通过静态文件服务直接访问，不需要预签名\n\treturn fmt.Sprintf(\"%s/%s\", s.baseURL, objectName), nil\n}\n"
  },
  {
    "path": "application/services/video_generation_service.go",
    "content": "package services\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\tmodels \"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/infrastructure/external/ffmpeg\"\n\t\"github.com/drama-generator/backend/infrastructure/storage\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/utils\"\n\t\"github.com/drama-generator/backend/pkg/video\"\n\t\"gorm.io/gorm\"\n)\n\ntype VideoGenerationService struct {\n\tdb              *gorm.DB\n\ttransferService *ResourceTransferService\n\tlog             *logger.Logger\n\tlocalStorage    *storage.LocalStorage\n\taiService       *AIService\n\tffmpeg          *ffmpeg.FFmpeg\n\tpromptI18n      *PromptI18n\n}\n\nfunc NewVideoGenerationService(db *gorm.DB, transferService *ResourceTransferService, localStorage *storage.LocalStorage, aiService *AIService, log *logger.Logger, promptI18n *PromptI18n) *VideoGenerationService {\n\tservice := &VideoGenerationService{\n\t\tdb:              db,\n\t\tlocalStorage:    localStorage,\n\t\ttransferService: transferService,\n\t\taiService:       aiService,\n\t\tlog:             log,\n\t\tffmpeg:          ffmpeg.NewFFmpeg(log),\n\t\tpromptI18n:      promptI18n,\n\t}\n\n\tgo service.RecoverPendingTasks()\n\n\treturn service\n}\n\ntype GenerateVideoRequest struct {\n\tStoryboardID *uint  `json:\"storyboard_id\"`\n\tDramaID      string `json:\"drama_id\" binding:\"required\"`\n\tImageGenID   *uint  `json:\"image_gen_id\"`\n\n\t// 参考图模式：single, first_last, multiple, none\n\tReferenceMode string `json:\"reference_mode\"`\n\n\t// 单图模式\n\tImageURL       string  `json:\"image_url\"`\n\tImageLocalPath *string `json:\"image_local_path\"` // 单图模式的本地路径\n\n\t// 首尾帧模式\n\tFirstFrameURL       *string `json:\"first_frame_url\"`\n\tFirstFrameLocalPath *string `json:\"first_frame_local_path\"` // 首帧本地路径\n\tLastFrameURL        *string `json:\"last_frame_url\"`\n\tLastFrameLocalPath  *string `json:\"last_frame_local_path\"` // 尾帧本地路径\n\n\t// 多图模式\n\tReferenceImageURLs []string `json:\"reference_image_urls\"`\n\n\tPrompt       string  `json:\"prompt\" binding:\"required,min=5,max=2000\"`\n\tProvider     string  `json:\"provider\"`\n\tModel        string  `json:\"model\"`\n\tDuration     *int    `json:\"duration\"`\n\tFPS          *int    `json:\"fps\"`\n\tAspectRatio  *string `json:\"aspect_ratio\"`\n\tStyle        *string `json:\"style\"`\n\tMotionLevel  *int    `json:\"motion_level\"`\n\tCameraMotion *string `json:\"camera_motion\"`\n\tSeed         *int64  `json:\"seed\"`\n}\n\nfunc (s *VideoGenerationService) GenerateVideo(request *GenerateVideoRequest) (*models.VideoGeneration, error) {\n\tif request.StoryboardID != nil {\n\t\tvar storyboard models.Storyboard\n\t\tif err := s.db.Preload(\"Episode\").Where(\"id = ?\", *request.StoryboardID).First(&storyboard).Error; err != nil {\n\t\t\treturn nil, fmt.Errorf(\"storyboard not found\")\n\t\t}\n\t\tif fmt.Sprintf(\"%d\", storyboard.Episode.DramaID) != request.DramaID {\n\t\t\treturn nil, fmt.Errorf(\"storyboard does not belong to drama\")\n\t\t}\n\t}\n\n\tif request.ImageGenID != nil {\n\t\tvar imageGen models.ImageGeneration\n\t\tif err := s.db.Where(\"id = ?\", *request.ImageGenID).First(&imageGen).Error; err != nil {\n\t\t\treturn nil, fmt.Errorf(\"image generation not found\")\n\t\t}\n\t}\n\n\tprovider := request.Provider\n\tif provider == \"\" {\n\t\tprovider = \"doubao\"\n\t}\n\n\tdramaID, _ := strconv.ParseUint(request.DramaID, 10, 32)\n\n\tvideoGen := &models.VideoGeneration{\n\t\tStoryboardID: request.StoryboardID,\n\t\tDramaID:      uint(dramaID),\n\t\tImageGenID:   request.ImageGenID,\n\t\tProvider:     provider,\n\t\tPrompt:       request.Prompt,\n\t\tModel:        request.Model,\n\t\tDuration:     request.Duration,\n\t\tFPS:          request.FPS,\n\t\tAspectRatio:  request.AspectRatio,\n\t\tStyle:        request.Style,\n\t\tMotionLevel:  request.MotionLevel,\n\t\tCameraMotion: request.CameraMotion,\n\t\tSeed:         request.Seed,\n\t\tStatus:       models.VideoStatusPending,\n\t}\n\n\t// 根据参考图模式处理不同的参数\n\tif request.ReferenceMode != \"\" {\n\t\tvideoGen.ReferenceMode = &request.ReferenceMode\n\t}\n\n\tswitch request.ReferenceMode {\n\tcase \"single\":\n\t\t// 单图模式 - 优先使用 local_path\n\t\tif request.ImageLocalPath != nil && *request.ImageLocalPath != \"\" {\n\t\t\tvideoGen.ImageURL = request.ImageLocalPath\n\t\t} else if request.ImageURL != \"\" {\n\t\t\tvideoGen.ImageURL = &request.ImageURL\n\t\t}\n\tcase \"first_last\":\n\t\t// 首尾帧模式 - 优先使用 local_path\n\t\tif request.FirstFrameLocalPath != nil && *request.FirstFrameLocalPath != \"\" {\n\t\t\tvideoGen.FirstFrameURL = request.FirstFrameLocalPath\n\t\t} else if request.FirstFrameURL != nil {\n\t\t\tvideoGen.FirstFrameURL = request.FirstFrameURL\n\t\t}\n\t\tif request.LastFrameLocalPath != nil && *request.LastFrameLocalPath != \"\" {\n\t\t\tvideoGen.LastFrameURL = request.LastFrameLocalPath\n\t\t} else if request.LastFrameURL != nil {\n\t\t\tvideoGen.LastFrameURL = request.LastFrameURL\n\t\t}\n\tcase \"multiple\":\n\t\t// 多图模式\n\t\tif len(request.ReferenceImageURLs) > 0 {\n\t\t\treferenceImagesJSON, err := json.Marshal(request.ReferenceImageURLs)\n\t\t\tif err == nil {\n\t\t\t\treferenceImagesStr := string(referenceImagesJSON)\n\t\t\t\tvideoGen.ReferenceImageURLs = &referenceImagesStr\n\t\t\t}\n\t\t}\n\tcase \"none\":\n\t\t// 无参考图，纯文本生成\n\tdefault:\n\t\t// 向后兼容：如果没有指定模式，根据提供的参数自动判断\n\t\tif request.ImageURL != \"\" {\n\t\t\tvideoGen.ImageURL = &request.ImageURL\n\t\t\tmode := \"single\"\n\t\t\tvideoGen.ReferenceMode = &mode\n\t\t} else if request.FirstFrameURL != nil || request.LastFrameURL != nil {\n\t\t\tvideoGen.FirstFrameURL = request.FirstFrameURL\n\t\t\tvideoGen.LastFrameURL = request.LastFrameURL\n\t\t\tmode := \"first_last\"\n\t\t\tvideoGen.ReferenceMode = &mode\n\t\t} else if len(request.ReferenceImageURLs) > 0 {\n\t\t\treferenceImagesJSON, err := json.Marshal(request.ReferenceImageURLs)\n\t\t\tif err == nil {\n\t\t\t\treferenceImagesStr := string(referenceImagesJSON)\n\t\t\t\tvideoGen.ReferenceImageURLs = &referenceImagesStr\n\t\t\t\tmode := \"multiple\"\n\t\t\t\tvideoGen.ReferenceMode = &mode\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := s.db.Create(videoGen).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create record: %w\", err)\n\t}\n\n\t// Start background goroutine to process video generation asynchronously\n\t// This allows the API to return immediately while video generation happens in background\n\t// CRITICAL: The goroutine will handle all video generation logic including API calls and polling\n\tgo s.ProcessVideoGeneration(videoGen.ID)\n\n\treturn videoGen, nil\n}\n\nfunc (s *VideoGenerationService) ProcessVideoGeneration(videoGenID uint) {\n\tvar videoGen models.VideoGeneration\n\tif err := s.db.First(&videoGen, videoGenID).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to load video generation\", \"error\", err, \"id\", videoGenID)\n\t\treturn\n\t}\n\n\t// 获取drama的style信息\n\tvar drama models.Drama\n\tif err := s.db.First(&drama, videoGen.DramaID).Error; err != nil {\n\t\ts.log.Warnw(\"Failed to load drama for style\", \"error\", err, \"drama_id\", videoGen.DramaID)\n\t}\n\n\ts.db.Model(&videoGen).Update(\"status\", models.VideoStatusProcessing)\n\n\tclient, err := s.getVideoClient(videoGen.Provider, videoGen.Model)\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to get video client\", \"error\", err, \"provider\", videoGen.Provider, \"model\", videoGen.Model)\n\t\ts.updateVideoGenError(videoGenID, err.Error())\n\t\treturn\n\t}\n\n\ts.log.Infow(\"Starting video generation\", \"id\", videoGenID, \"prompt\", videoGen.Prompt, \"provider\", videoGen.Provider)\n\n\tvar opts []video.VideoOption\n\tif videoGen.Model != \"\" {\n\t\topts = append(opts, video.WithModel(videoGen.Model))\n\t}\n\tif videoGen.Duration != nil {\n\t\topts = append(opts, video.WithDuration(*videoGen.Duration))\n\t}\n\tif videoGen.FPS != nil {\n\t\topts = append(opts, video.WithFPS(*videoGen.FPS))\n\t}\n\tif videoGen.AspectRatio != nil {\n\t\topts = append(opts, video.WithAspectRatio(*videoGen.AspectRatio))\n\t}\n\tif videoGen.Style != nil {\n\t\topts = append(opts, video.WithStyle(*videoGen.Style))\n\t}\n\tif videoGen.MotionLevel != nil {\n\t\topts = append(opts, video.WithMotionLevel(*videoGen.MotionLevel))\n\t}\n\tif videoGen.CameraMotion != nil {\n\t\topts = append(opts, video.WithCameraMotion(*videoGen.CameraMotion))\n\t}\n\tif videoGen.Seed != nil {\n\t\topts = append(opts, video.WithSeed(*videoGen.Seed))\n\t}\n\n\t// 根据参考图模式添加相应的选项，并将本地图片转换为base64\n\tif videoGen.ReferenceMode != nil {\n\t\tswitch *videoGen.ReferenceMode {\n\t\tcase \"first_last\":\n\t\t\t// 首尾帧模式 - 转换本地图片为base64\n\t\t\tif videoGen.FirstFrameURL != nil {\n\t\t\t\tfirstFrameBase64, err := s.convertImageToBase64(*videoGen.FirstFrameURL)\n\t\t\t\tif err != nil {\n\t\t\t\t\ts.log.Warnw(\"Failed to convert first frame to base64, using original URL\", \"error\", err)\n\t\t\t\t\topts = append(opts, video.WithFirstFrame(*videoGen.FirstFrameURL))\n\t\t\t\t} else {\n\t\t\t\t\topts = append(opts, video.WithFirstFrame(firstFrameBase64))\n\t\t\t\t}\n\t\t\t}\n\t\t\tif videoGen.LastFrameURL != nil {\n\t\t\t\tlastFrameBase64, err := s.convertImageToBase64(*videoGen.LastFrameURL)\n\t\t\t\tif err != nil {\n\t\t\t\t\ts.log.Warnw(\"Failed to convert last frame to base64, using original URL\", \"error\", err)\n\t\t\t\t\topts = append(opts, video.WithLastFrame(*videoGen.LastFrameURL))\n\t\t\t\t} else {\n\t\t\t\t\topts = append(opts, video.WithLastFrame(lastFrameBase64))\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"multiple\":\n\t\t\t// 多图模式 - 转换本地图片为base64\n\t\t\tif videoGen.ReferenceImageURLs != nil {\n\t\t\t\tvar imageURLs []string\n\t\t\t\tif err := json.Unmarshal([]byte(*videoGen.ReferenceImageURLs), &imageURLs); err == nil {\n\t\t\t\t\tvar base64Images []string\n\t\t\t\t\tfor _, imgURL := range imageURLs {\n\t\t\t\t\t\tbase64Img, err := s.convertImageToBase64(imgURL)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\ts.log.Warnw(\"Failed to convert reference image to base64, using original URL\", \"error\", err, \"url\", imgURL)\n\t\t\t\t\t\t\tbase64Images = append(base64Images, imgURL)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tbase64Images = append(base64Images, base64Img)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\topts = append(opts, video.WithReferenceImages(base64Images))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 构造imageURL参数（单图模式使用，其他模式传空字符串）\n\t// 如果是本地图片，转换为base64\n\timageURL := \"\"\n\tif videoGen.ImageURL != nil {\n\t\tbase64Image, err := s.convertImageToBase64(*videoGen.ImageURL)\n\t\tif err != nil {\n\t\t\ts.log.Warnw(\"Failed to convert image to base64, using original URL\", \"error\", err)\n\t\t\timageURL = *videoGen.ImageURL\n\t\t} else {\n\t\t\timageURL = base64Image\n\t\t}\n\t}\n\n\t// 构建完整的提示词：风格提示词 + 约束提示词 + 用户提示词\n\tprompt := videoGen.Prompt\n\n\t// 2. 添加视频约束提示词\n\t// 根据参考图模式选择对应的约束提示词\n\treferenceMode := \"single\" // 默认单图模式\n\tif videoGen.ReferenceMode != nil {\n\t\treferenceMode = *videoGen.ReferenceMode\n\t}\n\n\t// 如果是单图模式，需要检查图片是否为动作序列图\n\tif referenceMode == \"single\" && videoGen.ImageGenID != nil {\n\t\tvar imageGen models.ImageGeneration\n\t\tif err := s.db.First(&imageGen, *videoGen.ImageGenID).Error; err == nil {\n\t\t\t// 如果图片的frame_type是action，使用动作序列约束提示词\n\t\t\tif imageGen.FrameType != nil && *imageGen.FrameType == \"action\" {\n\t\t\t\treferenceMode = \"action_sequence\"\n\t\t\t\ts.log.Infow(\"Detected action sequence image in single mode\",\n\t\t\t\t\t\"id\", videoGenID,\n\t\t\t\t\t\"image_gen_id\", *videoGen.ImageGenID,\n\t\t\t\t\t\"frame_type\", *imageGen.FrameType)\n\t\t\t}\n\t\t}\n\t}\n\n\tconstraintPrompt := s.promptI18n.GetVideoConstraintPrompt(referenceMode)\n\tif constraintPrompt != \"\" {\n\t\tprompt = constraintPrompt + \"\\n\\n\" + prompt\n\t\ts.log.Infow(\"Added constraint prompt to video generation\",\n\t\t\t\"id\", videoGenID,\n\t\t\t\"reference_mode\", referenceMode,\n\t\t\t\"constraint_prompt_length\", len(constraintPrompt))\n\t}\n\n\t// 打印完整的提示词信息\n\ts.log.Infow(\"Video generation prompts\",\n\t\t\"id\", videoGenID,\n\t\t\"user_prompt\", videoGen.Prompt,\n\t\t\"constraint_prompt\", constraintPrompt,\n\t\t\"final_prompt\", prompt)\n\n\tresult, err := client.GenerateVideo(imageURL, prompt, opts...)\n\tif err != nil {\n\t\ts.log.Errorw(\"Video generation API call failed\", \"error\", err, \"id\", videoGenID)\n\t\ts.updateVideoGenError(videoGenID, err.Error())\n\t\treturn\n\t}\n\n\t// CRITICAL FIX: Validate TaskID before starting polling goroutine\n\t// Empty TaskID would cause polling to fail silently or cause issues\n\tif result.TaskID != \"\" {\n\t\ts.db.Model(&videoGen).Updates(map[string]interface{}{\n\t\t\t\"task_id\": result.TaskID,\n\t\t\t\"status\":  models.VideoStatusProcessing,\n\t\t})\n\t\t// Start background goroutine to poll task status\n\t\t// This allows the API to return immediately while video generation continues asynchronously\n\t\t// The goroutine will poll until completion, failure, or timeout (max 300 attempts * 10s = 50 minutes)\n\t\tgo s.pollTaskStatus(videoGenID, result.TaskID, videoGen.Provider, videoGen.Model)\n\t\treturn\n\t}\n\n\tif result.VideoURL != \"\" {\n\t\ts.completeVideoGeneration(videoGenID, result.VideoURL, &result.Duration, &result.Width, &result.Height, nil)\n\t\treturn\n\t}\n\n\ts.updateVideoGenError(videoGenID, \"no task ID or video URL returned\")\n}\n\nfunc (s *VideoGenerationService) pollTaskStatus(videoGenID uint, taskID string, provider string, model string) {\n\t// CRITICAL FIX: Validate taskID parameter to prevent invalid API calls\n\t// Empty taskID would cause unnecessary API calls and potential errors\n\tif taskID == \"\" {\n\t\ts.log.Errorw(\"Invalid empty taskID for polling\", \"video_gen_id\", videoGenID)\n\t\ts.updateVideoGenError(videoGenID, \"invalid task ID for polling\")\n\t\treturn\n\t}\n\n\tclient, err := s.getVideoClient(provider, model)\n\tif err != nil {\n\t\ts.log.Errorw(\"Failed to get video client for polling\", \"error\", err)\n\t\ts.updateVideoGenError(videoGenID, \"failed to get video client\")\n\t\treturn\n\t}\n\n\t// Polling configuration: max 300 attempts with 10 second intervals\n\t// Total maximum polling time: 300 * 10s = 50 minutes\n\t// This prevents infinite polling if the task never completes\n\tmaxAttempts := 300\n\tinterval := 10 * time.Second\n\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\t// Sleep before each poll attempt to avoid overwhelming the API\n\t\t// First iteration sleeps before the first check (after 0 attempts)\n\t\ttime.Sleep(interval)\n\n\t\tvar videoGen models.VideoGeneration\n\t\tif err := s.db.First(&videoGen, videoGenID).Error; err != nil {\n\t\t\ts.log.Errorw(\"Failed to load video generation\", \"error\", err, \"id\", videoGenID)\n\t\t\treturn\n\t\t}\n\n\t\t// CRITICAL FIX: Check if status was manually changed (e.g., cancelled by user)\n\t\t// If status is no longer \"processing\", stop polling to avoid unnecessary API calls\n\t\t// This prevents polling when the task has been cancelled or failed externally\n\t\tif videoGen.Status != models.VideoStatusProcessing {\n\t\t\ts.log.Infow(\"Video generation status changed, stopping poll\", \"id\", videoGenID, \"status\", videoGen.Status)\n\t\t\treturn\n\t\t}\n\n\t\t// Poll the video generation API for task status\n\t\t// Continue polling on transient errors (network issues, temporary API failures)\n\t\t// Only stop on permanent errors or task completion\n\t\tresult, err := client.GetTaskStatus(taskID)\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"Failed to get task status\", \"error\", err, \"task_id\", taskID, \"attempt\", attempt+1)\n\t\t\t// Continue polling on error - might be transient network issue\n\t\t\t// Will eventually timeout after maxAttempts if error persists\n\t\t\tcontinue\n\t\t}\n\n\t\t// Check if task completed successfully\n\t\t// CRITICAL FIX: Validate that video URL exists when task is marked as completed\n\t\t// Some APIs may mark task as completed but fail to provide the video URL\n\t\tif result.Completed {\n\t\t\tif result.VideoURL != \"\" {\n\t\t\t\t// Successfully completed with video URL - download and update database\n\t\t\t\ts.completeVideoGeneration(videoGenID, result.VideoURL, &result.Duration, &result.Width, &result.Height, nil)\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// Task marked as completed but no video URL - this is an error condition\n\t\t\ts.updateVideoGenError(videoGenID, \"task completed but no video URL\")\n\t\t\treturn\n\t\t}\n\n\t\t// Check if task failed with an error message\n\t\tif result.Error != \"\" {\n\t\t\ts.updateVideoGenError(videoGenID, result.Error)\n\t\t\treturn\n\t\t}\n\n\t\t// Task still in progress - log and continue polling\n\t\ts.log.Infow(\"Video generation in progress\", \"id\", videoGenID, \"attempt\", attempt+1, \"max_attempts\", maxAttempts)\n\t}\n\n\t// CRITICAL FIX: Handle polling timeout gracefully\n\t// After maxAttempts (50 minutes), mark task as failed if still not completed\n\t// This prevents indefinite polling and resource waste\n\ts.updateVideoGenError(videoGenID, fmt.Sprintf(\"polling timeout after %d attempts (%.1f minutes)\", maxAttempts, float64(maxAttempts*int(interval))/60.0))\n}\n\nfunc (s *VideoGenerationService) completeVideoGeneration(videoGenID uint, videoURL string, duration *int, width *int, height *int, firstFrameURL *string) {\n\tvar localVideoPath *string\n\n\t// 下载视频到本地存储并保存相对路径到数据库\n\tif s.localStorage != nil && videoURL != \"\" {\n\t\tdownloadResult, err := s.localStorage.DownloadFromURLWithPath(videoURL, \"videos\")\n\t\tif err != nil {\n\t\t\ts.log.Warnw(\"Failed to download video to local storage\",\n\t\t\t\t\"error\", err,\n\t\t\t\t\"id\", videoGenID,\n\t\t\t\t\"original_url\", videoURL)\n\t\t} else {\n\t\t\tlocalVideoPath = &downloadResult.RelativePath\n\t\t\ts.log.Infow(\"Video downloaded to local storage\",\n\t\t\t\t\"id\", videoGenID,\n\t\t\t\t\"original_url\", videoURL,\n\t\t\t\t\"local_path\", downloadResult.RelativePath)\n\t\t}\n\t}\n\n\t// 如果视频已下载到本地，探测真实时长\n\t// 特别是当 AI 服务返回的 duration 为 0 或 nil 时，必须探测\n\tshouldProbe := localVideoPath != nil && s.ffmpeg != nil && (duration == nil || *duration == 0)\n\tif shouldProbe {\n\t\tabsPath := s.localStorage.GetAbsolutePath(*localVideoPath)\n\t\tif probedDuration, err := s.ffmpeg.GetVideoDuration(absPath); err == nil {\n\t\t\t// 转换为整数秒（向上取整）\n\t\t\tdurationInt := int(probedDuration + 0.5)\n\t\t\tduration = &durationInt\n\t\t\ts.log.Infow(\"Probed video duration (was 0 or nil)\",\n\t\t\t\t\"id\", videoGenID,\n\t\t\t\t\"duration_seconds\", durationInt,\n\t\t\t\t\"duration_float\", probedDuration)\n\t\t} else {\n\t\t\ts.log.Errorw(\"Failed to probe video duration, duration will be 0\",\n\t\t\t\t\"error\", err,\n\t\t\t\t\"id\", videoGenID,\n\t\t\t\t\"local_path\", *localVideoPath)\n\t\t}\n\t} else if localVideoPath != nil && s.ffmpeg != nil && duration != nil && *duration > 0 {\n\t\t// 即使有 duration，也验证一下（可选）\n\t\tabsPath := s.localStorage.GetAbsolutePath(*localVideoPath)\n\t\tif probedDuration, err := s.ffmpeg.GetVideoDuration(absPath); err == nil {\n\t\t\tdurationInt := int(probedDuration + 0.5)\n\t\t\tif durationInt != *duration {\n\t\t\t\ts.log.Warnw(\"Probed duration differs from provided duration\",\n\t\t\t\t\t\"id\", videoGenID,\n\t\t\t\t\t\"provided\", *duration,\n\t\t\t\t\t\"probed\", durationInt)\n\t\t\t\t// 使用探测到的时长（更准确）\n\t\t\t\tduration = &durationInt\n\t\t\t}\n\t\t}\n\t}\n\n\t// 下载首帧图片到本地存储（仅用于缓存，不更新数据库）\n\tif firstFrameURL != nil && *firstFrameURL != \"\" && s.localStorage != nil {\n\t\t_, err := s.localStorage.DownloadFromURL(*firstFrameURL, \"video_frames\")\n\t\tif err != nil {\n\t\t\ts.log.Warnw(\"Failed to download first frame to local storage\",\n\t\t\t\t\"error\", err,\n\t\t\t\t\"id\", videoGenID,\n\t\t\t\t\"original_url\", *firstFrameURL)\n\t\t} else {\n\t\t\ts.log.Infow(\"First frame downloaded to local storage for caching\",\n\t\t\t\t\"id\", videoGenID,\n\t\t\t\t\"original_url\", *firstFrameURL)\n\t\t}\n\t}\n\n\t// 数据库中保存原始URL和本地路径\n\tupdates := map[string]interface{}{\n\t\t\"status\":     models.VideoStatusCompleted,\n\t\t\"video_url\":  videoURL,\n\t\t\"local_path\": localVideoPath,\n\t}\n\t// 只有当 duration 大于 0 时才保存，避免保存无效的 0 值\n\tif duration != nil && *duration > 0 {\n\t\tupdates[\"duration\"] = *duration\n\t}\n\tif width != nil {\n\t\tupdates[\"width\"] = *width\n\t}\n\tif height != nil {\n\t\tupdates[\"height\"] = *height\n\t}\n\tif firstFrameURL != nil {\n\t\tupdates[\"first_frame_url\"] = *firstFrameURL\n\t}\n\n\tif err := s.db.Model(&models.VideoGeneration{}).Where(\"id = ?\", videoGenID).Updates(updates).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to update video generation\", \"error\", err, \"id\", videoGenID)\n\t\treturn\n\t}\n\n\tvar videoGen models.VideoGeneration\n\tif err := s.db.First(&videoGen, videoGenID).Error; err == nil {\n\t\tif videoGen.StoryboardID != nil {\n\t\t\t// 更新 Storyboard 的 video_url 和 duration\n\t\t\tstoryboardUpdates := map[string]interface{}{\n\t\t\t\t\"video_url\": videoURL,\n\t\t\t}\n\t\t\t// 只有当 duration 大于 0 时才更新，避免用无效的 0 值覆盖\n\t\t\tif duration != nil && *duration > 0 {\n\t\t\t\tstoryboardUpdates[\"duration\"] = *duration\n\t\t\t}\n\t\t\tif err := s.db.Model(&models.Storyboard{}).Where(\"id = ?\", *videoGen.StoryboardID).Updates(storyboardUpdates).Error; err != nil {\n\t\t\t\ts.log.Warnw(\"Failed to update storyboard\", \"storyboard_id\", *videoGen.StoryboardID, \"error\", err)\n\t\t\t} else {\n\t\t\t\ts.log.Infow(\"Updated storyboard with video info\", \"storyboard_id\", *videoGen.StoryboardID, \"duration\", duration)\n\t\t\t}\n\t\t}\n\t}\n\n\ts.log.Infow(\"Video generation completed\", \"id\", videoGenID, \"url\", videoURL, \"duration\", duration)\n}\n\nfunc (s *VideoGenerationService) updateVideoGenError(videoGenID uint, errorMsg string) {\n\tif err := s.db.Model(&models.VideoGeneration{}).Where(\"id = ?\", videoGenID).Updates(map[string]interface{}{\n\t\t\"status\":    models.VideoStatusFailed,\n\t\t\"error_msg\": errorMsg,\n\t}).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to update video generation error\", \"error\", err, \"id\", videoGenID)\n\t}\n}\n\nfunc (s *VideoGenerationService) getVideoClient(provider string, modelName string) (video.VideoClient, error) {\n\t// 根据模型名称获取AI配置\n\tvar config *models.AIServiceConfig\n\tvar err error\n\n\tif modelName != \"\" {\n\t\tconfig, err = s.aiService.GetConfigForModel(\"video\", modelName)\n\t\tif err != nil {\n\t\t\ts.log.Warnw(\"Failed to get config for model, using default\", \"model\", modelName, \"error\", err)\n\t\t\tconfig, err = s.aiService.GetDefaultConfig(\"video\")\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"no video AI config found: %w\", err)\n\t\t\t}\n\t\t}\n\t} else {\n\t\tconfig, err = s.aiService.GetDefaultConfig(\"video\")\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"no video AI config found: %w\", err)\n\t\t}\n\t}\n\n\t// 使用配置中的信息创建客户端\n\tbaseURL := config.BaseURL\n\tapiKey := config.APIKey\n\tmodel := modelName\n\tif model == \"\" && len(config.Model) > 0 {\n\t\tmodel = config.Model[0]\n\t}\n\n\t// 根据配置中的 provider 创建对应的客户端\n\tvar endpoint string\n\tvar queryEndpoint string\n\n\tswitch config.Provider {\n\tcase \"chatfire\":\n\t\tendpoint = \"/video/generations\"\n\t\tqueryEndpoint = \"/video/task/{taskId}\"\n\t\treturn video.NewChatfireClient(baseURL, apiKey, model, endpoint, queryEndpoint), nil\n\tcase \"doubao\", \"volcengine\", \"volces\":\n\t\tendpoint = \"/contents/generations/tasks\"\n\t\tqueryEndpoint = \"/contents/generations/tasks/{taskId}\"\n\t\treturn video.NewVolcesArkClient(baseURL, apiKey, model, endpoint, queryEndpoint), nil\n\tcase \"openai\":\n\t\t// OpenAI Sora 使用 /v1/videos 端点\n\t\treturn video.NewOpenAISoraClient(baseURL, apiKey, model), nil\n\tcase \"runway\":\n\t\treturn video.NewRunwayClient(baseURL, apiKey, model), nil\n\tcase \"pika\":\n\t\treturn video.NewPikaClient(baseURL, apiKey, model), nil\n\tcase \"minimax\":\n\t\treturn video.NewMinimaxClient(baseURL, apiKey, model), nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported video provider: %s\", provider)\n\t}\n}\n\nfunc (s *VideoGenerationService) RecoverPendingTasks() {\n\tvar pendingVideos []models.VideoGeneration\n\t// Query for pending tasks with non-empty task_id\n\t// Note: Using IS NOT NULL and != '' to ensure we only get valid task IDs\n\tif err := s.db.Where(\"status = ? AND task_id IS NOT NULL AND task_id != ''\", models.VideoStatusProcessing).Find(&pendingVideos).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to load pending video tasks\", \"error\", err)\n\t\treturn\n\t}\n\n\ts.log.Infow(\"Recovering pending video generation tasks\", \"count\", len(pendingVideos))\n\n\tfor _, videoGen := range pendingVideos {\n\t\t// CRITICAL FIX: Check for nil TaskID before dereferencing to prevent panic\n\t\t// Even though we filter for non-empty task_id, GORM might still return nil pointers\n\t\t// This nil check prevents a potential runtime panic\n\t\tif videoGen.TaskID == nil || *videoGen.TaskID == \"\" {\n\t\t\ts.log.Warnw(\"Skipping video generation with nil or empty TaskID\", \"id\", videoGen.ID)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Start goroutine to poll task status for each pending video\n\t\t// Each goroutine will poll independently until completion or timeout\n\t\tgo s.pollTaskStatus(videoGen.ID, *videoGen.TaskID, videoGen.Provider, videoGen.Model)\n\t}\n}\n\nfunc (s *VideoGenerationService) GetVideoGeneration(id uint) (*models.VideoGeneration, error) {\n\tvar videoGen models.VideoGeneration\n\tif err := s.db.First(&videoGen, id).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn &videoGen, nil\n}\n\nfunc (s *VideoGenerationService) ListVideoGenerations(dramaID *uint, storyboardID *uint, status string, limit int, offset int) ([]*models.VideoGeneration, int64, error) {\n\tvar videos []*models.VideoGeneration\n\tvar total int64\n\n\tquery := s.db.Model(&models.VideoGeneration{})\n\n\tif dramaID != nil {\n\t\tquery = query.Where(\"drama_id = ?\", *dramaID)\n\t}\n\tif storyboardID != nil {\n\t\tquery = query.Where(\"storyboard_id = ?\", *storyboardID)\n\t}\n\tif status != \"\" {\n\t\tquery = query.Where(\"status = ?\", status)\n\t}\n\n\tif err := query.Count(&total).Error; err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tif err := query.Order(\"created_at DESC\").Limit(limit).Offset(offset).Find(&videos).Error; err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\treturn videos, total, nil\n}\n\nfunc (s *VideoGenerationService) GenerateVideoFromImage(imageGenID uint) (*models.VideoGeneration, error) {\n\tvar imageGen models.ImageGeneration\n\tif err := s.db.First(&imageGen, imageGenID).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"image generation not found\")\n\t}\n\n\tif imageGen.Status != models.ImageStatusCompleted || imageGen.ImageURL == nil {\n\t\treturn nil, fmt.Errorf(\"image is not ready\")\n\t}\n\n\t// 获取关联的Storyboard以获取时长\n\tvar duration *int\n\tif imageGen.StoryboardID != nil {\n\t\tvar storyboard models.Storyboard\n\t\tif err := s.db.Where(\"id = ?\", *imageGen.StoryboardID).First(&storyboard).Error; err == nil {\n\t\t\tduration = &storyboard.Duration\n\t\t\ts.log.Infow(\"Using storyboard duration for video generation\",\n\t\t\t\t\"storyboard_id\", *imageGen.StoryboardID,\n\t\t\t\t\"duration\", storyboard.Duration)\n\t\t}\n\t}\n\n\treq := &GenerateVideoRequest{\n\t\tDramaID:      fmt.Sprintf(\"%d\", imageGen.DramaID),\n\t\tStoryboardID: imageGen.StoryboardID,\n\t\tImageGenID:   &imageGenID,\n\t\tImageURL:     *imageGen.ImageURL,\n\t\tPrompt:       imageGen.Prompt,\n\t\tProvider:     \"doubao\",\n\t\tDuration:     duration,\n\t}\n\n\treturn s.GenerateVideo(req)\n}\n\nfunc (s *VideoGenerationService) BatchGenerateVideosForEpisode(episodeID string) ([]*models.VideoGeneration, error) {\n\tvar episode models.Episode\n\tif err := s.db.Preload(\"Storyboards\").Where(\"id = ?\", episodeID).First(&episode).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"episode not found\")\n\t}\n\n\tvar results []*models.VideoGeneration\n\tfor _, storyboard := range episode.Storyboards {\n\t\tif storyboard.ImagePrompt == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar imageGen models.ImageGeneration\n\t\tif err := s.db.Where(\"storyboard_id = ? AND status = ?\", storyboard.ID, models.ImageStatusCompleted).\n\t\t\tOrder(\"created_at DESC\").First(&imageGen).Error; err != nil {\n\t\t\ts.log.Warnw(\"No completed image for storyboard\", \"storyboard_id\", storyboard.ID)\n\t\t\tcontinue\n\t\t}\n\n\t\tvideoGen, err := s.GenerateVideoFromImage(imageGen.ID)\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"Failed to generate video\", \"storyboard_id\", storyboard.ID, \"error\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tresults = append(results, videoGen)\n\t}\n\n\treturn results, nil\n}\n\nfunc (s *VideoGenerationService) DeleteVideoGeneration(id uint) error {\n\treturn s.db.Delete(&models.VideoGeneration{}, id).Error\n}\n\n// convertImageToBase64 将图片转换为base64格式\n// 优先使用本地存储的图片，如果没有则使用URL\nfunc (s *VideoGenerationService) convertImageToBase64(imageURL string) (string, error) {\n\t// 如果已经是base64格式，直接返回\n\tif strings.HasPrefix(imageURL, \"data:\") {\n\t\treturn imageURL, nil\n\t}\n\n\t// 尝试从本地存储读取\n\tif s.localStorage != nil {\n\t\tvar relativePath string\n\n\t\t// 1. 检查是否是本地URL（包含 /static/）\n\t\tif strings.Contains(imageURL, \"/static/\") {\n\t\t\t// 提取相对路径，例如从 \"http://localhost:5678/static/images/xxx.jpg\" 提取 \"images/xxx.jpg\"\n\t\t\tparts := strings.Split(imageURL, \"/static/\")\n\t\t\tif len(parts) == 2 {\n\t\t\t\trelativePath = parts[1]\n\t\t\t}\n\t\t} else if !strings.HasPrefix(imageURL, \"http://\") && !strings.HasPrefix(imageURL, \"https://\") {\n\t\t\t// 2. 如果不是 HTTP/HTTPS URL，视为相对路径（如 \"images/xxx.jpg\"）\n\t\t\trelativePath = imageURL\n\t\t}\n\n\t\t// 如果识别出相对路径，尝试读取本地文件\n\t\tif relativePath != \"\" {\n\t\t\tabsPath := s.localStorage.GetAbsolutePath(relativePath)\n\n\t\t\t// 使用工具函数转换为base64\n\t\t\tbase64Str, err := utils.ImageToBase64(absPath)\n\t\t\tif err == nil {\n\t\t\t\ts.log.Infow(\"Converted local image to base64\", \"path\", relativePath)\n\t\t\t\treturn base64Str, nil\n\t\t\t}\n\t\t\ts.log.Warnw(\"Failed to convert local image to base64, will try URL\", \"error\", err, \"path\", absPath)\n\t\t}\n\t}\n\n\t// 如果本地读取失败或不是本地路径，尝试从URL下载并转换\n\tbase64Str, err := utils.ImageToBase64(imageURL)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to convert image to base64: %w\", err)\n\t}\n\n\turlLen := len(imageURL)\n\tif urlLen > 50 {\n\t\turlLen = 50\n\t}\n\ts.log.Infow(\"Converted remote image to base64\", \"url\", imageURL[:urlLen])\n\treturn base64Str, nil\n}\n"
  },
  {
    "path": "application/services/video_merge_service.go",
    "content": "package services\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strconv\"\n\t\"time\"\n\n\tmodels \"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/infrastructure/external/ffmpeg\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/drama-generator/backend/pkg/video\"\n\t\"gorm.io/gorm\"\n)\n\ntype VideoMergeService struct {\n\tdb              *gorm.DB\n\taiService       *AIService\n\ttransferService *ResourceTransferService\n\tffmpeg          *ffmpeg.FFmpeg\n\tstoragePath     string\n\tbaseURL         string\n\tlog             *logger.Logger\n}\n\nfunc NewVideoMergeService(db *gorm.DB, transferService *ResourceTransferService, storagePath, baseURL string, log *logger.Logger) *VideoMergeService {\n\treturn &VideoMergeService{\n\t\tdb:              db,\n\t\taiService:       NewAIService(db, log),\n\t\ttransferService: transferService,\n\t\tffmpeg:          ffmpeg.NewFFmpeg(log),\n\t\tstoragePath:     storagePath,\n\t\tbaseURL:         baseURL,\n\t\tlog:             log,\n\t}\n}\n\ntype MergeVideoRequest struct {\n\tEpisodeID string             `json:\"episode_id\" binding:\"required\"`\n\tDramaID   string             `json:\"drama_id\" binding:\"required\"`\n\tTitle     string             `json:\"title\"`\n\tScenes    []models.SceneClip `json:\"scenes\" binding:\"required,min=1\"`\n\tProvider  string             `json:\"provider\"`\n\tModel     string             `json:\"model\"`\n}\n\nfunc (s *VideoMergeService) MergeVideos(req *MergeVideoRequest) (*models.VideoMerge, error) {\n\t// 验证episode权限\n\tvar episode models.Episode\n\tif err := s.db.Preload(\"Drama\").Where(\"id = ?\", req.EpisodeID).First(&episode).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"episode not found\")\n\t}\n\n\t// 验证所有场景都有视频\n\tfor i, scene := range req.Scenes {\n\t\tif scene.VideoURL == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"scene %d has no video\", i+1)\n\t\t}\n\t}\n\n\tprovider := req.Provider\n\tif provider == \"\" {\n\t\tprovider = \"doubao\"\n\t}\n\n\t// 序列化场景列表\n\tscenesJSON, err := json.Marshal(req.Scenes)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to serialize scenes: %w\", err)\n\t}\n\n\ts.log.Infow(\"Serialized scenes to JSON\",\n\t\t\"scenes_count\", len(req.Scenes),\n\t\t\"scenes_json\", string(scenesJSON))\n\n\tepID, _ := strconv.ParseUint(req.EpisodeID, 10, 32)\n\tdramaID, _ := strconv.ParseUint(req.DramaID, 10, 32)\n\n\tvideoMerge := &models.VideoMerge{\n\t\tEpisodeID: uint(epID),\n\t\tDramaID:   uint(dramaID),\n\t\tTitle:     req.Title,\n\t\tProvider:  provider,\n\t\tModel:     &req.Model,\n\t\tScenes:    scenesJSON,\n\t\tStatus:    models.VideoMergeStatusPending,\n\t}\n\n\tif err := s.db.Create(videoMerge).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create merge record: %w\", err)\n\t}\n\n\tgo s.processMergeVideo(videoMerge.ID)\n\n\treturn videoMerge, nil\n}\n\nfunc (s *VideoMergeService) processMergeVideo(mergeID uint) {\n\tvar videoMerge models.VideoMerge\n\tif err := s.db.First(&videoMerge, mergeID).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to load video merge\", \"error\", err, \"id\", mergeID)\n\t\treturn\n\t}\n\n\ts.db.Model(&videoMerge).Update(\"status\", models.VideoMergeStatusProcessing)\n\n\tclient, err := s.getVideoClient(videoMerge.Provider)\n\tif err != nil {\n\t\ts.updateMergeError(mergeID, err.Error())\n\t\treturn\n\t}\n\n\t// 解析场景列表\n\tvar scenes []models.SceneClip\n\tif err := json.Unmarshal(videoMerge.Scenes, &scenes); err != nil {\n\t\ts.updateMergeError(mergeID, fmt.Sprintf(\"failed to parse scenes: %v\", err))\n\t\treturn\n\t}\n\n\t// 调用视频合并API\n\tresult, err := s.mergeVideoClips(client, scenes)\n\tif err != nil {\n\t\ts.updateMergeError(mergeID, err.Error())\n\t\treturn\n\t}\n\n\tif !result.Completed {\n\t\ts.db.Model(&videoMerge).Updates(map[string]interface{}{\n\t\t\t\"status\":  models.VideoMergeStatusProcessing,\n\t\t\t\"task_id\": result.TaskID,\n\t\t})\n\t\tgo s.pollMergeStatus(mergeID, client, result.TaskID)\n\t\treturn\n\t}\n\n\ts.completeMerge(mergeID, result)\n}\n\nfunc (s *VideoMergeService) mergeVideoClips(client video.VideoClient, scenes []models.SceneClip) (*video.VideoResult, error) {\n\tif len(scenes) == 0 {\n\t\treturn nil, fmt.Errorf(\"no scenes to merge\")\n\t}\n\n\t// 按Order字段排序场景\n\tsort.Slice(scenes, func(i, j int) bool {\n\t\treturn scenes[i].Order < scenes[j].Order\n\t})\n\n\ts.log.Infow(\"Merging video clips with FFmpeg\", \"scene_count\", len(scenes))\n\n\t// 计算总时长\n\tvar totalDuration float64\n\tfor _, scene := range scenes {\n\t\ttotalDuration += scene.Duration\n\t}\n\n\t// 准备FFmpeg合成选项\n\tclips := make([]ffmpeg.VideoClip, len(scenes))\n\tfor i, scene := range scenes {\n\t\t// 使用 scene.VideoURL，它已经在前面的代码中被正确处理\n\t\t// 如果是本地文件，已经包含了完整路径（storagePath + LocalPath）\n\t\t// 如果是 HTTP URL，则直接使用\n\t\tvideoPath := scene.VideoURL\n\n\t\tclips[i] = ffmpeg.VideoClip{\n\t\t\tURL:        videoPath,\n\t\t\tDuration:   scene.Duration,\n\t\t\tStartTime:  scene.StartTime,\n\t\t\tEndTime:    scene.EndTime,\n\t\t\tTransition: scene.Transition,\n\t\t}\n\n\t\ts.log.Infow(\"Clip added to merge queue\",\n\t\t\t\"order\", scene.Order,\n\t\t\t\"index\", i,\n\t\t\t\"video_path\", videoPath,\n\t\t\t\"duration\", scene.Duration,\n\t\t\t\"start_time\", scene.StartTime,\n\t\t\t\"end_time\", scene.EndTime)\n\t}\n\n\t// 创建视频输出目录\n\tvideoDir := filepath.Join(s.storagePath, \"videos\", \"merged\")\n\tif err := os.MkdirAll(videoDir, 0755); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create video directory: %w\", err)\n\t}\n\n\t// 生成输出文件名\n\tfileName := fmt.Sprintf(\"merged_%d.mp4\", time.Now().Unix())\n\toutputPath := filepath.Join(videoDir, fileName)\n\n\t// 使用FFmpeg合成视频\n\tmergedPath, err := s.ffmpeg.MergeVideos(&ffmpeg.MergeOptions{\n\t\tOutputPath: outputPath,\n\t\tClips:      clips,\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"ffmpeg merge failed: %w\", err)\n\t}\n\n\ts.log.Infow(\"Video merged successfully\", \"path\", mergedPath)\n\n\t// 生成相对路径（不包含协议、IP、端口）\n\trelPath := filepath.Join(\"videos\", \"merged\", fileName)\n\n\tresult := &video.VideoResult{\n\t\tVideoURL:  relPath, // 只保存相对路径\n\t\tDuration:  int(totalDuration),\n\t\tCompleted: true,\n\t\tStatus:    \"completed\",\n\t}\n\n\treturn result, nil\n}\n\nfunc (s *VideoMergeService) pollMergeStatus(mergeID uint, client video.VideoClient, taskID string) {\n\tmaxAttempts := 240\n\tpollInterval := 5 * time.Second\n\n\tfor i := 0; i < maxAttempts; i++ {\n\t\ttime.Sleep(pollInterval)\n\n\t\tresult, err := client.GetTaskStatus(taskID)\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"Failed to get merge task status\", \"error\", err, \"task_id\", taskID)\n\t\t\tcontinue\n\t\t}\n\n\t\tif result.Completed {\n\t\t\ts.completeMerge(mergeID, result)\n\t\t\treturn\n\t\t}\n\n\t\tif result.Error != \"\" {\n\t\t\ts.updateMergeError(mergeID, result.Error)\n\t\t\treturn\n\t\t}\n\t}\n\n\ts.updateMergeError(mergeID, \"timeout: video merge took too long\")\n}\n\nfunc (s *VideoMergeService) completeMerge(mergeID uint, result *video.VideoResult) {\n\tnow := time.Now()\n\n\t// 获取merge记录\n\tvar videoMerge models.VideoMerge\n\tif err := s.db.First(&videoMerge, mergeID).Error; err != nil {\n\t\ts.log.Errorw(\"Failed to load video merge for completion\", \"error\", err, \"id\", mergeID)\n\t\treturn\n\t}\n\n\tfinalVideoURL := result.VideoURL\n\n\t// 使用本地存储，不再使用MinIO\n\ts.log.Infow(\"Video merge completed, using local storage\", \"merge_id\", mergeID, \"local_path\", result.VideoURL)\n\n\tupdates := map[string]interface{}{\n\t\t\"status\":       models.VideoMergeStatusCompleted,\n\t\t\"merged_url\":   finalVideoURL,\n\t\t\"completed_at\": now,\n\t}\n\n\tif result.Duration > 0 {\n\t\tupdates[\"duration\"] = result.Duration\n\t}\n\n\ts.db.Model(&models.VideoMerge{}).Where(\"id = ?\", mergeID).Updates(updates)\n\n\t// 更新episode的状态和最终视频URL\n\tif videoMerge.EpisodeID != 0 {\n\t\ts.db.Model(&models.Episode{}).Where(\"id = ?\", videoMerge.EpisodeID).Updates(map[string]interface{}{\n\t\t\t\"status\":    \"completed\",\n\t\t\t\"video_url\": finalVideoURL,\n\t\t})\n\t\ts.log.Infow(\"Episode finalized\", \"episode_id\", videoMerge.EpisodeID, \"video_url\", finalVideoURL)\n\t}\n\n\ts.log.Infow(\"Video merge completed\", \"id\", mergeID, \"url\", finalVideoURL)\n}\n\nfunc (s *VideoMergeService) updateMergeError(mergeID uint, errorMsg string) {\n\ts.db.Model(&models.VideoMerge{}).Where(\"id = ?\", mergeID).Updates(map[string]interface{}{\n\t\t\"status\":    models.VideoMergeStatusFailed,\n\t\t\"error_msg\": errorMsg,\n\t})\n\ts.log.Errorw(\"Video merge failed\", \"id\", mergeID, \"error\", errorMsg)\n}\n\nfunc (s *VideoMergeService) getVideoClient(provider string) (video.VideoClient, error) {\n\tconfig, err := s.aiService.GetDefaultConfig(\"video\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get video config: %w\", err)\n\t}\n\n\t// 使用第一个模型\n\tmodel := \"\"\n\tif len(config.Model) > 0 {\n\t\tmodel = config.Model[0]\n\t}\n\n\t// 根据配置中的 provider 创建对应的客户端\n\tvar endpoint string\n\tvar queryEndpoint string\n\n\tswitch config.Provider {\n\tcase \"runway\":\n\t\treturn video.NewRunwayClient(config.BaseURL, config.APIKey, model), nil\n\tcase \"pika\":\n\t\treturn video.NewPikaClient(config.BaseURL, config.APIKey, model), nil\n\tcase \"openai\", \"sora\":\n\t\treturn video.NewOpenAISoraClient(config.BaseURL, config.APIKey, model), nil\n\tcase \"minimax\":\n\t\treturn video.NewMinimaxClient(config.BaseURL, config.APIKey, model), nil\n\tcase \"chatfire\":\n\t\tendpoint = \"/video/generations\"\n\t\tqueryEndpoint = \"/video/task/{taskId}\"\n\t\treturn video.NewChatfireClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil\n\tcase \"doubao\", \"volces\", \"ark\":\n\t\tendpoint = \"/contents/generations/tasks\"\n\t\tqueryEndpoint = \"/generations/tasks/{taskId}\"\n\t\treturn video.NewVolcesArkClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil\n\tdefault:\n\t\tendpoint = \"/contents/generations/tasks\"\n\t\tqueryEndpoint = \"/generations/tasks/{taskId}\"\n\t\treturn video.NewVolcesArkClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil\n\t}\n}\n\nfunc (s *VideoMergeService) GetMerge(mergeID uint) (*models.VideoMerge, error) {\n\tvar merge models.VideoMerge\n\tif err := s.db.Where(\"id = ? \", mergeID).First(&merge).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn &merge, nil\n}\n\nfunc (s *VideoMergeService) ListMerges(episodeID *string, status string, page, pageSize int) ([]models.VideoMerge, int64, error) {\n\tquery := s.db.Model(&models.VideoMerge{})\n\n\tif episodeID != nil && *episodeID != \"\" {\n\t\tquery = query.Where(\"episode_id = ?\", *episodeID)\n\t}\n\n\tif status != \"\" {\n\t\tquery = query.Where(\"status = ?\", status)\n\t}\n\n\tvar total int64\n\tif err := query.Count(&total).Error; err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tvar merges []models.VideoMerge\n\toffset := (page - 1) * pageSize\n\tif err := query.Order(\"created_at DESC\").Offset(offset).Limit(pageSize).Find(&merges).Error; err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\treturn merges, total, nil\n}\n\nfunc (s *VideoMergeService) DeleteMerge(mergeID uint) error {\n\tresult := s.db.Where(\"id = ? \", mergeID).Delete(&models.VideoMerge{})\n\tif result.Error != nil {\n\t\treturn result.Error\n\t}\n\tif result.RowsAffected == 0 {\n\t\treturn fmt.Errorf(\"merge not found\")\n\t}\n\treturn nil\n}\n\n// TimelineClip 时间线片段数据\ntype TimelineClip struct {\n\tAssetID      interface{}            `json:\"asset_id\"`      // 素材库视频ID（优先使用，可以是数字或字符串）\n\tStoryboardID string                 `json:\"storyboard_id\"` // 分镜ID（fallback）\n\tOrder        int                    `json:\"order\"`\n\tStartTime    float64                `json:\"start_time\"`\n\tEndTime      float64                `json:\"end_time\"`\n\tDuration     float64                `json:\"duration\"`\n\tTransition   map[string]interface{} `json:\"transition\"`\n}\n\n// getAssetIDString 将 AssetID 转换为字符串\nfunc getAssetIDString(assetID interface{}) string {\n\tif assetID == nil {\n\t\treturn \"\"\n\t}\n\tswitch v := assetID.(type) {\n\tcase string:\n\t\treturn v\n\tcase float64:\n\t\treturn fmt.Sprintf(\"%.0f\", v)\n\tcase int:\n\t\treturn fmt.Sprintf(\"%d\", v)\n\tdefault:\n\t\treturn fmt.Sprintf(\"%v\", v)\n\t}\n}\n\n// FinalizeEpisodeRequest 完成剧集制作请求\ntype FinalizeEpisodeRequest struct {\n\tEpisodeID string         `json:\"episode_id\"`\n\tClips     []TimelineClip `json:\"clips\"`\n}\n\n// FinalizeEpisode 完成集数制作，根据时间线场景顺序合成最终视频\nfunc (s *VideoMergeService) FinalizeEpisode(episodeID string, timelineData *FinalizeEpisodeRequest) (map[string]interface{}, error) {\n\t// 验证episode存在且属于该用户\n\tvar episode models.Episode\n\tif err := s.db.Preload(\"Drama\").Preload(\"Storyboards\").Where(\"id = ?\", episodeID).First(&episode).Error; err != nil {\n\t\treturn nil, fmt.Errorf(\"episode not found\")\n\t}\n\n\t// 构建分镜ID映射\n\tsceneMap := make(map[string]models.Storyboard)\n\tfor _, scene := range episode.Storyboards {\n\t\tsceneMap[fmt.Sprintf(\"%d\", scene.ID)] = scene\n\t}\n\n\t// 根据时间线数据构建场景片段\n\tvar sceneClips []models.SceneClip\n\tvar skippedScenes []int\n\n\tif timelineData != nil && len(timelineData.Clips) > 0 {\n\t\ts.log.Infow(\"Processing timeline data\", \"clips_count\", len(timelineData.Clips))\n\t\t// 使用前端提供的时间线数据\n\t\tfor i, clip := range timelineData.Clips {\n\t\t\tassetIDStr := getAssetIDString(clip.AssetID)\n\t\t\ts.log.Infow(\"Processing clip\", \"index\", i, \"storyboard_id\", clip.StoryboardID, \"asset_id\", assetIDStr, \"order\", clip.Order)\n\t\t\t// 优先使用素材库中的视频（通过AssetID）\n\t\t\tvar videoURL string\n\t\t\tvar sceneID uint\n\n\t\t\tif assetIDStr != \"\" {\n\t\t\t\t// 从素材库获取视频，优先使用 local_path\n\t\t\t\tvar asset models.Asset\n\t\t\t\tif err := s.db.Where(\"id = ? AND type = ?\", assetIDStr, models.AssetTypeVideo).First(&asset).Error; err == nil {\n\t\t\t\t\t// 优先使用 local_path\n\t\t\t\t\tif asset.LocalPath != nil && *asset.LocalPath != \"\" {\n\t\t\t\t\t\t// 检查是否已经是完整路径\n\t\t\t\t\t\tif filepath.IsAbs(*asset.LocalPath) || filepath.HasPrefix(*asset.LocalPath, s.storagePath) {\n\t\t\t\t\t\t\tvideoURL = *asset.LocalPath\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tvideoURL = filepath.Join(s.storagePath, *asset.LocalPath)\n\t\t\t\t\t\t}\n\t\t\t\t\t\ts.log.Infow(\"Using local video from asset library\", \"asset_id\", assetIDStr, \"local_path\", videoURL)\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// 回退到远程 URL\n\t\t\t\t\t\tvideoURL = asset.URL\n\t\t\t\t\t\ts.log.Infow(\"Using remote video from asset library\", \"asset_id\", assetIDStr, \"video_url\", videoURL)\n\t\t\t\t\t}\n\t\t\t\t\t// 如果asset关联了storyboard，使用关联的storyboard_id\n\t\t\t\t\tif asset.StoryboardID != nil {\n\t\t\t\t\t\tsceneID = *asset.StoryboardID\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\ts.log.Warnw(\"Asset not found, will try storyboard video\", \"asset_id\", assetIDStr, \"error\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 如果没有从素材库获取到视频，尝试从storyboard获取\n\t\t\tif videoURL == \"\" && clip.StoryboardID != \"\" {\n\t\t\t\tscene, exists := sceneMap[clip.StoryboardID]\n\t\t\t\tif !exists {\n\t\t\t\t\ts.log.Warnw(\"Storyboard not found in episode, skipping\", \"storyboard_id\", clip.StoryboardID)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// 查找关联的 video_generation 记录以获取 local_path\n\t\t\t\tvar videoGen models.VideoGeneration\n\t\t\t\tif err := s.db.Where(\"storyboard_id = ? AND status = ?\", scene.ID, \"completed\").Order(\"created_at DESC\").First(&videoGen).Error; err == nil {\n\t\t\t\t\tif videoGen.LocalPath != nil && *videoGen.LocalPath != \"\" {\n\t\t\t\t\t\t// 检查是否已经是完整路径\n\t\t\t\t\t\tif filepath.IsAbs(*videoGen.LocalPath) || filepath.HasPrefix(*videoGen.LocalPath, s.storagePath) {\n\t\t\t\t\t\t\tvideoURL = *videoGen.LocalPath\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tvideoURL = filepath.Join(s.storagePath, *videoGen.LocalPath)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tsceneID = scene.ID\n\t\t\t\t\t\ts.log.Infow(\"Using local video from video_generation\", \"storyboard_id\", clip.StoryboardID, \"local_path\", videoURL)\n\t\t\t\t\t} else if scene.VideoURL != nil && *scene.VideoURL != \"\" {\n\t\t\t\t\t\t// 回退到远程 URL\n\t\t\t\t\t\tvideoURL = *scene.VideoURL\n\t\t\t\t\t\tsceneID = scene.ID\n\t\t\t\t\t\ts.log.Infow(\"Using remote video from storyboard\", \"storyboard_id\", clip.StoryboardID, \"video_url\", videoURL)\n\t\t\t\t\t}\n\t\t\t\t} else if scene.VideoURL != nil && *scene.VideoURL != \"\" {\n\t\t\t\t\t// 如果没有找到 video_generation，直接使用 storyboard 的 video_url\n\t\t\t\t\tvideoURL = *scene.VideoURL\n\t\t\t\t\tsceneID = scene.ID\n\t\t\t\t\ts.log.Infow(\"Using video from storyboard (no video_generation found)\", \"storyboard_id\", clip.StoryboardID, \"video_url\", videoURL)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 如果仍然没有视频URL，跳过该片段\n\t\t\tif videoURL == \"\" {\n\t\t\t\ts.log.Warnw(\"No video available for clip, skipping\", \"clip\", clip)\n\t\t\t\tif clip.StoryboardID != \"\" {\n\t\t\t\t\tif scene, exists := sceneMap[clip.StoryboardID]; exists {\n\t\t\t\t\t\tskippedScenes = append(skippedScenes, scene.StoryboardNumber)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tsceneClip := models.SceneClip{\n\t\t\t\tSceneID:    sceneID,\n\t\t\t\tVideoURL:   videoURL,\n\t\t\t\tDuration:   clip.Duration,\n\t\t\t\tOrder:      clip.Order,\n\t\t\t\tStartTime:  clip.StartTime,\n\t\t\t\tEndTime:    clip.EndTime,\n\t\t\t\tTransition: clip.Transition,\n\t\t\t}\n\t\t\ts.log.Infow(\"Adding scene clip with transition\",\n\t\t\t\t\"scene_id\", sceneID,\n\t\t\t\t\"order\", clip.Order,\n\t\t\t\t\"video_url\", videoURL,\n\t\t\t\t\"transition\", clip.Transition)\n\t\t\tsceneClips = append(sceneClips, sceneClip)\n\t\t\ts.log.Infow(\"Scene clip added\", \"total_clips\", len(sceneClips))\n\t\t}\n\t} else {\n\t\t// 没有时间线数据，使用默认场景顺序\n\t\tif len(episode.Storyboards) == 0 {\n\t\t\treturn nil, fmt.Errorf(\"no scenes found for this episode\")\n\t\t}\n\n\t\torder := 0\n\t\tfor _, scene := range episode.Storyboards {\n\t\t\t// 优先从素材库查找该分镜关联的视频\n\t\t\tvar videoURL string\n\t\t\tvar asset models.Asset\n\t\t\tif err := s.db.Where(\"storyboard_id = ? AND type = ? AND episode_id = ?\",\n\t\t\t\tscene.ID, models.AssetTypeVideo, episode.ID).\n\t\t\t\tOrder(\"created_at DESC\").\n\t\t\t\tFirst(&asset).Error; err == nil {\n\t\t\t\t// 优先使用 local_path\n\t\t\t\tif asset.LocalPath != nil && *asset.LocalPath != \"\" {\n\t\t\t\t\t// 检查是否已经是完整路径\n\t\t\t\t\tif filepath.IsAbs(*asset.LocalPath) || filepath.HasPrefix(*asset.LocalPath, s.storagePath) {\n\t\t\t\t\t\tvideoURL = *asset.LocalPath\n\t\t\t\t\t} else {\n\t\t\t\t\t\tvideoURL = filepath.Join(s.storagePath, *asset.LocalPath)\n\t\t\t\t\t}\n\t\t\t\t\ts.log.Infow(\"Using local video from asset library for storyboard\",\n\t\t\t\t\t\t\"storyboard_id\", scene.ID,\n\t\t\t\t\t\t\"asset_id\", asset.ID,\n\t\t\t\t\t\t\"local_path\", videoURL)\n\t\t\t\t} else {\n\t\t\t\t\tvideoURL = asset.URL\n\t\t\t\t\ts.log.Infow(\"Using remote video from asset library for storyboard\",\n\t\t\t\t\t\t\"storyboard_id\", scene.ID,\n\t\t\t\t\t\t\"asset_id\", asset.ID,\n\t\t\t\t\t\t\"video_url\", videoURL)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// 如果素材库没有，查找 video_generation 记录\n\t\t\t\tvar videoGen models.VideoGeneration\n\t\t\t\tif err := s.db.Where(\"storyboard_id = ? AND status = ?\", scene.ID, \"completed\").Order(\"created_at DESC\").First(&videoGen).Error; err == nil {\n\t\t\t\t\tif videoGen.LocalPath != nil && *videoGen.LocalPath != \"\" {\n\t\t\t\t\t\t// 检查是否已经是完整路径\n\t\t\t\t\t\tif filepath.IsAbs(*videoGen.LocalPath) || filepath.HasPrefix(*videoGen.LocalPath, s.storagePath) {\n\t\t\t\t\t\t\tvideoURL = *videoGen.LocalPath\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tvideoURL = filepath.Join(s.storagePath, *videoGen.LocalPath)\n\t\t\t\t\t\t}\n\t\t\t\t\t\ts.log.Infow(\"Using local video from video_generation for storyboard\",\n\t\t\t\t\t\t\t\"storyboard_id\", scene.ID,\n\t\t\t\t\t\t\t\"local_path\", videoURL)\n\t\t\t\t\t} else if scene.VideoURL != nil && *scene.VideoURL != \"\" {\n\t\t\t\t\t\tvideoURL = *scene.VideoURL\n\t\t\t\t\t\ts.log.Infow(\"Using remote video from storyboard\",\n\t\t\t\t\t\t\t\"storyboard_id\", scene.ID,\n\t\t\t\t\t\t\t\"video_url\", videoURL)\n\t\t\t\t\t}\n\t\t\t\t} else if scene.VideoURL != nil && *scene.VideoURL != \"\" {\n\t\t\t\t\t// 最后回退到 storyboard 的 video_url\n\t\t\t\t\tvideoURL = *scene.VideoURL\n\t\t\t\t\ts.log.Infow(\"Using fallback video from storyboard\",\n\t\t\t\t\t\t\"storyboard_id\", scene.ID,\n\t\t\t\t\t\t\"video_url\", videoURL)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 跳过没有视频的场景\n\t\t\tif videoURL == \"\" {\n\t\t\t\ts.log.Warnw(\"Scene has no video, skipping\", \"storyboard_number\", scene.StoryboardNumber)\n\t\t\t\tskippedScenes = append(skippedScenes, scene.StoryboardNumber)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tclip := models.SceneClip{\n\t\t\t\tSceneID:  scene.ID,\n\t\t\t\tVideoURL: videoURL,\n\t\t\t\tDuration: float64(scene.Duration),\n\t\t\t\tOrder:    order,\n\t\t\t}\n\t\t\tsceneClips = append(sceneClips, clip)\n\t\t\torder++\n\t\t}\n\t}\n\n\t// 检查是否至少有一个场景可以合成\n\tif len(sceneClips) == 0 {\n\t\treturn nil, fmt.Errorf(\"no scenes with videos available for merging\")\n\t}\n\n\t// 创建视频合成任务\n\ttitle := fmt.Sprintf(\"%s - 第%d集\", episode.Drama.Title, episode.EpisodeNum)\n\n\tfinalReq := &MergeVideoRequest{\n\t\tEpisodeID: episodeID,\n\t\tDramaID:   fmt.Sprintf(\"%d\", episode.DramaID),\n\t\tTitle:     title,\n\t\tScenes:    sceneClips,\n\t\tProvider:  \"doubao\", // 默认使用doubao\n\t}\n\n\t// 执行视频合成\n\tvideoMerge, err := s.MergeVideos(finalReq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to start video merge: %w\", err)\n\t}\n\n\t// 更新episode状态为processing\n\ts.db.Model(&episode).Updates(map[string]interface{}{\n\t\t\"status\": \"processing\",\n\t})\n\n\tresult := map[string]interface{}{\n\t\t\"message\":      \"视频合成任务已创建，正在后台处理\",\n\t\t\"merge_id\":     videoMerge.ID,\n\t\t\"episode_id\":   episodeID,\n\t\t\"scenes_count\": len(sceneClips),\n\t}\n\n\t// 如果有跳过的场景，添加提示信息\n\tif len(skippedScenes) > 0 {\n\t\tresult[\"skipped_scenes\"] = skippedScenes\n\t\tresult[\"warning\"] = fmt.Sprintf(\"已跳过 %d 个未生成视频的场景（场景编号：%v）\", len(skippedScenes), skippedScenes)\n\t}\n\n\treturn result, nil\n}\n"
  },
  {
    "path": "cmd/migrate/main.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/infrastructure/database\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\n\t\"gorm.io/gorm\"\n)\n\n// DataMigrationService 数据迁移服务\ntype DataMigrationService struct {\n\tdb          *gorm.DB\n\tlog         *logger.Logger\n\tstorageRoot string\n\turlMapping  map[string]string // 原始URL -> 本地路径的映射\n}\n\n// MigrationStats 迁移统计信息\ntype MigrationStats struct {\n\tAssetsSuccess             int\n\tAssetsFailed              int\n\tCharacterLibrariesSuccess int\n\tCharacterLibrariesFailed  int\n\tCharactersSuccess         int\n\tCharactersFailed          int\n\tImageGenerationsSuccess   int\n\tImageGenerationsFailed    int\n\tScenesSuccess             int\n\tScenesFailed              int\n\tVideosSuccess             int\n\tVideosFailed              int\n}\n\nfunc main() {\n\tfmt.Println(\"=== 数据清洗工具：迁移 local_path ===\")\n\tfmt.Println(\"开始时间:\", time.Now().Format(\"2006-01-02 15:04:05\"))\n\tfmt.Println()\n\n\t// 1. 初始化日志\n\tlogr := logger.NewLogger(false)\n\tlogr.Info(\"初始化日志系统...\")\n\n\t// 2. 加载配置\n\tcfg, err := config.LoadConfig()\n\tif err != nil {\n\t\tlogr.Fatalw(\"加载配置失败\", \"error\", err)\n\t}\n\tlogr.Info(\"配置加载成功\")\n\n\t// 3. 连接数据库\n\tdb, err := database.NewDatabase(cfg.Database)\n\tif err != nil {\n\t\tlogr.Fatalw(\"数据库连接失败\", \"error\", err)\n\t}\n\tlogr.Info(\"数据库连接成功\")\n\n\t// 4. 创建迁移服务\n\tservice := &DataMigrationService{\n\t\tdb:          db,\n\t\tlog:         logr,\n\t\tstorageRoot: \"data/storage\",\n\t\turlMapping:  make(map[string]string),\n\t}\n\n\t// 5. 执行迁移\n\tif err := service.MigrateLocalPaths(); err != nil {\n\t\tlogr.Fatalw(\"数据清洗失败\", \"error\", err)\n\t}\n\n\tfmt.Println()\n\tfmt.Println(\"=== 数据清洗完成 ===\")\n\tfmt.Println(\"结束时间:\", time.Now().Format(\"2006-01-02 15:04:05\"))\n}\n\n// MigrateLocalPaths 迁移所有表中 local_path 为空的数据\nfunc (s *DataMigrationService) MigrateLocalPaths() error {\n\ts.log.Info(\"开始数据清洗：迁移 local_path 为空的数据\")\n\tstartTime := time.Now()\n\n\t// 确保存储目录存在\n\tif err := s.ensureStorageDirectories(); err != nil {\n\t\treturn fmt.Errorf(\"创建存储目录失败: %w\", err)\n\t}\n\n\t// 迁移各个表的数据（按指定顺序）\n\tstats := &MigrationStats{}\n\n\t// 1. 迁移 assets 表\n\tif err := s.migrateAssets(stats); err != nil {\n\t\ts.log.Errorw(\"迁移 assets 数据失败\", \"error\", err)\n\t}\n\n\t// 2. 迁移 character_libraries 表\n\tif err := s.migrateCharacterLibraries(stats); err != nil {\n\t\ts.log.Errorw(\"迁移 character_libraries 数据失败\", \"error\", err)\n\t}\n\n\t// 3. 迁移 characters 表\n\tif err := s.migrateCharacters(stats); err != nil {\n\t\ts.log.Errorw(\"迁移 characters 数据失败\", \"error\", err)\n\t}\n\n\t// 4. 迁移 image_generations 表\n\tif err := s.migrateImageGenerations(stats); err != nil {\n\t\ts.log.Errorw(\"迁移 image_generations 数据失败\", \"error\", err)\n\t}\n\n\t// 5. 迁移 scenes 表\n\tif err := s.migrateScenes(stats); err != nil {\n\t\ts.log.Errorw(\"迁移 scenes 数据失败\", \"error\", err)\n\t}\n\n\t// 6. 迁移 video_generations 表\n\tif err := s.migrateVideoGenerations(stats); err != nil {\n\t\ts.log.Errorw(\"迁移 video_generations 数据失败\", \"error\", err)\n\t}\n\n\tduration := time.Since(startTime)\n\ts.log.Infow(\"数据清洗完成\",\n\t\t\"总耗时\", duration.String(),\n\t\t\"URL映射缓存数\", len(s.urlMapping),\n\t\t\"Assets成功\", stats.AssetsSuccess,\n\t\t\"Assets失败\", stats.AssetsFailed,\n\t\t\"角色库成功\", stats.CharacterLibrariesSuccess,\n\t\t\"角色库失败\", stats.CharacterLibrariesFailed,\n\t\t\"角色成功\", stats.CharactersSuccess,\n\t\t\"角色失败\", stats.CharactersFailed,\n\t\t\"图片生成成功\", stats.ImageGenerationsSuccess,\n\t\t\"图片生成失败\", stats.ImageGenerationsFailed,\n\t\t\"场景成功\", stats.ScenesSuccess,\n\t\t\"场景失败\", stats.ScenesFailed,\n\t\t\"视频成功\", stats.VideosSuccess,\n\t\t\"视频失败\", stats.VideosFailed,\n\t)\n\n\treturn nil\n}\n\n// ensureStorageDirectories 确保存储目录存在\nfunc (s *DataMigrationService) ensureStorageDirectories() error {\n\tdirs := []string{\n\t\tfilepath.Join(s.storageRoot, \"images\"),\n\t\tfilepath.Join(s.storageRoot, \"characters\"),\n\t\tfilepath.Join(s.storageRoot, \"videos\"),\n\t}\n\n\tfor _, dir := range dirs {\n\t\tif err := os.MkdirAll(dir, 0755); err != nil {\n\t\t\treturn fmt.Errorf(\"创建目录 %s 失败: %w\", dir, err)\n\t\t}\n\t}\n\n\ts.log.Infow(\"存储目录创建成功\", \"root\", s.storageRoot)\n\treturn nil\n}\n\n// migrateAssets 迁移 assets 表数据\nfunc (s *DataMigrationService) migrateAssets(stats *MigrationStats) error {\n\ts.log.Info(\"开始迁移 assets 数据...\")\n\n\tvar assets []models.Asset\n\tif err := s.db.Where(\"(local_path IS NULL OR local_path = '') AND url IS NOT NULL AND url != ''\").Find(&assets).Error; err != nil {\n\t\treturn fmt.Errorf(\"查询 assets 数据失败: %w\", err)\n\t}\n\n\ts.log.Infow(\"找到需要迁移的 assets\", \"数量\", len(assets))\n\n\tfor _, asset := range assets {\n\t\ts.log.Infow(\"处理 asset\", \"id\", asset.ID, \"name\", asset.Name, \"type\", asset.Type, \"url\", asset.URL)\n\n\t\tsubDir := \"images\"\n\t\tif asset.Type == models.AssetTypeVideo {\n\t\t\tsubDir = \"videos\"\n\t\t}\n\n\t\tlocalPath, err := s.downloadOrGetCached(asset.URL, subDir, fmt.Sprintf(\"asset_%d\", asset.ID))\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"下载 asset 失败\", \"asset_id\", asset.ID, \"error\", err)\n\t\t\tstats.AssetsFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := s.db.Model(&asset).Update(\"local_path\", localPath).Error; err != nil {\n\t\t\ts.log.Errorw(\"更新 asset local_path 失败\", \"asset_id\", asset.ID, \"error\", err)\n\t\t\tstats.AssetsFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\ts.log.Infow(\"asset 迁移成功\", \"asset_id\", asset.ID, \"local_path\", localPath)\n\t\tstats.AssetsSuccess++\n\t}\n\n\treturn nil\n}\n\n// migrateCharacterLibraries 迁移 character_libraries 表数据\nfunc (s *DataMigrationService) migrateCharacterLibraries(stats *MigrationStats) error {\n\ts.log.Info(\"开始迁移 character_libraries 数据...\")\n\n\tvar charLibs []models.CharacterLibrary\n\tif err := s.db.Where(\"(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''\").Find(&charLibs).Error; err != nil {\n\t\treturn fmt.Errorf(\"查询 character_libraries 数据失败: %w\", err)\n\t}\n\n\ts.log.Infow(\"找到需要迁移的 character_libraries\", \"数量\", len(charLibs))\n\n\tfor _, charLib := range charLibs {\n\t\ts.log.Infow(\"处理 character_library\", \"id\", charLib.ID, \"name\", charLib.Name, \"image_url\", charLib.ImageURL)\n\n\t\tlocalPath, err := s.downloadOrGetCached(charLib.ImageURL, \"characters\", fmt.Sprintf(\"charlib_%d\", charLib.ID))\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"下载 character_library 图片失败\", \"charlib_id\", charLib.ID, \"error\", err)\n\t\t\tstats.CharacterLibrariesFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := s.db.Model(&charLib).Update(\"local_path\", localPath).Error; err != nil {\n\t\t\ts.log.Errorw(\"更新 character_library local_path 失败\", \"charlib_id\", charLib.ID, \"error\", err)\n\t\t\tstats.CharacterLibrariesFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\ts.log.Infow(\"character_library 迁移成功\", \"charlib_id\", charLib.ID, \"local_path\", localPath)\n\t\tstats.CharacterLibrariesSuccess++\n\t}\n\n\treturn nil\n}\n\n// migrateCharacters 迁移角色数据\nfunc (s *DataMigrationService) migrateCharacters(stats *MigrationStats) error {\n\ts.log.Info(\"开始迁移角色数据...\")\n\n\tvar characters []models.Character\n\tif err := s.db.Where(\"(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''\").Find(&characters).Error; err != nil {\n\t\treturn fmt.Errorf(\"查询角色数据失败: %w\", err)\n\t}\n\n\ts.log.Infow(\"找到需要迁移的角色\", \"数量\", len(characters))\n\n\tfor _, character := range characters {\n\t\tif character.ImageURL == nil {\n\t\t\tcontinue\n\t\t}\n\t\ts.log.Infow(\"处理角色\", \"id\", character.ID, \"name\", character.Name, \"image_url\", *character.ImageURL)\n\n\t\tlocalPath, err := s.downloadOrGetCached(*character.ImageURL, \"characters\", fmt.Sprintf(\"character_%d\", character.ID))\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"下载角色图片失败\", \"character_id\", character.ID, \"error\", err)\n\t\t\tstats.CharactersFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := s.db.Model(&character).Update(\"local_path\", localPath).Error; err != nil {\n\t\t\ts.log.Errorw(\"更新角色 local_path 失败\", \"character_id\", character.ID, \"error\", err)\n\t\t\tstats.CharactersFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\ts.log.Infow(\"角色迁移成功\", \"character_id\", character.ID, \"local_path\", localPath)\n\t\tstats.CharactersSuccess++\n\t}\n\n\treturn nil\n}\n\n// migrateImageGenerations 迁移 image_generations 表数据\nfunc (s *DataMigrationService) migrateImageGenerations(stats *MigrationStats) error {\n\ts.log.Info(\"开始迁移 image_generations 数据...\")\n\n\tvar imageGens []models.ImageGeneration\n\tif err := s.db.Where(\"(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''\").Find(&imageGens).Error; err != nil {\n\t\treturn fmt.Errorf(\"查询 image_generations 数据失败: %w\", err)\n\t}\n\n\ts.log.Infow(\"找到需要迁移的 image_generations\", \"数量\", len(imageGens))\n\n\tfor _, imageGen := range imageGens {\n\t\tif imageGen.ImageURL == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\timageTypeStr := string(imageGen.ImageType)\n\t\ts.log.Infow(\"处理 image_generation\", \"id\", imageGen.ID, \"image_type\", imageTypeStr, \"image_url\", *imageGen.ImageURL)\n\n\t\tsubDir := \"images\"\n\t\tif imageGen.ImageType == \"character\" {\n\t\t\tsubDir = \"characters\"\n\t\t}\n\n\t\tlocalPath, err := s.downloadOrGetCached(*imageGen.ImageURL, subDir, fmt.Sprintf(\"imggen_%d\", imageGen.ID))\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"下载 image_generation 图片失败\", \"imggen_id\", imageGen.ID, \"error\", err)\n\t\t\tstats.ImageGenerationsFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := s.db.Model(&imageGen).Update(\"local_path\", localPath).Error; err != nil {\n\t\t\ts.log.Errorw(\"更新 image_generation local_path 失败\", \"imggen_id\", imageGen.ID, \"error\", err)\n\t\t\tstats.ImageGenerationsFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\ts.log.Infow(\"image_generation 迁移成功\", \"imggen_id\", imageGen.ID, \"local_path\", localPath)\n\t\tstats.ImageGenerationsSuccess++\n\t}\n\n\treturn nil\n}\n\n// migrateScenes 迁移场景数据\nfunc (s *DataMigrationService) migrateScenes(stats *MigrationStats) error {\n\ts.log.Info(\"开始迁移场景数据...\")\n\n\tvar scenes []models.Scene\n\tif err := s.db.Where(\"(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''\").Find(&scenes).Error; err != nil {\n\t\treturn fmt.Errorf(\"查询场景数据失败: %w\", err)\n\t}\n\n\ts.log.Infow(\"找到需要迁移的场景\", \"数量\", len(scenes))\n\n\tfor _, scene := range scenes {\n\t\tif scene.ImageURL == nil {\n\t\t\tcontinue\n\t\t}\n\t\ts.log.Infow(\"处理场景\", \"id\", scene.ID, \"location\", scene.Location, \"image_url\", *scene.ImageURL)\n\n\t\tlocalPath, err := s.downloadOrGetCached(*scene.ImageURL, \"images\", fmt.Sprintf(\"scene_%d\", scene.ID))\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"下载场景图片失败\", \"scene_id\", scene.ID, \"error\", err)\n\t\t\tstats.ScenesFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := s.db.Model(&scene).Update(\"local_path\", localPath).Error; err != nil {\n\t\t\ts.log.Errorw(\"更新场景 local_path 失败\", \"scene_id\", scene.ID, \"error\", err)\n\t\t\tstats.ScenesFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\ts.log.Infow(\"场景迁移成功\", \"scene_id\", scene.ID, \"local_path\", localPath)\n\t\tstats.ScenesSuccess++\n\t}\n\n\treturn nil\n}\n\n// migrateVideoGenerations 迁移视频生成数据\nfunc (s *DataMigrationService) migrateVideoGenerations(stats *MigrationStats) error {\n\ts.log.Info(\"开始迁移视频生成数据...\")\n\n\tvar videoGens []models.VideoGeneration\n\tif err := s.db.Where(\"(local_path IS NULL OR local_path = '') AND video_url IS NOT NULL AND video_url != ''\").Find(&videoGens).Error; err != nil {\n\t\treturn fmt.Errorf(\"查询视频生成数据失败: %w\", err)\n\t}\n\n\ts.log.Infow(\"找到需要迁移的视频\", \"数量\", len(videoGens))\n\n\tfor _, videoGen := range videoGens {\n\t\tif videoGen.VideoURL == nil {\n\t\t\tcontinue\n\t\t}\n\t\ts.log.Infow(\"处理视频\", \"id\", videoGen.ID, \"video_url\", *videoGen.VideoURL)\n\n\t\tlocalPath, err := s.downloadOrGetCached(*videoGen.VideoURL, \"videos\", fmt.Sprintf(\"video_%d\", videoGen.ID))\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"下载视频失败\", \"video_gen_id\", videoGen.ID, \"error\", err)\n\t\t\tstats.VideosFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := s.db.Model(&videoGen).Update(\"local_path\", localPath).Error; err != nil {\n\t\t\ts.log.Errorw(\"更新视频 local_path 失败\", \"video_gen_id\", videoGen.ID, \"error\", err)\n\t\t\tstats.VideosFailed++\n\t\t\tcontinue\n\t\t}\n\n\t\ts.log.Infow(\"视频迁移成功\", \"video_gen_id\", videoGen.ID, \"local_path\", localPath)\n\t\tstats.VideosSuccess++\n\t}\n\n\treturn nil\n}\n\n// downloadOrGetCached 下载文件或从缓存获取本地路径\nfunc (s *DataMigrationService) downloadOrGetCached(url, subDir, prefix string) (string, error) {\n\t// 1. 检查 URL 映射缓存\n\tif localPath, exists := s.urlMapping[url]; exists {\n\t\ts.log.Infow(\"使用缓存的本地路径\", \"url\", url, \"local_path\", localPath)\n\t\treturn localPath, nil\n\t}\n\n\t// 2. 如果缓存中没有，则下载文件\n\tvar localPath string\n\tvar err error\n\n\t// 根据子目录判断是图片还是视频\n\tif subDir == \"videos\" {\n\t\tlocalPath, err = s.downloadAndSaveVideo(url, subDir, prefix)\n\t} else {\n\t\tlocalPath, err = s.downloadAndSaveImage(url, subDir, prefix)\n\t}\n\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// 3. 将 URL 和本地路径的映射关系存入缓存\n\ts.urlMapping[url] = localPath\n\ts.log.Infow(\"已缓存 URL 映射\", \"url\", url, \"local_path\", localPath)\n\n\treturn localPath, nil\n}\n\n// downloadAndSaveImage 下载并保存图片\nfunc (s *DataMigrationService) downloadAndSaveImage(imageURL, subDir, prefix string) (string, error) {\n\tif imageURL == \"\" {\n\t\treturn \"\", fmt.Errorf(\"图片 URL 为空\")\n\t}\n\n\t// 如果已经是本地路径，直接返回\n\tif strings.HasPrefix(imageURL, \"/static/\") || strings.HasPrefix(imageURL, \"data/\") {\n\t\treturn imageURL, nil\n\t}\n\n\t// 从 URL 中提取文件扩展名（去掉查询参数）\n\text := s.extractFileExtension(imageURL)\n\n\t// 生成文件名\n\ttimestamp := time.Now().Unix()\n\tfilename := fmt.Sprintf(\"%s_%d%s\", prefix, timestamp, ext)\n\trelativePath := filepath.Join(subDir, filename)\n\tfullPath := filepath.Join(s.storageRoot, relativePath)\n\n\t// 下载文件\n\tif err := s.downloadFile(imageURL, fullPath); err != nil {\n\t\treturn \"\", fmt.Errorf(\"下载文件失败: %w\", err)\n\t}\n\n\t// 返回相对路径（用于存储到数据库）\n\treturn relativePath, nil\n}\n\n// downloadAndSaveVideo 下载并保存视频\nfunc (s *DataMigrationService) downloadAndSaveVideo(videoURL, subDir, prefix string) (string, error) {\n\tif videoURL == \"\" {\n\t\treturn \"\", fmt.Errorf(\"视频 URL 为空\")\n\t}\n\n\t// 如果已经是本地路径，直接返回\n\tif strings.HasPrefix(videoURL, \"/static/\") || strings.HasPrefix(videoURL, \"data/\") {\n\t\treturn videoURL, nil\n\t}\n\n\t// 从 URL 中提取文件扩展名（去掉查询参数）\n\text := s.extractFileExtension(videoURL)\n\tif ext == \"\" || ext == \".jpeg\" || ext == \".jpg\" || ext == \".png\" {\n\t\text = \".mp4\" // 视频默认扩展名\n\t}\n\n\t// 生成文件名\n\ttimestamp := time.Now().Unix()\n\tfilename := fmt.Sprintf(\"%s_%d%s\", prefix, timestamp, ext)\n\trelativePath := filepath.Join(subDir, filename)\n\tfullPath := filepath.Join(s.storageRoot, relativePath)\n\n\t// 下载文件\n\tif err := s.downloadFile(videoURL, fullPath); err != nil {\n\t\treturn \"\", fmt.Errorf(\"下载文件失败: %w\", err)\n\t}\n\n\t// 返回相对路径（用于存储到数据库）\n\treturn relativePath, nil\n}\n\n// extractFileExtension 从 URL 中提取文件扩展名（去掉查询参数）\nfunc (s *DataMigrationService) extractFileExtension(url string) string {\n\t// 去掉查询参数\n\tif idx := strings.Index(url, \"?\"); idx != -1 {\n\t\turl = url[:idx]\n\t}\n\n\t// 去掉 fragment\n\tif idx := strings.Index(url, \"#\"); idx != -1 {\n\t\turl = url[:idx]\n\t}\n\n\t// 获取文件扩展名\n\text := filepath.Ext(url)\n\tif ext == \"\" {\n\t\t// 如果没有扩展名，默认返回 .jpg\n\t\treturn \".jpg\"\n\t}\n\n\t// 转换为小写\n\text = strings.ToLower(ext)\n\n\t// 验证扩展名是否合理（限制长度）\n\tif len(ext) > 10 {\n\t\treturn \".jpg\"\n\t}\n\n\treturn ext\n}\n\n// downloadFile 下载文件到指定路径\nfunc (s *DataMigrationService) downloadFile(url, filepath string) error {\n\ts.log.Infow(\"开始下载文件\", \"url\", url, \"filepath\", filepath)\n\n\t// 创建 HTTP 请求\n\tclient := &http.Client{\n\t\tTimeout: 60 * time.Second,\n\t}\n\n\tresp, err := client.Get(url)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"HTTP 请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"HTTP 状态码错误: %d\", resp.StatusCode)\n\t}\n\n\t// 创建文件\n\tout, err := os.Create(filepath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"创建文件失败: %w\", err)\n\t}\n\tdefer out.Close()\n\n\t// 写入文件\n\tsize, err := io.Copy(out, resp.Body)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"写入文件失败: %w\", err)\n\t}\n\n\ts.log.Infow(\"文件下载成功\", \"filepath\", filepath, \"size\", size)\n\treturn nil\n}\n"
  },
  {
    "path": "configs/config.example.yaml",
    "content": "app:\n  name: \"Huobao Drama API\"\n  version: \"1.0.0\"\n  debug: true\n  language: \"zh\" # 系统语言：zh(中文) 或 en(英文)\n\nserver:\n  port: 5678\n  host: \"0.0.0.0\"\n  cors_origins:\n    - \"http://localhost:3012\"\n  read_timeout: 600\n  write_timeout: 600\n\ndatabase:\n  type: \"sqlite\"\n  path: \"./data/drama_generator.db\"\n  max_idle: 10\n  max_open: 100\n\nstorage:\n  type: \"local\"\n  local_path: \"./data/storage\"\n  base_url: \"http://localhost:5678/static\"\n\nai:\n  default_text_provider: \"openai\"\n  default_image_provider: \"openai\"\n  default_video_provider: \"doubao\"\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  huobao-drama:\n    # image: huobao-drama:latest\n    container_name: huobao-drama\n    build:\n      context: .\n      dockerfile: Dockerfile\n      args:\n        # Docker Hub 镜像源（注意末尾斜杠）\n        DOCKER_REGISTRY: ${DOCKER_REGISTRY:-}\n        # npm 镜像源\n        NPM_REGISTRY: ${NPM_REGISTRY:-}\n        # Go 代理\n        GO_PROXY: ${GO_PROXY:-}\n        # Alpine apk 镜像源\n        ALPINE_MIRROR: ${ALPINE_MIRROR:-}\n    ports:\n      - \"5678:5678\"\n    volumes:\n      # 持久化数据目录（使用命名卷，容器内以 root 运行）\n      - huobao-data:/app/data\n      # 挂载配置文件（可选，如需自定义配置请取消注释）\n      # - ./configs/config.yaml:/app/configs/config.yaml:ro\n      # 注意：如果使用本地目录挂载，需要确保目录权限正确\n      # 例如：- ./data:/app/data （需要 chmod 777 ./data）\n    environment:\n      - TZ=Asia/Shanghai\n      # 访问宿主机服务说明：\n      # 使用 host.docker.internal 代替 127.0.0.1\n      # 例如：http://host.docker.internal:11434 (Ollama)\n    extra_hosts:\n      - \"host.docker.internal:host-gateway\"  # 统一支持所有平台\n    restart: unless-stopped\n    healthcheck:\n      test: [\"CMD\", \"wget\", \"--no-verbose\", \"--tries=1\", \"--spider\", \"http://localhost:5678/health\"]\n      interval: 30s\n      timeout: 3s\n      retries: 3\n      start_period: 10s\n    networks:\n      - huobao-network\n\nvolumes:\n  huobao-data:\n    driver: local\n\nnetworks:\n  huobao-network:\n    driver: bridge\n"
  },
  {
    "path": "docs/DATA_MIGRATION.md",
    "content": "# 数据清洗服务文档\n\n## 概述\n\n数据清洗服务（Data Migration Service）用于自动下载并迁移数据库中 `local_path` 字段为空的数据。该服务会在应用启动时自动执行，将远程 URL 的文件下载到本地存储，并更新数据库中的 `local_path` 字段。\n\n## 功能特性\n\n- ✅ **自动执行**：服务启动时自动运行，无需手动干预\n- ✅ **异步处理**：后台异步执行，不阻塞服务启动\n- ✅ **多表支持**：支持场景、角色、视频、分镜等多个表\n- ✅ **智能分类**：根据数据类型自动分类存储到不同目录\n- ✅ **错误容忍**：单个文件下载失败不影响其他文件的处理\n- ✅ **详细日志**：提供完整的执行日志和统计信息\n\n## 处理的数据表\n\n### 1. 场景表（scenes）\n\n- **字段**：`image_url` → `local_path`\n- **存储目录**：`data/storage/images/`\n- **文件命名**：`scene_{id}_{timestamp}.{ext}`\n\n### 2. 角色表（characters）\n\n- **字段**：`image_url` → `local_path`\n- **存储目录**：`data/storage/characters/`\n- **文件命名**：`character_{id}_{timestamp}.{ext}`\n\n### 3. 视频生成表（video_generations）\n\n- **字段**：`video_url` → `local_path`\n- **存储目录**：`data/storage/videos/`\n- **文件命名**：`video_{id}_{timestamp}.{ext}`\n\n### 4. 分镜表（storyboards）\n\n- **字段**：`image_url` → `local_path`\n- **存储目录**：`data/storage/images/`\n- **文件命名**：`storyboard_{id}_{timestamp}.{ext}`\n\n## 执行流程\n\n```\n1. 服务启动\n   ↓\n2. 数据库连接和迁移\n   ↓\n3. 启动数据清洗任务（异步）\n   ↓\n4. 创建存储目录\n   ↓\n5. 查询各表中 local_path 为空的数据\n   ↓\n6. 遍历每条记录\n   ├─ 下载文件到本地\n   ├─ 更新 local_path 字段\n   └─ 记录成功/失败统计\n   ↓\n7. 输出执行统计\n```\n\n## 日志示例\n\n### 启动日志\n\n```\nINFO  启动数据清洗任务...\nINFO  开始数据清洗：迁移 local_path 为空的数据\nINFO  存储目录创建成功  root=data/storage\n```\n\n### 处理日志\n\n```\nINFO  开始迁移场景数据...\nINFO  找到需要迁移的场景  数量=5\nINFO  处理场景  id=1 location=大型超市 image_url=https://...\nINFO  开始下载文件  url=https://... filepath=data/storage/images/scene_1_1706345678.jpg\nINFO  文件下载成功  filepath=data/storage/images/scene_1_1706345678.jpg size=245678\nINFO  场景迁移成功  scene_id=1 local_path=images/scene_1_1706345678.jpg\n```\n\n### 完成日志\n\n```\nINFO  数据清洗完成\n      总耗时=15.234s\n      场景成功=5 场景失败=0\n      角色成功=3 角色失败=1\n      视频成功=2 视频失败=0\n      分镜成功=4 分镜失败=0\n```\n\n### 错误日志\n\n```\nERROR 下载场景图片失败  scene_id=10 error=HTTP 状态码错误: 404\nERROR 更新角色 local_path 失败  character_id=5 error=database connection lost\n```\n\n## 配置说明\n\n### 存储根目录\n\n默认存储根目录为 `data/storage`，可在代码中修改：\n\n```go\nstorageRoot: \"data/storage\"  // 可自定义路径\n```\n\n### 下载超时设置\n\n默认 HTTP 请求超时为 60 秒：\n\n```go\nclient := &http.Client{\n    Timeout: 60 * time.Second,  // 可根据需要调整\n}\n```\n\n## 错误处理\n\n### 跳过的情况\n\n- URL 为空\n- URL 已经是本地路径（以 `/static/` 或 `data/` 开头）\n- HTTP 请求失败（404、超时等）\n- 文件写入失败\n- 数据库更新失败\n\n### 错误不会导致\n\n- ❌ 服务启动失败\n- ❌ 其他数据处理中断\n- ❌ 数据库回滚\n\n## 手动触发\n\n如果需要手动触发数据清洗（例如在运行时），可以通过以下方式：\n\n```go\n// 创建服务实例\nmigrationService := services.NewDataMigrationService(db, logger)\n\n// 执行迁移\nif err := migrationService.MigrateLocalPaths(); err != nil {\n    log.Printf(\"数据清洗失败: %v\", err)\n}\n```\n\n## 性能考虑\n\n### 异步执行\n\n数据清洗任务在后台异步执行，不会阻塞服务启动。服务可以立即开始处理用户请求。\n\n### 网络带宽\n\n- 大量文件下载可能占用网络带宽\n- 建议在低峰期执行或限制并发下载数\n\n### 存储空间\n\n- 确保服务器有足够的磁盘空间\n- 定期清理不再使用的文件\n\n## 监控建议\n\n### 关键指标\n\n- 成功迁移数量\n- 失败迁移数量\n- 总执行时间\n- 磁盘使用率\n\n### 告警条件\n\n- 失败率 > 10%\n- 执行时间 > 5 分钟\n- 磁盘使用率 > 90%\n\n## 故障排查\n\n### 问题：所有下载都失败\n\n**可能原因**：\n\n- 网络连接问题\n- 防火墙阻止外部请求\n- 源服务器不可用\n\n**解决方案**：\n\n- 检查网络连接\n- 检查防火墙配置\n- 验证源 URL 是否可访问\n\n### 问题：部分下载失败\n\n**可能原因**：\n\n- 特定 URL 无效或过期\n- 文件格式不支持\n- 临时网络波动\n\n**解决方案**：\n\n- 查看错误日志定位具体 URL\n- 手动验证 URL 有效性\n- 重启服务重试\n\n### 问题：数据库更新失败\n\n**可能原因**：\n\n- 数据库连接断开\n- 权限不足\n- 字段约束冲突\n\n**解决方案**：\n\n- 检查数据库连接\n- 验证数据库用户权限\n- 检查表结构和约束\n\n## 代码位置\n\n- **服务实现**：`application/services/data_migration_service.go`\n- **集成代码**：`main.go`（第 45-55 行）\n- **文档**：`docs/DATA_MIGRATION.md`\n\n## 版本历史\n\n- **v1.0.0** (2026-01-27)\n  - 初始版本\n  - 支持场景、角色、视频、分镜数据迁移\n  - 异步执行，详细日志\n"
  },
  {
    "path": "domain/models/ai_config.go",
    "content": "package models\n\nimport (\n\t\"database/sql/driver\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"time\"\n)\n\ntype AIServiceConfig struct {\n\tID            uint       `gorm:\"primaryKey;autoIncrement\" json:\"id\"`\n\tServiceType   string     `gorm:\"type:varchar(50);not null\" json:\"service_type\"` // text, image, video\n\tProvider      string     `gorm:\"type:varchar(50)\" json:\"provider\"`              // openai, gemini, volcengine, etc.\n\tName          string     `gorm:\"type:varchar(100);not null\" json:\"name\"`\n\tBaseURL       string     `gorm:\"type:varchar(255);not null\" json:\"base_url\"`\n\tAPIKey        string     `gorm:\"type:varchar(255);not null\" json:\"api_key\"`\n\tModel         ModelField `gorm:\"type:text\" json:\"model\"`\n\tEndpoint      string     `gorm:\"type:varchar(255)\" json:\"endpoint\"`\n\tQueryEndpoint string     `gorm:\"type:varchar(255)\" json:\"query_endpoint\"`\n\tPriority      int        `gorm:\"default:0\" json:\"priority\"` // 优先级，数值越大优先级越高\n\tIsDefault     bool       `gorm:\"default:false\" json:\"is_default\"`\n\tIsActive      bool       `gorm:\"default:true\" json:\"is_active\"`\n\tSettings      string     `gorm:\"type:text\" json:\"settings\"`\n\tCreatedAt     time.Time  `gorm:\"not null;autoCreateTime\" json:\"created_at\"`\n\tUpdatedAt     time.Time  `gorm:\"not null;autoUpdateTime\" json:\"updated_at\"`\n}\n\nfunc (c *AIServiceConfig) TableName() string {\n\treturn \"ai_service_configs\"\n}\n\ntype AIServiceProvider struct {\n\tID          uint      `gorm:\"primaryKey;autoIncrement\" json:\"id\"`\n\tName        string    `gorm:\"type:varchar(100);not null;uniqueIndex\" json:\"name\"`\n\tDisplayName string    `gorm:\"type:varchar(100);not null\" json:\"display_name\"`\n\tServiceType string    `gorm:\"type:varchar(50);not null\" json:\"service_type\"`\n\tDefaultURL  string    `gorm:\"type:varchar(255)\" json:\"default_url\"`\n\tDescription string    `gorm:\"type:text\" json:\"description\"`\n\tIsActive    bool      `gorm:\"default:true\" json:\"is_active\"`\n\tCreatedAt   time.Time `gorm:\"not null;autoCreateTime\" json:\"created_at\"`\n\tUpdatedAt   time.Time `gorm:\"not null;autoUpdateTime\" json:\"updated_at\"`\n}\n\nfunc (p *AIServiceProvider) TableName() string {\n\treturn \"ai_service_providers\"\n}\n\n// ModelField 自定义类型，支持字符串或字符串数组\ntype ModelField []string\n\n// Value 实现 driver.Valuer 接口，用于存储到数据库\nfunc (m ModelField) Value() (driver.Value, error) {\n\tif len(m) == 0 {\n\t\treturn nil, nil\n\t}\n\tdata, err := json.Marshal(m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn string(data), nil\n}\n\n// Scan 实现 sql.Scanner 接口，用于从数据库读取\nfunc (m *ModelField) Scan(value interface{}) error {\n\tif value == nil {\n\t\t*m = []string{}\n\t\treturn nil\n\t}\n\n\tvar data []byte\n\tswitch v := value.(type) {\n\tcase []byte:\n\t\tdata = v\n\tcase string:\n\t\tdata = []byte(v)\n\tdefault:\n\t\treturn errors.New(\"unsupported type for ModelField\")\n\t}\n\n\t// 尝试解析为数组\n\tvar arr []string\n\tif err := json.Unmarshal(data, &arr); err == nil {\n\t\t*m = arr\n\t\treturn nil\n\t}\n\n\t// 如果解析失败，尝试作为单个字符串处理\n\tvar str string\n\tif err := json.Unmarshal(data, &str); err == nil {\n\t\t*m = []string{str}\n\t\treturn nil\n\t}\n\n\t// 兼容旧数据：直接作为字符串\n\t*m = []string{string(data)}\n\treturn nil\n}\n\n// MarshalJSON 实现 json.Marshaler 接口\nfunc (m ModelField) MarshalJSON() ([]byte, error) {\n\tif len(m) == 0 {\n\t\treturn json.Marshal([]string{})\n\t}\n\treturn json.Marshal([]string(m))\n}\n\n// UnmarshalJSON 实现 json.Unmarshaler 接口，支持字符串或数组\nfunc (m *ModelField) UnmarshalJSON(data []byte) error {\n\t// 尝试解析为数组\n\tvar arr []string\n\tif err := json.Unmarshal(data, &arr); err == nil {\n\t\t*m = arr\n\t\treturn nil\n\t}\n\n\t// 尝试解析为单个字符串\n\tvar str string\n\tif err := json.Unmarshal(data, &str); err == nil {\n\t\t*m = []string{str}\n\t\treturn nil\n\t}\n\n\treturn errors.New(\"model field must be string or array of strings\")\n}\n"
  },
  {
    "path": "domain/models/asset.go",
    "content": "package models\n\nimport (\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype Asset struct {\n\tID        uint           `gorm:\"primarykey\" json:\"id\"`\n\tCreatedAt time.Time      `json:\"created_at\"`\n\tUpdatedAt time.Time      `json:\"updated_at\"`\n\tDeletedAt gorm.DeletedAt `gorm:\"index\" json:\"-\"`\n\n\tDramaID *uint  `gorm:\"index\" json:\"drama_id,omitempty\"`\n\tDrama   *Drama `gorm:\"foreignKey:DramaID\" json:\"drama,omitempty\"`\n\n\tEpisodeID     *uint `gorm:\"index\" json:\"episode_id,omitempty\"`\n\tStoryboardID  *uint `gorm:\"index\" json:\"storyboard_id,omitempty\"`\n\tStoryboardNum *int  `json:\"storyboard_num,omitempty\"`\n\n\tName         string    `gorm:\"type:varchar(200);not null\" json:\"name\"`\n\tDescription  *string   `gorm:\"type:text\" json:\"description,omitempty\"`\n\tType         AssetType `gorm:\"type:varchar(20);not null;index\" json:\"type\"`\n\tCategory     *string   `gorm:\"type:varchar(50);index\" json:\"category,omitempty\"`\n\tURL          string    `gorm:\"type:varchar(1000);not null\" json:\"url\"`\n\tThumbnailURL *string   `gorm:\"type:varchar(1000)\" json:\"thumbnail_url,omitempty\"`\n\tLocalPath    *string   `gorm:\"type:varchar(500)\" json:\"local_path\"`\n\n\tFileSize *int64  `json:\"file_size,omitempty\"`\n\tMimeType *string `gorm:\"type:varchar(100)\" json:\"mime_type,omitempty\"`\n\tWidth    *int    `json:\"width,omitempty\"`\n\tHeight   *int    `json:\"height,omitempty\"`\n\tDuration *int    `json:\"duration,omitempty\"`\n\tFormat   *string `gorm:\"type:varchar(50)\" json:\"format,omitempty\"`\n\n\tImageGenID *uint           `gorm:\"index\" json:\"image_gen_id,omitempty\"`\n\tImageGen   ImageGeneration `gorm:\"foreignKey:ImageGenID\" json:\"image_gen,omitempty\"`\n\n\tVideoGenID *uint           `gorm:\"index\" json:\"video_gen_id,omitempty\"`\n\tVideoGen   VideoGeneration `gorm:\"foreignKey:VideoGenID\" json:\"video_gen,omitempty\"`\n\n\tIsFavorite bool `gorm:\"default:false\" json:\"is_favorite\"`\n\tViewCount  int  `gorm:\"default:0\" json:\"view_count\"`\n}\n\ntype AssetType string\n\nconst (\n\tAssetTypeImage AssetType = \"image\"\n\tAssetTypeVideo AssetType = \"video\"\n\tAssetTypeAudio AssetType = \"audio\"\n)\n\nfunc (Asset) TableName() string {\n\treturn \"assets\"\n}\n"
  },
  {
    "path": "domain/models/character_library.go",
    "content": "package models\n\nimport (\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\n// CharacterLibrary 角色库模型\ntype CharacterLibrary struct {\n\tID          uint           `gorm:\"primaryKey;autoIncrement\" json:\"id\"`\n\tName        string         `gorm:\"type:varchar(100);not null\" json:\"name\"`\n\tCategory    *string        `gorm:\"type:varchar(50)\" json:\"category\"`\n\tImageURL    string         `gorm:\"type:varchar(500);not null\" json:\"image_url\"`\n\tLocalPath   *string        `gorm:\"type:varchar(500)\" json:\"local_path,omitempty\"`\n\tDescription *string        `gorm:\"type:text\" json:\"description\"`\n\tTags        *string        `gorm:\"type:varchar(500)\" json:\"tags\"`\n\tSourceType  string         `gorm:\"type:varchar(20);default:'generated'\" json:\"source_type\"` // generated, uploaded\n\tCreatedAt   time.Time      `gorm:\"not null;autoCreateTime\" json:\"created_at\"`\n\tUpdatedAt   time.Time      `gorm:\"not null;autoUpdateTime\" json:\"updated_at\"`\n\tDeletedAt   gorm.DeletedAt `gorm:\"index\" json:\"-\"`\n}\n\nfunc (c *CharacterLibrary) TableName() string {\n\treturn \"character_libraries\"\n}\n"
  },
  {
    "path": "domain/models/drama.go",
    "content": "package models\n\nimport (\n\t\"time\"\n\n\t\"gorm.io/datatypes\"\n\t\"gorm.io/gorm\"\n)\n\ntype Drama struct {\n\tID            uint           `gorm:\"primaryKey;autoIncrement\" json:\"id\"`\n\tTitle         string         `gorm:\"type:varchar(200);not null\" json:\"title\"`\n\tDescription   *string        `gorm:\"type:text\" json:\"description\"`\n\tGenre         *string        `gorm:\"type:varchar(50)\" json:\"genre\"`\n\tStyle         string         `gorm:\"type:varchar(50);default:'realistic'\" json:\"style\"`\n\tTotalEpisodes int            `gorm:\"default:1\" json:\"total_episodes\"`\n\tTotalDuration int            `gorm:\"default:0\" json:\"total_duration\"`\n\tStatus        string         `gorm:\"type:varchar(20);default:'draft';not null\" json:\"status\"`\n\tThumbnail     *string        `gorm:\"type:varchar(500)\" json:\"thumbnail\"`\n\tTags          datatypes.JSON `gorm:\"type:json\" json:\"tags\"`\n\tMetadata      datatypes.JSON `gorm:\"type:json\" json:\"metadata\"`\n\tCreatedAt     time.Time      `gorm:\"not null;autoCreateTime\" json:\"created_at\"`\n\tUpdatedAt     time.Time      `gorm:\"not null;autoUpdateTime\" json:\"updated_at\"`\n\tDeletedAt     gorm.DeletedAt `gorm:\"index\" json:\"-\"`\n\n\tEpisodes   []Episode   `gorm:\"foreignKey:DramaID\" json:\"episodes,omitempty\"`\n\tCharacters []Character `gorm:\"foreignKey:DramaID\" json:\"characters,omitempty\"`\n\tScenes     []Scene     `gorm:\"foreignKey:DramaID\" json:\"scenes,omitempty\"`\n\tProps      []Prop      `gorm:\"foreignKey:DramaID\" json:\"props,omitempty\"`\n}\n\nfunc (d *Drama) TableName() string {\n\treturn \"dramas\"\n}\n\ntype Character struct {\n\tID              uint           `gorm:\"primaryKey;autoIncrement\" json:\"id\"`\n\tDramaID         uint           `gorm:\"not null;index\" json:\"drama_id\"`\n\tName            string         `gorm:\"type:varchar(100);not null\" json:\"name\"`\n\tRole            *string        `gorm:\"type:varchar(50)\" json:\"role\"`\n\tDescription     *string        `gorm:\"type:text\" json:\"description\"`\n\tAppearance      *string        `gorm:\"type:text\" json:\"appearance\"`\n\tPersonality     *string        `gorm:\"type:text\" json:\"personality\"`\n\tVoiceStyle      *string        `gorm:\"type:varchar(200)\" json:\"voice_style\"`\n\tImageURL        *string        `gorm:\"type:varchar(500)\" json:\"image_url\"`\n\tLocalPath       *string        `gorm:\"type:text\" json:\"local_path,omitempty\"`\n\tReferenceImages datatypes.JSON `gorm:\"type:json\" json:\"reference_images\"`\n\tSeedValue       *string        `gorm:\"type:varchar(100)\" json:\"seed_value\"`\n\tSortOrder       int            `gorm:\"default:0\" json:\"sort_order\"`\n\tCreatedAt       time.Time      `gorm:\"not null;autoCreateTime\" json:\"created_at\"`\n\tUpdatedAt       time.Time      `gorm:\"not null;autoUpdateTime\" json:\"updated_at\"`\n\tDeletedAt       gorm.DeletedAt `gorm:\"index\" json:\"-\"`\n\n\t// 多对多关系：角色可以属于多个章节\n\tEpisodes []Episode `gorm:\"many2many:episode_characters;\" json:\"episodes,omitempty\"`\n\n\t// 运行时字段（不存储到数据库）\n\tImageGenerationStatus *string `gorm:\"-\" json:\"image_generation_status,omitempty\"`\n\tImageGenerationError  *string `gorm:\"-\" json:\"image_generation_error,omitempty\"`\n}\n\nfunc (c *Character) TableName() string {\n\treturn \"characters\"\n}\n\ntype Episode struct {\n\tID            uint           `gorm:\"primaryKey;autoIncrement\" json:\"id\"`\n\tDramaID       uint           `gorm:\"not null;index\" json:\"drama_id\"`\n\tEpisodeNum    int            `gorm:\"column:episode_number;not null\" json:\"episode_number\"`\n\tTitle         string         `gorm:\"type:varchar(200);not null\" json:\"title\"`\n\tScriptContent *string        `gorm:\"type:longtext\" json:\"script_content\"`\n\tDescription   *string        `gorm:\"type:text\" json:\"description\"`\n\tDuration      int            `gorm:\"default:0\" json:\"duration\"` // 总时长（秒）\n\tStatus        string         `gorm:\"type:varchar(20);default:'draft'\" json:\"status\"`\n\tVideoURL      *string        `gorm:\"type:varchar(500)\" json:\"video_url\"`\n\tThumbnail     *string        `gorm:\"type:varchar(500)\" json:\"thumbnail\"`\n\tCreatedAt     time.Time      `gorm:\"not null;autoCreateTime\" json:\"created_at\"`\n\tUpdatedAt     time.Time      `gorm:\"not null;autoUpdateTime\" json:\"updated_at\"`\n\tDeletedAt     gorm.DeletedAt `gorm:\"index\" json:\"-\"`\n\n\t// 关联\n\tDrama       Drama        `gorm:\"foreignKey:DramaID\" json:\"drama,omitempty\"`\n\tStoryboards []Storyboard `gorm:\"foreignKey:EpisodeID\" json:\"storyboards,omitempty\"`\n\tCharacters  []Character  `gorm:\"many2many:episode_characters;\" json:\"characters,omitempty\"`\n\tScenes      []Scene      `gorm:\"foreignKey:EpisodeID\" json:\"scenes,omitempty\"`\n}\n\nfunc (e *Episode) TableName() string {\n\treturn \"episodes\"\n}\n\ntype Storyboard struct {\n\tID               uint           `gorm:\"primaryKey;autoIncrement\" json:\"id\"`\n\tEpisodeID        uint           `gorm:\"not null;index:idx_storyboards_episode_id\" json:\"episode_id\"`\n\tSceneID          *uint          `gorm:\"index:idx_storyboards_scene_id;column:scene_id\" json:\"scene_id\"`\n\tStoryboardNumber int            `gorm:\"not null;column:storyboard_number\" json:\"storyboard_number\"`\n\tTitle            *string        `gorm:\"size:255\" json:\"title\"`\n\tLocation         *string        `gorm:\"size:255\" json:\"location\"`\n\tTime             *string        `gorm:\"size:255\" json:\"time\"`\n\tShotType         *string        `gorm:\"size:100\" json:\"shot_type\"`\n\tAngle            *string        `gorm:\"size:100\" json:\"angle\"`\n\tMovement         *string        `gorm:\"size:100\" json:\"movement\"`\n\tAction           *string        `gorm:\"type:text\" json:\"action\"`\n\tResult           *string        `gorm:\"type:text\" json:\"result\"`\n\tAtmosphere       *string        `gorm:\"type:text\" json:\"atmosphere\"`\n\tImagePrompt      *string        `gorm:\"type:text\" json:\"image_prompt\"`\n\tVideoPrompt      *string        `gorm:\"type:text\" json:\"video_prompt\"`\n\tBgmPrompt        *string        `gorm:\"type:text\" json:\"bgm_prompt\"`\n\tSoundEffect      *string        `gorm:\"size:255\" json:\"sound_effect\"`\n\tDialogue         *string        `gorm:\"type:text\" json:\"dialogue\"`\n\tDescription      *string        `gorm:\"type:text\" json:\"description\"`\n\tDuration         int            `gorm:\"default:5\" json:\"duration\"`\n\tComposedImage    *string        `gorm:\"type:text\" json:\"composed_image\"`\n\tVideoURL         *string        `gorm:\"type:text\" json:\"video_url\"`\n\tStatus           string         `gorm:\"type:varchar(20);default:'pending'\" json:\"status\"`\n\tCreatedAt        time.Time      `gorm:\"autoCreateTime\" json:\"created_at\"`\n\tUpdatedAt        time.Time      `gorm:\"autoUpdateTime\" json:\"updated_at\"`\n\tDeletedAt        gorm.DeletedAt `gorm:\"index\" json:\"-\"`\n\n\tEpisode    Episode     `gorm:\"foreignKey:EpisodeID;constraint:OnDelete:CASCADE\" json:\"episode,omitempty\"`\n\tBackground *Scene      `gorm:\"foreignKey:SceneID\" json:\"background,omitempty\"`\n\tCharacters []Character `gorm:\"many2many:storyboard_characters;\" json:\"characters,omitempty\"`\n\tProps      []Prop      `gorm:\"many2many:storyboard_props;\" json:\"props,omitempty\"`\n}\n\nfunc (s *Storyboard) TableName() string {\n\treturn \"storyboards\"\n}\n\ntype Scene struct {\n\tID              uint           `gorm:\"primaryKey;autoIncrement\" json:\"id\"`\n\tDramaID         uint           `gorm:\"not null;index:idx_scenes_drama_id\" json:\"drama_id\"`\n\tEpisodeID       *uint          `gorm:\"index:idx_scenes_episode_id\" json:\"episode_id\"` // 场景所属章节\n\tLocation        string         `gorm:\"type:varchar(200);not null\" json:\"location\"`\n\tTime            string         `gorm:\"type:varchar(100);not null\" json:\"time\"`\n\tPrompt          string         `gorm:\"type:text;not null\" json:\"prompt\"`\n\tStoryboardCount int            `gorm:\"default:1\" json:\"storyboard_count\"`\n\tImageURL        *string        `gorm:\"type:varchar(500)\" json:\"image_url\"`\n\tLocalPath       *string        `gorm:\"type:text\" json:\"local_path\"`\n\tStatus          string         `gorm:\"type:varchar(20);default:'pending'\" json:\"status\"` // pending, generated, failed\n\tCreatedAt       time.Time      `gorm:\"not null;autoCreateTime\" json:\"created_at\"`\n\tUpdatedAt       time.Time      `gorm:\"not null;autoUpdateTime\" json:\"updated_at\"`\n\tDeletedAt       gorm.DeletedAt `gorm:\"index\" json:\"-\"`\n\n\t// 运行时字段（不存储到数据库）\n\tImageGenerationStatus *string `gorm:\"-\" json:\"image_generation_status,omitempty\"`\n\tImageGenerationError  *string `gorm:\"-\" json:\"image_generation_error,omitempty\"`\n}\n\nfunc (s *Scene) TableName() string {\n\treturn \"scenes\"\n}\n\ntype Prop struct {\n\tID              uint           `gorm:\"primaryKey;autoIncrement\" json:\"id\"`\n\tDramaID         uint           `gorm:\"not null;index\" json:\"drama_id\"`\n\tName            string         `gorm:\"type:varchar(100);not null\" json:\"name\"`\n\tType            *string        `gorm:\"type:varchar(50)\" json:\"type\"` // e.g., \"weapon\", \"daily\", \"vehicle\"\n\tDescription     *string        `gorm:\"type:text\" json:\"description\"`\n\tPrompt          *string        `gorm:\"type:text\" json:\"prompt\"` // AI Image prompt\n\tImageURL        *string        `gorm:\"type:varchar(500)\" json:\"image_url\"`\n\tLocalPath       *string        `gorm:\"type:text\" json:\"local_path,omitempty\"`\n\tReferenceImages datatypes.JSON `gorm:\"type:json\" json:\"reference_images\"`\n\tCreatedAt       time.Time      `gorm:\"not null;autoCreateTime\" json:\"created_at\"`\n\tUpdatedAt       time.Time      `gorm:\"not null;autoUpdateTime\" json:\"updated_at\"`\n\tDeletedAt       gorm.DeletedAt `gorm:\"index\" json:\"-\"`\n\n\t// Relationships\n\tDrama       Drama        `gorm:\"foreignKey:DramaID\" json:\"drama,omitempty\"`\n\tStoryboards []Storyboard `gorm:\"many2many:storyboard_props;\" json:\"storyboards,omitempty\"`\n}\n\nfunc (p *Prop) TableName() string {\n\treturn \"props\"\n}\n"
  },
  {
    "path": "domain/models/frame_prompt.go",
    "content": "package models\n\nimport \"time\"\n\n// FramePrompt 帧提示词存储表\ntype FramePrompt struct {\n\tID           uint      `gorm:\"primarykey\" json:\"id\"`\n\tStoryboardID uint      `gorm:\"not null;index:idx_frame_prompts_storyboard\" json:\"storyboard_id\"`\n\tFrameType    string    `gorm:\"size:20;not null;index:idx_frame_prompts_type\" json:\"frame_type\"` // first, key, last, panel, action\n\tPrompt       string    `gorm:\"type:text;not null\" json:\"prompt\"`\n\tDescription  *string   `gorm:\"type:text\" json:\"description,omitempty\"`\n\tLayout       *string   `gorm:\"size:50\" json:\"layout,omitempty\"` // 仅用于panel/action类型，如 horizontal_3\n\tCreatedAt    time.Time `gorm:\"autoCreateTime\" json:\"created_at\"`\n\tUpdatedAt    time.Time `gorm:\"autoUpdateTime\" json:\"updated_at\"`\n}\n\nfunc (FramePrompt) TableName() string {\n\treturn \"frame_prompts\"\n}\n\n// FrameType 帧类型常量\nconst (\n\tFrameTypeFirst  = \"first\"\n\tFrameTypeKey    = \"key\"\n\tFrameTypeLast   = \"last\"\n\tFrameTypePanel  = \"panel\"\n\tFrameTypeAction = \"action\"\n)\n"
  },
  {
    "path": "domain/models/image_generation.go",
    "content": "package models\n\nimport (\n\t\"time\"\n\n\t\"gorm.io/datatypes\"\n)\n\ntype ImageGeneration struct {\n\tID              uint                  `gorm:\"primarykey\" json:\"id\"`\n\tStoryboardID    *uint                 `gorm:\"index\" json:\"storyboard_id,omitempty\"`\n\tDramaID         uint                  `gorm:\"not null;index\" json:\"drama_id\"`\n\tSceneID         *uint                 `gorm:\"index\" json:\"scene_id,omitempty\"`\n\tCharacterID     *uint                 `gorm:\"index\" json:\"character_id,omitempty\"`\n\tPropID          *uint                 `gorm:\"index\" json:\"prop_id,omitempty\"`\n\tImageType       string                `gorm:\"size:20;index;default:'storyboard'\" json:\"image_type\"`\n\tFrameType       *string               `gorm:\"size:20\" json:\"frame_type,omitempty\"`\n\tProvider        string                `gorm:\"size:50;not null\" json:\"provider\"`\n\tPrompt          string                `gorm:\"type:text;not null\" json:\"prompt\"`\n\tNegPrompt       *string               `gorm:\"column:negative_prompt;type:text\" json:\"negative_prompt,omitempty\"`\n\tModel           string                `gorm:\"size:100\" json:\"model\"`\n\tSize            string                `gorm:\"size:20\" json:\"size\"`\n\tQuality         string                `gorm:\"size:20\" json:\"quality\"`\n\tStyle           *string               `gorm:\"size:50\" json:\"style,omitempty\"`\n\tSteps           *int                  `json:\"steps,omitempty\"`\n\tCfgScale        *float64              `json:\"cfg_scale,omitempty\"`\n\tSeed            *int64                `json:\"seed,omitempty\"`\n\tImageURL        *string               `gorm:\"type:text\" json:\"image_url,omitempty\"`\n\tMinioURL        *string               `gorm:\"type:text\" json:\"minio_url,omitempty\"`\n\tLocalPath       *string               `gorm:\"type:text\" json:\"local_path,omitempty\"`\n\tStatus          ImageGenerationStatus `gorm:\"size:20;not null;default:'pending'\" json:\"status\"`\n\tTaskID          *string               `gorm:\"size:200\" json:\"task_id,omitempty\"`\n\tErrorMsg        *string               `gorm:\"type:text\" json:\"error_msg,omitempty\"`\n\tWidth           *int                  `json:\"width,omitempty\"`\n\tHeight          *int                  `json:\"height,omitempty\"`\n\tReferenceImages datatypes.JSON        `gorm:\"type:json\" json:\"reference_images,omitempty\"`\n\tCreatedAt       time.Time             `json:\"created_at\"`\n\tUpdatedAt       time.Time             `json:\"updated_at\"`\n\tCompletedAt     *time.Time            `json:\"completed_at,omitempty\"`\n\n\tStoryboard *Storyboard `gorm:\"foreignKey:StoryboardID\" json:\"storyboard,omitempty\"`\n\tDrama      Drama       `gorm:\"foreignKey:DramaID\" json:\"drama,omitempty\"`\n\tScene      *Scene      `gorm:\"foreignKey:SceneID\" json:\"scene,omitempty\"`\n\tCharacter  *Character  `gorm:\"foreignKey:CharacterID\" json:\"character,omitempty\"`\n\tProp       *Prop       `gorm:\"foreignKey:PropID\" json:\"prop,omitempty\"`\n}\n\nfunc (ImageGeneration) TableName() string {\n\treturn \"image_generations\"\n}\n\ntype ImageGenerationStatus string\n\nconst (\n\tImageStatusPending    ImageGenerationStatus = \"pending\"\n\tImageStatusProcessing ImageGenerationStatus = \"processing\"\n\tImageStatusCompleted  ImageGenerationStatus = \"completed\"\n\tImageStatusFailed     ImageGenerationStatus = \"failed\"\n)\n\ntype ImageProvider string\n\nconst (\n\tProviderOpenAI          ImageProvider = \"openai\"\n\tProviderMidjourney      ImageProvider = \"midjourney\"\n\tProviderStableDiffusion ImageProvider = \"stable_diffusion\"\n\tProviderDALLE           ImageProvider = \"dalle\"\n)\n\n// ImageType 图片类型\ntype ImageType string\n\nconst (\n\tImageTypeCharacter  ImageType = \"character\"  // 角色图片\n\tImageTypeScene      ImageType = \"scene\"      // 场景图片\n\tImageTypeProp       ImageType = \"prop\"       // 道具图片\n\tImageTypeStoryboard ImageType = \"storyboard\" // 分镜图片\n)\n"
  },
  {
    "path": "domain/models/task.go",
    "content": "package models\n\nimport (\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\n// AsyncTask 异步任务模型\ntype AsyncTask struct {\n\tID          string         `gorm:\"primaryKey;size:36\" json:\"id\"`\n\tType        string         `gorm:\"size:50;not null;index\" json:\"type\"`   // 任务类型：storyboard_generation\n\tStatus      string         `gorm:\"size:20;not null;index\" json:\"status\"` // pending, processing, completed, failed\n\tProgress    int            `gorm:\"default:0\" json:\"progress\"`            // 0-100\n\tMessage     string         `gorm:\"size:500\" json:\"message,omitempty\"`    // 当前状态消息\n\tError       string         `gorm:\"type:text\" json:\"error,omitempty\"`     // 错误信息\n\tResult      string         `gorm:\"type:text\" json:\"result,omitempty\"`    // JSON格式的结果数据\n\tResourceID  string         `gorm:\"size:36;index\" json:\"resource_id\"`     // 关联资源ID（如episode_id）\n\tCreatedAt   time.Time      `gorm:\"autoCreateTime\" json:\"created_at\"`\n\tUpdatedAt   time.Time      `gorm:\"autoUpdateTime\" json:\"updated_at\"`\n\tCompletedAt *time.Time     `json:\"completed_at,omitempty\"`\n\tDeletedAt   gorm.DeletedAt `gorm:\"index\" json:\"-\"`\n}\n"
  },
  {
    "path": "domain/models/timeline.go",
    "content": "package models\n\nimport (\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype Timeline struct {\n\tID        uint           `gorm:\"primarykey\" json:\"id\"`\n\tCreatedAt time.Time      `json:\"created_at\"`\n\tUpdatedAt time.Time      `json:\"updated_at\"`\n\tDeletedAt gorm.DeletedAt `gorm:\"index\" json:\"-\"`\n\n\tDramaID uint  `gorm:\"not null;index\" json:\"drama_id\"`\n\tDrama   Drama `gorm:\"foreignKey:DramaID\" json:\"drama,omitempty\"`\n\n\tEpisodeID *uint    `gorm:\"index\" json:\"episode_id,omitempty\"`\n\tEpisode   *Episode `gorm:\"foreignKey:EpisodeID\" json:\"episode,omitempty\"`\n\n\tName        string  `gorm:\"type:varchar(200);not null\" json:\"name\"`\n\tDescription *string `gorm:\"type:text\" json:\"description,omitempty\"`\n\n\tDuration   int     `gorm:\"default:0\" json:\"duration\"`\n\tFPS        int     `gorm:\"default:30\" json:\"fps\"`\n\tResolution *string `gorm:\"type:varchar(50)\" json:\"resolution,omitempty\"`\n\n\tStatus TimelineStatus `gorm:\"type:varchar(20);not null;default:'draft';index\" json:\"status\"`\n\n\tTracks []TimelineTrack `gorm:\"foreignKey:TimelineID\" json:\"tracks,omitempty\"`\n}\n\ntype TimelineStatus string\n\nconst (\n\tTimelineStatusDraft     TimelineStatus = \"draft\"\n\tTimelineStatusEditing   TimelineStatus = \"editing\"\n\tTimelineStatusCompleted TimelineStatus = \"completed\"\n\tTimelineStatusExporting TimelineStatus = \"exporting\"\n)\n\nfunc (Timeline) TableName() string {\n\treturn \"timelines\"\n}\n\ntype TimelineTrack struct {\n\tID        uint           `gorm:\"primarykey\" json:\"id\"`\n\tCreatedAt time.Time      `json:\"created_at\"`\n\tUpdatedAt time.Time      `json:\"updated_at\"`\n\tDeletedAt gorm.DeletedAt `gorm:\"index\" json:\"-\"`\n\n\tTimelineID uint     `gorm:\"not null;index\" json:\"timeline_id\"`\n\tTimeline   Timeline `gorm:\"foreignKey:TimelineID\" json:\"-\"`\n\n\tName     string    `gorm:\"type:varchar(100);not null\" json:\"name\"`\n\tType     TrackType `gorm:\"type:varchar(20);not null\" json:\"type\"`\n\tOrder    int       `gorm:\"not null;default:0\" json:\"order\"`\n\tIsLocked bool      `gorm:\"default:false\" json:\"is_locked\"`\n\tIsMuted  bool      `gorm:\"default:false\" json:\"is_muted\"`\n\tVolume   *int      `gorm:\"default:100\" json:\"volume,omitempty\"`\n\n\tClips []TimelineClip `gorm:\"foreignKey:TrackID\" json:\"clips,omitempty\"`\n}\n\ntype TrackType string\n\nconst (\n\tTrackTypeVideo TrackType = \"video\"\n\tTrackTypeAudio TrackType = \"audio\"\n\tTrackTypeText  TrackType = \"text\"\n)\n\nfunc (TimelineTrack) TableName() string {\n\treturn \"timeline_tracks\"\n}\n\ntype TimelineClip struct {\n\tID        uint           `gorm:\"primarykey\" json:\"id\"`\n\tCreatedAt time.Time      `json:\"created_at\"`\n\tUpdatedAt time.Time      `json:\"updated_at\"`\n\tDeletedAt gorm.DeletedAt `gorm:\"index\" json:\"-\"`\n\n\tTrackID uint          `gorm:\"not null;index\" json:\"track_id\"`\n\tTrack   TimelineTrack `gorm:\"foreignKey:TrackID\" json:\"-\"`\n\n\tAssetID *uint `gorm:\"index\" json:\"asset_id,omitempty\"`\n\tAsset   Asset `gorm:\"foreignKey:AssetID\" json:\"asset,omitempty\"`\n\n\tStoryboardID *uint       `gorm:\"index\" json:\"storyboard_id,omitempty\"`\n\tStoryboard   *Storyboard `gorm:\"foreignKey:StoryboardID\" json:\"storyboard,omitempty\"`\n\n\tName string `gorm:\"type:varchar(200)\" json:\"name\"`\n\n\tStartTime int `gorm:\"not null\" json:\"start_time\"`\n\tEndTime   int `gorm:\"not null\" json:\"end_time\"`\n\tDuration  int `gorm:\"not null\" json:\"duration\"`\n\n\tTrimStart *int `json:\"trim_start,omitempty\"`\n\tTrimEnd   *int `json:\"trim_end,omitempty\"`\n\n\tSpeed *float64 `gorm:\"default:1.0\" json:\"speed,omitempty\"`\n\n\tVolume  *int `json:\"volume,omitempty\"`\n\tIsMuted bool `gorm:\"default:false\" json:\"is_muted\"`\n\tFadeIn  *int `json:\"fade_in,omitempty\"`\n\tFadeOut *int `json:\"fade_out,omitempty\"`\n\n\tTransitionIn  *uint          `gorm:\"index\" json:\"transition_in_id,omitempty\"`\n\tTransitionOut *uint          `gorm:\"index\" json:\"transition_out_id,omitempty\"`\n\tInTransition  ClipTransition `gorm:\"foreignKey:TransitionIn\" json:\"in_transition,omitempty\"`\n\tOutTransition ClipTransition `gorm:\"foreignKey:TransitionOut\" json:\"out_transition,omitempty\"`\n\n\tEffects []ClipEffect `gorm:\"foreignKey:ClipID\" json:\"effects,omitempty\"`\n}\n\nfunc (TimelineClip) TableName() string {\n\treturn \"timeline_clips\"\n}\n\ntype ClipTransition struct {\n\tID        uint           `gorm:\"primarykey\" json:\"id\"`\n\tCreatedAt time.Time      `json:\"created_at\"`\n\tUpdatedAt time.Time      `json:\"updated_at\"`\n\tDeletedAt gorm.DeletedAt `gorm:\"index\" json:\"-\"`\n\n\tType     TransitionType `gorm:\"type:varchar(50);not null\" json:\"type\"`\n\tDuration int            `gorm:\"not null;default:500\" json:\"duration\"`\n\tEasing   *string        `gorm:\"type:varchar(50)\" json:\"easing,omitempty\"`\n\n\tConfig map[string]interface{} `gorm:\"serializer:json\" json:\"config,omitempty\"`\n}\n\ntype TransitionType string\n\nconst (\n\tTransitionTypeFade      TransitionType = \"fade\"\n\tTransitionTypeCrossFade TransitionType = \"crossfade\"\n\tTransitionTypeSlide     TransitionType = \"slide\"\n\tTransitionTypeWipe      TransitionType = \"wipe\"\n\tTransitionTypeZoom      TransitionType = \"zoom\"\n\tTransitionTypeDissolve  TransitionType = \"dissolve\"\n)\n\nfunc (ClipTransition) TableName() string {\n\treturn \"clip_transitions\"\n}\n\ntype ClipEffect struct {\n\tID        uint           `gorm:\"primarykey\" json:\"id\"`\n\tCreatedAt time.Time      `json:\"created_at\"`\n\tUpdatedAt time.Time      `json:\"updated_at\"`\n\tDeletedAt gorm.DeletedAt `gorm:\"index\" json:\"-\"`\n\n\tClipID uint         `gorm:\"not null;index\" json:\"clip_id\"`\n\tClip   TimelineClip `gorm:\"foreignKey:ClipID\" json:\"-\"`\n\n\tType      EffectType `gorm:\"type:varchar(50);not null\" json:\"type\"`\n\tName      string     `gorm:\"type:varchar(100)\" json:\"name\"`\n\tIsEnabled bool       `gorm:\"default:true\" json:\"is_enabled\"`\n\tOrder     int        `gorm:\"default:0\" json:\"order\"`\n\n\tConfig map[string]interface{} `gorm:\"serializer:json\" json:\"config,omitempty\"`\n}\n\ntype EffectType string\n\nconst (\n\tEffectTypeFilter     EffectType = \"filter\"\n\tEffectTypeColor      EffectType = \"color\"\n\tEffectTypeBlur       EffectType = \"blur\"\n\tEffectTypeBrightness EffectType = \"brightness\"\n\tEffectTypeContrast   EffectType = \"contrast\"\n\tEffectTypeSaturation EffectType = \"saturation\"\n)\n\nfunc (ClipEffect) TableName() string {\n\treturn \"clip_effects\"\n}\n"
  },
  {
    "path": "domain/models/video_generation.go",
    "content": "package models\n\nimport (\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype VideoGeneration struct {\n\tID        uint           `gorm:\"primarykey\" json:\"id\"`\n\tCreatedAt time.Time      `json:\"created_at\"`\n\tUpdatedAt time.Time      `json:\"updated_at\"`\n\tDeletedAt gorm.DeletedAt `gorm:\"index\" json:\"-\"`\n\n\tStoryboardID *uint       `gorm:\"index\" json:\"storyboard_id,omitempty\"`\n\tStoryboard   *Storyboard `gorm:\"foreignKey:StoryboardID\" json:\"storyboard,omitempty\"`\n\n\tDramaID uint  `gorm:\"not null;index\" json:\"drama_id\"`\n\tDrama   Drama `gorm:\"foreignKey:DramaID\" json:\"drama,omitempty\"`\n\n\tProvider string `gorm:\"type:varchar(50);not null;index\" json:\"provider\"`\n\tPrompt   string `gorm:\"type:text;not null\" json:\"prompt\"`\n\tModel    string `gorm:\"type:varchar(100)\" json:\"model,omitempty\"`\n\n\tImageGenID *uint           `gorm:\"index\" json:\"image_gen_id,omitempty\"`\n\tImageGen   ImageGeneration `gorm:\"foreignKey:ImageGenID\" json:\"image_gen,omitempty\"`\n\n\t// 参考图模式：single(单图), first_last(首尾帧), multiple(多图), none(无)\n\tReferenceMode *string `gorm:\"type:varchar(20)\" json:\"reference_mode,omitempty\"`\n\n\tImageURL           *string `gorm:\"type:varchar(1000)\" json:\"image_url,omitempty\"`\n\tFirstFrameURL      *string `gorm:\"type:varchar(1000)\" json:\"first_frame_url,omitempty\"`\n\tLastFrameURL       *string `gorm:\"type:varchar(1000)\" json:\"last_frame_url,omitempty\"`\n\tReferenceImageURLs *string `gorm:\"type:text\" json:\"reference_image_urls,omitempty\"` // JSON数组存储多张参考图\n\n\tDuration     *int    `json:\"duration,omitempty\"`\n\tFPS          *int    `json:\"fps,omitempty\"`\n\tResolution   *string `gorm:\"type:varchar(50)\" json:\"resolution,omitempty\"`\n\tAspectRatio  *string `gorm:\"type:varchar(20)\" json:\"aspect_ratio,omitempty\"`\n\tStyle        *string `gorm:\"type:varchar(100)\" json:\"style,omitempty\"`\n\tMotionLevel  *int    `json:\"motion_level,omitempty\"`\n\tCameraMotion *string `gorm:\"type:varchar(100)\" json:\"camera_motion,omitempty\"`\n\tSeed         *int64  `json:\"seed,omitempty\"`\n\n\tVideoURL  *string `gorm:\"type:varchar(1000)\" json:\"video_url,omitempty\"`\n\tMinioURL  *string `gorm:\"type:varchar(1000)\" json:\"minio_url,omitempty\"`\n\tLocalPath *string `gorm:\"type:varchar(500)\" json:\"local_path,omitempty\"`\n\n\tStatus VideoStatus `gorm:\"type:varchar(20);not null;default:'pending';index\" json:\"status\"`\n\tTaskID *string     `gorm:\"type:varchar(200);index\" json:\"task_id,omitempty\"`\n\n\tErrorMsg    *string    `gorm:\"type:text\" json:\"error_msg,omitempty\"`\n\tCompletedAt *time.Time `json:\"completed_at,omitempty\"`\n\n\tWidth  *int `json:\"width,omitempty\"`\n\tHeight *int `json:\"height,omitempty\"`\n}\n\ntype VideoStatus string\n\nconst (\n\tVideoStatusPending    VideoStatus = \"pending\"\n\tVideoStatusProcessing VideoStatus = \"processing\"\n\tVideoStatusCompleted  VideoStatus = \"completed\"\n\tVideoStatusFailed     VideoStatus = \"failed\"\n)\n\ntype VideoProvider string\n\nconst (\n\tVideoProviderRunway VideoProvider = \"runway\"\n\tVideoProviderPika   VideoProvider = \"pika\"\n\tVideoProviderDoubao VideoProvider = \"doubao\"\n\tVideoProviderOpenAI VideoProvider = \"openai\"\n)\n\nfunc (VideoGeneration) TableName() string {\n\treturn \"video_generations\"\n}\n"
  },
  {
    "path": "domain/models/video_merge.go",
    "content": "package models\n\nimport (\n\t\"time\"\n\n\t\"gorm.io/datatypes\"\n\t\"gorm.io/gorm\"\n)\n\ntype VideoMergeStatus string\n\nconst (\n\tVideoMergeStatusPending    VideoMergeStatus = \"pending\"\n\tVideoMergeStatusProcessing VideoMergeStatus = \"processing\"\n\tVideoMergeStatusCompleted  VideoMergeStatus = \"completed\"\n\tVideoMergeStatusFailed     VideoMergeStatus = \"failed\"\n)\n\ntype VideoMerge struct {\n\tID          uint             `gorm:\"primaryKey;autoIncrement\" json:\"id\"`\n\tEpisodeID   uint             `gorm:\"not null;index\" json:\"episode_id\"`\n\tDramaID     uint             `gorm:\"not null;index\" json:\"drama_id\"`\n\tTitle       string           `gorm:\"type:varchar(200)\" json:\"title\"`\n\tProvider    string           `gorm:\"type:varchar(50);not null\" json:\"provider\"`\n\tModel       *string          `gorm:\"type:varchar(100)\" json:\"model,omitempty\"`\n\tStatus      VideoMergeStatus `gorm:\"type:varchar(20);not null;default:'pending'\" json:\"status\"`\n\tScenes      datatypes.JSON   `gorm:\"type:json;not null\" json:\"scenes\"`\n\tMergedURL   *string          `gorm:\"type:varchar(500)\" json:\"merged_url,omitempty\"`\n\tDuration    *int             `gorm:\"type:int\" json:\"duration,omitempty\"`\n\tTaskID      *string          `gorm:\"type:varchar(100)\" json:\"task_id,omitempty\"`\n\tErrorMsg    *string          `gorm:\"type:text\" json:\"error_msg,omitempty\"`\n\tCreatedAt   time.Time        `gorm:\"not null;autoCreateTime\" json:\"created_at\"`\n\tCompletedAt *time.Time       `json:\"completed_at,omitempty\"`\n\tDeletedAt   gorm.DeletedAt   `gorm:\"index\" json:\"-\"`\n\n\tEpisode Episode `gorm:\"foreignKey:EpisodeID\" json:\"episode,omitempty\"`\n\tDrama   Drama   `gorm:\"foreignKey:DramaID\" json:\"drama,omitempty\"`\n}\n\ntype SceneClip struct {\n\tSceneID    uint                   `json:\"scene_id\"`\n\tVideoURL   string                 `json:\"video_url\"`\n\tStartTime  float64                `json:\"start_time\"`\n\tEndTime    float64                `json:\"end_time\"`\n\tDuration   float64                `json:\"duration\"`\n\tOrder      int                    `json:\"order\"`\n\tTransition map[string]interface{} `json:\"transition\"`\n}\n\nfunc (v *VideoMerge) TableName() string {\n\treturn \"video_merges\"\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/drama-generator/backend\n\ngo 1.23.0\n\nreplace github.com/drama-generator/backend => ./\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.9.1\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/robfig/cron/v3 v3.0.1\n\tgithub.com/spf13/viper v1.17.0\n\tgo.uber.org/zap v1.26.0\n\tgorm.io/datatypes v1.2.0\n\tgorm.io/driver/mysql v1.5.2\n\tgorm.io/driver/postgres v1.5.0\n\tgorm.io/driver/sqlite v1.6.0\n\tgorm.io/gorm v1.30.0\n\tmodernc.org/sqlite v1.34.4\n)\n\nrequire (\n\tgithub.com/bytedance/sonic v1.9.1 // indirect\n\tgithub.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\n\tgithub.com/fsnotify/fsnotify v1.6.0 // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.2 // indirect\n\tgithub.com/gin-contrib/sse v0.1.0 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/go-playground/validator/v10 v10.14.0 // indirect\n\tgithub.com/go-sql-driver/mysql v1.7.0 // indirect\n\tgithub.com/goccy/go-json v0.10.2 // indirect\n\tgithub.com/hashicorp/golang-lru/v2 v2.0.7 // indirect\n\tgithub.com/hashicorp/hcl v1.0.0 // indirect\n\tgithub.com/jackc/pgpassfile v1.0.0 // indirect\n\tgithub.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect\n\tgithub.com/jackc/pgx/v5 v5.3.0 // indirect\n\tgithub.com/jinzhu/inflection v1.0.0 // indirect\n\tgithub.com/jinzhu/now v1.1.5 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.2.11 // indirect\n\tgithub.com/leodido/go-urn v1.2.4 // indirect\n\tgithub.com/magiconair/properties v1.8.7 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mattn/go-sqlite3 v1.14.22 // indirect\n\tgithub.com/mitchellh/mapstructure v1.5.0 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/ncruces/go-strftime v0.1.9 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.1.0 // indirect\n\tgithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect\n\tgithub.com/sagikazarmark/locafero v0.3.0 // indirect\n\tgithub.com/sagikazarmark/slog-shim v0.1.0 // indirect\n\tgithub.com/sourcegraph/conc v0.3.0 // indirect\n\tgithub.com/spf13/afero v1.10.0 // indirect\n\tgithub.com/spf13/cast v1.5.1 // indirect\n\tgithub.com/spf13/pflag v1.0.5 // indirect\n\tgithub.com/stretchr/testify v1.9.0 // indirect\n\tgithub.com/subosito/gotenv v1.6.0 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.2.11 // indirect\n\tgo.uber.org/goleak v1.2.1 // indirect\n\tgo.uber.org/multierr v1.10.0 // indirect\n\tgolang.org/x/arch v0.3.0 // indirect\n\tgolang.org/x/crypto v0.36.0 // indirect\n\tgolang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect\n\tgolang.org/x/net v0.38.0 // indirect\n\tgolang.org/x/sys v0.34.0 // indirect\n\tgolang.org/x/text v0.26.0 // indirect\n\tgoogle.golang.org/protobuf v1.31.0 // indirect\n\tgopkg.in/ini.v1 v1.67.0 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n\tmodernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect\n\tmodernc.org/libc v1.55.3 // indirect\n\tmodernc.org/mathutil v1.6.0 // indirect\n\tmodernc.org/memory v1.8.0 // indirect\n\tmodernc.org/strutil v1.2.0 // indirect\n\tmodernc.org/token v1.1.0 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ncloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ncloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=\ncloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=\ncloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=\ncloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=\ncloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=\ncloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=\ncloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=\ncloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=\ncloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=\ncloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=\ncloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=\ncloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=\ncloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=\ncloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=\ncloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=\ncloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=\ncloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=\ncloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=\ncloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=\ncloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=\ncloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=\ncloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=\ncloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=\ncloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=\ncloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=\ncloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=\ncloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=\ncloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=\ncloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=\ncloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=\ncloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=\ncloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=\ncloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=\ncloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=\ncloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=\ndmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=\ngithub.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=\ngithub.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=\ngithub.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=\ngithub.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=\ngithub.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=\ngithub.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=\ngithub.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=\ngithub.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=\ngithub.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=\ngithub.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=\ngithub.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=\ngithub.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=\ngithub.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=\ngithub.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=\ngithub.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=\ngithub.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=\ngithub.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=\ngithub.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=\ngithub.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=\ngithub.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=\ngithub.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=\ngithub.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=\ngithub.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=\ngithub.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=\ngithub.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=\ngithub.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=\ngithub.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=\ngithub.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=\ngithub.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=\ngithub.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=\ngithub.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=\ngithub.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=\ngithub.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=\ngithub.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=\ngithub.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=\ngithub.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=\ngithub.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=\ngithub.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=\ngithub.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=\ngithub.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=\ngithub.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=\ngithub.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=\ngithub.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=\ngithub.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=\ngithub.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=\ngithub.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=\ngithub.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=\ngithub.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=\ngithub.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=\ngithub.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=\ngithub.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=\ngithub.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=\ngithub.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=\ngithub.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=\ngithub.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=\ngithub.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=\ngithub.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=\ngithub.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=\ngithub.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=\ngithub.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=\ngithub.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=\ngithub.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=\ngithub.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=\ngithub.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=\ngithub.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=\ngithub.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=\ngithub.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=\ngithub.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=\ngithub.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=\ngithub.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=\ngithub.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=\ngithub.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=\ngithub.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=\ngithub.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=\ngithub.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=\ngithub.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=\ngithub.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=\ngithub.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=\ngithub.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=\ngithub.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=\ngithub.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=\ngithub.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=\ngithub.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=\ngithub.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=\ngithub.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=\ngithub.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=\ngithub.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=\ngithub.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=\ngithub.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=\ngithub.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=\ngithub.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=\ngithub.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=\ngithub.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=\ngithub.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=\ngithub.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=\ngithub.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=\ngithub.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=\ngithub.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=\ngithub.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=\ngithub.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA=\ngithub.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=\ngithub.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=\ngithub.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=\ngithub.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=\ngithub.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=\ngithub.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=\ngithub.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=\ngithub.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=\ngithub.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=\ngithub.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=\ngithub.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=\ngithub.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=\ngithub.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=\ngithub.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=\ngithub.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE=\ngithub.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ=\ngithub.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=\ngithub.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=\ngithub.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=\ngithub.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=\ngithub.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=\ngithub.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=\ngithub.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=\ngithub.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=\ngithub.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=\ngithub.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=\ngithub.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=\ngithub.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ=\ngithub.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U=\ngithub.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=\ngithub.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=\ngithub.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=\ngithub.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=\ngithub.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY=\ngithub.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=\ngithub.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=\ngithub.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=\ngithub.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=\ngithub.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI=\ngithub.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\ngithub.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=\ngithub.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=\ngithub.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=\ngithub.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=\ngithub.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngo.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=\ngo.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=\ngo.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=\ngo.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=\ngo.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=\ngo.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=\ngo.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=\ngo.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=\ngo.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=\ngo.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=\ngo.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=\ngo.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=\ngolang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=\ngolang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=\ngolang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=\ngolang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=\ngolang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=\ngolang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=\ngolang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=\ngolang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=\ngolang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=\ngolang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=\ngolang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=\ngolang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=\ngolang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=\ngolang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=\ngolang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=\ngolang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=\ngolang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=\ngolang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=\ngolang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=\ngolang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=\ngolang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=\ngolang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=\ngolang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=\ngolang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=\ngolang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=\ngolang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=\ngolang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=\ngolang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=\ngolang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=\ngolang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=\ngolang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=\ngolang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=\ngolang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=\ngolang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=\ngolang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=\ngolang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=\ngolang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=\ngolang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=\ngolang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=\ngolang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=\ngolang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=\ngolang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=\ngolang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=\ngolang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=\ngolang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=\ngolang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=\ngolang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=\ngolang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=\ngolang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=\ngolang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=\ngolang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=\ngolang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=\ngolang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=\ngolang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=\ngolang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=\ngolang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=\ngoogle.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=\ngoogle.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=\ngoogle.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=\ngoogle.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=\ngoogle.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=\ngoogle.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=\ngoogle.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=\ngoogle.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=\ngoogle.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=\ngoogle.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=\ngoogle.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=\ngoogle.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=\ngoogle.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=\ngoogle.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=\ngoogle.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=\ngoogle.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=\ngoogle.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=\ngoogle.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=\ngoogle.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=\ngoogle.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=\ngoogle.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=\ngoogle.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=\ngoogle.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=\ngoogle.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=\ngoogle.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=\ngoogle.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=\ngoogle.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=\ngoogle.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=\ngoogle.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=\ngoogle.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=\ngoogle.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=\ngoogle.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=\ngoogle.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=\ngoogle.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=\ngoogle.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=\ngoogle.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=\ngoogle.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=\ngoogle.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=\ngoogle.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=\ngoogle.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=\ngoogle.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=\ngoogle.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=\ngoogle.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=\ngoogle.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=\ngoogle.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=\ngoogle.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=\ngoogle.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=\ngoogle.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=\ngoogle.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=\ngoogle.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=\ngoogle.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=\ngoogle.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=\ngoogle.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=\ngoogle.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=\ngoogle.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=\ngoogle.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=\ngoogle.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=\ngoogle.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=\ngoogle.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=\ngopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=\ngopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=\ngopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngorm.io/datatypes v1.2.0 h1:5YT+eokWdIxhJgWHdrb2zYUimyk0+TaFth+7a0ybzco=\ngorm.io/datatypes v1.2.0/go.mod h1:o1dh0ZvjIjhH/bngTpypG6lVRJ5chTBxE09FH/71k04=\ngorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=\ngorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=\ngorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=\ngorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=\ngorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=\ngorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=\ngorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0=\ngorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig=\ngorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=\ngorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=\ngorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=\ngorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=\nhonnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=\nhonnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=\nhonnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=\nmodernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=\nmodernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=\nmodernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=\nmodernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=\nmodernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=\nmodernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=\nmodernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=\nmodernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=\nmodernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=\nmodernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=\nmodernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=\nmodernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=\nmodernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=\nmodernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=\nmodernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=\nmodernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=\nmodernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=\nmodernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=\nmodernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=\nmodernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=\nmodernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=\nmodernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=\nmodernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=\nmodernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=\nmodernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=\nmodernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=\nrsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=\nrsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=\nrsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=\nrsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=\n"
  },
  {
    "path": "infrastructure/database/custom_logger.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"time\"\n\n\t\"gorm.io/gorm/logger\"\n)\n\n// CustomLogger 自定义 GORM logger，截断过长的 SQL 参数（如 base64 数据）\ntype CustomLogger struct {\n\tlogger.Interface\n}\n\n// NewCustomLogger 创建自定义 logger\nfunc NewCustomLogger() logger.Interface {\n\treturn &CustomLogger{\n\t\tInterface: logger.Default.LogMode(logger.Silent),\n\t}\n}\n\n// Trace 重写 Trace 方法，禁用 SQL 日志输出\nfunc (l *CustomLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {\n\t// 不输出任何 SQL 日志\n\t// 如果需要调试，可以临时取消注释下面的代码\n\t/*\n\t\tsql, rows := fc()\n\t\tsql = truncateLongValues(sql)\n\t\telapsed := time.Since(begin)\n\t\tif err != nil {\n\t\t\tl.Interface.Error(ctx, \"SQL error: %v [%v] %s\", err, elapsed, sql)\n\t\t} else {\n\t\t\tl.Interface.Info(ctx, \"[%.3fms] [rows:%d] %s\", float64(elapsed.Nanoseconds())/1e6, rows, sql)\n\t\t}\n\t*/\n}\n\n// truncateLongValues 截断 SQL 中的长字符串值\nfunc truncateLongValues(sql string) string {\n\t// 查找 base64 格式的数据 (data:image/...;base64,...)\n\tif strings.Contains(sql, \"data:image/\") && strings.Contains(sql, \";base64,\") {\n\t\tparts := strings.Split(sql, \"\\\"\")\n\t\tfor i, part := range parts {\n\t\t\tif strings.HasPrefix(part, \"data:image/\") && strings.Contains(part, \";base64,\") {\n\t\t\t\tif len(part) > 100 {\n\t\t\t\t\t// 保留前50字符，添加截断标记\n\t\t\t\t\tparts[i] = part[:50] + \"...[base64 data truncated]\"\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tsql = strings.Join(parts, \"\\\"\")\n\t}\n\n\t// 截断其他过长的值\n\tif len(sql) > 5000 {\n\t\t// 查找 VALUES 或 SET 后的内容\n\t\tif idx := strings.Index(sql, \" VALUES \"); idx > 0 && len(sql) > idx+5000 {\n\t\t\tsql = sql[:idx+5000] + \"...[truncated]\"\n\t\t} else if idx := strings.Index(sql, \" SET \"); idx > 0 && len(sql) > idx+3000 {\n\t\t\tsql = sql[:idx+3000] + \"...[truncated]\"\n\t\t} else if len(sql) > 5000 {\n\t\t\tsql = sql[:5000] + \"...[truncated]\"\n\t\t}\n\t}\n\n\treturn sql\n}\n\n// Info 实现 Info 方法\nfunc (l *CustomLogger) Info(ctx context.Context, msg string, data ...interface{}) {\n\tl.Interface.Info(ctx, msg, data...)\n}\n\n// Warn 实现 Warn 方法\nfunc (l *CustomLogger) Warn(ctx context.Context, msg string, data ...interface{}) {\n\tl.Interface.Warn(ctx, msg, data...)\n}\n\n// Error 实现 Error 方法\nfunc (l *CustomLogger) Error(ctx context.Context, msg string, data ...interface{}) {\n\t// 检查并截断 data 中的长字符串\n\ttruncatedData := make([]interface{}, len(data))\n\tfor i, d := range data {\n\t\tif str, ok := d.(string); ok && len(str) > 200 {\n\t\t\tif strings.HasPrefix(str, \"data:image/\") {\n\t\t\t\ttruncatedData[i] = str[:50] + \"...[base64 data]\"\n\t\t\t} else {\n\t\t\t\ttruncatedData[i] = str[:200] + \"...\"\n\t\t\t}\n\t\t} else {\n\t\t\ttruncatedData[i] = d\n\t\t}\n\t}\n\tl.Interface.Error(ctx, msg, truncatedData...)\n}\n\n// LogMode 实现 LogMode 方法\nfunc (l *CustomLogger) LogMode(level logger.LogLevel) logger.Interface {\n\tnewLogger := *l\n\tnewLogger.Interface = l.Interface.LogMode(level)\n\treturn &newLogger\n}\n"
  },
  {
    "path": "infrastructure/database/database.go",
    "content": "package database\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/drama-generator/backend/domain/models\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"gorm.io/driver/mysql\"\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n\t_ \"modernc.org/sqlite\"\n)\n\nfunc NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {\n\tdsn := cfg.DSN()\n\n\tif cfg.Type == \"sqlite\" {\n\t\tdbDir := filepath.Dir(dsn)\n\t\tif err := os.MkdirAll(dbDir, 0755); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create database directory: %w\", err)\n\t\t}\n\t}\n\n\tgormConfig := &gorm.Config{\n\t\tLogger: NewCustomLogger(),\n\t}\n\n\tvar db *gorm.DB\n\tvar err error\n\n\tif cfg.Type == \"sqlite\" {\n\t\t// 使用 modernc.org/sqlite 纯 Go 驱动（无需 CGO）\n\t\t// 添加并发优化参数：WAL 模式、busy_timeout、cache\n\t\tdsnWithParams := dsn + \"?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL&cache=shared\"\n\t\tdb, err = gorm.Open(sqlite.Dialector{\n\t\t\tDriverName: \"sqlite\",\n\t\t\tDSN:        dsnWithParams,\n\t\t}, gormConfig)\n\t} else {\n\t\tdb, err = gorm.Open(mysql.Open(dsn), gormConfig)\n\t}\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to connect to database: %w\", err)\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get database instance: %w\", err)\n\t}\n\n\t// SQLite 连接池配置（限制并发连接数）\n\tif cfg.Type == \"sqlite\" {\n\t\tsqlDB.SetMaxIdleConns(1)\n\t\tsqlDB.SetMaxOpenConns(1) // SQLite 单写入，限制为 1\n\t} else {\n\t\tsqlDB.SetMaxIdleConns(cfg.MaxIdle)\n\t\tsqlDB.SetMaxOpenConns(cfg.MaxOpen)\n\t}\n\tsqlDB.SetConnMaxLifetime(time.Hour)\n\n\tif err := sqlDB.Ping(); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to ping database: %w\", err)\n\t}\n\n\treturn db, nil\n}\n\nfunc AutoMigrate(db *gorm.DB) error {\n\treturn db.AutoMigrate(\n\t\t// 核心模型\n\t\t&models.Drama{},\n\t\t&models.Episode{},\n\t\t&models.Character{},\n\t\t&models.Scene{},\n\t\t&models.Storyboard{},\n\t\t&models.FramePrompt{},\n\t\t&models.Prop{},\n\n\t\t// 生成相关\n\t\t&models.ImageGeneration{},\n\t\t&models.VideoGeneration{},\n\t\t&models.VideoMerge{},\n\n\t\t// AI配置\n\t\t&models.AIServiceConfig{},\n\t\t&models.AIServiceProvider{},\n\n\t\t// 资源管理\n\t\t&models.Asset{},\n\t\t&models.CharacterLibrary{},\n\n\t\t// 任务管理\n\t\t&models.AsyncTask{},\n\t)\n}\n"
  },
  {
    "path": "infrastructure/external/ffmpeg/ffmpeg.go",
    "content": "package ffmpeg\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/drama-generator/backend/pkg/logger\"\n)\n\ntype FFmpeg struct {\n\tlog     *logger.Logger\n\ttempDir string\n}\n\nfunc NewFFmpeg(log *logger.Logger) *FFmpeg {\n\ttempDir := filepath.Join(os.TempDir(), \"drama-video-merge\")\n\tos.MkdirAll(tempDir, 0755)\n\n\treturn &FFmpeg{\n\t\tlog:     log,\n\t\ttempDir: tempDir,\n\t}\n}\n\ntype VideoClip struct {\n\tURL        string\n\tDuration   float64\n\tStartTime  float64\n\tEndTime    float64\n\tTransition map[string]interface{}\n}\n\ntype MergeOptions struct {\n\tOutputPath string\n\tClips      []VideoClip\n}\n\nfunc (f *FFmpeg) MergeVideos(opts *MergeOptions) (string, error) {\n\tif len(opts.Clips) == 0 {\n\t\treturn \"\", fmt.Errorf(\"no video clips to merge\")\n\t}\n\n\tf.log.Infow(\"Starting video merge with trimming\", \"clips_count\", len(opts.Clips))\n\n\t// 下载并裁剪所有视频片段\n\ttrimmedPaths := make([]string, 0, len(opts.Clips))\n\tdownloadedPaths := make([]string, 0, len(opts.Clips))\n\n\tfor i, clip := range opts.Clips {\n\t\t// 下载原始视频\n\t\tdownloadPath := filepath.Join(f.tempDir, fmt.Sprintf(\"download_%d_%d.mp4\", time.Now().Unix(), i))\n\t\tlocalPath, err := f.downloadVideo(clip.URL, downloadPath)\n\t\tif err != nil {\n\t\t\tf.cleanup(downloadedPaths)\n\t\t\tf.cleanup(trimmedPaths)\n\t\t\treturn \"\", fmt.Errorf(\"failed to download clip %d: %w\", i, err)\n\t\t}\n\t\tdownloadedPaths = append(downloadedPaths, localPath)\n\n\t\t// 裁剪视频片段（根据StartTime和EndTime）\n\t\ttrimmedPath := filepath.Join(f.tempDir, fmt.Sprintf(\"trimmed_%d_%d.mp4\", time.Now().Unix(), i))\n\t\terr = f.trimVideo(localPath, trimmedPath, clip.StartTime, clip.EndTime)\n\t\tif err != nil {\n\t\t\tf.cleanup(downloadedPaths)\n\t\t\tf.cleanup(trimmedPaths)\n\t\t\treturn \"\", fmt.Errorf(\"failed to trim clip %d: %w\", i, err)\n\t\t}\n\t\ttrimmedPaths = append(trimmedPaths, trimmedPath)\n\n\t\tf.log.Infow(\"Clip trimmed\",\n\t\t\t\"index\", i,\n\t\t\t\"start\", clip.StartTime,\n\t\t\t\"end\", clip.EndTime,\n\t\t\t\"duration\", clip.EndTime-clip.StartTime)\n\t}\n\n\t// 清理下载的原始文件\n\tf.cleanup(downloadedPaths)\n\n\t// 确保输出目录存在\n\toutputDir := filepath.Dir(opts.OutputPath)\n\tif err := os.MkdirAll(outputDir, 0755); err != nil {\n\t\tf.cleanup(trimmedPaths)\n\t\treturn \"\", fmt.Errorf(\"failed to create output directory: %w\", err)\n\t}\n\n\t// 合并裁剪后的视频片段（支持转场效果）\n\terr := f.concatenateVideosWithTransitions(trimmedPaths, opts.Clips, opts.OutputPath)\n\n\t// 清理裁剪后的临时文件\n\tf.cleanup(trimmedPaths)\n\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to concatenate videos: %w\", err)\n\t}\n\n\tf.log.Infow(\"Video merge completed\", \"output\", opts.OutputPath)\n\treturn opts.OutputPath, nil\n}\n\nfunc (f *FFmpeg) downloadVideo(url, destPath string) (string, error) {\n\t// 检查是否是本地文件路径\n\tif !strings.HasPrefix(url, \"http://\") && !strings.HasPrefix(url, \"https://\") {\n\t\t// 这是本地文件路径，检查文件是否存在\n\t\tif _, err := os.Stat(url); err == nil {\n\t\t\tf.log.Infow(\"Copying local video file to temp\", \"source\", url, \"dest\", destPath)\n\t\t\t// 复制本地文件到临时目录，避免删除原始文件\n\t\t\tsourceFile, err := os.Open(url)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", fmt.Errorf(\"failed to open source file: %w\", err)\n\t\t\t}\n\t\t\tdefer sourceFile.Close()\n\n\t\t\tdestFile, err := os.Create(destPath)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", fmt.Errorf(\"failed to create dest file: %w\", err)\n\t\t\t}\n\t\t\tdefer destFile.Close()\n\n\t\t\t_, err = io.Copy(destFile, sourceFile)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", fmt.Errorf(\"failed to copy file: %w\", err)\n\t\t\t}\n\n\t\t\treturn destPath, nil\n\t\t} else {\n\t\t\treturn \"\", fmt.Errorf(\"local file not found: %s\", url)\n\t\t}\n\t}\n\n\t// 远程 URL，需要下载\n\tf.log.Infow(\"Downloading video\", \"url\", url, \"dest\", destPath)\n\n\tresp, err := http.Get(url)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to download: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"bad status: %s\", resp.Status)\n\t}\n\n\tout, err := os.Create(destPath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create file: %w\", err)\n\t}\n\tdefer out.Close()\n\n\t_, err = io.Copy(out, resp.Body)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to save file: %w\", err)\n\t}\n\n\treturn destPath, nil\n}\n\nfunc (f *FFmpeg) trimVideo(inputPath, outputPath string, startTime, endTime float64) error {\n\tf.log.Infow(\"Trimming video\",\n\t\t\"input\", inputPath,\n\t\t\"output\", outputPath,\n\t\t\"start\", startTime,\n\t\t\"end\", endTime)\n\n\t// 如果startTime和endTime都为0，或者endTime <= startTime，复制整个视频\n\t// 使用重新编码而非-c copy以确保输出文件完整性\n\tif (startTime == 0 && endTime == 0) || endTime <= startTime {\n\t\tf.log.Infow(\"No valid trim range, re-encoding entire video\")\n\n\t\tcmd := exec.Command(\"ffmpeg\",\n\t\t\t\"-i\", inputPath,\n\t\t\t\"-c:v\", \"libx264\",\n\t\t\t\"-preset\", \"fast\",\n\t\t\t\"-crf\", \"23\",\n\t\t\t\"-c:a\", \"aac\",\n\t\t\t\"-b:a\", \"128k\",\n\t\t\t\"-movflags\", \"+faststart\",\n\t\t\t\"-y\",\n\t\t\toutputPath,\n\t\t)\n\n\t\toutput, err := cmd.CombinedOutput()\n\t\tif err != nil {\n\t\t\tf.log.Errorw(\"FFmpeg re-encode failed\", \"error\", err, \"output\", string(output))\n\t\t\treturn fmt.Errorf(\"ffmpeg re-encode failed: %w, output: %s\", err, string(output))\n\t\t}\n\n\t\tf.log.Infow(\"Video re-encoded successfully\", \"output\", outputPath)\n\t\treturn nil\n\t}\n\n\t// 使用FFmpeg裁剪视频\n\t// -ss: 开始时间（秒）\n\t// -to/-t: 结束时间或持续时间\n\t// 使用重新编码而非-c copy以确保输出文件完整性，避免Windows环境下流信息丢失\n\tvar cmd *exec.Cmd\n\tif endTime > 0 {\n\t\t// 有明确的结束时间\n\t\tcmd = exec.Command(\"ffmpeg\",\n\t\t\t\"-i\", inputPath,\n\t\t\t\"-ss\", fmt.Sprintf(\"%.2f\", startTime),\n\t\t\t\"-to\", fmt.Sprintf(\"%.2f\", endTime),\n\t\t\t\"-c:v\", \"libx264\",\n\t\t\t\"-preset\", \"fast\",\n\t\t\t\"-crf\", \"23\",\n\t\t\t\"-c:a\", \"aac\",\n\t\t\t\"-b:a\", \"128k\",\n\t\t\t\"-movflags\", \"+faststart\",\n\t\t\t\"-y\",\n\t\t\toutputPath,\n\t\t)\n\t} else {\n\t\t// 只有开始时间，裁剪到视频末尾\n\t\tcmd = exec.Command(\"ffmpeg\",\n\t\t\t\"-i\", inputPath,\n\t\t\t\"-ss\", fmt.Sprintf(\"%.2f\", startTime),\n\t\t\t\"-c:v\", \"libx264\",\n\t\t\t\"-preset\", \"fast\",\n\t\t\t\"-crf\", \"23\",\n\t\t\t\"-c:a\", \"aac\",\n\t\t\t\"-b:a\", \"128k\",\n\t\t\t\"-movflags\", \"+faststart\",\n\t\t\t\"-y\",\n\t\t\toutputPath,\n\t\t)\n\t}\n\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\tf.log.Errorw(\"FFmpeg trim failed\", \"error\", err, \"output\", string(output))\n\t\treturn fmt.Errorf(\"ffmpeg trim failed: %w, output: %s\", err, string(output))\n\t}\n\n\tf.log.Infow(\"Video trimmed successfully\", \"output\", outputPath)\n\treturn nil\n}\n\nfunc (f *FFmpeg) concatenateVideosWithTransitions(inputPaths []string, clips []VideoClip, outputPath string) error {\n\tif len(inputPaths) == 0 {\n\t\treturn fmt.Errorf(\"no input paths\")\n\t}\n\n\t// 如果只有一个视频，直接复制\n\tif len(inputPaths) == 1 {\n\t\tf.log.Infow(\"Only one clip, copying directly\")\n\t\treturn f.copyFile(inputPaths[0], outputPath)\n\t}\n\n\t// 检查是否有转场效果\n\thasTransitions := false\n\tfor _, clip := range clips {\n\t\tif clip.Transition != nil && len(clip.Transition) > 0 {\n\t\t\thasTransitions = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// 如果没有转场效果，使用简单拼接\n\tif !hasTransitions {\n\t\tf.log.Infow(\"No transitions, using simple concatenation\")\n\t\treturn f.concatenateVideos(inputPaths, outputPath)\n\t}\n\n\t// 使用xfade滤镜添加转场效果\n\tf.log.Infow(\"Merging with transitions\", \"clips_count\", len(inputPaths))\n\treturn f.mergeWithXfade(inputPaths, clips, outputPath)\n}\n\nfunc (f *FFmpeg) concatenateVideos(inputPaths []string, outputPath string) error {\n\t// 创建文件列表\n\tlistFile := filepath.Join(f.tempDir, fmt.Sprintf(\"filelist_%d.txt\", time.Now().Unix()))\n\tdefer os.Remove(listFile)\n\n\tvar content strings.Builder\n\tfor _, path := range inputPaths {\n\t\tcontent.WriteString(fmt.Sprintf(\"file '%s'\\n\", path))\n\t}\n\n\tif err := os.WriteFile(listFile, []byte(content.String()), 0644); err != nil {\n\t\treturn fmt.Errorf(\"failed to create file list: %w\", err)\n\t}\n\n\t// 使用FFmpeg合并视频\n\t// -f concat: 使用concat demuxer\n\t// -safe 0: 允许不安全的文件路径\n\t// -i: 输入文件列表\n\t// -c copy: 直接复制流，不重新编码（速度快）\n\tcmd := exec.Command(\"ffmpeg\",\n\t\t\"-f\", \"concat\",\n\t\t\"-safe\", \"0\",\n\t\t\"-i\", listFile,\n\t\t\"-c\", \"copy\",\n\t\t\"-y\", // 覆盖输出文件\n\t\toutputPath,\n\t)\n\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\tf.log.Errorw(\"FFmpeg failed\", \"error\", err, \"output\", string(output))\n\t\treturn fmt.Errorf(\"ffmpeg execution failed: %w, output: %s\", err, string(output))\n\t}\n\n\tf.log.Infow(\"FFmpeg concatenation completed\", \"output\", outputPath)\n\treturn nil\n}\n\nfunc (f *FFmpeg) mergeWithXfade(inputPaths []string, clips []VideoClip, outputPath string) error {\n\t// 使用xfade滤镜进行转场\n\t// 构建输入参数\n\targs := []string{}\n\tfor _, path := range inputPaths {\n\t\targs = append(args, \"-i\", path)\n\t}\n\n\t// 检测每个视频是否有音频流\n\taudioStreams := make([]bool, len(inputPaths))\n\thasAnyAudio := false\n\tfor i, path := range inputPaths {\n\t\taudioStreams[i] = f.hasAudioStream(path)\n\t\tif audioStreams[i] {\n\t\t\thasAnyAudio = true\n\t\t}\n\t\tf.log.Infow(\"Audio stream detection\", \"index\", i, \"path\", path, \"has_audio\", audioStreams[i])\n\t}\n\tf.log.Infow(\"Overall audio detection\", \"has_any_audio\", hasAnyAudio, \"audio_streams\", audioStreams)\n\n\t// 检测视频分辨率，找到最大分辨率作为目标分辨率\n\tmaxWidth := 0\n\tmaxHeight := 0\n\tfor i, path := range inputPaths {\n\t\twidth, height := f.getVideoResolution(path)\n\t\tif width > maxWidth {\n\t\t\tmaxWidth = width\n\t\t}\n\t\tif height > maxHeight {\n\t\t\tmaxHeight = height\n\t\t}\n\t\tf.log.Infow(\"Video resolution detection\", \"index\", i, \"width\", width, \"height\", height)\n\t}\n\tf.log.Infow(\"Target resolution\", \"width\", maxWidth, \"height\", maxHeight)\n\n\t// 为每个视频流添加缩放滤镜，统一分辨率\n\t// 同时为有转场的视频添加 tpad 延长（freeze 最后一帧）\n\tvar scaleFilters []string\n\tfor i := 0; i < len(inputPaths); i++ {\n\t\t// 检查当前视频是否需要转场到下一个视频\n\t\tvar tpadDuration float64 = 0\n\t\tif i < len(clips)-1 && clips[i].Transition != nil {\n\t\t\t// 检查转场类型\n\t\t\tif tType, ok := clips[i].Transition[\"type\"].(string); ok {\n\t\t\t\t// none 转场不需要 tpad\n\t\t\t\tif strings.ToLower(tType) != \"none\" && tType != \"\" {\n\t\t\t\t\tif tDuration, ok := clips[i].Transition[\"duration\"].(float64); ok && tDuration > 0 {\n\t\t\t\t\t\ttpadDuration = tDuration\n\t\t\t\t\t} else {\n\t\t\t\t\t\ttpadDuration = 1.0 // 默认1秒\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// 没有指定类型，默认需要转场\n\t\t\t\tif tDuration, ok := clips[i].Transition[\"duration\"].(float64); ok && tDuration > 0 {\n\t\t\t\t\ttpadDuration = tDuration\n\t\t\t\t} else {\n\t\t\t\t\ttpadDuration = 1.0\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 使用scale滤镜缩放到目标分辨率，pad添加黑边保持长宽比\n\t\t// 如果需要转场，使用 tpad 延长视频（freeze最后一帧）\n\t\tif tpadDuration > 0 {\n\t\t\tscaleFilters = append(scaleFilters,\n\t\t\t\tfmt.Sprintf(\"[%d:v]scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2,tpad=stop_mode=clone:stop_duration=%.2f[v%d]\",\n\t\t\t\t\ti, maxWidth, maxHeight, maxWidth, maxHeight, tpadDuration, i))\n\t\t\tf.log.Infow(\"Adding tpad to video\", \"index\", i, \"duration\", tpadDuration)\n\t\t} else {\n\t\t\tscaleFilters = append(scaleFilters,\n\t\t\t\tfmt.Sprintf(\"[%d:v]scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2[v%d]\",\n\t\t\t\t\ti, maxWidth, maxHeight, maxWidth, maxHeight, i))\n\t\t}\n\t}\n\n\t// 构建filter_complex\n\t// 检查是否有任何转场效果\n\thasAnyTransition := false\n\tfor i := 0; i < len(inputPaths)-1; i++ {\n\t\tif clips[i].Transition != nil {\n\t\t\tif tType, ok := clips[i].Transition[\"type\"].(string); ok {\n\t\t\t\tif strings.ToLower(tType) != \"none\" && tType != \"\" {\n\t\t\t\t\thasAnyTransition = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 如果没有任何转场，使用简单拼接\n\tif !hasAnyTransition {\n\t\tf.log.Infow(\"No transitions detected, using simple concatenation\")\n\t\treturn f.concatenateVideos(inputPaths, outputPath)\n\t}\n\n\t// 构建转场滤镜，使用缩放后的视频流\n\t// 对所有相邻视频都应用 xfade，type=none 时使用 0 秒时长实现无缝拼接\n\tvar transitionFilters []string\n\tvar offset float64 = 0\n\n\tfor i := 0; i < len(inputPaths)-1; i++ {\n\t\t// 获取当前片段的时长\n\t\tclipDuration := clips[i].Duration\n\t\tif clips[i].EndTime > 0 && clips[i].StartTime >= 0 {\n\t\t\tclipDuration = clips[i].EndTime - clips[i].StartTime\n\t\t}\n\n\t\t// 默认转场参数\n\t\ttransitionType := \"fade\"\n\t\ttransitionDuration := 1.0\n\n\t\tif clips[i].Transition != nil {\n\t\t\tif tType, ok := clips[i].Transition[\"type\"].(string); ok {\n\t\t\t\tif strings.ToLower(tType) == \"none\" || tType == \"\" {\n\t\t\t\t\t// none 转场使用 0 秒时长，实现无缝拼接\n\t\t\t\t\ttransitionDuration = 0.0\n\t\t\t\t\tf.log.Infow(\"Using no transition (0s xfade)\", \"clip_index\", i)\n\t\t\t\t} else {\n\t\t\t\t\ttransitionType = f.mapTransitionType(tType)\n\t\t\t\t\tf.log.Infow(\"Using transition type\", \"type\", tType, \"mapped\", transitionType)\n\t\t\t\t}\n\t\t\t}\n\t\t\t// 只有非 none 转场才读取时长\n\t\t\tif transitionDuration > 0 {\n\t\t\t\tif tDuration, ok := clips[i].Transition[\"duration\"].(float64); ok && tDuration > 0 {\n\t\t\t\t\ttransitionDuration = tDuration\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 计算转场开始的时间点\n\t\toffset += clipDuration\n\t\tif offset < 0 {\n\t\t\toffset = 0\n\t\t}\n\n\t\tf.log.Infow(\"Transition settings\",\n\t\t\t\"clip_index\", i,\n\t\t\t\"type\", transitionType,\n\t\t\t\"duration\", transitionDuration,\n\t\t\t\"offset\", offset,\n\t\t\t\"clip_duration\", clipDuration)\n\n\t\tvar inputLabel, outputLabel string\n\t\tif i == 0 {\n\t\t\tinputLabel = fmt.Sprintf(\"[v0][v1]\")\n\t\t} else {\n\t\t\tinputLabel = fmt.Sprintf(\"[vx%02d][v%d]\", i-1, i+1)\n\t\t}\n\n\t\tif i == len(inputPaths)-2 {\n\t\t\toutputLabel = \"[outv]\"\n\t\t} else {\n\t\t\toutputLabel = fmt.Sprintf(\"[vx%02d]\", i)\n\t\t}\n\n\t\tfilterPart := fmt.Sprintf(\"%sxfade=transition=%s:duration=%.1f:offset=%.1f%s\",\n\t\t\tinputLabel, transitionType, transitionDuration, offset, outputLabel)\n\t\ttransitionFilters = append(transitionFilters, filterPart)\n\t}\n\n\t// 合并缩放和转场滤镜\n\tvar videoFilters []string\n\tvideoFilters = append(videoFilters, scaleFilters...)\n\tvideoFilters = append(videoFilters, transitionFilters...)\n\tfilterComplex := strings.Join(videoFilters, \";\")\n\n\t// 音频处理：如果有任何视频包含音频流，则处理音频\n\tvar fullFilter string\n\tif hasAnyAudio {\n\t\t// 为音频流添加处理：生成静音流或延长音频\n\t\tvar audioFilters []string\n\t\tfor i := 0; i < len(inputPaths); i++ {\n\t\t\t// 计算该视频的时长\n\t\t\tclipDuration := clips[i].Duration\n\t\t\tif clips[i].EndTime > 0 && clips[i].StartTime >= 0 {\n\t\t\t\tclipDuration = clips[i].EndTime - clips[i].StartTime\n\t\t\t}\n\n\t\t\t// 检查是否需要为转场延长音频\n\t\t\tvar padDuration float64 = 0\n\t\t\tif i < len(clips)-1 && clips[i].Transition != nil {\n\t\t\t\t// 检查转场类型\n\t\t\t\tneedTransition := true\n\t\t\t\tif tType, ok := clips[i].Transition[\"type\"].(string); ok {\n\t\t\t\t\tif strings.ToLower(tType) == \"none\" || tType == \"\" {\n\t\t\t\t\t\tneedTransition = false\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// 只有需要转场时才延长音频\n\t\t\t\tif needTransition {\n\t\t\t\t\tif tDuration, ok := clips[i].Transition[\"duration\"].(float64); ok && tDuration > 0 {\n\t\t\t\t\t\tpadDuration = tDuration\n\t\t\t\t\t} else {\n\t\t\t\t\t\tpadDuration = 1.0\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif !audioStreams[i] {\n\t\t\t\t// 没有音频的视频：生成静音轨道（包括转场延长）\n\t\t\t\ttotalDuration := clipDuration + padDuration\n\t\t\t\taudioFilters = append(audioFilters,\n\t\t\t\t\tfmt.Sprintf(\"anullsrc=channel_layout=stereo:sample_rate=44100:duration=%.2f[a%d]\", totalDuration, i))\n\t\t\t\tf.log.Infow(\"Generated silence for audio\", \"index\", i, \"duration\", totalDuration)\n\t\t\t} else if padDuration > 0 {\n\t\t\t\t// 有音频且需要延长：使用apad添加静音延长（稍后会用acrossfade处理）\n\t\t\t\taudioFilters = append(audioFilters,\n\t\t\t\t\tfmt.Sprintf(\"[%d:a]apad=pad_dur=%.2f[a%d]\", i, padDuration, i))\n\t\t\t\tf.log.Infow(\"Padding audio with silence\", \"index\", i, \"pad_duration\", padDuration)\n\t\t\t} else {\n\t\t\t\t// 有音频但不需要延长：直接标记\n\t\t\t\taudioFilters = append(audioFilters,\n\t\t\t\t\tfmt.Sprintf(\"[%d:a]acopy[a%d]\", i, i))\n\t\t\t}\n\t\t}\n\n\t\t// 音频交叉淡入淡出（避免转场时静音）\n\t\t// 对所有相邻音频都应用 acrossfade，type=none 时使用 0 秒时长\n\t\tvar audioCrossfades []string\n\n\t\tfor i := 0; i < len(inputPaths)-1; i++ {\n\t\t\t// 默认转场时长\n\t\t\ttransitionDuration := 1.0\n\t\t\tif clips[i].Transition != nil {\n\t\t\t\tif tType, ok := clips[i].Transition[\"type\"].(string); ok {\n\t\t\t\t\tif strings.ToLower(tType) == \"none\" || tType == \"\" {\n\t\t\t\t\t\t// none 转场使用 0 秒\n\t\t\t\t\t\ttransitionDuration = 0.0\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// 只有非 none 转场才读取自定义时长\n\t\t\t\tif transitionDuration > 0 {\n\t\t\t\t\tif tDuration, ok := clips[i].Transition[\"duration\"].(float64); ok && tDuration > 0 {\n\t\t\t\t\t\ttransitionDuration = tDuration\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tvar inputLabel, outputLabel string\n\t\t\tif i == 0 {\n\t\t\t\tinputLabel = \"[a0][a1]\"\n\t\t\t} else {\n\t\t\t\tinputLabel = fmt.Sprintf(\"[ax%02d][a%d]\", i-1, i+1)\n\t\t\t}\n\n\t\t\tif i == len(inputPaths)-2 {\n\t\t\t\toutputLabel = \"[outa]\"\n\t\t\t} else {\n\t\t\t\toutputLabel = fmt.Sprintf(\"[ax%02d]\", i)\n\t\t\t}\n\n\t\t\t// acrossfade: d=转场时长，c1=第一个音频淡出曲线，c2=第二个音频淡入曲线\n\t\t\t// 0 秒时长实现无缝音频拼接\n\t\t\taudioCrossfades = append(audioCrossfades,\n\t\t\t\tfmt.Sprintf(\"%sacrossfade=d=%.2f:c1=tri:c2=tri%s\", inputLabel, transitionDuration, outputLabel))\n\n\t\t\tf.log.Infow(\"Audio crossfade\",\n\t\t\t\t\"clip_index\", i,\n\t\t\t\t\"duration\", transitionDuration)\n\t\t}\n\n\t\t// 构建完整滤镜：音频处理 + 音频交叉淡入淡出\n\t\tvar allAudioFilters []string\n\t\tallAudioFilters = append(allAudioFilters, audioFilters...)\n\t\tallAudioFilters = append(allAudioFilters, audioCrossfades...)\n\t\tfullFilter = filterComplex + \";\" + strings.Join(allAudioFilters, \";\")\n\t} else {\n\t\t// 所有视频都无音频流，只处理视频\n\t\tfullFilter = filterComplex\n\t}\n\n\t// 构建完整命令\n\targs = append(args,\n\t\t\"-filter_complex\", fullFilter,\n\t\t\"-map\", \"[outv]\",\n\t)\n\n\t// 仅在有任何音频时映射音频输出\n\tif hasAnyAudio {\n\t\targs = append(args, \"-map\", \"[outa]\")\n\t}\n\n\targs = append(args,\n\t\t\"-c:v\", \"libx264\",\n\t\t\"-preset\", \"medium\",\n\t\t\"-crf\", \"23\",\n\t)\n\n\t// 仅在有任何音频时设置音频编码参数\n\tif hasAnyAudio {\n\t\targs = append(args,\n\t\t\t\"-c:a\", \"aac\",\n\t\t\t\"-b:a\", \"128k\",\n\t\t)\n\t}\n\n\targs = append(args,\n\t\t\"-y\",\n\t\toutputPath,\n\t)\n\n\tf.log.Infow(\"Running FFmpeg with transitions\", \"filter\", fullFilter, \"has_any_audio\", hasAnyAudio)\n\n\tcmd := exec.Command(\"ffmpeg\", args...)\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\tf.log.Errorw(\"FFmpeg xfade failed\", \"error\", err, \"output\", string(output))\n\t\treturn fmt.Errorf(\"ffmpeg xfade failed: %w, output: %s\", err, string(output))\n\t}\n\n\tf.log.Infow(\"Video merged with transitions successfully\")\n\treturn nil\n}\n\nfunc (f *FFmpeg) mapTransitionType(transType string) string {\n\t// 将前端传入的转场类型映射为FFmpeg xfade支持的类型\n\t// FFmpeg xfade支持的完整转场列表: https://ffmpeg.org/ffmpeg-filters.html#xfade\n\tswitch strings.ToLower(transType) {\n\t// 淡入淡出类\n\tcase \"fade\", \"fadein\", \"fadeout\":\n\t\treturn \"fade\"\n\tcase \"fadeblack\":\n\t\treturn \"fadeblack\"\n\tcase \"fadewhite\":\n\t\treturn \"fadewhite\"\n\tcase \"fadegrays\":\n\t\treturn \"fadegrays\"\n\n\t// 滑动类\n\tcase \"slideleft\":\n\t\treturn \"slideleft\"\n\tcase \"slideright\":\n\t\treturn \"slideright\"\n\tcase \"slideup\":\n\t\treturn \"slideup\"\n\tcase \"slidedown\":\n\t\treturn \"slidedown\"\n\n\t// 擦除类\n\tcase \"wipeleft\":\n\t\treturn \"wipeleft\"\n\tcase \"wiperight\":\n\t\treturn \"wiperight\"\n\tcase \"wipeup\":\n\t\treturn \"wipeup\"\n\tcase \"wipedown\":\n\t\treturn \"wipedown\"\n\n\t// 圆形类\n\tcase \"circleopen\":\n\t\treturn \"circleopen\"\n\tcase \"circleclose\":\n\t\treturn \"circleclose\"\n\n\t// 矩形打开/关闭类\n\tcase \"horzopen\":\n\t\treturn \"horzopen\"\n\tcase \"horzclose\":\n\t\treturn \"horzclose\"\n\tcase \"vertopen\":\n\t\treturn \"vertopen\"\n\tcase \"vertclose\":\n\t\treturn \"vertclose\"\n\n\t// 其他特效\n\tcase \"dissolve\":\n\t\treturn \"dissolve\"\n\tcase \"distance\":\n\t\treturn \"distance\"\n\tcase \"pixelize\":\n\t\treturn \"pixelize\"\n\n\tdefault:\n\t\treturn \"fade\" // 默认淡入淡出\n\t}\n}\n\nfunc (f *FFmpeg) hasAudioStream(videoPath string) bool {\n\tcmd := exec.Command(\"ffprobe\",\n\t\t\"-v\", \"error\",\n\t\t\"-select_streams\", \"a:0\",\n\t\t\"-show_entries\", \"stream=codec_type\",\n\t\t\"-of\", \"default=noprint_wrappers=1:nokey=1\",\n\t\tvideoPath,\n\t)\n\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn false\n\t}\n\n\tresult := strings.TrimSpace(string(output))\n\treturn result == \"audio\"\n}\n\nfunc (f *FFmpeg) getVideoResolution(videoPath string) (int, int) {\n\tcmd := exec.Command(\"ffprobe\",\n\t\t\"-v\", \"error\",\n\t\t\"-select_streams\", \"v:0\",\n\t\t\"-show_entries\", \"stream=width,height\",\n\t\t\"-of\", \"csv=p=0\",\n\t\tvideoPath,\n\t)\n\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\tf.log.Warnw(\"Failed to get video resolution\", \"path\", videoPath, \"error\", err)\n\t\treturn 1920, 1080 // 默认分辨率\n\t}\n\n\tresult := strings.TrimSpace(string(output))\n\tparts := strings.Split(result, \",\")\n\tif len(parts) != 2 {\n\t\tf.log.Warnw(\"Invalid resolution format\", \"output\", result)\n\t\treturn 1920, 1080\n\t}\n\n\tvar width, height int\n\tfmt.Sscanf(parts[0], \"%d\", &width)\n\tfmt.Sscanf(parts[1], \"%d\", &height)\n\n\tif width <= 0 || height <= 0 {\n\t\treturn 1920, 1080\n\t}\n\n\treturn width, height\n}\n\n// GetVideoDuration 获取视频时长（秒）\nfunc (f *FFmpeg) GetVideoDuration(videoPath string) (float64, error) {\n\tcmd := exec.Command(\"ffprobe\",\n\t\t\"-v\", \"error\",\n\t\t\"-show_entries\", \"format=duration\",\n\t\t\"-of\", \"default=noprint_wrappers=1:nokey=1\",\n\t\tvideoPath,\n\t)\n\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\tf.log.Errorw(\"Failed to get video duration\", \"path\", videoPath, \"error\", err)\n\t\treturn 0, fmt.Errorf(\"ffprobe failed: %w\", err)\n\t}\n\n\tresult := strings.TrimSpace(string(output))\n\tvar duration float64\n\t_, err = fmt.Sscanf(result, \"%f\", &duration)\n\tif err != nil {\n\t\tf.log.Errorw(\"Failed to parse duration\", \"output\", result, \"error\", err)\n\t\treturn 0, fmt.Errorf(\"parse duration failed: %w\", err)\n\t}\n\n\tif duration <= 0 {\n\t\treturn 0, fmt.Errorf(\"invalid duration: %f\", duration)\n\t}\n\n\treturn duration, nil\n}\n\nfunc (f *FFmpeg) copyFile(src, dst string) error {\n\tcmd := exec.Command(\"cp\", src, dst)\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\tf.log.Errorw(\"File copy failed\", \"error\", err, \"output\", string(output))\n\t\treturn fmt.Errorf(\"copy failed: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (f *FFmpeg) cleanup(paths []string) {\n\tfor _, path := range paths {\n\t\tif err := os.Remove(path); err != nil {\n\t\t\tf.log.Warnw(\"Failed to cleanup file\", \"path\", path, \"error\", err)\n\t\t}\n\t}\n}\n\nfunc (f *FFmpeg) CleanupTempDir() error {\n\treturn os.RemoveAll(f.tempDir)\n}\n\n// ExtractAudio 从视频文件中提取音频轨道\n// 返回提取的音频文件路径\nfunc (f *FFmpeg) ExtractAudio(videoURL, outputPath string) (string, error) {\n\tf.log.Infow(\"Extracting audio from video\", \"url\", videoURL, \"output\", outputPath)\n\n\t// 下载视频文件\n\tdownloadPath := filepath.Join(f.tempDir, fmt.Sprintf(\"video_%d.mp4\", time.Now().Unix()))\n\tlocalVideoPath, err := f.downloadVideo(videoURL, downloadPath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to download video: %w\", err)\n\t}\n\tdefer os.Remove(localVideoPath)\n\n\t// 检查视频是否有音频流\n\tif !f.hasAudioStream(localVideoPath) {\n\t\tf.log.Warnw(\"Video has no audio stream, generating silence\", \"video\", videoURL)\n\t\t// 获取视频时长\n\t\tduration, err := f.GetVideoDuration(localVideoPath)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to get video duration: %w\", err)\n\t\t}\n\t\t// 生成静音音频文件\n\t\treturn f.generateSilence(outputPath, duration)\n\t}\n\n\t// 确保输出目录存在\n\toutputDir := filepath.Dir(outputPath)\n\tif err := os.MkdirAll(outputDir, 0755); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create output directory: %w\", err)\n\t}\n\n\t// 使用FFmpeg提取音频\n\t// -vn: 禁用视频\n\t// -acodec: 音频编码器\n\t// -ar: 音频采样率\n\t// -ac: 音频声道数\n\t// -ab: 音频比特率\n\tcmd := exec.Command(\"ffmpeg\",\n\t\t\"-i\", localVideoPath,\n\t\t\"-vn\",\n\t\t\"-acodec\", \"aac\",\n\t\t\"-ar\", \"44100\",\n\t\t\"-ac\", \"2\",\n\t\t\"-ab\", \"128k\",\n\t\t\"-y\",\n\t\toutputPath,\n\t)\n\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\tf.log.Errorw(\"FFmpeg audio extraction failed\", \"error\", err, \"output\", string(output))\n\t\treturn \"\", fmt.Errorf(\"ffmpeg audio extraction failed: %w, output: %s\", err, string(output))\n\t}\n\n\tf.log.Infow(\"Audio extracted successfully\", \"output\", outputPath)\n\treturn outputPath, nil\n}\n\n// generateSilence 生成指定时长的静音音频文件\nfunc (f *FFmpeg) generateSilence(outputPath string, duration float64) (string, error) {\n\tf.log.Infow(\"Generating silence audio\", \"duration\", duration, \"output\", outputPath)\n\n\t// 确保输出目录存在\n\toutputDir := filepath.Dir(outputPath)\n\tif err := os.MkdirAll(outputDir, 0755); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create output directory: %w\", err)\n\t}\n\n\t// 使用FFmpeg生成静音\n\t// -f lavfi: 使用lavfi（libavfilter）输入\n\t// -i anullsrc: 生成静音音频源\n\tcmd := exec.Command(\"ffmpeg\",\n\t\t\"-f\", \"lavfi\",\n\t\t\"-i\", fmt.Sprintf(\"anullsrc=channel_layout=stereo:sample_rate=44100\"),\n\t\t\"-t\", fmt.Sprintf(\"%.2f\", duration),\n\t\t\"-acodec\", \"aac\",\n\t\t\"-ab\", \"128k\",\n\t\t\"-y\",\n\t\toutputPath,\n\t)\n\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\tf.log.Errorw(\"FFmpeg silence generation failed\", \"error\", err, \"output\", string(output))\n\t\treturn \"\", fmt.Errorf(\"ffmpeg silence generation failed: %w, output: %s\", err, string(output))\n\t}\n\n\tf.log.Infow(\"Silence audio generated successfully\", \"output\", outputPath)\n\treturn outputPath, nil\n}\n"
  },
  {
    "path": "infrastructure/scheduler/resource_transfer_scheduler.go",
    "content": "package scheduler\n\nimport (\n\t\"time\"\n\n\t\"github.com/drama-generator/backend/application/services\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/robfig/cron/v3\"\n\t\"gorm.io/gorm\"\n)\n\ntype ResourceTransferScheduler struct {\n\tcron            *cron.Cron\n\ttransferService *services.ResourceTransferService\n\tdb              *gorm.DB\n\tlog             *logger.Logger\n\trunning         bool\n}\n\nfunc NewResourceTransferScheduler(\n\ttransferService *services.ResourceTransferService,\n\tdb *gorm.DB,\n\tlog *logger.Logger,\n) *ResourceTransferScheduler {\n\treturn &ResourceTransferScheduler{\n\t\tcron:            cron.New(cron.WithSeconds()),\n\t\ttransferService: transferService,\n\t\tdb:              db,\n\t\tlog:             log,\n\t\trunning:         false,\n\t}\n}\n\n// Start 启动定时任务\nfunc (s *ResourceTransferScheduler) Start() error {\n\tif s.running {\n\t\ts.log.Warn(\"Resource transfer scheduler already running\")\n\t\treturn nil\n\t}\n\n\ts.log.Info(\"Starting resource transfer scheduler...\")\n\n\t// 每小时执行一次资源转存任务\n\t_, err := s.cron.AddFunc(\"0 0 * * * *\", func() {\n\t\ts.log.Info(\"Starting scheduled resource transfer task\")\n\t\ts.transferPendingResources()\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// 每天凌晨2点执行完整扫描\n\t_, err = s.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\ts.log.Info(\"Starting daily full resource scan and transfer\")\n\t\ts.transferAllPendingResources()\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ts.cron.Start()\n\ts.running = true\n\ts.log.Info(\"Resource transfer scheduler started successfully\")\n\n\treturn nil\n}\n\n// Stop 停止定时任务\nfunc (s *ResourceTransferScheduler) Stop() {\n\tif !s.running {\n\t\treturn\n\t}\n\n\ts.log.Info(\"Stopping resource transfer scheduler...\")\n\tctx := s.cron.Stop()\n\t<-ctx.Done()\n\ts.running = false\n\ts.log.Info(\"Resource transfer scheduler stopped\")\n}\n\n// transferPendingResources 转存最近生成的待转存资源（最近24小时）\nfunc (s *ResourceTransferScheduler) transferPendingResources() {\n\ts.log.Info(\"Scanning for pending resources to transfer (last 24 hours)...\")\n\n\t// 查找最近24小时内完成的、还未转存的图片和视频\n\ttype DramaCount struct {\n\t\tDramaID string\n\t\tCount   int64\n\t}\n\n\t// 统计每个剧本的待转存图片数量\n\tvar imageDramas []DramaCount\n\ts.db.Raw(`\n\t\tSELECT drama_id, COUNT(*) as count \n\t\tFROM image_generations \n\t\tWHERE status = 'completed' \n\t\tAND image_url IS NOT NULL \n\t\tAND image_url != ''\n\t\tAND (minio_url IS NULL OR minio_url = '')\n\t\tAND completed_at >= ?\n\t\tGROUP BY drama_id\n\t`, time.Now().Add(-24*time.Hour)).Scan(&imageDramas)\n\n\t// 转存图片\n\timageCount := 0\n\tfor _, drama := range imageDramas {\n\t\tcount, err := s.transferService.BatchTransferImagesToMinio(drama.DramaID, 50) // 每个剧本最多转50个\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"Failed to transfer images for drama\",\n\t\t\t\t\"drama_id\", drama.DramaID,\n\t\t\t\t\"error\", err)\n\t\t\tcontinue\n\t\t}\n\t\timageCount += count\n\t\ts.log.Infow(\"Transferred images for drama\",\n\t\t\t\"drama_id\", drama.DramaID,\n\t\t\t\"count\", count)\n\t}\n\n\t// 统计每个剧本的待转存视频数量\n\tvar videoDramas []DramaCount\n\ts.db.Raw(`\n\t\tSELECT drama_id, COUNT(*) as count \n\t\tFROM video_generations \n\t\tWHERE status = 'completed' \n\t\tAND video_url IS NOT NULL \n\t\tAND video_url != ''\n\t\tAND (minio_url IS NULL OR minio_url = '')\n\t\tAND completed_at >= ?\n\t\tGROUP BY drama_id\n\t`, time.Now().Add(-24*time.Hour)).Scan(&videoDramas)\n\n\t// 转存视频\n\tvideoCount := 0\n\tfor _, drama := range videoDramas {\n\t\tcount, err := s.transferService.BatchTransferVideosToMinio(drama.DramaID, 50) // 每个剧本最多转50个\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"Failed to transfer videos for drama\",\n\t\t\t\t\"drama_id\", drama.DramaID,\n\t\t\t\t\"error\", err)\n\t\t\tcontinue\n\t\t}\n\t\tvideoCount += count\n\t\ts.log.Infow(\"Transferred videos for drama\",\n\t\t\t\"drama_id\", drama.DramaID,\n\t\t\t\"count\", count)\n\t}\n\n\ts.log.Infow(\"Scheduled resource transfer task completed\",\n\t\t\"images\", imageCount,\n\t\t\"videos\", videoCount)\n}\n\n// transferAllPendingResources 转存所有待转存的资源（全量扫描）\nfunc (s *ResourceTransferScheduler) transferAllPendingResources() {\n\ts.log.Info(\"Starting full scan for all pending resources...\")\n\n\t// 查找所有待转存的资源\n\ttype DramaCount struct {\n\t\tDramaID string\n\t\tCount   int64\n\t}\n\n\t// 统计所有剧本的待转存图片\n\tvar imageDramas []DramaCount\n\ts.db.Raw(`\n\t\tSELECT drama_id, COUNT(*) as count \n\t\tFROM image_generations \n\t\tWHERE status = 'completed' \n\t\tAND image_url IS NOT NULL \n\t\tAND image_url != ''\n\t\tAND (minio_url IS NULL OR minio_url = '')\n\t\tGROUP BY drama_id\n\t`).Scan(&imageDramas)\n\n\ts.log.Infow(\"Found dramas with pending images\", \"count\", len(imageDramas))\n\n\t// 转存所有待转存图片\n\ttotalImageCount := 0\n\tfor _, drama := range imageDramas {\n\t\tcount, err := s.transferService.BatchTransferImagesToMinio(drama.DramaID, 0) // 0表示全部转存\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"Failed to transfer images for drama\",\n\t\t\t\t\"drama_id\", drama.DramaID,\n\t\t\t\t\"error\", err)\n\t\t\tcontinue\n\t\t}\n\t\ttotalImageCount += count\n\t\ts.log.Infow(\"Transferred all images for drama\",\n\t\t\t\"drama_id\", drama.DramaID,\n\t\t\t\"count\", count)\n\t}\n\n\t// 统计所有剧本的待转存视频\n\tvar videoDramas []DramaCount\n\ts.db.Raw(`\n\t\tSELECT drama_id, COUNT(*) as count \n\t\tFROM video_generations \n\t\tWHERE status = 'completed' \n\t\tAND video_url IS NOT NULL \n\t\tAND video_url != ''\n\t\tAND (minio_url IS NULL OR minio_url = '')\n\t\tGROUP BY drama_id\n\t`).Scan(&videoDramas)\n\n\ts.log.Infow(\"Found dramas with pending videos\", \"count\", len(videoDramas))\n\n\t// 转存所有待转存视频\n\ttotalVideoCount := 0\n\tfor _, drama := range videoDramas {\n\t\tcount, err := s.transferService.BatchTransferVideosToMinio(drama.DramaID, 0) // 0表示全部转存\n\t\tif err != nil {\n\t\t\ts.log.Errorw(\"Failed to transfer videos for drama\",\n\t\t\t\t\"drama_id\", drama.DramaID,\n\t\t\t\t\"error\", err)\n\t\t\tcontinue\n\t\t}\n\t\ttotalVideoCount += count\n\t\ts.log.Infow(\"Transferred all videos for drama\",\n\t\t\t\"drama_id\", drama.DramaID,\n\t\t\t\"count\", count)\n\t}\n\n\ts.log.Infow(\"Full resource scan and transfer completed\",\n\t\t\"total_images\", totalImageCount,\n\t\t\"total_videos\", totalVideoCount,\n\t\t\"drama_count\", len(imageDramas)+len(videoDramas))\n}\n\n// RunNow 立即执行一次转存任务（用于手动触发）\nfunc (s *ResourceTransferScheduler) RunNow() {\n\ts.log.Info(\"Manually triggering resource transfer task...\")\n\tgo s.transferPendingResources()\n}\n\n// RunFullScan 立即执行一次全量扫描（用于手动触发）\nfunc (s *ResourceTransferScheduler) RunFullScan() {\n\ts.log.Info(\"Manually triggering full resource scan...\")\n\tgo s.transferAllPendingResources()\n}\n"
  },
  {
    "path": "infrastructure/storage/local_storage.go",
    "content": "package storage\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n)\n\ntype LocalStorage struct {\n\tbasePath string\n\tbaseURL  string\n}\n\nfunc NewLocalStorage(basePath, baseURL string) (*LocalStorage, error) {\n\tif err := os.MkdirAll(basePath, 0755); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create storage directory: %w\", err)\n\t}\n\n\treturn &LocalStorage{\n\t\tbasePath: basePath,\n\t\tbaseURL:  baseURL,\n\t}, nil\n}\n\nfunc (s *LocalStorage) Upload(file io.Reader, filename string, category string) (string, error) {\n\tdir := filepath.Join(s.basePath, category)\n\tif err := os.MkdirAll(dir, 0755); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create category directory: %w\", err)\n\t}\n\n\ttimestamp := time.Now().Format(\"20060102_150405\")\n\tnewFilename := fmt.Sprintf(\"%s_%s\", timestamp, filename)\n\tfilePath := filepath.Join(dir, newFilename)\n\n\tdst, err := os.Create(filePath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create file: %w\", err)\n\t}\n\tdefer dst.Close()\n\n\tif _, err := io.Copy(dst, file); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to save file: %w\", err)\n\t}\n\n\turl := fmt.Sprintf(\"%s/%s/%s\", s.baseURL, category, newFilename)\n\treturn url, nil\n}\n\nfunc (s *LocalStorage) Delete(url string) error {\n\treturn nil\n}\n\nfunc (s *LocalStorage) GetURL(path string) string {\n\treturn fmt.Sprintf(\"%s/%s\", s.baseURL, path)\n}\n\n// DownloadResult 下载结果，包含URL和相对路径\ntype DownloadResult struct {\n\tURL          string // 完整的访问URL\n\tRelativePath string // 相对于basePath的路径，用于保存到数据库\n\tAbsolutePath string // 绝对文件路径\n}\n\n// DownloadFromURL 从远程URL下载文件到本地存储\nfunc (s *LocalStorage) DownloadFromURL(url, category string) (string, error) {\n\tresult, err := s.DownloadFromURLWithPath(url, category)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn result.URL, nil\n}\n\n// DownloadFromURLWithPath 从远程URL下载文件到本地存储，返回详细信息\nfunc (s *LocalStorage) DownloadFromURLWithPath(url, category string) (*DownloadResult, error) {\n\t// CRITICAL FIX: Add HTTP client with timeout to prevent hanging indefinitely\n\t// Without timeout, the download can hang forever if the remote server is unresponsive\n\t// 5 minute timeout is reasonable for large video/image files\n\tclient := &http.Client{\n\t\tTimeout: 5 * time.Minute,\n\t}\n\t// 发送HTTP请求下载文件\n\tresp, err := client.Get(url)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to download file: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"failed to download file: HTTP %d\", resp.StatusCode)\n\t}\n\n\t// 从URL或Content-Type推断文件扩展名\n\text := getFileExtension(url, resp.Header.Get(\"Content-Type\"))\n\n\t// 创建目录\n\tdir := filepath.Join(s.basePath, category)\n\tif err := os.MkdirAll(dir, 0755); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create category directory: %w\", err)\n\t}\n\n\t// 生成唯一文件名（时间戳 + UUID 前8位）\n\ttimestamp := time.Now().Format(\"20060102_150405\")\n\tuniqueID := uuid.New().String()[:8]\n\tfilename := fmt.Sprintf(\"%s_%s%s\", timestamp, uniqueID, ext)\n\tfilePath := filepath.Join(dir, filename)\n\n\t// 保存文件\n\tdst, err := os.Create(filePath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create file: %w\", err)\n\t}\n\tdefer dst.Close()\n\n\tif _, err := io.Copy(dst, resp.Body); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to save file: %w\", err)\n\t}\n\n\t// 返回详细信息\n\trelativePath := filepath.Join(category, filename)\n\tlocalURL := fmt.Sprintf(\"%s/%s/%s\", s.baseURL, category, filename)\n\t\n\treturn &DownloadResult{\n\t\tURL:          localURL,\n\t\tRelativePath: relativePath,\n\t\tAbsolutePath: filePath,\n\t}, nil\n}\n\n// GetAbsolutePath 根据相对路径获取绝对路径\nfunc (s *LocalStorage) GetAbsolutePath(relativePath string) string {\n\treturn filepath.Join(s.basePath, relativePath)\n}\n\n// getFileExtension 从URL或Content-Type推断文件扩展名\nfunc getFileExtension(url, contentType string) string {\n\t// 首先尝试从URL获取扩展名\n\tif idx := strings.LastIndex(url, \".\"); idx != -1 {\n\t\text := url[idx:]\n\t\t// 只取扩展名部分，忽略查询参数\n\t\tif qIdx := strings.Index(ext, \"?\"); qIdx != -1 {\n\t\t\text = ext[:qIdx]\n\t\t}\n\t\tif len(ext) <= 5 { // 合理的扩展名长度\n\t\t\treturn ext\n\t\t}\n\t}\n\n\t// 根据Content-Type推断扩展名\n\tswitch {\n\tcase strings.Contains(contentType, \"image/jpeg\"):\n\t\treturn \".jpg\"\n\tcase strings.Contains(contentType, \"image/png\"):\n\t\treturn \".png\"\n\tcase strings.Contains(contentType, \"image/gif\"):\n\t\treturn \".gif\"\n\tcase strings.Contains(contentType, \"image/webp\"):\n\t\treturn \".webp\"\n\tcase strings.Contains(contentType, \"video/mp4\"):\n\t\treturn \".mp4\"\n\tcase strings.Contains(contentType, \"video/webm\"):\n\t\treturn \".webm\"\n\tcase strings.Contains(contentType, \"video/quicktime\"):\n\t\treturn \".mov\"\n\tdefault:\n\t\treturn \".bin\"\n\t}\n}\n"
  },
  {
    "path": "main.go",
    "content": "package main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/drama-generator/backend/api/routes\"\n\t\"github.com/drama-generator/backend/infrastructure/database\"\n\t\"github.com/drama-generator/backend/infrastructure/storage\"\n\t\"github.com/drama-generator/backend/pkg/config\"\n\t\"github.com/drama-generator/backend/pkg/logger\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc main() {\n\tcfg, err := config.LoadConfig()\n\tif err != nil {\n\t\tlog.Fatalf(\"Failed to load config: %v\", err)\n\t}\n\n\tlogr := logger.NewLogger(cfg.App.Debug)\n\tdefer logr.Sync()\n\n\tlogr.Info(\"Starting Drama Generator API Server...\")\n\n\tdb, err := database.NewDatabase(cfg.Database)\n\tif err != nil {\n\t\tlogr.Fatal(\"Failed to connect to database\", \"error\", err)\n\t}\n\tlogr.Info(\"Database connected successfully\")\n\n\t// 自动迁移数据库表结构\n\tif err := database.AutoMigrate(db); err != nil {\n\t\tlogr.Fatal(\"Failed to migrate database\", \"error\", err)\n\t}\n\tlogr.Info(\"Database tables migrated successfully\")\n\n\t// 初始化本地存储\n\tvar localStorage *storage.LocalStorage\n\tif cfg.Storage.Type == \"local\" {\n\t\tlocalStorage, err = storage.NewLocalStorage(cfg.Storage.LocalPath, cfg.Storage.BaseURL)\n\t\tif err != nil {\n\t\t\tlogr.Fatal(\"Failed to initialize local storage\", \"error\", err)\n\t\t}\n\t\tlogr.Info(\"Local storage initialized successfully\", \"path\", cfg.Storage.LocalPath)\n\t}\n\n\tif cfg.App.Debug {\n\t\tgin.SetMode(gin.DebugMode)\n\t} else {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\n\trouter := routes.SetupRouter(cfg, db, logr, localStorage)\n\n\tsrv := &http.Server{\n\t\tAddr:         fmt.Sprintf(\":%d\", cfg.Server.Port),\n\t\tHandler:      router,\n\t\tReadTimeout:  10 * time.Minute,\n\t\tWriteTimeout: 10 * time.Minute,\n\t}\n\n\tgo func() {\n\t\tlogr.Infow(\"🚀 Server starting...\",\n\t\t\t\"port\", cfg.Server.Port,\n\t\t\t\"mode\", gin.Mode())\n\t\tlogr.Info(\"📍 Access URLs:\")\n\t\tlogr.Info(fmt.Sprintf(\"   Frontend:  http://localhost:%d\", cfg.Server.Port))\n\t\tlogr.Info(fmt.Sprintf(\"   API:       http://localhost:%d/api/v1\", cfg.Server.Port))\n\t\tlogr.Info(fmt.Sprintf(\"   Health:    http://localhost:%d/health\", cfg.Server.Port))\n\t\tlogr.Info(\"📁 Static files:\")\n\t\tlogr.Info(fmt.Sprintf(\"   Uploads:   http://localhost:%d/static\", cfg.Server.Port))\n\t\tlogr.Info(fmt.Sprintf(\"   Assets:    http://localhost:%d/assets\", cfg.Server.Port))\n\t\tlogr.Info(\"✅ Server is ready!\")\n\n\t\tif err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {\n\t\t\tlogr.Fatal(\"Failed to start server\", \"error\", err)\n\t\t}\n\t}()\n\n\tquit := make(chan os.Signal, 1)\n\tsignal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)\n\t<-quit\n\n\tlogr.Info(\"Shutting down server...\")\n\n\t// 清理资源\n\t// CRITICAL FIX: Properly close database connection to prevent resource leaks\n\t// SQLite connections should be closed gracefully to avoid database lock issues\n\tsqlDB, err := db.DB()\n\tif err == nil {\n\t\tif err := sqlDB.Close(); err != nil {\n\t\t\tlogr.Warnw(\"Failed to close database connection\", \"error\", err)\n\t\t} else {\n\t\t\tlogr.Info(\"Database connection closed\")\n\t\t}\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tif err := srv.Shutdown(ctx); err != nil {\n\t\tlogr.Fatal(\"Server forced to shutdown\", \"error\", err)\n\t}\n\n\tlogr.Info(\"Server exited\")\n}\n"
  },
  {
    "path": "migrations/20260126_add_local_path.sql",
    "content": "-- 添加 local_path 字段到相关表\n-- 创建时间: 2026-01-26\n-- 说明: 为 characters, scenes, props, character_libraries 表添加 local_path 字段以支持本地存储路径\n\n-- 为 characters 表添加 local_path 字段\nALTER TABLE characters ADD COLUMN local_path TEXT;\n\n-- 为 scenes 表添加 local_path 字段\nALTER TABLE scenes ADD COLUMN local_path TEXT;\n\n-- 为 props 表添加 local_path 字段\nALTER TABLE props ADD COLUMN local_path TEXT;\n\n-- 为 character_libraries 表添加 local_path 字段\nALTER TABLE character_libraries ADD COLUMN local_path TEXT;\n"
  },
  {
    "path": "migrations/init.sql",
    "content": "-- AI短剧生成平台 - SQLite数据库初始化脚本 (开源版本 - 无用户认证)\n-- 创建时间: 2026-01-07\n-- 说明: 此版本适配SQLite，移除外键约束，适合单机部署\n\n-- ======================================\n-- 1. 剧本相关表\n-- ======================================\n\n-- 剧本表\nCREATE TABLE IF NOT EXISTS dramas (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    title TEXT NOT NULL,\n    description TEXT,\n    genre TEXT,\n    style TEXT NOT NULL DEFAULT 'realistic',\n    total_episodes INTEGER NOT NULL DEFAULT 1,\n    total_duration INTEGER NOT NULL DEFAULT 0, -- 总时长(秒)\n    status TEXT NOT NULL DEFAULT 'draft', -- draft, in_progress, completed\n    thumbnail TEXT,\n    tags TEXT, -- JSON存储\n    metadata TEXT, -- JSON存储\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_dramas_status ON dramas(status);\nCREATE INDEX IF NOT EXISTS idx_dramas_deleted_at ON dramas(deleted_at);\n\n-- 章节表\nCREATE TABLE IF NOT EXISTS episodes (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    drama_id INTEGER NOT NULL,\n    episode_number INTEGER NOT NULL,\n    title TEXT NOT NULL,\n    script_content TEXT,\n    description TEXT,\n    duration INTEGER NOT NULL DEFAULT 0, -- 时长(秒)\n    status TEXT NOT NULL DEFAULT 'draft',\n    video_url TEXT,\n    thumbnail TEXT,\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_episodes_drama_id ON episodes(drama_id);\nCREATE INDEX IF NOT EXISTS idx_episodes_status ON episodes(status);\nCREATE INDEX IF NOT EXISTS idx_episodes_deleted_at ON episodes(deleted_at);\n\n-- 角色表\nCREATE TABLE IF NOT EXISTS characters (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    drama_id INTEGER NOT NULL,\n    name TEXT NOT NULL,\n    role TEXT,\n    description TEXT,\n    appearance TEXT,\n    personality TEXT,\n    voice_style TEXT,\n    image_url TEXT,\n    local_path TEXT,\n    reference_images TEXT, -- JSON存储\n    seed_value TEXT,\n    sort_order INTEGER NOT NULL DEFAULT 0,\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_characters_drama_id ON characters(drama_id);\nCREATE INDEX IF NOT EXISTS idx_characters_deleted_at ON characters(deleted_at);\n\n-- 场景表\nCREATE TABLE IF NOT EXISTS scenes (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    drama_id INTEGER NOT NULL,\n    location TEXT NOT NULL,\n    time TEXT NOT NULL,\n    prompt TEXT NOT NULL,\n    storyboard_count INTEGER NOT NULL DEFAULT 1,\n    image_url TEXT,\n    local_path TEXT,\n    status TEXT NOT NULL DEFAULT 'pending', -- pending, generated, failed\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_scenes_drama_id ON scenes(drama_id);\nCREATE INDEX IF NOT EXISTS idx_scenes_status ON scenes(status);\nCREATE INDEX IF NOT EXISTS idx_scenes_deleted_at ON scenes(deleted_at);\n\n-- 道具表\nCREATE TABLE IF NOT EXISTS props (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    drama_id INTEGER NOT NULL,\n    name TEXT NOT NULL,\n    type TEXT,\n    description TEXT,\n    prompt TEXT,\n    image_url TEXT,\n    local_path TEXT,\n    reference_images TEXT, -- JSON存储\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_props_drama_id ON props(drama_id);\nCREATE INDEX IF NOT EXISTS idx_props_deleted_at ON props(deleted_at);\n\n-- 分镜表\nCREATE TABLE IF NOT EXISTS storyboards (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    episode_id INTEGER NOT NULL,\n    scene_id INTEGER,\n    storyboard_number INTEGER NOT NULL,\n    title TEXT,\n    description TEXT,\n    location TEXT,\n    time TEXT,\n    duration INTEGER NOT NULL DEFAULT 0, -- 时长(秒)\n    dialogue TEXT,\n    action TEXT,\n    atmosphere TEXT,\n    image_prompt TEXT,\n    video_prompt TEXT,\n    characters TEXT, -- JSON存储\n    composed_image TEXT,\n    video_url TEXT,\n    status TEXT NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_storyboards_episode_id ON storyboards(episode_id);\nCREATE INDEX IF NOT EXISTS idx_storyboards_scene_id ON storyboards(scene_id);\nCREATE INDEX IF NOT EXISTS idx_storyboards_storyboard_number ON storyboards(storyboard_number);\nCREATE INDEX IF NOT EXISTS idx_storyboards_status ON storyboards(status);\nCREATE INDEX IF NOT EXISTS idx_storyboards_deleted_at ON storyboards(deleted_at);\n\n-- ======================================\n-- 2. AI生成相关表\n-- ======================================\n\n-- 图片生成记录表\nCREATE TABLE IF NOT EXISTS image_generations (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    storyboard_id INTEGER, -- 修正：引用storyboards表\n    drama_id INTEGER NOT NULL,\n    provider TEXT NOT NULL, -- openai, midjourney, stable_diffusion\n    prompt TEXT NOT NULL,\n    negative_prompt TEXT,\n    model TEXT,\n    size TEXT,\n    quality TEXT,\n    style TEXT,\n    steps INTEGER,\n    cfg_scale REAL,\n    seed INTEGER,\n    image_url TEXT,\n    minio_url TEXT,\n    local_path TEXT,\n    status TEXT NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed\n    task_id TEXT,\n    error_msg TEXT,\n    width INTEGER,\n    height INTEGER,\n    reference_images TEXT, -- JSON存储\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    completed_at DATETIME,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_image_generations_storyboard_id ON image_generations(storyboard_id);\nCREATE INDEX IF NOT EXISTS idx_image_generations_drama_id ON image_generations(drama_id);\nCREATE INDEX IF NOT EXISTS idx_image_generations_status ON image_generations(status);\nCREATE INDEX IF NOT EXISTS idx_image_generations_task_id ON image_generations(task_id);\nCREATE INDEX IF NOT EXISTS idx_image_generations_deleted_at ON image_generations(deleted_at);\n\n-- 视频生成记录表\nCREATE TABLE IF NOT EXISTS video_generations (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    storyboard_id INTEGER, -- 修正：引用storyboards表\n    drama_id INTEGER NOT NULL,\n    provider TEXT NOT NULL, -- runway, pika, doubao, openai\n    prompt TEXT NOT NULL,\n    model TEXT,\n    image_gen_id INTEGER,\n    image_url TEXT,\n    first_frame_url TEXT,\n    duration INTEGER, -- 时长(秒)\n    fps INTEGER,\n    resolution TEXT,\n    aspect_ratio TEXT,\n    style TEXT,\n    motion_level INTEGER,\n    camera_motion TEXT,\n    seed INTEGER,\n    video_url TEXT,\n    minio_url TEXT,\n    local_path TEXT,\n    status TEXT NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed\n    task_id TEXT,\n    error_msg TEXT,\n    completed_at DATETIME,\n    width INTEGER,\n    height INTEGER,\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_video_generations_storyboard_id ON video_generations(storyboard_id);\nCREATE INDEX IF NOT EXISTS idx_video_generations_drama_id ON video_generations(drama_id);\nCREATE INDEX IF NOT EXISTS idx_video_generations_provider ON video_generations(provider);\nCREATE INDEX IF NOT EXISTS idx_video_generations_status ON video_generations(status);\nCREATE INDEX IF NOT EXISTS idx_video_generations_task_id ON video_generations(task_id);\nCREATE INDEX IF NOT EXISTS idx_video_generations_image_gen_id ON video_generations(image_gen_id);\nCREATE INDEX IF NOT EXISTS idx_video_generations_deleted_at ON video_generations(deleted_at);\n\n-- 视频合成记录表\nCREATE TABLE IF NOT EXISTS video_merges (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    episode_id INTEGER NOT NULL,\n    drama_id INTEGER NOT NULL,\n    title TEXT,\n    provider TEXT NOT NULL,\n    model TEXT,\n    status TEXT NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed\n    scenes TEXT NOT NULL, -- JSON存储：场景片段列表\n    merged_url TEXT,\n    duration INTEGER, -- 总时长(秒)\n    task_id TEXT,\n    error_msg TEXT,\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    completed_at DATETIME,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_video_merges_episode_id ON video_merges(episode_id);\nCREATE INDEX IF NOT EXISTS idx_video_merges_drama_id ON video_merges(drama_id);\nCREATE INDEX IF NOT EXISTS idx_video_merges_status ON video_merges(status);\nCREATE INDEX IF NOT EXISTS idx_video_merges_deleted_at ON video_merges(deleted_at);\n\n-- ======================================\n-- 3. 角色库表\n-- ======================================\n\n-- 角色库表 (开源版本 - 全局共享)\nCREATE TABLE IF NOT EXISTS character_libraries (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    name TEXT NOT NULL,\n    category TEXT,\n    image_url TEXT NOT NULL,\n    local_path TEXT,\n    description TEXT,\n    tags TEXT,\n    source_type TEXT NOT NULL DEFAULT 'generated', -- generated, uploaded\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_character_libraries_category ON character_libraries(category);\nCREATE INDEX IF NOT EXISTS idx_character_libraries_deleted_at ON character_libraries(deleted_at);\n\n-- ======================================\n-- 4. 时间线相关表\n-- ======================================\n\n-- 时间线表\nCREATE TABLE IF NOT EXISTS timelines (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    drama_id INTEGER NOT NULL,\n    episode_id INTEGER,\n    name TEXT NOT NULL,\n    description TEXT,\n    duration INTEGER NOT NULL DEFAULT 0, -- 总时长(秒)\n    fps INTEGER NOT NULL DEFAULT 30,\n    resolution TEXT,\n    status TEXT NOT NULL DEFAULT 'draft', -- draft, editing, completed, exporting\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_timelines_drama_id ON timelines(drama_id);\nCREATE INDEX IF NOT EXISTS idx_timelines_episode_id ON timelines(episode_id);\nCREATE INDEX IF NOT EXISTS idx_timelines_status ON timelines(status);\nCREATE INDEX IF NOT EXISTS idx_timelines_deleted_at ON timelines(deleted_at);\n\n-- 时间线轨道表\nCREATE TABLE IF NOT EXISTS timeline_tracks (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    timeline_id INTEGER NOT NULL,\n    name TEXT NOT NULL,\n    type TEXT NOT NULL, -- video, audio, text\n    track_order INTEGER NOT NULL DEFAULT 0,\n    is_locked INTEGER NOT NULL DEFAULT 0,\n    is_muted INTEGER NOT NULL DEFAULT 0,\n    volume INTEGER DEFAULT 100,\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_timeline_tracks_timeline_id ON timeline_tracks(timeline_id);\nCREATE INDEX IF NOT EXISTS idx_timeline_tracks_type ON timeline_tracks(type);\nCREATE INDEX IF NOT EXISTS idx_timeline_tracks_deleted_at ON timeline_tracks(deleted_at);\n\n-- 时间线片段表\nCREATE TABLE IF NOT EXISTS timeline_clips (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    track_id INTEGER NOT NULL,\n    asset_id INTEGER,\n    storyboard_id INTEGER, -- 修正：引用storyboards而非scenes\n    name TEXT,\n    start_time INTEGER NOT NULL, -- 开始时间(毫秒)\n    end_time INTEGER NOT NULL, -- 结束时间(毫秒)\n    duration INTEGER NOT NULL, -- 时长(毫秒)\n    trim_start INTEGER, -- 裁剪开始(毫秒)\n    trim_end INTEGER, -- 裁剪结束(毫秒)\n    speed REAL DEFAULT 1.0,\n    volume INTEGER,\n    is_muted INTEGER NOT NULL DEFAULT 0,\n    fade_in INTEGER, -- 淡入时长(毫秒)\n    fade_out INTEGER, -- 淡出时长(毫秒)\n    transition_in_id INTEGER,\n    transition_out_id INTEGER,\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_timeline_clips_track_id ON timeline_clips(track_id);\nCREATE INDEX IF NOT EXISTS idx_timeline_clips_asset_id ON timeline_clips(asset_id);\nCREATE INDEX IF NOT EXISTS idx_timeline_clips_storyboard_id ON timeline_clips(storyboard_id);\nCREATE INDEX IF NOT EXISTS idx_timeline_clips_transition_in ON timeline_clips(transition_in_id);\nCREATE INDEX IF NOT EXISTS idx_timeline_clips_transition_out ON timeline_clips(transition_out_id);\nCREATE INDEX IF NOT EXISTS idx_timeline_clips_deleted_at ON timeline_clips(deleted_at);\n\n-- 片段转场表\nCREATE TABLE IF NOT EXISTS clip_transitions (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    type TEXT NOT NULL, -- fade, crossfade, slide, wipe, zoom, dissolve\n    duration INTEGER NOT NULL DEFAULT 500, -- 转场时长(毫秒)\n    easing TEXT,\n    config TEXT, -- JSON存储\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_clip_transitions_type ON clip_transitions(type);\nCREATE INDEX IF NOT EXISTS idx_clip_transitions_deleted_at ON clip_transitions(deleted_at);\n\n-- 片段效果表\nCREATE TABLE IF NOT EXISTS clip_effects (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    clip_id INTEGER NOT NULL,\n    type TEXT NOT NULL, -- filter, color, blur, brightness, contrast, saturation\n    name TEXT,\n    is_enabled INTEGER NOT NULL DEFAULT 1,\n    effect_order INTEGER NOT NULL DEFAULT 0,\n    config TEXT, -- JSON存储\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_clip_effects_clip_id ON clip_effects(clip_id);\nCREATE INDEX IF NOT EXISTS idx_clip_effects_type ON clip_effects(type);\nCREATE INDEX IF NOT EXISTS idx_clip_effects_deleted_at ON clip_effects(deleted_at);\n\n-- ======================================\n-- 5. 资源管理相关表\n-- ======================================\n\n-- 资源表\nCREATE TABLE IF NOT EXISTS assets (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    drama_id INTEGER,\n    name TEXT NOT NULL,\n    description TEXT,\n    type TEXT NOT NULL, -- image, video, audio\n    category TEXT,\n    url TEXT NOT NULL,\n    thumbnail_url TEXT,\n    local_path TEXT,\n    file_size INTEGER,\n    mime_type TEXT,\n    width INTEGER,\n    height INTEGER,\n    duration INTEGER, -- 时长(秒)\n    format TEXT,\n    image_gen_id INTEGER,\n    video_gen_id INTEGER,\n    is_favorite INTEGER NOT NULL DEFAULT 0,\n    view_count INTEGER NOT NULL DEFAULT 0,\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_assets_drama_id ON assets(drama_id);\nCREATE INDEX IF NOT EXISTS idx_assets_type ON assets(type);\nCREATE INDEX IF NOT EXISTS idx_assets_category ON assets(category);\nCREATE INDEX IF NOT EXISTS idx_assets_image_gen_id ON assets(image_gen_id);\nCREATE INDEX IF NOT EXISTS idx_assets_video_gen_id ON assets(video_gen_id);\nCREATE INDEX IF NOT EXISTS idx_assets_deleted_at ON assets(deleted_at);\n\n-- 资源标签表\nCREATE TABLE IF NOT EXISTS asset_tags (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    name TEXT NOT NULL,\n    color TEXT,\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_asset_tags_deleted_at ON asset_tags(deleted_at);\n\n-- 资源集合表\nCREATE TABLE IF NOT EXISTS asset_collections (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    drama_id INTEGER,\n    name TEXT NOT NULL,\n    description TEXT,\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_asset_collections_drama_id ON asset_collections(drama_id);\nCREATE INDEX IF NOT EXISTS idx_asset_collections_deleted_at ON asset_collections(deleted_at);\n\n-- 资源标签关系表(多对多)\nCREATE TABLE IF NOT EXISTS asset_tag_relations (\n    asset_id INTEGER NOT NULL,\n    asset_tag_id INTEGER NOT NULL,\n    PRIMARY KEY (asset_id, asset_tag_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_asset_tag_relations_asset_id ON asset_tag_relations(asset_id);\nCREATE INDEX IF NOT EXISTS idx_asset_tag_relations_tag_id ON asset_tag_relations(asset_tag_id);\n\n-- 资源集合关系表(多对多)\nCREATE TABLE IF NOT EXISTS asset_collection_relations (\n    asset_id INTEGER NOT NULL,\n    asset_collection_id INTEGER NOT NULL,\n    PRIMARY KEY (asset_id, asset_collection_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_asset_collection_relations_asset_id ON asset_collection_relations(asset_id);\nCREATE INDEX IF NOT EXISTS idx_asset_collection_relations_collection_id ON asset_collection_relations(asset_collection_id);\n\n-- ======================================\n-- 6. AI服务配置表 (开源版本 - 全局配置)\n-- ======================================\n\n-- AI服务配置表 (全局配置，无用户隔离)\nCREATE TABLE IF NOT EXISTS ai_service_configs (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    service_type TEXT NOT NULL, -- text, image, video\n    provider TEXT, -- openai, gemini, volcengine, etc.\n    name TEXT NOT NULL,\n    base_url TEXT NOT NULL,\n    api_key TEXT NOT NULL,\n    model TEXT,\n    endpoint TEXT,\n    query_endpoint TEXT,\n    priority INTEGER NOT NULL DEFAULT 0,\n    is_default INTEGER NOT NULL DEFAULT 0,\n    is_active INTEGER NOT NULL DEFAULT 1,\n    settings TEXT, -- JSON存储\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_ai_service_configs_service_type ON ai_service_configs(service_type);\nCREATE INDEX IF NOT EXISTS idx_ai_service_configs_deleted_at ON ai_service_configs(deleted_at);\n\n-- AI服务提供商表\nCREATE TABLE IF NOT EXISTS ai_service_providers (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    name TEXT NOT NULL UNIQUE,\n    display_name TEXT NOT NULL,\n    service_type TEXT NOT NULL, -- text, image, video\n    default_url TEXT,\n    description TEXT,\n    is_active INTEGER NOT NULL DEFAULT 1,\n    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at DATETIME\n);\n\nCREATE INDEX IF NOT EXISTS idx_ai_service_providers_service_type ON ai_service_providers(service_type);\nCREATE INDEX IF NOT EXISTS idx_ai_service_providers_deleted_at ON ai_service_providers(deleted_at);\n\n-- ======================================\n-- 7. 初始数据\n-- ======================================\n\n-- 插入默认AI服务提供商\nINSERT OR IGNORE INTO ai_service_providers (name, display_name, service_type, default_url, description) VALUES\n('openai', 'OpenAI', 'text', 'https://api.openai.com/v1', 'OpenAI GPT模型'),\n('openai-dalle', 'OpenAI DALL-E', 'image', 'https://api.openai.com/v1', 'OpenAI DALL-E图片生成'),\n('openai-sora', 'OpenAI Sora', 'video', 'https://api.openai.com/v1', 'OpenAI Sora视频生成'),\n('midjourney', 'Midjourney', 'image', '', 'Midjourney图片生成'),\n('doubao-image', '豆包(火山引擎)', 'image', 'https://ark.cn-beijing.volces.com', '火山引擎豆包图片生成'),\n('gemini-image', 'Google Gemini', 'image', 'https://generativelanguage.googleapis.com', 'Google Gemini原生图片生成(base64)'),\n('runway', 'Runway', 'video', '', 'Runway视频生成'),\n('pika', 'Pika Labs', 'video', '', 'Pika视频生成'),\n('doubao', '豆包(火山引擎)', 'video', 'https://ark.cn-beijing.volces.com', '火山引擎豆包视频生成'),\n('minimax', 'MiniMax', 'video', '', 'MiniMax视频生成');\n"
  },
  {
    "path": "pkg/ai/client.go",
    "content": "package ai\n\n// AIClient 定义文本生成客户端接口\ntype AIClient interface {\n\tGenerateText(prompt string, systemPrompt string, options ...func(*ChatCompletionRequest)) (string, error)\n\tGenerateImage(prompt string, size string, n int) ([]string, error)\n\tTestConnection() error\n}\n"
  },
  {
    "path": "pkg/ai/gemini_client.go",
    "content": "package ai\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype GeminiClient struct {\n\tBaseURL    string\n\tAPIKey     string\n\tModel      string\n\tEndpoint   string\n\tHTTPClient *http.Client\n}\n\ntype GeminiTextRequest struct {\n\tContents          []GeminiContent    `json:\"contents\"`\n\tSystemInstruction *GeminiInstruction `json:\"systemInstruction,omitempty\"`\n}\n\ntype GeminiContent struct {\n\tParts []GeminiPart `json:\"parts\"`\n\tRole  string       `json:\"role,omitempty\"`\n}\n\ntype GeminiPart struct {\n\tText string `json:\"text\"`\n}\n\ntype GeminiInstruction struct {\n\tParts []GeminiPart `json:\"parts\"`\n}\n\ntype GeminiTextResponse struct {\n\tCandidates []struct {\n\t\tContent struct {\n\t\t\tParts []struct {\n\t\t\t\tText string `json:\"text\"`\n\t\t\t} `json:\"parts\"`\n\t\t\tRole string `json:\"role\"`\n\t\t} `json:\"content\"`\n\t\tFinishReason  string `json:\"finishReason\"`\n\t\tIndex         int    `json:\"index\"`\n\t\tSafetyRatings []struct {\n\t\t\tCategory    string `json:\"category\"`\n\t\t\tProbability string `json:\"probability\"`\n\t\t} `json:\"safetyRatings\"`\n\t} `json:\"candidates\"`\n\tUsageMetadata struct {\n\t\tPromptTokenCount     int `json:\"promptTokenCount\"`\n\t\tCandidatesTokenCount int `json:\"candidatesTokenCount\"`\n\t\tTotalTokenCount      int `json:\"totalTokenCount\"`\n\t} `json:\"usageMetadata\"`\n}\n\nfunc NewGeminiClient(baseURL, apiKey, model, endpoint string) *GeminiClient {\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://generativelanguage.googleapis.com\"\n\t}\n\tif endpoint == \"\" {\n\t\tendpoint = \"/v1beta/models/{model}:generateContent\"\n\t}\n\tif model == \"\" {\n\t\tmodel = \"gemini-3-pro\"\n\t}\n\treturn &GeminiClient{\n\t\tBaseURL:  baseURL,\n\t\tAPIKey:   apiKey,\n\t\tModel:    model,\n\t\tEndpoint: endpoint,\n\t\tHTTPClient: &http.Client{\n\t\t\tTimeout: 10 * time.Minute,\n\t\t},\n\t}\n}\n\nfunc (c *GeminiClient) GenerateText(prompt string, systemPrompt string, options ...func(*ChatCompletionRequest)) (string, error) {\n\tmodel := c.Model\n\n\t// 构建请求体\n\treqBody := GeminiTextRequest{\n\t\tContents: []GeminiContent{\n\t\t\t{\n\t\t\t\tParts: []GeminiPart{{Text: prompt}},\n\t\t\t\tRole:  \"user\",\n\t\t\t},\n\t\t},\n\t}\n\n\t// 使用 systemInstruction 字段处理系统提示\n\tif systemPrompt != \"\" {\n\t\treqBody.SystemInstruction = &GeminiInstruction{\n\t\t\tParts: []GeminiPart{{Text: systemPrompt}},\n\t\t}\n\t}\n\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\tfmt.Printf(\"Gemini: Failed to marshal request: %v\\n\", err)\n\t\treturn \"\", fmt.Errorf(\"marshal request: %w\", err)\n\t}\n\n\t// 替换端点中的 {model} 占位符\n\tendpoint := c.BaseURL + c.Endpoint\n\tendpoint = strings.ReplaceAll(endpoint, \"{model}\", model)\n\turl := fmt.Sprintf(\"%s?key=%s\", endpoint, c.APIKey)\n\n\t// 打印请求信息（隐藏 API Key）\n\tsafeURL := strings.Replace(url, c.APIKey, \"***\", 1)\n\tfmt.Printf(\"Gemini: Sending request to: %s\\n\", safeURL)\n\trequestPreview := string(jsonData)\n\tif len(jsonData) > 300 {\n\t\trequestPreview = string(jsonData[:300]) + \"...\"\n\t}\n\tfmt.Printf(\"Gemini: Request body: %s\\n\", requestPreview)\n\n\treq, err := http.NewRequest(\"POST\", url, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\tfmt.Printf(\"Gemini: Failed to create request: %v\\n\", err)\n\t\treturn \"\", fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tfmt.Printf(\"Gemini: Executing HTTP request...\\n\")\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\tfmt.Printf(\"Gemini: HTTP request failed: %v\\n\", err)\n\t\treturn \"\", fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tfmt.Printf(\"Gemini: Received response with status: %d\\n\", resp.StatusCode)\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tfmt.Printf(\"Gemini: Failed to read response body: %v\\n\", err)\n\t\treturn \"\", fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tfmt.Printf(\"Gemini: API error (status %d): %s\\n\", resp.StatusCode, string(body))\n\t\treturn \"\", fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\t// 打印响应体用于调试\n\tbodyPreview := string(body)\n\tif len(body) > 500 {\n\t\tbodyPreview = string(body[:500]) + \"...\"\n\t}\n\tfmt.Printf(\"Gemini: Response body: %s\\n\", bodyPreview)\n\n\tvar result GeminiTextResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\terrorPreview := string(body)\n\t\tif len(body) > 200 {\n\t\t\terrorPreview = string(body[:200])\n\t\t}\n\t\tfmt.Printf(\"Gemini: Failed to parse response: %v\\n\", err)\n\t\treturn \"\", fmt.Errorf(\"parse response: %w, body preview: %s\", err, errorPreview)\n\t}\n\n\tfmt.Printf(\"Gemini: Successfully parsed response, candidates count: %d\\n\", len(result.Candidates))\n\n\tif len(result.Candidates) == 0 {\n\t\tfmt.Printf(\"Gemini: No candidates in response\\n\")\n\t\treturn \"\", fmt.Errorf(\"no candidates in response\")\n\t}\n\n\tif len(result.Candidates[0].Content.Parts) == 0 {\n\t\tfmt.Printf(\"Gemini: No parts in first candidate\\n\")\n\t\treturn \"\", fmt.Errorf(\"no parts in response\")\n\t}\n\n\tresponseText := result.Candidates[0].Content.Parts[0].Text\n\tfmt.Printf(\"Gemini: Generated text: %s\\n\", responseText)\n\n\treturn responseText, nil\n}\n\nfunc (c *GeminiClient) GenerateImage(prompt string, size string, n int) ([]string, error) {\n\treturn nil, fmt.Errorf(\"GenerateImage not implemented for Gemini client\")\n}\n\nfunc (c *GeminiClient) TestConnection() error {\n\tfmt.Printf(\"Gemini: TestConnection called with BaseURL=%s, Model=%s, Endpoint=%s\\n\", c.BaseURL, c.Model, c.Endpoint)\n\t_, err := c.GenerateText(\"Hello\", \"\")\n\tif err != nil {\n\t\tfmt.Printf(\"Gemini: TestConnection failed: %v\\n\", err)\n\t} else {\n\t\tfmt.Printf(\"Gemini: TestConnection succeeded\\n\")\n\t}\n\treturn err\n}\n"
  },
  {
    "path": "pkg/ai/openai_client.go",
    "content": "package ai\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype OpenAIClient struct {\n\tBaseURL    string\n\tAPIKey     string\n\tModel      string\n\tEndpoint   string\n\tHTTPClient *http.Client\n}\n\ntype ChatMessage struct {\n\tRole    string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype ChatCompletionRequest struct {\n\tModel               string        `json:\"model\"`\n\tMessages            []ChatMessage `json:\"messages\"`\n\tTemperature         float64       `json:\"temperature,omitempty\"`\n\tMaxTokens           *int          `json:\"max_tokens,omitempty\"`\n\tMaxCompletionTokens *int          `json:\"max_completion_tokens,omitempty\"`\n\tTopP                float64       `json:\"top_p,omitempty\"`\n\tStream              bool          `json:\"stream,omitempty\"`\n}\n\ntype ChatCompletionResponse struct {\n\tID      string `json:\"id\"`\n\tObject  string `json:\"object\"`\n\tCreated int64  `json:\"created\"`\n\tModel   string `json:\"model\"`\n\tChoices []struct {\n\t\tIndex   int `json:\"index\"`\n\t\tMessage struct {\n\t\t\tRole    string `json:\"role\"`\n\t\t\tContent string `json:\"content\"`\n\t\t} `json:\"message\"`\n\t\tFinishReason string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage struct {\n\t\tPromptTokens     int `json:\"prompt_tokens\"`\n\t\tCompletionTokens int `json:\"completion_tokens\"`\n\t\tTotalTokens      int `json:\"total_tokens\"`\n\t} `json:\"usage\"`\n}\n\ntype ImageGenerationRequest struct {\n\tModel  string `json:\"model,omitempty\"`\n\tPrompt string `json:\"prompt\"`\n\tN      int    `json:\"n,omitempty\"`\n\tSize   string `json:\"size,omitempty\"`\n}\n\ntype ImageGenerationResponse struct {\n\tCreated int64 `json:\"created\"`\n\tData    []struct {\n\t\tURL     string `json:\"url\"`\n\t\tB64JSON string `json:\"b64_json\"`\n\t} `json:\"data\"`\n}\n\ntype ErrorResponse struct {\n\tError struct {\n\t\tMessage string `json:\"message\"`\n\t\tType    string `json:\"type\"`\n\t\tCode    string `json:\"code\"`\n\t} `json:\"error\"`\n}\n\nfunc NewOpenAIClient(baseURL, apiKey, model, endpoint string) *OpenAIClient {\n\tif endpoint == \"\" {\n\t\tendpoint = \"/v1/chat/completions\"\n\t}\n\n\treturn &OpenAIClient{\n\t\tBaseURL:  baseURL,\n\t\tAPIKey:   apiKey,\n\t\tModel:    model,\n\t\tEndpoint: endpoint,\n\t\tHTTPClient: &http.Client{\n\t\t\tTimeout: 10 * time.Minute,\n\t\t},\n\t}\n}\n\nfunc (c *OpenAIClient) ChatCompletion(messages []ChatMessage, options ...func(*ChatCompletionRequest)) (*ChatCompletionResponse, error) {\n\treq := &ChatCompletionRequest{\n\t\tModel:    c.Model,\n\t\tMessages: messages,\n\t}\n\n\tfor _, option := range options {\n\t\toption(req)\n\t}\n\n\treturn c.sendChatRequest(req)\n}\n\nfunc (c *OpenAIClient) sendChatRequest(req *ChatCompletionRequest) (*ChatCompletionResponse, error) {\n\tresp, err := c.doChatRequest(req)\n\tif err == nil {\n\t\treturn resp, nil\n\t}\n\n\tif shouldRetryWithMaxCompletionTokens(err, req) {\n\t\ttokens := *req.MaxTokens\n\t\tretryReq := *req\n\t\tretryReq.MaxTokens = nil\n\t\tretryReq.MaxCompletionTokens = &tokens\n\t\tfmt.Printf(\"OpenAI: retrying with max_completion_tokens=%d\\n\", tokens)\n\t\treturn c.doChatRequest(&retryReq)\n\t}\n\n\treturn nil, err\n}\n\nfunc (c *OpenAIClient) doChatRequest(req *ChatCompletionRequest) (*ChatCompletionResponse, error) {\n\tjsonData, err := json.Marshal(req)\n\tif err != nil {\n\t\tfmt.Printf(\"OpenAI: Failed to marshal request: %v\\n\", err)\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\n\turl := c.BaseURL + c.Endpoint\n\n\t// 打印请求信息\n\tfmt.Printf(\"OpenAI: Sending request to: %s\\n\", url)\n\tfmt.Printf(\"OpenAI: BaseURL=%s, Endpoint=%s, Model=%s\\n\", c.BaseURL, c.Endpoint, c.Model)\n\trequestPreview := string(jsonData)\n\tif len(jsonData) > 300 {\n\t\trequestPreview = string(jsonData[:300]) + \"...\"\n\t}\n\tfmt.Printf(\"OpenAI: Request body: %s\\n\", requestPreview)\n\n\thttpReq, err := http.NewRequest(\"POST\", url, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\tfmt.Printf(\"OpenAI: Failed to create request: %v\\n\", err)\n\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tfmt.Printf(\"OpenAI: Executing HTTP request...\\n\")\n\tresp, err := c.HTTPClient.Do(httpReq)\n\tif err != nil {\n\t\tfmt.Printf(\"OpenAI: HTTP request failed: %v\\n\", err)\n\t\treturn nil, fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tfmt.Printf(\"OpenAI: Received response with status: %d\\n\", resp.StatusCode)\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tfmt.Printf(\"OpenAI: Failed to read response body: %v\\n\", err)\n\t\treturn nil, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tfmt.Printf(\"OpenAI: API error (status %d): %s\\n\", resp.StatusCode, string(body))\n\t\tvar errResp ErrorResponse\n\t\tif err := json.Unmarshal(body, &errResp); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, string(body))\n\t\t}\n\t\treturn nil, fmt.Errorf(\"API error: %s\", errResp.Error.Message)\n\t}\n\n\t// 打印响应体用于调试\n\tbodyPreview := string(body)\n\tif len(body) > 500 {\n\t\tbodyPreview = string(body[:500]) + \"...\"\n\t}\n\tfmt.Printf(\"OpenAI: Response body: %s\\n\", bodyPreview)\n\n\tvar chatResp ChatCompletionResponse\n\tif err := json.Unmarshal(body, &chatResp); err != nil {\n\t\terrorPreview := string(body)\n\t\tif len(body) > 200 {\n\t\t\terrorPreview = string(body[:200])\n\t\t}\n\t\tfmt.Printf(\"OpenAI: Failed to parse response: %v\\n\", err)\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal response: %w, body preview: %s\", err, errorPreview)\n\t}\n\n\tfmt.Printf(\"OpenAI: Successfully parsed response, choices count: %d\\n\", len(chatResp.Choices))\n\n\tif len(chatResp.Choices) == 0 {\n\t\tfmt.Printf(\"OpenAI: No choices in response\\n\")\n\t\treturn nil, fmt.Errorf(\"no choices in response\")\n\t}\n\n\t// 检查 finish_reason，处理内容过滤的情况\n\tif len(chatResp.Choices) > 0 {\n\t\tfinishReason := chatResp.Choices[0].FinishReason\n\t\tcontent := chatResp.Choices[0].Message.Content\n\t\tusage := chatResp.Usage\n\n\t\tfmt.Printf(\"OpenAI: finish_reason=%s, content_length=%d\\n\", finishReason, len(content))\n\n\t\tif finishReason == \"content_filter\" {\n\t\t\treturn nil, fmt.Errorf(\"AI内容被安全过滤器拦截，可能因为：\\n1. 请求内容触发了安全策略\\n2. 生成的内容包含敏感信息\\n3. 建议：调整输入内容或联系API提供商调整过滤策略\")\n\t\t}\n\n\t\tif usage.TotalTokens == 0 && finishReason != \"stop\" {\n\t\t\treturn nil, fmt.Errorf(\"AI返回内容为空 (finish_reason: %s)，可能的原因：\\n1. 内容被过滤\\n2. Token限制\\n3. API异常\", finishReason)\n\t\t}\n\t}\n\n\treturn &chatResp, nil\n}\n\nfunc WithTemperature(temp float64) func(*ChatCompletionRequest) {\n\treturn func(req *ChatCompletionRequest) {\n\t\treq.Temperature = temp\n\t}\n}\n\nfunc WithMaxTokens(tokens int) func(*ChatCompletionRequest) {\n\treturn func(req *ChatCompletionRequest) {\n\t\treq.MaxTokens = &tokens\n\t}\n}\n\nfunc WithTopP(topP float64) func(*ChatCompletionRequest) {\n\treturn func(req *ChatCompletionRequest) {\n\t\treq.TopP = topP\n\t}\n}\n\nfunc (c *OpenAIClient) GenerateText(prompt string, systemPrompt string, options ...func(*ChatCompletionRequest)) (string, error) {\n\tmessages := []ChatMessage{}\n\n\tif systemPrompt != \"\" {\n\t\tmessages = append(messages, ChatMessage{\n\t\t\tRole:    \"system\",\n\t\t\tContent: systemPrompt,\n\t\t})\n\t}\n\n\tmessages = append(messages, ChatMessage{\n\t\tRole:    \"user\",\n\t\tContent: prompt,\n\t})\n\n\tresp, err := c.ChatCompletion(messages, options...)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif len(resp.Choices) == 0 {\n\t\treturn \"\", fmt.Errorf(\"no response from API\")\n\t}\n\n\treturn resp.Choices[0].Message.Content, nil\n}\n\nfunc (c *OpenAIClient) GenerateImage(prompt string, size string, n int) ([]string, error) {\n\t// 图片生成端点通常是 /v1/images/generations\n\t// 如果 c.Endpoint 是 chat 端点，我们需要将其替换\n\t// 这是一个简单的处理逻辑，实际可能需要更复杂的配置\n\timageEndpoint := \"/v1/images/generations\"\n\n\t// 如果 BaseURL 是类似 api.openai.com，那么直接拼接\n\turl := c.BaseURL + imageEndpoint\n\n\treqBody := ImageGenerationRequest{\n\t\tPrompt: prompt,\n\t\tN:      n,\n\t\tSize:   size,\n\t\tModel:  c.Model, // 如果是DALL-E 3，模型名很重要\n\t}\n\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\thttpReq, err := http.NewRequest(\"POST\", url, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tresp, err := c.HTTPClient.Do(httpReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tvar errResp ErrorResponse\n\t\tif err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Message != \"\" {\n\t\t\treturn nil, fmt.Errorf(\"API error: %s\", errResp.Error.Message)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\tvar imgResp ImageGenerationResponse\n\tif err := json.Unmarshal(body, &imgResp); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar urls []string\n\tfor _, data := range imgResp.Data {\n\t\tif data.URL != \"\" {\n\t\t\turls = append(urls, data.URL)\n\t\t} else if data.B64JSON != \"\" {\n\t\t\t// 如果返回的是base64，添加前缀\n\t\t\turls = append(urls, \"data:image/png;base64,\"+data.B64JSON)\n\t\t}\n\t}\n\n\treturn urls, nil\n}\n\nfunc (c *OpenAIClient) TestConnection() error {\n\tfmt.Printf(\"OpenAI: TestConnection called with BaseURL=%s, Endpoint=%s, Model=%s\\n\", c.BaseURL, c.Endpoint, c.Model)\n\n\tmessages := []ChatMessage{\n\t\t{\n\t\t\tRole:    \"user\",\n\t\t\tContent: \"Hello\",\n\t\t},\n\t}\n\n\t_, err := c.ChatCompletion(messages, WithMaxTokens(50))\n\tif err != nil {\n\t\tfmt.Printf(\"OpenAI: TestConnection failed: %v\\n\", err)\n\t} else {\n\t\tfmt.Printf(\"OpenAI: TestConnection succeeded\\n\")\n\t}\n\treturn err\n}\n\nfunc shouldRetryWithMaxCompletionTokens(err error, req *ChatCompletionRequest) bool {\n\tif err == nil || req == nil || req.MaxTokens == nil || req.MaxCompletionTokens != nil {\n\t\treturn false\n\t}\n\n\tmsg := err.Error()\n\tif strings.Contains(msg, \"Unsupported parameter: 'max_tokens'\") {\n\t\treturn true\n\t}\n\tif strings.Contains(msg, \"max_tokens is not supported\") {\n\t\treturn true\n\t}\n\tif strings.Contains(msg, \"max_completion_tokens\") {\n\t\treturn true\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "pkg/config/config.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/viper\"\n)\n\ntype Config struct {\n\tApp      AppConfig      `mapstructure:\"app\"`\n\tServer   ServerConfig   `mapstructure:\"server\"`\n\tDatabase DatabaseConfig `mapstructure:\"database\"`\n\tStorage  StorageConfig  `mapstructure:\"storage\"`\n\tAI       AIConfig       `mapstructure:\"ai\"`\n}\n\ntype AppConfig struct {\n\tName     string `mapstructure:\"name\"`\n\tVersion  string `mapstructure:\"version\"`\n\tDebug    bool   `mapstructure:\"debug\"`\n\tLanguage string `mapstructure:\"language\"` // zh 或 en\n}\n\ntype ServerConfig struct {\n\tPort         int      `mapstructure:\"port\"`\n\tHost         string   `mapstructure:\"host\"`\n\tCORSOrigins  []string `mapstructure:\"cors_origins\"`\n\tReadTimeout  int      `mapstructure:\"read_timeout\"`\n\tWriteTimeout int      `mapstructure:\"write_timeout\"`\n}\n\ntype DatabaseConfig struct {\n\tType     string `mapstructure:\"type\"` // sqlite, mysql\n\tPath     string `mapstructure:\"path\"` // SQLite数据库文件路径\n\tHost     string `mapstructure:\"host\"`\n\tPort     int    `mapstructure:\"port\"`\n\tUser     string `mapstructure:\"user\"`\n\tPassword string `mapstructure:\"password\"`\n\tDatabase string `mapstructure:\"database\"`\n\tCharset  string `mapstructure:\"charset\"`\n\tMaxIdle  int    `mapstructure:\"max_idle\"`\n\tMaxOpen  int    `mapstructure:\"max_open\"`\n}\n\ntype StorageConfig struct {\n\tType      string `mapstructure:\"type\"`       // local, minio\n\tLocalPath string `mapstructure:\"local_path\"` // 本地存储路径\n\tBaseURL   string `mapstructure:\"base_url\"`   // 访问URL前缀\n}\n\ntype AIConfig struct {\n\tDefaultTextProvider  string `mapstructure:\"default_text_provider\"`\n\tDefaultImageProvider string `mapstructure:\"default_image_provider\"`\n\tDefaultVideoProvider string `mapstructure:\"default_video_provider\"`\n}\n\nfunc LoadConfig() (*Config, error) {\n\tviper.SetConfigName(\"config\")\n\tviper.SetConfigType(\"yaml\")\n\tviper.AddConfigPath(\"./configs\")\n\tviper.AddConfigPath(\".\")\n\n\tviper.AutomaticEnv()\n\n\tif err := viper.ReadInConfig(); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read config: %w\", err)\n\t}\n\n\tvar config Config\n\tif err := viper.Unmarshal(&config); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal config: %w\", err)\n\t}\n\n\treturn &config, nil\n}\n\nfunc (c *DatabaseConfig) DSN() string {\n\tif c.Type == \"sqlite\" {\n\t\treturn c.Path\n\t}\n\t// MySQL DSN\n\treturn fmt.Sprintf(\"%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local\",\n\t\tc.User,\n\t\tc.Password,\n\t\tc.Host,\n\t\tc.Port,\n\t\tc.Database,\n\t\tc.Charset,\n\t)\n}\n"
  },
  {
    "path": "pkg/image/gemini_image_client.go",
    "content": "package image\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype GeminiImageClient struct {\n\tBaseURL    string\n\tAPIKey     string\n\tModel      string\n\tEndpoint   string\n\tHTTPClient *http.Client\n}\n\ntype GeminiImageRequest struct {\n\tContents []struct {\n\t\tParts []GeminiPart `json:\"parts\"`\n\t} `json:\"contents\"`\n\tGenerationConfig struct {\n\t\tResponseModalities []string `json:\"responseModalities\"`\n\t} `json:\"generationConfig\"`\n}\n\ntype GeminiPart struct {\n\tText       string            `json:\"text,omitempty\"`\n\tInlineData *GeminiInlineData `json:\"inlineData,omitempty\"`\n}\n\ntype GeminiInlineData struct {\n\tMimeType string `json:\"mimeType\"`\n\tData     string `json:\"data\"` // base64 编码的图片数据\n}\n\ntype GeminiImageResponse struct {\n\tCandidates []struct {\n\t\tContent struct {\n\t\t\tParts []struct {\n\t\t\t\tInlineData struct {\n\t\t\t\t\tMimeType string `json:\"mimeType\"`\n\t\t\t\t\tData     string `json:\"data\"`\n\t\t\t\t} `json:\"inlineData,omitempty\"`\n\t\t\t\tText string `json:\"text,omitempty\"`\n\t\t\t} `json:\"parts\"`\n\t\t} `json:\"content\"`\n\t} `json:\"candidates\"`\n\tUsageMetadata struct {\n\t\tPromptTokenCount     int `json:\"promptTokenCount\"`\n\t\tCandidatesTokenCount int `json:\"candidatesTokenCount\"`\n\t\tTotalTokenCount      int `json:\"totalTokenCount\"`\n\t} `json:\"usageMetadata\"`\n}\n\n// downloadImageToBase64 下载图片 URL 并转换为 base64\nfunc downloadImageToBase64(imageURL string) (string, string, error) {\n\tresp, err := http.Get(imageURL)\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"download image: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", \"\", fmt.Errorf(\"download image failed with status: %d\", resp.StatusCode)\n\t}\n\n\timageData, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"read image data: %w\", err)\n\t}\n\n\t// 根据 Content-Type 确定 mimeType\n\tmimeType := resp.Header.Get(\"Content-Type\")\n\tif mimeType == \"\" {\n\t\tmimeType = \"image/jpeg\"\n\t}\n\n\tbase64Data := base64.StdEncoding.EncodeToString(imageData)\n\treturn base64Data, mimeType, nil\n}\n\nfunc NewGeminiImageClient(baseURL, apiKey, model, endpoint string) *GeminiImageClient {\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://generativelanguage.googleapis.com\"\n\t}\n\tif endpoint == \"\" {\n\t\tendpoint = \"/v1beta/models/{model}:generateContent\"\n\t}\n\tif model == \"\" {\n\t\tmodel = \"gemini-3-pro-image-preview\"\n\t}\n\treturn &GeminiImageClient{\n\t\tBaseURL:  baseURL,\n\t\tAPIKey:   apiKey,\n\t\tModel:    model,\n\t\tEndpoint: endpoint,\n\t\tHTTPClient: &http.Client{\n\t\t\tTimeout: 10 * time.Minute,\n\t\t},\n\t}\n}\n\nfunc (c *GeminiImageClient) GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error) {\n\toptions := &ImageOptions{\n\t\tSize:    \"1920x1920\",\n\t\tQuality: \"standard\",\n\t}\n\n\tfor _, opt := range opts {\n\t\topt(options)\n\t}\n\n\tmodel := c.Model\n\tif options.Model != \"\" {\n\t\tmodel = options.Model\n\t}\n\n\tpromptText := prompt\n\tif options.NegativePrompt != \"\" {\n\t\tpromptText += fmt.Sprintf(\"\\n\\nNegative prompt: %s\", options.NegativePrompt)\n\t}\n\tif options.Size != \"\" {\n\t\tpromptText += fmt.Sprintf(\"\\n\\nImage size: %s\", options.Size)\n\t}\n\n\t// 构建请求的 parts，支持参考图\n\tparts := []GeminiPart{}\n\n\t// 如果有参考图，先添加参考图\n\tif len(options.ReferenceImages) > 0 {\n\t\tfor _, refImg := range options.ReferenceImages {\n\t\t\tvar base64Data string\n\t\t\tvar mimeType string\n\t\t\tvar err error\n\n\t\t\t// 检查是否是 HTTP/HTTPS URL\n\t\t\tif strings.HasPrefix(refImg, \"http://\") || strings.HasPrefix(refImg, \"https://\") {\n\t\t\t\t// 下载图片并转换为 base64\n\t\t\t\tbase64Data, mimeType, err = downloadImageToBase64(refImg)\n\t\t\t\tif err != nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t} else if strings.HasPrefix(refImg, \"data:\") {\n\t\t\t\t// 如果是 data URI 格式，需要解析\n\t\t\t\t// 格式: data:image/jpeg;base64,xxxxx\n\t\t\t\tmimeType = \"image/jpeg\"\n\t\t\t\tparts := []byte(refImg)\n\t\t\t\tfor i := 0; i < len(parts); i++ {\n\t\t\t\t\tif parts[i] == ',' {\n\t\t\t\t\t\tbase64Data = refImg[i+1:]\n\t\t\t\t\t\t// 提取 mime type\n\t\t\t\t\t\tif i > 11 {\n\t\t\t\t\t\t\tmimeTypeEnd := i\n\t\t\t\t\t\t\tfor j := 5; j < i; j++ {\n\t\t\t\t\t\t\t\tif parts[j] == ';' {\n\t\t\t\t\t\t\t\t\tmimeTypeEnd = j\n\t\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tmimeType = refImg[5:mimeTypeEnd]\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// 假设已经是 base64 编码\n\t\t\t\tbase64Data = refImg\n\t\t\t\tmimeType = \"image/jpeg\"\n\t\t\t}\n\n\t\t\tif base64Data != \"\" {\n\t\t\t\tparts = append(parts, GeminiPart{\n\t\t\t\t\tInlineData: &GeminiInlineData{\n\t\t\t\t\t\tMimeType: mimeType,\n\t\t\t\t\t\tData:     base64Data,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\t// 添加文本提示词\n\tparts = append(parts, GeminiPart{\n\t\tText: promptText,\n\t})\n\n\treqBody := GeminiImageRequest{\n\t\tContents: []struct {\n\t\t\tParts []GeminiPart `json:\"parts\"`\n\t\t}{\n\t\t\t{\n\t\t\t\tParts: parts,\n\t\t\t},\n\t\t},\n\t\tGenerationConfig: struct {\n\t\t\tResponseModalities []string `json:\"responseModalities\"`\n\t\t}{\n\t\t\tResponseModalities: []string{\"IMAGE\"},\n\t\t},\n\t}\n\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"marshal request: %w\", err)\n\t}\n\n\tendpoint := c.BaseURL + c.Endpoint\n\tendpoint = replaceModelPlaceholder(endpoint, model)\n\turl := fmt.Sprintf(\"%s?key=%s\", endpoint, c.APIKey)\n\n\treq, err := http.NewRequest(\"POST\", url, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbodyStr := string(body)\n\t\tif len(bodyStr) > 1000 {\n\t\t\tbodyStr = fmt.Sprintf(\"%s ... %s\", bodyStr[:500], bodyStr[len(bodyStr)-500:])\n\t\t}\n\t\treturn nil, fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, bodyStr)\n\t}\n\n\tvar result GeminiImageResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse response: %w\", err)\n\t}\n\n\tif len(result.Candidates) == 0 || len(result.Candidates[0].Content.Parts) == 0 {\n\t\treturn nil, fmt.Errorf(\"no image generated in response\")\n\t}\n\n\tbase64Data := result.Candidates[0].Content.Parts[0].InlineData.Data\n\tif base64Data == \"\" {\n\t\treturn nil, fmt.Errorf(\"no base64 image data in response\")\n\t}\n\n\tdataURI := fmt.Sprintf(\"data:image/jpeg;base64,%s\", base64Data)\n\n\treturn &ImageResult{\n\t\tStatus:    \"completed\",\n\t\tImageURL:  dataURI,\n\t\tCompleted: true,\n\t\tWidth:     1024,\n\t\tHeight:    1024,\n\t}, nil\n}\n\nfunc (c *GeminiImageClient) GetTaskStatus(taskID string) (*ImageResult, error) {\n\treturn nil, fmt.Errorf(\"not supported for Gemini (synchronous generation)\")\n}\n\nfunc replaceModelPlaceholder(endpoint, model string) string {\n\tresult := endpoint\n\tif bytes.Contains([]byte(result), []byte(\"{model}\")) {\n\t\tresult = string(bytes.ReplaceAll([]byte(result), []byte(\"{model}\"), []byte(model)))\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "pkg/image/image_client.go",
    "content": "package image\n\ntype ImageClient interface {\n\tGenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error)\n\tGetTaskStatus(taskID string) (*ImageResult, error)\n}\n\ntype ImageResult struct {\n\tTaskID    string\n\tStatus    string\n\tImageURL  string\n\tWidth     int\n\tHeight    int\n\tError     string\n\tCompleted bool\n}\n\ntype ImageOptions struct {\n\tNegativePrompt  string\n\tSize            string\n\tQuality         string\n\tStyle           string\n\tSteps           int\n\tCfgScale        float64\n\tSeed            int64\n\tModel           string\n\tWidth           int\n\tHeight          int\n\tReferenceImages []string // 参考图片URL列表\n}\n\ntype ImageOption func(*ImageOptions)\n\nfunc WithNegativePrompt(prompt string) ImageOption {\n\treturn func(o *ImageOptions) {\n\t\to.NegativePrompt = prompt\n\t}\n}\n\nfunc WithSize(size string) ImageOption {\n\treturn func(o *ImageOptions) {\n\t\to.Size = size\n\t}\n}\n\nfunc WithQuality(quality string) ImageOption {\n\treturn func(o *ImageOptions) {\n\t\to.Quality = quality\n\t}\n}\n\nfunc WithStyle(style string) ImageOption {\n\treturn func(o *ImageOptions) {\n\t\to.Style = style\n\t}\n}\n\nfunc WithSteps(steps int) ImageOption {\n\treturn func(o *ImageOptions) {\n\t\to.Steps = steps\n\t}\n}\n\nfunc WithCfgScale(scale float64) ImageOption {\n\treturn func(o *ImageOptions) {\n\t\to.CfgScale = scale\n\t}\n}\n\nfunc WithSeed(seed int64) ImageOption {\n\treturn func(o *ImageOptions) {\n\t\to.Seed = seed\n\t}\n}\n\nfunc WithModel(model string) ImageOption {\n\treturn func(o *ImageOptions) {\n\t\to.Model = model\n\t}\n}\n\nfunc WithDimensions(width, height int) ImageOption {\n\treturn func(o *ImageOptions) {\n\t\to.Width = width\n\t\to.Height = height\n\t}\n}\n\nfunc WithReferenceImages(images []string) ImageOption {\n\treturn func(o *ImageOptions) {\n\t\to.ReferenceImages = images\n\t}\n}\n"
  },
  {
    "path": "pkg/image/openai_image_client.go",
    "content": "package image\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n)\n\ntype OpenAIImageClient struct {\n\tBaseURL    string\n\tAPIKey     string\n\tModel      string\n\tEndpoint   string\n\tHTTPClient *http.Client\n}\n\ntype DALLERequest struct {\n\tModel   string   `json:\"model\"`\n\tPrompt  string   `json:\"prompt\"`\n\tSize    string   `json:\"size,omitempty\"`\n\tQuality string   `json:\"quality,omitempty\"`\n\tN       int      `json:\"n\"`\n\tImage   []string `json:\"image,omitempty\"`\n}\n\ntype DALLEResponse struct {\n\tCreated int64 `json:\"created\"`\n\tData    []struct {\n\t\tURL           string `json:\"url\"`\n\t\tRevisedPrompt string `json:\"revised_prompt,omitempty\"`\n\t} `json:\"data\"`\n}\n\nfunc NewOpenAIImageClient(baseURL, apiKey, model, endpoint string) *OpenAIImageClient {\n\tif endpoint == \"\" {\n\t\tendpoint = \"/v1/images/generations\"\n\t}\n\treturn &OpenAIImageClient{\n\t\tBaseURL:  baseURL,\n\t\tAPIKey:   apiKey,\n\t\tModel:    model,\n\t\tEndpoint: endpoint,\n\t\tHTTPClient: &http.Client{\n\t\t\tTimeout: 10 * time.Minute,\n\t\t},\n\t}\n}\n\nfunc (c *OpenAIImageClient) GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error) {\n\toptions := &ImageOptions{\n\t\tSize:    \"1920x1920\",\n\t\tQuality: \"standard\",\n\t}\n\n\tfor _, opt := range opts {\n\t\topt(options)\n\t}\n\n\tmodel := c.Model\n\tif options.Model != \"\" {\n\t\tmodel = options.Model\n\t}\n\n\treqBody := DALLERequest{\n\t\tModel:   model,\n\t\tPrompt:  prompt,\n\t\tSize:    options.Size,\n\t\tQuality: options.Quality,\n\t\tN:       1,\n\t\tImage:   options.ReferenceImages,\n\t}\n\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"marshal request: %w\", err)\n\t}\n\n\turl := c.BaseURL + c.Endpoint\n\tfmt.Printf(\"[OpenAI Image] Request URL: %s\\n\", url)\n\tfmt.Printf(\"[OpenAI Image] Request Body: %s\\n\", string(jsonData))\n\n\treq, err := http.NewRequest(\"POST\", url, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\tfmt.Printf(\"OpenAI API Response: %s\\n\", string(body))\n\n\tvar result DALLEResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse response: %w, body: %s\", err, string(body))\n\t}\n\n\tif len(result.Data) == 0 {\n\t\treturn nil, fmt.Errorf(\"no image generated, response: %s\", string(body))\n\t}\n\n\treturn &ImageResult{\n\t\tStatus:    \"completed\",\n\t\tImageURL:  result.Data[0].URL,\n\t\tCompleted: true,\n\t}, nil\n}\n\nfunc (c *OpenAIImageClient) GetTaskStatus(taskID string) (*ImageResult, error) {\n\treturn nil, fmt.Errorf(\"not supported for OpenAI/DALL-E\")\n}\n"
  },
  {
    "path": "pkg/image/volcengine_image_client.go",
    "content": "package image\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n)\n\ntype VolcEngineImageClient struct {\n\tBaseURL       string\n\tAPIKey        string\n\tModel         string\n\tEndpoint      string\n\tQueryEndpoint string\n\tHTTPClient    *http.Client\n}\n\ntype VolcEngineImageRequest struct {\n\tModel                     string   `json:\"model\"`\n\tPrompt                    string   `json:\"prompt\"`\n\tImage                     []string `json:\"image,omitempty\"`\n\tSequentialImageGeneration string   `json:\"sequential_image_generation,omitempty\"`\n\tSize                      string   `json:\"size,omitempty\"`\n\tWatermark                 bool     `json:\"watermark,omitempty\"`\n}\n\ntype VolcEngineImageResponse struct {\n\tModel   string `json:\"model\"`\n\tCreated int64  `json:\"created\"`\n\tData    []struct {\n\t\tURL  string `json:\"url\"`\n\t\tSize string `json:\"size\"`\n\t} `json:\"data\"`\n\tUsage struct {\n\t\tGeneratedImages int `json:\"generated_images\"`\n\t\tOutputTokens    int `json:\"output_tokens\"`\n\t\tTotalTokens     int `json:\"total_tokens\"`\n\t} `json:\"usage\"`\n\tError interface{} `json:\"error,omitempty\"`\n}\n\nfunc NewVolcEngineImageClient(baseURL, apiKey, model, endpoint, queryEndpoint string) *VolcEngineImageClient {\n\tif endpoint == \"\" {\n\t\tendpoint = \"/api/v3/images/generations\"\n\t}\n\tif queryEndpoint == \"\" {\n\t\tqueryEndpoint = endpoint\n\t}\n\treturn &VolcEngineImageClient{\n\t\tBaseURL:       baseURL,\n\t\tAPIKey:        apiKey,\n\t\tModel:         model,\n\t\tEndpoint:      endpoint,\n\t\tQueryEndpoint: queryEndpoint,\n\t\tHTTPClient: &http.Client{\n\t\t\tTimeout: 10 * time.Minute,\n\t\t},\n\t}\n}\n\nfunc (c *VolcEngineImageClient) GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error) {\n\toptions := &ImageOptions{\n\t\tSize:    \"1920x1920\",\n\t\tQuality: \"standard\",\n\t}\n\n\tfor _, opt := range opts {\n\t\topt(options)\n\t}\n\n\tmodel := c.Model\n\tif options.Model != \"\" {\n\t\tmodel = options.Model\n\t}\n\n\tpromptText := prompt\n\tif options.NegativePrompt != \"\" {\n\t\tpromptText += fmt.Sprintf(\". Negative: %s\", options.NegativePrompt)\n\t}\n\n\tsize := options.Size\n\tif size == \"\" {\n\t\tif model == \"doubao-seedream-4-5-251128\" {\n\t\t\tsize = \"2K\"\n\t\t} else {\n\t\t\tsize = \"1K\"\n\t\t}\n\t}\n\n\treqBody := VolcEngineImageRequest{\n\t\tModel:                     model,\n\t\tPrompt:                    promptText,\n\t\tImage:                     options.ReferenceImages,\n\t\tSequentialImageGeneration: \"disabled\",\n\t\tSize:                      size,\n\t\tWatermark:                 false,\n\t}\n\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"marshal request: %w\", err)\n\t}\n\n\turl := c.BaseURL + c.Endpoint\n\tfmt.Printf(\"[VolcEngine Image] Request URL: %s\\n\", url)\n\tfmt.Printf(\"[VolcEngine Image] Request Body: %s\\n\", string(jsonData))\n\n\treq, err := http.NewRequest(\"POST\", url, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tfmt.Printf(\"VolcEngine Image API Response: %s\\n\", string(body))\n\n\tif resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {\n\t\treturn nil, fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\tvar result VolcEngineImageResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse response: %w\", err)\n\t}\n\n\tif result.Error != nil {\n\t\treturn nil, fmt.Errorf(\"volcengine error: %v\", result.Error)\n\t}\n\n\tif len(result.Data) == 0 {\n\t\treturn nil, fmt.Errorf(\"no image generated\")\n\t}\n\n\treturn &ImageResult{\n\t\tStatus:    \"completed\",\n\t\tImageURL:  result.Data[0].URL,\n\t\tCompleted: true,\n\t}, nil\n}\n\nfunc (c *VolcEngineImageClient) GetTaskStatus(taskID string) (*ImageResult, error) {\n\treturn nil, fmt.Errorf(\"not supported for VolcEngine Seedream (synchronous generation)\")\n}\n"
  },
  {
    "path": "pkg/logger/logger.go",
    "content": "package logger\n\nimport (\n\t\"go.uber.org/zap\"\n\t\"go.uber.org/zap/zapcore\"\n)\n\ntype Logger struct {\n\t*zap.SugaredLogger\n}\n\nfunc NewLogger(debug bool) *Logger {\n\tvar config zap.Config\n\n\tif debug {\n\t\tconfig = zap.NewDevelopmentConfig()\n\t\tconfig.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder\n\t\t// 在开发模式下，禁用时间戳和调用者信息，使输出更简洁\n\t\tconfig.EncoderConfig.TimeKey = \"\"\n\t\tconfig.EncoderConfig.CallerKey = \"\"\n\t} else {\n\t\tconfig = zap.NewProductionConfig()\n\t\tconfig.EncoderConfig.TimeKey = \"timestamp\"\n\t\tconfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder\n\t}\n\n\tlogger, err := config.Build()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\treturn &Logger{\n\t\tSugaredLogger: logger.Sugar(),\n\t}\n}\n"
  },
  {
    "path": "pkg/response/response.go",
    "content": "package response\n\nimport (\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\ntype Response struct {\n\tSuccess   bool        `json:\"success\"`\n\tData      interface{} `json:\"data,omitempty\"`\n\tError     *ErrorInfo  `json:\"error,omitempty\"`\n\tMessage   string      `json:\"message,omitempty\"`\n\tTimestamp string      `json:\"timestamp\"`\n}\n\ntype ErrorInfo struct {\n\tCode    string      `json:\"code\"`\n\tMessage string      `json:\"message\"`\n\tDetails interface{} `json:\"details,omitempty\"`\n}\n\ntype PaginationData struct {\n\tItems      interface{} `json:\"items\"`\n\tPagination Pagination  `json:\"pagination\"`\n}\n\ntype Pagination struct {\n\tPage       int   `json:\"page\"`\n\tPageSize   int   `json:\"page_size\"`\n\tTotal      int64 `json:\"total\"`\n\tTotalPages int64 `json:\"total_pages\"`\n}\n\nfunc Success(c *gin.Context, data interface{}) {\n\tc.JSON(http.StatusOK, Response{\n\t\tSuccess:   true,\n\t\tData:      data,\n\t\tTimestamp: time.Now().UTC().Format(time.RFC3339),\n\t})\n}\n\nfunc SuccessWithMessage(c *gin.Context, message string, data interface{}) {\n\tc.JSON(http.StatusOK, Response{\n\t\tSuccess:   true,\n\t\tData:      data,\n\t\tMessage:   message,\n\t\tTimestamp: time.Now().UTC().Format(time.RFC3339),\n\t})\n}\n\nfunc Created(c *gin.Context, data interface{}) {\n\tc.JSON(http.StatusCreated, Response{\n\t\tSuccess:   true,\n\t\tData:      data,\n\t\tTimestamp: time.Now().UTC().Format(time.RFC3339),\n\t})\n}\n\nfunc SuccessWithPagination(c *gin.Context, items interface{}, total int64, page int, pageSize int) {\n\ttotalPages := (total + int64(pageSize) - 1) / int64(pageSize)\n\tc.JSON(http.StatusOK, Response{\n\t\tSuccess: true,\n\t\tData: PaginationData{\n\t\t\tItems: items,\n\t\t\tPagination: Pagination{\n\t\t\t\tPage:       page,\n\t\t\t\tPageSize:   pageSize,\n\t\t\t\tTotal:      total,\n\t\t\t\tTotalPages: totalPages,\n\t\t\t},\n\t\t},\n\t\tTimestamp: time.Now().UTC().Format(time.RFC3339),\n\t})\n}\n\nfunc Error(c *gin.Context, statusCode int, errCode string, message string) {\n\tc.JSON(statusCode, Response{\n\t\tSuccess: false,\n\t\tError: &ErrorInfo{\n\t\t\tCode:    errCode,\n\t\t\tMessage: message,\n\t\t},\n\t\tTimestamp: time.Now().UTC().Format(time.RFC3339),\n\t})\n}\n\nfunc ErrorWithDetails(c *gin.Context, statusCode int, errCode string, message string, details interface{}) {\n\tc.JSON(statusCode, Response{\n\t\tSuccess: false,\n\t\tError: &ErrorInfo{\n\t\t\tCode:    errCode,\n\t\t\tMessage: message,\n\t\t\tDetails: details,\n\t\t},\n\t\tTimestamp: time.Now().UTC().Format(time.RFC3339),\n\t})\n}\n\nfunc BadRequest(c *gin.Context, message string) {\n\tError(c, http.StatusBadRequest, \"BAD_REQUEST\", message)\n}\n\nfunc Unauthorized(c *gin.Context, message string) {\n\tError(c, http.StatusUnauthorized, \"UNAUTHORIZED\", message)\n}\n\nfunc Forbidden(c *gin.Context, message string) {\n\tError(c, http.StatusForbidden, \"FORBIDDEN\", message)\n}\n\nfunc NotFound(c *gin.Context, message string) {\n\tError(c, http.StatusNotFound, \"NOT_FOUND\", message)\n}\n\nfunc InternalError(c *gin.Context, message string) {\n\tError(c, http.StatusInternalServerError, \"INTERNAL_ERROR\", message)\n}\n"
  },
  {
    "path": "pkg/utils/image_utils.go",
    "content": "package utils\n\nimport (\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n)\n\n// ImageToBase64 将图片转换为 base64 编码\n// 支持本地文件路径和 HTTP/HTTPS URL\nfunc ImageToBase64(imagePath string) (string, error) {\n\tvar data []byte\n\tvar err error\n\n\tif strings.HasPrefix(imagePath, \"http://\") || strings.HasPrefix(imagePath, \"https://\") {\n\t\t// 从 URL 下载图片\n\t\tdata, err = downloadImageFromURL(imagePath)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to download image from URL: %w\", err)\n\t\t}\n\t} else {\n\t\t// 从本地文件读取\n\t\tdata, err = os.ReadFile(imagePath)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to read local image file: %w\", err)\n\t\t}\n\t}\n\n\t// 转换为 base64\n\tbase64Str := base64.StdEncoding.EncodeToString(data)\n\t\n\t// 检测 MIME 类型\n\tmimeType := detectImageMimeType(data)\n\t\n\t// 返回 data URI 格式\n\treturn fmt.Sprintf(\"data:%s;base64,%s\", mimeType, base64Str), nil\n}\n\n// downloadImageFromURL 从 URL 下载图片数据\nfunc downloadImageFromURL(url string) ([]byte, error) {\n\tresp, err := http.Get(url)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"HTTP error: %d\", resp.StatusCode)\n\t}\n\n\treturn io.ReadAll(resp.Body)\n}\n\n// detectImageMimeType 检测图片的 MIME 类型\nfunc detectImageMimeType(data []byte) string {\n\tif len(data) < 12 {\n\t\treturn \"image/jpeg\" // 默认\n\t}\n\n\t// PNG: 89 50 4E 47\n\tif data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {\n\t\treturn \"image/png\"\n\t}\n\n\t// JPEG: FF D8 FF\n\tif data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {\n\t\treturn \"image/jpeg\"\n\t}\n\n\t// GIF: 47 49 46\n\tif data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 {\n\t\treturn \"image/gif\"\n\t}\n\n\t// WebP: 52 49 46 46 ... 57 45 42 50\n\tif data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&\n\t\tdata[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 {\n\t\treturn \"image/webp\"\n\t}\n\n\treturn \"image/jpeg\" // 默认\n}\n"
  },
  {
    "path": "pkg/utils/json_parser.go",
    "content": "package utils\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// SafeParseAIJSON 安全地解析AI返回的JSON，处理常见的格式问题\n// 包括：\n// 1. 移除Markdown代码块标记\n// 2. 提取JSON对象\n// 3. 清理多余的空白和换行\n// 4. 尝试修复截断的JSON\n// 5. 提供详细的错误信息\nfunc SafeParseAIJSON(aiResponse string, v interface{}) error {\n\tif aiResponse == \"\" {\n\t\treturn fmt.Errorf(\"AI返回内容为空\")\n\t}\n\n\t// 1. 移除可能的Markdown代码块标记\n\tcleaned := strings.TrimSpace(aiResponse)\n\t// 移除开头的 ```json 或 ```\n\tcleaned = regexp.MustCompile(\"(?m)^```json\\\\s*\").ReplaceAllString(cleaned, \"\")\n\tcleaned = regexp.MustCompile(\"(?m)^```\\\\s*\").ReplaceAllString(cleaned, \"\")\n\t// 移除结尾的 ```\n\tcleaned = regexp.MustCompile(\"(?m)```\\\\s*$\").ReplaceAllString(cleaned, \"\")\n\tcleaned = strings.TrimSpace(cleaned)\n\n\t// 2. 提取JSON (支持对象 {} 和数组 [])\n\tvar jsonMatch string\n\n\t// 优先尝试提取完整的JSON（对象或数组）\n\t// 先尝试对象格式\n\tif strings.HasPrefix(cleaned, \"{\") {\n\t\tjsonRegex := regexp.MustCompile(`(?s)\\{.*\\}`)\n\t\tjsonMatch = jsonRegex.FindString(cleaned)\n\t}\n\n\t// 如果没找到对象，尝试数组格式\n\tif jsonMatch == \"\" && strings.HasPrefix(cleaned, \"[\") {\n\t\tjsonRegex := regexp.MustCompile(`(?s)\\[.*\\]`)\n\t\tjsonMatch = jsonRegex.FindString(cleaned)\n\t}\n\n\t// 如果还是没找到，尝试从中间提取\n\tif jsonMatch == \"\" {\n\t\t// 尝试对象\n\t\tobjRegex := regexp.MustCompile(`(?s)\\{.*\\}`)\n\t\tjsonMatch = objRegex.FindString(cleaned)\n\n\t\t// 如果对象没找到，尝试数组\n\t\tif jsonMatch == \"\" {\n\t\t\tarrRegex := regexp.MustCompile(`(?s)\\[.*\\]`)\n\t\t\tjsonMatch = arrRegex.FindString(cleaned)\n\t\t}\n\t}\n\n\tif jsonMatch == \"\" {\n\t\treturn fmt.Errorf(\"响应中未找到有效的JSON对象或数组，原始响应: %s\", truncateString(aiResponse, 200))\n\t}\n\n\t// 3. 尝试解析JSON\n\terr := json.Unmarshal([]byte(jsonMatch), v)\n\tif err == nil {\n\t\treturn nil // 解析成功\n\t}\n\n\t// 4. 如果解析失败，尝试修复截断的JSON\n\tfixedJSON := attemptJSONRepair(jsonMatch)\n\tif fixedJSON != jsonMatch {\n\t\tif err := json.Unmarshal([]byte(fixedJSON), v); err == nil {\n\t\t\treturn nil // 修复后解析成功\n\t\t}\n\t}\n\n\t// 5. 检测是否是响应被截断导致的问题\n\tif isTruncated(jsonMatch) {\n\t\treturn fmt.Errorf(\n\t\t\t\"AI响应可能被截断，导致JSON不完整。\\n请尝试：\\n1. 增加maxTokens参数\\n2. 简化输入内容\\n3. 使用更强大的模型\\n\\n原始错误: %s\\n响应长度: %d\\n响应末尾: %s\",\n\t\t\terr.Error(),\n\t\t\tlen(jsonMatch),\n\t\t\ttruncateString(jsonMatch[maxInt(0, len(jsonMatch)-200):], 200),\n\t\t)\n\t}\n\n\t// 6. 提供详细的错误上下文\n\tif jsonErr, ok := err.(*json.SyntaxError); ok {\n\t\terrorPos := int(jsonErr.Offset)\n\t\tstart := maxInt(0, errorPos-100)\n\t\tend := minInt(len(jsonMatch), errorPos+100)\n\n\t\tcontext := jsonMatch[start:end]\n\t\tmarker := strings.Repeat(\" \", errorPos-start) + \"^\"\n\n\t\treturn fmt.Errorf(\n\t\t\t\"JSON解析失败: %s\\n错误位置附近:\\n%s\\n%s\",\n\t\t\tjsonErr.Error(),\n\t\t\tcontext,\n\t\t\tmarker,\n\t\t)\n\t}\n\n\treturn fmt.Errorf(\"JSON解析失败: %w\\n原始响应: %s\", err, truncateString(jsonMatch, 300))\n}\n\n// attemptJSONRepair 尝试修复常见的JSON问题\nfunc attemptJSONRepair(jsonStr string) string {\n\t// 1. 处理未闭合的字符串\n\t// 如果最后一个字符不是 }，尝试补全\n\ttrimmed := strings.TrimSpace(jsonStr)\n\n\t// 2. 检查是否有未闭合的引号\n\tif strings.Count(trimmed, `\"`)%2 != 0 {\n\t\t// 有奇数个引号，尝试补全最后一个引号\n\t\ttrimmed += `\"`\n\t}\n\n\t// 3. 统计括号\n\topenBraces := strings.Count(trimmed, \"{\")\n\tcloseBraces := strings.Count(trimmed, \"}\")\n\topenBrackets := strings.Count(trimmed, \"[\")\n\tcloseBrackets := strings.Count(trimmed, \"]\")\n\n\t// 4. 处理多余的闭合括号（从末尾移除）\n\t// 这是 AI 生成 JSON 时常见的问题\n\tfor closeBrackets > openBrackets && len(trimmed) > 0 {\n\t\t// 从末尾向前查找多余的 ]\n\t\tlastIdx := strings.LastIndex(trimmed, \"]\")\n\t\tif lastIdx >= 0 {\n\t\t\ttrimmed = trimmed[:lastIdx] + trimmed[lastIdx+1:]\n\t\t\tcloseBrackets--\n\t\t} else {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tfor closeBraces > openBraces && len(trimmed) > 0 {\n\t\t// 从末尾向前查找多余的 }\n\t\tlastIdx := strings.LastIndex(trimmed, \"}\")\n\t\tif lastIdx >= 0 {\n\t\t\ttrimmed = trimmed[:lastIdx] + trimmed[lastIdx+1:]\n\t\t\tcloseBraces--\n\t\t} else {\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// 重新统计括号（因为可能已修改）\n\topenBraces = strings.Count(trimmed, \"{\")\n\tcloseBraces = strings.Count(trimmed, \"}\")\n\topenBrackets = strings.Count(trimmed, \"[\")\n\tcloseBrackets = strings.Count(trimmed, \"]\")\n\n\t// 5. 补全未闭合的数组\n\tfor i := 0; i < openBrackets-closeBrackets; i++ {\n\t\ttrimmed += \"]\"\n\t}\n\n\t// 6. 补全未闭合的对象\n\tfor i := 0; i < openBraces-closeBraces; i++ {\n\t\ttrimmed += \"}\"\n\t}\n\n\treturn trimmed\n}\n\n// ExtractJSONFromText 从文本中提取JSON对象或数组\nfunc ExtractJSONFromText(text string) string {\n\ttext = strings.TrimSpace(text)\n\n\t// 移除Markdown代码块\n\ttext = regexp.MustCompile(\"(?m)^```json\\\\s*\").ReplaceAllString(text, \"\")\n\ttext = regexp.MustCompile(\"(?m)^```\\\\s*\").ReplaceAllString(text, \"\")\n\ttext = strings.TrimSpace(text)\n\n\t// 查找JSON对象\n\tif idx := strings.Index(text, \"{\"); idx != -1 {\n\t\tif lastIdx := strings.LastIndex(text, \"}\"); lastIdx != -1 && lastIdx > idx {\n\t\t\treturn text[idx : lastIdx+1]\n\t\t}\n\t}\n\n\t// 查找JSON数组\n\tif idx := strings.Index(text, \"[\"); idx != -1 {\n\t\tif lastIdx := strings.LastIndex(text, \"]\"); lastIdx != -1 && lastIdx > idx {\n\t\t\treturn text[idx : lastIdx+1]\n\t\t}\n\t}\n\n\treturn text\n}\n\n// ValidateJSON 验证JSON字符串是否有效\nfunc ValidateJSON(jsonStr string) error {\n\tvar js json.RawMessage\n\treturn json.Unmarshal([]byte(jsonStr), &js)\n}\n\n// isTruncated 检测JSON字符串是否可能被截断\nfunc isTruncated(jsonStr string) bool {\n\ttrimmed := strings.TrimSpace(jsonStr)\n\tif len(trimmed) == 0 {\n\t\treturn false\n\t}\n\n\t// 检查是否以不完整的字符串结尾（引号未闭合）\n\tlastChar := trimmed[len(trimmed)-1]\n\tif lastChar != '}' && lastChar != ']' {\n\t\treturn true\n\t}\n\n\t// 检查括号是否匹配\n\topenBraces := strings.Count(trimmed, \"{\")\n\tcloseBraces := strings.Count(trimmed, \"}\")\n\topenBrackets := strings.Count(trimmed, \"[\")\n\tcloseBrackets := strings.Count(trimmed, \"]\")\n\n\tif openBraces != closeBraces || openBrackets != closeBrackets {\n\t\treturn true\n\t}\n\n\t// 检查引号是否匹配（简化检查，不考虑转义）\n\tquoteCount := strings.Count(trimmed, `\"`)\n\tif quoteCount%2 != 0 {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// Helper functions\nfunc truncateString(s string, maxLen int) string {\n\tif len(s) <= maxLen {\n\t\treturn s\n\t}\n\treturn s[:maxLen] + \"...\"\n}\n\nfunc maxInt(a, b int) int {\n\tif a > b {\n\t\treturn a\n\t}\n\treturn b\n}\n\nfunc minInt(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}\n"
  },
  {
    "path": "pkg/utils/json_parser_test.go",
    "content": "package utils\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n)\n\n// TestAttemptJSONRepairExcessBraces tests fixing JSON with excess closing braces\n// This is the fix for issue #28: AI sometimes returns JSON with extra closing braces\nfunc TestAttemptJSONRepairExcessBraces(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname: \"normal JSON\",\n\t\t\tinput: `{\"backgrounds\": [{\"location\": \"test\", \"prompt\": \"hello\"}]}`,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"extra closing brace - issue #28 case\",\n\t\t\tinput: `{\"backgrounds\": [{\"location\": \"test\", \"prompt\": \"hello\"}]}}`,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"extra closing bracket\",\n\t\t\tinput: `{\"backgrounds\": [{\"location\": \"test\", \"prompt\": \"hello\"}]]}`,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple extra closing braces\",\n\t\t\tinput: `{\"backgrounds\": [{\"location\": \"test\", \"prompt\": \"hello\"}]}}}`,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"missing closing brace\",\n\t\t\tinput: `{\"backgrounds\": [{\"location\": \"test\", \"prompt\": \"hello\"}]`,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"missing closing bracket\",\n\t\t\tinput: `{\"backgrounds\": [{\"location\": \"test\", \"prompt\": \"hello\"}`,\n\t\t\twantErr: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar result struct {\n\t\t\t\tBackgrounds []struct {\n\t\t\t\t\tLocation string `json:\"location\"`\n\t\t\t\t\tPrompt   string `json:\"prompt\"`\n\t\t\t\t} `json:\"backgrounds\"`\n\t\t\t}\n\n\t\t\terr := SafeParseAIJSON(tt.input, &result)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"SafeParseAIJSON() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !tt.wantErr {\n\t\t\t\t// Verify the parsed result\n\t\t\t\tif len(result.Backgrounds) != 1 {\n\t\t\t\t\tt.Errorf(\"Expected 1 background, got %d\", len(result.Backgrounds))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif result.Backgrounds[0].Location != \"test\" {\n\t\t\t\t\tt.Errorf(\"Expected location 'test', got '%s'\", result.Backgrounds[0].Location)\n\t\t\t\t}\n\t\t\t\tif result.Backgrounds[0].Prompt != \"hello\" {\n\t\t\t\t\tt.Errorf(\"Expected prompt 'hello', got '%s'\", result.Backgrounds[0].Prompt)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestAttemptJSONRepairFunction tests the attemptJSONRepair function directly\nfunc TestAttemptJSONRepairFunction(t *testing.T) {\n\ttests := []struct {\n\t\tname   string\n\t\tinput  string\n\t\tvalid  bool\n\t}{\n\t\t{\n\t\t\tname:  \"fix extra closing brace\",\n\t\t\tinput: `{\"key\": \"value\"}}`,\n\t\t\tvalid: true,\n\t\t},\n\t\t{\n\t\t\tname:  \"fix extra closing bracket\",\n\t\t\tinput: `[\"item1\", \"item2\"]]`,\n\t\t\tvalid: true,\n\t\t},\n\t\t{\n\t\t\tname:  \"fix missing closing brace\",\n\t\t\tinput: `{\"key\": \"value\"`,\n\t\t\tvalid: true,\n\t\t},\n\t\t{\n\t\t\tname:  \"fix missing closing bracket\",\n\t\t\tinput: `[\"item1\", \"item2\"`,\n\t\t\tvalid: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\trepaired := attemptJSONRepair(tt.input)\n\t\t\tvar js json.RawMessage\n\t\t\terr := json.Unmarshal([]byte(repaired), &js)\n\t\t\tif tt.valid && err != nil {\n\t\t\t\tt.Errorf(\"attemptJSONRepair() failed to produce valid JSON: %v\\nInput: %s\\nOutput: %s\", err, tt.input, repaired)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/video/chatfire_client.go",
    "content": "package video\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n)\n\n// ChatfireClient Chatfire 视频生成客户端\ntype ChatfireClient struct {\n\tBaseURL       string\n\tAPIKey        string\n\tModel         string\n\tEndpoint      string\n\tQueryEndpoint string\n\tHTTPClient    *http.Client\n}\n\ntype ChatfireRequest struct {\n\tModel    string `json:\"model\"`\n\tPrompt   string `json:\"prompt\"`\n\tImageURL string `json:\"image_url,omitempty\"`\n\tDuration int    `json:\"duration,omitempty\"`\n\tSize     string `json:\"size,omitempty\"`\n}\n\n// ChatfireSoraRequest Sora 模型请求格式\ntype ChatfireSoraRequest struct {\n\tModel          string `json:\"model\"`\n\tPrompt         string `json:\"prompt\"`\n\tSeconds        string `json:\"seconds,omitempty\"`\n\tSize           string `json:\"size,omitempty\"`\n\tInputReference string `json:\"input_reference,omitempty\"`\n}\n\n// ChatfireDoubaoRequest 豆包/火山模型请求格式\ntype ChatfireDoubaoRequest struct {\n\tModel   string `json:\"model\"`\n\tContent []struct {\n\t\tType     string                 `json:\"type\"`\n\t\tText     string                 `json:\"text,omitempty\"`\n\t\tImageURL map[string]interface{} `json:\"image_url,omitempty\"`\n\t\tRole     string                 `json:\"role,omitempty\"`\n\t} `json:\"content\"`\n}\n\ntype ChatfireResponse struct {\n\tID     string          `json:\"id\"`\n\tTaskID string          `json:\"task_id,omitempty\"`\n\tStatus string          `json:\"status,omitempty\"`\n\tError  json.RawMessage `json:\"error,omitempty\"`\n\tData   struct {\n\t\tID       string `json:\"id,omitempty\"`\n\t\tStatus   string `json:\"status,omitempty\"`\n\t\tVideoURL string `json:\"video_url,omitempty\"`\n\t} `json:\"data,omitempty\"`\n}\n\ntype ChatfireTaskResponse struct {\n\tID       string          `json:\"id,omitempty\"`\n\tTaskID   string          `json:\"task_id,omitempty\"`\n\tStatus   string          `json:\"status,omitempty\"`\n\tVideoURL string          `json:\"video_url,omitempty\"`\n\tError    json.RawMessage `json:\"error,omitempty\"`\n\tData     struct {\n\t\tID       string `json:\"id,omitempty\"`\n\t\tStatus   string `json:\"status,omitempty\"`\n\t\tVideoURL string `json:\"video_url,omitempty\"`\n\t} `json:\"data,omitempty\"`\n\tContent struct {\n\t\tVideoURL string `json:\"video_url,omitempty\"`\n\t} `json:\"content,omitempty\"`\n}\n\n// getErrorMessage 从 error 字段提取错误信息（支持字符串或对象）\nfunc getErrorMessage(errorData json.RawMessage) string {\n\tif len(errorData) == 0 {\n\t\treturn \"\"\n\t}\n\n\t// 尝试解析为字符串\n\tvar errStr string\n\tif err := json.Unmarshal(errorData, &errStr); err == nil {\n\t\treturn errStr\n\t}\n\n\t// 尝试解析为对象\n\tvar errObj struct {\n\t\tMessage string `json:\"message\"`\n\t\tCode    string `json:\"code\"`\n\t}\n\tif err := json.Unmarshal(errorData, &errObj); err == nil {\n\t\tif errObj.Message != \"\" {\n\t\t\treturn errObj.Message\n\t\t}\n\t}\n\n\t// 返回原始 JSON 字符串\n\treturn string(errorData)\n}\n\nfunc NewChatfireClient(baseURL, apiKey, model, endpoint, queryEndpoint string) *ChatfireClient {\n\tif endpoint == \"\" {\n\t\tendpoint = \"/video/generations\"\n\t}\n\tif queryEndpoint == \"\" {\n\t\tqueryEndpoint = \"/video/task/{taskId}\"\n\t}\n\treturn &ChatfireClient{\n\t\tBaseURL:       baseURL,\n\t\tAPIKey:        apiKey,\n\t\tModel:         model,\n\t\tEndpoint:      endpoint,\n\t\tQueryEndpoint: queryEndpoint,\n\t\tHTTPClient: &http.Client{\n\t\t\tTimeout: 300 * time.Second,\n\t\t},\n\t}\n}\n\nfunc (c *ChatfireClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) {\n\toptions := &VideoOptions{\n\t\tDuration:    5,\n\t\tAspectRatio: \"16:9\",\n\t}\n\n\tfor _, opt := range opts {\n\t\topt(options)\n\t}\n\n\tmodel := c.Model\n\tif options.Model != \"\" {\n\t\tmodel = options.Model\n\t}\n\n\t// 根据模型名称选择请求格式\n\tvar jsonData []byte\n\tvar err error\n\n\tif strings.Contains(model, \"doubao\") || strings.Contains(model, \"seedance\") {\n\t\t// 豆包/火山格式\n\t\treqBody := ChatfireDoubaoRequest{\n\t\t\tModel: model,\n\t\t}\n\n\t\t// 构建prompt文本（包含duration和ratio参数）\n\t\tpromptText := prompt\n\t\tif options.AspectRatio != \"\" {\n\t\t\tpromptText += fmt.Sprintf(\"  --ratio %s\", options.AspectRatio)\n\t\t}\n\t\tif options.Duration > 0 {\n\t\t\tpromptText += fmt.Sprintf(\"  --dur %d\", options.Duration)\n\t\t}\n\n\t\t// 添加文本内容\n\t\treqBody.Content = append(reqBody.Content, struct {\n\t\t\tType     string                 `json:\"type\"`\n\t\t\tText     string                 `json:\"text,omitempty\"`\n\t\t\tImageURL map[string]interface{} `json:\"image_url,omitempty\"`\n\t\t\tRole     string                 `json:\"role,omitempty\"`\n\t\t}{Type: \"text\", Text: promptText})\n\n\t\t// 处理不同的图片模式\n\t\t// 1. 组图模式（多个reference_image）\n\t\tif len(options.ReferenceImageURLs) > 0 {\n\t\t\tfor _, refURL := range options.ReferenceImageURLs {\n\t\t\t\treqBody.Content = append(reqBody.Content, struct {\n\t\t\t\t\tType     string                 `json:\"type\"`\n\t\t\t\t\tText     string                 `json:\"text,omitempty\"`\n\t\t\t\t\tImageURL map[string]interface{} `json:\"image_url,omitempty\"`\n\t\t\t\t\tRole     string                 `json:\"role,omitempty\"`\n\t\t\t\t}{\n\t\t\t\t\tType: \"image_url\",\n\t\t\t\t\tImageURL: map[string]interface{}{\n\t\t\t\t\t\t\"url\": refURL,\n\t\t\t\t\t},\n\t\t\t\t\tRole: \"reference_image\",\n\t\t\t\t})\n\t\t\t}\n\t\t} else if options.FirstFrameURL != \"\" && options.LastFrameURL != \"\" {\n\t\t\t// 2. 首尾帧模式\n\t\t\treqBody.Content = append(reqBody.Content, struct {\n\t\t\t\tType     string                 `json:\"type\"`\n\t\t\t\tText     string                 `json:\"text,omitempty\"`\n\t\t\t\tImageURL map[string]interface{} `json:\"image_url,omitempty\"`\n\t\t\t\tRole     string                 `json:\"role,omitempty\"`\n\t\t\t}{\n\t\t\t\tType: \"image_url\",\n\t\t\t\tImageURL: map[string]interface{}{\n\t\t\t\t\t\"url\": options.FirstFrameURL,\n\t\t\t\t},\n\t\t\t\tRole: \"first_frame\",\n\t\t\t})\n\t\t\treqBody.Content = append(reqBody.Content, struct {\n\t\t\t\tType     string                 `json:\"type\"`\n\t\t\t\tText     string                 `json:\"text,omitempty\"`\n\t\t\t\tImageURL map[string]interface{} `json:\"image_url,omitempty\"`\n\t\t\t\tRole     string                 `json:\"role,omitempty\"`\n\t\t\t}{\n\t\t\t\tType: \"image_url\",\n\t\t\t\tImageURL: map[string]interface{}{\n\t\t\t\t\t\"url\": options.LastFrameURL,\n\t\t\t\t},\n\t\t\t\tRole: \"last_frame\",\n\t\t\t})\n\t\t} else if imageURL != \"\" {\n\t\t\t// 3. 单图模式（默认）\n\t\t\treqBody.Content = append(reqBody.Content, struct {\n\t\t\t\tType     string                 `json:\"type\"`\n\t\t\t\tText     string                 `json:\"text,omitempty\"`\n\t\t\t\tImageURL map[string]interface{} `json:\"image_url,omitempty\"`\n\t\t\t\tRole     string                 `json:\"role,omitempty\"`\n\t\t\t}{\n\t\t\t\tType: \"image_url\",\n\t\t\t\tImageURL: map[string]interface{}{\n\t\t\t\t\t\"url\": imageURL,\n\t\t\t\t},\n\t\t\t\t// 单图模式不需要role\n\t\t\t})\n\t\t} else if options.FirstFrameURL != \"\" {\n\t\t\t// 4. 只有首帧\n\t\t\treqBody.Content = append(reqBody.Content, struct {\n\t\t\t\tType     string                 `json:\"type\"`\n\t\t\t\tText     string                 `json:\"text,omitempty\"`\n\t\t\t\tImageURL map[string]interface{} `json:\"image_url,omitempty\"`\n\t\t\t\tRole     string                 `json:\"role,omitempty\"`\n\t\t\t}{\n\t\t\t\tType: \"image_url\",\n\t\t\t\tImageURL: map[string]interface{}{\n\t\t\t\t\t\"url\": options.FirstFrameURL,\n\t\t\t\t},\n\t\t\t\tRole: \"first_frame\",\n\t\t\t})\n\t\t}\n\n\t\tjsonData, err = json.Marshal(reqBody)\n\t} else if strings.Contains(model, \"sora\") {\n\t\t// Sora 格式\n\t\tseconds := fmt.Sprintf(\"%d\", options.Duration)\n\t\tsize := options.AspectRatio\n\t\tif size == \"16:9\" {\n\t\t\tsize = \"1280x720\"\n\t\t} else if size == \"9:16\" {\n\t\t\tsize = \"720x1280\"\n\t\t}\n\n\t\treqBody := ChatfireSoraRequest{\n\t\t\tModel:          model,\n\t\t\tPrompt:         prompt,\n\t\t\tSeconds:        seconds,\n\t\t\tSize:           size,\n\t\t\tInputReference: imageURL,\n\t\t}\n\t\tjsonData, err = json.Marshal(reqBody)\n\t} else {\n\t\t// 默认格式\n\t\treqBody := ChatfireRequest{\n\t\t\tModel:    model,\n\t\t\tPrompt:   prompt,\n\t\t\tImageURL: imageURL,\n\t\t\tDuration: options.Duration,\n\t\t\tSize:     options.AspectRatio,\n\t\t}\n\t\tjsonData, err = json.Marshal(reqBody)\n\t}\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"marshal request: %w\", err)\n\t}\n\n\tendpoint := c.BaseURL + c.Endpoint\n\treq, err := http.NewRequest(\"POST\", endpoint, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {\n\t\treturn nil, fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\t// 调试日志：打印响应内容\n\tfmt.Printf(\"[Chatfire] Response body: %s\\n\", string(body))\n\n\tvar result ChatfireResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse response: %w, body: %s\", err, string(body))\n\t}\n\n\t// 优先使用 id 字段，其次使用 task_id\n\ttaskID := result.ID\n\tif taskID == \"\" {\n\t\ttaskID = result.TaskID\n\t}\n\n\t// 如果有 data 嵌套，优先使用 data 中的值\n\tif result.Data.ID != \"\" {\n\t\ttaskID = result.Data.ID\n\t}\n\n\tstatus := result.Status\n\tif status == \"\" && result.Data.Status != \"\" {\n\t\tstatus = result.Data.Status\n\t}\n\n\tfmt.Printf(\"[Chatfire] Parsed result - TaskID: %s, Status: %s\\n\", taskID, status)\n\n\tif errMsg := getErrorMessage(result.Error); errMsg != \"\" {\n\t\treturn nil, fmt.Errorf(\"chatfire error: %s\", errMsg)\n\t}\n\n\tvideoResult := &VideoResult{\n\t\tTaskID:    taskID,\n\t\tStatus:    status,\n\t\tCompleted: status == \"completed\" || status == \"succeeded\",\n\t\tDuration:  options.Duration,\n\t}\n\n\treturn videoResult, nil\n}\n\nfunc (c *ChatfireClient) GetTaskStatus(taskID string) (*VideoResult, error) {\n\tqueryPath := c.QueryEndpoint\n\tif strings.Contains(queryPath, \"{taskId}\") {\n\t\tqueryPath = strings.ReplaceAll(queryPath, \"{taskId}\", taskID)\n\t} else if strings.Contains(queryPath, \"{task_id}\") {\n\t\tqueryPath = strings.ReplaceAll(queryPath, \"{task_id}\", taskID)\n\t} else {\n\t\tqueryPath = queryPath + \"/\" + taskID\n\t}\n\n\tendpoint := c.BaseURL + queryPath\n\treq, err := http.NewRequest(\"GET\", endpoint, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\t// 调试日志：打印响应内容\n\tfmt.Printf(\"[Chatfire] GetTaskStatus Response body: %s\\n\", string(body))\n\n\tvar result ChatfireTaskResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse response: %w, body: %s\", err, string(body))\n\t}\n\n\t// 优先使用 id 字段，其次使用 task_id\n\tresponseTaskID := result.ID\n\tif responseTaskID == \"\" {\n\t\tresponseTaskID = result.TaskID\n\t}\n\n\t// 如果有 data 嵌套，优先使用 data 中的值\n\tif result.Data.ID != \"\" {\n\t\tresponseTaskID = result.Data.ID\n\t}\n\n\tstatus := result.Status\n\tif status == \"\" && result.Data.Status != \"\" {\n\t\tstatus = result.Data.Status\n\t}\n\n\t// 按优先级获取 video_url：VideoURL -> Data.VideoURL -> Content.VideoURL\n\tvideoURL := result.VideoURL\n\tif videoURL == \"\" && result.Data.VideoURL != \"\" {\n\t\tvideoURL = result.Data.VideoURL\n\t}\n\tif videoURL == \"\" && result.Content.VideoURL != \"\" {\n\t\tvideoURL = result.Content.VideoURL\n\t}\n\n\tfmt.Printf(\"[Chatfire] Parsed result - TaskID: %s, Status: %s, VideoURL: %s\\n\", responseTaskID, status, videoURL)\n\n\tvideoResult := &VideoResult{\n\t\tTaskID:    responseTaskID,\n\t\tStatus:    status,\n\t\tCompleted: status == \"completed\" || status == \"succeeded\",\n\t}\n\n\tif errMsg := getErrorMessage(result.Error); errMsg != \"\" {\n\t\tvideoResult.Error = errMsg\n\t}\n\n\tif videoURL != \"\" {\n\t\tvideoResult.VideoURL = videoURL\n\t\tvideoResult.Completed = true\n\t}\n\n\treturn videoResult, nil\n}\n"
  },
  {
    "path": "pkg/video/minimax_client.go",
    "content": "package video\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n)\n\n// MiniMax Hailuo 支持的模型\nconst (\n\t// ModelHailuo23 全新视频生成模型，肢体动作、面部表情、物理表现与指令遵循再度突破\n\t// 支持：文生视频、图生视频\n\t// 时长：768P(6s/10s), 1080P(6s)\n\tModelHailuo23 = \"MiniMax-Hailuo-2.3\"\n\n\t// ModelHailuo23Fast 全新图生视频模型，物理表现与指令遵循具佳，更快更优惠\n\t// 支持：图生视频\n\t// 时长：768P(6s/10s), 1080P(6s)\n\tModelHailuo23Fast = \"MiniMax-Hailuo-2.3-Fast\"\n\n\t// ModelHailuo02 新一代视频生成模型，1080p 原生，SOTA 指令遵循，极致物理表现\n\t// 支持：文生视频、图生视频、首尾帧模式\n\t// 时长：768P(6s/10s), 1080P(6s)\n\tModelHailuo02 = \"MiniMax-Hailuo-02\"\n)\n\n// MiniMax Hailuo 支持的分辨率\nconst (\n\tResolution768P  = \"768P\"\n\tResolution1080P = \"1080P\"\n)\n\n// MiniMax Hailuo 支持的时长（秒）\nconst (\n\tDuration6s  = 6\n\tDuration10s = 10\n)\n\n// MinimaxClient Minimax视频生成客户端\ntype MinimaxClient struct {\n\tBaseURL    string\n\tAPIKey     string\n\tModel      string\n\tHTTPClient *http.Client\n}\n\ntype MinimaxSubjectReference struct {\n\tType  string   `json:\"type\"`\n\tImage []string `json:\"image\"`\n}\n\ntype MinimaxRequest struct {\n\tPrompt           string                    `json:\"prompt\"`\n\tFirstFrameImage  string                    `json:\"first_frame_image,omitempty\"`\n\tLastFrameImage   string                    `json:\"last_frame_image,omitempty\"`\n\tSubjectReference []MinimaxSubjectReference `json:\"subject_reference,omitempty\"`\n\tModel            string                    `json:\"model\"`\n\tDuration         int                       `json:\"duration,omitempty\"`\n\tResolution       string                    `json:\"resolution,omitempty\"`\n}\n\n// MinimaxCreateResponse 创建任务的响应\ntype MinimaxCreateResponse struct {\n\tTaskID   string `json:\"task_id\"`\n\tBaseResp struct {\n\t\tStatusCode int    `json:\"status_code\"`\n\t\tStatusMsg  string `json:\"status_msg\"`\n\t} `json:\"base_resp\"`\n}\n\n// MinimaxQueryResponse 查询任务状态的响应\ntype MinimaxQueryResponse struct {\n\tTaskID      string `json:\"task_id\"`\n\tStatus      string `json:\"status\"` // Processing, Success, Failed\n\tFileID      string `json:\"file_id\"`\n\tVideoWidth  int    `json:\"video_width\"`\n\tVideoHeight int    `json:\"video_height\"`\n\tBaseResp    struct {\n\t\tStatusCode int    `json:\"status_code\"`\n\t\tStatusMsg  string `json:\"status_msg\"`\n\t} `json:\"base_resp\"`\n}\n\n// MinimaxFileResponse 获取文件信息的响应\ntype MinimaxFileResponse struct {\n\tFile struct {\n\t\tFileID      interface{} `json:\"file_id\"` // 可能是 string 或 number\n\t\tBytes       int         `json:\"bytes\"`\n\t\tCreatedAt   int64       `json:\"created_at\"`\n\t\tFilename    string      `json:\"filename\"`\n\t\tPurpose     string      `json:\"purpose\"`\n\t\tDownloadURL string      `json:\"download_url\"`\n\t} `json:\"file\"`\n\tBaseResp struct {\n\t\tStatusCode int    `json:\"status_code\"`\n\t\tStatusMsg  string `json:\"status_msg\"`\n\t} `json:\"base_resp\"`\n}\n\nfunc NewMinimaxClient(baseURL, apiKey, model string) *MinimaxClient {\n\treturn &MinimaxClient{\n\t\tBaseURL: baseURL,\n\t\tAPIKey:  apiKey,\n\t\tModel:   model,\n\t\tHTTPClient: &http.Client{\n\t\t\tTimeout: 300 * time.Second,\n\t\t},\n\t}\n}\n\n// GenerateVideo 生成视频（支持首尾帧和主体参考）\n// 步骤1：创建任务，返回 task_id\nfunc (c *MinimaxClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) {\n\toptions := &VideoOptions{\n\t\tDuration:   6,\n\t\tResolution: \"1080P\",\n\t}\n\n\tfor _, opt := range opts {\n\t\topt(options)\n\t}\n\n\tmodel := c.Model\n\tif options.Model != \"\" {\n\t\tmodel = options.Model\n\t}\n\n\treqBody := MinimaxRequest{\n\t\tPrompt:   prompt,\n\t\tModel:    model,\n\t\tDuration: options.Duration,\n\t}\n\n\t// 设置分辨率\n\tif options.Resolution != \"\" {\n\t\treqBody.Resolution = options.Resolution\n\t}\n\n\t// 支持首帧图片\n\tif options.FirstFrameURL != \"\" {\n\t\treqBody.FirstFrameImage = options.FirstFrameURL\n\t} else if imageURL != \"\" {\n\t\treqBody.FirstFrameImage = imageURL\n\t}\n\n\t// 支持尾帧图片\n\tif options.LastFrameURL != \"\" {\n\t\treqBody.LastFrameImage = options.LastFrameURL\n\t}\n\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"marshal request: %w\", err)\n\t}\n\n\t// 步骤1：创建任务，POST 请求\n\t// 注意：BaseURL 应该已包含 /v1，例如 https://api.minimaxi.com/v1\n\tendpoint := c.BaseURL + \"/video_generation\"\n\treq, err := http.NewRequest(\"POST\", endpoint, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {\n\t\treturn nil, fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\tvar result MinimaxCreateResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse response: %w\", err)\n\t}\n\n\tif result.BaseResp.StatusCode != 0 {\n\t\treturn nil, fmt.Errorf(\"minimax error: %s\", result.BaseResp.StatusMsg)\n\t}\n\n\t// 第一步只返回 task_id，状态为 Processing\n\tvideoResult := &VideoResult{\n\t\tTaskID:    result.TaskID,\n\t\tStatus:    \"Processing\",\n\t\tCompleted: false,\n\t}\n\n\treturn videoResult, nil\n}\n\n// GetTaskStatus 查询任务状态\n// 步骤2：查询任务状态，如果成功则进入步骤3获取文件下载地址\nfunc (c *MinimaxClient) GetTaskStatus(taskID string) (*VideoResult, error) {\n\t// 步骤2：查询任务状态\n\t// 注意：BaseURL 应该已包含 /v1\n\tendpoint := fmt.Sprintf(\"%s/query/video_generation?task_id=%s\", c.BaseURL, taskID)\n\treq, err := http.NewRequest(\"GET\", endpoint, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\tvar queryResult MinimaxQueryResponse\n\tif err := json.Unmarshal(body, &queryResult); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse response: %w\", err)\n\t}\n\n\tif queryResult.BaseResp.StatusCode != 0 {\n\t\treturn nil, fmt.Errorf(\"minimax error: %s\", queryResult.BaseResp.StatusMsg)\n\t}\n\n\tvideoResult := &VideoResult{\n\t\tTaskID:    queryResult.TaskID,\n\t\tStatus:    queryResult.Status,\n\t\tWidth:     queryResult.VideoWidth,\n\t\tHeight:    queryResult.VideoHeight,\n\t\tCompleted: false,\n\t}\n\n\t// 如果状态是 Success 且有 file_id，则获取文件下载地址\n\tif queryResult.Status == \"Success\" && queryResult.FileID != \"\" {\n\t\tdownloadURL, err := c.getFileDownloadURL(queryResult.FileID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to get download URL: %w\", err)\n\t\t}\n\t\tvideoResult.VideoURL = downloadURL\n\t\tvideoResult.Completed = true\n\t} else if queryResult.Status == \"Failed\" {\n\t\tvideoResult.Error = \"Video generation failed\"\n\t\tvideoResult.Completed = true\n\t}\n\n\treturn videoResult, nil\n}\n\n// getFileDownloadURL 步骤3：根据 file_id 获取文件下载地址\nfunc (c *MinimaxClient) getFileDownloadURL(fileID string) (string, error) {\n\t// 注意：BaseURL 应该已包含 /v1\n\tendpoint := fmt.Sprintf(\"%s/files/retrieve?file_id=%s\", c.BaseURL, fileID)\n\treq, err := http.NewRequest(\"GET\", endpoint, nil)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\tvar fileResult MinimaxFileResponse\n\tif err := json.Unmarshal(body, &fileResult); err != nil {\n\t\treturn \"\", fmt.Errorf(\"parse response: %w\", err)\n\t}\n\n\tif fileResult.BaseResp.StatusCode != 0 {\n\t\treturn \"\", fmt.Errorf(\"minimax error: %s\", fileResult.BaseResp.StatusMsg)\n\t}\n\n\treturn fileResult.File.DownloadURL, nil\n}\n"
  },
  {
    "path": "pkg/video/openai_sora_client.go",
    "content": "package video\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime/multipart\"\n\t\"net/http\"\n\t\"net/textproto\" // Added for explicit MIME header control\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype OpenAISoraClient struct {\n\tBaseURL    string\n\tAPIKey     string\n\tModel      string\n\tHTTPClient *http.Client\n}\n\ntype OpenAISoraResponse struct {\n\tID          string `json:\"id\"`\n\tObject      string `json:\"object\"`\n\tModel       string `json:\"model\"`\n\tStatus      string `json:\"status\"`\n\tProgress    int    `json:\"progress\"`\n\tCreatedAt   int64  `json:\"created_at\"`\n\tCompletedAt int64  `json:\"completed_at\"`\n\tSize        string `json:\"size\"`\n\tSeconds     string `json:\"seconds\"`\n\tQuality     string `json:\"quality\"`\n\tVideoURL    string `json:\"video_url\"` // 直接的video_url字段\n\tVideo       struct {\n\t\tURL string `json:\"url\"`\n\t} `json:\"video\"` // 嵌套的video.url字段（兼容）\n\tError struct {\n\t\tMessage string `json:\"message\"`\n\t\tType    string `json:\"type\"`\n\t} `json:\"error\"`\n}\n\nfunc NewOpenAISoraClient(baseURL, apiKey, model string) *OpenAISoraClient {\n\treturn &OpenAISoraClient{\n\t\tBaseURL: baseURL,\n\t\tAPIKey:  apiKey,\n\t\tModel:   model,\n\t\tHTTPClient: &http.Client{\n\t\t\tTimeout: 300 * time.Second,\n\t\t},\n\t}\n}\n\nfunc (c *OpenAISoraClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) {\n\toptions := &VideoOptions{\n\t\tDuration: 4,\n\t}\n\n\tfor _, opt := range opts {\n\t\topt(options)\n\t}\n\n\tmodel := c.Model\n\tif options.Model != \"\" {\n\t\tmodel = options.Model\n\t}\n\n\tbody := &bytes.Buffer{}\n\twriter := multipart.NewWriter(body)\n\n\t// Add basic fields\n\twriter.WriteField(\"model\", model)\n\twriter.WriteField(\"prompt\", prompt)\n\n\tif options.Duration > 0 {\n\t\twriter.WriteField(\"seconds\", fmt.Sprintf(\"%d\", options.Duration))\n\t}\n\n\tif options.Resolution != \"\" {\n\t\twriter.WriteField(\"size\", options.Resolution)\n\t}\n\n\t// [PR FIX START]\n\t// The OpenAI Sora API requires 'input_reference' to be a file upload (binary), not a URL string\n\t// set the Content-Type header (e.g., image/png) or the API returns 400\n\tif imageURL != \"\" {\n\t\tvar imageData []byte\n\t\tvar mimeType string\n\t\tvar filename string = \"reference_image.png\"\n\n\t\tif strings.HasPrefix(imageURL, \"data:\") {\n\t\t\t// Case A: Handle Base64 Data URI (often stored in DB)\n\t\t\tparts := strings.Split(imageURL, \",\")\n\t\t\tif len(parts) != 2 {\n\t\t\t\treturn nil, fmt.Errorf(\"invalid data URI format\")\n\t\t\t}\n\n\t\t\t// Extract mime type from header (e.g., \"data:image/jpeg;base64\")\n\t\t\theader := parts[0]\n\t\t\tif strings.Contains(header, \"image/jpeg\") || strings.Contains(header, \"image/jpg\") {\n\t\t\t\tmimeType = \"image/jpeg\"\n\t\t\t\tfilename = \"reference.jpg\"\n\t\t\t} else if strings.Contains(header, \"image/png\") {\n\t\t\t\tmimeType = \"image/png\"\n\t\t\t\tfilename = \"reference.png\"\n\t\t\t} else if strings.Contains(header, \"image/webp\") {\n\t\t\t\tmimeType = \"image/webp\"\n\t\t\t\tfilename = \"reference.webp\"\n\t\t\t} else {\n\t\t\t\tmimeType = \"image/png\" // Default fallback\n\t\t\t}\n\n\t\t\tdecoded, err := base64.StdEncoding.DecodeString(parts[1])\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to decode base64 image: %w\", err)\n\t\t\t}\n\t\t\timageData = decoded\n\n\t\t} else {\n\t\t\t// Case B: Handle Standard HTTP/HTTPS URL\n\t\t\tresp, err := http.Get(imageURL)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to download reference image: %w\", err)\n\t\t\t}\n\t\t\tdefer resp.Body.Close()\n\n\t\t\tif resp.StatusCode != http.StatusOK {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to download reference image, status: %d\", resp.StatusCode)\n\t\t\t}\n\n\t\t\tdata, err := io.ReadAll(resp.Body)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to read downloaded image: %w\", err)\n\t\t\t}\n\t\t\timageData = data\n\n\t\t\t// Use the Content-Type header from the response\n\t\t\tmimeType = resp.Header.Get(\"Content-Type\")\n\n\t\t\t// Fallback/Correction if server sends bad headers\n\t\t\tif mimeType == \"\" || mimeType == \"application/octet-stream\" {\n\t\t\t\text := filepath.Ext(imageURL)\n\t\t\t\tswitch strings.ToLower(ext) {\n\t\t\t\tcase \".jpg\", \".jpeg\":\n\t\t\t\t\tmimeType = \"image/jpeg\"\n\t\t\t\tcase \".png\":\n\t\t\t\t\tmimeType = \"image/png\"\n\t\t\t\tcase \".webp\":\n\t\t\t\t\tmimeType = \"image/webp\"\n\t\t\t\tdefault:\n\t\t\t\t\tmimeType = \"image/png\"\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Ensure filename has extension\n\t\t\tbase := filepath.Base(imageURL)\n\t\t\tif base != \"\" && base != \".\" {\n\t\t\t\tif idx := strings.Index(base, \"?\"); idx != -1 {\n\t\t\t\t\tbase = base[:idx]\n\t\t\t\t}\n\t\t\t\tfilename = base\n\t\t\t}\n\t\t}\n\n\t\t// Create the MIME Header manually to force the Content-Type.\n\t\t// Standard writer.CreateFormFile does not set Content-Type, causing \"unsupported mimetype\" errors.\n\t\th := make(textproto.MIMEHeader)\n\t\th.Set(\"Content-Disposition\", fmt.Sprintf(`form-data; name=\"input_reference\"; filename=\"%s\"`, filename))\n\t\th.Set(\"Content-Type\", mimeType)\n\n\t\tpart, err := writer.CreatePart(h)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"create part: %w\", err)\n\t\t}\n\t\tif _, err := part.Write(imageData); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"write image data: %w\", err)\n\t\t}\n\t}\n\t// [PR FIX END]\n\n\twriter.Close()\n\n\tendpoint := c.BaseURL + \"/videos\"\n\treq, err := http.NewRequest(\"POST\", endpoint, body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", writer.FormDataContentType())\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\trespBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {\n\t\treturn nil, fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, string(respBody))\n\t}\n\n\tvar result OpenAISoraResponse\n\tif err := json.Unmarshal(respBody, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse response: %w\", err)\n\t}\n\n\tif result.Error.Message != \"\" {\n\t\treturn nil, fmt.Errorf(\"openai error: %s\", result.Error.Message)\n\t}\n\n\tvideoResult := &VideoResult{\n\t\tTaskID:    result.ID,\n\t\tStatus:    result.Status,\n\t\tCompleted: result.Status == \"completed\",\n\t}\n\n\t// 优先使用video_url字段，兼容video.url嵌套结构\n\tif result.VideoURL != \"\" {\n\t\tvideoResult.VideoURL = result.VideoURL\n\t} else if result.Video.URL != \"\" {\n\t\tvideoResult.VideoURL = result.Video.URL\n\t}\n\n\treturn videoResult, nil\n}\n\nfunc (c *OpenAISoraClient) GetTaskStatus(taskID string) (*VideoResult, error) {\n\tendpoint := c.BaseURL + \"/videos/\" + taskID\n\treq, err := http.NewRequest(\"GET\", endpoint, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tvar result OpenAISoraResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse response: %w\", err)\n\t}\n\n\tvideoResult := &VideoResult{\n\t\tTaskID:    result.ID,\n\t\tStatus:    result.Status,\n\t\tCompleted: result.Status == \"completed\",\n\t}\n\n\tif result.Error.Message != \"\" {\n\t\tvideoResult.Error = result.Error.Message\n\t}\n\n\t// 优先使用video_url字段，兼容video.url嵌套结构\n\tif result.VideoURL != \"\" {\n\t\tvideoResult.VideoURL = result.VideoURL\n\t} else if result.Video.URL != \"\" {\n\t\tvideoResult.VideoURL = result.Video.URL\n\t}\n\n\treturn videoResult, nil\n}"
  },
  {
    "path": "pkg/video/video_client.go",
    "content": "package video\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n)\n\ntype VideoClient interface {\n\tGenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error)\n\tGetTaskStatus(taskID string) (*VideoResult, error)\n}\n\ntype VideoResult struct {\n\tTaskID       string\n\tStatus       string\n\tVideoURL     string\n\tThumbnailURL string\n\tDuration     int\n\tWidth        int\n\tHeight       int\n\tError        string\n\tCompleted    bool\n}\n\ntype VideoOptions struct {\n\tModel              string\n\tDuration           int\n\tFPS                int\n\tResolution         string\n\tAspectRatio        string\n\tStyle              string\n\tMotionLevel        int\n\tCameraMotion       string\n\tSeed               int64\n\tFirstFrameURL      string\n\tLastFrameURL       string\n\tReferenceImageURLs []string\n}\n\ntype VideoOption func(*VideoOptions)\n\nfunc WithModel(model string) VideoOption {\n\treturn func(o *VideoOptions) {\n\t\to.Model = model\n\t}\n}\n\nfunc WithDuration(duration int) VideoOption {\n\treturn func(o *VideoOptions) {\n\t\to.Duration = duration\n\t}\n}\n\nfunc WithFPS(fps int) VideoOption {\n\treturn func(o *VideoOptions) {\n\t\to.FPS = fps\n\t}\n}\n\nfunc WithResolution(resolution string) VideoOption {\n\treturn func(o *VideoOptions) {\n\t\to.Resolution = resolution\n\t}\n}\n\nfunc WithAspectRatio(ratio string) VideoOption {\n\treturn func(o *VideoOptions) {\n\t\to.AspectRatio = ratio\n\t}\n}\n\nfunc WithStyle(style string) VideoOption {\n\treturn func(o *VideoOptions) {\n\t\to.Style = style\n\t}\n}\n\nfunc WithMotionLevel(level int) VideoOption {\n\treturn func(o *VideoOptions) {\n\t\to.MotionLevel = level\n\t}\n}\n\nfunc WithCameraMotion(motion string) VideoOption {\n\treturn func(o *VideoOptions) {\n\t\to.CameraMotion = motion\n\t}\n}\n\nfunc WithSeed(seed int64) VideoOption {\n\treturn func(o *VideoOptions) {\n\t\to.Seed = seed\n\t}\n}\n\nfunc WithFirstFrame(url string) VideoOption {\n\treturn func(o *VideoOptions) {\n\t\to.FirstFrameURL = url\n\t}\n}\n\nfunc WithLastFrame(url string) VideoOption {\n\treturn func(o *VideoOptions) {\n\t\to.LastFrameURL = url\n\t}\n}\n\nfunc WithReferenceImages(urls []string) VideoOption {\n\treturn func(o *VideoOptions) {\n\t\to.ReferenceImageURLs = urls\n\t}\n}\n\ntype RunwayClient struct {\n\tBaseURL    string\n\tAPIKey     string\n\tModel      string\n\tHTTPClient *http.Client\n}\n\ntype RunwayRequest struct {\n\tModel       string `json:\"model\"`\n\tPromptImage string `json:\"prompt_image\"`\n\tPromptText  string `json:\"prompt_text\"`\n\tDuration    int    `json:\"duration,omitempty\"`\n\tAspectRatio string `json:\"aspect_ratio,omitempty\"`\n\tSeed        int64  `json:\"seed,omitempty\"`\n}\n\ntype RunwayResponse struct {\n\tID     string `json:\"id\"`\n\tStatus string `json:\"status\"`\n\tOutput struct {\n\t\tURL string `json:\"url\"`\n\t} `json:\"output\"`\n\tError string `json:\"error,omitempty\"`\n}\n\nfunc NewRunwayClient(baseURL, apiKey, model string) *RunwayClient {\n\treturn &RunwayClient{\n\t\tBaseURL: baseURL,\n\t\tAPIKey:  apiKey,\n\t\tModel:   model,\n\t\tHTTPClient: &http.Client{\n\t\t\tTimeout: 180 * time.Second,\n\t\t},\n\t}\n}\n\nfunc (c *RunwayClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) {\n\toptions := &VideoOptions{\n\t\tDuration:    5,\n\t\tAspectRatio: \"16:9\",\n\t}\n\n\tfor _, opt := range opts {\n\t\topt(options)\n\t}\n\n\tmodel := c.Model\n\tif options.Model != \"\" {\n\t\tmodel = options.Model\n\t}\n\n\treqBody := RunwayRequest{\n\t\tModel:       model,\n\t\tPromptImage: imageURL,\n\t\tPromptText:  prompt,\n\t\tDuration:    options.Duration,\n\t\tAspectRatio: options.AspectRatio,\n\t\tSeed:        options.Seed,\n\t}\n\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"marshal request: %w\", err)\n\t}\n\n\tendpoint := c.BaseURL + \"/v1/video/generate\"\n\treq, err := http.NewRequest(\"POST\", endpoint, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {\n\t\treturn nil, fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\tvar result RunwayResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse response: %w\", err)\n\t}\n\n\tif result.Error != \"\" {\n\t\treturn nil, fmt.Errorf(\"runway error: %s\", result.Error)\n\t}\n\n\tvideoResult := &VideoResult{\n\t\tTaskID:    result.ID,\n\t\tStatus:    result.Status,\n\t\tCompleted: result.Status == \"succeeded\",\n\t}\n\n\tif result.Output.URL != \"\" {\n\t\tvideoResult.VideoURL = result.Output.URL\n\t}\n\n\treturn videoResult, nil\n}\n\nfunc (c *RunwayClient) GetTaskStatus(taskID string) (*VideoResult, error) {\n\tendpoint := c.BaseURL + \"/v1/video/status/\" + taskID\n\treq, err := http.NewRequest(\"GET\", endpoint, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tvar result RunwayResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse response: %w\", err)\n\t}\n\n\tvideoResult := &VideoResult{\n\t\tTaskID:    result.ID,\n\t\tStatus:    result.Status,\n\t\tCompleted: result.Status == \"succeeded\",\n\t}\n\n\tif result.Error != \"\" {\n\t\tvideoResult.Error = result.Error\n\t}\n\n\tif result.Output.URL != \"\" {\n\t\tvideoResult.VideoURL = result.Output.URL\n\t}\n\n\treturn videoResult, nil\n}\n\ntype PikaClient struct {\n\tBaseURL    string\n\tAPIKey     string\n\tModel      string\n\tHTTPClient *http.Client\n}\n\ntype PikaRequest struct {\n\tModel        string `json:\"model\"`\n\tImage        string `json:\"image\"`\n\tPrompt       string `json:\"prompt\"`\n\tDuration     int    `json:\"duration,omitempty\"`\n\tAspectRatio  string `json:\"aspect_ratio,omitempty\"`\n\tMotion       int    `json:\"motion,omitempty\"`\n\tCameraMotion string `json:\"camera_motion,omitempty\"`\n\tSeed         int64  `json:\"seed,omitempty\"`\n}\n\ntype PikaResponse struct {\n\tJobID  string `json:\"job_id\"`\n\tStatus string `json:\"status\"`\n\tResult struct {\n\t\tVideoURL string `json:\"video_url\"`\n\t} `json:\"result\"`\n\tError string `json:\"error,omitempty\"`\n}\n\nfunc NewPikaClient(baseURL, apiKey, model string) *PikaClient {\n\treturn &PikaClient{\n\t\tBaseURL: baseURL,\n\t\tAPIKey:  apiKey,\n\t\tModel:   model,\n\t\tHTTPClient: &http.Client{\n\t\t\tTimeout: 180 * time.Second,\n\t\t},\n\t}\n}\n\nfunc (c *PikaClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) {\n\toptions := &VideoOptions{\n\t\tDuration:    3,\n\t\tAspectRatio: \"16:9\",\n\t\tMotionLevel: 50,\n\t}\n\n\tfor _, opt := range opts {\n\t\topt(options)\n\t}\n\n\tmodel := c.Model\n\tif options.Model != \"\" {\n\t\tmodel = options.Model\n\t}\n\n\treqBody := PikaRequest{\n\t\tModel:        model,\n\t\tImage:        imageURL,\n\t\tPrompt:       prompt,\n\t\tDuration:     options.Duration,\n\t\tAspectRatio:  options.AspectRatio,\n\t\tMotion:       options.MotionLevel,\n\t\tCameraMotion: options.CameraMotion,\n\t\tSeed:         options.Seed,\n\t}\n\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"marshal request: %w\", err)\n\t}\n\n\tendpoint := c.BaseURL + \"/v1/video/generate\"\n\treq, err := http.NewRequest(\"POST\", endpoint, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {\n\t\treturn nil, fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\tvar result PikaResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse response: %w\", err)\n\t}\n\n\tif result.Error != \"\" {\n\t\treturn nil, fmt.Errorf(\"pika error: %s\", result.Error)\n\t}\n\n\tvideoResult := &VideoResult{\n\t\tTaskID:    result.JobID,\n\t\tStatus:    result.Status,\n\t\tCompleted: result.Status == \"completed\",\n\t}\n\n\tif result.Result.VideoURL != \"\" {\n\t\tvideoResult.VideoURL = result.Result.VideoURL\n\t}\n\n\treturn videoResult, nil\n}\n\nfunc (c *PikaClient) GetTaskStatus(taskID string) (*VideoResult, error) {\n\tendpoint := c.BaseURL + \"/v1/video/status/\" + taskID\n\treq, err := http.NewRequest(\"GET\", endpoint, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tvar result PikaResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse response: %w\", err)\n\t}\n\n\tvideoResult := &VideoResult{\n\t\tTaskID:    result.JobID,\n\t\tStatus:    result.Status,\n\t\tCompleted: result.Status == \"completed\",\n\t}\n\n\tif result.Error != \"\" {\n\t\tvideoResult.Error = result.Error\n\t}\n\n\tif result.Result.VideoURL != \"\" {\n\t\tvideoResult.VideoURL = result.Result.VideoURL\n\t}\n\n\treturn videoResult, nil\n}\n"
  },
  {
    "path": "pkg/video/volces_ark_client.go",
    "content": "package video\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n)\n\n// VolcesArkClient 火山引擎ARK视频生成客户端\ntype VolcesArkClient struct {\n\tBaseURL       string\n\tAPIKey        string\n\tModel         string\n\tEndpoint      string\n\tQueryEndpoint string\n\tHTTPClient    *http.Client\n}\n\ntype VolcesArkContent struct {\n\tType     string                 `json:\"type\"`\n\tText     string                 `json:\"text,omitempty\"`\n\tImageURL map[string]interface{} `json:\"image_url,omitempty\"`\n\tRole     string                 `json:\"role,omitempty\"`\n}\n\ntype VolcesArkRequest struct {\n\tModel         string             `json:\"model\"`\n\tContent       []VolcesArkContent `json:\"content\"`\n\tGenerateAudio bool               `json:\"generate_audio,omitempty\"`\n}\n\ntype VolcesArkResponse struct {\n\tID      string `json:\"id\"`\n\tModel   string `json:\"model\"`\n\tStatus  string `json:\"status\"`\n\tContent struct {\n\t\tVideoURL string `json:\"video_url\"`\n\t} `json:\"content\"`\n\tUsage struct {\n\t\tCompletionTokens int `json:\"completion_tokens\"`\n\t\tTotalTokens      int `json:\"total_tokens\"`\n\t} `json:\"usage\"`\n\tCreatedAt             int64       `json:\"created_at\"`\n\tUpdatedAt             int64       `json:\"updated_at\"`\n\tSeed                  int         `json:\"seed\"`\n\tResolution            string      `json:\"resolution\"`\n\tRatio                 string      `json:\"ratio\"`\n\tDuration              int         `json:\"duration\"`\n\tFramesPerSecond       int         `json:\"framespersecond\"`\n\tServiceTier           string      `json:\"service_tier\"`\n\tExecutionExpiresAfter int         `json:\"execution_expires_after\"`\n\tGenerateAudio         bool        `json:\"generate_audio\"`\n\tError                 interface{} `json:\"error,omitempty\"`\n}\n\nfunc NewVolcesArkClient(baseURL, apiKey, model, endpoint, queryEndpoint string) *VolcesArkClient {\n\tif endpoint == \"\" {\n\t\tendpoint = \"/api/v3/contents/generations/tasks\"\n\t}\n\tif queryEndpoint == \"\" {\n\t\tqueryEndpoint = endpoint\n\t}\n\treturn &VolcesArkClient{\n\t\tBaseURL:       baseURL,\n\t\tAPIKey:        apiKey,\n\t\tModel:         model,\n\t\tEndpoint:      endpoint,\n\t\tQueryEndpoint: queryEndpoint,\n\t\tHTTPClient: &http.Client{\n\t\t\tTimeout: 300 * time.Second,\n\t\t},\n\t}\n}\n\n// GenerateVideo 生成视频（支持首帧、首尾帧、参考图等多种模式）\nfunc (c *VolcesArkClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) {\n\toptions := &VideoOptions{\n\t\tDuration:    5,\n\t\tAspectRatio: \"adaptive\",\n\t}\n\n\tfor _, opt := range opts {\n\t\topt(options)\n\t}\n\n\tmodel := c.Model\n\tif options.Model != \"\" {\n\t\tmodel = options.Model\n\t}\n\n\t// 构建prompt文本（包含duration和ratio参数）\n\tpromptText := prompt\n\tif options.AspectRatio != \"\" {\n\t\tpromptText += fmt.Sprintf(\"  --ratio %s\", options.AspectRatio)\n\t}\n\tif options.Duration > 0 {\n\t\tpromptText += fmt.Sprintf(\"  --dur %d\", options.Duration)\n\t}\n\n\tcontent := []VolcesArkContent{\n\t\t{\n\t\t\tType: \"text\",\n\t\t\tText: promptText,\n\t\t},\n\t}\n\n\t// 处理不同的图片模式\n\t// 1. 组图模式（多个reference_image）\n\tif len(options.ReferenceImageURLs) > 0 {\n\t\tfor _, refURL := range options.ReferenceImageURLs {\n\t\t\tcontent = append(content, VolcesArkContent{\n\t\t\t\tType: \"image_url\",\n\t\t\t\tImageURL: map[string]interface{}{\n\t\t\t\t\t\"url\": refURL,\n\t\t\t\t},\n\t\t\t\tRole: \"reference_image\",\n\t\t\t})\n\t\t}\n\t} else if options.FirstFrameURL != \"\" && options.LastFrameURL != \"\" {\n\t\t// 2. 首尾帧模式\n\t\tcontent = append(content, VolcesArkContent{\n\t\t\tType: \"image_url\",\n\t\t\tImageURL: map[string]interface{}{\n\t\t\t\t\"url\": options.FirstFrameURL,\n\t\t\t},\n\t\t\tRole: \"first_frame\",\n\t\t})\n\t\tcontent = append(content, VolcesArkContent{\n\t\t\tType: \"image_url\",\n\t\t\tImageURL: map[string]interface{}{\n\t\t\t\t\"url\": options.LastFrameURL,\n\t\t\t},\n\t\t\tRole: \"last_frame\",\n\t\t})\n\t} else if imageURL != \"\" {\n\t\t// 3. 单图模式（默认）\n\t\tcontent = append(content, VolcesArkContent{\n\t\t\tType: \"image_url\",\n\t\t\tImageURL: map[string]interface{}{\n\t\t\t\t\"url\": imageURL,\n\t\t\t},\n\t\t\t// 单图模式不需要role\n\t\t})\n\t} else if options.FirstFrameURL != \"\" {\n\t\t// 4. 只有首帧\n\t\tcontent = append(content, VolcesArkContent{\n\t\t\tType: \"image_url\",\n\t\t\tImageURL: map[string]interface{}{\n\t\t\t\t\"url\": options.FirstFrameURL,\n\t\t\t},\n\t\t\tRole: \"first_frame\",\n\t\t})\n\t}\n\n\t// 只有 seedance-1-5-pro 模型支持 generate_audio 参数\n\tgenerateAudio := false\n\tif strings.Contains(strings.ToLower(model), \"seedance-1-5-pro\") {\n\t\tgenerateAudio = true\n\t}\n\n\treqBody := VolcesArkRequest{\n\t\tModel:         model,\n\t\tContent:       content,\n\t\tGenerateAudio: generateAudio,\n\t}\n\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"marshal request: %w\", err)\n\t}\n\n\tendpoint := c.BaseURL + c.Endpoint\n\tfmt.Printf(\"[VolcesARK] Generating video - Endpoint: %s, FullURL: %s, Model: %s\\n\", c.Endpoint, endpoint, model)\n\tfmt.Printf(\"[VolcesARK] Request body: %s\\n\", string(jsonData))\n\n\treq, err := http.NewRequest(\"POST\", endpoint, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tfmt.Printf(\"[VolcesARK] Response status: %d, body: %s\\n\", resp.StatusCode, string(body))\n\n\tif resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {\n\t\treturn nil, fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\tvar result VolcesArkResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse response: %w\", err)\n\t}\n\n\tfmt.Printf(\"[VolcesARK] Video generation initiated - TaskID: %s, Status: %s\\n\", result.ID, result.Status)\n\n\tif result.Error != nil {\n\t\terrorMsg := fmt.Sprintf(\"%v\", result.Error)\n\t\treturn nil, fmt.Errorf(\"volces error: %s\", errorMsg)\n\t}\n\n\tvideoResult := &VideoResult{\n\t\tTaskID:    result.ID,\n\t\tStatus:    result.Status,\n\t\tCompleted: result.Status == \"completed\" || result.Status == \"succeeded\",\n\t\tDuration:  result.Duration,\n\t}\n\n\tif result.Content.VideoURL != \"\" {\n\t\tvideoResult.VideoURL = result.Content.VideoURL\n\t\tvideoResult.Completed = true\n\t}\n\n\treturn videoResult, nil\n}\n\nfunc (c *VolcesArkClient) GetTaskStatus(taskID string) (*VideoResult, error) {\n\t// 替换占位符{taskId}、{task_id}或直接拼接\n\tqueryPath := c.QueryEndpoint\n\tif strings.Contains(queryPath, \"{taskId}\") {\n\t\tqueryPath = strings.ReplaceAll(queryPath, \"{taskId}\", taskID)\n\t} else if strings.Contains(queryPath, \"{task_id}\") {\n\t\tqueryPath = strings.ReplaceAll(queryPath, \"{task_id}\", taskID)\n\t} else {\n\t\tqueryPath = queryPath + \"/\" + taskID\n\t}\n\n\tendpoint := c.BaseURL + queryPath\n\tfmt.Printf(\"[VolcesARK] Querying task status - TaskID: %s, QueryEndpoint: %s, FullURL: %s\\n\", taskID, c.QueryEndpoint, endpoint)\n\n\treq, err := http.NewRequest(\"GET\", endpoint, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.APIKey)\n\n\tresp, err := c.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tfmt.Printf(\"[VolcesARK] Response body: %s\\n\", string(body))\n\n\tvar result VolcesArkResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse response: %w\", err)\n\t}\n\n\tfmt.Printf(\"[VolcesARK] Parsed result - ID: %s, Status: %s, VideoURL: %s\\n\", result.ID, result.Status, result.Content.VideoURL)\n\n\tvideoResult := &VideoResult{\n\t\tTaskID:    result.ID,\n\t\tStatus:    result.Status,\n\t\tCompleted: result.Status == \"completed\" || result.Status == \"succeeded\",\n\t\tDuration:  result.Duration,\n\t}\n\n\tif result.Error != nil {\n\t\tvideoResult.Error = fmt.Sprintf(\"%v\", result.Error)\n\t}\n\n\tif result.Content.VideoURL != \"\" {\n\t\tvideoResult.VideoURL = result.Content.VideoURL\n\t\tvideoResult.Completed = true\n\t}\n\n\treturn videoResult, nil\n}\n"
  },
  {
    "path": "web/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n# Environment\n.env\n.env.local\n.env.*.local\n"
  },
  {
    "path": "web/index.html",
    "content": "<!doctype html>\n<html lang=\"zh-CN\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/public/favicon.ico\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Drama Generator - AI 短剧生成平台</title>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/main.ts\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "web/nginx.conf",
    "content": "server {\n    listen 80;\n    server_name localhost;\n    root /usr/share/nginx/html;\n    index index.html;\n\n    location / {\n        try_files $uri $uri/ /index.html;\n    }\n\n    location /api {\n        proxy_pass http://api:8080;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n\n    gzip on;\n    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;\n    gzip_comp_level 6;\n    gzip_min_length 1000;\n}\n"
  },
  {
    "path": "web/package.json",
    "content": "{\n  \"name\": \"drama-generator-frontend\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"build:check\": \"vue-tsc --noEmit --skipLibCheck && vite build\",\n    \"build:skip\": \"vite build\",\n    \"preview\": \"vite preview\",\n    \"lint\": \"eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore\"\n  },\n  \"dependencies\": {\n    \"@element-plus/icons-vue\": \"^2.3.0\",\n    \"@ffmpeg/ffmpeg\": \"^0.12.15\",\n    \"@ffmpeg/util\": \"^0.12.2\",\n    \"axios\": \"^1.6.0\",\n    \"cropperjs\": \"^2.1.0\",\n    \"dayjs\": \"^1.11.10\",\n    \"element-plus\": \"^2.5.0\",\n    \"lodash-es\": \"^4.17.22\",\n    \"pinia\": \"^2.1.0\",\n    \"vue\": \"^3.4.0\",\n    \"vue-i18n\": \"^9.14.5\",\n    \"vue-router\": \"^4.2.0\"\n  },\n  \"devDependencies\": {\n    \"@tailwindcss/postcss\": \"^4.1.0\",\n    \"@types/node\": \"^20.10.0\",\n    \"@vitejs/plugin-vue\": \"^5.0.0\",\n    \"@vue/tsconfig\": \"^0.5.0\",\n    \"autoprefixer\": \"^10.4.0\",\n    \"postcss\": \"^8.4.0\",\n    \"sass-embedded\": \"^1.97.1\",\n    \"tailwindcss\": \"^4.1.0\",\n    \"typescript\": \"^5.3.0\",\n    \"vite\": \"^5.0.0\",\n    \"vue-tsc\": \"^2.2.12\"\n  }\n}\n"
  },
  {
    "path": "web/public/ffmpeg/ffmpeg-core.js",
    "content": "\nvar createFFmpegCore = (() => {\n  var _scriptDir = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined;\n  \n  return (\nfunction(createFFmpegCore = {})  {\n\nvar Module=typeof createFFmpegCore!=\"undefined\"?createFFmpegCore:{};var readyPromiseResolve,readyPromiseReject;Module[\"ready\"]=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});const NULL=0;const SIZE_I32=Uint32Array.BYTES_PER_ELEMENT;const DEFAULT_ARGS=[\"./ffmpeg\",\"-nostdin\",\"-y\"];const DEFAULT_ARGS_FFPROBE=[\"./ffprobe\"];Module[\"NULL\"]=NULL;Module[\"SIZE_I32\"]=SIZE_I32;Module[\"DEFAULT_ARGS\"]=DEFAULT_ARGS;Module[\"DEFAULT_ARGS_FFPROBE\"]=DEFAULT_ARGS_FFPROBE;Module[\"ret\"]=-1;Module[\"timeout\"]=-1;Module[\"logger\"]=()=>{};Module[\"progress\"]=()=>{};function stringToPtr(str){const len=Module[\"lengthBytesUTF8\"](str)+1;const ptr=Module[\"_malloc\"](len);Module[\"stringToUTF8\"](str,ptr,len);return ptr}function stringsToPtr(strs){const len=strs.length;const ptr=Module[\"_malloc\"](len*SIZE_I32);for(let i=0;i<len;i++){Module[\"setValue\"](ptr+SIZE_I32*i,stringToPtr(strs[i]),\"i32\")}return ptr}function print(message){Module[\"logger\"]({type:\"stdout\",message:message})}function printErr(message){if(!message.startsWith(\"Aborted(native code called abort())\"))Module[\"logger\"]({type:\"stderr\",message:message})}function exec(..._args){const args=[...Module[\"DEFAULT_ARGS\"],..._args];try{Module[\"_ffmpeg\"](args.length,stringsToPtr(args))}catch(e){if(!e.message.startsWith(\"Aborted\")){throw e}}return Module[\"ret\"]}function ffprobe(..._args){const args=[...Module[\"DEFAULT_ARGS_FFPROBE\"],..._args];try{Module[\"_ffprobe\"](args.length,stringsToPtr(args))}catch(e){if(!e.message.startsWith(\"Aborted\")){throw e}}return Module[\"ret\"]}function setLogger(logger){Module[\"logger\"]=logger}function setTimeout(timeout){Module[\"timeout\"]=timeout}function setProgress(handler){Module[\"progress\"]=handler}function receiveProgress(progress,time){Module[\"progress\"]({progress:progress,time:time})}function reset(){Module[\"ret\"]=-1;Module[\"timeout\"]=-1}function _locateFile(path,prefix){const mainScriptUrlOrBlob=Module[\"mainScriptUrlOrBlob\"];if(mainScriptUrlOrBlob){const{wasmURL:wasmURL,workerURL:workerURL}=JSON.parse(atob(mainScriptUrlOrBlob.slice(mainScriptUrlOrBlob.lastIndexOf(\"#\")+1)));if(path.endsWith(\".wasm\"))return wasmURL;if(path.endsWith(\".worker.js\"))return workerURL}return prefix+path}Module[\"stringToPtr\"]=stringToPtr;Module[\"stringsToPtr\"]=stringsToPtr;Module[\"print\"]=print;Module[\"printErr\"]=printErr;Module[\"locateFile\"]=_locateFile;Module[\"exec\"]=exec;Module[\"ffprobe\"]=ffprobe;Module[\"setLogger\"]=setLogger;Module[\"setTimeout\"]=setTimeout;Module[\"setProgress\"]=setProgress;Module[\"reset\"]=reset;Module[\"receiveProgress\"]=receiveProgress;var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram=\"./this.program\";var quit_=(status,toThrow)=>{throw toThrow};var ENVIRONMENT_IS_WEB=false;var ENVIRONMENT_IS_WORKER=true;var ENVIRONMENT_IS_NODE=false;var scriptDirectory=\"\";function locateFile(path){if(Module[\"locateFile\"]){return Module[\"locateFile\"](path,scriptDirectory)}return scriptDirectory+path}var read_,readAsync,readBinary,setWindowTitle;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!=\"undefined\"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptDir){scriptDirectory=_scriptDir}if(scriptDirectory.indexOf(\"blob:\")!==0){scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,\"\").lastIndexOf(\"/\")+1)}else{scriptDirectory=\"\"}{read_=url=>{var xhr=new XMLHttpRequest;xhr.open(\"GET\",url,false);xhr.send(null);return xhr.responseText};if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open(\"GET\",url,false);xhr.responseType=\"arraybuffer\";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=(url,onload,onerror)=>{var xhr=new XMLHttpRequest;xhr.open(\"GET\",url,true);xhr.responseType=\"arraybuffer\";xhr.onload=()=>{if(xhr.status==200||xhr.status==0&&xhr.response){onload(xhr.response);return}onerror()};xhr.onerror=onerror;xhr.send(null)}}setWindowTitle=title=>document.title=title}else{}var out=Module[\"print\"]||console.log.bind(console);var err=Module[\"printErr\"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module[\"arguments\"])arguments_=Module[\"arguments\"];if(Module[\"thisProgram\"])thisProgram=Module[\"thisProgram\"];if(Module[\"quit\"])quit_=Module[\"quit\"];var wasmBinary;if(Module[\"wasmBinary\"])wasmBinary=Module[\"wasmBinary\"];var noExitRuntime=Module[\"noExitRuntime\"]||true;if(typeof WebAssembly!=\"object\"){abort(\"no native wasm support detected\")}var wasmMemory;var ABORT=false;var EXITSTATUS;function assert(condition,text){if(!condition){abort(text)}}var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAP64,HEAPU64,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module[\"HEAP8\"]=HEAP8=new Int8Array(b);Module[\"HEAP16\"]=HEAP16=new Int16Array(b);Module[\"HEAP32\"]=HEAP32=new Int32Array(b);Module[\"HEAPU8\"]=HEAPU8=new Uint8Array(b);Module[\"HEAPU16\"]=HEAPU16=new Uint16Array(b);Module[\"HEAPU32\"]=HEAPU32=new Uint32Array(b);Module[\"HEAPF32\"]=HEAPF32=new Float32Array(b);Module[\"HEAPF64\"]=HEAPF64=new Float64Array(b);Module[\"HEAP64\"]=HEAP64=new BigInt64Array(b);Module[\"HEAPU64\"]=HEAPU64=new BigUint64Array(b)}var wasmTable;var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;var runtimeKeepaliveCounter=0;function keepRuntimeAlive(){return noExitRuntime||runtimeKeepaliveCounter>0}function preRun(){if(Module[\"preRun\"]){if(typeof Module[\"preRun\"]==\"function\")Module[\"preRun\"]=[Module[\"preRun\"]];while(Module[\"preRun\"].length){addOnPreRun(Module[\"preRun\"].shift())}}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;if(!Module[\"noFSInit\"]&&!FS.init.initialized)FS.init();FS.ignorePermissions=false;TTY.init();SOCKFS.root=FS.mount(SOCKFS,{},null);callRuntimeCallbacks(__ATINIT__)}function postRun(){if(Module[\"postRun\"]){if(typeof Module[\"postRun\"]==\"function\")Module[\"postRun\"]=[Module[\"postRun\"]];while(Module[\"postRun\"].length){addOnPostRun(Module[\"postRun\"].shift())}}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function getUniqueRunDependency(id){return id}function addRunDependency(id){runDependencies++;if(Module[\"monitorRunDependencies\"]){Module[\"monitorRunDependencies\"](runDependencies)}}function removeRunDependency(id){runDependencies--;if(Module[\"monitorRunDependencies\"]){Module[\"monitorRunDependencies\"](runDependencies)}if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){if(Module[\"onAbort\"]){Module[\"onAbort\"](what)}what=\"Aborted(\"+what+\")\";err(what);ABORT=true;EXITSTATUS=1;what+=\". Build with -sASSERTIONS for more info.\";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix=\"data:application/octet-stream;base64,\";function isDataURI(filename){return filename.startsWith(dataURIPrefix)}var wasmBinaryFile;wasmBinaryFile=\"ffmpeg-core.wasm\";if(!isDataURI(wasmBinaryFile)){wasmBinaryFile=locateFile(wasmBinaryFile)}function getBinary(file){try{if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw\"both async and sync fetching of the wasm failed\"}catch(err){abort(err)}}function getBinaryPromise(binaryFile){if(!wasmBinary&&(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER)){if(typeof fetch==\"function\"){return fetch(binaryFile,{credentials:\"same-origin\"}).then(response=>{if(!response[\"ok\"]){throw\"failed to load wasm binary file at '\"+binaryFile+\"'\"}return response[\"arrayBuffer\"]()}).catch(()=>getBinary(binaryFile))}}return Promise.resolve().then(()=>getBinary(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>{return WebAssembly.instantiate(binary,imports)}).then(instance=>{return instance}).then(receiver,reason=>{err(\"failed to asynchronously prepare wasm: \"+reason);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming==\"function\"&&!isDataURI(binaryFile)&&typeof fetch==\"function\"){return fetch(binaryFile,{credentials:\"same-origin\"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(\"wasm streaming compile failed: \"+reason);err(\"falling back to ArrayBuffer instantiation\");return instantiateArrayBuffer(binaryFile,imports,callback)})})}else{return instantiateArrayBuffer(binaryFile,imports,callback)}}function createWasm(){var info={\"a\":wasmImports};function receiveInstance(instance,module){var exports=instance.exports;Module[\"asm\"]=exports;wasmMemory=Module[\"asm\"][\"ra\"];updateMemoryViews();wasmTable=Module[\"asm\"][\"ua\"];addOnInit(Module[\"asm\"][\"sa\"]);removeRunDependency(\"wasm-instantiate\");return exports}addRunDependency(\"wasm-instantiate\");function receiveInstantiationResult(result){receiveInstance(result[\"instance\"])}if(Module[\"instantiateWasm\"]){try{return Module[\"instantiateWasm\"](info,receiveInstance)}catch(e){err(\"Module.instantiateWasm callback failed with error: \"+e);readyPromiseReject(e)}}instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}var ASM_CONSTS={6077464:$0=>{Module.ret=$0}};function send_progress(progress,time){Module.receiveProgress(progress,time)}function is_timeout(diff){if(Module.timeout===-1)return 0;else{return Module.timeout<=diff}}function ExitStatus(status){this.name=\"ExitStatus\";this.message=`Program terminated with exit(${status})`;this.status=status}function callRuntimeCallbacks(callbacks){while(callbacks.length>0){callbacks.shift()(Module)}}var wasmTableMirror=[];function getWasmTableEntry(funcPtr){var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}return func}function getValue(ptr,type=\"i8\"){if(type.endsWith(\"*\"))type=\"*\";switch(type){case\"i1\":return HEAP8[ptr>>0];case\"i8\":return HEAP8[ptr>>0];case\"i16\":return HEAP16[ptr>>1];case\"i32\":return HEAP32[ptr>>2];case\"i64\":return HEAP64[ptr>>3];case\"float\":return HEAPF32[ptr>>2];case\"double\":return HEAPF64[ptr>>3];case\"*\":return HEAPU32[ptr>>2];default:abort(`invalid type for getValue: ${type}`)}}function setValue(ptr,value,type=\"i8\"){if(type.endsWith(\"*\"))type=\"*\";switch(type){case\"i1\":HEAP8[ptr>>0]=value;break;case\"i8\":HEAP8[ptr>>0]=value;break;case\"i16\":HEAP16[ptr>>1]=value;break;case\"i32\":HEAP32[ptr>>2]=value;break;case\"i64\":HEAP64[ptr>>3]=BigInt(value);break;case\"float\":HEAPF32[ptr>>2]=value;break;case\"double\":HEAPF64[ptr>>3]=value;break;case\"*\":HEAPU32[ptr>>2]=value;break;default:abort(`invalid type for setValue: ${type}`)}}var UTF8Decoder=typeof TextDecoder!=\"undefined\"?new TextDecoder(\"utf8\"):undefined;function UTF8ArrayToString(heapOrArray,idx,maxBytesToRead){var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str=\"\";while(idx<endPtr){var u0=heapOrArray[idx++];if(!(u0&128)){str+=String.fromCharCode(u0);continue}var u1=heapOrArray[idx++]&63;if((u0&224)==192){str+=String.fromCharCode((u0&31)<<6|u1);continue}var u2=heapOrArray[idx++]&63;if((u0&240)==224){u0=(u0&15)<<12|u1<<6|u2}else{u0=(u0&7)<<18|u1<<12|u2<<6|heapOrArray[idx++]&63}if(u0<65536){str+=String.fromCharCode(u0)}else{var ch=u0-65536;str+=String.fromCharCode(55296|ch>>10,56320|ch&1023)}}return str}function UTF8ToString(ptr,maxBytesToRead){return ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):\"\"}function ___assert_fail(condition,filename,line,func){abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):\"unknown filename\",line,func?UTF8ToString(func):\"unknown function\"])}function ExceptionInfo(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24;this.set_type=function(type){HEAPU32[this.ptr+4>>2]=type};this.get_type=function(){return HEAPU32[this.ptr+4>>2]};this.set_destructor=function(destructor){HEAPU32[this.ptr+8>>2]=destructor};this.get_destructor=function(){return HEAPU32[this.ptr+8>>2]};this.set_caught=function(caught){caught=caught?1:0;HEAP8[this.ptr+12>>0]=caught};this.get_caught=function(){return HEAP8[this.ptr+12>>0]!=0};this.set_rethrown=function(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13>>0]=rethrown};this.get_rethrown=function(){return HEAP8[this.ptr+13>>0]!=0};this.init=function(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)};this.set_adjusted_ptr=function(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr};this.get_adjusted_ptr=function(){return HEAPU32[this.ptr+16>>2]};this.get_exception_ptr=function(){var isPointer=___cxa_is_pointer_type(this.get_type());if(isPointer){return HEAPU32[this.excPtr>>2]}var adjusted=this.get_adjusted_ptr();if(adjusted!==0)return adjusted;return this.excPtr}}var exceptionLast=0;var uncaughtExceptionCount=0;function ___cxa_throw(ptr,type,destructor){var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast}var dlopenMissingError=\"To use dlopen, you need enable dynamic linking, see https://emscripten.org/docs/compiling/Dynamic-Linking.html\";function ___dlsym(handle,symbol){abort(dlopenMissingError)}var PATH={isAbs:path=>path.charAt(0)===\"/\",splitPath:filename=>{var splitPathRe=/^(\\/?|)([\\s\\S]*?)((?:\\.{1,2}|[^\\/]+?|)(\\.[^.\\/]*|))(?:[\\/]*)$/;return splitPathRe.exec(filename).slice(1)},normalizeArray:(parts,allowAboveRoot)=>{var up=0;for(var i=parts.length-1;i>=0;i--){var last=parts[i];if(last===\".\"){parts.splice(i,1)}else if(last===\"..\"){parts.splice(i,1);up++}else if(up){parts.splice(i,1);up--}}if(allowAboveRoot){for(;up;up--){parts.unshift(\"..\")}}return parts},normalize:path=>{var isAbsolute=PATH.isAbs(path),trailingSlash=path.substr(-1)===\"/\";path=PATH.normalizeArray(path.split(\"/\").filter(p=>!!p),!isAbsolute).join(\"/\");if(!path&&!isAbsolute){path=\".\"}if(path&&trailingSlash){path+=\"/\"}return(isAbsolute?\"/\":\"\")+path},dirname:path=>{var result=PATH.splitPath(path),root=result[0],dir=result[1];if(!root&&!dir){return\".\"}if(dir){dir=dir.substr(0,dir.length-1)}return root+dir},basename:path=>{if(path===\"/\")return\"/\";path=PATH.normalize(path);path=path.replace(/\\/$/,\"\");var lastSlash=path.lastIndexOf(\"/\");if(lastSlash===-1)return path;return path.substr(lastSlash+1)},join:function(){var paths=Array.prototype.slice.call(arguments);return PATH.normalize(paths.join(\"/\"))},join2:(l,r)=>{return PATH.normalize(l+\"/\"+r)}};function initRandomFill(){if(typeof crypto==\"object\"&&typeof crypto[\"getRandomValues\"]==\"function\"){return view=>crypto.getRandomValues(view)}else abort(\"initRandomDevice\")}function randomFill(view){return(randomFill=initRandomFill())(view)}var PATH_FS={resolve:function(){var resolvedPath=\"\",resolvedAbsolute=false;for(var i=arguments.length-1;i>=-1&&!resolvedAbsolute;i--){var path=i>=0?arguments[i]:FS.cwd();if(typeof path!=\"string\"){throw new TypeError(\"Arguments to path.resolve must be strings\")}else if(!path){return\"\"}resolvedPath=path+\"/\"+resolvedPath;resolvedAbsolute=PATH.isAbs(path)}resolvedPath=PATH.normalizeArray(resolvedPath.split(\"/\").filter(p=>!!p),!resolvedAbsolute).join(\"/\");return(resolvedAbsolute?\"/\":\"\")+resolvedPath||\".\"},relative:(from,to)=>{from=PATH_FS.resolve(from).substr(1);to=PATH_FS.resolve(to).substr(1);function trim(arr){var start=0;for(;start<arr.length;start++){if(arr[start]!==\"\")break}var end=arr.length-1;for(;end>=0;end--){if(arr[end]!==\"\")break}if(start>end)return[];return arr.slice(start,end-start+1)}var fromParts=trim(from.split(\"/\"));var toParts=trim(to.split(\"/\"));var length=Math.min(fromParts.length,toParts.length);var samePartsLength=length;for(var i=0;i<length;i++){if(fromParts[i]!==toParts[i]){samePartsLength=i;break}}var outputParts=[];for(var i=samePartsLength;i<fromParts.length;i++){outputParts.push(\"..\")}outputParts=outputParts.concat(toParts.slice(samePartsLength));return outputParts.join(\"/\")}};function lengthBytesUTF8(str){var len=0;for(var i=0;i<str.length;++i){var c=str.charCodeAt(i);if(c<=127){len++}else if(c<=2047){len+=2}else if(c>=55296&&c<=57343){len+=4;++i}else{len+=3}}return len}function stringToUTF8Array(str,heap,outIdx,maxBytesToWrite){if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i<str.length;++i){var u=str.charCodeAt(i);if(u>=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx}function intArrayFromString(stringy,dontAddNull,length){var len=length>0?length:lengthBytesUTF8(stringy)+1;var u8array=new Array(len);var numBytesWritten=stringToUTF8Array(stringy,u8array,0,u8array.length);if(dontAddNull)u8array.length=numBytesWritten;return u8array}var TTY={ttys:[],init:function(){},shutdown:function(){},register:function(dev,ops){TTY.ttys[dev]={input:[],output:[],ops:ops};FS.registerDevice(dev,TTY.stream_ops)},stream_ops:{open:function(stream){var tty=TTY.ttys[stream.node.rdev];if(!tty){throw new FS.ErrnoError(43)}stream.tty=tty;stream.seekable=false},close:function(stream){stream.tty.ops.fsync(stream.tty)},fsync:function(stream){stream.tty.ops.fsync(stream.tty)},read:function(stream,buffer,offset,length,pos){if(!stream.tty||!stream.tty.ops.get_char){throw new FS.ErrnoError(60)}var bytesRead=0;for(var i=0;i<length;i++){var result;try{result=stream.tty.ops.get_char(stream.tty)}catch(e){throw new FS.ErrnoError(29)}if(result===undefined&&bytesRead===0){throw new FS.ErrnoError(6)}if(result===null||result===undefined)break;bytesRead++;buffer[offset+i]=result}if(bytesRead){stream.node.timestamp=Date.now()}return bytesRead},write:function(stream,buffer,offset,length,pos){if(!stream.tty||!stream.tty.ops.put_char){throw new FS.ErrnoError(60)}try{for(var i=0;i<length;i++){stream.tty.ops.put_char(stream.tty,buffer[offset+i])}}catch(e){throw new FS.ErrnoError(29)}if(length){stream.node.timestamp=Date.now()}return i}},default_tty_ops:{get_char:function(tty){if(!tty.input.length){var result=null;if(typeof window!=\"undefined\"&&typeof window.prompt==\"function\"){result=window.prompt(\"Input: \");if(result!==null){result+=\"\\n\"}}else if(typeof readline==\"function\"){result=readline();if(result!==null){result+=\"\\n\"}}if(!result){return null}tty.input=intArrayFromString(result,true)}return tty.input.shift()},put_char:function(tty,val){if(val===null||val===10){out(UTF8ArrayToString(tty.output,0));tty.output=[]}else{if(val!=0)tty.output.push(val)}},fsync:function(tty){if(tty.output&&tty.output.length>0){out(UTF8ArrayToString(tty.output,0));tty.output=[]}}},default_tty1_ops:{put_char:function(tty,val){if(val===null||val===10){err(UTF8ArrayToString(tty.output,0));tty.output=[]}else{if(val!=0)tty.output.push(val)}},fsync:function(tty){if(tty.output&&tty.output.length>0){err(UTF8ArrayToString(tty.output,0));tty.output=[]}}}};function zeroMemory(address,size){HEAPU8.fill(0,address,address+size);return address}function alignMemory(size,alignment){return Math.ceil(size/alignment)*alignment}function mmapAlloc(size){size=alignMemory(size,65536);var ptr=_emscripten_builtin_memalign(65536,size);if(!ptr)return 0;return zeroMemory(ptr,size)}var MEMFS={ops_table:null,mount:function(mount){return MEMFS.createNode(null,\"/\",16384|511,0)},createNode:function(parent,name,mode,dev){if(FS.isBlkdev(mode)||FS.isFIFO(mode)){throw new FS.ErrnoError(63)}if(!MEMFS.ops_table){MEMFS.ops_table={dir:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,lookup:MEMFS.node_ops.lookup,mknod:MEMFS.node_ops.mknod,rename:MEMFS.node_ops.rename,unlink:MEMFS.node_ops.unlink,rmdir:MEMFS.node_ops.rmdir,readdir:MEMFS.node_ops.readdir,symlink:MEMFS.node_ops.symlink},stream:{llseek:MEMFS.stream_ops.llseek}},file:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:{llseek:MEMFS.stream_ops.llseek,read:MEMFS.stream_ops.read,write:MEMFS.stream_ops.write,allocate:MEMFS.stream_ops.allocate,mmap:MEMFS.stream_ops.mmap,msync:MEMFS.stream_ops.msync}},link:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,readlink:MEMFS.node_ops.readlink},stream:{}},chrdev:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:FS.chrdev_stream_ops}}}var node=FS.createNode(parent,name,mode,dev);if(FS.isDir(node.mode)){node.node_ops=MEMFS.ops_table.dir.node;node.stream_ops=MEMFS.ops_table.dir.stream;node.contents={}}else if(FS.isFile(node.mode)){node.node_ops=MEMFS.ops_table.file.node;node.stream_ops=MEMFS.ops_table.file.stream;node.usedBytes=0;node.contents=null}else if(FS.isLink(node.mode)){node.node_ops=MEMFS.ops_table.link.node;node.stream_ops=MEMFS.ops_table.link.stream}else if(FS.isChrdev(node.mode)){node.node_ops=MEMFS.ops_table.chrdev.node;node.stream_ops=MEMFS.ops_table.chrdev.stream}node.timestamp=Date.now();if(parent){parent.contents[name]=node;parent.timestamp=node.timestamp}return node},getFileDataAsTypedArray:function(node){if(!node.contents)return new Uint8Array(0);if(node.contents.subarray)return node.contents.subarray(0,node.usedBytes);return new Uint8Array(node.contents)},expandFileStorage:function(node,newCapacity){var prevCapacity=node.contents?node.contents.length:0;if(prevCapacity>=newCapacity)return;var CAPACITY_DOUBLING_MAX=1024*1024;newCapacity=Math.max(newCapacity,prevCapacity*(prevCapacity<CAPACITY_DOUBLING_MAX?2:1.125)>>>0);if(prevCapacity!=0)newCapacity=Math.max(newCapacity,256);var oldContents=node.contents;node.contents=new Uint8Array(newCapacity);if(node.usedBytes>0)node.contents.set(oldContents.subarray(0,node.usedBytes),0)},resizeFileStorage:function(node,newSize){if(node.usedBytes==newSize)return;if(newSize==0){node.contents=null;node.usedBytes=0}else{var oldContents=node.contents;node.contents=new Uint8Array(newSize);if(oldContents){node.contents.set(oldContents.subarray(0,Math.min(newSize,node.usedBytes)))}node.usedBytes=newSize}},node_ops:{getattr:function(node){var attr={};attr.dev=FS.isChrdev(node.mode)?node.id:1;attr.ino=node.id;attr.mode=node.mode;attr.nlink=1;attr.uid=0;attr.gid=0;attr.rdev=node.rdev;if(FS.isDir(node.mode)){attr.size=4096}else if(FS.isFile(node.mode)){attr.size=node.usedBytes}else if(FS.isLink(node.mode)){attr.size=node.link.length}else{attr.size=0}attr.atime=new Date(node.timestamp);attr.mtime=new Date(node.timestamp);attr.ctime=new Date(node.timestamp);attr.blksize=4096;attr.blocks=Math.ceil(attr.size/attr.blksize);return attr},setattr:function(node,attr){if(attr.mode!==undefined){node.mode=attr.mode}if(attr.timestamp!==undefined){node.timestamp=attr.timestamp}if(attr.size!==undefined){MEMFS.resizeFileStorage(node,attr.size)}},lookup:function(parent,name){throw FS.genericErrors[44]},mknod:function(parent,name,mode,dev){return MEMFS.createNode(parent,name,mode,dev)},rename:function(old_node,new_dir,new_name){if(FS.isDir(old_node.mode)){var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(new_node){for(var i in new_node.contents){throw new FS.ErrnoError(55)}}}delete old_node.parent.contents[old_node.name];old_node.parent.timestamp=Date.now();old_node.name=new_name;new_dir.contents[new_name]=old_node;new_dir.timestamp=old_node.parent.timestamp;old_node.parent=new_dir},unlink:function(parent,name){delete parent.contents[name];parent.timestamp=Date.now()},rmdir:function(parent,name){var node=FS.lookupNode(parent,name);for(var i in node.contents){throw new FS.ErrnoError(55)}delete parent.contents[name];parent.timestamp=Date.now()},readdir:function(node){var entries=[\".\",\"..\"];for(var key in node.contents){if(!node.contents.hasOwnProperty(key)){continue}entries.push(key)}return entries},symlink:function(parent,newname,oldpath){var node=MEMFS.createNode(parent,newname,511|40960,0);node.link=oldpath;return node},readlink:function(node){if(!FS.isLink(node.mode)){throw new FS.ErrnoError(28)}return node.link}},stream_ops:{read:function(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=stream.node.usedBytes)return 0;var size=Math.min(stream.node.usedBytes-position,length);if(size>8&&contents.subarray){buffer.set(contents.subarray(position,position+size),offset)}else{for(var i=0;i<size;i++)buffer[offset+i]=contents[position+i]}return size},write:function(stream,buffer,offset,length,position,canOwn){if(buffer.buffer===HEAP8.buffer){canOwn=false}if(!length)return 0;var node=stream.node;node.timestamp=Date.now();if(buffer.subarray&&(!node.contents||node.contents.subarray)){if(canOwn){node.contents=buffer.subarray(offset,offset+length);node.usedBytes=length;return length}else if(node.usedBytes===0&&position===0){node.contents=buffer.slice(offset,offset+length);node.usedBytes=length;return length}else if(position+length<=node.usedBytes){node.contents.set(buffer.subarray(offset,offset+length),position);return length}}MEMFS.expandFileStorage(node,position+length);if(node.contents.subarray&&buffer.subarray){node.contents.set(buffer.subarray(offset,offset+length),position)}else{for(var i=0;i<length;i++){node.contents[position+i]=buffer[offset+i]}}node.usedBytes=Math.max(node.usedBytes,position+length);return length},llseek:function(stream,offset,whence){var position=offset;if(whence===1){position+=stream.position}else if(whence===2){if(FS.isFile(stream.node.mode)){position+=stream.node.usedBytes}}if(position<0){throw new FS.ErrnoError(28)}return position},allocate:function(stream,offset,length){MEMFS.expandFileStorage(stream.node,offset+length);stream.node.usedBytes=Math.max(stream.node.usedBytes,offset+length)},mmap:function(stream,length,position,prot,flags){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}var ptr;var allocated;var contents=stream.node.contents;if(!(flags&2)&&contents.buffer===HEAP8.buffer){allocated=false;ptr=contents.byteOffset}else{if(position>0||position+length<contents.length){if(contents.subarray){contents=contents.subarray(position,position+length)}else{contents=Array.prototype.slice.call(contents,position,position+length)}}allocated=true;ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}HEAP8.set(contents,ptr)}return{ptr:ptr,allocated:allocated}},msync:function(stream,buffer,offset,length,mmapFlags){MEMFS.stream_ops.write(stream,buffer,0,length,offset,false);return 0}}};function asyncLoad(url,onload,onerror,noRunDep){var dep=!noRunDep?getUniqueRunDependency(`al ${url}`):\"\";readAsync(url,arrayBuffer=>{assert(arrayBuffer,`Loading data file \"${url}\" failed (no arrayBuffer).`);onload(new Uint8Array(arrayBuffer));if(dep)removeRunDependency(dep)},event=>{if(onerror){onerror()}else{throw`Loading data file \"${url}\" failed.`}});if(dep)addRunDependency(dep)}var preloadPlugins=Module[\"preloadPlugins\"]||[];function FS_handledByPreloadPlugin(byteArray,fullname,finish,onerror){if(typeof Browser!=\"undefined\")Browser.init();var handled=false;preloadPlugins.forEach(function(plugin){if(handled)return;if(plugin[\"canHandle\"](fullname)){plugin[\"handle\"](byteArray,fullname,finish,onerror);handled=true}});return handled}function FS_createPreloadedFile(parent,name,url,canRead,canWrite,onload,onerror,dontCreateFile,canOwn,preFinish){var fullname=name?PATH_FS.resolve(PATH.join2(parent,name)):parent;var dep=getUniqueRunDependency(`cp ${fullname}`);function processData(byteArray){function finish(byteArray){if(preFinish)preFinish();if(!dontCreateFile){FS.createDataFile(parent,name,byteArray,canRead,canWrite,canOwn)}if(onload)onload();removeRunDependency(dep)}if(FS_handledByPreloadPlugin(byteArray,fullname,finish,()=>{if(onerror)onerror();removeRunDependency(dep)})){return}finish(byteArray)}addRunDependency(dep);if(typeof url==\"string\"){asyncLoad(url,byteArray=>processData(byteArray),onerror)}else{processData(url)}}function FS_modeStringToFlags(str){var flagModes={\"r\":0,\"r+\":2,\"w\":512|64|1,\"w+\":512|64|2,\"a\":1024|64|1,\"a+\":1024|64|2};var flags=flagModes[str];if(typeof flags==\"undefined\"){throw new Error(`Unknown file open mode: ${str}`)}return flags}function FS_getMode(canRead,canWrite){var mode=0;if(canRead)mode|=292|73;if(canWrite)mode|=146;return mode}var WORKERFS={DIR_MODE:16895,FILE_MODE:33279,reader:null,mount:function(mount){assert(ENVIRONMENT_IS_WORKER);if(!WORKERFS.reader)WORKERFS.reader=new FileReaderSync;var root=WORKERFS.createNode(null,\"/\",WORKERFS.DIR_MODE,0);var createdParents={};function ensureParent(path){var parts=path.split(\"/\");var parent=root;for(var i=0;i<parts.length-1;i++){var curr=parts.slice(0,i+1).join(\"/\");if(!createdParents[curr]){createdParents[curr]=WORKERFS.createNode(parent,parts[i],WORKERFS.DIR_MODE,0)}parent=createdParents[curr]}return parent}function base(path){var parts=path.split(\"/\");return parts[parts.length-1]}Array.prototype.forEach.call(mount.opts[\"files\"]||[],function(file){WORKERFS.createNode(ensureParent(file.name),base(file.name),WORKERFS.FILE_MODE,0,file,file.lastModifiedDate)});(mount.opts[\"blobs\"]||[]).forEach(function(obj){WORKERFS.createNode(ensureParent(obj[\"name\"]),base(obj[\"name\"]),WORKERFS.FILE_MODE,0,obj[\"data\"])});(mount.opts[\"packages\"]||[]).forEach(function(pack){pack[\"metadata\"].files.forEach(function(file){var name=file.filename.substr(1);WORKERFS.createNode(ensureParent(name),base(name),WORKERFS.FILE_MODE,0,pack[\"blob\"].slice(file.start,file.end))})});return root},createNode:function(parent,name,mode,dev,contents,mtime){var node=FS.createNode(parent,name,mode);node.mode=mode;node.node_ops=WORKERFS.node_ops;node.stream_ops=WORKERFS.stream_ops;node.timestamp=(mtime||new Date).getTime();assert(WORKERFS.FILE_MODE!==WORKERFS.DIR_MODE);if(mode===WORKERFS.FILE_MODE){node.size=contents.size;node.contents=contents}else{node.size=4096;node.contents={}}if(parent){parent.contents[name]=node}return node},node_ops:{getattr:function(node){return{dev:1,ino:node.id,mode:node.mode,nlink:1,uid:0,gid:0,rdev:undefined,size:node.size,atime:new Date(node.timestamp),mtime:new Date(node.timestamp),ctime:new Date(node.timestamp),blksize:4096,blocks:Math.ceil(node.size/4096)}},setattr:function(node,attr){if(attr.mode!==undefined){node.mode=attr.mode}if(attr.timestamp!==undefined){node.timestamp=attr.timestamp}},lookup:function(parent,name){throw new FS.ErrnoError(44)},mknod:function(parent,name,mode,dev){throw new FS.ErrnoError(63)},rename:function(oldNode,newDir,newName){throw new FS.ErrnoError(63)},unlink:function(parent,name){throw new FS.ErrnoError(63)},rmdir:function(parent,name){throw new FS.ErrnoError(63)},readdir:function(node){var entries=[\".\",\"..\"];for(var key in node.contents){if(!node.contents.hasOwnProperty(key)){continue}entries.push(key)}return entries},symlink:function(parent,newName,oldPath){throw new FS.ErrnoError(63)}},stream_ops:{read:function(stream,buffer,offset,length,position){if(position>=stream.node.size)return 0;var chunk=stream.node.contents.slice(position,position+length);var ab=WORKERFS.reader.readAsArrayBuffer(chunk);buffer.set(new Uint8Array(ab),offset);return chunk.size},write:function(stream,buffer,offset,length,position){throw new FS.ErrnoError(29)},llseek:function(stream,offset,whence){var position=offset;if(whence===1){position+=stream.position}else if(whence===2){if(FS.isFile(stream.node.mode)){position+=stream.node.size}}if(position<0){throw new FS.ErrnoError(28)}return position}}};var FS={root:null,mounts:[],devices:{},streams:[],nextInode:1,nameTable:null,currentPath:\"/\",initialized:false,ignorePermissions:true,ErrnoError:null,genericErrors:{},filesystems:null,syncFSRequests:0,lookupPath:(path,opts={})=>{path=PATH_FS.resolve(path);if(!path)return{path:\"\",node:null};var defaults={follow_mount:true,recurse_count:0};opts=Object.assign(defaults,opts);if(opts.recurse_count>8){throw new FS.ErrnoError(32)}var parts=path.split(\"/\").filter(p=>!!p);var current=FS.root;var current_path=\"/\";for(var i=0;i<parts.length;i++){var islast=i===parts.length-1;if(islast&&opts.parent){break}current=FS.lookupNode(current,parts[i]);current_path=PATH.join2(current_path,parts[i]);if(FS.isMountpoint(current)){if(!islast||islast&&opts.follow_mount){current=current.mounted.root}}if(!islast||opts.follow){var count=0;while(FS.isLink(current.mode)){var link=FS.readlink(current_path);current_path=PATH_FS.resolve(PATH.dirname(current_path),link);var lookup=FS.lookupPath(current_path,{recurse_count:opts.recurse_count+1});current=lookup.node;if(count++>40){throw new FS.ErrnoError(32)}}}}return{path:current_path,node:current}},getPath:node=>{var path;while(true){if(FS.isRoot(node)){var mount=node.mount.mountpoint;if(!path)return mount;return mount[mount.length-1]!==\"/\"?`${mount}/${path}`:mount+path}path=path?`${node.name}/${path}`:node.name;node=node.parent}},hashName:(parentid,name)=>{var hash=0;for(var i=0;i<name.length;i++){hash=(hash<<5)-hash+name.charCodeAt(i)|0}return(parentid+hash>>>0)%FS.nameTable.length},hashAddNode:node=>{var hash=FS.hashName(node.parent.id,node.name);node.name_next=FS.nameTable[hash];FS.nameTable[hash]=node},hashRemoveNode:node=>{var hash=FS.hashName(node.parent.id,node.name);if(FS.nameTable[hash]===node){FS.nameTable[hash]=node.name_next}else{var current=FS.nameTable[hash];while(current){if(current.name_next===node){current.name_next=node.name_next;break}current=current.name_next}}},lookupNode:(parent,name)=>{var errCode=FS.mayLookup(parent);if(errCode){throw new FS.ErrnoError(errCode,parent)}var hash=FS.hashName(parent.id,name);for(var node=FS.nameTable[hash];node;node=node.name_next){var nodeName=node.name;if(node.parent.id===parent.id&&nodeName===name){return node}}return FS.lookup(parent,name)},createNode:(parent,name,mode,rdev)=>{var node=new FS.FSNode(parent,name,mode,rdev);FS.hashAddNode(node);return node},destroyNode:node=>{FS.hashRemoveNode(node)},isRoot:node=>{return node===node.parent},isMountpoint:node=>{return!!node.mounted},isFile:mode=>{return(mode&61440)===32768},isDir:mode=>{return(mode&61440)===16384},isLink:mode=>{return(mode&61440)===40960},isChrdev:mode=>{return(mode&61440)===8192},isBlkdev:mode=>{return(mode&61440)===24576},isFIFO:mode=>{return(mode&61440)===4096},isSocket:mode=>{return(mode&49152)===49152},flagsToPermissionString:flag=>{var perms=[\"r\",\"w\",\"rw\"][flag&3];if(flag&512){perms+=\"w\"}return perms},nodePermissions:(node,perms)=>{if(FS.ignorePermissions){return 0}if(perms.includes(\"r\")&&!(node.mode&292)){return 2}else if(perms.includes(\"w\")&&!(node.mode&146)){return 2}else if(perms.includes(\"x\")&&!(node.mode&73)){return 2}return 0},mayLookup:dir=>{var errCode=FS.nodePermissions(dir,\"x\");if(errCode)return errCode;if(!dir.node_ops.lookup)return 2;return 0},mayCreate:(dir,name)=>{try{var node=FS.lookupNode(dir,name);return 20}catch(e){}return FS.nodePermissions(dir,\"wx\")},mayDelete:(dir,name,isdir)=>{var node;try{node=FS.lookupNode(dir,name)}catch(e){return e.errno}var errCode=FS.nodePermissions(dir,\"wx\");if(errCode){return errCode}if(isdir){if(!FS.isDir(node.mode)){return 54}if(FS.isRoot(node)||FS.getPath(node)===FS.cwd()){return 10}}else{if(FS.isDir(node.mode)){return 31}}return 0},mayOpen:(node,flags)=>{if(!node){return 44}if(FS.isLink(node.mode)){return 32}else if(FS.isDir(node.mode)){if(FS.flagsToPermissionString(flags)!==\"r\"||flags&512){return 31}}return FS.nodePermissions(node,FS.flagsToPermissionString(flags))},MAX_OPEN_FDS:4096,nextfd:()=>{for(var fd=0;fd<=FS.MAX_OPEN_FDS;fd++){if(!FS.streams[fd]){return fd}}throw new FS.ErrnoError(33)},getStream:fd=>FS.streams[fd],createStream:(stream,fd=-1)=>{if(!FS.FSStream){FS.FSStream=function(){this.shared={}};FS.FSStream.prototype={};Object.defineProperties(FS.FSStream.prototype,{object:{get:function(){return this.node},set:function(val){this.node=val}},isRead:{get:function(){return(this.flags&2097155)!==1}},isWrite:{get:function(){return(this.flags&2097155)!==0}},isAppend:{get:function(){return this.flags&1024}},flags:{get:function(){return this.shared.flags},set:function(val){this.shared.flags=val}},position:{get:function(){return this.shared.position},set:function(val){this.shared.position=val}}})}stream=Object.assign(new FS.FSStream,stream);if(fd==-1){fd=FS.nextfd()}stream.fd=fd;FS.streams[fd]=stream;return stream},closeStream:fd=>{FS.streams[fd]=null},chrdev_stream_ops:{open:stream=>{var device=FS.getDevice(stream.node.rdev);stream.stream_ops=device.stream_ops;if(stream.stream_ops.open){stream.stream_ops.open(stream)}},llseek:()=>{throw new FS.ErrnoError(70)}},major:dev=>dev>>8,minor:dev=>dev&255,makedev:(ma,mi)=>ma<<8|mi,registerDevice:(dev,ops)=>{FS.devices[dev]={stream_ops:ops}},getDevice:dev=>FS.devices[dev],getMounts:mount=>{var mounts=[];var check=[mount];while(check.length){var m=check.pop();mounts.push(m);check.push.apply(check,m.mounts)}return mounts},syncfs:(populate,callback)=>{if(typeof populate==\"function\"){callback=populate;populate=false}FS.syncFSRequests++;if(FS.syncFSRequests>1){err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`)}var mounts=FS.getMounts(FS.root.mount);var completed=0;function doCallback(errCode){FS.syncFSRequests--;return callback(errCode)}function done(errCode){if(errCode){if(!done.errored){done.errored=true;return doCallback(errCode)}return}if(++completed>=mounts.length){doCallback(null)}}mounts.forEach(mount=>{if(!mount.type.syncfs){return done(null)}mount.type.syncfs(mount,populate,done)})},mount:(type,opts,mountpoint)=>{var root=mountpoint===\"/\";var pseudo=!mountpoint;var node;if(root&&FS.root){throw new FS.ErrnoError(10)}else if(!root&&!pseudo){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});mountpoint=lookup.path;node=lookup.node;if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}if(!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}}var mount={type:type,opts:opts,mountpoint:mountpoint,mounts:[]};var mountRoot=type.mount(mount);mountRoot.mount=mount;mount.root=mountRoot;if(root){FS.root=mountRoot}else if(node){node.mounted=mount;if(node.mount){node.mount.mounts.push(mount)}}return mountRoot},unmount:mountpoint=>{var lookup=FS.lookupPath(mountpoint,{follow_mount:false});if(!FS.isMountpoint(lookup.node)){throw new FS.ErrnoError(28)}var node=lookup.node;var mount=node.mounted;var mounts=FS.getMounts(mount);Object.keys(FS.nameTable).forEach(hash=>{var current=FS.nameTable[hash];while(current){var next=current.name_next;if(mounts.includes(current.mount)){FS.destroyNode(current)}current=next}});node.mounted=null;var idx=node.mount.mounts.indexOf(mount);node.mount.mounts.splice(idx,1)},lookup:(parent,name)=>{return parent.node_ops.lookup(parent,name)},mknod:(path,mode,dev)=>{var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);if(!name||name===\".\"||name===\"..\"){throw new FS.ErrnoError(28)}var errCode=FS.mayCreate(parent,name);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.mknod){throw new FS.ErrnoError(63)}return parent.node_ops.mknod(parent,name,mode,dev)},create:(path,mode)=>{mode=mode!==undefined?mode:438;mode&=4095;mode|=32768;return FS.mknod(path,mode,0)},mkdir:(path,mode)=>{mode=mode!==undefined?mode:511;mode&=511|512;mode|=16384;return FS.mknod(path,mode,0)},mkdirTree:(path,mode)=>{var dirs=path.split(\"/\");var d=\"\";for(var i=0;i<dirs.length;++i){if(!dirs[i])continue;d+=\"/\"+dirs[i];try{FS.mkdir(d,mode)}catch(e){if(e.errno!=20)throw e}}},mkdev:(path,mode,dev)=>{if(typeof dev==\"undefined\"){dev=mode;mode=438}mode|=8192;return FS.mknod(path,mode,dev)},symlink:(oldpath,newpath)=>{if(!PATH_FS.resolve(oldpath)){throw new FS.ErrnoError(44)}var lookup=FS.lookupPath(newpath,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var newname=PATH.basename(newpath);var errCode=FS.mayCreate(parent,newname);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.symlink){throw new FS.ErrnoError(63)}return parent.node_ops.symlink(parent,newname,oldpath)},rename:(old_path,new_path)=>{var old_dirname=PATH.dirname(old_path);var new_dirname=PATH.dirname(new_path);var old_name=PATH.basename(old_path);var new_name=PATH.basename(new_path);var lookup,old_dir,new_dir;lookup=FS.lookupPath(old_path,{parent:true});old_dir=lookup.node;lookup=FS.lookupPath(new_path,{parent:true});new_dir=lookup.node;if(!old_dir||!new_dir)throw new FS.ErrnoError(44);if(old_dir.mount!==new_dir.mount){throw new FS.ErrnoError(75)}var old_node=FS.lookupNode(old_dir,old_name);var relative=PATH_FS.relative(old_path,new_dirname);if(relative.charAt(0)!==\".\"){throw new FS.ErrnoError(28)}relative=PATH_FS.relative(new_path,old_dirname);if(relative.charAt(0)!==\".\"){throw new FS.ErrnoError(55)}var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(old_node===new_node){return}var isdir=FS.isDir(old_node.mode);var errCode=FS.mayDelete(old_dir,old_name,isdir);if(errCode){throw new FS.ErrnoError(errCode)}errCode=new_node?FS.mayDelete(new_dir,new_name,isdir):FS.mayCreate(new_dir,new_name);if(errCode){throw new FS.ErrnoError(errCode)}if(!old_dir.node_ops.rename){throw new FS.ErrnoError(63)}if(FS.isMountpoint(old_node)||new_node&&FS.isMountpoint(new_node)){throw new FS.ErrnoError(10)}if(new_dir!==old_dir){errCode=FS.nodePermissions(old_dir,\"w\");if(errCode){throw new FS.ErrnoError(errCode)}}FS.hashRemoveNode(old_node);try{old_dir.node_ops.rename(old_node,new_dir,new_name)}catch(e){throw e}finally{FS.hashAddNode(old_node)}},rmdir:path=>{var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,true);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.rmdir){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.rmdir(parent,name);FS.destroyNode(node)},readdir:path=>{var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;if(!node.node_ops.readdir){throw new FS.ErrnoError(54)}return node.node_ops.readdir(node)},unlink:path=>{var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,false);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.unlink){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.unlink(parent,name);FS.destroyNode(node)},readlink:path=>{var lookup=FS.lookupPath(path);var link=lookup.node;if(!link){throw new FS.ErrnoError(44)}if(!link.node_ops.readlink){throw new FS.ErrnoError(28)}return PATH_FS.resolve(FS.getPath(link.parent),link.node_ops.readlink(link))},stat:(path,dontFollow)=>{var lookup=FS.lookupPath(path,{follow:!dontFollow});var node=lookup.node;if(!node){throw new FS.ErrnoError(44)}if(!node.node_ops.getattr){throw new FS.ErrnoError(63)}return node.node_ops.getattr(node)},lstat:path=>{return FS.stat(path,true)},chmod:(path,mode,dontFollow)=>{var node;if(typeof path==\"string\"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}if(!node.node_ops.setattr){throw new FS.ErrnoError(63)}node.node_ops.setattr(node,{mode:mode&4095|node.mode&~4095,timestamp:Date.now()})},lchmod:(path,mode)=>{FS.chmod(path,mode,true)},fchmod:(fd,mode)=>{var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}FS.chmod(stream.node,mode)},chown:(path,uid,gid,dontFollow)=>{var node;if(typeof path==\"string\"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}if(!node.node_ops.setattr){throw new FS.ErrnoError(63)}node.node_ops.setattr(node,{timestamp:Date.now()})},lchown:(path,uid,gid)=>{FS.chown(path,uid,gid,true)},fchown:(fd,uid,gid)=>{var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}FS.chown(stream.node,uid,gid)},truncate:(path,len)=>{if(len<0){throw new FS.ErrnoError(28)}var node;if(typeof path==\"string\"){var lookup=FS.lookupPath(path,{follow:true});node=lookup.node}else{node=path}if(!node.node_ops.setattr){throw new FS.ErrnoError(63)}if(FS.isDir(node.mode)){throw new FS.ErrnoError(31)}if(!FS.isFile(node.mode)){throw new FS.ErrnoError(28)}var errCode=FS.nodePermissions(node,\"w\");if(errCode){throw new FS.ErrnoError(errCode)}node.node_ops.setattr(node,{size:len,timestamp:Date.now()})},ftruncate:(fd,len)=>{var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(28)}FS.truncate(stream.node,len)},utime:(path,atime,mtime)=>{var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;node.node_ops.setattr(node,{timestamp:Math.max(atime,mtime)})},open:(path,flags,mode)=>{if(path===\"\"){throw new FS.ErrnoError(44)}flags=typeof flags==\"string\"?FS_modeStringToFlags(flags):flags;mode=typeof mode==\"undefined\"?438:mode;if(flags&64){mode=mode&4095|32768}else{mode=0}var node;if(typeof path==\"object\"){node=path}else{path=PATH.normalize(path);try{var lookup=FS.lookupPath(path,{follow:!(flags&131072)});node=lookup.node}catch(e){}}var created=false;if(flags&64){if(node){if(flags&128){throw new FS.ErrnoError(20)}}else{node=FS.mknod(path,mode,0);created=true}}if(!node){throw new FS.ErrnoError(44)}if(FS.isChrdev(node.mode)){flags&=~512}if(flags&65536&&!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}if(!created){var errCode=FS.mayOpen(node,flags);if(errCode){throw new FS.ErrnoError(errCode)}}if(flags&512&&!created){FS.truncate(node,0)}flags&=~(128|512|131072);var stream=FS.createStream({node:node,path:FS.getPath(node),flags:flags,seekable:true,position:0,stream_ops:node.stream_ops,ungotten:[],error:false});if(stream.stream_ops.open){stream.stream_ops.open(stream)}if(Module[\"logReadFiles\"]&&!(flags&1)){if(!FS.readFiles)FS.readFiles={};if(!(path in FS.readFiles)){FS.readFiles[path]=1}}return stream},close:stream=>{if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(stream.getdents)stream.getdents=null;try{if(stream.stream_ops.close){stream.stream_ops.close(stream)}}catch(e){throw e}finally{FS.closeStream(stream.fd)}stream.fd=null},isClosed:stream=>{return stream.fd===null},llseek:(stream,offset,whence)=>{if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(!stream.seekable||!stream.stream_ops.llseek){throw new FS.ErrnoError(70)}if(whence!=0&&whence!=1&&whence!=2){throw new FS.ErrnoError(28)}stream.position=stream.stream_ops.llseek(stream,offset,whence);stream.ungotten=[];return stream.position},read:(stream,buffer,offset,length,position)=>{if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.read){throw new FS.ErrnoError(28)}var seeking=typeof position!=\"undefined\";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesRead=stream.stream_ops.read(stream,buffer,offset,length,position);if(!seeking)stream.position+=bytesRead;return bytesRead},write:(stream,buffer,offset,length,position,canOwn)=>{if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.write){throw new FS.ErrnoError(28)}if(stream.seekable&&stream.flags&1024){FS.llseek(stream,0,2)}var seeking=typeof position!=\"undefined\";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesWritten=stream.stream_ops.write(stream,buffer,offset,length,position,canOwn);if(!seeking)stream.position+=bytesWritten;return bytesWritten},allocate:(stream,offset,length)=>{if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(offset<0||length<=0){throw new FS.ErrnoError(28)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(!FS.isFile(stream.node.mode)&&!FS.isDir(stream.node.mode)){throw new FS.ErrnoError(43)}if(!stream.stream_ops.allocate){throw new FS.ErrnoError(138)}stream.stream_ops.allocate(stream,offset,length)},mmap:(stream,length,position,prot,flags)=>{if((prot&2)!==0&&(flags&2)===0&&(stream.flags&2097155)!==2){throw new FS.ErrnoError(2)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(2)}if(!stream.stream_ops.mmap){throw new FS.ErrnoError(43)}return stream.stream_ops.mmap(stream,length,position,prot,flags)},msync:(stream,buffer,offset,length,mmapFlags)=>{if(!stream.stream_ops.msync){return 0}return stream.stream_ops.msync(stream,buffer,offset,length,mmapFlags)},munmap:stream=>0,ioctl:(stream,cmd,arg)=>{if(!stream.stream_ops.ioctl){throw new FS.ErrnoError(59)}return stream.stream_ops.ioctl(stream,cmd,arg)},readFile:(path,opts={})=>{opts.flags=opts.flags||0;opts.encoding=opts.encoding||\"binary\";if(opts.encoding!==\"utf8\"&&opts.encoding!==\"binary\"){throw new Error(`Invalid encoding type \"${opts.encoding}\"`)}var ret;var stream=FS.open(path,opts.flags);var stat=FS.stat(path);var length=stat.size;var buf=new Uint8Array(length);FS.read(stream,buf,0,length,0);if(opts.encoding===\"utf8\"){ret=UTF8ArrayToString(buf,0)}else if(opts.encoding===\"binary\"){ret=buf}FS.close(stream);return ret},writeFile:(path,data,opts={})=>{opts.flags=opts.flags||577;var stream=FS.open(path,opts.flags,opts.mode);if(typeof data==\"string\"){var buf=new Uint8Array(lengthBytesUTF8(data)+1);var actualNumBytes=stringToUTF8Array(data,buf,0,buf.length);FS.write(stream,buf,0,actualNumBytes,undefined,opts.canOwn)}else if(ArrayBuffer.isView(data)){FS.write(stream,data,0,data.byteLength,undefined,opts.canOwn)}else{throw new Error(\"Unsupported data type\")}FS.close(stream)},cwd:()=>FS.currentPath,chdir:path=>{var lookup=FS.lookupPath(path,{follow:true});if(lookup.node===null){throw new FS.ErrnoError(44)}if(!FS.isDir(lookup.node.mode)){throw new FS.ErrnoError(54)}var errCode=FS.nodePermissions(lookup.node,\"x\");if(errCode){throw new FS.ErrnoError(errCode)}FS.currentPath=lookup.path},createDefaultDirectories:()=>{FS.mkdir(\"/tmp\");FS.mkdir(\"/home\");FS.mkdir(\"/home/web_user\")},createDefaultDevices:()=>{FS.mkdir(\"/dev\");FS.registerDevice(FS.makedev(1,3),{read:()=>0,write:(stream,buffer,offset,length,pos)=>length});FS.mkdev(\"/dev/null\",FS.makedev(1,3));TTY.register(FS.makedev(5,0),TTY.default_tty_ops);TTY.register(FS.makedev(6,0),TTY.default_tty1_ops);FS.mkdev(\"/dev/tty\",FS.makedev(5,0));FS.mkdev(\"/dev/tty1\",FS.makedev(6,0));var randomBuffer=new Uint8Array(1024),randomLeft=0;var randomByte=()=>{if(randomLeft===0){randomLeft=randomFill(randomBuffer).byteLength}return randomBuffer[--randomLeft]};FS.createDevice(\"/dev\",\"random\",randomByte);FS.createDevice(\"/dev\",\"urandom\",randomByte);FS.mkdir(\"/dev/shm\");FS.mkdir(\"/dev/shm/tmp\")},createSpecialDirectories:()=>{FS.mkdir(\"/proc\");var proc_self=FS.mkdir(\"/proc/self\");FS.mkdir(\"/proc/self/fd\");FS.mount({mount:()=>{var node=FS.createNode(proc_self,\"fd\",16384|511,73);node.node_ops={lookup:(parent,name)=>{var fd=+name;var stream=FS.getStream(fd);if(!stream)throw new FS.ErrnoError(8);var ret={parent:null,mount:{mountpoint:\"fake\"},node_ops:{readlink:()=>stream.path}};ret.parent=ret;return ret}};return node}},{},\"/proc/self/fd\")},createStandardStreams:()=>{if(Module[\"stdin\"]){FS.createDevice(\"/dev\",\"stdin\",Module[\"stdin\"])}else{FS.symlink(\"/dev/tty\",\"/dev/stdin\")}if(Module[\"stdout\"]){FS.createDevice(\"/dev\",\"stdout\",null,Module[\"stdout\"])}else{FS.symlink(\"/dev/tty\",\"/dev/stdout\")}if(Module[\"stderr\"]){FS.createDevice(\"/dev\",\"stderr\",null,Module[\"stderr\"])}else{FS.symlink(\"/dev/tty1\",\"/dev/stderr\")}var stdin=FS.open(\"/dev/stdin\",0);var stdout=FS.open(\"/dev/stdout\",1);var stderr=FS.open(\"/dev/stderr\",1)},ensureErrnoError:()=>{if(FS.ErrnoError)return;FS.ErrnoError=function ErrnoError(errno,node){this.name=\"ErrnoError\";this.node=node;this.setErrno=function(errno){this.errno=errno};this.setErrno(errno);this.message=\"FS error\"};FS.ErrnoError.prototype=new Error;FS.ErrnoError.prototype.constructor=FS.ErrnoError;[44].forEach(code=>{FS.genericErrors[code]=new FS.ErrnoError(code);FS.genericErrors[code].stack=\"<generic error, no stack>\"})},staticInit:()=>{FS.ensureErrnoError();FS.nameTable=new Array(4096);FS.mount(MEMFS,{},\"/\");FS.createDefaultDirectories();FS.createDefaultDevices();FS.createSpecialDirectories();FS.filesystems={\"MEMFS\":MEMFS,\"WORKERFS\":WORKERFS}},init:(input,output,error)=>{FS.init.initialized=true;FS.ensureErrnoError();Module[\"stdin\"]=input||Module[\"stdin\"];Module[\"stdout\"]=output||Module[\"stdout\"];Module[\"stderr\"]=error||Module[\"stderr\"];FS.createStandardStreams()},quit:()=>{FS.init.initialized=false;for(var i=0;i<FS.streams.length;i++){var stream=FS.streams[i];if(!stream){continue}FS.close(stream)}},findObject:(path,dontResolveLastLink)=>{var ret=FS.analyzePath(path,dontResolveLastLink);if(!ret.exists){return null}return ret.object},analyzePath:(path,dontResolveLastLink)=>{try{var lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});path=lookup.path}catch(e){}var ret={isRoot:false,exists:false,error:0,name:null,path:null,object:null,parentExists:false,parentPath:null,parentObject:null};try{var lookup=FS.lookupPath(path,{parent:true});ret.parentExists=true;ret.parentPath=lookup.path;ret.parentObject=lookup.node;ret.name=PATH.basename(path);lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});ret.exists=true;ret.path=lookup.path;ret.object=lookup.node;ret.name=lookup.node.name;ret.isRoot=lookup.path===\"/\"}catch(e){ret.error=e.errno}return ret},createPath:(parent,path,canRead,canWrite)=>{parent=typeof parent==\"string\"?parent:FS.getPath(parent);var parts=path.split(\"/\").reverse();while(parts.length){var part=parts.pop();if(!part)continue;var current=PATH.join2(parent,part);try{FS.mkdir(current)}catch(e){}parent=current}return current},createFile:(parent,name,properties,canRead,canWrite)=>{var path=PATH.join2(typeof parent==\"string\"?parent:FS.getPath(parent),name);var mode=FS_getMode(canRead,canWrite);return FS.create(path,mode)},createDataFile:(parent,name,data,canRead,canWrite,canOwn)=>{var path=name;if(parent){parent=typeof parent==\"string\"?parent:FS.getPath(parent);path=name?PATH.join2(parent,name):parent}var mode=FS_getMode(canRead,canWrite);var node=FS.create(path,mode);if(data){if(typeof data==\"string\"){var arr=new Array(data.length);for(var i=0,len=data.length;i<len;++i)arr[i]=data.charCodeAt(i);data=arr}FS.chmod(node,mode|146);var stream=FS.open(node,577);FS.write(stream,data,0,data.length,0,canOwn);FS.close(stream);FS.chmod(node,mode)}return node},createDevice:(parent,name,input,output)=>{var path=PATH.join2(typeof parent==\"string\"?parent:FS.getPath(parent),name);var mode=FS_getMode(!!input,!!output);if(!FS.createDevice.major)FS.createDevice.major=64;var dev=FS.makedev(FS.createDevice.major++,0);FS.registerDevice(dev,{open:stream=>{stream.seekable=false},close:stream=>{if(output&&output.buffer&&output.buffer.length){output(10)}},read:(stream,buffer,offset,length,pos)=>{var bytesRead=0;for(var i=0;i<length;i++){var result;try{result=input()}catch(e){throw new FS.ErrnoError(29)}if(result===undefined&&bytesRead===0){throw new FS.ErrnoError(6)}if(result===null||result===undefined)break;bytesRead++;buffer[offset+i]=result}if(bytesRead){stream.node.timestamp=Date.now()}return bytesRead},write:(stream,buffer,offset,length,pos)=>{for(var i=0;i<length;i++){try{output(buffer[offset+i])}catch(e){throw new FS.ErrnoError(29)}}if(length){stream.node.timestamp=Date.now()}return i}});return FS.mkdev(path,mode,dev)},forceLoadFile:obj=>{if(obj.isDevice||obj.isFolder||obj.link||obj.contents)return true;if(typeof XMLHttpRequest!=\"undefined\"){throw new Error(\"Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread.\")}else if(read_){try{obj.contents=intArrayFromString(read_(obj.url),true);obj.usedBytes=obj.contents.length}catch(e){throw new FS.ErrnoError(29)}}else{throw new Error(\"Cannot load without read() or XMLHttpRequest.\")}},createLazyFile:(parent,name,url,canRead,canWrite)=>{function LazyUint8Array(){this.lengthKnown=false;this.chunks=[]}LazyUint8Array.prototype.get=function LazyUint8Array_get(idx){if(idx>this.length-1||idx<0){return undefined}var chunkOffset=idx%this.chunkSize;var chunkNum=idx/this.chunkSize|0;return this.getter(chunkNum)[chunkOffset]};LazyUint8Array.prototype.setDataGetter=function LazyUint8Array_setDataGetter(getter){this.getter=getter};LazyUint8Array.prototype.cacheLength=function LazyUint8Array_cacheLength(){var xhr=new XMLHttpRequest;xhr.open(\"HEAD\",url,false);xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error(\"Couldn't load \"+url+\". Status: \"+xhr.status);var datalength=Number(xhr.getResponseHeader(\"Content-length\"));var header;var hasByteServing=(header=xhr.getResponseHeader(\"Accept-Ranges\"))&&header===\"bytes\";var usesGzip=(header=xhr.getResponseHeader(\"Content-Encoding\"))&&header===\"gzip\";var chunkSize=1024*1024;if(!hasByteServing)chunkSize=datalength;var doXHR=(from,to)=>{if(from>to)throw new Error(\"invalid range (\"+from+\", \"+to+\") or no bytes requested!\");if(to>datalength-1)throw new Error(\"only \"+datalength+\" bytes available! programmer error!\");var xhr=new XMLHttpRequest;xhr.open(\"GET\",url,false);if(datalength!==chunkSize)xhr.setRequestHeader(\"Range\",\"bytes=\"+from+\"-\"+to);xhr.responseType=\"arraybuffer\";if(xhr.overrideMimeType){xhr.overrideMimeType(\"text/plain; charset=x-user-defined\")}xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error(\"Couldn't load \"+url+\". Status: \"+xhr.status);if(xhr.response!==undefined){return new Uint8Array(xhr.response||[])}return intArrayFromString(xhr.responseText||\"\",true)};var lazyArray=this;lazyArray.setDataGetter(chunkNum=>{var start=chunkNum*chunkSize;var end=(chunkNum+1)*chunkSize-1;end=Math.min(end,datalength-1);if(typeof lazyArray.chunks[chunkNum]==\"undefined\"){lazyArray.chunks[chunkNum]=doXHR(start,end)}if(typeof lazyArray.chunks[chunkNum]==\"undefined\")throw new Error(\"doXHR failed!\");return lazyArray.chunks[chunkNum]});if(usesGzip||!datalength){chunkSize=datalength=1;datalength=this.getter(0).length;chunkSize=datalength;out(\"LazyFiles on gzip forces download of the whole file when length is accessed\")}this._length=datalength;this._chunkSize=chunkSize;this.lengthKnown=true};if(typeof XMLHttpRequest!=\"undefined\"){if(!ENVIRONMENT_IS_WORKER)throw\"Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc\";var lazyArray=new LazyUint8Array;Object.defineProperties(lazyArray,{length:{get:function(){if(!this.lengthKnown){this.cacheLength()}return this._length}},chunkSize:{get:function(){if(!this.lengthKnown){this.cacheLength()}return this._chunkSize}}});var properties={isDevice:false,contents:lazyArray}}else{var properties={isDevice:false,url:url}}var node=FS.createFile(parent,name,properties,canRead,canWrite);if(properties.contents){node.contents=properties.contents}else if(properties.url){node.contents=null;node.url=properties.url}Object.defineProperties(node,{usedBytes:{get:function(){return this.contents.length}}});var stream_ops={};var keys=Object.keys(node.stream_ops);keys.forEach(key=>{var fn=node.stream_ops[key];stream_ops[key]=function forceLoadLazyFile(){FS.forceLoadFile(node);return fn.apply(null,arguments)}});function writeChunks(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=contents.length)return 0;var size=Math.min(contents.length-position,length);if(contents.slice){for(var i=0;i<size;i++){buffer[offset+i]=contents[position+i]}}else{for(var i=0;i<size;i++){buffer[offset+i]=contents.get(position+i)}}return size}stream_ops.read=(stream,buffer,offset,length,position)=>{FS.forceLoadFile(node);return writeChunks(stream,buffer,offset,length,position)};stream_ops.mmap=(stream,length,position,prot,flags)=>{FS.forceLoadFile(node);var ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}writeChunks(stream,HEAP8,ptr,length,position);return{ptr:ptr,allocated:true}};node.stream_ops=stream_ops;return node}};var SYSCALLS={DEFAULT_POLLMASK:5,calculateAt:function(dirfd,path,allowEmpty){if(PATH.isAbs(path)){return path}var dir;if(dirfd===-100){dir=FS.cwd()}else{var dirstream=SYSCALLS.getStreamFromFD(dirfd);dir=dirstream.path}if(path.length==0){if(!allowEmpty){throw new FS.ErrnoError(44)}return dir}return PATH.join2(dir,path)},doStat:function(func,path,buf){try{var stat=func(path)}catch(e){if(e&&e.node&&PATH.normalize(path)!==PATH.normalize(FS.getPath(e.node))){return-54}throw e}HEAP32[buf>>2]=stat.dev;HEAP32[buf+8>>2]=stat.ino;HEAP32[buf+12>>2]=stat.mode;HEAPU32[buf+16>>2]=stat.nlink;HEAP32[buf+20>>2]=stat.uid;HEAP32[buf+24>>2]=stat.gid;HEAP32[buf+28>>2]=stat.rdev;HEAP64[buf+40>>3]=BigInt(stat.size);HEAP32[buf+48>>2]=4096;HEAP32[buf+52>>2]=stat.blocks;var atime=stat.atime.getTime();var mtime=stat.mtime.getTime();var ctime=stat.ctime.getTime();HEAP64[buf+56>>3]=BigInt(Math.floor(atime/1e3));HEAPU32[buf+64>>2]=atime%1e3*1e3;HEAP64[buf+72>>3]=BigInt(Math.floor(mtime/1e3));HEAPU32[buf+80>>2]=mtime%1e3*1e3;HEAP64[buf+88>>3]=BigInt(Math.floor(ctime/1e3));HEAPU32[buf+96>>2]=ctime%1e3*1e3;HEAP64[buf+104>>3]=BigInt(stat.ino);return 0},doMsync:function(addr,stream,len,flags,offset){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}if(flags&2){return 0}var buffer=HEAPU8.slice(addr,addr+len);FS.msync(stream,buffer,offset,len,flags)},varargs:undefined,get:function(){SYSCALLS.varargs+=4;var ret=HEAP32[SYSCALLS.varargs-4>>2];return ret},getStr:function(ptr){var ret=UTF8ToString(ptr);return ret},getStreamFromFD:function(fd){var stream=FS.getStream(fd);if(!stream)throw new FS.ErrnoError(8);return stream}};function ___syscall__newselect(nfds,readfds,writefds,exceptfds,timeout){try{var total=0;var srcReadLow=readfds?HEAP32[readfds>>2]:0,srcReadHigh=readfds?HEAP32[readfds+4>>2]:0;var srcWriteLow=writefds?HEAP32[writefds>>2]:0,srcWriteHigh=writefds?HEAP32[writefds+4>>2]:0;var srcExceptLow=exceptfds?HEAP32[exceptfds>>2]:0,srcExceptHigh=exceptfds?HEAP32[exceptfds+4>>2]:0;var dstReadLow=0,dstReadHigh=0;var dstWriteLow=0,dstWriteHigh=0;var dstExceptLow=0,dstExceptHigh=0;var allLow=(readfds?HEAP32[readfds>>2]:0)|(writefds?HEAP32[writefds>>2]:0)|(exceptfds?HEAP32[exceptfds>>2]:0);var allHigh=(readfds?HEAP32[readfds+4>>2]:0)|(writefds?HEAP32[writefds+4>>2]:0)|(exceptfds?HEAP32[exceptfds+4>>2]:0);var check=function(fd,low,high,val){return fd<32?low&val:high&val};for(var fd=0;fd<nfds;fd++){var mask=1<<fd%32;if(!check(fd,allLow,allHigh,mask)){continue}var stream=SYSCALLS.getStreamFromFD(fd);var flags=SYSCALLS.DEFAULT_POLLMASK;if(stream.stream_ops.poll){flags=stream.stream_ops.poll(stream)}if(flags&1&&check(fd,srcReadLow,srcReadHigh,mask)){fd<32?dstReadLow=dstReadLow|mask:dstReadHigh=dstReadHigh|mask;total++}if(flags&4&&check(fd,srcWriteLow,srcWriteHigh,mask)){fd<32?dstWriteLow=dstWriteLow|mask:dstWriteHigh=dstWriteHigh|mask;total++}if(flags&2&&check(fd,srcExceptLow,srcExceptHigh,mask)){fd<32?dstExceptLow=dstExceptLow|mask:dstExceptHigh=dstExceptHigh|mask;total++}}if(readfds){HEAP32[readfds>>2]=dstReadLow;HEAP32[readfds+4>>2]=dstReadHigh}if(writefds){HEAP32[writefds>>2]=dstWriteLow;HEAP32[writefds+4>>2]=dstWriteHigh}if(exceptfds){HEAP32[exceptfds>>2]=dstExceptLow;HEAP32[exceptfds+4>>2]=dstExceptHigh}return total}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}var SOCKFS={mount:function(mount){Module[\"websocket\"]=Module[\"websocket\"]&&\"object\"===typeof Module[\"websocket\"]?Module[\"websocket\"]:{};Module[\"websocket\"]._callbacks={};Module[\"websocket\"][\"on\"]=function(event,callback){if(\"function\"===typeof callback){this._callbacks[event]=callback}return this};Module[\"websocket\"].emit=function(event,param){if(\"function\"===typeof this._callbacks[event]){this._callbacks[event].call(this,param)}};return FS.createNode(null,\"/\",16384|511,0)},createSocket:function(family,type,protocol){type&=~526336;var streaming=type==1;if(streaming&&protocol&&protocol!=6){throw new FS.ErrnoError(66)}var sock={family:family,type:type,protocol:protocol,server:null,error:null,peers:{},pending:[],recv_queue:[],sock_ops:SOCKFS.websocket_sock_ops};var name=SOCKFS.nextname();var node=FS.createNode(SOCKFS.root,name,49152,0);node.sock=sock;var stream=FS.createStream({path:name,node:node,flags:2,seekable:false,stream_ops:SOCKFS.stream_ops});sock.stream=stream;return sock},getSocket:function(fd){var stream=FS.getStream(fd);if(!stream||!FS.isSocket(stream.node.mode)){return null}return stream.node.sock},stream_ops:{poll:function(stream){var sock=stream.node.sock;return sock.sock_ops.poll(sock)},ioctl:function(stream,request,varargs){var sock=stream.node.sock;return sock.sock_ops.ioctl(sock,request,varargs)},read:function(stream,buffer,offset,length,position){var sock=stream.node.sock;var msg=sock.sock_ops.recvmsg(sock,length);if(!msg){return 0}buffer.set(msg.buffer,offset);return msg.buffer.length},write:function(stream,buffer,offset,length,position){var sock=stream.node.sock;return sock.sock_ops.sendmsg(sock,buffer,offset,length)},close:function(stream){var sock=stream.node.sock;sock.sock_ops.close(sock)}},nextname:function(){if(!SOCKFS.nextname.current){SOCKFS.nextname.current=0}return\"socket[\"+SOCKFS.nextname.current+++\"]\"},websocket_sock_ops:{createPeer:function(sock,addr,port){var ws;if(typeof addr==\"object\"){ws=addr;addr=null;port=null}if(ws){if(ws._socket){addr=ws._socket.remoteAddress;port=ws._socket.remotePort}else{var result=/ws[s]?:\\/\\/([^:]+):(\\d+)/.exec(ws.url);if(!result){throw new Error(\"WebSocket URL must be in the format ws(s)://address:port\")}addr=result[1];port=parseInt(result[2],10)}}else{try{var runtimeConfig=Module[\"websocket\"]&&\"object\"===typeof Module[\"websocket\"];var url=\"ws:#\".replace(\"#\",\"//\");if(runtimeConfig){if(\"string\"===typeof Module[\"websocket\"][\"url\"]){url=Module[\"websocket\"][\"url\"]}}if(url===\"ws://\"||url===\"wss://\"){var parts=addr.split(\"/\");url=url+parts[0]+\":\"+port+\"/\"+parts.slice(1).join(\"/\")}var subProtocols=\"binary\";if(runtimeConfig){if(\"string\"===typeof Module[\"websocket\"][\"subprotocol\"]){subProtocols=Module[\"websocket\"][\"subprotocol\"]}}var opts=undefined;if(subProtocols!==\"null\"){subProtocols=subProtocols.replace(/^ +| +$/g,\"\").split(/ *, */);opts=subProtocols}if(runtimeConfig&&null===Module[\"websocket\"][\"subprotocol\"]){subProtocols=\"null\";opts=undefined}var WebSocketConstructor;{WebSocketConstructor=WebSocket}ws=new WebSocketConstructor(url,opts);ws.binaryType=\"arraybuffer\"}catch(e){throw new FS.ErrnoError(23)}}var peer={addr:addr,port:port,socket:ws,dgram_send_queue:[]};SOCKFS.websocket_sock_ops.addPeer(sock,peer);SOCKFS.websocket_sock_ops.handlePeerEvents(sock,peer);if(sock.type===2&&typeof sock.sport!=\"undefined\"){peer.dgram_send_queue.push(new Uint8Array([255,255,255,255,\"p\".charCodeAt(0),\"o\".charCodeAt(0),\"r\".charCodeAt(0),\"t\".charCodeAt(0),(sock.sport&65280)>>8,sock.sport&255]))}return peer},getPeer:function(sock,addr,port){return sock.peers[addr+\":\"+port]},addPeer:function(sock,peer){sock.peers[peer.addr+\":\"+peer.port]=peer},removePeer:function(sock,peer){delete sock.peers[peer.addr+\":\"+peer.port]},handlePeerEvents:function(sock,peer){var first=true;var handleOpen=function(){Module[\"websocket\"].emit(\"open\",sock.stream.fd);try{var queued=peer.dgram_send_queue.shift();while(queued){peer.socket.send(queued);queued=peer.dgram_send_queue.shift()}}catch(e){peer.socket.close()}};function handleMessage(data){if(typeof data==\"string\"){var encoder=new TextEncoder;data=encoder.encode(data)}else{assert(data.byteLength!==undefined);if(data.byteLength==0){return}data=new Uint8Array(data)}var wasfirst=first;first=false;if(wasfirst&&data.length===10&&data[0]===255&&data[1]===255&&data[2]===255&&data[3]===255&&data[4]===\"p\".charCodeAt(0)&&data[5]===\"o\".charCodeAt(0)&&data[6]===\"r\".charCodeAt(0)&&data[7]===\"t\".charCodeAt(0)){var newport=data[8]<<8|data[9];SOCKFS.websocket_sock_ops.removePeer(sock,peer);peer.port=newport;SOCKFS.websocket_sock_ops.addPeer(sock,peer);return}sock.recv_queue.push({addr:peer.addr,port:peer.port,data:data});Module[\"websocket\"].emit(\"message\",sock.stream.fd)}if(ENVIRONMENT_IS_NODE){peer.socket.on(\"open\",handleOpen);peer.socket.on(\"message\",function(data,isBinary){if(!isBinary){return}handleMessage(new Uint8Array(data).buffer)});peer.socket.on(\"close\",function(){Module[\"websocket\"].emit(\"close\",sock.stream.fd)});peer.socket.on(\"error\",function(error){sock.error=14;Module[\"websocket\"].emit(\"error\",[sock.stream.fd,sock.error,\"ECONNREFUSED: Connection refused\"])})}else{peer.socket.onopen=handleOpen;peer.socket.onclose=function(){Module[\"websocket\"].emit(\"close\",sock.stream.fd)};peer.socket.onmessage=function peer_socket_onmessage(event){handleMessage(event.data)};peer.socket.onerror=function(error){sock.error=14;Module[\"websocket\"].emit(\"error\",[sock.stream.fd,sock.error,\"ECONNREFUSED: Connection refused\"])}}},poll:function(sock){if(sock.type===1&&sock.server){return sock.pending.length?64|1:0}var mask=0;var dest=sock.type===1?SOCKFS.websocket_sock_ops.getPeer(sock,sock.daddr,sock.dport):null;if(sock.recv_queue.length||!dest||dest&&dest.socket.readyState===dest.socket.CLOSING||dest&&dest.socket.readyState===dest.socket.CLOSED){mask|=64|1}if(!dest||dest&&dest.socket.readyState===dest.socket.OPEN){mask|=4}if(dest&&dest.socket.readyState===dest.socket.CLOSING||dest&&dest.socket.readyState===dest.socket.CLOSED){mask|=16}return mask},ioctl:function(sock,request,arg){switch(request){case 21531:var bytes=0;if(sock.recv_queue.length){bytes=sock.recv_queue[0].data.length}HEAP32[arg>>2]=bytes;return 0;default:return 28}},close:function(sock){if(sock.server){try{sock.server.close()}catch(e){}sock.server=null}var peers=Object.keys(sock.peers);for(var i=0;i<peers.length;i++){var peer=sock.peers[peers[i]];try{peer.socket.close()}catch(e){}SOCKFS.websocket_sock_ops.removePeer(sock,peer)}return 0},bind:function(sock,addr,port){if(typeof sock.saddr!=\"undefined\"||typeof sock.sport!=\"undefined\"){throw new FS.ErrnoError(28)}sock.saddr=addr;sock.sport=port;if(sock.type===2){if(sock.server){sock.server.close();sock.server=null}try{sock.sock_ops.listen(sock,0)}catch(e){if(!(e.name===\"ErrnoError\"))throw e;if(e.errno!==138)throw e}}},connect:function(sock,addr,port){if(sock.server){throw new FS.ErrnoError(138)}if(typeof sock.daddr!=\"undefined\"&&typeof sock.dport!=\"undefined\"){var dest=SOCKFS.websocket_sock_ops.getPeer(sock,sock.daddr,sock.dport);if(dest){if(dest.socket.readyState===dest.socket.CONNECTING){throw new FS.ErrnoError(7)}else{throw new FS.ErrnoError(30)}}}var peer=SOCKFS.websocket_sock_ops.createPeer(sock,addr,port);sock.daddr=peer.addr;sock.dport=peer.port;throw new FS.ErrnoError(26)},listen:function(sock,backlog){if(!ENVIRONMENT_IS_NODE){throw new FS.ErrnoError(138)}},accept:function(listensock){if(!listensock.server||!listensock.pending.length){throw new FS.ErrnoError(28)}var newsock=listensock.pending.shift();newsock.stream.flags=listensock.stream.flags;return newsock},getname:function(sock,peer){var addr,port;if(peer){if(sock.daddr===undefined||sock.dport===undefined){throw new FS.ErrnoError(53)}addr=sock.daddr;port=sock.dport}else{addr=sock.saddr||0;port=sock.sport||0}return{addr:addr,port:port}},sendmsg:function(sock,buffer,offset,length,addr,port){if(sock.type===2){if(addr===undefined||port===undefined){addr=sock.daddr;port=sock.dport}if(addr===undefined||port===undefined){throw new FS.ErrnoError(17)}}else{addr=sock.daddr;port=sock.dport}var dest=SOCKFS.websocket_sock_ops.getPeer(sock,addr,port);if(sock.type===1){if(!dest||dest.socket.readyState===dest.socket.CLOSING||dest.socket.readyState===dest.socket.CLOSED){throw new FS.ErrnoError(53)}else if(dest.socket.readyState===dest.socket.CONNECTING){throw new FS.ErrnoError(6)}}if(ArrayBuffer.isView(buffer)){offset+=buffer.byteOffset;buffer=buffer.buffer}var data;data=buffer.slice(offset,offset+length);if(sock.type===2){if(!dest||dest.socket.readyState!==dest.socket.OPEN){if(!dest||dest.socket.readyState===dest.socket.CLOSING||dest.socket.readyState===dest.socket.CLOSED){dest=SOCKFS.websocket_sock_ops.createPeer(sock,addr,port)}dest.dgram_send_queue.push(data);return length}}try{dest.socket.send(data);return length}catch(e){throw new FS.ErrnoError(28)}},recvmsg:function(sock,length){if(sock.type===1&&sock.server){throw new FS.ErrnoError(53)}var queued=sock.recv_queue.shift();if(!queued){if(sock.type===1){var dest=SOCKFS.websocket_sock_ops.getPeer(sock,sock.daddr,sock.dport);if(!dest){throw new FS.ErrnoError(53)}if(dest.socket.readyState===dest.socket.CLOSING||dest.socket.readyState===dest.socket.CLOSED){return null}throw new FS.ErrnoError(6)}throw new FS.ErrnoError(6)}var queuedLength=queued.data.byteLength||queued.data.length;var queuedOffset=queued.data.byteOffset||0;var queuedBuffer=queued.data.buffer||queued.data;var bytesRead=Math.min(length,queuedLength);var res={buffer:new Uint8Array(queuedBuffer,queuedOffset,bytesRead),addr:queued.addr,port:queued.port};if(sock.type===1&&bytesRead<queuedLength){var bytesRemaining=queuedLength-bytesRead;queued.data=new Uint8Array(queuedBuffer,queuedOffset+bytesRead,bytesRemaining);sock.recv_queue.unshift(queued)}return res}}};function getSocketFromFD(fd){var socket=SOCKFS.getSocket(fd);if(!socket)throw new FS.ErrnoError(8);return socket}function setErrNo(value){HEAP32[___errno_location()>>2]=value;return value}function inetPton4(str){var b=str.split(\".\");for(var i=0;i<4;i++){var tmp=Number(b[i]);if(isNaN(tmp))return null;b[i]=tmp}return(b[0]|b[1]<<8|b[2]<<16|b[3]<<24)>>>0}function jstoi_q(str){return parseInt(str)}function inetPton6(str){var words;var w,offset,z;var valid6regx=/^((?=.*::)(?!.*::.+::)(::)?([\\dA-F]{1,4}:(:|\\b)|){5}|([\\dA-F]{1,4}:){6})((([\\dA-F]{1,4}((?!\\3)::|:\\b|$))|(?!\\2\\3)){2}|(((2[0-4]|1\\d|[1-9])?\\d|25[0-5])\\.?\\b){4})$/i;var parts=[];if(!valid6regx.test(str)){return null}if(str===\"::\"){return[0,0,0,0,0,0,0,0]}if(str.startsWith(\"::\")){str=str.replace(\"::\",\"Z:\")}else{str=str.replace(\"::\",\":Z:\")}if(str.indexOf(\".\")>0){str=str.replace(new RegExp(\"[.]\",\"g\"),\":\");words=str.split(\":\");words[words.length-4]=jstoi_q(words[words.length-4])+jstoi_q(words[words.length-3])*256;words[words.length-3]=jstoi_q(words[words.length-2])+jstoi_q(words[words.length-1])*256;words=words.slice(0,words.length-2)}else{words=str.split(\":\")}offset=0;z=0;for(w=0;w<words.length;w++){if(typeof words[w]==\"string\"){if(words[w]===\"Z\"){for(z=0;z<8-words.length+1;z++){parts[w+z]=0}offset=z-1}else{parts[w+offset]=_htons(parseInt(words[w],16))}}else{parts[w+offset]=words[w]}}return[parts[1]<<16|parts[0],parts[3]<<16|parts[2],parts[5]<<16|parts[4],parts[7]<<16|parts[6]]}function writeSockaddr(sa,family,addr,port,addrlen){switch(family){case 2:addr=inetPton4(addr);zeroMemory(sa,16);if(addrlen){HEAP32[addrlen>>2]=16}HEAP16[sa>>1]=family;HEAP32[sa+4>>2]=addr;HEAP16[sa+2>>1]=_htons(port);break;case 10:addr=inetPton6(addr);zeroMemory(sa,28);if(addrlen){HEAP32[addrlen>>2]=28}HEAP32[sa>>2]=family;HEAP32[sa+8>>2]=addr[0];HEAP32[sa+12>>2]=addr[1];HEAP32[sa+16>>2]=addr[2];HEAP32[sa+20>>2]=addr[3];HEAP16[sa+2>>1]=_htons(port);break;default:return 5}return 0}var DNS={address_map:{id:1,addrs:{},names:{}},lookup_name:function(name){var res=inetPton4(name);if(res!==null){return name}res=inetPton6(name);if(res!==null){return name}var addr;if(DNS.address_map.addrs[name]){addr=DNS.address_map.addrs[name]}else{var id=DNS.address_map.id++;assert(id<65535,\"exceeded max address mappings of 65535\");addr=\"172.29.\"+(id&255)+\".\"+(id&65280);DNS.address_map.names[addr]=name;DNS.address_map.addrs[name]=addr}return addr},lookup_addr:function(addr){if(DNS.address_map.names[addr]){return DNS.address_map.names[addr]}return null}};function ___syscall_accept4(fd,addr,addrlen,flags,d1,d2){try{var sock=getSocketFromFD(fd);var newsock=sock.sock_ops.accept(sock);if(addr){var errno=writeSockaddr(addr,newsock.family,DNS.lookup_name(newsock.daddr),newsock.dport,addrlen)}return newsock.stream.fd}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function inetNtop4(addr){return(addr&255)+\".\"+(addr>>8&255)+\".\"+(addr>>16&255)+\".\"+(addr>>24&255)}function inetNtop6(ints){var str=\"\";var word=0;var longest=0;var lastzero=0;var zstart=0;var len=0;var i=0;var parts=[ints[0]&65535,ints[0]>>16,ints[1]&65535,ints[1]>>16,ints[2]&65535,ints[2]>>16,ints[3]&65535,ints[3]>>16];var hasipv4=true;var v4part=\"\";for(i=0;i<5;i++){if(parts[i]!==0){hasipv4=false;break}}if(hasipv4){v4part=inetNtop4(parts[6]|parts[7]<<16);if(parts[5]===-1){str=\"::ffff:\";str+=v4part;return str}if(parts[5]===0){str=\"::\";if(v4part===\"0.0.0.0\")v4part=\"\";if(v4part===\"0.0.0.1\")v4part=\"1\";str+=v4part;return str}}for(word=0;word<8;word++){if(parts[word]===0){if(word-lastzero>1){len=0}lastzero=word;len++}if(len>longest){longest=len;zstart=word-longest+1}}for(word=0;word<8;word++){if(longest>1){if(parts[word]===0&&word>=zstart&&word<zstart+longest){if(word===zstart){str+=\":\";if(zstart===0)str+=\":\"}continue}}str+=Number(_ntohs(parts[word]&65535)).toString(16);str+=word<7?\":\":\"\"}return str}function readSockaddr(sa,salen){var family=HEAP16[sa>>1];var port=_ntohs(HEAPU16[sa+2>>1]);var addr;switch(family){case 2:if(salen!==16){return{errno:28}}addr=HEAP32[sa+4>>2];addr=inetNtop4(addr);break;case 10:if(salen!==28){return{errno:28}}addr=[HEAP32[sa+8>>2],HEAP32[sa+12>>2],HEAP32[sa+16>>2],HEAP32[sa+20>>2]];addr=inetNtop6(addr);break;default:return{errno:5}}return{family:family,addr:addr,port:port}}function getSocketAddress(addrp,addrlen,allowNull){if(allowNull&&addrp===0)return null;var info=readSockaddr(addrp,addrlen);if(info.errno)throw new FS.ErrnoError(info.errno);info.addr=DNS.lookup_addr(info.addr)||info.addr;return info}function ___syscall_bind(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);var info=getSocketAddress(addr,addrlen);sock.sock_ops.bind(sock,info.addr,info.port);return 0}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_connect(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);var info=getSocketAddress(addr,addrlen);sock.sock_ops.connect(sock,info.addr,info.port);return 0}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_faccessat(dirfd,path,amode,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(amode&~7){return-28}var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;if(!node){return-44}var perms=\"\";if(amode&4)perms+=\"r\";if(amode&2)perms+=\"w\";if(amode&1)perms+=\"x\";if(perms&&FS.nodePermissions(node,perms)){return-2}return 0}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_fcntl64(fd,cmd,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(cmd){case 0:{var arg=SYSCALLS.get();if(arg<0){return-28}var newStream;newStream=FS.createStream(stream,arg);return newStream.fd}case 1:case 2:return 0;case 3:return stream.flags;case 4:{var arg=SYSCALLS.get();stream.flags|=arg;return 0}case 5:{var arg=SYSCALLS.get();var offset=0;HEAP16[arg+offset>>1]=2;return 0}case 6:case 7:return 0;case 16:case 8:return-28;case 9:setErrNo(28);return-1;default:{return-28}}}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_fstat64(fd,buf){try{var stream=SYSCALLS.getStreamFromFD(fd);return SYSCALLS.doStat(FS.stat,stream.path,buf)}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function stringToUTF8(str,outPtr,maxBytesToWrite){return stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite)}function ___syscall_getdents64(fd,dirp,count){try{var stream=SYSCALLS.getStreamFromFD(fd);if(!stream.getdents){stream.getdents=FS.readdir(stream.path)}var struct_size=280;var pos=0;var off=FS.llseek(stream,0,1);var idx=Math.floor(off/struct_size);while(idx<stream.getdents.length&&pos+struct_size<=count){var id;var type;var name=stream.getdents[idx];if(name===\".\"){id=stream.node.id;type=4}else if(name===\"..\"){var lookup=FS.lookupPath(stream.path,{parent:true});id=lookup.node.id;type=4}else{var child=FS.lookupNode(stream.node,name);id=child.id;type=FS.isChrdev(child.mode)?2:FS.isDir(child.mode)?4:FS.isLink(child.mode)?10:8}HEAP64[dirp+pos>>3]=BigInt(id);HEAP64[dirp+pos+8>>3]=BigInt((idx+1)*struct_size);HEAP16[dirp+pos+16>>1]=280;HEAP8[dirp+pos+18>>0]=type;stringToUTF8(name,dirp+pos+19,256);pos+=struct_size;idx+=1}FS.llseek(stream,idx*struct_size,0);return pos}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_getpeername(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);if(!sock.daddr){return-53}var errno=writeSockaddr(addr,sock.family,DNS.lookup_name(sock.daddr),sock.dport,addrlen);return 0}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_getsockname(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);var errno=writeSockaddr(addr,sock.family,DNS.lookup_name(sock.saddr||\"0.0.0.0\"),sock.sport,addrlen);return 0}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_getsockopt(fd,level,optname,optval,optlen,d1){try{var sock=getSocketFromFD(fd);if(level===1){if(optname===4){HEAP32[optval>>2]=sock.error;HEAP32[optlen>>2]=4;sock.error=null;return 0}}return-50}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_ioctl(fd,op,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(op){case 21509:case 21505:{if(!stream.tty)return-59;return 0}case 21510:case 21511:case 21512:case 21506:case 21507:case 21508:{if(!stream.tty)return-59;return 0}case 21519:{if(!stream.tty)return-59;var argp=SYSCALLS.get();HEAP32[argp>>2]=0;return 0}case 21520:{if(!stream.tty)return-59;return-28}case 21531:{var argp=SYSCALLS.get();return FS.ioctl(stream,op,argp)}case 21523:{if(!stream.tty)return-59;return 0}case 21524:{if(!stream.tty)return-59;return 0}default:return-28}}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_listen(fd,backlog){try{var sock=getSocketFromFD(fd);sock.sock_ops.listen(sock,backlog);return 0}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_lstat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.lstat,path,buf)}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_mkdirat(dirfd,path,mode){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);path=PATH.normalize(path);if(path[path.length-1]===\"/\")path=path.substr(0,path.length-1);FS.mkdir(path,mode,0);return 0}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_newfstatat(dirfd,path,buf,flags){try{path=SYSCALLS.getStr(path);var nofollow=flags&256;var allowEmpty=flags&4096;flags=flags&~6400;path=SYSCALLS.calculateAt(dirfd,path,allowEmpty);return SYSCALLS.doStat(nofollow?FS.lstat:FS.stat,path,buf)}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs;try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);var mode=varargs?SYSCALLS.get():0;return FS.open(path,flags,mode).fd}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_poll(fds,nfds,timeout){try{var nonzero=0;for(var i=0;i<nfds;i++){var pollfd=fds+8*i;var fd=HEAP32[pollfd>>2];var events=HEAP16[pollfd+4>>1];var mask=32;var stream=FS.getStream(fd);if(stream){mask=SYSCALLS.DEFAULT_POLLMASK;if(stream.stream_ops.poll){mask=stream.stream_ops.poll(stream)}}mask&=events|8|16;if(mask)nonzero++;HEAP16[pollfd+6>>1]=mask}return nonzero}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_recvfrom(fd,buf,len,flags,addr,addrlen){try{var sock=getSocketFromFD(fd);var msg=sock.sock_ops.recvmsg(sock,len);if(!msg)return 0;if(addr){var errno=writeSockaddr(addr,sock.family,DNS.lookup_name(msg.addr),msg.port,addrlen)}HEAPU8.set(msg.buffer,buf);return msg.buffer.byteLength}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_renameat(olddirfd,oldpath,newdirfd,newpath){try{oldpath=SYSCALLS.getStr(oldpath);newpath=SYSCALLS.getStr(newpath);oldpath=SYSCALLS.calculateAt(olddirfd,oldpath);newpath=SYSCALLS.calculateAt(newdirfd,newpath);FS.rename(oldpath,newpath);return 0}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_rmdir(path){try{path=SYSCALLS.getStr(path);FS.rmdir(path);return 0}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_sendto(fd,message,length,flags,addr,addr_len){try{var sock=getSocketFromFD(fd);var dest=getSocketAddress(addr,addr_len,true);if(!dest){return FS.write(sock.stream,HEAP8,message,length)}return sock.sock_ops.sendmsg(sock,HEAP8,message,length,dest.addr,dest.port)}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_socket(domain,type,protocol){try{var sock=SOCKFS.createSocket(domain,type,protocol);return sock.stream.fd}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_stat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.stat,path,buf)}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function ___syscall_unlinkat(dirfd,path,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(flags===0){FS.unlink(path)}else if(flags===512){FS.rmdir(path)}else{abort(\"Invalid flags passed to unlinkat\")}return 0}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}var nowIsMonotonic=true;function __emscripten_get_now_is_monotonic(){return nowIsMonotonic}function __emscripten_throw_longjmp(){throw Infinity}function readI53FromI64(ptr){return HEAPU32[ptr>>2]+HEAP32[ptr+4>>2]*4294967296}function __gmtime_js(time,tmPtr){var date=new Date(readI53FromI64(time)*1e3);HEAP32[tmPtr>>2]=date.getUTCSeconds();HEAP32[tmPtr+4>>2]=date.getUTCMinutes();HEAP32[tmPtr+8>>2]=date.getUTCHours();HEAP32[tmPtr+12>>2]=date.getUTCDate();HEAP32[tmPtr+16>>2]=date.getUTCMonth();HEAP32[tmPtr+20>>2]=date.getUTCFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getUTCDay();var start=Date.UTC(date.getUTCFullYear(),0,1,0,0,0,0);var yday=(date.getTime()-start)/(1e3*60*60*24)|0;HEAP32[tmPtr+28>>2]=yday}function isLeapYear(year){return year%4===0&&(year%100!==0||year%400===0)}var MONTH_DAYS_LEAP_CUMULATIVE=[0,31,60,91,121,152,182,213,244,274,305,335];var MONTH_DAYS_REGULAR_CUMULATIVE=[0,31,59,90,120,151,181,212,243,273,304,334];function ydayFromDate(date){var leap=isLeapYear(date.getFullYear());var monthDaysCumulative=leap?MONTH_DAYS_LEAP_CUMULATIVE:MONTH_DAYS_REGULAR_CUMULATIVE;var yday=monthDaysCumulative[date.getMonth()]+date.getDate()-1;return yday}function __localtime_js(time,tmPtr){var date=new Date(readI53FromI64(time)*1e3);HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr+36>>2]=-(date.getTimezoneOffset()*60);var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dst=(summerOffset!=winterOffset&&date.getTimezoneOffset()==Math.min(winterOffset,summerOffset))|0;HEAP32[tmPtr+32>>2]=dst}function __mktime_js(tmPtr){var date=new Date(HEAP32[tmPtr+20>>2]+1900,HEAP32[tmPtr+16>>2],HEAP32[tmPtr+12>>2],HEAP32[tmPtr+8>>2],HEAP32[tmPtr+4>>2],HEAP32[tmPtr>>2],0);var dst=HEAP32[tmPtr+32>>2];var guessedOffset=date.getTimezoneOffset();var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dstOffset=Math.min(winterOffset,summerOffset);if(dst<0){HEAP32[tmPtr+32>>2]=Number(summerOffset!=winterOffset&&dstOffset==guessedOffset)}else if(dst>0!=(dstOffset==guessedOffset)){var nonDstOffset=Math.max(winterOffset,summerOffset);var trueOffset=dst>0?dstOffset:nonDstOffset;date.setTime(date.getTime()+(trueOffset-guessedOffset)*6e4)}HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getYear();return date.getTime()/1e3|0}function __mmap_js(len,prot,flags,fd,off,allocated,addr){try{var stream=SYSCALLS.getStreamFromFD(fd);var res=FS.mmap(stream,len,off,prot,flags);var ptr=res.ptr;HEAP32[allocated>>2]=res.allocated;HEAPU32[addr>>2]=ptr;return 0}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function __munmap_js(addr,len,prot,flags,fd,offset){try{var stream=SYSCALLS.getStreamFromFD(fd);if(prot&2){SYSCALLS.doMsync(addr,stream,len,flags,offset)}FS.munmap(stream)}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return-e.errno}}function stringToNewUTF8(str){var size=lengthBytesUTF8(str)+1;var ret=_malloc(size);if(ret)stringToUTF8(str,ret,size);return ret}function __tzset_js(timezone,daylight,tzname){var currentYear=(new Date).getFullYear();var winter=new Date(currentYear,0,1);var summer=new Date(currentYear,6,1);var winterOffset=winter.getTimezoneOffset();var summerOffset=summer.getTimezoneOffset();var stdTimezoneOffset=Math.max(winterOffset,summerOffset);HEAPU32[timezone>>2]=stdTimezoneOffset*60;HEAP32[daylight>>2]=Number(winterOffset!=summerOffset);function extractZone(date){var match=date.toTimeString().match(/\\(([A-Za-z ]+)\\)$/);return match?match[1]:\"GMT\"}var winterName=extractZone(winter);var summerName=extractZone(summer);var winterNamePtr=stringToNewUTF8(winterName);var summerNamePtr=stringToNewUTF8(summerName);if(summerOffset<winterOffset){HEAPU32[tzname>>2]=winterNamePtr;HEAPU32[tzname+4>>2]=summerNamePtr}else{HEAPU32[tzname>>2]=summerNamePtr;HEAPU32[tzname+4>>2]=winterNamePtr}}function _abort(){abort(\"\")}Module[\"_abort\"]=_abort;function _dlopen(handle){abort(dlopenMissingError)}var readEmAsmArgsArray=[];function readEmAsmArgs(sigPtr,buf){readEmAsmArgsArray.length=0;var ch;buf>>=2;while(ch=HEAPU8[sigPtr++]){buf+=ch!=105&buf;readEmAsmArgsArray.push(ch==105?HEAP32[buf]:(ch==106?HEAP64:HEAPF64)[buf++>>1]);++buf}return readEmAsmArgsArray}function runEmAsmFunction(code,sigPtr,argbuf){var args=readEmAsmArgs(sigPtr,argbuf);return ASM_CONSTS[code].apply(null,args)}function _emscripten_asm_const_int(code,sigPtr,argbuf){return runEmAsmFunction(code,sigPtr,argbuf)}function _emscripten_date_now(){return Date.now()}function getHeapMax(){return 2147483648}function _emscripten_get_heap_max(){return getHeapMax()}var _emscripten_get_now;_emscripten_get_now=()=>performance.now();function _emscripten_memcpy_big(dest,src,num){HEAPU8.copyWithin(dest,src,src+num)}function emscripten_realloc_buffer(size){var b=wasmMemory.buffer;try{wasmMemory.grow(size-b.byteLength+65535>>>16);updateMemoryViews();return 1}catch(e){}}function _emscripten_resize_heap(requestedSize){var oldSize=HEAPU8.length;requestedSize=requestedSize>>>0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}var alignUp=(x,multiple)=>x+(multiple-x%multiple)%multiple;for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignUp(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=emscripten_realloc_buffer(newSize);if(replacement){return true}}return false}var ENV={};function getExecutableName(){return thisProgram||\"./this.program\"}function getEnvStrings(){if(!getEnvStrings.strings){var lang=(typeof navigator==\"object\"&&navigator.languages&&navigator.languages[0]||\"C\").replace(\"-\",\"_\")+\".UTF-8\";var env={\"USER\":\"web_user\",\"LOGNAME\":\"web_user\",\"PATH\":\"/\",\"PWD\":\"/\",\"HOME\":\"/home/web_user\",\"LANG\":lang,\"_\":getExecutableName()};for(var x in ENV){if(ENV[x]===undefined)delete env[x];else env[x]=ENV[x]}var strings=[];for(var x in env){strings.push(`${x}=${env[x]}`)}getEnvStrings.strings=strings}return getEnvStrings.strings}function stringToAscii(str,buffer){for(var i=0;i<str.length;++i){HEAP8[buffer++>>0]=str.charCodeAt(i)}HEAP8[buffer>>0]=0}function _environ_get(__environ,environ_buf){var bufSize=0;getEnvStrings().forEach(function(string,i){var ptr=environ_buf+bufSize;HEAPU32[__environ+i*4>>2]=ptr;stringToAscii(string,ptr);bufSize+=string.length+1});return 0}function _environ_sizes_get(penviron_count,penviron_buf_size){var strings=getEnvStrings();HEAPU32[penviron_count>>2]=strings.length;var bufSize=0;strings.forEach(function(string){bufSize+=string.length+1});HEAPU32[penviron_buf_size>>2]=bufSize;return 0}function _proc_exit(code){EXITSTATUS=code;if(!keepRuntimeAlive()){if(Module[\"onExit\"])Module[\"onExit\"](code);ABORT=true}quit_(code,new ExitStatus(code))}function exitJS(status,implicit){EXITSTATUS=status;_proc_exit(status)}var _exit=exitJS;function _fd_close(fd){try{var stream=SYSCALLS.getStreamFromFD(fd);FS.close(stream);return 0}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return e.errno}}function _fd_fdstat_get(fd,pbuf){try{var rightsBase=0;var rightsInheriting=0;var flags=0;{var stream=SYSCALLS.getStreamFromFD(fd);var type=stream.tty?2:FS.isDir(stream.mode)?3:FS.isLink(stream.mode)?7:4}HEAP8[pbuf>>0]=type;HEAP16[pbuf+2>>1]=flags;HEAP64[pbuf+8>>3]=BigInt(rightsBase);HEAP64[pbuf+16>>3]=BigInt(rightsInheriting);return 0}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return e.errno}}function doReadv(stream,iov,iovcnt,offset){var ret=0;for(var i=0;i<iovcnt;i++){var ptr=HEAPU32[iov>>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.read(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr<len)break;if(typeof offset!==\"undefined\"){offset+=curr}}return ret}function _fd_read(fd,iov,iovcnt,pnum){try{var stream=SYSCALLS.getStreamFromFD(fd);var num=doReadv(stream,iov,iovcnt);HEAPU32[pnum>>2]=num;return 0}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return e.errno}}var MAX_INT53=9007199254740992;var MIN_INT53=-9007199254740992;function bigintToI53Checked(num){return num<MIN_INT53||num>MAX_INT53?NaN:Number(num)}function _fd_seek(fd,offset,whence,newOffset){try{offset=bigintToI53Checked(offset);if(isNaN(offset))return 61;var stream=SYSCALLS.getStreamFromFD(fd);FS.llseek(stream,offset,whence);HEAP64[newOffset>>3]=BigInt(stream.position);if(stream.getdents&&offset===0&&whence===0)stream.getdents=null;return 0}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return e.errno}}function doWritev(stream,iov,iovcnt,offset){var ret=0;for(var i=0;i<iovcnt;i++){var ptr=HEAPU32[iov>>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.write(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(typeof offset!==\"undefined\"){offset+=curr}}return ret}function _fd_write(fd,iov,iovcnt,pnum){try{var stream=SYSCALLS.getStreamFromFD(fd);var num=doWritev(stream,iov,iovcnt);HEAPU32[pnum>>2]=num;return 0}catch(e){if(typeof FS==\"undefined\"||!(e.name===\"ErrnoError\"))throw e;return e.errno}}function _getaddrinfo(node,service,hint,out){var addr=0;var port=0;var flags=0;var family=0;var type=0;var proto=0;var ai;function allocaddrinfo(family,type,proto,canon,addr,port){var sa,salen,ai;var errno;salen=family===10?28:16;addr=family===10?inetNtop6(addr):inetNtop4(addr);sa=_malloc(salen);errno=writeSockaddr(sa,family,addr,port);assert(!errno);ai=_malloc(32);HEAP32[ai+4>>2]=family;HEAP32[ai+8>>2]=type;HEAP32[ai+12>>2]=proto;HEAPU32[ai+24>>2]=canon;HEAPU32[ai+20>>2]=sa;if(family===10){HEAP32[ai+16>>2]=28}else{HEAP32[ai+16>>2]=16}HEAP32[ai+28>>2]=0;return ai}if(hint){flags=HEAP32[hint>>2];family=HEAP32[hint+4>>2];type=HEAP32[hint+8>>2];proto=HEAP32[hint+12>>2]}if(type&&!proto){proto=type===2?17:6}if(!type&&proto){type=proto===17?2:1}if(proto===0){proto=6}if(type===0){type=1}if(!node&&!service){return-2}if(flags&~(1|2|4|1024|8|16|32)){return-1}if(hint!==0&&HEAP32[hint>>2]&2&&!node){return-1}if(flags&32){return-2}if(type!==0&&type!==1&&type!==2){return-7}if(family!==0&&family!==2&&family!==10){return-6}if(service){service=UTF8ToString(service);port=parseInt(service,10);if(isNaN(port)){if(flags&1024){return-2}return-8}}if(!node){if(family===0){family=2}if((flags&1)===0){if(family===2){addr=_htonl(2130706433)}else{addr=[0,0,0,1]}}ai=allocaddrinfo(family,type,proto,null,addr,port);HEAPU32[out>>2]=ai;return 0}node=UTF8ToString(node);addr=inetPton4(node);if(addr!==null){if(family===0||family===2){family=2}else if(family===10&&flags&8){addr=[0,0,_htonl(65535),addr];family=10}else{return-2}}else{addr=inetPton6(node);if(addr!==null){if(family===0||family===10){family=10}else{return-2}}}if(addr!=null){ai=allocaddrinfo(family,type,proto,node,addr,port);HEAPU32[out>>2]=ai;return 0}if(flags&4){return-2}node=DNS.lookup_name(node);addr=inetPton4(node);if(family===0){family=2}else if(family===10){addr=[0,0,_htonl(65535),addr]}ai=allocaddrinfo(family,type,proto,null,addr,port);HEAPU32[out>>2]=ai;return 0}function _getnameinfo(sa,salen,node,nodelen,serv,servlen,flags){var info=readSockaddr(sa,salen);if(info.errno){return-6}var port=info.port;var addr=info.addr;var overflowed=false;if(node&&nodelen){var lookup;if(flags&1||!(lookup=DNS.lookup_addr(addr))){if(flags&8){return-2}}else{addr=lookup}var numBytesWrittenExclNull=stringToUTF8(addr,node,nodelen);if(numBytesWrittenExclNull+1>=nodelen){overflowed=true}}if(serv&&servlen){port=\"\"+port;var numBytesWrittenExclNull=stringToUTF8(port,serv,servlen);if(numBytesWrittenExclNull+1>=servlen){overflowed=true}}if(overflowed){return-12}return 0}function arraySum(array,index){var sum=0;for(var i=0;i<=index;sum+=array[i++]){}return sum}var MONTH_DAYS_LEAP=[31,29,31,30,31,30,31,31,30,31,30,31];var MONTH_DAYS_REGULAR=[31,28,31,30,31,30,31,31,30,31,30,31];function addDays(date,days){var newDate=new Date(date.getTime());while(days>0){var leap=isLeapYear(newDate.getFullYear());var currentMonth=newDate.getMonth();var daysInCurrentMonth=(leap?MONTH_DAYS_LEAP:MONTH_DAYS_REGULAR)[currentMonth];if(days>daysInCurrentMonth-newDate.getDate()){days-=daysInCurrentMonth-newDate.getDate()+1;newDate.setDate(1);if(currentMonth<11){newDate.setMonth(currentMonth+1)}else{newDate.setMonth(0);newDate.setFullYear(newDate.getFullYear()+1)}}else{newDate.setDate(newDate.getDate()+days);return newDate}}return newDate}function writeArrayToMemory(array,buffer){HEAP8.set(array,buffer)}function _strftime(s,maxsize,format,tm){var tm_zone=HEAP32[tm+40>>2];var date={tm_sec:HEAP32[tm>>2],tm_min:HEAP32[tm+4>>2],tm_hour:HEAP32[tm+8>>2],tm_mday:HEAP32[tm+12>>2],tm_mon:HEAP32[tm+16>>2],tm_year:HEAP32[tm+20>>2],tm_wday:HEAP32[tm+24>>2],tm_yday:HEAP32[tm+28>>2],tm_isdst:HEAP32[tm+32>>2],tm_gmtoff:HEAP32[tm+36>>2],tm_zone:tm_zone?UTF8ToString(tm_zone):\"\"};var pattern=UTF8ToString(format);var EXPANSION_RULES_1={\"%c\":\"%a %b %d %H:%M:%S %Y\",\"%D\":\"%m/%d/%y\",\"%F\":\"%Y-%m-%d\",\"%h\":\"%b\",\"%r\":\"%I:%M:%S %p\",\"%R\":\"%H:%M\",\"%T\":\"%H:%M:%S\",\"%x\":\"%m/%d/%y\",\"%X\":\"%H:%M:%S\",\"%Ec\":\"%c\",\"%EC\":\"%C\",\"%Ex\":\"%m/%d/%y\",\"%EX\":\"%H:%M:%S\",\"%Ey\":\"%y\",\"%EY\":\"%Y\",\"%Od\":\"%d\",\"%Oe\":\"%e\",\"%OH\":\"%H\",\"%OI\":\"%I\",\"%Om\":\"%m\",\"%OM\":\"%M\",\"%OS\":\"%S\",\"%Ou\":\"%u\",\"%OU\":\"%U\",\"%OV\":\"%V\",\"%Ow\":\"%w\",\"%OW\":\"%W\",\"%Oy\":\"%y\"};for(var rule in EXPANSION_RULES_1){pattern=pattern.replace(new RegExp(rule,\"g\"),EXPANSION_RULES_1[rule])}var WEEKDAYS=[\"Sunday\",\"Monday\",\"Tuesday\",\"Wednesday\",\"Thursday\",\"Friday\",\"Saturday\"];var MONTHS=[\"January\",\"February\",\"March\",\"April\",\"May\",\"June\",\"July\",\"August\",\"September\",\"October\",\"November\",\"December\"];function leadingSomething(value,digits,character){var str=typeof value==\"number\"?value.toString():value||\"\";while(str.length<digits){str=character[0]+str}return str}function leadingNulls(value,digits){return leadingSomething(value,digits,\"0\")}function compareByDay(date1,date2){function sgn(value){return value<0?-1:value>0?1:0}var compare;if((compare=sgn(date1.getFullYear()-date2.getFullYear()))===0){if((compare=sgn(date1.getMonth()-date2.getMonth()))===0){compare=sgn(date1.getDate()-date2.getDate())}}return compare}function getFirstWeekStartDate(janFourth){switch(janFourth.getDay()){case 0:return new Date(janFourth.getFullYear()-1,11,29);case 1:return janFourth;case 2:return new Date(janFourth.getFullYear(),0,3);case 3:return new Date(janFourth.getFullYear(),0,2);case 4:return new Date(janFourth.getFullYear(),0,1);case 5:return new Date(janFourth.getFullYear()-1,11,31);case 6:return new Date(janFourth.getFullYear()-1,11,30)}}function getWeekBasedYear(date){var thisDate=addDays(new Date(date.tm_year+1900,0,1),date.tm_yday);var janFourthThisYear=new Date(thisDate.getFullYear(),0,4);var janFourthNextYear=new Date(thisDate.getFullYear()+1,0,4);var firstWeekStartThisYear=getFirstWeekStartDate(janFourthThisYear);var firstWeekStartNextYear=getFirstWeekStartDate(janFourthNextYear);if(compareByDay(firstWeekStartThisYear,thisDate)<=0){if(compareByDay(firstWeekStartNextYear,thisDate)<=0){return thisDate.getFullYear()+1}return thisDate.getFullYear()}return thisDate.getFullYear()-1}var EXPANSION_RULES_2={\"%a\":function(date){return WEEKDAYS[date.tm_wday].substring(0,3)},\"%A\":function(date){return WEEKDAYS[date.tm_wday]},\"%b\":function(date){return MONTHS[date.tm_mon].substring(0,3)},\"%B\":function(date){return MONTHS[date.tm_mon]},\"%C\":function(date){var year=date.tm_year+1900;return leadingNulls(year/100|0,2)},\"%d\":function(date){return leadingNulls(date.tm_mday,2)},\"%e\":function(date){return leadingSomething(date.tm_mday,2,\" \")},\"%g\":function(date){return getWeekBasedYear(date).toString().substring(2)},\"%G\":function(date){return getWeekBasedYear(date)},\"%H\":function(date){return leadingNulls(date.tm_hour,2)},\"%I\":function(date){var twelveHour=date.tm_hour;if(twelveHour==0)twelveHour=12;else if(twelveHour>12)twelveHour-=12;return leadingNulls(twelveHour,2)},\"%j\":function(date){return leadingNulls(date.tm_mday+arraySum(isLeapYear(date.tm_year+1900)?MONTH_DAYS_LEAP:MONTH_DAYS_REGULAR,date.tm_mon-1),3)},\"%m\":function(date){return leadingNulls(date.tm_mon+1,2)},\"%M\":function(date){return leadingNulls(date.tm_min,2)},\"%n\":function(){return\"\\n\"},\"%p\":function(date){if(date.tm_hour>=0&&date.tm_hour<12){return\"AM\"}return\"PM\"},\"%S\":function(date){return leadingNulls(date.tm_sec,2)},\"%t\":function(){return\"\\t\"},\"%u\":function(date){return date.tm_wday||7},\"%U\":function(date){var days=date.tm_yday+7-date.tm_wday;return leadingNulls(Math.floor(days/7),2)},\"%V\":function(date){var val=Math.floor((date.tm_yday+7-(date.tm_wday+6)%7)/7);if((date.tm_wday+371-date.tm_yday-2)%7<=2){val++}if(!val){val=52;var dec31=(date.tm_wday+7-date.tm_yday-1)%7;if(dec31==4||dec31==5&&isLeapYear(date.tm_year%400-1)){val++}}else if(val==53){var jan1=(date.tm_wday+371-date.tm_yday)%7;if(jan1!=4&&(jan1!=3||!isLeapYear(date.tm_year)))val=1}return leadingNulls(val,2)},\"%w\":function(date){return date.tm_wday},\"%W\":function(date){var days=date.tm_yday+7-(date.tm_wday+6)%7;return leadingNulls(Math.floor(days/7),2)},\"%y\":function(date){return(date.tm_year+1900).toString().substring(2)},\"%Y\":function(date){return date.tm_year+1900},\"%z\":function(date){var off=date.tm_gmtoff;var ahead=off>=0;off=Math.abs(off)/60;off=off/60*100+off%60;return(ahead?\"+\":\"-\")+String(\"0000\"+off).slice(-4)},\"%Z\":function(date){return date.tm_zone},\"%%\":function(){return\"%\"}};pattern=pattern.replace(/%%/g,\"\\0\\0\");for(var rule in EXPANSION_RULES_2){if(pattern.includes(rule)){pattern=pattern.replace(new RegExp(rule,\"g\"),EXPANSION_RULES_2[rule](date))}}pattern=pattern.replace(/\\0\\0/g,\"%\");var bytes=intArrayFromString(pattern,false);if(bytes.length>maxsize){return 0}writeArrayToMemory(bytes,s);return bytes.length-1}var FSNode=function(parent,name,mode,rdev){if(!parent){parent=this}this.parent=parent;this.mount=parent.mount;this.mounted=null;this.id=FS.nextInode++;this.name=name;this.mode=mode;this.node_ops={};this.stream_ops={};this.rdev=rdev};var readMode=292|73;var writeMode=146;Object.defineProperties(FSNode.prototype,{read:{get:function(){return(this.mode&readMode)===readMode},set:function(val){val?this.mode|=readMode:this.mode&=~readMode}},write:{get:function(){return(this.mode&writeMode)===writeMode},set:function(val){val?this.mode|=writeMode:this.mode&=~writeMode}},isFolder:{get:function(){return FS.isDir(this.mode)}},isDevice:{get:function(){return FS.isChrdev(this.mode)}}});FS.FSNode=FSNode;FS.createPreloadedFile=FS_createPreloadedFile;FS.staticInit();var wasmImports={\"b\":___assert_fail,\"f\":___cxa_throw,\"ka\":___dlsym,\"R\":___syscall__newselect,\"L\":___syscall_accept4,\"K\":___syscall_bind,\"J\":___syscall_connect,\"la\":___syscall_faccessat,\"g\":___syscall_fcntl64,\"ha\":___syscall_fstat64,\"U\":___syscall_getdents64,\"I\":___syscall_getpeername,\"H\":___syscall_getsockname,\"G\":___syscall_getsockopt,\"y\":___syscall_ioctl,\"F\":___syscall_listen,\"ea\":___syscall_lstat64,\"$\":___syscall_mkdirat,\"fa\":___syscall_newfstatat,\"w\":___syscall_openat,\"V\":___syscall_poll,\"E\":___syscall_recvfrom,\"T\":___syscall_renameat,\"S\":___syscall_rmdir,\"D\":___syscall_sendto,\"v\":___syscall_socket,\"ga\":___syscall_stat64,\"O\":___syscall_unlinkat,\"ia\":__emscripten_get_now_is_monotonic,\"M\":__emscripten_throw_longjmp,\"Y\":__gmtime_js,\"Z\":__localtime_js,\"_\":__mktime_js,\"W\":__mmap_js,\"X\":__munmap_js,\"P\":__tzset_js,\"a\":_abort,\"t\":_dlopen,\"oa\":_emscripten_asm_const_int,\"m\":_emscripten_date_now,\"Q\":_emscripten_get_heap_max,\"p\":_emscripten_get_now,\"ja\":_emscripten_memcpy_big,\"N\":_emscripten_resize_heap,\"ca\":_environ_get,\"da\":_environ_sizes_get,\"l\":_exit,\"n\":_fd_close,\"ba\":_fd_fdstat_get,\"x\":_fd_read,\"aa\":_fd_seek,\"q\":_fd_write,\"k\":_getaddrinfo,\"i\":_getnameinfo,\"pa\":invoke_i,\"na\":invoke_ii,\"c\":invoke_iii,\"o\":invoke_iiii,\"s\":invoke_iiiii,\"z\":invoke_iiiiii,\"r\":invoke_iiiiiiiii,\"B\":invoke_iiiijj,\"qa\":invoke_iij,\"h\":invoke_vi,\"j\":invoke_vii,\"d\":invoke_viiii,\"ma\":invoke_viiiiii,\"A\":invoke_viiiiiiii,\"C\":is_timeout,\"u\":send_progress,\"e\":_strftime};var asm=createWasm();var ___wasm_call_ctors=function(){return(___wasm_call_ctors=Module[\"asm\"][\"sa\"]).apply(null,arguments)};var _malloc=Module[\"_malloc\"]=function(){return(_malloc=Module[\"_malloc\"]=Module[\"asm\"][\"ta\"]).apply(null,arguments)};var ___errno_location=function(){return(___errno_location=Module[\"asm\"][\"va\"]).apply(null,arguments)};var _ntohs=function(){return(_ntohs=Module[\"asm\"][\"wa\"]).apply(null,arguments)};var _htons=function(){return(_htons=Module[\"asm\"][\"xa\"]).apply(null,arguments)};var _ffmpeg=Module[\"_ffmpeg\"]=function(){return(_ffmpeg=Module[\"_ffmpeg\"]=Module[\"asm\"][\"ya\"]).apply(null,arguments)};var _ffprobe=Module[\"_ffprobe\"]=function(){return(_ffprobe=Module[\"_ffprobe\"]=Module[\"asm\"][\"za\"]).apply(null,arguments)};var _htonl=function(){return(_htonl=Module[\"asm\"][\"Aa\"]).apply(null,arguments)};var _emscripten_builtin_memalign=function(){return(_emscripten_builtin_memalign=Module[\"asm\"][\"Ba\"]).apply(null,arguments)};var _setThrew=function(){return(_setThrew=Module[\"asm\"][\"Ca\"]).apply(null,arguments)};var stackSave=function(){return(stackSave=Module[\"asm\"][\"Da\"]).apply(null,arguments)};var stackRestore=function(){return(stackRestore=Module[\"asm\"][\"Ea\"]).apply(null,arguments)};var ___cxa_is_pointer_type=function(){return(___cxa_is_pointer_type=Module[\"asm\"][\"Fa\"]).apply(null,arguments)};var _ff_h264_cabac_tables=Module[\"_ff_h264_cabac_tables\"]=1546732;var ___start_em_js=Module[\"___start_em_js\"]=6077485;var ___stop_em_js=Module[\"___stop_em_js\"]=6077662;function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiijj(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iij(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_i(index){var sp=stackSave();try{return getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}Module[\"setValue\"]=setValue;Module[\"getValue\"]=getValue;Module[\"UTF8ToString\"]=UTF8ToString;Module[\"stringToUTF8\"]=stringToUTF8;Module[\"lengthBytesUTF8\"]=lengthBytesUTF8;Module[\"FS\"]=FS;var calledRun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}preRun();if(runDependencies>0){return}function doRun(){if(calledRun)return;calledRun=true;Module[\"calledRun\"]=true;if(ABORT)return;initRuntime();readyPromiseResolve(Module);if(Module[\"onRuntimeInitialized\"])Module[\"onRuntimeInitialized\"]();postRun()}if(Module[\"setStatus\"]){Module[\"setStatus\"](\"Running...\");setTimeout(function(){setTimeout(function(){Module[\"setStatus\"](\"\")},1);doRun()},1)}else{doRun()}}if(Module[\"preInit\"]){if(typeof Module[\"preInit\"]==\"function\")Module[\"preInit\"]=[Module[\"preInit\"]];while(Module[\"preInit\"].length>0){Module[\"preInit\"].pop()()}}run();\n\n\n  return createFFmpegCore.ready\n}\n\n);\n})();\nif (typeof exports === 'object' && typeof module === 'object')\n  module.exports = createFFmpegCore;\nelse if (typeof define === 'function' && define['amd'])\n  define([], function() { return createFFmpegCore; });\nelse if (typeof exports === 'object')\n  exports[\"createFFmpegCore\"] = createFFmpegCore;\n"
  },
  {
    "path": "web/src/App.vue",
    "content": "<template>\n  <router-view />\n</template>\n\n<script setup lang=\"ts\"></script>\n\n<style>\n#app {\n  width: 100%;\n  height: 100vh;\n}\n</style>\n"
  },
  {
    "path": "web/src/api/ai.ts",
    "content": "import type {\n    AIServiceConfig,\n    AIServiceType,\n    CreateAIConfigRequest,\n    TestConnectionRequest,\n    UpdateAIConfigRequest\n} from '../types/ai'\nimport request from '../utils/request'\n\nexport const aiAPI = {\n  list(serviceType?: AIServiceType) {\n    return request.get<AIServiceConfig[]>('/ai-configs', {\n      params: { service_type: serviceType }\n    })\n  },\n\n  create(data: CreateAIConfigRequest) {\n    return request.post<AIServiceConfig>('/ai-configs', data)\n  },\n\n  get(id: number) {\n    return request.get<AIServiceConfig>(`/ai-configs/${id}`)\n  },\n\n  update(id: number, data: UpdateAIConfigRequest) {\n    return request.put<AIServiceConfig>(`/ai-configs/${id}`, data)\n  },\n\n  delete(id: number) {\n    return request.delete(`/ai-configs/${id}`)\n  },\n\n  testConnection(data: TestConnectionRequest) {\n    return request.post('/ai-configs/test', data)\n  }\n}\n"
  },
  {
    "path": "web/src/api/asset.ts",
    "content": "import type {\n    Asset,\n    AssetCollection,\n    AssetTag,\n    CreateAssetRequest,\n    ListAssetsParams,\n    UpdateAssetRequest\n} from '../types/asset'\nimport request from '../utils/request'\n\nexport const assetAPI = {\n  createAsset(data: CreateAssetRequest) {\n    return request.post<Asset>('/assets', data)\n  },\n\n  updateAsset(id: number, data: UpdateAssetRequest) {\n    return request.put<Asset>(`/assets/${id}`, data)\n  },\n\n  getAsset(id: number) {\n    return request.get<Asset>(`/assets/${id}`)\n  },\n\n  listAssets(params: ListAssetsParams) {\n    return request.get<{\n      items: Asset[]\n      pagination: {\n        page: number\n        page_size: number\n        total: number\n        total_pages: number\n      }\n    }>('/assets', { params })\n  },\n\n  deleteAsset(id: number) {\n    return request.delete(`/assets/${id}`)\n  },\n\n  importFromImage(imageGenId: number) {\n    return request.post<Asset>(`/assets/import/image/${imageGenId}`)\n  },\n\n  importFromVideo(videoGenId: number) {\n    return request.post<Asset>(`/assets/import/video/${videoGenId}`)\n  }\n}\n"
  },
  {
    "path": "web/src/api/audio.ts",
    "content": "import axios from 'axios'\n\nconst API_BASE_URL = '/api/v1'\n\nexport interface ExtractAudioRequest {\n  video_url: string\n}\n\nexport interface ExtractAudioResponse {\n  audio_url: string\n  duration: number\n}\n\nexport interface BatchExtractAudioRequest {\n  video_urls: string[]\n}\n\nexport interface BatchExtractAudioResponse {\n  results: ExtractAudioResponse[]\n  total: number\n}\n\nexport const audioAPI = {\n  /**\n   * 从视频URL提取音频\n   */\n  extractAudio: async (videoUrl: string): Promise<ExtractAudioResponse> => {\n    const response = await axios.post<ExtractAudioResponse>(\n      `${API_BASE_URL}/audio/extract`,\n      { video_url: videoUrl }\n    )\n    return response.data\n  },\n\n  /**\n   * 批量从视频URL提取音频\n   */\n  batchExtractAudio: async (videoUrls: string[]): Promise<BatchExtractAudioResponse> => {\n    const response = await axios.post<BatchExtractAudioResponse>(\n      `${API_BASE_URL}/audio/extract/batch`,\n      { video_urls: videoUrls }\n    )\n    return response.data\n  }\n}\n"
  },
  {
    "path": "web/src/api/character-library.ts",
    "content": "import request from '../utils/request'\n\nexport interface CharacterLibraryItem {\n  id: string\n  name: string\n  category?: string\n  image_url: string\n  description?: string\n  tags?: string\n  source_type: string\n  created_at: string\n  updated_at: string\n}\n\nexport interface CreateLibraryItemRequest {\n  name: string\n  category?: string\n  image_url: string\n  description?: string\n  tags?: string\n  source_type?: string\n}\n\nexport interface CharacterLibraryQuery {\n  page?: number\n  page_size?: number\n  category?: string\n  source_type?: string\n  keyword?: string\n}\n\nexport const characterLibraryAPI = {\n  // 获取角色库列表\n  list(params?: CharacterLibraryQuery) {\n    return request.get<{\n      items: CharacterLibraryItem[]\n      pagination: {\n        page: number\n        page_size: number\n        total: number\n        total_pages: number\n      }\n    }>('/character-library', { params })\n  },\n\n  // 创建角色库项\n  create(data: CreateLibraryItemRequest) {\n    return request.post<CharacterLibraryItem>('/character-library', data)\n  },\n\n  // 获取角色库项详情\n  get(id: string) {\n    return request.get<CharacterLibraryItem>(`/character-library/${id}`)\n  },\n\n  // 删除角色库项\n  delete(id: string) {\n    return request.delete(`/character-library/${id}`)\n  },\n\n  // 上传角色图片\n  uploadCharacterImage(characterId: string, imageUrl: string) {\n    return request.put(`/characters/${characterId}/image`, { image_url: imageUrl })\n  },\n\n  // 从角色库应用形象\n  applyFromLibrary(characterId: string, libraryItemId: string) {\n    return request.put(`/characters/${characterId}/image-from-library`, {\n      library_item_id: libraryItemId\n    })\n  },\n\n  // 将角色添加到角色库\n  addCharacterToLibrary(characterId: string, category?: string) {\n    return request.post<CharacterLibraryItem>(`/characters/${characterId}/add-to-library`, {\n      category\n    })\n  },\n\n  // AI生成角色形象\n  generateCharacterImage(characterId: string, model?: string) {\n    return request.post<{ image_url: string }>(`/characters/${characterId}/generate-image`, {\n      model\n    })\n  },\n\n  // 批量生成角色形象\n  batchGenerateCharacterImages(characterIds: string[], model?: string) {\n    return request.post<{ message: string; count: number }>('/characters/batch-generate-images', {\n      character_ids: characterIds,\n      model\n    })\n  },\n\n  // 更新角色信息\n  updateCharacter(characterId: number, data: {\n    name?: string\n    appearance?: string\n    personality?: string\n    description?: string\n    image_url?: string\n    local_path?: string\n  }) {\n    return request.put(`/characters/${characterId}`, data)\n  },\n\n  // 删除角色\n  deleteCharacter(characterId: number) {\n    return request.delete(`/characters/${characterId}`)\n  },\n\n  // 从剧本提取角色\n  extractFromEpisode(episodeId: number) {\n    return request.post<{ task_id: string; message: string }>(`/episodes/${episodeId}/characters/extract`)\n  }\n}\n"
  },
  {
    "path": "web/src/api/drama.ts",
    "content": "import type {\n  CreateDramaRequest,\n  Drama,\n  DramaListQuery,\n  DramaStats,\n  UpdateDramaRequest\n} from '../types/drama'\nimport request from '../utils/request'\n\nexport const dramaAPI = {\n  list(params?: DramaListQuery) {\n    return request.get<{\n      items: Drama[]\n      pagination: {\n        page: number\n        page_size: number\n        total: number\n        total_pages: number\n      }\n    }>('/dramas', { params })\n  },\n\n  create(data: CreateDramaRequest) {\n    return request.post<Drama>('/dramas', data)\n  },\n\n  get(id: string) {\n    return request.get<Drama>(`/dramas/${id}`)\n  },\n\n  update(id: string, data: UpdateDramaRequest) {\n    return request.put<Drama>(`/dramas/${id}`, data)\n  },\n\n  delete(id: string) {\n    return request.delete(`/dramas/${id}`)\n  },\n\n  getStats() {\n    return request.get<DramaStats>('/dramas/stats')\n  },\n\n  saveOutline(id: string, data: { title: string; summary: string; genre?: string; tags?: string[] }) {\n    return request.put(`/dramas/${id}/outline`, data)\n  },\n\n  getCharacters(dramaId: string) {\n    return request.get(`/dramas/${dramaId}/characters`)\n  },\n\n  saveCharacters(id: string, data: any[], episodeId?: string) {\n    return request.put(`/dramas/${id}/characters`, {\n      characters: data,\n      episode_id: episodeId ? parseInt(episodeId) : undefined\n    })\n  },\n\n  updateCharacter(id: number, data: any) {\n    return request.put(`/characters/${id}`, data)\n  },\n\n  saveEpisodes(id: string, data: any[]) {\n    return request.put(`/dramas/${id}/episodes`, { episodes: data })\n  },\n\n  saveProgress(id: string, data: { current_step: string; step_data?: any }) {\n    return request.put(`/dramas/${id}/progress`, data)\n  },\n\n  generateStoryboard(episodeId: string) {\n    return request.post(`/episodes/${episodeId}/storyboards`)\n  },\n\n  getBackgrounds(episodeId: string) {\n    return request.get(`/images/episode/${episodeId}/backgrounds`)\n  },\n\n  extractBackgrounds(episodeId: string, model?: string) {\n    return request.post<{ task_id: string; status: string; message: string }>(`/images/episode/${episodeId}/backgrounds/extract`, { model })\n  },\n\n  batchGenerateBackgrounds(episodeId: string) {\n    return request.post(`/images/episode/${episodeId}/batch`)\n  },\n\n  generateSingleBackground(backgroundId: number, dramaId: string, prompt: string) {\n    return request.post('/images', {\n      background_id: backgroundId,\n      drama_id: dramaId,\n      prompt: prompt\n    })\n  },\n\n  getStoryboards(episodeId: string) {\n    return request.get(`/episodes/${episodeId}/storyboards`)\n  },\n\n  updateStoryboard(storyboardId: string, data: any) {\n    return request.put(`/storyboards/${storyboardId}`, data)\n  },\n\n  updateScene(sceneId: string, data: {\n    background_id?: string;\n    characters?: string[];\n    location?: string;\n    time?: string;\n    prompt?: string;\n    action?: string;\n    dialogue?: string;\n    description?: string;\n    duration?: number;\n    image_url?: string;\n    local_path?: string;\n  }) {\n    return request.put(`/scenes/${sceneId}`, data)\n  },\n\n  createScene(data: {\n    drama_id: number;\n    episode_id?: number;\n    location: string;\n    time?: string;\n    prompt?: string;\n    description?: string;\n    image_url?: string;\n    local_path?: string;\n  }) {\n    return request.post('/scenes', data)\n  },\n\n  generateSceneImage(data: { scene_id: number; prompt?: string; model?: string }) {\n    return request.post<{ image_generation: { id: number } }>('/scenes/generate-image', data)\n  },\n\n  updateScenePrompt(sceneId: string, prompt: string) {\n    return request.put(`/scenes/${sceneId}/prompt`, { prompt })\n  },\n\n  deleteScene(sceneId: string) {\n    return request.delete(`/scenes/${sceneId}`)\n  },\n\n  // 完成集数制作（触发视频合成）\n  finalizeEpisode(episodeId: string, timelineData?: any) {\n    return request.post(`/episodes/${episodeId}/finalize`, timelineData || {})\n  },\n\n  createStoryboard(data: {\n    episode_id: number;\n    storyboard_number: number;\n    title?: string;\n    description?: string;\n    action?: string;\n    dialogue?: string;\n    scene_id?: number;\n    duration: number;\n  }) {\n    return request.post('/storyboards', data)\n  },\n\n  deleteStoryboard(storyboardId: number) {\n    return request.delete(`/storyboards/${storyboardId}`)\n  }\n}\n"
  },
  {
    "path": "web/src/api/frame.ts",
    "content": "import request from '../utils/request'\n\n// 帧类型\nexport type FrameType = 'first' | 'key' | 'last' | 'panel' | 'action'\n\n// 单帧提示词\nexport interface SingleFramePrompt {\n  prompt: string\n  description: string\n}\n\n// 多帧提示词\nexport interface MultiFramePrompt {\n  layout: string // horizontal_3, grid_2x2 等\n  frames: SingleFramePrompt[]\n}\n\n// 生成帧提示词响应 (异步任务)\nexport interface GenerateFramePromptResponse {\n  task_id: string\n  status: string\n  message: string\n}\n\n// 生成帧提示词请求\nexport interface GenerateFramePromptRequest {\n  frame_type: FrameType\n  panel_count?: number // 分镜板格数，默认3\n}\n\n/**\n * 生成指定类型的帧提示词\n */\nexport function generateFramePrompt(\n  storyboardId: number,\n  data: GenerateFramePromptRequest\n): Promise<GenerateFramePromptResponse> {\n  return request.post<GenerateFramePromptResponse>(`/storyboards/${storyboardId}/frame-prompt`, data)\n}\n\n/**\n * 生成首帧提示词\n */\nexport function generateFirstFrame(storyboardId: number): Promise<GenerateFramePromptResponse> {\n  return generateFramePrompt(storyboardId, { frame_type: 'first' })\n}\n\n/**\n * 生成关键帧提示词\n */\nexport function generateKeyFrame(storyboardId: number): Promise<GenerateFramePromptResponse> {\n  return generateFramePrompt(storyboardId, { frame_type: 'key' })\n}\n\n/**\n * 生成尾帧提示词\n */\nexport function generateLastFrame(storyboardId: number): Promise<GenerateFramePromptResponse> {\n  return generateFramePrompt(storyboardId, { frame_type: 'last' })\n}\n\n/**\n * 生成分镜板（3格组合）\n */\nexport function generatePanelFrames(\n  storyboardId: number,\n  panelCount: number = 3\n): Promise<GenerateFramePromptResponse> {\n  return generateFramePrompt(storyboardId, {\n    frame_type: 'panel',\n    panel_count: panelCount\n  })\n}\n\n/**\n * 生成动作序列（5格）\n */\nexport function generateActionSequence(storyboardId: number): Promise<GenerateFramePromptResponse> {\n  return generateFramePrompt(storyboardId, { frame_type: 'action' })\n}\n\n// 帧提示词记录（从数据库查询）\nexport interface FramePromptRecord {\n  id: number\n  storyboard_id: number\n  frame_type: FrameType\n  prompt: string\n  description?: string\n  layout?: string\n  created_at: string\n  updated_at: string\n}\n\n/**\n * 查询镜头的所有已生成帧提示词\n */\nexport function getStoryboardFramePrompts(storyboardId: number): Promise<{ frame_prompts: FramePromptRecord[] }> {\n  return request.get<{ frame_prompts: FramePromptRecord[] }>(`/storyboards/${storyboardId}/frame-prompts`)\n}\n"
  },
  {
    "path": "web/src/api/generation.ts",
    "content": "import type {\n    GenerateCharactersRequest\n} from '../types/generation'\nimport request from '../utils/request'\n\nexport const generationAPI = {\n  generateCharacters(data: GenerateCharactersRequest) {\n    return request.post<{ task_id: string; status: string; message: string }>('/generation/characters', data)\n  },\n\n  generateStoryboard(episodeId: string, model?: string) {\n    return request.post<{ task_id: string; status: string; message: string }>(`/episodes/${episodeId}/storyboards`, { model })\n  },\n\n  getTaskStatus(taskId: string) {\n    return request.get<{\n      id: string\n      type: string\n      status: string\n      progress: number\n      message?: string\n      error?: string\n      result?: string\n      created_at: string\n      updated_at: string\n      completed_at?: string\n    }>(`/tasks/${taskId}`)\n  }\n  \n}\n"
  },
  {
    "path": "web/src/api/image.ts",
    "content": "import type {\n  GenerateImageRequest,\n  ImageGeneration,\n  ImageGenerationListParams\n} from '../types/image'\nimport request from '../utils/request'\n\nexport const imageAPI = {\n  generateImage(data: GenerateImageRequest) {\n    return request.post<ImageGeneration>('/images', data)\n  },\n\n  generateForScene(sceneId: number) {\n    return request.post<ImageGeneration[]>(`/images/scene/${sceneId}`)\n  },\n\n  batchGenerateForEpisode(episodeId: number) {\n    return request.post<ImageGeneration[]>(`/images/episode/${episodeId}/batch`)\n  },\n\n  getImage(id: number) {\n    return request.get<ImageGeneration>(`/images/${id}`)\n  },\n\n  listImages(params: ImageGenerationListParams) {\n    return request.get<{\n      items: ImageGeneration[]\n      pagination: {\n        page: number\n        page_size: number\n        total: number\n        total_pages: number\n      }\n    }>('/images', { params })\n  },\n\n  deleteImage(id: number) {\n    return request.delete(`/images/${id}`)\n  },\n\n  // 上传图片并创建图片生成记录\n  uploadImage(data: {\n    storyboard_id: number\n    drama_id: number\n    frame_type: string\n    image_url: string\n    prompt?: string\n  }) {\n    return request.post<ImageGeneration>('/images/upload', data)\n  }\n}\n"
  },
  {
    "path": "web/src/api/prop.ts",
    "content": "import request from '../utils/request'\nimport type { Prop, CreatePropRequest, UpdatePropRequest } from '../types/prop'\n\nexport const propAPI = {\n    list(dramaId: string | number) {\n        return request.get<Prop[]>('/dramas/' + dramaId + '/props')\n    },\n    create(data: CreatePropRequest) {\n        return request.post<Prop>('/props', data)\n    },\n    update(id: number, data: UpdatePropRequest) {\n        return request.put<void>('/props/' + id, data)\n    },\n    delete(id: number) {\n        return request.delete<void>('/props/' + id)\n    },\n    extractFromScript(episodeId: number) {\n        return request.post<{ task_id: string }>(`/episodes/${episodeId}/props/extract`)\n    },\n    generateImage(id: number) {\n        return request.post<{ task_id: string }>(`/props/${id}/generate`)\n    },\n    associateWithStoryboard(storyboardId: number, propIds: number[]) {\n        return request.post<void>(`/storyboards/${storyboardId}/props`, { prop_ids: propIds })\n    }\n}\n"
  },
  {
    "path": "web/src/api/settings.ts",
    "content": "import request from '../utils/request'\n\nexport const settingsAPI = {\n  // 获取系统语言\n  getLanguage() {\n    return request.get<{ language: string }>('/settings/language')\n  },\n\n  // 更新系统语言\n  updateLanguage(language: 'zh' | 'en') {\n    return request.put<{ message: string; language: string }>('/settings/language', { language })\n  }\n}\n"
  },
  {
    "path": "web/src/api/task.ts",
    "content": "import request from '../utils/request'\n\nexport interface AsyncTask {\n    id: string\n    type: string\n    status: 'pending' | 'processing' | 'completed' | 'failed'\n    progress: number\n    message: string\n    result?: any\n    error?: string\n    created_at: string\n}\n\nexport const taskAPI = {\n    getStatus(taskId: string) {\n        return request.get<AsyncTask>(`/tasks/${taskId}`)\n    }\n}\n"
  },
  {
    "path": "web/src/api/video.ts",
    "content": "import type {\n  GenerateVideoRequest,\n  VideoGeneration,\n  VideoGenerationListParams\n} from '../types/video'\nimport request from '../utils/request'\n\nexport const videoAPI = {\n  generateVideo(data: GenerateVideoRequest) {\n    return request.post<VideoGeneration>('/videos', data)\n  },\n\n  generateFromImage(imageGenId: number) {\n    return request.post<VideoGeneration>(`/videos/image/${imageGenId}`)\n  },\n\n  batchGenerateForEpisode(episodeId: number) {\n    return request.post<VideoGeneration[]>(`/videos/episode/${episodeId}/batch`)\n  },\n\n  getVideoGeneration(id: number) {\n    return request.get<VideoGeneration>(`/videos/${id}`)\n  },\n  \n  getVideo(id: number) {\n    return request.get<VideoGeneration>(`/videos/${id}`)\n  },\n\n  listVideos(params: VideoGenerationListParams) {\n    return request.get<{\n      items: VideoGeneration[]\n      pagination: {\n        page: number\n        page_size: number\n        total: number\n        total_pages: number\n      }\n    }>('/videos', { params })\n  },\n\n  deleteVideo(id: number) {\n    return request.delete(`/videos/${id}`)\n  }\n}\n"
  },
  {
    "path": "web/src/api/videoMerge.ts",
    "content": "import request from '../utils/request'\n\nexport interface SceneClip {\n  scene_id: string\n  video_url: string\n  start_time: number\n  end_time: number\n  duration: number\n  order: number\n}\n\nexport interface MergeVideoRequest {\n  episode_id: string\n  drama_id: string\n  title: string\n  scenes: SceneClip[]\n  provider?: string\n  model?: string\n}\n\nexport interface VideoMerge {\n  id: number\n  episode_id: string\n  drama_id: string\n  title: string\n  provider: string\n  model?: string\n  status: 'pending' | 'processing' | 'completed' | 'failed'\n  scenes: SceneClip[]\n  merged_url?: string\n  duration?: number\n  task_id?: string\n  error_msg?: string\n  created_at: string\n  completed_at?: string\n}\n\nexport const videoMergeAPI = {\n  async mergeVideos(data: MergeVideoRequest): Promise<VideoMerge> {\n    const response = await request.post<{ merge: VideoMerge }>('/video-merges', data)\n    return response.merge\n  },\n\n  async getMerge(mergeId: number): Promise<VideoMerge> {\n    const response = await request.get<{ merge: VideoMerge }>(`/video-merges/${mergeId}`)\n    return response.merge\n  },\n\n  async listMerges(params: {\n    episode_id?: string\n    status?: string\n    page?: number\n    page_size?: number\n  }): Promise<{ merges: VideoMerge[]; total: number }> {\n    const response = await request.get<{ merges: VideoMerge[]; total: number }>('/video-merges', { params })\n    return {\n      merges: response.merges || [],\n      total: response.total || 0\n    }\n  },\n\n  async deleteMerge(mergeId: number): Promise<void> {\n    await request.delete(`/video-merges/${mergeId}`)\n  }\n}\n"
  },
  {
    "path": "web/src/assets/styles/element/index.scss",
    "content": "/*just override what you need*/\n@forward 'element-plus/theme-chalk/src/dark/var.scss' with (\n  $bg-color: (\n    'page': #0a0a0a,\n    '': #141414,\n    'overlay': #1d1e1f,\n  ),\n  $fill-color: (\n    '': #262727,\n    'light': #1d1e1f,\n    'lighter': #141414,\n    'extra-light': #191919,\n    'dark': #3a3a3a,\n    'darker': #4a4a4a,\n    'blank': #1a1a1a,\n  )\n);"
  },
  {
    "path": "web/src/assets/styles/main.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n/* ========================================\n   CSS Variables for Theme / 主题 CSS 变量\n   Modern minimalist design system\n   ======================================== */\n:root {\n  /* Background colors / 背景色 */\n  --bg-primary: #f8fafc;\n  --bg-secondary: #ffffff;\n  --bg-card: #ffffff;\n  --bg-card-hover: #f1f5f9;\n  --bg-elevated: #ffffff;\n  --bg-overlay: rgba(15, 23, 42, 0.5);\n\n  /* Text colors / 文字色 */\n  --text-primary: #0f172a;\n  --text-secondary: #475569;\n  --text-muted: #94a3b8;\n  --text-inverse: #ffffff;\n\n  /* Border colors / 边框色 */\n  --border-primary: #e2e8f0;\n  --border-secondary: #cbd5e1;\n  --border-focus: #0ea5e9;\n\n  /* Primary accent / 主强调色 */\n  --accent: #0ea5e9;\n  --accent-hover: #0284c7;\n  --accent-light: #e0f2fe;\n  --accent-dark: #0369a1;\n\n  /* Status colors / 状态色 */\n  --success: #10b981;\n  --success-light: #d1fae5;\n  --warning: #f59e0b;\n  --warning-light: #fef3c7;\n  --error: #ef4444;\n  --error-light: #fee2e2;\n  --info: #3b82f6;\n  --info-light: #dbeafe;\n\n  /* Shadows / 阴影 - refined for depth */\n  --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.03);\n  --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.05), 0 1px 2px -1px rgb(0 0 0 / 0.05);\n  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.05);\n  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.05);\n  --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.05);\n  --shadow-glow: 0 0 20px rgba(14, 165, 233, 0.15);\n  --shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04);\n  --shadow-card-hover: 0 8px 16px -4px rgb(0 0 0 / 0.1), 0 4px 6px -2px rgb(0 0 0 / 0.05);\n\n  /* Transition / 过渡 */\n  --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);\n  --transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);\n  --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);\n  --transition-bounce: 500ms cubic-bezier(0.68, -0.55, 0.265, 1.55);\n\n  /* Border radius / 圆角 */\n  --radius-xs: 0.25rem;\n  --radius-sm: 0.375rem;\n  --radius-md: 0.5rem;\n  --radius-lg: 0.75rem;\n  --radius-xl: 1rem;\n  --radius-2xl: 1.25rem;\n  --radius-full: 9999px;\n\n  /* Spacing scale / 间距比例 */\n  --space-1: 0.25rem;\n  --space-2: 0.5rem;\n  --space-3: 0.75rem;\n  --space-4: 1rem;\n  --space-5: 1.25rem;\n  --space-6: 1.5rem;\n  --space-8: 2rem;\n  --space-10: 2.5rem;\n  --space-12: 3rem;\n}\n\n/* Dark mode theme / 深色模式主题 - 参考深色UI设计 */\n.dark {\n  /* Background - 深邃的蓝黑色调 */\n  --bg-primary: #0c1015;\n  --bg-secondary: #12181f;\n  --bg-card: #181f28;\n  --bg-card-hover: #1e2730;\n  --bg-elevated: #1a2129;\n  --bg-overlay: rgba(0, 0, 0, 0.8);\n\n  /* Text - 清晰的层次对比 */\n  --text-primary: #e8edf3;\n  --text-secondary: #8b9bb0;\n  --text-muted: #5a6a7e;\n  --text-inverse: #0c1015;\n\n  /* Border - 微妙的边框 */\n  --border-primary: #252d38;\n  --border-secondary: #323d4d;\n  --border-focus: #22d3ee;\n\n  /* Accent - 青色强调色 */\n  --accent: #22d3ee;\n  --accent-hover: #06b6d4;\n  --accent-light: rgba(34, 211, 238, 0.12);\n  --accent-dark: #67e8f9;\n\n  /* Status colors / 状态色 */\n  --success: #34d399;\n  --success-light: rgba(52, 211, 153, 0.12);\n  --warning: #fbbf24;\n  --warning-light: rgba(251, 191, 36, 0.12);\n  --error: #f87171;\n  --error-light: rgba(248, 113, 113, 0.12);\n  --info: #60a5fa;\n  --info-light: rgba(96, 165, 250, 0.12);\n\n  /* Shadows - 更深的阴影 */\n  --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.4);\n  --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.5), 0 1px 2px -1px rgb(0 0 0 / 0.5);\n  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.6), 0 2px 4px -2px rgb(0 0 0 / 0.5);\n  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.7), 0 4px 6px -4px rgb(0 0 0 / 0.6);\n  --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.8), 0 8px 10px -6px rgb(0 0 0 / 0.7);\n  --shadow-glow: 0 0 20px rgba(34, 211, 238, 0.25);\n  --shadow-card: 0 2px 4px 0 rgb(0 0 0 / 0.3);\n  --shadow-card-hover: 0 8px 20px -4px rgb(0 0 0 / 0.5), 0 0 0 1px rgba(34, 211, 238, 0.15);\n\n  --el-fill-color-blank: #181f28;\n  --el-border-color: #4d4d4d;\n  --el-border-color-light: #2a333d;\n  --el-fill-color-light: #2a333d;\n  --el-bg-color-overlay: #181f28;\n  --el-text-color-regular: #e8edf3;\n  --el-descriptions-table-border: #4d4d4d;\n  --el-border-color-lighter: #4d4d4d;\n  --el-text-color-primary: #e8edf3;\n}\n\n/* ========================================\n   Base Styles / 基础样式\n   ======================================== */\n* {\n  margin: 0;\n  padding: 0;\n  box-sizing: border-box;\n}\n\nhtml,\nbody {\n  width: 100%;\n  height: 100%;\n  font-family:\n    'Inter',\n    -apple-system,\n    BlinkMacSystemFont,\n    'Segoe UI',\n    Roboto,\n    'Helvetica Neue',\n    Arial,\n    sans-serif;\n  background-color: var(--bg-primary);\n  color: var(--text-primary);\n  transition:\n    background-color var(--transition-normal),\n    color var(--transition-normal);\n}\n\n#app {\n  width: 100%;\n  height: 100%;\n}\n\n/* ========================================\n   Element Plus Overrides / Element Plus 样式覆盖\n   Modern minimalist design overrides\n   ======================================== */\n/* 单行打点 */\n.overflow-tooltip {\n  display: inline-block;\n  width: 100%;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  vertical-align: middle;\n  cursor: pointer;\n}\n\n.el-select-dropdown__item {\n  line-height: unset;\n}\n/* Button overrides / 按钮样式覆盖 */\n\n.el-button {\n  --el-button-border-radius: var(--radius-lg);\n  font-weight: 500;\n  transition: all var(--transition-fast);\n  border: none;\n  letter-spacing: -0.01em;\n}\n\n.el-button--default {\n  background: var(--bg-card);\n  border: 1px solid var(--border-primary);\n  color: var(--text-primary);\n}\n\n.el-button--default:hover {\n  background: var(--bg-card-hover);\n  border-color: var(--border-secondary);\n  color: var(--text-primary);\n}\n\n.el-button--primary {\n  --el-button-bg-color: var(--accent);\n  --el-button-border-color: var(--accent);\n  --el-button-hover-bg-color: var(--accent-hover);\n  --el-button-hover-border-color: var(--accent-hover);\n  background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%);\n  box-shadow: 0 2px 8px rgba(14, 165, 233, 0.25);\n}\n\n.el-button--primary:hover {\n  transform: translateY(-1px);\n  box-shadow: 0 4px 12px rgba(14, 165, 233, 0.35);\n}\n\n.el-button--primary:active {\n  transform: translateY(0);\n}\n\n.el-button--danger {\n  background: linear-gradient(135deg, var(--error) 0%, #dc2626 100%);\n  box-shadow: 0 2px 8px rgba(239, 68, 68, 0.25);\n}\n\n.el-button--danger:hover {\n  transform: translateY(-1px);\n  box-shadow: 0 4px 12px rgba(239, 68, 68, 0.35);\n}\n\n.el-button--success {\n  background: linear-gradient(135deg, var(--success) 0%, #059669 100%);\n  box-shadow: 0 2px 8px rgba(16, 185, 129, 0.25);\n}\n\n.el-button.is-text {\n  color: var(--text-secondary);\n}\n\n.el-button.is-text:hover {\n  color: var(--accent);\n  background: var(--accent-light);\n}\n\n.el-button.is-circle {\n  background: var(--bg-card);\n  border: 1px solid var(--border-primary);\n  color: var(--text-secondary);\n}\n\n.el-button.is-circle:hover {\n  background: var(--bg-card-hover);\n  border-color: var(--border-secondary);\n  color: var(--text-primary);\n}\n\n/* Back button / 返回按钮 */\n.back-btn {\n  display: inline-flex;\n  align-items: center;\n  gap: 0.375rem;\n  padding: 0.5rem 0.875rem;\n  background: var(--bg-card);\n  border: 1px solid var(--border-primary);\n  border-radius: var(--radius-lg);\n  color: var(--text-secondary);\n  font-size: 0.875rem;\n  font-weight: 500;\n  cursor: pointer;\n  transition: all var(--transition-fast);\n  white-space: nowrap;\n}\n\n.back-btn:hover {\n  background: var(--bg-card-hover);\n  color: var(--text-primary);\n  border-color: var(--border-secondary);\n}\n\n.back-btn:focus-visible {\n  outline: 2px solid var(--accent);\n  outline-offset: 2px;\n}\n\n/* Card overrides / 卡片样式覆盖 */\n.el-card {\n  --el-card-bg-color: var(--bg-card);\n  --el-card-border-color: var(--border-primary);\n  --el-card-border-radius: var(--radius-xl);\n  border: 1px solid var(--border-primary);\n  box-shadow: var(--shadow-card);\n  transition: all var(--transition-normal);\n}\n\n.el-card:hover {\n  box-shadow: var(--shadow-card-hover);\n}\n\n.el-card__header {\n  border-bottom: 1px solid var(--border-primary);\n  padding: var(--space-4) var(--space-5);\n}\n\n.el-card__body {\n  padding: var(--space-5);\n}\n\n.dark .el-card {\n  --el-card-bg-color: var(--bg-card);\n  --el-card-border-color: var(--border-primary);\n}\n\n/* Dialog overrides / 对话框样式覆盖 */\n.el-dialog {\n  --el-dialog-bg-color: var(--bg-card);\n  --el-dialog-border-radius: var(--radius-2xl);\n  box-shadow: var(--shadow-xl);\n  border: 1px solid var(--border-primary);\n}\n\n.el-dialog__header {\n  padding: var(--space-5) var(--space-6);\n  border-bottom: 1px solid var(--border-primary);\n  margin-right: 0;\n}\n\n.el-dialog__title {\n  font-size: 1.125rem;\n  font-weight: 600;\n  color: var(--text-primary);\n  letter-spacing: -0.02em;\n}\n\n.el-dialog__body {\n  padding: var(--space-6);\n}\n\n.el-dialog__footer {\n  padding: var(--space-4) var(--space-6);\n  border-top: 1px solid var(--border-primary);\n}\n\n.dark .el-dialog {\n  --el-dialog-bg-color: var(--bg-card);\n}\n\n.dark .el-dialog__title {\n  color: var(--text-primary);\n}\n\n/* Input overrides / 输入框样式覆盖 */\n.el-input__wrapper {\n  --el-input-bg-color: var(--bg-secondary);\n  --el-input-border-color: var(--border-primary);\n  border-radius: var(--radius-lg) !important;\n  box-shadow: 0 0 0 1px var(--border-primary) inset !important;\n  transition: all var(--transition-fast);\n  padding: 0 var(--space-3);\n}\n\n.el-input__wrapper:hover {\n  box-shadow: 0 0 0 1px var(--border-secondary) inset !important;\n}\n\n.el-input__wrapper.is-focus {\n  box-shadow: 0 0 0 2px var(--accent) inset !important;\n}\n\n.el-input__inner {\n  color: var(--text-primary);\n  font-size: 0.875rem;\n}\n\n.el-input__inner::placeholder {\n  color: var(--text-muted);\n}\n\n.el-textarea__inner {\n  --el-input-bg-color: var(--bg-secondary);\n  border-radius: var(--radius-lg) !important;\n  box-shadow: 0 0 0 1px var(--border-primary) inset;\n  transition: all var(--transition-fast);\n  padding: var(--space-3);\n  color: var(--text-primary);\n  font-size: 0.875rem;\n}\n\n.el-textarea__inner:hover {\n  box-shadow: 0 0 0 1px var(--border-secondary) inset;\n}\n\n.el-textarea__inner:focus {\n  box-shadow: 0 0 0 2px var(--accent) inset;\n}\n\n.el-textarea__inner::placeholder {\n  color: var(--text-muted);\n}\n\n.dark .el-input__wrapper {\n  background-color: var(--bg-secondary);\n}\n\n.dark .el-input__inner {\n  color: var(--text-primary);\n}\n\n.dark .el-textarea__inner {\n  background-color: var(--bg-secondary);\n  color: var(--text-primary);\n}\n\n/* Select overrides / 选择器样式覆盖 */\n.el-select .el-input__wrapper {\n  background: var(--bg-secondary);\n}\n\n.el-select-dropdown {\n  border-radius: var(--radius-lg);\n  border: 1px solid var(--border-primary);\n  box-shadow: var(--shadow-lg);\n}\n\n.el-select-dropdown__item {\n  font-size: 0.875rem;\n  padding: var(--space-2) var(--space-3);\n  border-radius: var(--radius-sm);\n  margin: 2px var(--space-1);\n}\n\n.el-select-dropdown__item.is-selected {\n  background: var(--accent-light);\n  color: var(--accent);\n  font-weight: 500;\n}\n\n.el-select-dropdown__item:hover {\n  background: var(--bg-card-hover);\n}\n\n.dark .el-select-dropdown {\n  background: var(--bg-elevated);\n  border-color: var(--border-primary);\n}\n\n.dark .el-select-dropdown__item:hover {\n  background: var(--bg-card-hover);\n}\n\n/* Tag overrides / 标签样式覆盖 */\n.el-tag {\n  --el-tag-border-radius: var(--radius-md);\n  font-weight: 500;\n  font-size: 0.75rem;\n  padding: 0 var(--space-2);\n  height: 1.5rem;\n  line-height: 1.5rem;\n  border: none;\n}\n\n.el-tag--info {\n  background: var(--bg-card-hover);\n  color: var(--text-secondary);\n}\n\n.el-tag--primary {\n  background: var(--accent-light);\n  color: var(--accent);\n}\n\n.el-tag--success {\n  background: var(--success-light);\n  color: var(--success);\n}\n\n.el-tag--warning {\n  background: var(--warning-light);\n  color: var(--warning);\n}\n\n.el-tag--danger {\n  background: var(--error-light);\n  color: var(--error);\n}\n\n/* Tabs overrides / 标签页样式覆盖 */\n.el-tabs__header {\n  margin-bottom: var(--space-6);\n}\n\n.el-tabs__nav-wrap::after {\n  display: none;\n}\n\n.el-tabs__item {\n  font-weight: 500;\n  font-size: 0.875rem;\n  color: var(--text-secondary);\n  padding: 0 var(--space-5);\n  height: 2.5rem;\n  line-height: 2.5rem;\n  transition: color var(--transition-fast);\n}\n\n.el-tabs__item:hover {\n  color: var(--text-primary);\n}\n\n.el-tabs__item.is-active {\n  color: var(--accent);\n  font-weight: 600;\n}\n\n.el-tabs__active-bar {\n  background: var(--accent);\n  height: 2px;\n  border-radius: var(--radius-full);\n}\n\n.dark .el-tabs__item {\n  color: var(--text-secondary);\n}\n\n.dark .el-tabs__item.is-active {\n  color: var(--accent);\n}\n\n/* Table overrides / 表格样式覆盖 */\n.el-table {\n  --el-table-bg-color: var(--bg-card);\n  --el-table-header-bg-color: var(--bg-secondary);\n  --el-table-tr-bg-color: var(--bg-card);\n  --el-table-row-hover-bg-color: var(--bg-card-hover);\n  --el-table-border-color: var(--border-primary);\n  border-radius: var(--radius-lg);\n  overflow: hidden;\n}\n\n.el-table th.el-table__cell {\n  font-weight: 600;\n  font-size: 0.75rem;\n  text-transform: uppercase;\n  letter-spacing: 0.05em;\n  color: var(--text-muted);\n}\n\n.el-table td.el-table__cell {\n  font-size: 0.875rem;\n  color: var(--text-primary);\n}\n\n.dark .el-table {\n  --el-table-bg-color: var(--bg-card);\n  --el-table-header-bg-color: var(--bg-secondary);\n  --el-table-tr-bg-color: var(--bg-card);\n  --el-table-row-hover-bg-color: var(--bg-card-hover);\n  --el-fill-color-lighter: var(--bg-secondary);\n}\n\n.dark .el-table th.el-table__cell,\n.dark .el-table td.el-table__cell {\n  border-color: var(--border-primary);\n}\n\n.dark .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {\n  background-color: var(--bg-secondary);\n}\n\n/* Pagination overrides / 分页样式覆盖 */\n.el-pagination {\n  --el-pagination-bg-color: transparent;\n  --el-pagination-button-bg-color: var(--bg-card);\n  gap: var(--space-1);\n}\n\n.el-pager li {\n  min-width: 2rem;\n  height: 2rem;\n  line-height: 2rem;\n  font-weight: 500;\n  font-size: 0.8125rem;\n  border-radius: var(--radius-md);\n  background: transparent;\n  color: var(--text-secondary);\n  transition: all var(--transition-fast);\n}\n\n.el-pager li:hover {\n  color: var(--accent);\n  background: var(--accent-light);\n}\n\n.el-pager li.is-active {\n  background: var(--accent);\n  color: white;\n  font-weight: 600;\n}\n\n.el-pagination button {\n  min-width: 2rem;\n  height: 2rem;\n  border-radius: var(--radius-md);\n  background: transparent;\n  color: var(--text-secondary);\n  transition: all var(--transition-fast);\n}\n\n.el-pagination button:hover:not(:disabled) {\n  color: var(--accent);\n  background: var(--accent-light);\n}\n\n.el-pagination button:disabled {\n  opacity: 0.4;\n}\n\n.dark .el-pagination {\n  --el-pagination-text-color: var(--text-secondary);\n  --el-pagination-button-color: var(--text-primary);\n}\n\n.dark .el-pagination button,\n.dark .el-pager li {\n  background-color: transparent;\n  color: var(--text-secondary);\n}\n\n.dark .el-pager li:hover {\n  background: var(--accent-light);\n}\n\n.dark .el-pager li.is-active {\n  background: var(--accent);\n  color: var(--text-inverse);\n}\n\n/* Empty state overrides / 空状态样式覆盖 */\n.el-empty {\n  padding: var(--space-12) var(--space-6);\n}\n\n.el-empty__description p {\n  color: var(--text-muted);\n  font-size: 0.875rem;\n}\n\n.dark .el-empty__description p {\n  color: var(--text-muted);\n}\n\n/* Alert overrides / 提示框样式覆盖 */\n.el-alert {\n  border-radius: var(--radius-lg);\n  border: none;\n  padding: var(--space-4);\n}\n\n.el-alert--info {\n  background: var(--info-light);\n}\n\n.el-alert--success {\n  background: var(--success-light);\n}\n\n.el-alert--warning {\n  background: var(--warning-light);\n}\n\n.el-alert--error {\n  background: var(--error-light);\n}\n\n.dark .el-alert--info {\n  --el-alert-bg-color: var(--info-light);\n}\n\n/* Form overrides / 表单样式覆盖 */\n.el-form-item__label {\n  font-weight: 500;\n  font-size: 0.875rem;\n  color: var(--text-primary);\n  margin-bottom: var(--space-2);\n}\n\n.dark .el-form-item__label {\n  color: var(--text-primary);\n}\n\n/* Descriptions overrides / 描述列表样式覆盖 */\n.el-descriptions {\n  --el-descriptions-item-bordered-label-background: var(--bg-secondary);\n}\n\n.el-descriptions__label {\n  font-weight: 500;\n  color: var(--text-secondary);\n}\n\n.el-descriptions__content {\n  color: var(--text-primary);\n}\n\n.dark .el-descriptions__label,\n.dark .el-descriptions__content {\n  background: var(--bg-secondary);\n}\n\n/* Message Box overrides / 消息框样式覆盖 */\n.el-message-box {\n  border-radius: var(--radius-xl);\n  border: 1px solid var(--border-primary);\n  box-shadow: var(--shadow-xl);\n}\n\n.dark .el-message-box {\n  background: var(--bg-card);\n}\n\n/* Popconfirm overrides / 确认弹窗样式覆盖 */\n.el-popconfirm {\n  border-radius: var(--radius-lg);\n}\n\n.dark .el-popconfirm {\n  --el-popconfirm-bg-color: var(--bg-card);\n}\n\n/* Loading overrides / 加载样式覆盖 */\n.el-loading-mask {\n  background: var(--bg-overlay);\n  backdrop-filter: blur(4px);\n}\n\n.el-loading-spinner .circular {\n  width: 32px;\n  height: 32px;\n}\n\n.el-loading-spinner .path {\n  stroke: var(--accent);\n}\n\n/* Switch overrides / 开关样式覆盖 */\n.el-switch {\n  --el-switch-on-color: var(--accent);\n}\n\n.dark .el-switch__core {\n  background: var(--bg-secondary);\n  border-color: var(--border-primary);\n}\n\n/* Tooltip overrides / 提示框样式覆盖 */\n.el-tooltip__trigger {\n  outline: none;\n}\n\n/* Avatar overrides / 头像样式覆盖 */\n.el-avatar {\n  --el-avatar-bg-color: var(--accent);\n}\n\n/* Scrollbar overrides / 滚动条样式覆盖 */\n.el-scrollbar__thumb {\n  background: var(--border-secondary);\n  border-radius: var(--radius-full);\n}\n\n.el-scrollbar__thumb:hover {\n  background: var(--text-muted);\n}\n\n/* 图片样式 */\n.el-image-viewer__close {\n  background-color: var(--text-muted);\n  /* border: 1px solid var(--border-primary); */\n}\n\n.el-image-viewer__actions {\n  background-color: #0f172a;\n  /* border: 1px solid var(--border-primary); */\n}\n\n/* ========================================\n   Utility Classes / 工具类\n   ======================================== */\n.page-container {\n  min-height: 100vh;\n  background-color: var(--bg-primary);\n  /* padding: var(--space-2) var(--space-3); */\n  transition: background-color var(--transition-normal);\n}\n\n/* @media (min-width: 768px) {\n  .page-container {\n    padding: var(--space-3) var(--space-4);\n  }\n} */\n\n/* @media (min-width: 1024px) {\n  .page-container {\n    padding: var(--space-4) var(--space-5);\n  }\n} */\n\n.content-wrapper {\n  margin: 0 auto;\n  width: 100%;\n}\n\n/* ========================================\n   Layout Components / 布局组件\n   ======================================== */\n\n/* Glass morphism card / 玻璃态卡片 */\n.glass-card {\n  background: rgba(255, 255, 255, 0.8);\n  backdrop-filter: blur(12px);\n  border: 1px solid var(--border-primary);\n  border-radius: var(--radius-xl);\n  box-shadow: var(--shadow-card);\n  transition: all var(--transition-normal);\n}\n\n.dark .glass-card {\n  background: rgba(26, 35, 50, 0.8);\n}\n\n.glass-card:hover {\n  box-shadow: var(--shadow-card-hover);\n}\n\n/* Gradient backgrounds / 渐变背景 */\n.gradient-primary {\n  background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%);\n}\n\n.gradient-success {\n  background: linear-gradient(135deg, var(--success) 0%, #059669 100%);\n}\n\n.gradient-warning {\n  background: linear-gradient(135deg, var(--warning) 0%, #d97706 100%);\n}\n\n.gradient-error {\n  background: linear-gradient(135deg, var(--error) 0%, #dc2626 100%);\n}\n\n/* Animated gradient / 动画渐变 */\n.gradient-animated {\n  background: linear-gradient(-45deg, #0ea5e9, #06b6d4, #8b5cf6, #ec4899);\n  background-size: 400% 400%;\n  animation: gradient-shift 15s ease infinite;\n}\n\n@keyframes gradient-shift {\n  0% {\n    background-position: 0% 50%;\n  }\n\n  50% {\n    background-position: 100% 50%;\n  }\n\n  100% {\n    background-position: 0% 50%;\n  }\n}\n\n/* Glow effect / 发光效果 */\n.glow-primary {\n  box-shadow: 0 0 20px rgba(14, 165, 233, 0.3);\n}\n\n.glow-success {\n  box-shadow: 0 0 20px rgba(16, 185, 129, 0.3);\n}\n\n/* Skeleton loading / 骨架屏加载 */\n.skeleton {\n  background: linear-gradient(90deg, var(--bg-card-hover) 25%, var(--bg-secondary) 50%, var(--bg-card-hover) 75%);\n  background-size: 200% 100%;\n  animation: skeleton-loading 1.5s infinite;\n  border-radius: var(--radius-md);\n}\n\n@keyframes skeleton-loading {\n  0% {\n    background-position: 200% 0;\n  }\n\n  100% {\n    background-position: -200% 0;\n  }\n}\n\n/* Hover lift effect / 悬停提升效果 */\n.hover-lift {\n  transition:\n    transform var(--transition-normal),\n    box-shadow var(--transition-normal);\n}\n\n.hover-lift:hover {\n  transform: translateY(-2px);\n  box-shadow: var(--shadow-lg);\n}\n\n/* Interactive scale / 交互缩放 */\n.interactive-scale {\n  transition: transform var(--transition-fast);\n}\n\n.interactive-scale:hover {\n  transform: scale(1.02);\n}\n\n.interactive-scale:active {\n  transform: scale(0.98);\n}\n\n/* Focus ring / 焦点环 */\n.focus-ring:focus-visible {\n  outline: 2px solid var(--accent);\n  outline-offset: 2px;\n}\n\n/* Text truncation / 文本截断 */\n.truncate-1 {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.truncate-2 {\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  line-clamp: 2;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n}\n\n.truncate-3 {\n  display: -webkit-box;\n  -webkit-line-clamp: 3;\n  line-clamp: 3;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n}\n\n/* Scrollbar styling / 滚动条样式 */\n/* 全局滚动条样式 */\n::-webkit-scrollbar {\n  width: 8px;\n  height: 8px;\n}\n\n::-webkit-scrollbar-track {\n  background: var(--bg-secondary);\n  border-radius: var(--radius-full);\n}\n\n::-webkit-scrollbar-thumb {\n  background: var(--border-secondary);\n  border-radius: var(--radius-full);\n  transition: background var(--transition-fast);\n}\n\n::-webkit-scrollbar-thumb:hover {\n  background: var(--accent);\n}\n\n/* Firefox 滚动条样式 */\n* {\n  scrollbar-width: thin;\n  scrollbar-color: var(--border-secondary) var(--bg-secondary);\n}\n\n/* 自定义滚动条类 */\n.custom-scrollbar::-webkit-scrollbar {\n  width: 6px;\n  height: 6px;\n}\n\n.custom-scrollbar::-webkit-scrollbar-track {\n  background: transparent;\n}\n\n.custom-scrollbar::-webkit-scrollbar-thumb {\n  background: var(--border-secondary);\n  border-radius: var(--radius-full);\n}\n\n.custom-scrollbar::-webkit-scrollbar-thumb:hover {\n  background: var(--accent);\n}\n\n/* Hide scrollbar / 隐藏滚动条 */\n.hide-scrollbar {\n  -ms-overflow-style: none;\n  scrollbar-width: none;\n}\n\n.hide-scrollbar::-webkit-scrollbar {\n  display: none;\n}\n\n/* ========================================\n   Animation Utilities / 动画工具\n   ======================================== */\n\n/* Fade in / 淡入 */\n.animate-fade-in {\n  animation: fade-in 0.3s ease-out;\n}\n\n@keyframes fade-in {\n  from {\n    opacity: 0;\n  }\n\n  to {\n    opacity: 1;\n  }\n}\n\n/* Slide up / 向上滑入 */\n.animate-slide-up {\n  animation: slide-up 0.3s ease-out;\n}\n\n@keyframes slide-up {\n  from {\n    opacity: 0;\n    transform: translateY(10px);\n  }\n\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n/* Scale in / 缩放进入 */\n.animate-scale-in {\n  animation: scale-in 0.2s ease-out;\n}\n\n@keyframes scale-in {\n  from {\n    opacity: 0;\n    transform: scale(0.95);\n  }\n\n  to {\n    opacity: 1;\n    transform: scale(1);\n  }\n}\n\n/* Pulse / 脉冲 */\n.animate-pulse {\n  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n@keyframes pulse {\n  0%,\n  100% {\n    opacity: 1;\n  }\n\n  50% {\n    opacity: 0.5;\n  }\n}\n\n/* Spin / 旋转 */\n.animate-spin {\n  animation: spin 1s linear infinite;\n}\n\n@keyframes spin {\n  from {\n    transform: rotate(0deg);\n  }\n\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n/* Bounce / 弹跳 */\n.animate-bounce {\n  animation: bounce 1s infinite;\n}\n\n@keyframes bounce {\n  0%,\n  100% {\n    transform: translateY(-5%);\n    animation-timing-function: cubic-bezier(0.8, 0, 1, 1);\n  }\n\n  50% {\n    transform: translateY(0);\n    animation-timing-function: cubic-bezier(0, 0, 0.2, 1);\n  }\n}\n\n/* ========================================\n   Status Indicators / 状态指示器\n   ======================================== */\n.status-dot {\n  width: 8px;\n  height: 8px;\n  border-radius: var(--radius-full);\n  flex-shrink: 0;\n}\n\n.status-dot.success {\n  background: var(--success);\n}\n\n.status-dot.warning {\n  background: var(--warning);\n}\n\n.status-dot.error {\n  background: var(--error);\n}\n\n.status-dot.info {\n  background: var(--info);\n}\n\n.status-dot.muted {\n  background: var(--text-muted);\n}\n\n.status-dot.pulse {\n  animation: status-pulse 2s infinite;\n}\n\n@keyframes status-pulse {\n  0%,\n  100% {\n    box-shadow: 0 0 0 0 currentColor;\n    opacity: 1;\n  }\n\n  50% {\n    box-shadow: 0 0 0 4px currentColor;\n    opacity: 0.5;\n  }\n}\n\n/* ========================================\n   Typography Utilities / 排版工具\n   ======================================== */\n.text-gradient {\n  background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);\n  -webkit-background-clip: text;\n  -webkit-text-fill-color: transparent;\n  background-clip: text;\n}\n\n.font-display {\n  font-family:\n    'Inter',\n    -apple-system,\n    BlinkMacSystemFont,\n    'Segoe UI',\n    Roboto,\n    sans-serif;\n  font-weight: 700;\n  letter-spacing: -0.02em;\n}\n\n.font-mono {\n  font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;\n}\n"
  },
  {
    "path": "web/src/components/LanguageSwitcher.vue",
    "content": "<template>\n  <el-dropdown @command=\"handleCommand\">\n    <span class=\"language-switcher\">\n      <el-icon><Switch /></el-icon>\n      <span class=\"lang-text\">{{ currentLangText }}</span>\n    </span>\n    <template #dropdown>\n      <el-dropdown-menu>\n        <el-dropdown-item command=\"zh-CN\" :disabled=\"currentLang === 'zh-CN'\">\n          🇨🇳 简体中文\n        </el-dropdown-item>\n        <el-dropdown-item command=\"en-US\" :disabled=\"currentLang === 'en-US'\">\n          🇺🇸 English\n        </el-dropdown-item>\n      </el-dropdown-menu>\n    </template>\n  </el-dropdown>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { setLanguage } from '@/locales'\nimport { ElMessage, ElMessageBox } from 'element-plus'\nimport { settingsAPI } from '@/api/settings'\n\nconst { locale } = useI18n()\n\nconst currentLang = ref(locale.value)\nconst loading = ref(false)\n\nconst currentLangText = computed(() => {\n  return currentLang.value === 'zh-CN' ? '中文' : 'English'\n})\n\nconst handleCommand = async (lang: string) => {\n  if (loading.value) return\n  \n  // 将 zh-CN/en-US 转换为 zh/en (后端格式)\n  const backendLang = lang === 'zh-CN' ? 'zh' : 'en'\n  const currentBackendLang = currentLang.value === 'zh-CN' ? 'zh' : 'en'\n  \n  // 双语确认消息\n  const confirmMessage = backendLang === 'zh' \n    ? `切换为中文后，后端生成的所有提示词、角色描述、场景描述等都将使用中文。是否继续？\n\n\nAfter switching to Chinese, all prompts, character descriptions, scene descriptions generated by the backend will use Chinese. Continue?`\n    : `After switching to English, all prompts, character descriptions, scene descriptions generated by the backend will use English. Continue?\n\n\n切换为英文后，后端生成的所有提示词、角色描述、场景描述等都将使用英文。是否继续？`\n  \n  try {\n    await ElMessageBox.confirm(\n      confirmMessage,\n      '切换语言 / Switch Language',\n      {\n        confirmButtonText: '确定 / Confirm',\n        cancelButtonText: '取消 / Cancel',\n        type: 'warning',\n        dangerouslyUseHTMLString: false\n      }\n    )\n\n    loading.value = true\n    \n    // 调用后端API更新语言设置\n    const res = await settingsAPI.updateLanguage(backendLang)\n    console.log('Backend language updated:', res)\n    \n    // 更新前端语言\n    setLanguage(lang)\n    currentLang.value = lang\n    \n    // 使用后端返回的双语消息（request拦截器已经返回了data）\n    ElMessage.success({\n      message: res?.message || (backendLang === 'zh' ? '语言已切换为中文' : 'Language switched to English'),\n      duration: 3000\n    })\n  } catch (error: any) {\n    if (error !== 'cancel') {\n      console.error('Failed to switch language:', error)\n      \n      // 安全获取错误消息\n      let errorMessage = '未知错误'\n      if (error?.message) {\n        errorMessage = error.message\n      } else if (error?.response?.data?.error?.message) {\n        errorMessage = error.response.data.error.message\n      } else if (typeof error === 'string') {\n        errorMessage = error\n      }\n      \n      // 双语错误提示\n      const errorMsg = currentBackendLang === 'zh'\n        ? `切换语言失败: ${errorMessage}`\n        : `Failed to switch language: ${errorMessage}`\n      \n      ElMessage.error({\n        message: errorMsg,\n        duration: 5000\n      })\n    }\n  } finally {\n    loading.value = false\n  }\n}\n</script>\n\n<style scoped>\n.language-switcher {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  cursor: pointer;\n  padding: 8px 12px;\n  border-radius: 6px;\n  transition: all 0.2s;\n}\n\n.language-switcher:hover {\n  background-color: rgba(0, 0, 0, 0.05);\n}\n\n.lang-text {\n  font-size: 14px;\n  color: #606266;\n}\n</style>\n"
  },
  {
    "path": "web/src/components/common/AIConfigDialog.vue",
    "content": "<template>\n  <el-dialog\n    v-model=\"visible\"\n    :title=\"$t('aiConfig.title')\"\n    width=\"900px\"\n    :close-on-click-modal=\"false\"\n    destroy-on-close\n    class=\"ai-config-dialog\"\n  >\n    <!-- Dialog Header Actions -->\n    <template #header>\n      <div class=\"dialog-header\">\n        <span class=\"dialog-title\">{{ $t(\"aiConfig.title\") }}</span>\n        <div class=\"header-actions\">\n          <el-button type=\"success\" size=\"small\" @click=\"showQuickSetupDialog\">\n            <el-icon><MagicStick /></el-icon>\n            <span>一键配置火宝</span>\n          </el-button>\n          <el-button type=\"primary\" size=\"small\" @click=\"showCreateDialog\">\n            <el-icon><Plus /></el-icon>\n            <span>{{ $t(\"aiConfig.addConfig\") }}</span>\n          </el-button>\n        </div>\n      </div>\n    </template>\n\n    <!-- Tabs -->\n    <el-tabs\n      v-model=\"activeTab\"\n      @tab-change=\"handleTabChange\"\n      class=\"config-tabs\"\n    >\n      <el-tab-pane :label=\"$t('aiConfig.tabs.text')\" name=\"text\">\n        <ConfigList\n          :configs=\"configs\"\n          :loading=\"loading\"\n          :show-test-button=\"true\"\n          @edit=\"handleEdit\"\n          @delete=\"handleDelete\"\n          @toggle-active=\"handleToggleActive\"\n          @test=\"handleTest\"\n        />\n      </el-tab-pane>\n\n      <el-tab-pane :label=\"$t('aiConfig.tabs.image')\" name=\"image\">\n        <ConfigList\n          :configs=\"configs\"\n          :loading=\"loading\"\n          :show-test-button=\"false\"\n          @edit=\"handleEdit\"\n          @delete=\"handleDelete\"\n          @toggle-active=\"handleToggleActive\"\n        />\n      </el-tab-pane>\n\n      <el-tab-pane :label=\"$t('aiConfig.tabs.video')\" name=\"video\">\n        <ConfigList\n          :configs=\"configs\"\n          :loading=\"loading\"\n          :show-test-button=\"false\"\n          @edit=\"handleEdit\"\n          @delete=\"handleDelete\"\n          @toggle-active=\"handleToggleActive\"\n        />\n      </el-tab-pane>\n    </el-tabs>\n\n    <!-- Quick Setup Dialog -->\n    <el-dialog\n      v-model=\"quickSetupVisible\"\n      title=\"一键配置\"\n      width=\"500px\"\n      :close-on-click-modal=\"false\"\n      append-to-body\n    >\n      <div class=\"quick-setup-info\">\n        <p>将自动创建以下配置：</p>\n        <ul>\n          <li>\n            <strong>文本服务</strong>: {{ providerConfigs.text[1].models[0] }}\n          </li>\n          <li>\n            <strong>图片服务</strong>: {{ providerConfigs.image[1].models[0] }}\n          </li>\n          <li>\n            <strong>视频服务</strong>: {{ providerConfigs.video[1].models[0] }}\n          </li>\n        </ul>\n        <p class=\"quick-setup-tip\">Base URL: https://api.chatfire.site/v1</p>\n      </div>\n      <el-form label-width=\"80px\">\n        <el-form-item label=\"API Key\" required>\n          <el-input\n            v-model=\"quickSetupApiKey\"\n            type=\"password\"\n            show-password\n            placeholder=\"请输入 ChatFire API Key\"\n          />\n        </el-form-item>\n      </el-form>\n      <template #footer>\n        <div class=\"quick-setup-footer\">\n          <a\n            href=\"https://api.chatfire.site/login?inviteCode=C4453345\"\n            target=\"_blank\"\n            class=\"register-link\"\n          >\n            没有 API Key？点击注册\n          </a>\n          <div class=\"footer-buttons\">\n            <el-button @click=\"quickSetupVisible = false\">取消</el-button>\n            <el-button\n              type=\"primary\"\n              @click=\"handleQuickSetup\"\n              :loading=\"quickSetupLoading\"\n            >\n              确认配置\n            </el-button>\n          </div>\n        </div>\n      </template>\n    </el-dialog>\n\n    <!-- Edit/Create Sub-Dialog -->\n    <el-dialog\n      v-model=\"editDialogVisible\"\n      :title=\"isEdit ? $t('aiConfig.editConfig') : $t('aiConfig.addConfig')\"\n      width=\"600px\"\n      :close-on-click-modal=\"false\"\n      append-to-body\n    >\n      <el-form ref=\"formRef\" :model=\"form\" :rules=\"rules\" label-width=\"100px\">\n        <el-form-item :label=\"$t('aiConfig.form.name')\" prop=\"name\">\n          <el-input\n            v-model=\"form.name\"\n            :placeholder=\"$t('aiConfig.form.namePlaceholder')\"\n          />\n        </el-form-item>\n\n        <el-form-item :label=\"$t('aiConfig.form.provider')\" prop=\"provider\">\n          <el-select\n            v-model=\"form.provider\"\n            :placeholder=\"$t('aiConfig.form.providerPlaceholder')\"\n            @change=\"handleProviderChange\"\n            style=\"width: 100%\"\n          >\n            <el-option\n              v-for=\"provider in availableProviders\"\n              :key=\"provider.id\"\n              :label=\"provider.name\"\n              :value=\"provider.id\"\n              :disabled=\"provider.disabled\"\n            />\n          </el-select>\n          <div class=\"form-tip\">{{ $t(\"aiConfig.form.providerTip\") }}</div>\n        </el-form-item>\n\n        <el-form-item :label=\"$t('aiConfig.form.priority')\" prop=\"priority\">\n          <el-input-number\n            v-model=\"form.priority\"\n            :min=\"0\"\n            :max=\"100\"\n            :step=\"1\"\n            style=\"width: 100%\"\n          />\n          <div class=\"form-tip\">{{ $t(\"aiConfig.form.priorityTip\") }}</div>\n        </el-form-item>\n\n        <el-form-item :label=\"$t('aiConfig.form.model')\" prop=\"model\">\n          <el-select\n            v-model=\"form.model\"\n            :placeholder=\"$t('aiConfig.form.modelPlaceholder')\"\n            multiple\n            filterable\n            allow-create\n            default-first-option\n            collapse-tags\n            collapse-tags-tooltip\n            style=\"width: 100%\"\n          >\n            <el-option\n              v-for=\"model in availableModels\"\n              :key=\"model\"\n              :label=\"model\"\n              :value=\"model\"\n            />\n          </el-select>\n          <div class=\"form-tip\">{{ $t(\"aiConfig.form.modelTip\") }}</div>\n        </el-form-item>\n\n        <el-form-item :label=\"$t('aiConfig.form.baseUrl')\" prop=\"base_url\">\n          <el-input\n            v-model=\"form.base_url\"\n            :placeholder=\"$t('aiConfig.form.baseUrlPlaceholder')\"\n          />\n          <div class=\"form-tip\">\n            {{ $t(\"aiConfig.form.baseUrlTip\") }}\n            <br />\n            {{ $t(\"aiConfig.form.fullEndpoint\") }}: {{ fullEndpointExample }}\n          </div>\n        </el-form-item>\n\n        <el-form-item :label=\"$t('aiConfig.form.apiKey')\" prop=\"api_key\">\n          <el-input\n            v-model=\"form.api_key\"\n            type=\"password\"\n            show-password\n            :placeholder=\"$t('aiConfig.form.apiKeyPlaceholder')\"\n          />\n          <div class=\"form-tip\">{{ $t(\"aiConfig.form.apiKeyTip\") }}</div>\n        </el-form-item>\n\n        <el-form-item v-if=\"isEdit\" :label=\"$t('aiConfig.form.isActive')\">\n          <el-switch v-model=\"form.is_active\" />\n        </el-form-item>\n      </el-form>\n\n      <template #footer>\n        <div class=\"quick-setup-footer\">\n          <a\n            href=\"https://api.chatfire.site/login?inviteCode=C4453345\"\n            target=\"_blank\"\n            class=\"register-link\"\n          >\n            没有 API Key？点击注册\n          </a>\n          <div class=\"footer-buttons\">\n            <el-button @click=\"editDialogVisible = false\">{{\n              $t(\"common.cancel\")\n            }}</el-button>\n            <el-button\n              v-if=\"form.service_type === 'text'\"\n              @click=\"testConnection\"\n              :loading=\"testing\"\n              >{{ $t(\"aiConfig.actions.test\") }}</el-button\n            >\n            <el-button\n              type=\"primary\"\n              @click=\"handleSubmit\"\n              :loading=\"submitting\"\n            >\n              {{ isEdit ? $t(\"common.save\") : $t(\"common.create\") }}\n            </el-button>\n          </div>\n        </div>\n      </template>\n    </el-dialog>\n  </el-dialog>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, reactive, computed, watch } from \"vue\";\nimport {\n  ElMessage,\n  ElMessageBox,\n  type FormInstance,\n  type FormRules,\n} from \"element-plus\";\nimport { Plus, MagicStick } from \"@element-plus/icons-vue\";\nimport { aiAPI } from \"@/api/ai\";\nimport type {\n  AIServiceConfig,\n  AIServiceType,\n  CreateAIConfigRequest,\n  UpdateAIConfigRequest,\n} from \"@/types/ai\";\nimport ConfigList from \"@/views/settings/components/ConfigList.vue\";\n\nconst props = defineProps<{\n  modelValue: boolean;\n}>();\n\nconst emit = defineEmits<{\n  \"update:modelValue\": [value: boolean];\n  \"config-updated\": [];\n}>();\n\nconst visible = computed({\n  get: () => props.modelValue,\n  set: (val) => emit(\"update:modelValue\", val),\n});\n\nconst activeTab = ref<AIServiceType>(\"text\");\nconst loading = ref(false);\nconst configs = ref<AIServiceConfig[]>([]);\nconst editDialogVisible = ref(false);\nconst isEdit = ref(false);\nconst editingId = ref<number>();\nconst formRef = ref<FormInstance>();\nconst submitting = ref(false);\nconst testing = ref(false);\nconst quickSetupVisible = ref(false);\nconst quickSetupApiKey = ref(\"\");\nconst quickSetupLoading = ref(false);\n\nconst form = reactive<\n  CreateAIConfigRequest & { is_active?: boolean; provider?: string }\n>({\n  service_type: \"text\",\n  provider: \"\",\n  name: \"\",\n  base_url: \"\",\n  api_key: \"\",\n  model: [],\n  priority: 0,\n  is_active: true,\n});\n\n// Provider configs\ninterface ProviderConfig {\n  id: string;\n  name: string;\n  models: string[];\n  disabled?: boolean;\n}\n\nconst providerConfigs: Record<AIServiceType, ProviderConfig[]> = {\n  text: [\n    {\n      id: \"openai\",\n      name: \"OpenAI\",\n      models: [\"gpt-5.2\", \"gemini-3-flash-preview\"],\n    },\n    {\n      id: \"chatfire\",\n      name: \"Chatfire\",\n      models: [\n        \"gemini-3-flash-preview\",\n        \"claude-sonnet-4-5-20250929\",\n        \"doubao-seed-1-8-251228\",\n      ],\n    },\n    {\n      id: \"gemini\",\n      name: \"Google Gemini\",\n      models: [\"gemini-2.5-pro\", \"gemini-3-flash-preview\"],\n    },\n  ],\n  image: [\n    {\n      id: \"volcengine\",\n      name: \"火山引擎\",\n      models: [\"doubao-seedream-4-5-251128\", \"doubao-seedream-4-0-250828\"],\n    },\n    {\n      id: \"chatfire\",\n      name: \"Chatfire\",\n      models: [\"nano-banana-pro\", \"doubao-seedream-4-5-251128\"],\n    },\n    {\n      id: \"gemini\",\n      name: \"Google Gemini\",\n      models: [\"gemini-3-pro-image-preview\"],\n    },\n    { id: \"openai\", name: \"OpenAI\", models: [\"dall-e-3\", \"dall-e-2\"] },\n  ],\n  video: [\n    {\n      id: \"volces\",\n      name: \"火山引擎\",\n      models: [\n        \"doubao-seedance-1-5-pro-251215\",\n        \"doubao-seedance-1-0-lite-i2v-250428\",\n        \"doubao-seedance-1-0-lite-t2v-250428\",\n        \"doubao-seedance-1-0-pro-250528\",\n        \"doubao-seedance-1-0-pro-fast-251015\",\n      ],\n    },\n    {\n      id: \"chatfire\",\n      name: \"Chatfire\",\n      models: [\n        \"doubao-seedance-1-5-pro-251215\",\n        \"doubao-seedance-1-0-lite-i2v-250428\",\n        \"doubao-seedance-1-0-lite-t2v-250428\",\n        \"doubao-seedance-1-0-pro-250528\",\n        \"doubao-seedance-1-0-pro-fast-251015\",\n        \"sora-2\",\n        \"sora-2-pro\",\n      ],\n    },\n    {\n      id: \"minimax\",\n      name: \"MiniMax 海螺\",\n      models: [\n        \"MiniMax-Hailuo-2.3\",\n        \"MiniMax-Hailuo-2.3-Fast\",\n        \"MiniMax-Hailuo-02\",\n      ],\n    },\n    { id: \"openai\", name: \"OpenAI\", models: [\"sora-2\", \"sora-2-pro\"] },\n  ],\n};\n\n// 当前可用的厂商列表（显示所有配置的厂商）\nconst availableProviders = computed(() => {\n  // 返回当前service_type下的所有厂商\n  return providerConfigs[form.service_type] || [];\n});\n\n// 当前可用的模型列表（从预定义配置中获取）\nconst availableModels = computed(() => {\n  if (!form.provider || !form.service_type) return [];\n\n  // 从预定义配置中查找当前厂商的模型列表\n  const providerConfig = providerConfigs[form.service_type]?.find(\n    (p) => p.id === form.provider,\n  );\n\n  return providerConfig?.models || [];\n});\n\nconst fullEndpointExample = computed(() => {\n  const baseUrl = form.base_url || \"https://api.example.com\";\n  const provider = form.provider;\n  const serviceType = form.service_type;\n\n  let endpoint = \"\";\n\n  if (serviceType === \"text\") {\n    if (provider === \"gemini\" || provider === \"google\") {\n      endpoint = \"/v1beta/models/{model}:generateContent\";\n    } else {\n      endpoint = \"/chat/completions\";\n    }\n  } else if (serviceType === \"image\") {\n    if (provider === \"gemini\" || provider === \"google\") {\n      endpoint = \"/v1beta/models/{model}:generateContent\";\n    } else {\n      endpoint = \"/images/generations\";\n    }\n  } else if (serviceType === \"video\") {\n    if (provider === \"chatfire\") {\n      endpoint = \"/video/generations\";\n    } else if (\n      provider === \"doubao\" ||\n      provider === \"volcengine\" ||\n      provider === \"volces\"\n    ) {\n      endpoint = \"/contents/generations/tasks\";\n    } else if (provider === \"minimax\") {\n      endpoint = \"/video_generation\";\n    } else if (provider === \"openai\") {\n      endpoint = \"/videos\";\n    } else {\n      endpoint = \"/video/generations\";\n    }\n  }\n\n  return baseUrl + endpoint;\n});\n\nconst rules: FormRules = {\n  name: [{ required: true, message: \"请输入配置名称\", trigger: \"blur\" }],\n  provider: [{ required: true, message: \"请选择厂商\", trigger: \"change\" }],\n  base_url: [\n    { required: true, message: \"请输入 Base URL\", trigger: \"blur\" },\n    { type: \"url\", message: \"请输入正确的 URL 格式\", trigger: \"blur\" },\n  ],\n  api_key: [{ required: true, message: \"请输入 API Key\", trigger: \"blur\" }],\n  model: [\n    {\n      required: true,\n      message: \"请至少选择一个模型\",\n      trigger: \"change\",\n      validator: (rule: any, value: any, callback: any) => {\n        if (Array.isArray(value) && value.length > 0) {\n          callback();\n        } else if (typeof value === \"string\" && value.length > 0) {\n          callback();\n        } else {\n          callback(new Error(\"请至少选择一个模型\"));\n        }\n      },\n    },\n  ],\n};\n\nconst loadConfigs = async () => {\n  loading.value = true;\n  try {\n    configs.value = await aiAPI.list(activeTab.value);\n  } catch (error: any) {\n    ElMessage.error(error.message || \"加载失败\");\n  } finally {\n    loading.value = false;\n  }\n};\n\nconst generateConfigName = (\n  provider: string,\n  serviceType: AIServiceType,\n): string => {\n  const providerNames: Record<string, string> = {\n    chatfire: \"ChatFire\",\n    openai: \"OpenAI\",\n    gemini: \"Gemini\",\n    google: \"Google\",\n  };\n\n  const serviceNames: Record<AIServiceType, string> = {\n    text: \"文本\",\n    image: \"图片\",\n    video: \"视频\",\n  };\n\n  const randomNum = Math.floor(Math.random() * 10000)\n    .toString()\n    .padStart(4, \"0\");\n  const providerName = providerNames[provider] || provider;\n  const serviceName = serviceNames[serviceType] || serviceType;\n\n  return `${providerName}-${serviceName}-${randomNum}`;\n};\n\nconst showCreateDialog = () => {\n  isEdit.value = false;\n  editingId.value = undefined;\n  resetForm();\n  form.service_type = activeTab.value;\n  form.provider = \"chatfire\";\n  form.base_url = \"https://api.chatfire.site/v1\";\n  form.name = generateConfigName(\"chatfire\", activeTab.value);\n  // 图片模型配置默认 nano\n  if (activeTab.value === \"image\") {\n    form.model = [\"nano-banana-pro\"];\n  }\n  editDialogVisible.value = true;\n};\n\nconst handleEdit = (config: AIServiceConfig) => {\n  isEdit.value = true;\n  editingId.value = config.id;\n\n  Object.assign(form, {\n    service_type: config.service_type,\n    provider: config.provider || \"chatfire\",\n    name: config.name,\n    base_url: config.base_url,\n    api_key: config.api_key,\n    model: Array.isArray(config.model) ? config.model : [config.model],\n    priority: config.priority || 0,\n    is_active: config.is_active,\n  });\n  editDialogVisible.value = true;\n};\n\nconst handleDelete = async (config: AIServiceConfig) => {\n  try {\n    await ElMessageBox.confirm(\"确定要删除该配置吗？\", \"警告\", {\n      confirmButtonText: \"确定\",\n      cancelButtonText: \"取消\",\n      type: \"warning\",\n    });\n\n    await aiAPI.delete(config.id);\n    ElMessage.success(\"删除成功\");\n    loadConfigs();\n  } catch (error: any) {\n    if (error !== \"cancel\") {\n      ElMessage.error(error.message || \"删除失败\");\n    }\n  }\n};\n\nconst handleToggleActive = async (config: AIServiceConfig) => {\n  try {\n    const newActiveState = !config.is_active;\n    await aiAPI.update(config.id, { is_active: newActiveState });\n    ElMessage.success(newActiveState ? \"已启用配置\" : \"已禁用配置\");\n    await loadConfigs();\n  } catch (error: any) {\n    ElMessage.error(error.message || \"操作失败\");\n  }\n};\n\nconst testConnection = async () => {\n  if (!formRef.value) return;\n\n  const valid = await formRef.value.validate().catch(() => false);\n  if (!valid) return;\n\n  testing.value = true;\n  try {\n    await aiAPI.testConnection({\n      base_url: form.base_url,\n      api_key: form.api_key,\n      model: form.model,\n      provider: form.provider,\n    });\n    ElMessage.success(\"连接测试成功！\");\n  } catch (error: any) {\n    ElMessage.error(error.message || \"连接测试失败\");\n  } finally {\n    testing.value = false;\n  }\n};\n\nconst handleTest = async (config: AIServiceConfig) => {\n  testing.value = true;\n  try {\n    await aiAPI.testConnection({\n      base_url: config.base_url,\n      api_key: config.api_key,\n      model: config.model,\n      provider: config.provider,\n    });\n    ElMessage.success(\"连接测试成功！\");\n  } catch (error: any) {\n    ElMessage.error(error.message || \"连接测试失败\");\n  } finally {\n    testing.value = false;\n  }\n};\n\nconst handleSubmit = async () => {\n  if (!formRef.value) return;\n\n  await formRef.value.validate(async (valid) => {\n    if (!valid) return;\n\n    submitting.value = true;\n    try {\n      if (isEdit.value && editingId.value) {\n        const updateData: UpdateAIConfigRequest = {\n          name: form.name,\n          provider: form.provider,\n          base_url: form.base_url,\n          api_key: form.api_key,\n          model: form.model,\n          priority: form.priority,\n          is_active: form.is_active,\n        };\n        await aiAPI.update(editingId.value, updateData);\n        ElMessage.success(\"更新成功\");\n      } else {\n        await aiAPI.create(form);\n        ElMessage.success(\"创建成功\");\n      }\n\n      editDialogVisible.value = false;\n      loadConfigs();\n      emit(\"config-updated\");\n    } catch (error: any) {\n      ElMessage.error(error.message || \"操作失败\");\n    } finally {\n      submitting.value = false;\n    }\n  });\n};\n\nconst handleTabChange = (tabName: string | number) => {\n  activeTab.value = tabName as AIServiceType;\n  loadConfigs();\n};\n\nconst handleProviderChange = () => {\n  form.model = [];\n\n  // 根据厂商自动设置 Base URL\n  if (form.provider === \"gemini\" || form.provider === \"google\") {\n    form.base_url = \"https://generativelanguage.googleapis.com\";\n  } else if (form.provider === \"minimax\") {\n    form.base_url = \"https://api.minimaxi.com/v1\";\n  } else if (form.provider === \"volces\" || form.provider === \"volcengine\") {\n    form.base_url = \"https://ark.cn-beijing.volces.com/api/v3\";\n  } else if (form.provider === \"openai\") {\n    form.base_url = \"https://api.openai.com/v1\";\n  } else {\n    // chatfire 和其他厂商\n    form.base_url = \"https://api.chatfire.site/v1\";\n  }\n\n  if (!isEdit.value) {\n    form.name = generateConfigName(form.provider, form.service_type);\n  }\n};\n\nconst resetForm = () => {\n  const serviceType = form.service_type || \"text\";\n  Object.assign(form, {\n    service_type: serviceType,\n    provider: \"\",\n    name: \"\",\n    base_url: \"\",\n    api_key: \"\",\n    model: [],\n    priority: 0,\n    is_active: true,\n  });\n  formRef.value?.resetFields();\n};\n\nconst showQuickSetupDialog = () => {\n  quickSetupApiKey.value = \"\";\n  quickSetupVisible.value = true;\n};\n\nconst handleQuickSetup = async () => {\n  if (!quickSetupApiKey.value.trim()) {\n    ElMessage.warning(\"请输入 API Key\");\n    return;\n  }\n\n  quickSetupLoading.value = true;\n  const baseUrl = \"https://api.chatfire.site/v1\";\n  const apiKey = quickSetupApiKey.value.trim();\n\n  try {\n    // 加载所有类型的配置，检查是否已存在相同 baseUrl 的配置\n    const [textConfigs, imageConfigs, videoConfigs] = await Promise.all([\n      aiAPI.list(\"text\"),\n      aiAPI.list(\"image\"),\n      aiAPI.list(\"video\"),\n    ]);\n\n    const createdServices: string[] = [];\n    const skippedServices: string[] = [];\n\n    // 创建文本配置（如果不存在）\n    const existingTextConfig = textConfigs.find((c) => c.base_url === baseUrl);\n    if (!existingTextConfig) {\n      const textProvider = providerConfigs.text.find(\n        (p) => p.id === \"chatfire\",\n      )!;\n      await aiAPI.create({\n        service_type: \"text\",\n        provider: \"chatfire\",\n        name: generateConfigName(\"chatfire\", \"text\"),\n        base_url: baseUrl,\n        api_key: apiKey,\n        model: [textProvider.models[0]],\n        priority: 0,\n      });\n      createdServices.push(\"文本\");\n    } else {\n      skippedServices.push(\"文本\");\n    }\n\n    // 创建图片配置（如果不存在）\n    const existingImageConfig = imageConfigs.find(\n      (c) => c.base_url === baseUrl,\n    );\n    if (!existingImageConfig) {\n      const imageProvider = providerConfigs.image.find(\n        (p) => p.id === \"chatfire\",\n      )!;\n      await aiAPI.create({\n        service_type: \"image\",\n        provider: \"chatfire\",\n        name: generateConfigName(\"chatfire\", \"image\"),\n        base_url: baseUrl,\n        api_key: apiKey,\n        model: [imageProvider.models[0]],\n        priority: 0,\n      });\n      createdServices.push(\"图片\");\n    } else {\n      skippedServices.push(\"图片\");\n    }\n\n    // 创建视频配置（如果不存在）\n    const existingVideoConfig = videoConfigs.find(\n      (c) => c.base_url === baseUrl,\n    );\n    if (!existingVideoConfig) {\n      const videoProvider = providerConfigs.video.find(\n        (p) => p.id === \"chatfire\",\n      )!;\n      await aiAPI.create({\n        service_type: \"video\",\n        provider: \"chatfire\",\n        name: generateConfigName(\"chatfire\", \"video\"),\n        base_url: baseUrl,\n        api_key: apiKey,\n        model: [videoProvider.models[0]],\n        priority: 0,\n      });\n      createdServices.push(\"视频\");\n    } else {\n      skippedServices.push(\"视频\");\n    }\n\n    // 显示结果消息\n    if (createdServices.length > 0 && skippedServices.length > 0) {\n      ElMessage.success(\n        `已创建 ${createdServices.join(\"、\")} 配置，${skippedServices.join(\"、\")} 配置已存在`,\n      );\n    } else if (createdServices.length > 0) {\n      ElMessage.success(\n        `一键配置成功！已创建 ${createdServices.join(\"、\")} 服务配置`,\n      );\n    } else {\n      ElMessage.info(\"所有配置已存在，无需重复创建\");\n    }\n\n    quickSetupVisible.value = false;\n    loadConfigs();\n    if (createdServices.length > 0) {\n      emit(\"config-updated\");\n    }\n  } catch (error: any) {\n    ElMessage.error(error.message || \"配置失败\");\n  } finally {\n    quickSetupLoading.value = false;\n  }\n};\n\n// Load configs when dialog opens\nwatch(visible, (val) => {\n  if (val) {\n    loadConfigs();\n  }\n});\n</script>\n\n<style scoped>\n.ai-config-dialog :deep(.el-dialog__header) {\n  padding: 16px 20px;\n  border-bottom: 1px solid var(--border-primary);\n  margin-right: 0;\n}\n\n.dialog-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  width: 100%;\n  padding-right: 32px;\n}\n\n.header-actions {\n  display: flex;\n  gap: 8px;\n}\n\n.quick-setup-info {\n  margin-bottom: 16px;\n  padding: 12px 16px;\n  background: var(--bg-secondary);\n  border-radius: 8px;\n  font-size: 14px;\n  color: var(--text-primary);\n\n  p {\n    margin: 0 0 8px 0;\n  }\n\n  ul {\n    margin: 8px 0;\n    padding-left: 20px;\n  }\n\n  li {\n    margin: 4px 0;\n    color: var(--text-secondary);\n  }\n\n  .quick-setup-tip {\n    margin-top: 12px;\n    font-size: 12px;\n    color: var(--text-muted);\n  }\n}\n\n.quick-setup-footer {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  width: 100%;\n}\n\n.register-link {\n  font-size: 12px;\n  color: var(--text-muted);\n  text-decoration: none;\n  transition: color 0.2s;\n\n  &:hover {\n    color: var(--accent);\n  }\n}\n\n.footer-buttons {\n  display: flex;\n  gap: 8px;\n}\n\n.dialog-title {\n  font-size: 1.125rem;\n  font-weight: 600;\n  color: var(--text-primary);\n}\n\n.ai-config-dialog :deep(.el-dialog__body) {\n  padding: 20px;\n  max-height: 60vh;\n  overflow-y: auto;\n}\n\n.config-tabs {\n  margin: 0;\n}\n\n.form-tip {\n  font-size: 0.75rem;\n  color: var(--text-muted);\n  margin-top: 0.25rem;\n  word-break: break-all;\n  overflow-wrap: break-word;\n  line-height: 1.5;\n}\n\n/* Dark mode */\n.dark .ai-config-dialog :deep(.el-dialog) {\n  background: var(--bg-card);\n}\n\n.dark .ai-config-dialog :deep(.el-dialog__header) {\n  background: var(--bg-card);\n}\n\n.dark :deep(.el-form-item__label) {\n  color: var(--text-primary);\n}\n\n.dark :deep(.el-input__wrapper) {\n  background: var(--bg-secondary);\n  box-shadow: 0 0 0 1px var(--border-primary) inset;\n}\n\n.dark :deep(.el-input__inner) {\n  color: var(--text-primary);\n}\n\n.dark :deep(.el-select .el-input__wrapper) {\n  background: var(--bg-secondary);\n}\n</style>\n"
  },
  {
    "path": "web/src/components/common/ActionButton.vue",
    "content": "<template>\n  <!-- Minimalist action button with icon and optional tooltip -->\n  <!-- 简约操作按钮，带图标和可选提示 -->\n  <el-tooltip\n    v-if=\"tooltip\"\n    :content=\"tooltip\"\n    placement=\"top\"\n    :show-after=\"500\"\n  >\n    <button\n      :class=\"['action-button', variant, { disabled }]\"\n      :disabled=\"disabled\"\n      @click=\"$emit('click')\"\n    >\n      <el-icon :size=\"size\">\n        <component :is=\"icon\" />\n      </el-icon>\n    </button>\n  </el-tooltip>\n  <button\n    v-else\n    :class=\"['action-button', variant, { disabled }]\"\n    :disabled=\"disabled\"\n    @click=\"$emit('click')\"\n  >\n    <el-icon :size=\"size\">\n      <component :is=\"icon\" />\n    </el-icon>\n  </button>\n</template>\n\n<script setup lang=\"ts\">\nimport type { Component } from 'vue'\n\n/**\n * ActionButton - Minimalist icon button for actions\n * 操作按钮 - 简约图标按钮用于各种操作\n */\nwithDefaults(defineProps<{\n  icon: Component\n  tooltip?: string\n  variant?: 'default' | 'primary' | 'danger'\n  size?: number\n  disabled?: boolean\n}>(), {\n  variant: 'default',\n  size: 16,\n  disabled: false\n})\n\ndefineEmits<{\n  click: []\n}>()\n</script>\n\n<style scoped>\n.action-button {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  padding: 0.5rem;\n  border: none;\n  border-radius: var(--radius-sm);\n  background: transparent;\n  color: var(--text-muted);\n  cursor: pointer;\n  transition: all var(--transition-fast);\n}\n\n.action-button:hover {\n  background: var(--bg-card-hover);\n  color: var(--text-primary);\n}\n\n.action-button:focus-visible {\n  outline: 2px solid var(--accent);\n  outline-offset: 1px;\n}\n\n.action-button.primary:hover {\n  background: var(--accent-light);\n  color: var(--accent);\n}\n\n.action-button.danger:hover {\n  background: #fef2f2;\n  color: #ef4444;\n}\n\n.dark .action-button.danger:hover {\n  background: rgba(239, 68, 68, 0.15);\n}\n\n.action-button.disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n\n.action-button.disabled:hover {\n  background: transparent;\n  color: var(--text-muted);\n}\n</style>\n"
  },
  {
    "path": "web/src/components/common/AppHeader.vue",
    "content": "<template>\n  <div class=\"app-header-wrapper\">\n    <header class=\"app-header\" :class=\"{ 'header-fixed': fixed }\">\n      <div class=\"header-content\">\n        <!-- Left section: Logo + Left slot -->\n        <div class=\"header-left\">\n          <router-link v-if=\"showLogo\" to=\"/\" class=\"logo\">\n            <span class=\"logo-text\">🎬 HuoBao Drama</span>\n          </router-link>\n          <!-- Left slot for business content | 左侧插槽用于业务内容 -->\n          <slot name=\"left\" />\n        </div>\n\n        <!-- Center section: Center slot -->\n        <div class=\"header-center\">\n          <slot name=\"center\" />\n        </div>\n\n        <!-- Right section: Actions + Right slot -->\n        <div class=\"header-right\">\n          \n          <!-- Language Switcher | 语言切换 -->\n          <LanguageSwitcher v-if=\"showLanguage\" />\n          \n          <!-- Theme Toggle | 主题切换 -->\n          <ThemeToggle v-if=\"showTheme\" />\n          \n          <!-- AI Config (Model Switch) | AI 配置（模型切换） -->\n          <el-button v-if=\"showAIConfig\" @click=\"handleOpenAIConfig\" class=\"header-btn\">\n            <el-icon><Setting /></el-icon>\n            <span class=\"btn-text\">{{ $t('drama.aiConfig') }}</span>\n          </el-button>\n          <!-- Right slot for business content (before actions) | 右侧插槽（在操作按钮前） -->\n          <slot name=\"right\" />\n        </div>\n      </div>\n    </header>\n    \n    <!-- AI Config Dialog | AI 配置对话框 -->\n    <AIConfigDialog v-model=\"showConfigDialog\" @config-updated=\"emit('config-updated')\" />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport { Setting } from '@element-plus/icons-vue'\nimport ThemeToggle from './ThemeToggle.vue'\nimport AIConfigDialog from './AIConfigDialog.vue'\nimport LanguageSwitcher from '@/components/LanguageSwitcher.vue'\n\n/**\n * AppHeader - Global application header component\n * 应用顶部头组件\n * \n * Features | 功能:\n * - Fixed position at top | 固定在顶部\n * - Model/Theme/Language switch | 模型/主题/语言切换\n * - Slots support for business content | 支持插槽放置业务内容\n * \n * Slots | 插槽:\n * - left: Content after logo | logo 右侧内容\n * - center: Center content | 中间内容\n * - right: Content before actions | 操作按钮左侧内容\n */\n\ninterface Props {\n  /** Fixed position at top | 是否固定在顶部 */\n  fixed?: boolean\n  /** Show logo | 是否显示 logo */\n  showLogo?: boolean\n  /** Show language switcher | 是否显示语言切换 */\n  showLanguage?: boolean\n  /** Show theme toggle | 是否显示主题切换 */\n  showTheme?: boolean\n  /** Show AI config button | 是否显示 AI 配置按钮 */\n  showAIConfig?: boolean\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  fixed: true,\n  showLogo: true,\n  showLanguage: true,\n  showTheme: true,\n  showAIConfig: true\n})\n\nconst emit = defineEmits<{\n  (e: 'open-ai-config'): void\n  (e: 'config-updated'): void\n}>()\n\n// AI Config dialog state | AI 配置对话框状态\nconst showConfigDialog = ref(false)\n\n// Handle open AI config | 处理打开 AI 配置\nconst handleOpenAIConfig = () => {\n  showConfigDialog.value = true\n  emit('open-ai-config')\n}\n\n// Expose methods for external control | 暴露方法供外部控制\ndefineExpose({\n  openAIConfig: () => {\n    showConfigDialog.value = true\n  }\n})\n</script>\n\n<style scoped>\n.app-header {\n  background: var(--bg-card);\n  border-bottom: 1px solid var(--border-primary);\n  backdrop-filter: blur(8px);\n  z-index: 1000;\n}\n\n.app-header.header-fixed {\n  position: fixed;\n  top: 0;\n  left: 0;\n  right: 0;\n}\n\n.header-content {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 0 var(--space-4);\n  height: 70px;\n  max-width: 100%;\n  margin: 0 auto;\n}\n\n.header-left {\n  display: flex;\n  align-items: center;\n  gap: var(--space-4);\n  flex-shrink: 0;\n}\n\n.header-center {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex: 1;\n  min-width: 0;\n}\n\n.header-right {\n  display: flex;\n  align-items: center;\n  gap: var(--space-2);\n  flex-shrink: 0;\n}\n\n.logo {\n  display: flex;\n  align-items: center;\n  gap: var(--space-2);\n  text-decoration: none;\n  color: var(--text-primary);\n  font-weight: 700;\n  font-size: 1.125rem;\n  transition: opacity var(--transition-fast);\n}\n\n.logo:hover {\n  opacity: 0.8;\n}\n\n.logo-text {\n  background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);\n  -webkit-background-clip: text;\n  -webkit-text-fill-color: transparent;\n  background-clip: text;\n}\n\n.header-btn {\n  border-radius: var(--radius-lg);\n  font-weight: 500;\n}\n\n.header-btn .btn-text {\n  margin-left: 4px;\n}\n\n/* Dark mode adjustments | 深色模式适配 */\n.dark .app-header {\n  background: rgba(26, 33, 41, 0.95);\n}\n\n/* ========================================\n   Common Slot Styles / 插槽通用样式\n   ======================================== */\n\n/* Back Button | 返回按钮 */\n:deep(.back-btn) {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  padding: 8px 12px;\n  font-size: 0.875rem;\n  font-weight: 500;\n  color: var(--text-secondary);\n  border-radius: var(--radius-md);\n  transition: all var(--transition-fast);\n}\n\n:deep(.back-btn:hover) {\n  color: var(--text-primary);\n  background: var(--bg-hover);\n}\n\n:deep(.back-btn .el-icon) {\n  font-size: 1rem;\n}\n\n/* Page Title | 页面标题 */\n:deep(.page-title) {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n}\n\n:deep(.page-title h1),\n:deep(.header-title),\n:deep(.drama-title) {\n  margin: 0;\n  font-size: 1.25rem;\n  font-weight: 700;\n  color: var(--text-primary);\n  line-height: 1.3;\n}\n\n:deep(.page-title .subtitle) {\n  font-size: 0.8125rem;\n  color: var(--text-muted);\n}\n\n/* Episode Title | 章节标题 */\n:deep(.episode-title) {\n  font-size: 1rem;\n  font-weight: 600;\n  color: var(--text-primary);\n}\n\n/* Responsive | 响应式 */\n@media (max-width: 768px) {\n  .header-content {\n    padding: 0 var(--space-3);\n  }\n  \n  .btn-text {\n    display: none;\n  }\n  \n  .header-btn {\n    padding: 8px;\n  }\n\n  :deep(.page-title h1),\n  :deep(.header-title),\n  :deep(.drama-title) {\n    font-size: 1rem;\n  }\n\n  :deep(.back-btn span) {\n    display: none;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/src/components/common/AppLayout.vue",
    "content": "<template>\n  <div class=\"app-layout\">\n    <!-- Global Header -->\n    <header class=\"app-header\">\n      <div class=\"header-content\">\n        <div class=\"header-left\">\n          <router-link to=\"/\" class=\"logo\">\n            <span class=\"logo-text\">🎬 HuoBao Drama</span>\n          </router-link>\n        </div>\n        <div class=\"header-right\">\n          <LanguageSwitcher />\n          <ThemeToggle />\n          <el-button @click=\"showAIConfig = true\" class=\"header-btn\">\n            <el-icon><Setting /></el-icon>\n            <span class=\"btn-text\">{{ $t('drama.aiConfig') }}</span>\n          </el-button>\n          <!-- <el-button :icon=\"Setting\" circle @click=\"showAIConfig = true\" :title=\"$t('aiConfig.title')\" /> -->\n        </div>\n      </div>\n    </header>\n\n    <!-- Main Content -->\n    <main class=\"app-main\">\n      <slot />\n    </main>\n\n    <!-- AI Config Dialog -->\n    <AIConfigDialog v-model=\"showAIConfig\" />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport { Setting } from '@element-plus/icons-vue'\nimport ThemeToggle from './ThemeToggle.vue'\nimport AIConfigDialog from './AIConfigDialog.vue'\nimport LanguageSwitcher from '@/components/LanguageSwitcher.vue'\n\nconst showAIConfig = ref(false)\n</script>\n\n<style scoped>\n.app-layout {\n  min-height: 100vh;\n  display: flex;\n  flex-direction: column;\n}\n\n.app-header {\n  position: sticky;\n  top: 0;\n  z-index: 100;\n  background: var(--bg-card);\n  border-bottom: 1px solid var(--border-primary);\n  backdrop-filter: blur(8px);\n}\n\n.header-content {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 0 var(--space-4);\n  height: 56px;\n  max-width: 100%;\n  margin: 0 auto;\n}\n.header-btn {\n  border-radius: var(--radius-lg);\n  font-weight: 500;\n}\n\n.header-btn.primary {\n  background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%);\n  border: none;\n  box-shadow: 0 4px 14px rgba(14, 165, 233, 0.35);\n}\n\n.header-btn.primary:hover {\n  transform: translateY(-1px);\n  box-shadow: 0 6px 20px rgba(14, 165, 233, 0.45);\n}\n.header-left {\n  display: flex;\n  align-items: center;\n  gap: var(--space-4);\n}\n\n.logo {\n  display: flex;\n  align-items: center;\n  gap: var(--space-2);\n  text-decoration: none;\n  color: var(--text-primary);\n  font-weight: 700;\n  font-size: 1.125rem;\n  transition: opacity var(--transition-fast);\n}\n\n.logo:hover {\n  opacity: 0.8;\n}\n\n.logo-text {\n  background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);\n  -webkit-background-clip: text;\n  -webkit-text-fill-color: transparent;\n  background-clip: text;\n}\n\n.header-right {\n  display: flex;\n  align-items: center;\n  gap: var(--space-2);\n}\n\n.app-main {\n  flex: 1;\n}\n\n/* Dark mode adjustments */\n.dark .app-header {\n  background: rgba(26, 33, 41, 0.95);\n}\n</style>\n"
  },
  {
    "path": "web/src/components/common/BaseCard.vue",
    "content": "<template>\n  <!-- Base Card Component - Reusable card with modern design -->\n  <!-- 基础卡片组件 - 现代设计的可复用卡片 -->\n  <div \n    :class=\"[\n      'base-card',\n      `variant-${variant}`,\n      { 'is-hoverable': hoverable, 'is-clickable': clickable }\n    ]\"\n    @click=\"clickable ? $emit('click') : undefined\"\n    :tabindex=\"clickable ? 0 : undefined\"\n    @keydown.enter=\"clickable ? $emit('click') : undefined\"\n  >\n    <!-- Card Header / 卡片头部 -->\n    <div v-if=\"$slots.header || title\" class=\"card-header\">\n      <slot name=\"header\">\n        <div class=\"header-content\">\n          <div v-if=\"icon\" class=\"header-icon\">\n            <el-icon :size=\"iconSize\" :color=\"iconColor\">\n              <component :is=\"icon\" />\n            </el-icon>\n          </div>\n          <div class=\"header-text\">\n            <h3 class=\"card-title\">{{ title }}</h3>\n            <p v-if=\"subtitle\" class=\"card-subtitle\">{{ subtitle }}</p>\n          </div>\n        </div>\n        <div v-if=\"$slots.headerActions\" class=\"header-actions\">\n          <slot name=\"headerActions\"></slot>\n        </div>\n      </slot>\n    </div>\n\n    <!-- Card Body / 卡片内容 -->\n    <div :class=\"['card-body', { 'no-padding': noPadding }]\">\n      <slot></slot>\n    </div>\n\n    <!-- Card Footer / 卡片底部 -->\n    <div v-if=\"$slots.footer\" class=\"card-footer\">\n      <slot name=\"footer\"></slot>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport type { Component } from 'vue'\n\n/**\n * BaseCard - Reusable card component with modern design\n * 基础卡片组件 - 现代设计的可复用卡片\n */\nwithDefaults(defineProps<{\n  title?: string\n  subtitle?: string\n  icon?: Component\n  iconSize?: number\n  iconColor?: string\n  variant?: 'default' | 'elevated' | 'outlined' | 'ghost'\n  hoverable?: boolean\n  clickable?: boolean\n  noPadding?: boolean\n}>(), {\n  variant: 'default',\n  iconSize: 20,\n  hoverable: false,\n  clickable: false,\n  noPadding: false\n})\n\ndefineEmits<{\n  click: []\n}>()\n</script>\n\n<style scoped>\n/* Card Container / 卡片容器 */\n.base-card {\n  display: flex;\n  flex-direction: column;\n  background: var(--bg-card);\n  border-radius: var(--radius-xl);\n  transition: all var(--transition-normal);\n  overflow: hidden;\n}\n\n/* Variants / 变体样式 */\n.variant-default {\n  border: 1px solid var(--border-primary);\n  box-shadow: var(--shadow-card);\n}\n\n.variant-elevated {\n  border: none;\n  box-shadow: var(--shadow-md);\n}\n\n.variant-outlined {\n  border: 1px solid var(--border-primary);\n  box-shadow: none;\n}\n\n.variant-ghost {\n  background: transparent;\n  border: none;\n  box-shadow: none;\n}\n\n/* Hover & Clickable States / 悬停和可点击状态 */\n.is-hoverable:hover {\n  box-shadow: var(--shadow-card-hover);\n  border-color: var(--border-secondary);\n}\n\n.is-clickable {\n  cursor: pointer;\n}\n\n.is-clickable:hover {\n  border-color: var(--accent);\n  box-shadow: var(--shadow-glow);\n}\n\n.is-clickable:focus-visible {\n  outline: 2px solid var(--accent);\n  outline-offset: 2px;\n}\n\n.is-clickable:active {\n  transform: scale(0.995);\n}\n\n/* Card Header / 卡片头部 */\n.card-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: var(--space-4) var(--space-5);\n  border-bottom: 1px solid var(--border-primary);\n}\n\n.header-content {\n  display: flex;\n  align-items: center;\n  gap: var(--space-3);\n}\n\n.header-icon {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 2.5rem;\n  height: 2.5rem;\n  background: var(--accent-light);\n  border-radius: var(--radius-lg);\n  color: var(--accent);\n}\n\n.header-text {\n  display: flex;\n  flex-direction: column;\n  gap: 0.125rem;\n}\n\n.card-title {\n  margin: 0;\n  font-size: 1rem;\n  font-weight: 600;\n  color: var(--text-primary);\n  letter-spacing: -0.01em;\n}\n\n.card-subtitle {\n  margin: 0;\n  font-size: 0.8125rem;\n  color: var(--text-muted);\n}\n\n.header-actions {\n  display: flex;\n  align-items: center;\n  gap: var(--space-2);\n}\n\n/* Card Body / 卡片内容 */\n.card-body {\n  padding: var(--space-5);\n  flex: 1;\n}\n\n.card-body.no-padding {\n  padding: 0;\n}\n\n/* Card Footer / 卡片底部 */\n.card-footer {\n  display: flex;\n  align-items: center;\n  justify-content: flex-end;\n  gap: var(--space-3);\n  padding: var(--space-4) var(--space-5);\n  border-top: 1px solid var(--border-primary);\n  background: var(--bg-secondary);\n}\n\n/* Dark mode adjustments / 深色模式调整 */\n.dark .card-footer {\n  background: var(--bg-secondary);\n}\n</style>\n"
  },
  {
    "path": "web/src/components/common/CreateDramaDialog.vue",
    "content": "<template>\n  <!-- Create Drama Dialog / 创建短剧弹窗 -->\n  <el-dialog\n    v-model=\"visible\"\n    :title=\"$t('drama.createNew')\"\n    width=\"520px\"\n    :close-on-click-modal=\"false\"\n    class=\"create-dialog\"\n    @closed=\"handleClosed\"\n  >\n    <div class=\"dialog-desc\">{{ $t(\"drama.createDesc\") }}</div>\n\n    <el-form\n      ref=\"formRef\"\n      :model=\"form\"\n      :rules=\"rules\"\n      label-position=\"top\"\n      class=\"create-form\"\n      @submit.prevent=\"handleSubmit\"\n    >\n      <el-form-item :label=\"$t('drama.projectName')\" prop=\"title\" required>\n        <el-input\n          v-model=\"form.title\"\n          :placeholder=\"$t('drama.projectNamePlaceholder')\"\n          size=\"large\"\n          maxlength=\"100\"\n          show-word-limit\n        />\n      </el-form-item>\n\n      <el-form-item :label=\"$t('drama.projectDesc')\" prop=\"description\">\n        <el-input\n          v-model=\"form.description\"\n          type=\"textarea\"\n          :rows=\"4\"\n          :placeholder=\"$t('drama.projectDescPlaceholder')\"\n          maxlength=\"500\"\n          show-word-limit\n          resize=\"none\"\n        />\n      </el-form-item>\n\n      <el-form-item :label=\"$t('drama.style')\" prop=\"style\" required>\n        <el-select\n          v-model=\"form.style\"\n          :placeholder=\"$t('drama.stylePlaceholder')\"\n          size=\"large\"\n          style=\"width: 100%\"\n        >\n          <el-option :label=\"$t('drama.styles.ghibli')\" value=\"ghibli\" />\n          <el-option :label=\"$t('drama.styles.guoman')\" value=\"guoman\" />\n          <el-option :label=\"$t('drama.styles.wasteland')\" value=\"wasteland\" />\n          <el-option :label=\"$t('drama.styles.nostalgia')\" value=\"nostalgia\" />\n          <el-option :label=\"$t('drama.styles.pixel')\" value=\"pixel\" />\n          <el-option :label=\"$t('drama.styles.voxel')\" value=\"voxel\" />\n          <el-option :label=\"$t('drama.styles.urban')\" value=\"urban\" />\n          <el-option :label=\"$t('drama.styles.guoman3d')\" value=\"guoman3d\" />\n          <el-option :label=\"$t('drama.styles.chibi3d')\" value=\"chibi3d\" />\n        </el-select>\n      </el-form-item>\n    </el-form>\n\n    <template #footer>\n      <div class=\"dialog-footer\">\n        <el-button size=\"large\" @click=\"handleClose\">\n          {{ $t(\"common.cancel\") }}\n        </el-button>\n        <el-button\n          type=\"primary\"\n          size=\"large\"\n          :loading=\"loading\"\n          @click=\"handleSubmit\"\n        >\n          <el-icon v-if=\"!loading\"><Plus /></el-icon>\n          {{ $t(\"drama.createNew\") }}\n        </el-button>\n      </div>\n    </template>\n  </el-dialog>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, reactive, watch } from \"vue\";\nimport { useRouter } from \"vue-router\";\nimport { ElMessage, type FormInstance, type FormRules } from \"element-plus\";\nimport { Plus } from \"@element-plus/icons-vue\";\nimport { dramaAPI } from \"@/api/drama\";\nimport type { CreateDramaRequest } from \"@/types/drama\";\n\n/**\n * CreateDramaDialog - Reusable dialog for creating new drama projects\n * 创建短剧弹窗 - 可复用的创建短剧项目弹窗\n */\nconst props = defineProps<{\n  modelValue: boolean;\n}>();\n\nconst emit = defineEmits<{\n  \"update:modelValue\": [value: boolean];\n  created: [id: string];\n}>();\n\nconst router = useRouter();\nconst formRef = ref<FormInstance>();\nconst loading = ref(false);\n\n// v-model binding / 双向绑定\nconst visible = ref(props.modelValue);\nwatch(\n  () => props.modelValue,\n  (val) => {\n    visible.value = val;\n  },\n);\nwatch(visible, (val) => {\n  emit(\"update:modelValue\", val);\n});\n\n// Form data / 表单数据\nconst form = reactive<CreateDramaRequest>({\n  title: \"\",\n  description: \"\",\n  style: \"ghibli\",\n});\n\n// Validation rules / 验证规则\nconst rules: FormRules = {\n  title: [\n    { required: true, message: \"请输入项目标题\", trigger: \"blur\" },\n    {\n      min: 1,\n      max: 100,\n      message: \"标题长度在 1 到 100 个字符\",\n      trigger: \"blur\",\n    },\n  ],\n  style: [{ required: true, message: \"请选择风格\", trigger: \"change\" }],\n};\n\n// Reset form when dialog closes / 关闭时重置表单\nconst handleClosed = () => {\n  form.title = \"\";\n  form.description = \"\";\n  formRef.value?.resetFields();\n};\n\n// Close dialog / 关闭弹窗\nconst handleClose = () => {\n  visible.value = false;\n};\n\n// Submit form / 提交表单\nconst handleSubmit = async () => {\n  if (!formRef.value) return;\n\n  await formRef.value.validate(async (valid) => {\n    if (valid) {\n      loading.value = true;\n      try {\n        const drama = await dramaAPI.create(form);\n        ElMessage.success(\"创建成功\");\n        visible.value = false;\n        emit(\"created\", drama.id);\n        // Navigate to drama detail page / 跳转到短剧详情页\n        router.push(`/dramas/${drama.id}`);\n      } catch (error: any) {\n        ElMessage.error(error.message || \"创建失败\");\n      } finally {\n        loading.value = false;\n      }\n    }\n  });\n};\n</script>\n\n<style scoped>\n/* ========================================\n   Dialog Styles / 弹窗样式\n   ======================================== */\n.create-dialog :deep(.el-dialog) {\n  border-radius: var(--radius-xl);\n}\n\n.create-dialog :deep(.el-dialog__header) {\n  padding: 1.25rem 1.5rem;\n  border-bottom: 1px solid var(--border-primary);\n  margin-right: 0;\n}\n\n.create-dialog :deep(.el-dialog__title) {\n  font-size: 1.125rem;\n  font-weight: 600;\n  color: var(--text-primary);\n}\n\n.create-dialog :deep(.el-dialog__body) {\n  padding: 1.5rem;\n}\n\n.dialog-desc {\n  margin-bottom: 1.5rem;\n  font-size: 0.875rem;\n  color: var(--text-secondary);\n}\n\n/* ========================================\n   Form Styles / 表单样式\n   ======================================== */\n.create-form :deep(.el-form-item) {\n  margin-bottom: 1.25rem;\n}\n\n.create-form :deep(.el-form-item__label) {\n  font-weight: 500;\n  color: var(--text-primary);\n  margin-bottom: 0.5rem;\n}\n\n.create-form :deep(.el-input__wrapper),\n.create-form :deep(.el-textarea__inner) {\n  background: var(--bg-secondary);\n  border-radius: var(--radius-md);\n  box-shadow: 0 0 0 1px var(--border-primary) inset;\n  transition: all var(--transition-fast);\n}\n\n.create-form :deep(.el-input__wrapper:hover),\n.create-form :deep(.el-textarea__inner:hover) {\n  box-shadow: 0 0 0 1px var(--border-secondary) inset;\n}\n\n.create-form :deep(.el-input__wrapper.is-focus),\n.create-form :deep(.el-textarea__inner:focus) {\n  box-shadow: 0 0 0 2px var(--accent) inset;\n}\n\n.create-form :deep(.el-input__inner),\n.create-form :deep(.el-textarea__inner) {\n  color: var(--text-primary);\n}\n\n.create-form :deep(.el-input__inner::placeholder),\n.create-form :deep(.el-textarea__inner::placeholder) {\n  color: var(--text-muted);\n}\n\n.create-form :deep(.el-input__count) {\n  color: var(--text-muted);\n  background: transparent;\n}\n\n/* ========================================\n   Footer Styles / 底部样式\n   ======================================== */\n.dialog-footer {\n  display: flex;\n  justify-content: flex-end;\n  gap: 0.75rem;\n}\n\n.dialog-footer .el-button {\n  min-width: 100px;\n}\n</style>\n"
  },
  {
    "path": "web/src/components/common/EmptyState.vue",
    "content": "<template>\n  <!-- Empty State Component - Display when no data available -->\n  <!-- 空状态组件 - 无数据时的展示 -->\n  <div :class=\"['empty-state', `size-${size}`]\">\n    <div class=\"empty-icon\" :class=\"{ 'has-animation': animated }\">\n      <el-icon :size=\"iconSize\">\n        <component :is=\"icon\" />\n      </el-icon>\n    </div>\n    <h3 class=\"empty-title\">{{ title }}</h3>\n    <p v-if=\"description\" class=\"empty-description\">{{ description }}</p>\n    <div v-if=\"$slots.default\" class=\"empty-actions\">\n      <slot></slot>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, type Component } from 'vue'\nimport { FolderOpened } from '@element-plus/icons-vue'\n\n/**\n * EmptyState - Display when no data is available\n * 空状态组件 - 无数据时的占位展示\n */\nconst props = withDefaults(defineProps<{\n  title: string\n  description?: string\n  icon?: Component\n  size?: 'small' | 'medium' | 'large'\n  animated?: boolean\n}>(), {\n  icon: FolderOpened,\n  size: 'medium',\n  animated: true\n})\n\n// Icon size based on component size / 根据组件尺寸设置图标大小\nconst iconSize = computed(() => {\n  const sizes = {\n    small: 32,\n    medium: 48,\n    large: 64\n  }\n  return sizes[props.size]\n})\n</script>\n\n<style scoped>\n.empty-state {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  text-align: center;\n  padding: var(--space-8) var(--space-4);\n}\n\n/* Size variants / 尺寸变体 */\n.size-small {\n  padding: var(--space-6) var(--space-4);\n}\n\n.size-small .empty-title {\n  font-size: 0.9375rem;\n}\n\n.size-small .empty-description {\n  font-size: 0.8125rem;\n}\n\n.size-large {\n  padding: var(--space-12) var(--space-6);\n}\n\n.size-large .empty-title {\n  font-size: 1.25rem;\n}\n\n/* Icon / 图标 */\n.empty-icon {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 5rem;\n  height: 5rem;\n  margin-bottom: var(--space-4);\n  background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);\n  border-radius: 50%;\n  color: white;\n}\n\n.empty-icon.has-animation {\n  animation: pulse-glow 3s ease-in-out infinite;\n}\n\n@keyframes pulse-glow {\n  0%, 100% {\n    box-shadow: 0 0 20px rgba(14, 165, 233, 0.3);\n  }\n  50% {\n    box-shadow: 0 0 40px rgba(14, 165, 233, 0.5);\n  }\n}\n\n/* Title / 标题 */\n.empty-title {\n  margin: 0 0 var(--space-2) 0;\n  font-size: 1.0625rem;\n  font-weight: 600;\n  color: var(--text-primary);\n  letter-spacing: -0.01em;\n}\n\n/* Description / 描述 */\n.empty-description {\n  margin: 0;\n  font-size: 0.875rem;\n  color: var(--text-muted);\n  max-width: 320px;\n  line-height: 1.5;\n}\n\n/* Actions / 操作区 */\n.empty-actions {\n  margin-top: var(--space-5);\n  display: flex;\n  gap: var(--space-3);\n}\n</style>\n"
  },
  {
    "path": "web/src/components/common/ImageCropDialog.vue",
    "content": "<template>\n  <el-dialog\n    v-model=\"dialogVisible\"\n    title=\"裁剪动作序列图\"\n    width=\"70vw\"\n    :close-on-click-modal=\"false\"\n    destroy-on-close\n    class=\"crop-dialog\"\n    align-center\n    style=\"height: 90vh\"\n  >\n    <div class=\"crop-container\">\n      <!-- 下方区域 -->\n      <div class=\"content-area\">\n        <!-- 左侧裁剪区域 -->\n        <div class=\"crop-area\">\n          <div class=\"crop-canvas-wrapper\">\n            <cropper-canvas ref=\"cropperCanvasRef\" background class=\"cropper-canvas-element\">\n              <cropper-image\n                v-if=\"imageUrl\"\n                ref=\"cropperImageRef\"\n                :src=\"imageUrl\"\n                alt=\"crop\"\n                rotatable\n                scalable\n                skewable\n                translatable\n              />\n              <cropper-shade hidden style=\"min-width: 300px; min-height: 300px\"></cropper-shade>\n              <cropper-handle action=\"move\" plain></cropper-handle>\n              <cropper-selection\n                :width=\"300\"\n                :height=\"300\"\n                style=\"min-width: 300px; min-height: 300px\"\n                movable\n                resizable\n                outlined\n                aspectRatio=\"1\"\n              >\n                <!-- <cropper-grid role=\"grid\" bordered covered></cropper-grid> -->\n                <!-- <cropper-crosshair centered></cropper-crosshair> -->\n                <cropper-handle action=\"move\" theme-color=\"rgba(255, 255, 255, 0.35)\"></cropper-handle>\n                <cropper-handle action=\"n-resize\"></cropper-handle>\n                <cropper-handle action=\"e-resize\"></cropper-handle>\n                <cropper-handle action=\"s-resize\"></cropper-handle>\n                <cropper-handle action=\"w-resize\"></cropper-handle>\n                <cropper-handle action=\"ne-resize\"></cropper-handle>\n                <cropper-handle action=\"nw-resize\"></cropper-handle>\n                <cropper-handle action=\"se-resize\"></cropper-handle>\n                <cropper-handle action=\"sw-resize\"></cropper-handle>\n              </cropper-selection>\n            </cropper-canvas>\n          </div>\n        </div>\n\n        <!-- 缩放控制区域 -->\n        <div class=\"zoom-control\">\n          <div class=\"slider-box\">\n            <el-button :icon=\"ZoomIn\" circle @click=\"handleZoomIn\" :disabled=\"zoomLevel >= 200\" />\n            <el-slider\n              v-model=\"zoomLevel\"\n              :min=\"50\"\n              :max=\"200\"\n              :step=\"1\"\n              vertical\n              height=\"300px\"\n              @change=\"handleZoomChange\"\n            />\n            <el-button :icon=\"ZoomOut\" circle @click=\"handleZoomOut\" :disabled=\"zoomLevel <= 50\" />\n            <div class=\"zoom-label\">{{ zoomLevel }}%</div>\n          </div>\n\n          <div class=\"crop-actions\">\n            <!-- 裁剪和重置按钮 -->\n            <el-button :icon=\"Crop\" circle type=\"primary\" @click=\"cropImage\" :disabled=\"!canCrop\" title=\"裁剪\" />\n            <el-button :icon=\"RefreshLeft\" circle @click=\"resetCrop\" title=\"重置\" style=\"margin: 0\" />\n          </div>\n        </div>\n\n        <!-- 右侧预览区域 -->\n        <div class=\"preview-area\">\n          <!-- 顶部文件夹区域 -->\n          <div class=\"folder-area\">\n            <div\n              v-for=\"folder in folders\"\n              :key=\"folder.type\"\n              class=\"folder-item\"\n              :class=\"{\n                'drag-over': dragOverFolder === folder.type,\n              }\"\n              @dragover.prevent=\"handleFolderDragOver(folder.type)\"\n              @dragleave=\"handleFolderDragLeave\"\n              @drop=\"handleFolderDrop($event, folder.type)\"\n            >\n              <el-icon :size=\"20\" class=\"folder-icon\">\n                <Folder />\n              </el-icon>\n              <span class=\"folder-name\">{{ folder.name }}</span>\n              <el-badge :value=\"folder.count\" :hidden=\"folder.count === 0\" />\n            </div>\n          </div>\n          <div class=\"preview-title\">已裁剪图片 ({{ croppedImages.length }})</div>\n          <div class=\"preview-grid\">\n            <div\n              v-for=\"(img, index) in croppedImages\"\n              :key=\"index\"\n              class=\"preview-item\"\n              draggable=\"true\"\n              @dragstart=\"handleImageDragStart($event, img, index)\"\n              @dragend=\"handleImageDragEnd\"\n            >\n              <img :src=\"img.url\" alt=\"cropped\" />\n              <div class=\"preview-overlay\">\n                <el-icon :size=\"16\" class=\"delete-icon\" @click=\"removeCroppedImage(index)\">\n                  <Close />\n                </el-icon>\n              </div>\n              <div v-if=\"img.frameType\" class=\"frame-type-badge\">\n                {{ getFrameTypeName(img.frameType) }}\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <template #footer>\n      <el-button @click=\"handleClose\">取消</el-button>\n      <el-button type=\"primary\" @click=\"handleSave\" :loading=\"saving\"> 保存 </el-button>\n    </template>\n  </el-dialog>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, watch, nextTick, onMounted } from 'vue'\nimport { Folder, Close, ZoomIn, ZoomOut, Crop, RefreshLeft } from '@element-plus/icons-vue'\nimport 'cropperjs'\n\ninterface CroppedImage {\n  url: string\n  blob: Blob\n  frameType?: string\n}\n\ninterface Props {\n  modelValue: boolean\n  imageUrl?: string\n}\n\ninterface Emits {\n  (e: 'update:modelValue', value: boolean): void\n  (e: 'save', images: { blob: Blob; frameType: string }[]): void\n}\n\nconst props = defineProps<Props>()\nconst emit = defineEmits<Emits>()\n\nconst dialogVisible = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n\nconst cropperCanvasRef = ref<any>()\nconst cropperImageRef = ref<any>()\nconst croppedImages = ref<CroppedImage[]>([])\nconst dragOverFolder = ref<string | null>(null)\nconst draggingImageIndex = ref<number | null>(null)\nconst saving = ref(false)\nconst zoomLevel = ref(100)\nconst previousZoomLevel = ref(100)\n\nconst folders = computed(() => [\n  {\n    type: 'first',\n    name: '首帧',\n    count: croppedImages.value.filter((img) => img.frameType === 'first').length,\n  },\n  {\n    type: 'last',\n    name: '尾帧',\n    count: croppedImages.value.filter((img) => img.frameType === 'last').length,\n  },\n  {\n    type: 'key',\n    name: '关键帧',\n    count: croppedImages.value.filter((img) => img.frameType === 'key').length,\n  },\n])\n\nconst canCrop = computed(() => {\n  return cropperCanvasRef.value !== undefined && cropperImageRef.value !== undefined\n})\nwatch(\n  () => props.modelValue,\n  (val) => {\n    if (val) {\n      nextTick(() => {\n        console.log('Dialog opened, canvas ref:', cropperCanvasRef.value)\n        console.log('Image ref:', cropperImageRef.value)\n      })\n    }\n  },\n)\n\nconst cropImage = async () => {\n  if (!cropperCanvasRef.value) {\n    console.error('Cropper canvas not found')\n    return\n  }\n\n  try {\n    // 使用 Web Components API 的 $toCanvas 方法获取裁剪后的画布\n    const selection = cropperCanvasRef.value.querySelector('cropper-selection')\n    if (!selection) {\n      console.error('Cropper selection not found')\n      return\n    }\n\n    // 获取选区的 canvas\n    const canvas = await selection.$toCanvas()\n    if (!canvas) {\n      console.error('Failed to get canvas')\n      return\n    }\n\n    // 转换为 Blob\n    canvas.toBlob((blob) => {\n      if (!blob) return\n\n      const url = URL.createObjectURL(blob)\n      croppedImages.value.push({\n        url,\n        blob,\n        frameType: undefined,\n      })\n      console.log('Image cropped successfully')\n    })\n  } catch (error) {\n    console.error('Error cropping image:', error)\n  }\n}\n\nconst resetCrop = () => {\n  if (cropperImageRef.value && cropperImageRef.value.$resetTransform) {\n    cropperImageRef.value.$resetTransform()\n    zoomLevel.value = 100\n    console.log('Crop reset')\n  }\n}\n\nconst handleZoomIn = () => {\n  if (zoomLevel.value < 200) {\n    zoomLevel.value = Math.min(200, zoomLevel.value + 2)\n    applyZoom()\n  }\n}\n\nconst handleZoomOut = () => {\n  if (zoomLevel.value > 50) {\n    zoomLevel.value = Math.max(50, zoomLevel.value - 2)\n    applyZoom()\n  }\n}\n\nconst handleZoomChange = (value: number) => {\n  zoomLevel.value = value\n  applyZoom()\n}\n\nconst applyZoom = () => {\n  if (cropperImageRef.value && cropperImageRef.value.$scale) {\n    // 计算相对于上一次的缩放比例\n    const ratio = zoomLevel.value / previousZoomLevel.value\n    cropperImageRef.value.$scale(ratio)\n    previousZoomLevel.value = zoomLevel.value\n  }\n}\n\nconst removeCroppedImage = (index: number) => {\n  const img = croppedImages.value[index]\n  URL.revokeObjectURL(img.url)\n  croppedImages.value.splice(index, 1)\n}\n\nconst handleImageDragStart = (event: DragEvent, img: CroppedImage, index: number) => {\n  draggingImageIndex.value = index\n  if (event.dataTransfer) {\n    event.dataTransfer.effectAllowed = 'move'\n  }\n}\n\nconst handleImageDragEnd = () => {\n  draggingImageIndex.value = null\n  dragOverFolder.value = null\n}\n\nconst handleFolderDragOver = (folderType: string) => {\n  dragOverFolder.value = folderType\n}\n\nconst handleFolderDragLeave = () => {\n  dragOverFolder.value = null\n}\n\nconst handleFolderDrop = (event: DragEvent, folderType: string) => {\n  event.preventDefault()\n  dragOverFolder.value = null\n\n  if (draggingImageIndex.value === null) return\n\n  const img = croppedImages.value[draggingImageIndex.value]\n  img.frameType = folderType\n  draggingImageIndex.value = null\n}\n\nconst getFrameTypeName = (type: string) => {\n  const map: Record<string, string> = {\n    first: '首帧',\n    last: '尾帧',\n    key: '关键帧',\n  }\n  return map[type] || type\n}\n\nconst handleClose = () => {\n  croppedImages.value.forEach((img) => {\n    URL.revokeObjectURL(img.url)\n  })\n  croppedImages.value = []\n  dialogVisible.value = false\n}\n\nconst handleSave = () => {\n  const imagesToSave = croppedImages.value\n    .filter((img) => img.frameType)\n    .map((img) => ({\n      blob: img.blob,\n      frameType: img.frameType!,\n    }))\n\n  if (imagesToSave.length === 0) {\n    return\n  }\n\n  emit('save', imagesToSave)\n  handleClose()\n}\n</script>\n\n<style>\n.crop-dialog .el-dialog__body {\n  height: calc(100% - 120px) !important;\n}\n</style>\n\n<style scoped>\n:deep(.crop-dialog.el-dialog) {\n  width: 1200px;\n  height: 90vh;\n  margin: 0;\n  display: flex;\n  flex-direction: column;\n}\n\n:deep(.crop-dialog .el-dialog__body) {\n  padding: 16px;\n  flex: 1;\n  overflow: hidden;\n  display: flex;\n  height: calc(100% - 120px) !important;\n}\n\n.crop-container {\n  display: flex;\n  flex-direction: column;\n  height: 100%;\n  gap: 20px;\n}\n\n.folder-area {\n  display: flex;\n  gap: 16px;\n  /* padding: 16px;\n  background: var(--bg-secondary);\n  border-radius: 8px;\n  border: 2px dashed var(--border-primary); */\n}\n\n.folder-item {\n  flex: 1;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 16px;\n  background: var(--bg-primary);\n  border: 2px solid var(--border-primary);\n  border-radius: 8px;\n  cursor: pointer;\n  transition: all 0.3s;\n}\n\n.folder-item:hover {\n  border-color: var(--accent);\n  background: var(--bg-card);\n}\n\n.folder-item.drag-over {\n  border-color: var(--accent);\n  background: var(--accent-light);\n  transform: scale(1.05);\n}\n\n.folder-icon {\n  color: var(--accent);\n}\n\n.folder-name {\n  font-weight: 500;\n  color: var(--text-primary);\n}\n\n.content-area {\n  flex: 1;\n  display: flex;\n  gap: 20px;\n  min-height: 0;\n  overflow: hidden;\n  justify-content: center;\n}\n\n.crop-area {\n  flex: 1;\n  max-width: 700px;\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  border: 1px solid var(--border-primary);\n  border-radius: 8px;\n  padding: 16px;\n  background: var(--bg-primary);\n  min-height: 0;\n}\n\n.zoom-control {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: space-between;\n  padding: 16px 8px;\n  background: var(--bg-primary);\n  border: 1px solid var(--border-primary);\n  border-radius: 8px;\n  height: fit-content;\n  align-self: center;\n  height: 100%;\n}\n\n.slider-box,\n.crop-actions {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  gap: var(--space-3);\n}\n\n.zoom-label {\n  font-size: 12px;\n  color: var(--text-secondary);\n  font-weight: 500;\n  margin-top: 4px;\n}\n\n.crop-title,\n.preview-title {\n  font-size: 14px;\n  font-weight: 600;\n  color: var(--text-primary);\n  margin-bottom: 8px;\n  flex-shrink: 0;\n}\n\n.crop-canvas-wrapper {\n  background: #000;\n  border-radius: 4px;\n  overflow: hidden;\n  width: 100%;\n  height: 500px;\n  position: relative;\n  flex: 1;\n  min-height: 400px;\n}\n\n.cropper-canvas-element {\n  width: 100%;\n  height: 100%;\n  display: block;\n}\n\n/* Cropper.js 基础样式 */\n.crop-canvas-wrapper :deep(.cropper-container) {\n  direction: ltr;\n  font-size: 0;\n  line-height: 0;\n  position: relative;\n  touch-action: none;\n  user-select: none;\n}\n\n.crop-canvas-wrapper :deep(.cropper-container img) {\n  display: block;\n  height: 100%;\n  image-orientation: 0deg;\n  max-height: none !important;\n  max-width: none !important;\n  min-height: 0 !important;\n  min-width: 0 !important;\n  width: 100%;\n}\n\n.crop-canvas-wrapper :deep(.cropper-wrap-box),\n.crop-canvas-wrapper :deep(.cropper-canvas),\n.crop-canvas-wrapper :deep(.cropper-drag-box),\n.crop-canvas-wrapper :deep(.cropper-crop-box),\n.crop-canvas-wrapper :deep(.cropper-modal) {\n  bottom: 0;\n  left: 0;\n  position: absolute;\n  right: 0;\n  top: 0;\n}\n\n.crop-canvas-wrapper :deep(.cropper-drag-box) {\n  background-color: #fff;\n  opacity: 0;\n}\n\n.crop-canvas-wrapper :deep(.cropper-modal) {\n  background-color: #000;\n  opacity: 0.5;\n}\n\n.crop-canvas-wrapper :deep(.cropper-view-box) {\n  display: block;\n  height: 100%;\n  outline: 1px solid #39f;\n  outline-color: rgba(51, 153, 255, 0.75);\n  overflow: hidden;\n  width: 100%;\n}\n\n.crop-canvas-wrapper :deep(.cropper-dashed) {\n  border: 0 dashed #eee;\n  display: block;\n  opacity: 0.5;\n  position: absolute;\n}\n\n.crop-canvas-wrapper :deep(.cropper-dashed.dashed-h) {\n  border-bottom-width: 1px;\n  border-top-width: 1px;\n  height: calc(100% / 3);\n  left: 0;\n  top: calc(100% / 3);\n  width: 100%;\n}\n\n.crop-canvas-wrapper :deep(.cropper-dashed.dashed-v) {\n  border-left-width: 1px;\n  border-right-width: 1px;\n  height: 100%;\n  left: calc(100% / 3);\n  top: 0;\n  width: calc(100% / 3);\n}\n\n.crop-canvas-wrapper :deep(.cropper-center) {\n  display: block;\n  height: 0;\n  left: 50%;\n  opacity: 0.75;\n  position: absolute;\n  top: 50%;\n  width: 0;\n}\n\n.crop-canvas-wrapper :deep(.cropper-center::before),\n.crop-canvas-wrapper :deep(.cropper-center::after) {\n  background-color: #eee;\n  content: ' ';\n  display: block;\n  position: absolute;\n}\n\n.crop-canvas-wrapper :deep(.cropper-center::before) {\n  height: 1px;\n  left: -3px;\n  top: 0;\n  width: 7px;\n}\n\n.crop-canvas-wrapper :deep(.cropper-center::after) {\n  height: 7px;\n  left: 0;\n  top: -3px;\n  width: 1px;\n}\n\n.crop-canvas-wrapper :deep(.cropper-face),\n.crop-canvas-wrapper :deep(.cropper-line),\n.crop-canvas-wrapper :deep(.cropper-point) {\n  display: block;\n  height: 100%;\n  opacity: 0.1;\n  position: absolute;\n  width: 100%;\n}\n\n.crop-canvas-wrapper :deep(.cropper-face) {\n  background-color: #fff;\n  left: 0;\n  top: 0;\n}\n\n.crop-canvas-wrapper :deep(.cropper-line) {\n  background-color: #39f;\n}\n\n.crop-canvas-wrapper :deep(.cropper-line.line-e) {\n  cursor: ew-resize;\n  right: -3px;\n  top: 0;\n  width: 5px;\n}\n\n.crop-canvas-wrapper :deep(.cropper-line.line-n) {\n  cursor: ns-resize;\n  height: 5px;\n  left: 0;\n  top: -3px;\n}\n\n.crop-canvas-wrapper :deep(.cropper-line.line-w) {\n  cursor: ew-resize;\n  left: -3px;\n  top: 0;\n  width: 5px;\n}\n\n.crop-canvas-wrapper :deep(.cropper-line.line-s) {\n  bottom: -3px;\n  cursor: ns-resize;\n  height: 5px;\n  left: 0;\n}\n\n.crop-canvas-wrapper :deep(.cropper-point) {\n  background-color: #39f;\n  height: 5px;\n  opacity: 0.75;\n  width: 5px;\n}\n\n.crop-canvas-wrapper :deep(.cropper-point.point-e) {\n  cursor: ew-resize;\n  margin-top: -3px;\n  right: -3px;\n  top: 50%;\n}\n\n.crop-canvas-wrapper :deep(.cropper-point.point-n) {\n  cursor: ns-resize;\n  left: 50%;\n  margin-left: -3px;\n  top: -3px;\n}\n\n.crop-canvas-wrapper :deep(.cropper-point.point-w) {\n  cursor: ew-resize;\n  left: -3px;\n  margin-top: -3px;\n  top: 50%;\n}\n\n.crop-canvas-wrapper :deep(.cropper-point.point-s) {\n  bottom: -3px;\n  cursor: s-resize;\n  left: 50%;\n  margin-left: -3px;\n}\n\n.crop-canvas-wrapper :deep(.cropper-point.point-ne) {\n  cursor: nesw-resize;\n  right: -3px;\n  top: -3px;\n}\n\n.crop-canvas-wrapper :deep(.cropper-point.point-nw) {\n  cursor: nwse-resize;\n  left: -3px;\n  top: -3px;\n}\n\n.crop-canvas-wrapper :deep(.cropper-point.point-sw) {\n  bottom: -3px;\n  cursor: nesw-resize;\n  left: -3px;\n}\n\n.crop-canvas-wrapper :deep(.cropper-point.point-se) {\n  bottom: -3px;\n  cursor: nwse-resize;\n  height: 20px;\n  opacity: 1;\n  right: -3px;\n  width: 20px;\n}\n\n.crop-controls {\n  display: flex;\n  gap: 8px;\n  justify-content: center;\n}\n\n.preview-area {\n  flex: 1;\n  min-width: 300px;\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  border: 1px solid var(--border-primary);\n  border-radius: 8px;\n  padding: 16px;\n  background: var(--bg-primary);\n  min-height: 0;\n  max-width: 420px;\n}\n\n.preview-grid {\n  flex: 1;\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));\n  gap: 12px;\n  overflow-y: auto;\n  align-content: start;\n}\n\n.preview-item {\n  position: relative;\n  aspect-ratio: 1;\n  border-radius: 8px;\n  overflow: hidden;\n  cursor: move;\n  border: 2px solid var(--border-primary);\n  transition: all 0.3s;\n}\n\n.preview-item:hover {\n  border-color: var(--accent);\n  transform: translateY(-2px);\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);\n}\n\n.preview-item img {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  display: block;\n}\n\n.preview-overlay {\n  position: absolute;\n  top: 0;\n  right: 0;\n  padding: 4px;\n  background: rgba(0, 0, 0, 0.6);\n  border-radius: 0 0 0 8px;\n  opacity: 0;\n  transition: opacity 0.3s;\n}\n\n.preview-item:hover .preview-overlay {\n  opacity: 1;\n}\n\n.delete-icon {\n  color: #fff;\n  cursor: pointer;\n}\n\n.delete-icon:hover {\n  color: #f56c6c;\n}\n\n.frame-type-badge {\n  position: absolute;\n  bottom: 4px;\n  left: 4px;\n  padding: 2px 8px;\n  background: var(--accent);\n  color: #fff;\n  font-size: 10px;\n  border-radius: 4px;\n  font-weight: 500;\n}\n</style>\n"
  },
  {
    "path": "web/src/components/common/ImagePreview.vue",
    "content": "<template>\n  <div class=\"image-preview-wrapper\">\n    <!-- 缩略图 -->\n    <div\n      class=\"thumbnail-container\"\n      @click=\"handlePreview\"\n      :class=\"{ 'has-image': hasImage }\"\n    >\n      <img v-if=\"hasImage\" :src=\"imageUrl\" :alt=\"alt\" class=\"thumbnail-image\" />\n      <div v-else class=\"no-image-placeholder\">\n        <el-icon :size=\"size / 2\"><Picture /></el-icon>\n        <span v-if=\"showPlaceholderText\">{{ placeholderText }}</span>\n      </div>\n    </div>\n\n    <!-- 放大预览对话框 -->\n    <el-dialog\n      v-model=\"previewVisible\"\n      :width=\"dialogWidth\"\n      align-center\n      :show-close=\"true\"\n      class=\"image-preview-dialog\"\n      @close=\"handleClose\"\n    >\n      <template #header>\n        <div class=\"preview-header\">\n          <span class=\"preview-title\">{{ alt || \"图片预览\" }}</span>\n        </div>\n      </template>\n\n      <div class=\"preview-content\">\n        <img :src=\"imageUrl\" :alt=\"alt\" class=\"preview-image\" />\n      </div>\n    </el-dialog>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed } from \"vue\";\nimport { Picture } from \"@element-plus/icons-vue\";\n\ninterface Props {\n  imageUrl?: string;\n  alt?: string;\n  size?: number;\n  placeholderText?: string;\n  showPlaceholderText?: boolean;\n  dialogWidth?: string;\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  imageUrl: \"\",\n  alt: \"\",\n  size: 120,\n  placeholderText: \"暂无图片\",\n  showPlaceholderText: true,\n  dialogWidth: \"800px\",\n});\n\nconst previewVisible = ref(false);\n\nconst hasImage = computed(() => {\n  return props.imageUrl && props.imageUrl.trim() !== \"\";\n});\n\nconst handlePreview = () => {\n  if (hasImage.value) {\n    previewVisible.value = true;\n  }\n};\n\nconst handleClose = () => {\n  previewVisible.value = false;\n};\n</script>\n\n<style scoped>\n.image-preview-wrapper {\n  display: inline-block;\n  width: 100%;\n  height: 100%;\n}\n\n.thumbnail-container {\n  width: 100%;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  overflow: hidden;\n  border-radius: var(--radius-md);\n  background: var(--bg-secondary);\n  transition: all var(--transition-fast);\n}\n\n.thumbnail-container.has-image {\n  cursor: pointer;\n}\n\n.thumbnail-container.has-image:hover {\n  transform: scale(1.05);\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n}\n\n.thumbnail-image {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  display: block;\n}\n\n.no-image-placeholder {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  color: var(--text-muted);\n  padding: 16px;\n  text-align: center;\n}\n\n.no-image-placeholder span {\n  font-size: 12px;\n}\n\n/* 预览对话框样式 */\n.image-preview-dialog :deep(.el-dialog) {\n  border-radius: var(--radius-xl);\n  background: var(--bg-primary);\n}\n\n.image-preview-dialog :deep(.el-dialog__header) {\n  padding: 16px 20px;\n  border-bottom: 1px solid var(--border-primary);\n  margin-right: 0;\n}\n\n.image-preview-dialog :deep(.el-dialog__body) {\n  padding: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: #000;\n}\n\n.preview-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n}\n\n.preview-title {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--text-primary);\n}\n\n.preview-content {\n  width: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  min-height: 400px;\n  max-height: 80vh;\n}\n\n.preview-image {\n  max-width: 100%;\n  max-height: 80vh;\n  object-fit: contain;\n  display: block;\n}\n</style>\n"
  },
  {
    "path": "web/src/components/common/PageHeader.vue",
    "content": "<template>\n  <!-- Page header component with title, subtitle and action buttons -->\n  <!-- 页面头部组件，包含标题、副标题和操作按钮 -->\n  <header :class=\"['page-header', { 'with-back': showBack, 'with-border': showBorder }]\">\n    <div class=\"header-content\">\n      <!-- Back button section / 返回按钮区域 -->\n      <div v-if=\"showBack\" class=\"header-nav\">\n        <button class=\"back-btn\" @click=\"handleBack\">\n          <el-icon><ArrowLeft /></el-icon>\n          <span>{{ backText }}</span>\n        </button>\n        <div class=\"nav-divider\"></div>\n      </div>\n\n      <!-- Title section / 标题区域 -->\n      <div class=\"header-text\">\n        <div class=\"title-row\">\n          <div v-if=\"$slots.icon\" class=\"title-icon\">\n            <slot name=\"icon\"></slot>\n          </div>\n          <h1 class=\"header-title\">{{ title }}\n\n            <p v-if=\"subtitle\" class=\"header-subtitle\">{{ subtitle }}</p>\n          </h1>\n          <slot name=\"badge\"></slot>\n        </div>\n      </div>\n\n      <!-- Actions section / 操作区域 -->\n      <div class=\"header-actions\">\n        <slot name=\"actions\"></slot>\n      </div>\n    </div>\n\n    <!-- Extra content / 额外内容 -->\n    <div v-if=\"$slots.extra\" class=\"header-extra\">\n      <slot name=\"extra\"></slot>\n    </div>\n  </header>\n</template>\n\n<script setup lang=\"ts\">\nimport { useRouter } from 'vue-router'\nimport { ArrowLeft } from '@element-plus/icons-vue'\n\n/**\n * PageHeader - Reusable page header component\n * 页面头部组件 - 可复用的页面头部\n */\nconst props = withDefaults(defineProps<{\n  title: string\n  subtitle?: string\n  showBack?: boolean\n  backText?: string\n  showBorder?: boolean\n}>(), {\n  showBack: false,\n  backText: '返回',\n  showBorder: true\n})\n\nconst emit = defineEmits<{\n  back: []\n}>()\n\nconst router = useRouter()\n\n// Handle back navigation / 处理返回导航\nconst handleBack = () => {\n  emit('back')\n  router.back()\n}\n</script>\n\n<style scoped>\n.page-header {\n  margin-bottom: var(--space-3);\n}\n\n.page-header.with-border {\n  padding-bottom: var(--space-3);\n  border-bottom: 1px solid var(--border-primary);\n}\n\n.header-content {\n  display: flex;\n  flex-direction: column;\n  gap: var(--space-2);\n}\n\n@media (min-width: 768px) {\n  .header-content {\n    flex-direction: row;\n    align-items: center;\n  }\n  \n  .page-header.with-back .header-content {\n    flex-wrap: nowrap;\n  }\n}\n\n/* Navigation / 导航 */\n.header-nav {\n  display: flex;\n  align-items: center;\n  gap: var(--space-4);\n  flex-shrink: 0;\n}\n\n.back-btn {\n  display: inline-flex;\n  align-items: center;\n  gap: 0.375rem;\n  padding: 0.5rem 0.875rem;\n  background: var(--bg-card);\n  border: 1px solid var(--border-primary);\n  border-radius: var(--radius-lg);\n  color: var(--text-secondary);\n  font-size: 0.875rem;\n  font-weight: 500;\n  cursor: pointer;\n  transition: all var(--transition-fast);\n  white-space: nowrap;\n}\n\n.back-btn:hover {\n  background: var(--bg-card-hover);\n  color: var(--text-primary);\n  border-color: var(--border-secondary);\n}\n\n.back-btn:focus-visible {\n  outline: 2px solid var(--accent);\n  outline-offset: 2px;\n}\n\n.nav-divider {\n  width: 1px;\n  height: 2rem;\n  background: var(--border-primary);\n}\n\n/* Title / 标题 */\n.header-text {\n  display: flex;\n  flex-direction: column;\n  gap: 0.25rem;\n  flex: 1;\n  min-width: 0;\n}\n\n.title-row {\n  display: flex;\n  align-items: center;\n  gap: var(--space-3);\n}\n\n.title-icon {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 2.5rem;\n  height: 2.5rem;\n  background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);\n  border-radius: var(--radius-lg);\n  color: white;\n  flex-shrink: 0;\n}\n\n.header-title {\n  margin: 0;\n  font-size: 1.5rem;\n  font-weight: 700;\n  color: var(--text-primary);\n  letter-spacing: -0.025em;\n  line-height: 1.2;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  display: flex;\n  align-items: baseline;\n  gap: 10px;\n}\n\n@media (min-width: 768px) {\n  .header-title {\n    font-size: 1.75rem;\n  }\n}\n\n.header-subtitle {\n  margin: 0;\n  font-size: 0.875rem;\n  color: var(--text-muted);\n  font-weight: 500;\n  max-width: 480px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n/* Actions / 操作 */\n.header-actions {\n  display: flex;\n  flex-wrap: wrap;\n  gap: var(--space-2);\n  align-items: center;\n  flex-shrink: 0;\n}\n\n@media (min-width: 768px) {\n  .header-actions {\n    margin-left: auto;\n  }\n}\n\n/* Extra / 额外内容 */\n.header-extra {\n  margin-top: var(--space-4);\n  padding-top: var(--space-4);\n  border-top: 1px solid var(--border-primary);\n}\n</style>\n"
  },
  {
    "path": "web/src/components/common/ProjectCard.vue",
    "content": "<template>\n  <!-- Project card component - Compact design with hover actions -->\n  <!-- 项目卡片组件 - 紧凑设计，悬停显示操作 -->\n  <article \n    class=\"project-card\"\n    @click=\"$emit('click')\"\n    tabindex=\"0\"\n    @keydown.enter=\"$emit('click')\"\n  >\n    <!-- Gradient header with icon / 渐变头部区域 -->\n    <div class=\"card-header\">\n      <el-icon class=\"header-icon\"><Film /></el-icon>\n      <!-- Hover actions / 悬停操作区 -->\n      <div class=\"hover-actions\" @click.stop>\n        <slot name=\"actions\"></slot>\n      </div>\n    </div>\n\n    <!-- Card content / 卡片内容 -->\n    <div class=\"card-body\">\n      <h3 class=\"card-title\">{{ title }}</h3>\n      <p v-if=\"description\" class=\"card-description\">{{ description }}</p>\n      \n      <!-- Footer section / 底部区域 -->\n      <div class=\"card-footer\">\n        <span class=\"meta-time\">{{ formattedDate }}</span>\n        <span class=\"episode-label\">共 {{ episodeCount }} 集</span>\n      </div>\n    </div>\n  </article>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { Film } from '@element-plus/icons-vue'\n\n/**\n * ProjectCard - Reusable project/drama card component\n * 项目卡片组件 - 可复用的项目展示卡片\n */\nconst props = withDefaults(defineProps<{\n  title: string\n  description?: string\n  updatedAt: string\n  episodeCount?: number\n}>(), {\n  description: '',\n  episodeCount: 0\n})\n\ndefineEmits<{\n  click: []\n}>()\n\n// Format date / 格式化日期\nconst formattedDate = computed(() => {\n  const date = new Date(props.updatedAt)\n  const year = date.getFullYear()\n  const month = String(date.getMonth() + 1).padStart(2, '0')\n  const day = String(date.getDate()).padStart(2, '0')\n  const hours = String(date.getHours()).padStart(2, '0')\n  const minutes = String(date.getMinutes()).padStart(2, '0')\n  const seconds = String(date.getSeconds()).padStart(2, '0')\n  return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`\n})\n</script>\n\n<style scoped>\n/* Card Container / 卡片容器 */\n.project-card {\n  position: relative;\n  display: flex;\n  flex-direction: column;\n  background: var(--bg-card);\n  border: 1px solid var(--border-primary);\n  border-radius: var(--radius-md);\n  overflow: hidden;\n  cursor: pointer;\n  transition: all var(--transition-normal);\n  width: 200px;\n}\n\n.project-card:hover {\n  border-color: var(--accent);\n}\n\n.project-card:focus-visible {\n  outline: 2px solid var(--accent);\n  outline-offset: 2px;\n}\n\n/* Card Header / 卡片头部 */\n.card-header {\n  position: relative;\n  height: 120px;\n  background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.header-icon {\n  font-size: 28px;\n  color: rgba(255, 255, 255, 0.8);\n}\n\n/* Hover Actions / 悬停操作区 */\n.hover-actions {\n  position: absolute;\n  top: 8px;\n  right: 8px;\n  display: flex;\n  gap: 4px;\n  opacity: 0;\n  transition: opacity var(--transition-fast);\n  z-index: 10;\n}\n\n.project-card:hover .hover-actions {\n  opacity: 1;\n}\n\n/* Body Section / 内容区域 */\n.card-body {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  padding: 12px;\n  gap: 10px;\n}\n\n.card-title {\n  margin: 0;\n  font-size: 1.2rem;\n  font-weight: 600;\n  color: var(--text-primary);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.card-description {\n  margin: 0;\n  font-size: 0.85rem;\n  color: var(--text-secondary);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  line-clamp: 2;\n  -webkit-box-orient: vertical;\n  line-height: 1.4;\n}\n\n/* Footer Section / 底部区域 */\n.card-footer {\n  margin-top: auto;\n  padding-top: 8px;\n  border-top: 1px solid var(--border-primary);\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n}\n\n.meta-time,\n.episode-label {\n  font-size: 0.75rem;\n  color: var(--text-muted);\n}\n\n:deep(.action-button) {\n  width: 28px !important;\n  height: 28px !important;\n  padding: 0 !important;\n  background: var(--bg-secondary) !important;\n  border: none !important;\n}\n</style>\n"
  },
  {
    "path": "web/src/components/common/StatCard.vue",
    "content": "<template>\n  <!-- Stat Card Component - Display statistics with modern design -->\n  <!-- 统计卡片组件 - 现代设计的统计数据展示 -->\n  <div :class=\"['stat-card', `variant-${variant}`]\">\n    <div class=\"stat-icon\" :style=\"{ background: iconBg }\">\n      <el-icon :size=\"24\" :color=\"iconColor\">\n        <component :is=\"icon\" />\n      </el-icon>\n    </div>\n    <div class=\"stat-content\">\n      <span class=\"stat-label\">{{ label }}</span>\n      <div class=\"stat-value-row\">\n        <span class=\"stat-value\" :style=\"{ color: valueColor }\">{{ formattedValue }}</span>\n        <span v-if=\"suffix\" class=\"stat-suffix\">{{ suffix }}</span>\n      </div>\n      <span v-if=\"description\" class=\"stat-description\">{{ description }}</span>\n    </div>\n    <div v-if=\"trend !== undefined\" :class=\"['stat-trend', trendDirection]\">\n      <el-icon :size=\"14\">\n        <component :is=\"trendIcon\" />\n      </el-icon>\n      <span>{{ Math.abs(trend) }}%</span>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, type Component } from 'vue'\nimport { TrendCharts, ArrowUp, ArrowDown } from '@element-plus/icons-vue'\n\n/**\n * StatCard - Display statistics with trend indicator\n * 统计卡片 - 带趋势指示器的统计数据展示\n */\nconst props = withDefaults(defineProps<{\n  label: string\n  value: number | string\n  icon: Component\n  iconColor?: string\n  iconBg?: string\n  valueColor?: string\n  suffix?: string\n  description?: string\n  trend?: number\n  variant?: 'default' | 'compact'\n}>(), {\n  iconColor: 'var(--accent)',\n  iconBg: 'var(--accent-light)',\n  valueColor: 'var(--accent)',\n  variant: 'default'\n})\n\n// Format large numbers / 格式化大数字\nconst formattedValue = computed(() => {\n  if (typeof props.value === 'string') return props.value\n  if (props.value >= 1000000) {\n    return (props.value / 1000000).toFixed(1) + 'M'\n  }\n  if (props.value >= 1000) {\n    return (props.value / 1000).toFixed(1) + 'K'\n  }\n  return props.value.toString()\n})\n\n// Trend direction / 趋势方向\nconst trendDirection = computed(() => {\n  if (props.trend === undefined) return ''\n  return props.trend >= 0 ? 'up' : 'down'\n})\n\n// Trend icon / 趋势图标\nconst trendIcon = computed(() => {\n  if (props.trend === undefined) return TrendCharts\n  return props.trend >= 0 ? ArrowUp : ArrowDown\n})\n</script>\n\n<style scoped>\n.stat-card {\n  display: flex;\n  align-items: flex-start;\n  gap: var(--space-3);\n  padding: var(--space-3);\n  background: var(--bg-card);\n  border: 1px solid var(--border-primary);\n  border-radius: var(--radius-lg);\n  transition: all var(--transition-normal);\n}\n\n.stat-card:hover {\n  border-color: var(--border-secondary);\n  box-shadow: var(--shadow-card-hover);\n}\n\n/* Compact variant / 紧凑变体 */\n.variant-compact {\n  padding: var(--space-2);\n  gap: var(--space-2);\n}\n\n.variant-compact .stat-icon {\n  width: 2.5rem;\n  height: 2.5rem;\n}\n\n.variant-compact .stat-value {\n  font-size: 1.5rem;\n}\n\n/* Icon / 图标 */\n.stat-icon {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 3rem;\n  height: 3rem;\n  border-radius: var(--radius-lg);\n  flex-shrink: 0;\n}\n\n/* Content / 内容 */\n.stat-content {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  gap: 0.125rem;\n}\n\n.stat-label {\n  font-size: 0.8125rem;\n  font-weight: 500;\n  color: var(--text-muted);\n}\n\n.stat-value-row {\n  display: flex;\n  align-items: baseline;\n  gap: var(--space-1);\n}\n\n.stat-value {\n  font-size: 1.75rem;\n  font-weight: 700;\n  letter-spacing: -0.02em;\n  line-height: 1.2;\n}\n\n.stat-suffix {\n  font-size: 0.875rem;\n  font-weight: 500;\n  color: var(--text-muted);\n}\n\n.stat-description {\n  font-size: 0.75rem;\n  color: var(--text-muted);\n  margin-top: var(--space-1);\n}\n\n/* Trend / 趋势 */\n.stat-trend {\n  display: flex;\n  align-items: center;\n  gap: 0.25rem;\n  padding: 0.25rem 0.5rem;\n  border-radius: var(--radius-md);\n  font-size: 0.75rem;\n  font-weight: 600;\n}\n\n.stat-trend.up {\n  background: var(--success-light);\n  color: var(--success);\n}\n\n.stat-trend.down {\n  background: var(--error-light);\n  color: var(--error);\n}\n</style>\n"
  },
  {
    "path": "web/src/components/common/ThemeToggle.vue",
    "content": "<template>\n  <!-- Theme toggle button for switching between light/dark mode -->\n  <!-- 主题切换按钮，用于切换浅色/深色模式 -->\n  <button\n    class=\"theme-toggle\"\n    :aria-label=\"isDark ? '切换到浅色模式' : '切换到深色模式'\"\n    @click=\"toggleTheme\"\n  >\n    <transition name=\"icon-fade\" mode=\"out-in\">\n      <el-icon v-if=\"isDark\" key=\"moon\" :size=\"18\">\n        <Moon />\n      </el-icon>\n      <el-icon v-else key=\"sun\" :size=\"18\">\n        <Sunny />\n      </el-icon>\n    </transition>\n  </button>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, onMounted } from 'vue'\nimport { Moon, Sunny } from '@element-plus/icons-vue'\n\n/**\n * ThemeToggle - Dark/Light mode toggle button\n * 主题切换按钮 - 深色/浅色模式切换\n */\nconst isDark = ref(false)\n\n// Initialize theme from localStorage or system preference\n// 从 localStorage 或系统偏好初始化主题\nonMounted(() => {\n  const savedTheme = localStorage.getItem('theme')\n  if (savedTheme) {\n    isDark.value = savedTheme === 'dark'\n  } else {\n    isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches\n  }\n  applyTheme()\n})\n\n// Toggle between dark and light mode / 切换深色和浅色模式\nconst toggleTheme = () => {\n  isDark.value = !isDark.value\n  applyTheme()\n  localStorage.setItem('theme', isDark.value ? 'dark' : 'light')\n}\n\n// Apply theme to document / 应用主题到文档\nconst applyTheme = () => {\n  if (isDark.value) {\n    document.documentElement.classList.add('dark')\n  } else {\n    document.documentElement.classList.remove('dark')\n  }\n}\n</script>\n\n<style scoped>\n.theme-toggle {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 2.25rem;\n  height: 2.25rem;\n  border: 1px solid var(--border-primary);\n  border-radius: var(--radius-md);\n  background: var(--bg-card);\n  color: var(--text-secondary);\n  cursor: pointer;\n  transition: all var(--transition-fast);\n}\n\n.theme-toggle:hover {\n  background: var(--bg-card-hover);\n  color: var(--text-primary);\n  border-color: var(--border-secondary);\n}\n\n.theme-toggle:focus-visible {\n  outline: 2px solid var(--accent);\n  outline-offset: 2px;\n}\n\n/* Icon transition / 图标过渡动画 */\n.icon-fade-enter-active,\n.icon-fade-leave-active {\n  transition: all 0.2s ease;\n}\n\n.icon-fade-enter-from {\n  opacity: 0;\n  transform: rotate(-90deg) scale(0.8);\n}\n\n.icon-fade-leave-to {\n  opacity: 0;\n  transform: rotate(90deg) scale(0.8);\n}\n</style>\n"
  },
  {
    "path": "web/src/components/common/index.ts",
    "content": "/**\n * Common UI Components barrel export\n * 通用 UI 组件统一导出\n */\n\n// Layout Components / 布局组件\nexport { default as PageHeader } from './PageHeader.vue'\nexport { default as BaseCard } from './BaseCard.vue'\nexport { default as StatCard } from './StatCard.vue'\nexport { default as EmptyState } from './EmptyState.vue'\n\n// Interactive Components / 交互组件\nexport { default as ProjectCard } from './ProjectCard.vue'\nexport { default as ThemeToggle } from './ThemeToggle.vue'\nexport { default as ActionButton } from './ActionButton.vue'\nexport { default as ImagePreview } from \"./ImagePreview.vue\";\nexport { default as ImageCropDialog } from \"./ImageCropDialog.vue\";\n\n// Dialog Components / 弹窗组件\nexport { default as CreateDramaDialog } from './CreateDramaDialog.vue'\nexport { default as AIConfigDialog } from './AIConfigDialog.vue'\n\n// Layout Components / 布局组件\nexport { default as AppLayout } from './AppLayout.vue'\nexport { default as AppHeader } from './AppHeader.vue'"
  },
  {
    "path": "web/src/components/editor/GridImageEditor.vue",
    "content": "<template>\n  <el-dialog v-model=\"visible\" :title=\"$t('editor.gridImageEditor')\" width=\"900px\" :close-on-click-modal=\"false\"\n    @close=\"handleClose\" align-center>\n    <!-- 宫格类型选择 -->\n    <div class=\"grid-type-selector\">\n      <div class=\"section-label\">{{ $t(\"editor.gridType\") }}</div>\n      <el-radio-group v-model=\"gridType\" @change=\"initGridImages\">\n        <el-radio-button :label=\"4\">{{\n          $t(\"editor.fourGrid\")\n          }}</el-radio-button>\n        <el-radio-button :label=\"6\">{{ $t(\"editor.sixGrid\") }}</el-radio-button>\n        <el-radio-button :label=\"9\">{{\n          $t(\"editor.nineGrid\")\n          }}</el-radio-button>\n      </el-radio-group>\n    </div>\n\n    <!-- 宫格图片编辑区域 -->\n    <div class=\"grid-editor\">\n      <div class=\"section-label\">{{ $t(\"editor.editGridImage\") }}</div>\n      <div class=\"grid-container\" :class=\"`grid-${gridType}`\">\n        <div v-for=\"(item, index) in gridImages\" :key=\"index\" class=\"grid-cell\" @click=\"handleGridCellClick(index)\">\n          <img v-if=\"item.url\" :src=\"item.url\" alt=\"\" />\n          <div v-else class=\"grid-cell-placeholder\">\n            <el-icon :size=\"32\">\n              <Plus />\n            </el-icon>\n          </div>\n          <div v-if=\"item.url\" class=\"grid-cell-actions\">\n            <el-icon @click.stop=\"previewGridCell(index)\">\n              <ZoomIn />\n            </el-icon>\n            <el-icon @click.stop=\"removeGridCell(index)\">\n              <Delete />\n            </el-icon>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <template #footer>\n      <div class=\"dialog-footer\">\n        <el-button @click=\"clearGrid\">{{ $t(\"editor.clear\") }}</el-button>\n        <el-button @click=\"handleClose\">{{ $t(\"common.cancel\") }}</el-button>\n        <el-button type=\"primary\" :loading=\"creating\" :disabled=\"!isGridComplete\" @click=\"createGridImage\">\n          {{ creating ? $t(\"editor.creating\") : $t(\"editor.createGridImage\") }}\n        </el-button>\n      </div>\n    </template>\n\n    <!-- 图片选择对话框 -->\n    <el-dialog v-model=\"showImageSelector\" :title=\"$t('editor.selectImage')\" width=\"900px\" :close-on-click-modal=\"false\"\n      append-to-body>\n      <el-tabs type=\"border-card\">\n        <el-tab-pane :label=\"$t('editor.existingImages')\">\n          <div class=\"image-selector-grid\">\n            <div v-for=\"img in allImages\" :key=\"img.id\" class=\"image-selector-item\" @click=\"selectImageForGrid(img)\">\n              <el-image :src=\"getImageUrl(img)\" fit=\"cover\" style=\"width: 100%; height: 150px\" />\n              <div class=\"image-selector-label\">\n                {{ getFrameTypeText(img.frame_type) }}\n              </div>\n            </div>\n          </div>\n          <el-empty v-if=\"allImages.length === 0\" :description=\"$t('editor.noImagesAvailable')\" />\n        </el-tab-pane>\n        <el-tab-pane :label=\"$t('editor.uploadNewImage')\">\n          <el-upload drag :auto-upload=\"false\" :show-file-list=\"false\" accept=\"image/*\"\n            :on-change=\"handleUploadForGrid\">\n            <el-icon :size=\"67\">\n              <Upload />\n            </el-icon>\n            <div class=\"el-upload__text\">\n              {{ $t(\"common.upload\") }}<em>{{ $t(\"common.upload\") }}</em>\n            </div>\n            <template #tip>\n              <div class=\"el-upload__tip\">\n                {{ $t(\"editor.uploadNewImage\") }}\n              </div>\n            </template>\n          </el-upload>\n        </el-tab-pane>\n      </el-tabs>\n      <template #footer>\n        <el-button @click=\"showImageSelector = false\">{{\n          $t(\"common.cancel\")\n          }}</el-button>\n      </template>\n    </el-dialog>\n\n    <!-- 宫格图片预览对话框 -->\n    <el-dialog v-model=\"showGridImagePreview\" :title=\"$t('editor.preview')\" width=\"800px\" append-to-body align-center>\n      <div v-if=\"previewGridImage\" class=\"grid-preview-container\">\n        <img :src=\"previewGridImage.url\" style=\"width: 100%; display: block\" />\n        <div style=\"margin-top: 16px; text-align: center\">\n          <el-button type=\"primary\" @click=\"replaceGridImage\">{{\n            $t(\"editor.replaceImage\")\n            }}</el-button>\n          <el-button @click=\"showGridImagePreview = false\">{{\n            $t(\"common.close\")\n            }}</el-button>\n        </div>\n      </div>\n    </el-dialog>\n  </el-dialog>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, watch } from \"vue\";\nimport { useI18n } from \"vue-i18n\";\nimport { ElMessage } from \"element-plus\";\nimport { Plus, ZoomIn, Delete, Upload } from \"@element-plus/icons-vue\";\nimport { getImageUrl } from \"@/utils/image\";\nimport { imageAPI } from \"@/api/image\";\n\nconst { t: $t } = useI18n();\n\ninterface GridImage {\n  id?: number;\n  url?: string;\n  file?: File;\n  source?: string;\n}\n\ninterface ImageGeneration {\n  id: number;\n  frame_type: string;\n  image_url?: string;\n  local_path?: string;\n  [key: string]: any;\n}\n\nconst props = defineProps<{\n  modelValue: boolean;\n  storyboardId: number;\n  dramaId: number;\n  allImages: ImageGeneration[];\n}>();\n\nconst emit = defineEmits<{\n  (e: \"update:modelValue\", value: boolean): void;\n  (e: \"success\"): void;\n}>();\n\nconst visible = computed({\n  get: () => props.modelValue,\n  set: (val) => emit(\"update:modelValue\", val),\n});\n\nconst gridType = ref<4 | 6 | 9>(4);\nconst gridImages = ref<GridImage[]>([]);\nconst showImageSelector = ref(false);\nconst currentGridIndex = ref<number>(-1);\nconst showGridImagePreview = ref(false);\nconst previewGridImage = ref<{ url: string; index: number } | null>(null);\nconst creating = ref(false);\n\n// 初始化宫格图片数组\nconst initGridImages = () => {\n  gridImages.value = Array.from({ length: gridType.value }, () => ({}));\n};\n\n// 检查宫格是否完整\nconst isGridComplete = computed(() => {\n  return gridImages.value.every((item) => item.url);\n});\n\n// 处理宫格单元格点击\nconst handleGridCellClick = (index: number) => {\n  currentGridIndex.value = index;\n  showImageSelector.value = true;\n};\n\n// 选择已有图片用于宫格\nconst selectImageForGrid = (img: ImageGeneration) => {\n  if (\n    currentGridIndex.value >= 0 &&\n    currentGridIndex.value < gridImages.value.length\n  ) {\n    gridImages.value[currentGridIndex.value] = {\n      id: img.id,\n      url: getImageUrl(img),\n      source: \"existing\",\n    };\n    showImageSelector.value = false;\n  }\n};\n\n// 处理上传图片用于宫格\nconst handleUploadForGrid = (file: any) => {\n  const reader = new FileReader();\n  reader.onload = (e) => {\n    if (\n      currentGridIndex.value >= 0 &&\n      currentGridIndex.value < gridImages.value.length\n    ) {\n      gridImages.value[currentGridIndex.value] = {\n        url: e.target?.result as string,\n        file: file.raw,\n        source: \"upload\",\n      };\n      showImageSelector.value = false;\n    }\n  };\n  reader.readAsDataURL(file.raw);\n};\n\n// 预览宫格单元格\nconst previewGridCell = (index: number) => {\n  const item = gridImages.value[index];\n  if (item.url) {\n    previewGridImage.value = { url: item.url, index };\n    showGridImagePreview.value = true;\n  }\n};\n\n// 替换宫格图片\nconst replaceGridImage = () => {\n  if (previewGridImage.value) {\n    currentGridIndex.value = previewGridImage.value.index;\n    showGridImagePreview.value = false;\n    showImageSelector.value = true;\n  }\n};\n\n// 移除宫格单元格图片\nconst removeGridCell = (index: number) => {\n  gridImages.value[index] = {};\n};\n\n// 清空宫格\nconst clearGrid = () => {\n  initGridImages();\n};\n\n// 获取帧类型文本\nconst getFrameTypeText = (frameType: string) => {\n  const typeMap: Record<string, string> = {\n    first: $t(\"editor.firstFrame\"),\n    last: $t(\"editor.lastFrame\"),\n    panel: $t(\"editor.panelFrame\"),\n    action: $t(\"editor.actionSequence\"),\n    key: $t(\"editor.keyFrame\"),\n  };\n  return typeMap[frameType] || frameType;\n};\n\n// 创建宫格图片\nconst createGridImage = async () => {\n  if (!isGridComplete.value) {\n    ElMessage.warning($t(\"editor.allCellsRequired\"));\n    return;\n  }\n\n  creating.value = true;\n  try {\n    // 创建 canvas 来合成宫格图片\n    const canvas = document.createElement(\"canvas\");\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) throw new Error(\"无法创建 canvas 上下文\");\n\n    // 根据宫格类型设置画布尺寸和布局\n    const cellSize = 512; // 每个图片的尺寸\n    const gap = 6; // 图片之间的间隙\n    let cols = 2,\n      rows = 2;\n    if (gridType.value === 6) {\n      cols = 3;\n      rows = 2;\n    } else if (gridType.value === 9) {\n      cols = 3;\n      rows = 3;\n    }\n\n    // 计算画布总尺寸：图片尺寸 * 数量 + 间隙 * (数量 - 1)\n    canvas.width = cellSize * cols + gap * (cols - 1);\n    canvas.height = cellSize * rows + gap * (rows - 1);\n\n    // 填充背景色（间隙颜色）\n    ctx.fillStyle = \"#ffffff\";\n    ctx.fillRect(0, 0, canvas.width, canvas.height);\n\n    // 加载所有图片并绘制到 canvas\n    const loadImage = (url: string): Promise<HTMLImageElement> => {\n      return new Promise((resolve, reject) => {\n        const img = new Image();\n        img.crossOrigin = \"anonymous\";\n        img.onload = () => resolve(img);\n        img.onerror = reject;\n        img.src = url;\n      });\n    };\n\n    for (let i = 0; i < gridImages.value.length; i++) {\n      const item = gridImages.value[i];\n      if (!item.url) continue;\n\n      const img = await loadImage(item.url);\n      const col = i % cols;\n      const row = Math.floor(i / cols);\n      // 计算绘制位置：考虑间隙\n      const x = col * (cellSize + gap);\n      const y = row * (cellSize + gap);\n      ctx.drawImage(img, x, y, cellSize, cellSize);\n    }\n\n    // 将 canvas 转换为 blob\n    const blob = await new Promise<Blob>((resolve) => {\n      canvas.toBlob((b) => resolve(b!), \"image/jpeg\", 0.9);\n    });\n\n    // 上传合成的图片\n    const formData = new FormData();\n    formData.append(\"file\", blob, \"grid-image.jpg\");\n\n    const response = await fetch(\"/api/v1/upload/image\", {\n      method: \"POST\",\n      body: formData,\n    });\n\n    if (!response.ok) {\n      throw new Error(\"上传失败\");\n    }\n\n    const result = await response.json();\n    const imageUrl = result.data?.url;\n\n    if (imageUrl) {\n      // 创建图片生成记录（关联到动作序列帧类型）\n      await imageAPI.uploadImage({\n        storyboard_id: props.storyboardId,\n        drama_id: props.dramaId,\n        frame_type: \"action\",\n        image_url: imageUrl,\n        prompt: `${$t(\"editor.createGridImage\")} - ${gridType.value}${$t(\"editor.gridType\")}`,\n      });\n\n      ElMessage.success($t(\"editor.createSuccess\"));\n      emit(\"success\");\n      handleClose();\n    }\n  } catch (error: any) {\n    console.error(\"制作宫格图片失败:\", error);\n    ElMessage.error(error.message || $t(\"editor.createFailed\"));\n  } finally {\n    creating.value = false;\n  }\n};\n\n// 关闭对话框\nconst handleClose = () => {\n  clearGrid();\n  visible.value = false;\n};\n\n// 监听对话框打开，初始化宫格\nwatch(visible, (newVal) => {\n  if (newVal) {\n    initGridImages();\n  }\n});\n</script>\n\n<style scoped>\n.section-label {\n  font-size: 14px;\n  font-weight: 500;\n  color: var(--text-primary);\n  margin-bottom: 12px;\n}\n\n.grid-type-selector {\n  margin-bottom: 20px;\n}\n\n.grid-editor {\n  margin-bottom: 20px;\n}\n\n.grid-container {\n  display: grid;\n  gap: 12px;\n  padding: 12px;\n  background: var(--bg-secondary);\n  border-radius: 8px;\n  border: 1px solid var(--border-primary);\n  height: 550px;\n  max-height: 550px;\n  place-content: center;\n  place-items: center;\n}\n\n.grid-container.grid-4 {\n  grid-template-columns: repeat(2, 200px);\n  grid-template-rows: repeat(2, 200px);\n}\n\n.grid-container.grid-6 {\n  grid-template-columns: repeat(3, 200px);\n  grid-template-rows: repeat(2, 200px);\n}\n\n.grid-container.grid-9 {\n  grid-template-columns: repeat(3, 160px);\n  grid-template-rows: repeat(3, 160px);\n}\n\n.grid-cell {\n  position: relative;\n  border: 2px dashed var(--border-primary);\n  border-radius: 8px;\n  overflow: hidden;\n  cursor: pointer;\n  transition: all 0.3s ease;\n  background: var(--bg-card);\n  min-height: 0;\n  width: 100%;\n  height: 100%;\n}\n\n.grid-cell:hover {\n  border-color: var(--accent);\n  box-shadow: 0 2px 8px rgba(14, 165, 233, 0.2);\n}\n\n.grid-cell img {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n}\n\n.grid-cell-placeholder {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  height: 100%;\n  color: var(--text-secondary);\n}\n\n.grid-cell-actions {\n  position: absolute;\n  top: 8px;\n  right: 8px;\n  display: flex;\n  gap: 4px;\n  opacity: 0;\n  transition: opacity 0.3s ease;\n}\n\n.grid-cell:hover .grid-cell-actions {\n  opacity: 1;\n}\n\n.grid-cell-actions .el-icon {\n  width: 28px;\n  height: 28px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: rgba(0, 0, 0, 0.6);\n  border-radius: 4px;\n  color: white;\n  cursor: pointer;\n  transition: all 0.3s ease;\n}\n\n.grid-cell-actions .el-icon:hover {\n  background: rgba(0, 0, 0, 0.8);\n  transform: scale(1.1);\n}\n\n.dialog-footer {\n  display: flex;\n  justify-content: flex-end;\n  gap: 12px;\n}\n\n/* 图片选择器样式 */\n.image-selector-grid {\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));\n  gap: 12px;\n  max-height: 500px;\n  overflow-y: auto;\n  padding: 12px;\n}\n\n.image-selector-item {\n  position: relative;\n  cursor: pointer;\n  border-radius: 8px;\n  overflow: hidden;\n  transition: all 0.3s ease;\n  border: 2px solid transparent;\n}\n\n.image-selector-item:hover {\n  border-color: var(--accent);\n  box-shadow: 0 2px 8px rgba(14, 165, 233, 0.3);\n  transform: translateY(-2px);\n}\n\n.image-selector-label {\n  position: absolute;\n  bottom: 0;\n  left: 0;\n  right: 0;\n  padding: 4px 8px;\n  background: rgba(0, 0, 0, 0.7);\n  color: white;\n  font-size: 12px;\n  text-align: center;\n}\n\n.grid-preview-container {\n  text-align: center;\n}\n\n.grid-preview-container img {\n  max-width: 100%;\n  border-radius: 8px;\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);\n}\n</style>\n"
  },
  {
    "path": "web/src/components/editor/StoryboardEditor.vue",
    "content": "<template>\n  <div class=\"storyboard-editor\">\n    <!-- 左侧分镜列表 -->\n    <div class=\"left-panel\">\n      <div class=\"panel-header\">\n        <div class=\"header-left\">\n          <el-button :icon=\"ArrowLeft\" size=\"small\" text @click=\"handleBack\">返回</el-button>\n          <h3>分镜列表</h3>\n        </div>\n        <el-tag type=\"success\" size=\"small\">{{ storyboards.length }} 个镜头</el-tag>\n      </div>\n      <div class=\"scene-list\">\n        <div\n          v-for=\"(shot, index) in storyboards\"\n          :key=\"shot.id || index\"\n          class=\"scene-item\"\n          :class=\"{ active: currentShotIndex === index }\"\n          @click=\"selectShot(index)\"\n        >\n          <div class=\"scene-number\">{{ shot.storyboard_number }}</div>\n          <div class=\"scene-content\">\n            <div class=\"scene-title\">\n              <el-tag size=\"small\" type=\"info\">{{ shot.shot_type }}</el-tag>\n              <span class=\"time-location\">{{ shot.time }} · {{ shot.location }}</span>\n            </div>\n            <div class=\"scene-desc\">{{ shot.action }}</div>\n          </div>\n          <div class=\"scene-thumb\" v-if=\"shot.background_url\">\n            <el-image :src=\"shot.background_url\" fit=\"cover\" />\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- 中间预览区 -->\n    <div class=\"center-panel\">\n      <div class=\"preview-header\">\n        <div class=\"header-info\">\n          <el-tag type=\"info\">镜头 {{ currentShot?.storyboard_number || '-' }}</el-tag>\n          <span class=\"shot-type\">{{ currentShot?.shot_type }}</span>\n        </div>\n        <div class=\"header-actions\">\n          <el-button-group>\n            <el-button :icon=\"VideoPlay\" size=\"small\" v-if=\"currentShot?.video_url\">预览</el-button>\n            <el-button :icon=\"Refresh\" size=\"small\" @click=\"handleRegenerateShot\">重新生成</el-button>\n            <el-button :icon=\"Download\" size=\"small\">导出</el-button>\n          </el-button-group>\n        </div>\n      </div>\n      \n      <div class=\"preview-area\">\n        <div class=\"preview-container\">\n          <!-- 视频预览优先 -->\n          <div v-if=\"currentShot?.video_url\" class=\"preview-video\">\n            <video :src=\"currentShot.video_url\" controls style=\"max-width: 100%; max-height: 100%;\" />\n          </div>\n          <!-- 背景图预览 -->\n          <div v-else-if=\"currentShot?.background_url\" class=\"preview-image\">\n            <el-image :src=\"currentShot.background_url\" fit=\"contain\" />\n          </div>\n          <!-- 占位符 -->\n          <div v-else class=\"preview-placeholder\">\n            <el-icon :size=\"60\"><Picture /></el-icon>\n            <p>暂无预览</p>\n            <p class=\"hint\">请先生成背景图或视频</p>\n          </div>\n        </div>\n      </div>\n\n      <!-- 底部时间线 -->\n      <div class=\"timeline-panel\">\n        <div class=\"timeline-header\">\n          <div class=\"timeline-tools\">\n            <el-button-group size=\"small\">\n              <el-button :icon=\"VideoPlay\">播放</el-button>\n              <el-button :icon=\"VideoPause\">暂停</el-button>\n            </el-button-group>\n            <span class=\"timecode\">00:00:00 / 00:{{ formatDuration(storyboards.length * 3) }}</span>\n          </div>\n          <div class=\"timeline-zoom\">\n            <el-slider v-model=\"timelineZoom\" :min=\"1\" :max=\"10\" :show-tooltip=\"false\" style=\"width: 100px;\" />\n          </div>\n        </div>\n        <div class=\"timeline-track\">\n          <div class=\"timeline-ruler\">\n            <div \n              v-for=\"i in Math.ceil(storyboards.length / 5)\" \n              :key=\"i\" \n              class=\"ruler-mark\"\n              :style=\"{ left: `${(i - 1) * 300 + 30}px` }\"\n            >\n              <span>{{ i * 5 }}</span>\n            </div>\n          </div>\n          <div class=\"timeline-clips\">\n            <div\n              v-for=\"(shot, index) in storyboards\"\n              :key=\"shot.id || index\"\n              class=\"timeline-clip\"\n              :class=\"{ active: currentShotIndex === index }\"\n              @click=\"selectShot(index)\"\n            >\n              <div class=\"clip-content\">\n                <span class=\"clip-number\">{{ shot.storyboard_number }}</span>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- 右侧参数面板 -->\n    <div class=\"right-panel\">\n      <el-tabs v-model=\"activeTab\" class=\"panel-tabs\">\n        <el-tab-pane label=\"基础信息\" name=\"info\">\n          <div class=\"param-section\" v-if=\"currentShot\">\n            <div class=\"param-group\">\n              <label>镜号</label>\n              <el-input :model-value=\"currentShot.storyboard_number\" disabled size=\"small\" />\n            </div>\n            <div class=\"param-group\">\n              <label>景别</label>\n              <el-select v-model=\"currentShot.shot_type\" size=\"small\" @change=\"handleShotUpdateImmediate\">\n                <el-option label=\"特写\" value=\"特写\" />\n                <el-option label=\"近景\" value=\"近景\" />\n                <el-option label=\"中景\" value=\"中景\" />\n                <el-option label=\"全景\" value=\"全景\" />\n                <el-option label=\"远景\" value=\"远景\" />\n              </el-select>\n            </div>\n            <div class=\"param-group\">\n              <label>镜头角度</label>\n              <el-select v-model=\"currentShot.angle\" size=\"small\" @change=\"handleShotUpdateImmediate\">\n                <el-option label=\"平视\" value=\"平视\" />\n                <el-option label=\"仰视\" value=\"仰视\" />\n                <el-option label=\"俯视\" value=\"俯视\" />\n                <el-option label=\"侧面\" value=\"侧面\" />\n                <el-option label=\"背面\" value=\"背面\" />\n              </el-select>\n            </div>\n            <div class=\"param-group\">\n              <label>运镜方式</label>\n              <el-select v-model=\"currentShot.movement\" size=\"small\" @change=\"handleShotUpdateImmediate\">\n                <el-option label=\"固定镜头\" value=\"固定镜头\" />\n                <el-option label=\"推镜\" value=\"推镜\" />\n                <el-option label=\"拉镜\" value=\"拉镜\" />\n                <el-option label=\"摇镜\" value=\"摇镜\" />\n                <el-option label=\"跟镜\" value=\"跟镜\" />\n                <el-option label=\"移镜\" value=\"移镜\" />\n              </el-select>\n            </div>\n            <div class=\"param-row\">\n              <div class=\"param-group\">\n                <label>时间</label>\n                <el-input v-model=\"currentShot.time\" size=\"small\" @blur=\"handleShotUpdateImmediate\" />\n              </div>\n              <div class=\"param-group\">\n                <label>地点</label>\n                <el-input v-model=\"currentShot.location\" size=\"small\" @blur=\"handleShotUpdateImmediate\" />\n              </div>\n            </div>\n            \n            <el-divider />\n            \n            <div class=\"param-group\">\n              <label>对角/旁白</label>\n              <el-input \n                v-model=\"currentShot.dialogue\" \n                type=\"textarea\" \n                :rows=\"2\"\n                size=\"small\"\n                placeholder=\"角色对话或旁白\"\n                @blur=\"handleShotUpdateImmediate\"\n              />\n            </div>\n            <div class=\"param-group\">\n              <label>动作</label>\n              <el-input \n                v-model=\"currentShot.action\" \n                type=\"textarea\" \n                :rows=\"2\"\n                size=\"small\"\n                @blur=\"handleShotUpdateImmediate\"\n              />\n            </div>\n            <div class=\"param-group\">\n              <label>画面结果</label>\n              <el-input \n                v-model=\"currentShot.result\" \n                type=\"textarea\" \n                :rows=\"2\"\n                size=\"small\"\n                @blur=\"handleShotUpdateImmediate\"\n              />\n            </div>\n            \n<el-divider />\n            \n            <div class=\"param-group\">\n              <label>环境氛围</label>\n              <el-input \n                v-model=\"currentShot.atmosphere\" \n                type=\"textarea\" \n                :rows=\"2\"\n                size=\"small\"\n                placeholder=\"描述光线、色调、声音环境等\"\n                @blur=\"handleShotUpdateImmediate\"\n              />\n            </div>\n            <div class=\"param-group\">\n              <label>时长（秒）</label>\n              <el-input-number \n                v-model=\"currentShot.duration\" \n                :min=\"4\" \n                :max=\"12\" \n                size=\"small\"\n                @change=\"handleShotUpdateImmediate\"\n              />\n            </div>\n          </div>\n        </el-tab-pane>\n\n        <el-tab-pane label=\"场景制作\" name=\"scene\">\n          <div class=\"param-section\" v-if=\"currentShot\">\n            <!-- 人物选择 -->\n            <div class=\"section-title\">人物设置</div>\n            <div class=\"param-group\">\n              <label>场景角色</label>\n              <el-select \n                v-model=\"selectedCharacters\" \n                multiple \n                placeholder=\"选择出现的角色\"\n                size=\"small\"\n                style=\"width: 100%\"\n              >\n                <el-option\n                  v-for=\"char in availableCharacters\"\n                  :key=\"char.id\"\n                  :label=\"char.name\"\n                  :value=\"char.id\"\n                />\n              </el-select>\n              <p class=\"help-text\">选择在此镜头中出现的角色</p>\n            </div>\n            \n            <div class=\"character-list\">\n              <div \n                v-for=\"charId in selectedCharacters\" \n                :key=\"charId\"\n                class=\"character-avatar-item\"\n                :title=\"getCharacterById(charId)?.name\"\n              >\n                <div class=\"avatar-wrapper\">\n                  <el-image \n                    v-if=\"getCharacterById(charId)?.avatar_url\" \n                    :src=\"getCharacterById(charId).avatar_url\" \n                    fit=\"cover\"\n                  />\n                  <el-icon v-else :size=\"24\"><User /></el-icon>\n                </div>\n                <div class=\"avatar-name\">{{ getCharacterById(charId)?.name }}</div>\n              </div>\n            </div>\n            \n            <el-divider />\n            \n            <!-- 背景设置 -->\n            <div class=\"section-title\">背景设置</div>\n            <div class=\"param-group\">\n              <label>背景图片</label>\n              <div class=\"background-compact\">\n                <div class=\"background-preview-small\" v-if=\"currentShot.background_url\">\n                  <el-image :src=\"currentShot.background_url\" fit=\"cover\" />\n                </div>\n                <div v-else class=\"background-placeholder-small\">\n                  <el-icon :size=\"20\"><Picture /></el-icon>\n                </div>\n                <div class=\"background-actions-inline\">\n                  <el-button \n                    size=\"small\" \n                    type=\"primary\" \n                    :icon=\"MagicStick\"\n                    :loading=\"generating\"\n                    @click=\"handleGenerateBackground\"\n                  >\n                    生成\n                  </el-button>\n                  <el-button \n                    size=\"small\" \n                    :icon=\"Upload\"\n                    @click=\"handleUploadBackground\"\n                  >\n                    上传\n                  </el-button>\n                </div>\n              </div>\n            </div>\n            \n            <div class=\"param-group\">\n              <label>背景描述</label>\n              <el-input \n                v-model=\"backgroundPrompt\" \n                type=\"textarea\" \n                :rows=\"2\"\n                placeholder=\"描述场景背景\"\n                size=\"small\"\n              />\n            </div>\n            \n            <el-divider />\n            \n            <!-- 场景合成 -->\n            <div class=\"section-title\">场景合成</div>\n            <div class=\"param-group\">\n              <label>合成预览</label>\n              <div class=\"composition-preview\">\n                <div v-if=\"currentShot.composed_url\" class=\"composed-image\">\n                  <el-image :src=\"currentShot.composed_url\" fit=\"cover\" />\n                </div>\n                <div v-else class=\"composition-placeholder\">\n                  <el-icon><Picture /></el-icon>\n                  <p>未合成场景</p>\n                  <p class=\"hint\">需要先生成背景和选择人物</p>\n                </div>\n              </div>\n            </div>\n            \n            <div class=\"param-group\" v-if=\"!currentShot.background_url\">\n              <el-alert\n                title=\"请先生成背景图\"\n                type=\"warning\"\n                :closable=\"false\"\n                show-icon\n              />\n            </div>\n            <div class=\"param-group\" v-else-if=\"selectedCharacters.length === 0\">\n              <el-alert\n                title=\"请先选择场景角色\"\n                type=\"warning\"\n                :closable=\"false\"\n                show-icon\n              />\n            </div>\n            \n            <div class=\"param-group\">\n              <el-button \n                type=\"success\" \n                :icon=\"MagicStick\"\n                :loading=\"generating\"\n                :disabled=\"!currentShot.background_url || selectedCharacters.length === 0\"\n                @click=\"handleComposeScene\"\n                block\n              >\n                合成场景\n              </el-button>\n            </div>\n          </div>\n        </el-tab-pane>\n\n        <el-tab-pane label=\"视频生成\" name=\"video\">\n          <div class=\"param-section\" v-if=\"currentShot\">\n            <div class=\"param-group\">\n              <label>视频预览</label>\n              <div class=\"video-preview\" v-if=\"currentShot.video_url\" @click=\"toggleVideoPlay\">\n                <video ref=\"videoPlayerRef\" :src=\"currentShot.video_url\" style=\"width: 100%;\" @ended=\"videoPlaying = false\" />\n                <div class=\"video-play-overlay\" :class=\"{ hidden: videoPlaying }\">\n                  <el-icon :size=\"48\"><VideoPlay /></el-icon>\n                </div>\n              </div>\n              <div v-else class=\"video-placeholder\">\n                <el-icon><VideoCamera /></el-icon>\n                <p>未生成视频</p>\n              </div>\n            </div>\n            \n            <div class=\"param-group\" v-if=\"!currentShot.background_url\">\n              <el-alert\n                title=\"请先生成背景图\"\n                type=\"warning\"\n                :closable=\"false\"\n                show-icon\n              />\n            </div>\n            <div class=\"param-group\" v-else-if=\"selectedCharacters.length === 0\">\n              <el-alert\n                title=\"建议选择场景角色以生成更完整的视频\"\n                type=\"info\"\n                :closable=\"false\"\n                show-icon\n              />\n            </div>\n            \n            <div class=\"param-group\">\n              <el-button \n                size=\"small\" \n                type=\"success\" \n                :icon=\"VideoPlay\" \n                :loading=\"generating\"\n                :disabled=\"!currentShot.background_url\"\n                block\n                @click=\"handleGenerateVideo\"\n              >\n                生成视频\n              </el-button>\n            </div>\n          </div>\n        </el-tab-pane>\n      </el-tabs>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted, watch } from 'vue'\nimport { ElMessage, ElMessageBox } from 'element-plus'\nimport { debounce } from 'lodash-es'\nimport { \n  VideoPlay, \n  VideoPause,\n  VideoCamera,\n  Picture,\n  Download,\n  Refresh,\n  Upload,\n  MagicStick,\n  Rank,\n  User,\n  ArrowLeft\n} from '@element-plus/icons-vue'\nimport { dramaAPI } from '@/api/drama'\nimport { videoAPI } from '@/api/video'\nimport { useRouter } from 'vue-router'\n\ninterface Storyboard {\n  id?: string | number\n  storyboard_number: number\n  shot_type?: string\n  angle?: string\n  movement?: string\n  time?: string\n  location?: string\n  action?: string\n  result?: string\n  atmosphere?: string\n  dialogue?: string\n  duration?: number\n  background_url?: string\n  video_url?: string\n  scene_id?: string | number\n  title?: string\n  bgm_prompt?: string\n  sound_effect?: string\n}\n\ninterface Background {\n  id: number\n  episode_id: string\n  prompt: string  // 后端返回中文提示词\n  image_url?: string\n  created_at?: string\n}\n\nconst props = defineProps<{\n  storyboards: Storyboard[]\n  episodeId: string\n  dramaId?: string\n}>()\n\nconst emit = defineEmits<{\n  'update:storyboard': [shot: Storyboard]\n  'shot-selected': [index: number]\n  'refresh': []\n}>()\n\nconst currentShotIndex = ref(0)\nconst activeTab = ref('info')\nconst timelineZoom = ref(5)\nconst selectedCharacters = ref<string[]>([])\nconst availableCharacters = ref<any[]>([])\nconst backgroundPrompt = ref('')\nconst backgroundsCache = ref<Background[]>([])\nconst videoPlayerRef = ref<HTMLVideoElement | null>(null)\nconst videoPlaying = ref(false)\n\nconst toggleVideoPlay = () => {\n  if (!videoPlayerRef.value) return\n  if (videoPlaying.value) {\n    videoPlayerRef.value.pause()\n    videoPlaying.value = false\n  } else {\n    videoPlayerRef.value.play()\n    videoPlaying.value = true\n  }\n}\n\nconst router = useRouter()\n\nconst cameraIcons = ['正面', '侧面', '俯视', '仰视', '特写', '远景']\nconst motionIcons = ['静止', '推进', '拉远', '平移', '跟随', '环绕']\n\nconst handleBack = () => {\n  if (props.dramaId) {\n    router.push(`/drama/${props.dramaId}/episodes`)\n  } else {\n    router.back()\n  }\n}\n\nconst currentShot = computed(() => {\n  return props.storyboards[currentShotIndex.value] || null\n})\n\nconst selectShot = (index: number) => {\n  currentShotIndex.value = index\n  emit('shot-selected', index)\n  \n  // 自动选择场景中提到的角色\n  autoSelectCharacters()\n  \n  // 加载背景描述\n  loadBackgroundPrompt()\n}\n\n// 加载当前镜头的背景描述\nconst loadBackgroundPrompt = () => {\n  if (!currentShot.value) return\n  \n  // 根据background_id从 backgrounds缓存中获取prompt（中文）\n  if (currentShot.value.background_id && backgroundsCache.value.length > 0) {\n    const bgId = typeof currentShot.value.background_id === 'string' \n      ? parseInt(currentShot.value.background_id) \n      : currentShot.value.background_id\n    \n    const background = backgroundsCache.value.find(bg => bg.id === bgId)\n    if (background) {\n      backgroundPrompt.value = background.prompt || ''\n      return\n    }\n  }\n  \n  // 如果没有background_id或找不到，自动组合生成\n  const parts = []\n  if (currentShot.value.time) parts.push(currentShot.value.time)\n  if (currentShot.value.location) parts.push(currentShot.value.location)\n  backgroundPrompt.value = parts.join('，')\n}\n\n// 自动从场景描述中提取并选择相关角色\nconst autoSelectCharacters = () => {\n  if (!currentShot.value) return\n  \n  const description = `${currentShot.value.dialogue || ''} ${currentShot.value.action || ''}`.toLowerCase()\n  const matchedCharacters: string[] = []\n  \n  // 遍历所有可用角色，检查是否在描述中被提及\n  availableCharacters.value.forEach(char => {\n    const charName = char.name.toLowerCase()\n    if (description.includes(charName)) {\n      matchedCharacters.push(char.id)\n    }\n  })\n  \n  // 如果找到匹配的角色，自动选中\n  if (matchedCharacters.length > 0) {\n    selectedCharacters.value = matchedCharacters\n  }\n}\n\n// 测试函数：不使用防抖，立即触发\nconst handleShotUpdateImmediate = async () => {\n  console.log('=== handleShotUpdate 被触发 ===')\n  \n  if (!currentShot.value) {\n    console.warn('handleShotUpdate: currentShot.value is null')\n    return\n  }\n  \n  if (!currentShot.value.id) {\n    console.warn('handleShotUpdate: currentShot.value.id is null or undefined', currentShot.value)\n    return\n  }\n  \n  try {\n    // 构建更新数据，只发送有值的字段（包括空字符串）\n    const updateData: Record<string, any> = {}\n    \n    if (currentShot.value.shot_type !== undefined) updateData.shot_type = currentShot.value.shot_type\n    if (currentShot.value.angle !== undefined) updateData.angle = currentShot.value.angle\n    if (currentShot.value.movement !== undefined) updateData.movement = currentShot.value.movement\n    if (currentShot.value.time !== undefined) updateData.time = currentShot.value.time\n    if (currentShot.value.location !== undefined) updateData.location = currentShot.value.location\n    if (currentShot.value.action !== undefined) updateData.action = currentShot.value.action\n    if (currentShot.value.dialogue !== undefined) updateData.dialogue = currentShot.value.dialogue\n    if (currentShot.value.result !== undefined) updateData.result = currentShot.value.result\n    if (currentShot.value.atmosphere !== undefined) updateData.atmosphere = currentShot.value.atmosphere\n    if (currentShot.value.duration !== undefined) updateData.duration = currentShot.value.duration\n    if (currentShot.value.title !== undefined) updateData.title = currentShot.value.title\n    if (currentShot.value.bgm_prompt !== undefined) updateData.bgm_prompt = currentShot.value.bgm_prompt\n    if (currentShot.value.sound_effect !== undefined) updateData.sound_effect = currentShot.value.sound_effect\n    \n    console.log('调用更新接口:', {\n      storyboard_id: currentShot.value.id,\n      updateData\n    })\n    \n    await dramaAPI.updateStoryboard(currentShot.value.id.toString(), updateData)\n    \n    emit('update:storyboard', currentShot.value)\n    ElMessage.success('分镜更新成功')\n  } catch (error: any) {\n    console.error('更新分镜失败:', error)\n    ElMessage.error(error.message || '更新失败')\n  }\n}\n\nconst handleShotUpdate = debounce(async () => {\n  if (!currentShot.value) {\n    console.warn('handleShotUpdate: currentShot.value is null')\n    return\n  }\n  \n  if (!currentShot.value.id) {\n    console.warn('handleShotUpdate: currentShot.value.id is null or undefined', currentShot.value)\n    return\n  }\n  \n  try {\n    // 构建更新数据，只发送有值的字段（包括空字符串）\n    const updateData: Record<string, any> = {}\n    \n    if (currentShot.value.shot_type !== undefined) updateData.shot_type = currentShot.value.shot_type\n    if (currentShot.value.angle !== undefined) updateData.angle = currentShot.value.angle\n    if (currentShot.value.movement !== undefined) updateData.movement = currentShot.value.movement\n    if (currentShot.value.time !== undefined) updateData.time = currentShot.value.time\n    if (currentShot.value.location !== undefined) updateData.location = currentShot.value.location\n    if (currentShot.value.action !== undefined) updateData.action = currentShot.value.action\n    if (currentShot.value.dialogue !== undefined) updateData.dialogue = currentShot.value.dialogue\n    if (currentShot.value.result !== undefined) updateData.result = currentShot.value.result\n    if (currentShot.value.atmosphere !== undefined) updateData.atmosphere = currentShot.value.atmosphere\n    if (currentShot.value.duration !== undefined) updateData.duration = currentShot.value.duration\n    if (currentShot.value.title !== undefined) updateData.title = currentShot.value.title\n    if (currentShot.value.bgm_prompt !== undefined) updateData.bgm_prompt = currentShot.value.bgm_prompt\n    if (currentShot.value.sound_effect !== undefined) updateData.sound_effect = currentShot.value.sound_effect\n    \n    console.log('调用更新接口:', {\n      storyboard_id: currentShot.value.id,\n      updateData\n    })\n    \n    await dramaAPI.updateStoryboard(currentShot.value.id.toString(), updateData)\n    \n    emit('update:storyboard', currentShot.value)\n    ElMessage.success('分镜更新成功')\n  } catch (error: any) {\n    console.error('更新分镜失败:', error)\n    ElMessage.error(error.message || '更新失败')\n  }\n}, 500)\n\n// 使用立即触发版本进行测试\nconst testUpdate = () => {\n  console.log('testUpdate 被调用')\n  handleShotUpdateImmediate()\n}\n\nconst formatDuration = (seconds: number) => {\n  const mins = Math.floor(seconds / 60)\n  const secs = seconds % 60\n  return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`\n}\n\nconst generating = ref(false)\n\nconst handleGenerateBackground = async () => {\n  if (!currentShot.value || !currentShot.value.id) {\n    ElMessage.warning('请先选择一个镜头')\n    return\n  }\n\n  // 检查是否有 background_id\n  if (!currentShot.value.background_id) {\n    ElMessage.warning('该镜头未关联背景信息，请先提取背景')\n    return\n  }\n\n  // 检查是否有背景描述\n  if (!backgroundPrompt.value) {\n    ElMessage.warning('背景描述为空，请先填写背景描述')\n    return\n  }\n\n  try {\n    await ElMessageBox.confirm(\n      `将使用以下描述生成背景图片：\\n\\n${backgroundPrompt.value}\\n\\n是否继续？`,\n      '生成背景',\n      {\n        confirmButtonText: '确定',\n        cancelButtonText: '取消',\n        type: 'info'\n      }\n    )\n\n    generating.value = true\n    ElMessage.info('正在生成背景图片...')\n    \n    if (props.dramaId) {\n      // 使用 background_id 和 backgrounds 表中的中文 prompt\n      const bgId = typeof currentShot.value.background_id === 'string' \n        ? parseInt(currentShot.value.background_id) \n        : currentShot.value.background_id\n      \n      await dramaAPI.generateSingleBackground(\n        bgId, \n        props.dramaId,\n        backgroundPrompt.value\n      )\n    }\n    \n    ElMessage.success('背景图片生成成功')\n    emit('refresh')\n  } catch (error: any) {\n    if (error !== 'cancel') {\n      ElMessage.error(error.message || '生成失败')\n    }\n  } finally {\n    generating.value = false\n  }\n}\n\nconst handleGenerateVideo = async () => {\n  if (!currentShot.value || !currentShot.value.id) {\n    ElMessage.warning('请先选择一个镜头')\n    return\n  }\n\n  if (!currentShot.value.background_url) {\n    ElMessage.warning('请先生成背景图片')\n    return\n  }\n\n  try {\n    const characterInfo = selectedCharacters.value.length > 0\n      ? `\\n角色：${selectedCharacters.value.map(id => getCharacterById(id)?.name).filter(Boolean).join('、')}`\n      : ''\n    \n    await ElMessageBox.confirm(\n      `将生成视频：\\n场景：${currentShot.value.location}\\n动作：${currentShot.value.action}${characterInfo}\\n\\n预计需要1-3分钟，是否继续？`,\n      '视频生成',\n      {\n        confirmButtonText: '确定',\n        cancelButtonText: '取消',\n        type: 'success'\n      }\n    )\n\n    generating.value = true\n    ElMessage.info('正在生成视频...')\n    \n    await videoAPI.generateVideo({\n      scene_id: parseInt(currentShot.value.id),\n      prompt: currentShot.value.action\n    })\n    \n    ElMessage.success('视频生成任务已创建，请稍后查看')\n    emit('refresh')\n  } catch (error: any) {\n    if (error !== 'cancel') {\n      ElMessage.error(error.message || '生成失败')\n    }\n  } finally {\n    generating.value = false\n  }\n}\n\nconst handleUploadBackground = () => {\n  ElMessage.info('上传功能开发中')\n}\n\nconst getCharacterById = (id: string) => {\n  return availableCharacters.value.find(c => c.id === id)\n}\n\nconst handleComposeScene = async () => {\n  if (!currentShot.value) return\n  \n  if (!currentShot.value.background_url) {\n    ElMessage.warning('请先生成背景图')\n    return\n  }\n  \n  if (selectedCharacters.length === 0) {\n    ElMessage.warning('请先选择场景角色')\n    return\n  }\n  \n  try {\n    const characterNames = selectedCharacters.value\n      .map(id => getCharacterById(id)?.name)\n      .filter(Boolean)\n      .join('、')\n    \n    await ElMessageBox.confirm(\n      `将合成以下内容：\\n背景：${currentShot.value.location}\\n角色：${characterNames}\\n\\n是否继续？`,\n      '场景合成',\n      {\n        confirmButtonText: '确定',\n        cancelButtonText: '取消',\n        type: 'info'\n      }\n    )\n    \n    generating.value = true\n    ElMessage.info('正在合成场景...')\n    \n    // TODO: 调用场景合成API\n    // await compositionAPI.composeScene(currentShot.value.id, selectedCharacters.value)\n    \n    ElMessage.success('场景合成成功')\n    emit('refresh')\n  } catch (error: any) {\n    if (error !== 'cancel') {\n      ElMessage.error(error.message || '合成失败')\n    }\n  } finally {\n    generating.value = false\n  }\n}\n\nconst handleRegenerateShot = async () => {\n  if (!currentShot.value) return\n  \n  try {\n    await ElMessageBox.confirm(\n      '重新生成将清空当前镜头的背景和视频，是否继续？',\n      '重新生成',\n      {\n        confirmButtonText: '确定',\n        cancelButtonText: '取消',\n        type: 'warning'\n      }\n    )\n    \n    ElMessage.info('功能开发中')\n  } catch {\n    // 用户取消\n  }\n}\n\n// 加载背景数据缓存\nconst loadBackgrounds = async () => {\n  if (!props.episodeId) return\n  \n  try {\n    const result = await dramaAPI.getBackgrounds(props.episodeId)\n    backgroundsCache.value = result.data || result || []\n    // 加载完背景数据后，重新加载当前背景描述\n    loadBackgroundPrompt()\n  } catch (error) {\n    console.error('加载背景数据失败:', error)\n  }\n}\n\n// 加载可用角色列表\nonMounted(async () => {\n  // 加载背景数据\n  await loadBackgrounds()\n  \n  // 加载角色数据\n  if (props.dramaId) {\n    try {\n      const result = await dramaAPI.getCharacters(props.dramaId)\n      availableCharacters.value = result.data || result || []\n      // 加载完角色后，自动选择当前镜头相关的角色\n      autoSelectCharacters()\n    } catch (error) {\n      console.error('加载角色失败:', error)\n    }\n  }\n})\n\n// 监听镜头变化，自动选择相关角色和加载背景描述\nwatch(() => currentShot.value, () => {\n  if (currentShot.value && availableCharacters.value.length > 0) {\n    autoSelectCharacters()\n    loadBackgroundPrompt()\n  }\n}, { deep: true })\n</script>\n\n<style scoped lang=\"scss\">\n.storyboard-editor {\n  display: flex;\n  height: 100vh;\n  background: var(--bg-primary);\n  color: var(--text-primary);\n  overflow: hidden;\n}\n\n.left-panel {\n  width: 280px;\n  flex-shrink: 0;\n  background: var(--bg-card);\n  display: flex;\n  flex-direction: column;\n  border-right: 1px solid var(--border-primary);\n\n  .panel-header {\n    padding: 16px;\n    border-bottom: 1px solid var(--border-primary);\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n\n    .header-left {\n      display: flex;\n      align-items: center;\n      gap: 8px;\n    }\n\n    h3 {\n      margin: 0;\n      font-size: 14px;\n      font-weight: 600;\n      color: var(--text-primary);\n    }\n  }\n\n  .scene-list {\n    flex: 1;\n    overflow-y: auto;\n    padding: 8px;\n\n    .scene-item {\n      display: flex;\n      gap: 12px;\n      padding: 12px;\n      margin-bottom: 8px;\n      background: var(--bg-secondary);\n      border-radius: 8px;\n      cursor: pointer;\n      transition: all 0.2s;\n      border: 1px solid var(--border-primary);\n\n      &:hover {\n        background: var(--accent-light);\n        border-color: var(--accent);\n      }\n\n      &.active {\n        background: var(--accent-light);\n        border-color: var(--accent);\n        box-shadow: var(--shadow-md);\n      }\n\n      .scene-number {\n        flex-shrink: 0;\n        width: 32px;\n        height: 32px;\n        background: var(--accent);\n        border-radius: 4px;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        font-weight: 600;\n        font-size: 12px;\n        color: var(--text-inverse);\n      }\n\n      .scene-content {\n        flex: 1;\n        min-width: 0;\n\n        .scene-title {\n          display: flex;\n          align-items: center;\n          gap: 6px;\n          margin-bottom: 4px;\n\n          .time-location {\n            font-size: 11px;\n            color: var(--text-muted);\n            white-space: nowrap;\n            overflow: hidden;\n            text-overflow: ellipsis;\n          }\n        }\n\n        .scene-desc {\n          font-size: 12px;\n          color: var(--text-secondary);\n          line-height: 1.4;\n          overflow: hidden;\n          text-overflow: ellipsis;\n          display: -webkit-box;\n          -webkit-line-clamp: 2;\n          -webkit-box-orient: vertical;\n        }\n      }\n\n      .scene-thumb {\n        flex-shrink: 0;\n        width: 60px;\n        height: 40px;\n        border-radius: 4px;\n        overflow: hidden;\n        background: var(--bg-card-hover);\n        border: 1px solid var(--border-primary);\n      }\n    }\n  }\n}\n\n.center-panel {\n  flex: 1;\n  min-width: 0;\n  display: flex;\n  flex-direction: column;\n  background: var(--bg-primary);\n\n  .preview-header {\n    padding: 12px 20px;\n    background: var(--bg-card);\n    border-bottom: 1px solid var(--border-primary);\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n\n    .header-info {\n      display: flex;\n      align-items: center;\n      gap: 12px;\n\n      .shot-type {\n        font-size: 14px;\n        color: var(--text-secondary);\n      }\n    }\n  }\n\n  .preview-area {\n    flex: 1;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    padding: 20px;\n    overflow: hidden;\n\n    .preview-container {\n      max-width: 90%;\n      max-height: 90%;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      background: var(--bg-card);\n      border-radius: 8px;\n      overflow: hidden;\n      border: 1px solid var(--border-primary);\n      box-shadow: var(--shadow-md);\n\n      .preview-image {\n        width: 100%;\n        height: 100%;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n\n        :deep(.el-image) {\n          max-width: 100%;\n          max-height: 100%;\n        }\n      }\n\n      .preview-placeholder {\n        text-align: center;\n        color: var(--text-muted);\n\n        p {\n          margin-top: 12px;\n          font-size: 14px;\n        }\n\n        .hint {\n          font-size: 12px;\n          color: var(--text-muted);\n          margin-top: 8px;\n        }\n      }\n\n      .preview-video {\n        width: 100%;\n        height: 100%;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n      }\n    }\n  }\n\n  .timeline-panel {\n    height: 140px;\n    background: var(--bg-card);\n    border-top: 1px solid var(--border-primary);\n\n    .timeline-header {\n      display: flex;\n      justify-content: space-between;\n      align-items: center;\n      padding: 8px 16px;\n      border-bottom: 1px solid var(--border-primary);\n\n      .timeline-tools {\n        display: flex;\n        align-items: center;\n        gap: 12px;\n\n        .timecode {\n          font-size: 12px;\n          color: var(--text-muted);\n          font-family: monospace;\n        }\n      }\n\n      .timeline-zoom {\n        display: flex;\n        align-items: center;\n        gap: 8px;\n      }\n    }\n\n    .timeline-track {\n      position: relative;\n      height: 92px;\n      overflow-x: auto;\n      overflow-y: hidden;\n      padding: 8px 16px;\n\n      &::-webkit-scrollbar {\n        height: 8px;\n      }\n\n      &::-webkit-scrollbar-track {\n        background: var(--bg-secondary);\n      }\n\n      &::-webkit-scrollbar-thumb {\n        background: var(--border-secondary);\n        border-radius: 4px;\n\n        &:hover {\n          background: var(--border-primary);\n        }\n      }\n\n      .timeline-ruler {\n        position: relative;\n        height: 20px;\n        border-bottom: 1px solid var(--border-primary);\n        min-width: max-content;\n\n        .ruler-mark {\n          position: absolute;\n          transform: translateX(-50%);\n\n          &::before {\n            content: '';\n            display: block;\n            width: 1px;\n            height: 8px;\n            background: var(--border-primary);\n            margin-bottom: 2px;\n          }\n\n          span {\n            font-size: 10px;\n            color: var(--text-secondary);\n          }\n        }\n      }\n\n      .timeline-clips {\n        position: relative;\n        height: 48px;\n        margin-top: 8px;\n        display: flex;\n        gap: 4px;\n        min-width: max-content;\n\n        .timeline-clip {\n          flex-shrink: 0;\n          width: 60px;\n          height: 100%;\n          background: var(--accent-light);\n          border-radius: 4px;\n          cursor: pointer;\n          transition: all 0.2s;\n          border: 1px solid var(--accent);\n\n          &:hover {\n            background: var(--accent);\n            border-color: var(--accent-hover);\n          }\n\n          &.active {\n            background: var(--accent);\n            border-color: var(--accent);\n            box-shadow: var(--shadow-glow);\n          }\n\n          .clip-content {\n            padding: 4px 8px;\n            height: 100%;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n\n            .clip-number {\n              font-size: 11px;\n              font-weight: 600;\n              color: var(--text-primary);\n            }\n          }\n\n          &.active .clip-content .clip-number {\n            color: var(--text-inverse);\n          }\n        }\n      }\n    }\n  }\n}\n\n.right-panel {\n  width: 420px;\n  flex-shrink: 0;\n  background: var(--bg-card);\n  border-left: 1px solid var(--border-primary);\n  display: flex;\n  flex-direction: column;\n\n  .panel-tabs {\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    height: 100%;\n\n    :deep(.el-tabs__header) {\n      margin: 0;\n      background: var(--bg-card);\n      border-bottom: 1px solid var(--border-primary);\n      flex-shrink: 0;\n      padding: 0 8px;\n    }\n\n    :deep(.el-tabs__content) {\n      flex: 1;\n      overflow-y: auto;\n      padding: 16px 14px;\n      height: 0;\n    }\n\n    :deep(.el-tabs__item) {\n      color: var(--text-secondary);\n      font-size: 13px;\n      font-weight: 500;\n      padding: 0 20px;\n      transition: all 0.3s ease;\n\n      &:hover {\n        color: var(--accent);\n      }\n\n      &.is-active {\n        color: var(--accent);\n        background: var(--accent-light);\n      }\n    }\n\n    :deep(.el-tabs__active-bar) {\n      background: linear-gradient(90deg, var(--accent) 0%, var(--accent-hover) 100%);\n      height: 3px;\n    }\n\n    :deep(.el-tab-pane) {\n      height: 100%;\n    }\n  }\n\n  .param-section {\n    .section-title {\n      font-size: 14px;\n      font-weight: 600;\n      color: var(--accent);\n      margin: 12px 0 10px;\n      padding: 6px 10px;\n      background: var(--accent-light);\n      border-radius: 6px;\n      border-left: 3px solid var(--accent);\n    }\n    \n    .param-group {\n      margin-bottom: 10px;\n      padding: 10px;\n      background: var(--bg-secondary);\n      border-radius: 8px;\n      border: 1px solid var(--border-primary);\n      transition: all 0.3s ease;\n\n      &:hover {\n        background: var(--accent-light);\n        border-color: var(--accent);\n        box-shadow: var(--shadow-sm);\n      }\n\n      label {\n        display: block;\n        font-size: 12px;\n        color: var(--text-secondary);\n        margin-bottom: 6px;\n        font-weight: 600;\n        letter-spacing: 0.3px;\n      }\n\n      .icon-grid {\n        display: grid;\n        grid-template-columns: repeat(3, 1fr);\n        gap: 8px;\n\n        .icon-button {\n          aspect-ratio: 1;\n          padding: 8px;\n        }\n      }\n    }\n\n    .param-row {\n      display: grid;\n      grid-template-columns: 1fr 1fr;\n      gap: 12px;\n    }\n\n    .background-compact {\n      display: flex;\n      align-items: center;\n      gap: 10px;\n    }\n\n    .background-preview-small {\n      width: 80px;\n      height: 60px;\n      background: var(--bg-secondary);\n      border-radius: 6px;\n      overflow: hidden;\n      border: 1px solid var(--border-primary);\n      flex-shrink: 0;\n    }\n\n    .background-placeholder-small {\n      width: 80px;\n      height: 60px;\n      background: var(--bg-primary);\n      border-radius: 6px;\n      border: 2px dashed var(--border-secondary);\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      color: var(--text-muted);\n      flex-shrink: 0;\n      transition: all 0.3s ease;\n\n      &:hover {\n        border-color: var(--accent);\n        background: var(--accent-light);\n      }\n    }\n\n    .background-actions-inline {\n      display: flex;\n      gap: 6px;\n      flex: 1;\n\n      .el-button {\n        flex: 1;\n      }\n    }\n\n    .background-preview,\n    .video-preview {\n      width: 100%;\n      aspect-ratio: 2/1;\n      background: var(--bg-secondary);\n      border-radius: 6px;\n      overflow: hidden;\n      margin-bottom: 8px;\n      border: 1px solid var(--border-primary);\n      box-shadow: var(--shadow-sm);\n      position: relative;\n      cursor: pointer;\n    }\n\n    .video-play-overlay {\n      position: absolute;\n      top: 0;\n      left: 0;\n      right: 0;\n      bottom: 0;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      background: rgba(0, 0, 0, 0.4);\n      transition: opacity 0.3s ease;\n\n      .el-icon {\n        color: white;\n        filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));\n      }\n\n      &.hidden {\n        opacity: 0;\n      }\n\n      &:hover {\n        background: rgba(0, 0, 0, 0.5);\n      }\n    }\n\n    .background-placeholder,\n    .video-placeholder {\n      width: 100%;\n      aspect-ratio: 2/1;\n      background: var(--bg-primary);\n      border-radius: 6px;\n      border: 2px dashed var(--border-secondary);\n      display: flex;\n      flex-direction: column;\n      align-items: center;\n      justify-content: center;\n      color: var(--text-muted);\n      margin-bottom: 8px;\n      transition: all 0.3s ease;\n\n      &:hover {\n        border-color: var(--accent);\n        background: var(--accent-light);\n      }\n\n      .el-icon {\n        font-size: 32px;\n      }\n\n      p {\n        margin-top: 6px;\n        font-size: 11px;\n        font-weight: 500;\n      }\n    }\n\n    .background-actions,\n    .video-actions {\n      display: flex;\n      flex-direction: column;\n      gap: 8px;\n      margin-top: 10px;\n\n      :deep(.el-button) {\n        border-radius: 6px;\n        font-weight: 500;\n        transition: all 0.2s ease;\n\n        &.el-button--primary {\n          &:hover {\n            transform: translateY(-1px);\n            box-shadow: 0 4px 8px rgba(64, 158, 255, 0.2);\n          }\n        }\n\n        &.el-button--success {\n          &:hover {\n            transform: translateY(-1px);\n            box-shadow: 0 4px 8px rgba(103, 194, 58, 0.2);\n          }\n        }\n\n        &:active {\n          transform: translateY(0);\n        }\n      }\n    }\n\n    .help-text {\n      margin-top: 6px;\n      font-size: 11px;\n      color: var(--text-muted);\n      line-height: 1.5;\n      padding: 4px 8px;\n      background: var(--accent-light);\n      border-radius: 4px;\n      border-left: 2px solid var(--accent);\n    }\n\n    .character-list {\n      margin-top: 8px;\n      display: grid;\n      grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));\n      gap: 10px;\n\n      .character-avatar-item {\n        display: flex;\n        flex-direction: column;\n        align-items: center;\n        cursor: pointer;\n        transition: all 0.3s ease;\n\n        &:hover {\n          transform: translateY(-2px);\n          \n          .avatar-wrapper {\n            border-color: var(--accent);\n            box-shadow: var(--shadow-md);\n          }\n        }\n\n        .avatar-wrapper {\n          width: 50px;\n          height: 50px;\n          border-radius: 50%;\n          overflow: hidden;\n          background: var(--bg-secondary);\n          display: flex;\n          align-items: center;\n          justify-content: center;\n          color: var(--text-muted);\n          border: 2px solid var(--border-secondary);\n          transition: all 0.3s ease;\n          margin-bottom: 6px;\n        }\n\n        .avatar-name {\n          font-size: 11px;\n          color: var(--text-secondary);\n          text-align: center;\n          max-width: 100%;\n          overflow: hidden;\n          text-overflow: ellipsis;\n          white-space: nowrap;\n        }\n      }\n    }\n\n    .composition-preview {\n      .composed-image,\n      .composition-placeholder {\n        width: 100%;\n        aspect-ratio: 2/1;\n        background: var(--bg-primary);\n        border-radius: 6px;\n        border: 2px dashed var(--border-secondary);\n        overflow: hidden;\n        display: flex;\n        flex-direction: column;\n        align-items: center;\n        justify-content: center;\n        margin-bottom: 8px;\n        color: var(--text-muted);\n\n        .el-icon {\n          font-size: 32px;\n        }\n\n        p {\n          margin-top: 6px;\n          font-size: 11px;\n        }\n\n        .hint {\n          font-size: 10px;\n          color: var(--text-muted);\n          margin-top: 4px;\n        }\n      }\n    }\n  }\n}\n\n:deep(.el-input__wrapper),\n:deep(.el-textarea__inner),\n:deep(.el-select) {\n  background-color: var(--bg-card);\n  border-radius: 6px;\n  border: 1px solid var(--border-primary);\n  transition: all 0.3s ease;\n\n  &:hover {\n    border-color: var(--border-secondary);\n  }\n\n  &:focus,\n  &.is-focus {\n    border-color: var(--accent);\n    box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.1);\n  }\n}\n\n:deep(.el-input__inner),\n:deep(.el-textarea__inner) {\n  color: var(--text-primary);\n  font-size: 13px;\n}\n\n:deep(.el-textarea__inner) {\n  line-height: 1.6;\n}\n\n:deep(.el-divider) {\n  border-color: var(--border-primary);\n  margin: 12px 0;\n}\n\n:deep(.el-button) {\n  &.is-loading {\n    opacity: 0.8;\n  }\n}\n\n:deep(.el-image__inner) {\n  max-width: 100%;\n  max-height: 100%;\n}\n\n:deep(.el-scrollbar__view) {\n  height: 100%;\n}\n</style>\n"
  },
  {
    "path": "web/src/components/editor/VideoTimelineEditor.vue",
    "content": "<template>\n  <div class=\"video-timeline-editor\">\n    <!-- 顶部工具栏 -->\n    <div class=\"editor-toolbar\">\n      <div class=\"toolbar-left\">\n        <el-button-group>\n          <el-button :icon=\"VideoPlay\" @click=\"playTimeline\" :disabled=\"timelineClips.length === 0\">{{\n            $t('common.play')\n          }}</el-button>\n          <el-button :icon=\"VideoPause\" @click=\"pauseTimeline\">{{ $t('common.pause') }}</el-button>\n        </el-button-group>\n        <span class=\"time-display\">{{ formatTime(currentTime) }} / {{ formatTime(totalDuration) }}</span>\n      </div>\n      <div class=\"toolbar-right\">\n        <el-button\n          type=\"primary\"\n          :icon=\"VideoCamera\"\n          @click=\"submitTimelineForMerge\"\n          :disabled=\"timelineClips.length === 0\"\n          :loading=\"serverMerging\"\n        >\n          {{ $t('video.merge') }}\n        </el-button>\n      </div>\n    </div>\n\n    <!-- 主工作区 -->\n    <div class=\"editor-workspace\">\n      <!-- 预览区域 -->\n      <div class=\"preview-panel\">\n        <div class=\"video-preview\" @click=\"togglePlay\">\n          <video\n            ref=\"previewPlayer\"\n            :src=\"currentPreviewUrl\"\n            @loadedmetadata=\"handlePreviewLoaded\"\n            @timeupdate=\"handlePreviewTimeUpdate\"\n            @ended=\"handlePreviewEnded\"\n          />\n          <!-- 音频播放器（隐藏） -->\n          <audio\n            ref=\"audioPlayer\"\n            :src=\"currentAudioUrl\"\n            @loadedmetadata=\"handleAudioLoaded\"\n            @ended=\"handleAudioEnded\"\n            style=\"display: none\"\n          />\n          <!-- 转场效果层 -->\n          <div\n            v-if=\"transitionState.active\"\n            class=\"transition-overlay\"\n            :class=\"[\n              `transition-${transitionState.type}`,\n              {\n                'transition-in': transitionState.phase === 'in',\n                'transition-out': transitionState.phase === 'out',\n              },\n            ]\"\n            :style=\"{ animationDuration: transitionState.duration + 's' }\"\n          ></div>\n          <!-- 播放/暂停图标覆盖层 -->\n          <div class=\"video-play-overlay\" :class=\"{ hidden: isPlaying }\" v-if=\"currentPreviewUrl\">\n            <el-icon :size=\"64\"><VideoPlay /></el-icon>\n          </div>\n          <div class=\"preview-overlay\" v-if=\"!currentPreviewUrl\">\n            <el-empty :description=\"$t('video.dragToTimeline')\" />\n          </div>\n        </div>\n        <div class=\"preview-controls\">\n          <el-slider v-model=\"currentTime\" :max=\"totalDuration\" :step=\"0.1\" @change=\"seekToTime\" />\n        </div>\n      </div>\n\n      <!-- 素材库 -->\n      <div class=\"media-library\">\n        <div class=\"library-header\">\n          <div class=\"header-left\">\n            <h4>{{ $t('video.mediaLibrary') }}</h4>\n            <span>{{ $t('video.videoCount', { count: availableStoryboards.length }) }}</span>\n          </div>\n          <el-button\n            type=\"primary\"\n            size=\"small\"\n            :icon=\"FolderAdd\"\n            @click=\"addAllScenesInOrder\"\n            :disabled=\"availableStoryboards.length === 0\"\n          >\n            {{ $t('common.addAll') }}\n          </el-button>\n        </div>\n        <div class=\"media-grid\">\n          <div\n            v-for=\"scene in availableStoryboards\"\n            :key=\"scene.id\"\n            class=\"media-item\"\n            draggable=\"true\"\n            @dragstart=\"handleDragStart($event, scene)\"\n          >\n            <div class=\"media-thumbnail\" @click=\"previewScene(scene)\">\n              <video :src=\"scene.video_url\" />\n              <div class=\"media-duration\">{{ scene.duration > 0 ? scene.duration.toFixed(1) : '?' }}s</div>\n              <el-button\n                class=\"delete-btn\"\n                type=\"danger\"\n                size=\"small\"\n                :icon=\"Delete\"\n                circle\n                @click.stop=\"deleteAsset(scene)\"\n              />\n              <div class=\"media-overlay\">\n                <el-button type=\"primary\" size=\"small\" :icon=\"Plus\" @click.stop=\"addClipToTimeline(scene)\">\n                  {{ $t('common.addToTimeline') }}\n                </el-button>\n              </div>\n            </div>\n            <div class=\"media-info\">\n              <div class=\"media-title\">{{ $t('storyboard.shot') }} #{{ scene.storyboard_num || scene.asset_id }}</div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- 时间线区域 -->\n    <div class=\"timeline-panel\">\n      <div class=\"timeline-header\">\n        <div class=\"zoom-controls\">\n          <el-button-group size=\"small\">\n            <el-button @click=\"zoomOut\">-</el-button>\n            <el-button @click=\"zoomReset\">{{ $t('common.reset') }}</el-button>\n            <el-button @click=\"zoomIn\">+</el-button>\n          </el-button-group>\n          <span class=\"zoom-level\">{{ Math.round(zoom * 100) }}%</span>\n        </div>\n      </div>\n\n      <div class=\"timeline-container\" ref=\"timelineContainer\">\n        <!-- 时间标尺 -->\n        <div class=\"timeline-ruler\" :style=\"{ width: timelineWidth + 'px' }\">\n          <div\n            v-for=\"tick in timeRulerTicks\"\n            :key=\"tick.time\"\n            class=\"ruler-tick\"\n            :style=\"{ left: tick.position + 'px' }\"\n          >\n            <div class=\"tick-mark\" :class=\"tick.type\"></div>\n            <div class=\"tick-label\" v-if=\"tick.type === 'major'\">\n              {{ formatTime(tick.time) }}\n            </div>\n          </div>\n        </div>\n\n        <!-- 播放头 -->\n        <div class=\"playhead\" :style=\"{ left: playheadPosition + 'px' }\">\n          <div class=\"playhead-line\" @mousedown=\"startDragPlayhead\"></div>\n          <div class=\"playhead-handle\" @mousedown=\"startDragPlayhead\"></div>\n        </div>\n\n        <!-- 视频轨道 -->\n        <div\n          class=\"timeline-track\"\n          :style=\"{ width: timelineWidth + 'px' }\"\n          @drop=\"handleTrackDrop($event)\"\n          @dragover.prevent\n          @click=\"clickTimeline($event)\"\n        >\n          <div class=\"track-label\">\n            <span>{{ $t('video.videoTrack') }}</span>\n            <el-button\n              type=\"text\"\n              size=\"small\"\n              @click.stop=\"clearAllClips\"\n              :disabled=\"timelineClips.length === 0\"\n              :title=\"$t('video.clearTrack')\"\n            >\n              <el-icon><Delete /></el-icon>\n            </el-button>\n          </div>\n          <div class=\"track-clips\">\n            <!-- 视频片段 -->\n            <div\n              v-for=\"(clip, index) in timelineClips\"\n              :key=\"clip.id\"\n              class=\"track-clip\"\n              :class=\"{ selected: selectedClipId === clip.id }\"\n              :style=\"getClipStyle(clip)\"\n              @click.stop=\"selectClip(clip)\"\n              @mousedown=\"startDragClip($event, clip)\"\n            >\n              <div class=\"clip-content\">\n                <div class=\"clip-thumbnail\">\n                  <video :src=\"clip.video_url\" />\n                </div>\n                <div class=\"clip-info\">\n                  <div class=\"clip-title\">{{ $t('storyboard.scene') }} {{ clip.storyboard_number }}</div>\n                  <div class=\"clip-duration\">{{ clip.duration.toFixed(1) }}s</div>\n                </div>\n              </div>\n              <div class=\"clip-resize-left\" @mousedown.stop=\"startResizeClip($event, clip, 'left')\"></div>\n              <div class=\"clip-resize-right\" @mousedown.stop=\"startResizeClip($event, clip, 'right')\"></div>\n              <div class=\"clip-remove\" @click.stop=\"removeClip(clip)\">\n                <el-icon><Close /></el-icon>\n              </div>\n            </div>\n\n            <!-- 转场指示器 -->\n            <div\n              v-for=\"(clip, index) in timelineClips.slice(1)\"\n              :key=\"'transition-' + clip.id\"\n              class=\"transition-indicator\"\n              :style=\"getTransitionStyle(clip)\"\n              @click.stop=\"openTransitionDialog(timelineClips[index])\"\n            >\n              <el-icon><connection /></el-icon>\n              <span class=\"transition-label\">{{ getTransitionLabel(timelineClips[index]) }}</span>\n            </div>\n          </div>\n        </div>\n\n        <!-- 音频轨道 -->\n        <div\n          v-if=\"showAudioTrack\"\n          class=\"timeline-track audio-track\"\n          :style=\"{ width: timelineWidth + 'px' }\"\n          @click=\"clickTimeline($event)\"\n        >\n          <div class=\"track-label\">\n            <span>{{ $t('video.audioTrack') }}</span>\n            <el-button\n              type=\"text\"\n              size=\"small\"\n              @click.stop=\"extractAllAudio\"\n              :disabled=\"timelineClips.length === 0\"\n              :title=\"$t('video.extractAudio')\"\n            >\n              <el-icon><Headset /></el-icon>\n            </el-button>\n          </div>\n          <div class=\"track-clips\">\n            <!-- 音频片段 -->\n            <div\n              v-for=\"audio in audioClips\"\n              :key=\"audio.id\"\n              class=\"track-clip audio-clip\"\n              :class=\"{ selected: selectedAudioClipId === audio.id }\"\n              :style=\"getClipStyle(audio)\"\n              @click.stop=\"selectAudioClip(audio)\"\n              @mousedown=\"startDragAudioClip($event, audio)\"\n            >\n              <div class=\"clip-content\">\n                <div class=\"audio-waveform\">\n                  <el-icon><Microphone /></el-icon>\n                </div>\n                <div class=\"clip-info\">\n                  <div class=\"clip-title\">{{ $t('video.audio') }} {{ audio.order + 1 }}</div>\n                  <div class=\"clip-duration\">{{ audio.duration.toFixed(1) }}s</div>\n                </div>\n              </div>\n              <div class=\"clip-resize-left\" @mousedown.stop=\"startResizeAudioClip($event, audio, 'left')\"></div>\n              <div class=\"clip-resize-right\" @mousedown.stop=\"startResizeAudioClip($event, audio, 'right')\"></div>\n              <div class=\"clip-remove\" @click.stop=\"removeAudioClip(audio)\">\n                <el-icon><Close /></el-icon>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- 转场设置对话框 -->\n    <el-dialog v-model=\"transitionDialogVisible\" title=\"设置转场效果\" width=\"500px\">\n      <el-form label-width=\"100px\">\n        <el-form-item :label=\"$t('video.transitionType')\">\n          <el-select v-model=\"editingTransition.type\" :placeholder=\"$t('video.selectTransition')\">\n            <el-option label=\"无转场\" value=\"none\" />\n            <!-- 淡入淡出类 -->\n            <el-option label=\"淡入淡出\" value=\"fade\" />\n            <el-option label=\"黑场过渡\" value=\"fadeblack\" />\n            <el-option label=\"白场过渡\" value=\"fadewhite\" />\n            <el-option label=\"灰场过渡\" value=\"fadegrays\" />\n            <!-- 滑动类 -->\n            <el-option label=\"左滑\" value=\"slideleft\" />\n            <el-option label=\"右滑\" value=\"slideright\" />\n            <el-option label=\"上滑\" value=\"slideup\" />\n            <el-option label=\"下滑\" value=\"slidedown\" />\n            <!-- 擦除类 -->\n            <el-option label=\"左擦除\" value=\"wipeleft\" />\n            <el-option label=\"右擦除\" value=\"wiperight\" />\n            <el-option label=\"上擦除\" value=\"wipeup\" />\n            <el-option label=\"下擦除\" value=\"wipedown\" />\n            <!-- 圆形类 -->\n            <el-option label=\"圆形展开\" value=\"circleopen\" />\n            <el-option label=\"圆形收缩\" value=\"circleclose\" />\n            <!-- 其他特效 -->\n            <el-option label=\"溶解\" value=\"dissolve\" />\n            <el-option label=\"距离\" value=\"distance\" />\n            <el-option label=\"水平打开\" value=\"horzopen\" />\n            <el-option label=\"水平关闭\" value=\"horzclose\" />\n            <el-option label=\"垂直打开\" value=\"vertopen\" />\n            <el-option label=\"垂直关闭\" value=\"vertclose\" />\n          </el-select>\n        </el-form-item>\n        <el-form-item :label=\"$t('video.transitionDuration')\" v-if=\"editingTransition.type !== 'none'\">\n          <el-slider\n            v-model=\"editingTransition.duration\"\n            :min=\"0.3\"\n            :max=\"3\"\n            :step=\"0.1\"\n            show-input\n            :format-tooltip=\"(val: number) => val.toFixed(1) + 's'\"\n          />\n        </el-form-item>\n        <el-alert\n          v-if=\"editingTransition.type !== 'none'\"\n          title=\"注意：添加转场效果需要重新编码视频，处理时间会更长\"\n          type=\"warning\"\n          :closable=\"false\"\n          show-icon\n        />\n      </el-form>\n      <template #footer>\n        <el-button @click=\"transitionDialogVisible = false\">取消</el-button>\n        <el-button type=\"primary\" @click=\"applyTransition\">确定</el-button>\n      </template>\n    </el-dialog>\n\n    <!-- 合并进度对话框 -->\n    <el-dialog\n      v-model=\"mergeDialogVisible\"\n      title=\"视频合并中\"\n      width=\"500px\"\n      :close-on-click-modal=\"false\"\n      :close-on-press-escape=\"false\"\n      :show-close=\"!merging\"\n    >\n      <div class=\"merge-progress-container\">\n        <div class=\"progress-info\">\n          <div class=\"progress-phase\">\n            <el-tag :type=\"getPhaseType(mergeProgressDetail.phase)\">\n              {{ getPhaseText(mergeProgressDetail.phase) }}\n            </el-tag>\n          </div>\n          <div class=\"progress-message\">{{ mergeProgressDetail.message }}</div>\n        </div>\n\n        <el-progress\n          :percentage=\"mergeProgressDetail.progress\"\n          :status=\"mergeProgressDetail.phase === 'completed' ? 'success' : undefined\"\n          :stroke-width=\"20\"\n        />\n\n        <div class=\"progress-tips\">\n          <p v-if=\"mergeProgressDetail.phase === 'loading'\">\n            <el-icon><Loading /></el-icon>\n            正在加载FFmpeg引擎（首次需要下载约30MB）...\n          </p>\n          <p v-else-if=\"mergeProgressDetail.phase === 'processing'\">\n            <el-icon><Download /></el-icon>\n            正在处理视频文件，请稍候...\n          </p>\n          <p v-else-if=\"mergeProgressDetail.phase === 'encoding'\">\n            <el-icon><VideoCamera /></el-icon>\n            正在编码合并视频，可能需要几分钟...\n          </p>\n          <p v-else-if=\"mergeProgressDetail.phase === 'completed'\">\n            <el-icon><Check /></el-icon>\n            合并完成！视频已自动下载。\n          </p>\n        </div>\n      </div>\n\n      <template #footer v-if=\"!merging\">\n        <el-button @click=\"mergeDialogVisible = false\">关闭</el-button>\n      </template>\n    </el-dialog>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted, onUnmounted } from 'vue'\nimport { ElMessage, ElMessageBox } from 'element-plus'\nimport {\n  VideoPlay,\n  VideoPause,\n  Plus,\n  FolderAdd,\n  ArrowLeft,\n  ArrowRight,\n  Scissor,\n  Connection,\n  Setting,\n  ZoomIn,\n  ZoomOut,\n  Refresh,\n  Download,\n  Delete,\n  Close,\n  VideoCamera,\n  Check,\n  Loading,\n  Headset,\n  Microphone,\n} from '@element-plus/icons-vue'\nimport { videoMerger, type MergeProgress } from '@/utils/videoMerger'\nimport { trimAndMergeVideos } from '@/utils/ffmpeg'\nimport { getVideoUrl } from '@/utils/image'\n\ninterface Scene {\n  id: string\n  storyboard_id: string\n  storyboard_number: number\n  title?: string\n  description?: string\n  location?: string\n  time?: string\n  video_url: string\n  asset_id?: string\n  duration?: number\n}\n\ninterface TimelineClip {\n  id: string\n  storyboard_id: string\n  storyboard_number: number\n  video_url: string\n  asset_id?: string // 素材库中的资源ID\n  start_time: number\n  end_time: number\n  duration: number\n  position: number // 在时间线上的位置（秒）\n  order: number\n  transition?: {\n    type:\n      | 'fade'\n      | 'fadeblack'\n      | 'fadewhite'\n      | 'fadegrays'\n      | 'slideleft'\n      | 'slideright'\n      | 'slideup'\n      | 'slidedown'\n      | 'wipeleft'\n      | 'wiperight'\n      | 'wipeup'\n      | 'wipedown'\n      | 'circleopen'\n      | 'circleclose'\n      | 'dissolve'\n      | 'distance'\n      | 'horzopen'\n      | 'horzclose'\n      | 'vertopen'\n      | 'vertclose'\n      | 'none'\n    duration: number\n  }\n  audio_url?: string // 提取的音频URL\n  muted?: boolean // 是否静音\n}\n\ninterface AudioClip {\n  id: string\n  source_clip_id: string // 关联的视频片段ID\n  audio_url: string\n  start_time: number\n  end_time: number\n  duration: number\n  position: number\n  order: number\n  volume: number // 音量 0-1\n}\n\nconst props = defineProps<{\n  scenes: Scene[]\n  episodeId: string\n  dramaId: string\n  assets?: any[]\n}>()\n\nconst emit = defineEmits<{\n  (e: 'merge-completed', mergeId: number): void\n  (e: 'asset-deleted'): void\n}>()\n\n// 基础状态\nconst availableStoryboards = computed(() => {\n  const assets = (props.assets || [])\n    .filter((a) => {\n      const isValid = a.type === 'video' && a.url\n      return isValid\n    })\n    .map((a) => ({\n      id: `asset_${a.id}`,\n      storyboard_number: a.storyboard_num || a.id,\n      storyboard_num: a.storyboard_num,\n      storyboard_id: a.storyboard_id,\n      video_url: getVideoUrl(a), // 优先使用 local_path\n      duration: a.duration || 0,\n      name: a.name,\n      isAsset: true,\n      asset_id: a.id, // 使用 asset_id 字段名\n    }))\n    .sort((a, b) => {\n      // 优先按storyboard_num排序，如果没有则按storyboard_id排序，最后按asset id排序\n      const aNum = a.storyboard_num || a.storyboard_id || a.asset_id\n      const bNum = b.storyboard_num || b.storyboard_id || b.asset_id\n      return aNum - bNum\n    })\n  return assets\n})\nconst timelineClips = ref<TimelineClip[]>([])\nconst audioClips = ref<AudioClip[]>([])\nconst selectedClipId = ref<string | null>(null)\nconst selectedAudioClipId = ref<string | null>(null)\nconst previewPlayer = ref<HTMLVideoElement | null>(null)\nconst audioPlayer = ref<HTMLAudioElement | null>(null)\nconst timelineContainer = ref<HTMLElement | null>(null)\nconst showAudioTrack = ref(true) // 是否显示音频轨道\n\n// 时间线状态\nconst currentTime = ref(0)\nconst zoom = ref(1) // 缩放级别\nconst pixelsPerSecond = computed(() => 50 * zoom.value) // 每秒对应的像素数\nconst isPlaying = ref(false)\nconst playbackTimer = ref<number | null>(null)\n\n// 转场预览状态（必须在模板使用前定义）\nconst transitionState = ref({\n  active: false,\n  type: 'fade',\n  phase: 'in' as 'in' | 'out',\n  duration: 1.0,\n})\n\n// 导出状态\nconst merging = ref(false)\nconst serverMerging = ref(false)\nconst mergeProgress = ref(0)\nconst mergeDialogVisible = ref(false)\nconst mergeProgressDetail = ref<MergeProgress>({\n  phase: 'loading',\n  progress: 0,\n  message: '',\n})\n\n// 转场设置状态\nconst transitionDialogVisible = ref(false)\nconst editingTransitionClipId = ref<string | null>(null)\nconst editingTransition = ref({\n  type: 'fade' as\n    | 'fade'\n    | 'fadeblack'\n    | 'fadewhite'\n    | 'fadegrays'\n    | 'slideleft'\n    | 'slideright'\n    | 'slideup'\n    | 'slidedown'\n    | 'wipeleft'\n    | 'wiperight'\n    | 'wipeup'\n    | 'wipedown'\n    | 'circleopen'\n    | 'circleclose'\n    | 'dissolve'\n    | 'distance'\n    | 'horzopen'\n    | 'horzclose'\n    | 'vertopen'\n    | 'vertclose'\n    | 'none',\n  duration: 1.0,\n})\n\n// 计算总时长\nconst totalDuration = computed(() => {\n  if (timelineClips.value.length === 0) return 0\n  const lastClip = timelineClips.value[timelineClips.value.length - 1]\n  return lastClip ? lastClip.position + lastClip.duration : 0\n})\n\n// 工具函数\nconst formatTime = (seconds: number) => {\n  const mins = Math.floor(seconds / 60)\n  const secs = Math.floor(seconds % 60)\n  const ms = Math.floor((seconds % 1) * 10)\n  return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms}`\n}\n\nconst getSceneDesc = (scene: Scene) => {\n  const parts = []\n  if (scene.location) parts.push(scene.location)\n  if (scene.time) parts.push(scene.time)\n  return parts.join(' · ') || scene.description?.slice(0, 15) + '...' || '无描述'\n}\n\n// 预览相关\nconst currentPreviewUrl = computed(() => {\n  if (timelineClips.value.length === 0) return ''\n  // 根据当前时间找到应该播放的片段\n  const clip = timelineClips.value.find(\n    (c) => currentTime.value >= c.position && currentTime.value < c.position + c.duration,\n  )\n  return clip?.video_url || timelineClips.value[0]?.video_url || ''\n})\n\n// 当前音频URL\nconst currentAudioUrl = computed(() => {\n  if (audioClips.value.length === 0) return ''\n  // 根据当前时间找到应该播放的音频片段\n  const audioClip = audioClips.value.find(\n    (a) => currentTime.value >= a.position && currentTime.value < a.position + a.duration,\n  )\n  return audioClip?.audio_url || ''\n})\n\nconst previewScene = (scene: Scene) => {\n  if (previewPlayer.value) {\n    previewPlayer.value.src = scene.video_url\n    previewPlayer.value.play()\n  }\n}\n\nconst handlePreviewLoaded = () => {\n  // 视频加载完成后跳转到正确的时间点\n  if (previewPlayer.value) {\n    const clip = timelineClips.value.find(\n      (c) => currentTime.value >= c.position && currentTime.value < c.position + c.duration,\n    )\n    if (clip) {\n      const offsetInClip = currentTime.value - clip.position\n      previewPlayer.value.currentTime = clip.start_time + offsetInClip\n    }\n  }\n}\n\nconst handleAudioLoaded = () => {\n  // 音频加载完成后跳转到正确的时间点\n  if (audioPlayer.value && audioClips.value.length > 0) {\n    const audioClip = audioClips.value.find(\n      (a) => currentTime.value >= a.position && currentTime.value < a.position + a.duration,\n    )\n    if (audioClip) {\n      const offsetInClip = currentTime.value - audioClip.position\n      audioPlayer.value.currentTime = audioClip.start_time + offsetInClip\n    }\n  }\n}\n\nconst handleAudioEnded = () => {\n  // 音频自然结束，尝试播放下一个音频片段\n  const currentAudio = audioClips.value.find(\n    (a) => currentTime.value >= a.position && currentTime.value < a.position + a.duration,\n  )\n\n  if (currentAudio) {\n    const currentIndex = audioClips.value.findIndex((a) => a.id === currentAudio.id)\n    const nextAudio = audioClips.value[currentIndex + 1]\n\n    if (nextAudio && isPlaying.value) {\n      // 有下一个音频片段且正在播放，继续\n      // 时间线会自动更新到下一个片段\n    }\n  }\n}\n\nconst handlePreviewTimeUpdate = () => {\n  if (!isPlaying.value || !previewPlayer.value) return\n\n  // 找到当前播放的片段\n  const currentClip = timelineClips.value.find(\n    (c) => currentTime.value >= c.position && currentTime.value < c.position + c.duration,\n  )\n\n  if (!currentClip) {\n    pauseTimeline()\n    return\n  }\n\n  // 计算时间线上的当前位置\n  const videoTime = previewPlayer.value.currentTime\n  const clipOffset = videoTime - currentClip.start_time\n  currentTime.value = currentClip.position + clipOffset\n\n  // 检查是否播放到片段结尾（提前0.1秒检测，避免播放完才切换）\n  if (videoTime >= currentClip.end_time - 0.1) {\n    // 查找下一个片段\n    const currentIndex = timelineClips.value.findIndex((c) => c.id === currentClip.id)\n    const nextClip = timelineClips.value[currentIndex + 1]\n\n    if (nextClip) {\n      // 切换到下一个片段\n      switchToClip(nextClip)\n    } else {\n      // 没有下一个片段，停止播放\n      pauseTimeline()\n      currentTime.value = totalDuration.value\n    }\n  }\n}\n\nconst switchToClip = async (clip: TimelineClip) => {\n  if (!previewPlayer.value) return\n\n  // 获取转场配置\n  const transition = clip.transition\n  const hasTransition = transition && transition.type !== 'none'\n  const transitionDuration = hasTransition ? transition.duration * 1000 : 0\n\n  if (hasTransition) {\n    // 触发转场效果\n    transitionState.value = {\n      active: true,\n      type: transition.type,\n      phase: 'out',\n      duration: transition.duration,\n    }\n\n    // 等待转场动画完成一半\n    await new Promise((resolve) => setTimeout(resolve, transitionDuration / 2))\n  }\n\n  // 暂停当前播放，避免冲突\n  previewPlayer.value.pause()\n  if (audioPlayer.value) {\n    audioPlayer.value.pause()\n  }\n\n  // 切换视频源\n  currentTime.value = clip.position\n  previewPlayer.value.src = clip.video_url\n\n  // 同步切换音频源\n  if (audioClips.value.length > 0 && audioPlayer.value) {\n    const audioClip = audioClips.value.find(\n      (a) => clip.position >= a.position && clip.position < a.position + a.duration,\n    )\n    if (audioClip) {\n      audioPlayer.value.src = audioClip.audio_url\n    }\n  }\n\n  // 等待视频加载\n  try {\n    await new Promise((resolve, reject) => {\n      if (!previewPlayer.value) return reject()\n\n      const onCanPlay = () => {\n        previewPlayer.value?.removeEventListener('canplay', onCanPlay)\n        previewPlayer.value?.removeEventListener('error', onError)\n        resolve(undefined)\n      }\n\n      const onError = () => {\n        previewPlayer.value?.removeEventListener('canplay', onCanPlay)\n        previewPlayer.value?.removeEventListener('error', onError)\n        reject()\n      }\n\n      previewPlayer.value.addEventListener('canplay', onCanPlay)\n      previewPlayer.value.addEventListener('error', onError)\n    })\n\n    // 设置起始时间并播放\n    previewPlayer.value.currentTime = clip.start_time\n\n    if (hasTransition) {\n      // 切换到转场入场阶段\n      transitionState.value.phase = 'in'\n\n      // 等待转场剩余时间\n      setTimeout(() => {\n        transitionState.value.active = false\n      }, transitionDuration / 2)\n    }\n\n    if (isPlaying.value) {\n      await previewPlayer.value.play()\n\n      // 同步播放音频\n      if (audioClips.value.length > 0 && audioPlayer.value) {\n        const audioClip = audioClips.value.find(\n          (a) => clip.position >= a.position && clip.position < a.position + a.duration,\n        )\n        if (audioClip && audioPlayer.value.src) {\n          audioPlayer.value.currentTime = audioClip.start_time\n          audioPlayer.value.play().catch((err) => {\n            console.warn('音频播放失败:', err)\n          })\n        }\n      }\n    }\n  } catch (error) {\n    console.error('切换视频片段失败:', error)\n    transitionState.value.active = false\n    pauseTimeline()\n  }\n}\n\nconst handlePreviewEnded = () => {\n  // 视频自然结束，尝试播放下一个片段\n  const currentClip = timelineClips.value.find(\n    (c) => currentTime.value >= c.position && currentTime.value < c.position + c.duration,\n  )\n\n  if (currentClip) {\n    const currentIndex = timelineClips.value.findIndex((c) => c.id === currentClip.id)\n    const nextClip = timelineClips.value[currentIndex + 1]\n\n    if (nextClip) {\n      currentTime.value = nextClip.position\n      seekToTime(nextClip.position)\n    } else {\n      pauseTimeline()\n    }\n  }\n}\n\n// 时间线计算\nconst timelineWidth = computed(() => {\n  const duration = Math.max(totalDuration.value, 30)\n  const contentWidth = duration * pixelsPerSecond.value\n  const minContentWidth = 800 // 最小内容宽度\n  return 100 + Math.max(contentWidth, minContentWidth) + 100 // 100px左边距 + 100px右边距\n})\n\nconst playheadPosition = computed(() => {\n  return 100 + currentTime.value * pixelsPerSecond.value\n})\n\nconst timeRulerTicks = computed(() => {\n  const ticks = []\n  const duration = Math.max(totalDuration.value, 30)\n  const interval = zoom.value >= 1.5 ? 1 : zoom.value >= 0.5 ? 5 : 10\n\n  for (let i = 0; i <= duration; i += interval) {\n    ticks.push({\n      time: i,\n      position: 100 + i * pixelsPerSecond.value,\n      type: i % (interval * 2) === 0 ? 'major' : 'minor',\n    })\n  }\n  return ticks\n})\n\n// 片段样式计算\nconst getClipStyle = (clip: TimelineClip) => {\n  return {\n    left: 100 + clip.position * pixelsPerSecond.value + 'px',\n    width: clip.duration * pixelsPerSecond.value + 'px',\n  }\n}\n\n// 拖拽场景到时间线\nconst handleDragStart = (event: DragEvent, scene: Scene) => {\n  if (event.dataTransfer) {\n    event.dataTransfer.effectAllowed = 'copy'\n    event.dataTransfer.setData('scene', JSON.stringify(scene))\n  }\n}\n\nconst handleTrackDrop = (event: DragEvent) => {\n  event.preventDefault()\n  const sceneData = event.dataTransfer?.getData('scene')\n  if (!sceneData) return\n\n  const scene = JSON.parse(sceneData) as Scene\n\n  // 默认添加到末尾，不使用拖拽位置（避免产生空隙）\n  addClipToTimeline(scene)\n}\n\nconst getVideoDuration = (videoUrl: string): Promise<number> => {\n  return new Promise((resolve, reject) => {\n    const video = document.createElement('video')\n    video.preload = 'metadata'\n    video.src = videoUrl\n\n    video.onloadedmetadata = () => {\n      const duration = video.duration\n      video.remove()\n      resolve(duration)\n    }\n\n    video.onerror = () => {\n      video.remove()\n      reject(new Error('Failed to load video'))\n    }\n  })\n}\n\nconst addClipToTimeline = async (scene: Scene, insertAtPosition?: number) => {\n  // 获取视频真实时长\n  let videoDuration = scene.duration || 5\n  if (scene.video_url) {\n    try {\n      videoDuration = await getVideoDuration(scene.video_url)\n    } catch (error) {\n      console.warn('Failed to get video duration, using default or scene duration:', error)\n      videoDuration = scene.duration || 5\n    }\n  }\n\n  // 计算新片段的位置\n  let clipPosition: number\n  let insertAfterIndex: number | null = null\n\n  if (insertAtPosition !== undefined && timelineClips.value.length > 0) {\n    // 如果指定了插入位置,找到应该插入的位置\n    clipPosition = insertAtPosition\n  } else if (selectedClipId.value && timelineClips.value.length > 0) {\n    // 如果有选中的片段，插入到选中片段之后\n    const selectedIndex = timelineClips.value.findIndex((c) => c.id === selectedClipId.value)\n    if (selectedIndex !== -1) {\n      const selectedClip = timelineClips.value[selectedIndex]\n      clipPosition = selectedClip.position + selectedClip.duration\n      insertAfterIndex = selectedIndex\n    } else {\n      // 选中的片段不存在，添加到末尾\n      const lastClip = timelineClips.value[timelineClips.value.length - 1]\n      clipPosition = lastClip.position + lastClip.duration\n    }\n  } else {\n    // 默认添加到末尾（紧密连接）\n    if (timelineClips.value.length === 0) {\n      clipPosition = 0 // 第一个片段从0开始\n    } else {\n      // 添加到最后一个片段的结尾\n      const lastClip = timelineClips.value[timelineClips.value.length - 1]\n      clipPosition = lastClip.position + lastClip.duration\n    }\n  }\n\n  const newClip: TimelineClip = {\n    id: `clip_${Date.now()}_${scene.id}`,\n    storyboard_id: scene.storyboard_id,\n    storyboard_number: scene.storyboard_number,\n    video_url: scene.video_url,\n    asset_id: scene.asset_id, // 保存素材库ID\n    start_time: 0,\n    end_time: videoDuration,\n    duration: videoDuration,\n    position: clipPosition,\n    order: timelineClips.value.length,\n    transition: {\n      type: 'fade',\n      duration: 1.0,\n    },\n  }\n\n  // 如果是插入到中间，需要调整后续片段的位置\n  if (insertAfterIndex !== null && insertAfterIndex < timelineClips.value.length - 1) {\n    const newDuration = newClip.duration\n    // 将后续所有片段向后移动\n    for (let i = insertAfterIndex + 1; i < timelineClips.value.length; i++) {\n      timelineClips.value[i].position += newDuration\n    }\n  }\n\n  timelineClips.value.push(newClip)\n  timelineClips.value.sort((a, b) => a.position - b.position)\n  updateClipOrders()\n\n  // 选中新添加的片段\n  selectedClipId.value = newClip.id\n\n  const insertInfo = insertAfterIndex !== null ? '（已插入到选中片段后）' : ''\n  ElMessage.success(`已添加到时间线${insertInfo}`)\n}\n\n// 一键添加全部场景\nconst addAllScenesInOrder = async () => {\n  if (availableStoryboards.value.length === 0) {\n    ElMessage.warning('没有可用的场景')\n    return\n  }\n\n  // 按场景编号排序\n  const sortedScenes = [...availableStoryboards.value].sort((a, b) => a.storyboard_number - b.storyboard_number)\n\n  // 清空当前选中，让所有场景都添加到末尾\n  selectedClipId.value = null\n\n  // 批量添加（顺序添加以确保正确的时长）\n  for (const scene of sortedScenes) {\n    await addClipToTimeline(scene)\n  }\n\n  ElMessage.success(`已批量添加 ${sortedScenes.length} 个场景到时间线`)\n}\n\n// 删除素材\nconst deleteAsset = async (scene: any) => {\n  if (!scene.isAsset) {\n    ElMessage.warning('只能删除素材库中的视频')\n    return\n  }\n\n  try {\n    // 直接调用API删除\n    const { assetAPI } = await import('@/api/asset')\n    await assetAPI.deleteAsset(scene.asset_id)\n\n    ElMessage.success('删除成功')\n\n    // 通知父组件刷新素材列表\n    emit('asset-deleted')\n  } catch (error: any) {\n    console.error('删除素材失败:', error)\n    ElMessage.error(error.message || '删除失败')\n  }\n}\n\n// 转场相关方法\nconst getTransitionStyle = (clip: TimelineClip) => {\n  // 转场指示器显示在片段开始位置\n  return {\n    left: 100 + clip.position * pixelsPerSecond.value - 15 + 'px',\n  }\n}\n\nconst getTransitionLabel = (clip: TimelineClip) => {\n  if (!clip.transition || clip.transition.type === 'none') {\n    return '无'\n  }\n  const labels: Record<string, string> = {\n    fade: '淡入',\n    fadeblack: '黑场',\n    fadewhite: '白场',\n    fadegrays: '灰场',\n    slideleft: '左滑',\n    slideright: '右滑',\n    slideup: '上滑',\n    slidedown: '下滑',\n    wipeleft: '左擦',\n    wiperight: '右擦',\n    wipeup: '上擦',\n    wipedown: '下擦',\n    circleopen: '圆开',\n    circleclose: '圆关',\n    dissolve: '溶解',\n    distance: '距离',\n    horzopen: '水平开',\n    horzclose: '水平关',\n    vertopen: '垂直开',\n    vertclose: '垂直关',\n  }\n  return labels[clip.transition.type] || '转场'\n}\n\nconst openTransitionDialog = (clip: TimelineClip) => {\n  console.log('🎬 打开转场设置对话框:', {\n    clip_id: clip.id,\n    storyboard_id: clip.storyboard_id,\n    order: clip.order,\n    current_transition: clip.transition,\n  })\n  editingTransitionClipId.value = clip.id\n  editingTransition.value = {\n    type: clip.transition?.type || 'fade',\n    duration: clip.transition?.duration || 1.0,\n  }\n  transitionDialogVisible.value = true\n}\n\nconst applyTransition = () => {\n  const clip = timelineClips.value.find((c) => c.id === editingTransitionClipId.value)\n  if (clip) {\n    clip.transition = {\n      type: editingTransition.value.type,\n      duration: editingTransition.value.duration,\n    }\n    console.log('✅ 转场效果已设置:', {\n      clip_id: clip.id,\n      storyboard_id: clip.storyboard_id,\n      order: clip.order,\n      transition: clip.transition,\n    })\n    ElMessage.success('转场效果已设置')\n  } else {\n    console.error('❌ 未找到目标片段:', editingTransitionClipId.value)\n  }\n  transitionDialogVisible.value = false\n}\n\n// 选择和删除片段\nconst selectClip = (clip: TimelineClip) => {\n  selectedClipId.value = clip.id\n}\n\nconst removeClip = (clip: TimelineClip) => {\n  const index = timelineClips.value.findIndex((c) => c.id === clip.id)\n  if (index !== -1) {\n    timelineClips.value.splice(index, 1)\n    updateClipOrders()\n\n    // 同时移除关联的音频片段\n    const audioIndex = audioClips.value.findIndex((a) => a.source_clip_id === clip.id)\n    if (audioIndex !== -1) {\n      audioClips.value.splice(audioIndex, 1)\n      updateAudioClipOrders()\n    }\n  }\n}\n\nconst clearAllClips = () => {\n  if (timelineClips.value.length === 0) return\n\n  timelineClips.value = []\n  audioClips.value = []\n  selectedClipId.value = null\n  selectedAudioClipId.value = null\n  currentTime.value = 0\n  ElMessage.success('已清空轨道')\n}\n\nconst updateClipOrders = () => {\n  timelineClips.value.forEach((clip, index) => {\n    clip.order = index\n  })\n}\n\n// 音频片段管理\nconst extractAllAudio = async () => {\n  if (timelineClips.value.length === 0) {\n    ElMessage.warning('时间线上没有视频片段')\n    return\n  }\n\n  const loadingMessage = ElMessage.info({\n    message: '正在从视频中提取音频轨道，请稍候...',\n    duration: 0,\n  })\n\n  try {\n    // 清空现有音频\n    audioClips.value = []\n\n    // 收集所有视频URL\n    const videoUrls = timelineClips.value.map((clip) => clip.video_url)\n\n    // 调用后端API批量提取音频\n    const { audioAPI } = await import('@/api/audio')\n    const response = await audioAPI.batchExtractAudio(videoUrls)\n\n    if (!response.results || response.results.length === 0) {\n      throw new Error('音频提取失败，未返回结果')\n    }\n\n    // 为每个视频片段创建对应的音频片段\n    timelineClips.value.forEach((clip, index) => {\n      const extractedAudio = response.results[index]\n      if (!extractedAudio) {\n        console.warn(`视频片段 ${index} 未能提取音频`)\n        return\n      }\n\n      // 验证音频时长\n      const audioDuration = extractedAudio.duration\n      if (!audioDuration || audioDuration <= 0) {\n        console.error(`音频片段 ${index} 时长无效:`, audioDuration)\n        throw new Error(`音频片段 ${index + 1} 时长无效`)\n      }\n\n      console.log(`音频片段 ${index}:`, {\n        video_duration: clip.duration,\n        audio_duration: audioDuration,\n        video_position: clip.position,\n        video_url: clip.video_url,\n        audio_url: extractedAudio.audio_url,\n      })\n\n      const audioClip: AudioClip = {\n        id: `audio_${Date.now()}_${index}`,\n        source_clip_id: clip.id,\n        audio_url: extractedAudio.audio_url,\n        start_time: 0, // 音频从头开始播放\n        end_time: audioDuration, // 使用实际音频时长\n        duration: audioDuration, // 使用提取的音频时长\n        position: clip.position, // 和视频片段在时间轴上相同位置\n        order: index,\n        volume: 1.0,\n      }\n      audioClips.value.push(audioClip)\n    })\n\n    updateAudioClipOrders()\n    loadingMessage.close()\n    ElMessage.success(`已成功提取 ${audioClips.value.length} 个音频片段`)\n  } catch (error: any) {\n    console.error('提取音频失败:', error)\n    loadingMessage.close()\n    ElMessage.error(error.message || '音频提取失败，请重试')\n    // 清空部分提取的音频\n    audioClips.value = []\n  }\n}\n\nconst selectAudioClip = (audio: AudioClip) => {\n  selectedAudioClipId.value = audio.id\n  // 取消选中视频片段\n  selectedClipId.value = null\n}\n\nconst removeAudioClip = (audio: AudioClip) => {\n  const index = audioClips.value.findIndex((a) => a.id === audio.id)\n  if (index !== -1) {\n    audioClips.value.splice(index, 1)\n    updateAudioClipOrders()\n  }\n}\n\nconst updateAudioClipOrders = () => {\n  audioClips.value.forEach((clip, index) => {\n    clip.order = index\n  })\n}\n\n// 拖拽音频片段\nconst startDragAudioClip = (event: MouseEvent, audio: AudioClip) => {\n  if (dragState.value.isResizing) return\n\n  event.stopPropagation()\n  dragState.value = {\n    isDragging: true,\n    isResizing: false,\n    clipId: audio.id,\n    startX: event.clientX,\n    startPosition: audio.position,\n    startTime: 0,\n    originalDuration: audio.duration,\n  }\n\n  selectedAudioClipId.value = audio.id\n  document.addEventListener('mousemove', handleDragAudioMove)\n  document.addEventListener('mouseup', handleDragAudioEnd)\n}\n\nconst handleDragAudioMove = (event: MouseEvent) => {\n  if (!dragState.value.isDragging || !dragState.value.clipId) return\n\n  const audio = audioClips.value.find((a) => a.id === dragState.value.clipId)\n  if (!audio) return\n\n  const deltaX = event.clientX - dragState.value.startX\n  const deltaTime = deltaX / pixelsPerSecond.value\n  const newPosition = Math.max(0, dragState.value.startPosition + deltaTime)\n\n  audio.position = newPosition\n}\n\nconst handleDragAudioEnd = () => {\n  dragState.value.isDragging = false\n  dragState.value.clipId = null\n\n  document.removeEventListener('mousemove', handleDragAudioMove)\n  document.removeEventListener('mouseup', handleDragAudioEnd)\n\n  // 重新排序\n  audioClips.value.sort((a, b) => a.position - b.position)\n  updateAudioClipOrders()\n}\n\n// 调整音频片段大小\nconst startResizeAudioClip = (event: MouseEvent, audio: AudioClip, side: 'left' | 'right') => {\n  event.stopPropagation()\n\n  dragState.value = {\n    isDragging: false,\n    isResizing: true,\n    resizeSide: side,\n    clipId: audio.id,\n    startX: event.clientX,\n    startPosition: audio.position,\n    startTime: audio.start_time,\n    originalDuration: audio.duration,\n  }\n\n  selectedAudioClipId.value = audio.id\n  document.addEventListener('mousemove', handleResizeAudioMove)\n  document.addEventListener('mouseup', handleResizeAudioEnd)\n}\n\nconst handleResizeAudioMove = (event: MouseEvent) => {\n  if (!dragState.value.isResizing || !dragState.value.clipId) return\n\n  const audio = audioClips.value.find((a) => a.id === dragState.value.clipId)\n  if (!audio) return\n\n  const deltaX = event.clientX - dragState.value.startX\n  const deltaTime = deltaX / pixelsPerSecond.value\n\n  if (dragState.value.resizeSide === 'left') {\n    const newStartTime = Math.max(0, dragState.value.startTime + deltaTime)\n    const maxStartTime = dragState.value.startTime + dragState.value.originalDuration - 0.1\n\n    audio.start_time = Math.min(newStartTime, maxStartTime)\n    audio.position = dragState.value.startPosition + deltaTime\n    audio.duration = dragState.value.originalDuration - (audio.start_time - dragState.value.startTime)\n  } else {\n    const newDuration = Math.max(0.1, dragState.value.originalDuration + deltaTime)\n    const maxDuration = audio.end_time - audio.start_time\n\n    audio.duration = Math.min(newDuration, maxDuration)\n    audio.end_time = audio.start_time + audio.duration\n  }\n}\n\nconst handleResizeAudioEnd = () => {\n  dragState.value.isResizing = false\n  dragState.value.clipId = null\n\n  document.removeEventListener('mousemove', handleResizeAudioMove)\n  document.removeEventListener('mouseup', handleResizeAudioEnd)\n}\n\n// 拖拽和调整片段\ninterface DragState {\n  isDragging: boolean\n  isResizing: boolean\n  resizeSide?: 'left' | 'right'\n  clipId: string | null\n  startX: number\n  startPosition: number\n  startTime: number\n  originalDuration: number\n}\n\nconst dragState = ref<DragState>({\n  isDragging: false,\n  isResizing: false,\n  clipId: null,\n  startX: 0,\n  startPosition: 0,\n  startTime: 0,\n  originalDuration: 0,\n})\n\n// 拖拽移动片段位置\nconst startDragClip = (event: MouseEvent, clip: TimelineClip) => {\n  if (dragState.value.isResizing) return\n\n  event.stopPropagation()\n  dragState.value = {\n    isDragging: true,\n    isResizing: false,\n    clipId: clip.id,\n    startX: event.clientX,\n    startPosition: clip.position,\n    startTime: 0,\n    originalDuration: clip.duration,\n  }\n\n  selectedClipId.value = clip.id\n  document.addEventListener('mousemove', handleDragMove)\n  document.addEventListener('mouseup', handleDragEnd)\n}\n\nconst handleDragMove = (event: MouseEvent) => {\n  if (!dragState.value.clipId) return\n\n  const clip = timelineClips.value.find((c) => c.id === dragState.value.clipId)\n  if (!clip) return\n\n  if (dragState.value.isDragging) {\n    // 计算新位置\n    const deltaX = event.clientX - dragState.value.startX\n    const deltaTime = deltaX / pixelsPerSecond.value\n    let newPosition = Math.max(0, dragState.value.startPosition + deltaTime)\n\n    // 吸附到其他片段边缘\n    newPosition = snapToNearby(newPosition, clip.id, clip.duration)\n\n    clip.position = newPosition\n    updateClipOrders()\n  } else if (dragState.value.isResizing) {\n    handleResizeMove(event, clip)\n  }\n}\n\nconst handleDragEnd = () => {\n  dragState.value = {\n    isDragging: false,\n    isResizing: false,\n    clipId: null,\n    startX: 0,\n    startPosition: 0,\n    startTime: 0,\n    originalDuration: 0,\n  }\n\n  document.removeEventListener('mousemove', handleDragMove)\n  document.removeEventListener('mouseup', handleDragEnd)\n\n  // 重新排序片段并紧密连接\n  timelineClips.value.sort((a, b) => a.position - b.position)\n  compactClips()\n  updateClipOrders()\n}\n\n// 紧密排列所有片段（消除空隙）\nconst compactClips = () => {\n  let currentPosition = 0\n  for (const clip of timelineClips.value) {\n    clip.position = currentPosition\n    currentPosition += clip.duration\n  }\n}\n\n// 调整片段时长\nconst startResizeClip = (event: MouseEvent, clip: TimelineClip, side: 'left' | 'right') => {\n  event.stopPropagation()\n\n  dragState.value = {\n    isDragging: false,\n    isResizing: true,\n    resizeSide: side,\n    clipId: clip.id,\n    startX: event.clientX,\n    startPosition: clip.position,\n    startTime: side === 'left' ? clip.start_time : clip.end_time,\n    originalDuration: clip.duration,\n  }\n\n  selectedClipId.value = clip.id\n  document.addEventListener('mousemove', handleDragMove)\n  document.addEventListener('mouseup', handleDragEnd)\n}\n\nconst handleResizeMove = (event: MouseEvent, clip: TimelineClip) => {\n  const deltaX = event.clientX - dragState.value.startX\n  const deltaTime = deltaX / pixelsPerSecond.value\n\n  if (dragState.value.resizeSide === 'left') {\n    // 调整开始时间（不改变位置，只改变裁剪点）\n    const newStartTime = Math.max(0, dragState.value.startTime + deltaTime)\n    const maxStartTime = clip.end_time - 0.1 // 至少保留0.1秒\n\n    clip.start_time = Math.min(newStartTime, maxStartTime)\n    clip.duration = clip.end_time - clip.start_time\n\n    // 调整左边缘后需要重新紧密连接\n    const clipIndex = timelineClips.value.findIndex((c) => c.id === clip.id)\n    if (clipIndex > 0) {\n      // 调整前面片段的结束位置\n      compactClipsFromIndex(clipIndex)\n    }\n  } else {\n    // 调整结束时间\n    const scene = props.scenes.find((s) => s.id === clip.scene_id)\n    const maxDuration = scene?.duration || 10\n    const maxEndTime = clip.start_time + maxDuration\n\n    const newEndTime = Math.max(clip.start_time + 0.1, dragState.value.startTime + deltaTime)\n    clip.end_time = Math.min(newEndTime, maxEndTime)\n    clip.duration = clip.end_time - clip.start_time\n\n    // 调整右边缘后需要重新紧密连接后续片段\n    const clipIndex = timelineClips.value.findIndex((c) => c.id === clip.id)\n    if (clipIndex < timelineClips.value.length - 1) {\n      compactClipsFromIndex(clipIndex + 1)\n    }\n  }\n}\n\n// 从指定索引开始重新紧密排列片段\nconst compactClipsFromIndex = (startIndex: number) => {\n  if (startIndex >= timelineClips.value.length) return\n\n  for (let i = startIndex; i < timelineClips.value.length; i++) {\n    if (i === 0) {\n      timelineClips.value[i].position = 0\n    } else {\n      const prevClip = timelineClips.value[i - 1]\n      timelineClips.value[i].position = prevClip.position + prevClip.duration\n    }\n  }\n}\n\n// 吸附到附近片段\nconst snapToNearby = (position: number, clipId: string, duration: number): number => {\n  const snapThreshold = 5 / pixelsPerSecond.value // 5像素的吸附范围\n\n  for (const other of timelineClips.value) {\n    if (other.id === clipId) continue\n\n    const otherEnd = other.position + other.duration\n\n    // 吸附到前一个片段的结尾\n    if (Math.abs(position - otherEnd) < snapThreshold) {\n      return otherEnd\n    }\n\n    // 吸附到后一个片段的开头\n    if (Math.abs(position + duration - other.position) < snapThreshold) {\n      return other.position - duration\n    }\n  }\n\n  // 吸附到起点\n  if (position < snapThreshold) {\n    return 0\n  }\n\n  return position\n}\n\n// 缩放控制\nconst zoomIn = () => {\n  zoom.value = Math.min(zoom.value * 1.2, 3)\n}\n\nconst zoomOut = () => {\n  zoom.value = Math.max(zoom.value / 1.2, 0.3)\n}\n\nconst zoomReset = () => {\n  zoom.value = 1\n}\n\n// 播放头拖拽\nconst playheadDragState = ref({\n  isDragging: false,\n  startX: 0,\n  startTime: 0,\n})\n\nconst startDragPlayhead = (event: MouseEvent) => {\n  event.stopPropagation()\n  \n  playheadDragState.value = {\n    isDragging: true,\n    startX: event.clientX,\n    startTime: currentTime.value,\n  }\n  \n  // 暂停播放\n  if (isPlaying.value) {\n    pauseTimeline()\n  }\n  \n  document.addEventListener('mousemove', handlePlayheadDragMove)\n  document.addEventListener('mouseup', handlePlayheadDragEnd)\n}\n\nconst handlePlayheadDragMove = (event: MouseEvent) => {\n  if (!playheadDragState.value.isDragging) return\n  \n  const deltaX = event.clientX - playheadDragState.value.startX\n  const deltaTime = deltaX / pixelsPerSecond.value\n  const newTime = Math.max(0, Math.min(totalDuration.value, playheadDragState.value.startTime + deltaTime))\n  \n  seekToTime(newTime)\n}\n\nconst handlePlayheadDragEnd = () => {\n  playheadDragState.value.isDragging = false\n  \n  document.removeEventListener('mousemove', handlePlayheadDragMove)\n  document.removeEventListener('mouseup', handlePlayheadDragEnd)\n}\n\n// 时间线点击跳转\nconst clickTimeline = (event: MouseEvent) => {\n  if (dragState.value.isDragging || dragState.value.isResizing) return\n\n  const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()\n  const clickX = event.clientX - rect.left - 100\n  const newTime = Math.max(0, clickX / pixelsPerSecond.value)\n  seekToTime(newTime)\n}\n\nconst seekToTime = (time: number) => {\n  currentTime.value = time\n\n  // 找到对应时间的视频片段并播放\n  const clip = timelineClips.value.find((c) => time >= c.position && time < c.position + c.duration)\n\n  if (clip && previewPlayer.value) {\n    // 切换视频源（如果需要）\n    if (previewPlayer.value.src !== clip.video_url) {\n      previewPlayer.value.src = clip.video_url\n    }\n\n    // 跳转到片段内的对应时间\n    const offsetInClip = time - clip.position\n    previewPlayer.value.currentTime = clip.start_time + offsetInClip\n\n    if (isPlaying.value) {\n      previewPlayer.value.play()\n    }\n  }\n\n  // 同步音频播放器\n  if (audioClips.value.length > 0 && audioPlayer.value) {\n    const audioClip = audioClips.value.find((a) => time >= a.position && time < a.position + a.duration)\n\n    if (audioClip) {\n      // 切换音频源（如果需要）\n      if (audioPlayer.value.src !== audioClip.audio_url) {\n        audioPlayer.value.src = audioClip.audio_url\n      }\n\n      // 跳转到音频片段内的对应时间\n      const offsetInAudioClip = time - audioClip.position\n      audioPlayer.value.currentTime = audioClip.start_time + offsetInAudioClip\n\n      if (isPlaying.value) {\n        audioPlayer.value.play().catch((err) => {\n          console.warn('音频播放失败:', err)\n        })\n      }\n    } else {\n      // 当前位置没有音频，暂停音频播放器\n      audioPlayer.value.pause()\n    }\n  }\n}\n\n// 播放控制\nconst playTimeline = () => {\n  if (timelineClips.value.length === 0) {\n    ElMessage.warning('时间线中没有视频片段')\n    return\n  }\n\n  isPlaying.value = true\n\n  // 找到当前时间对应的视频片段\n  const clip = timelineClips.value.find(\n    (c) => currentTime.value >= c.position && currentTime.value < c.position + c.duration,\n  )\n\n  if (clip && previewPlayer.value) {\n    if (previewPlayer.value.src !== clip.video_url) {\n      previewPlayer.value.src = clip.video_url\n    }\n    const offsetInClip = currentTime.value - clip.position\n    previewPlayer.value.currentTime = clip.start_time + offsetInClip\n    previewPlayer.value.play()\n  } else if (timelineClips.value[0]) {\n    // 如果当前时间超出范围，从头开始播放\n    currentTime.value = 0\n    seekToTime(0)\n    previewPlayer.value?.play()\n  }\n\n  // 同时播放音频（如果有）\n  if (audioClips.value.length > 0 && audioPlayer.value) {\n    const audioClip = audioClips.value.find(\n      (a) => currentTime.value >= a.position && currentTime.value < a.position + a.duration,\n    )\n\n    if (audioClip) {\n      if (audioPlayer.value.src !== audioClip.audio_url) {\n        audioPlayer.value.src = audioClip.audio_url\n      }\n      const offsetInAudioClip = currentTime.value - audioClip.position\n      audioPlayer.value.currentTime = audioClip.start_time + offsetInAudioClip\n      audioPlayer.value.play().catch((err) => {\n        console.warn('音频播放失败:', err)\n      })\n    }\n  }\n}\n\nconst pauseTimeline = () => {\n  isPlaying.value = false\n  if (previewPlayer.value) {\n    previewPlayer.value.pause()\n  }\n  // 同时暂停音频\n  if (audioPlayer.value) {\n    audioPlayer.value.pause()\n  }\n}\n\nconst togglePlay = () => {\n  if (isPlaying.value) {\n    pauseTimeline()\n  } else {\n    playTimeline()\n  }\n}\n\n// 键盘快捷键\nconst handleKeyPress = (event: KeyboardEvent) => {\n  // 如果在输入框中，不处理快捷键\n  if ((event.target as HTMLElement).tagName === 'INPUT') return\n\n  switch (event.code) {\n    case 'Space':\n      event.preventDefault()\n      if (isPlaying.value) {\n        pauseTimeline()\n      } else {\n        playTimeline()\n      }\n      break\n    case 'Delete':\n    case 'Backspace':\n      if (selectedClipId.value) {\n        event.preventDefault()\n        const clip = timelineClips.value.find((c) => c.id === selectedClipId.value)\n        if (clip) removeClip(clip)\n      }\n      break\n    case 'ArrowLeft':\n      event.preventDefault()\n      seekToTime(Math.max(0, currentTime.value - 1))\n      break\n    case 'ArrowRight':\n      event.preventDefault()\n      seekToTime(Math.min(totalDuration.value, currentTime.value + 1))\n      break\n    case 'Home':\n      event.preventDefault()\n      seekToTime(0)\n      break\n    case 'End':\n      event.preventDefault()\n      seekToTime(totalDuration.value)\n      break\n  }\n}\n\n// 生命周期管理\nonMounted(() => {\n  document.addEventListener('keydown', handleKeyPress)\n})\n\nonUnmounted(() => {\n  document.removeEventListener('keydown', handleKeyPress)\n  document.removeEventListener('mousemove', handleDragMove)\n  document.removeEventListener('mouseup', handleDragEnd)\n  document.removeEventListener('mousemove', handlePlayheadDragMove)\n  document.removeEventListener('mouseup', handlePlayheadDragEnd)\n})\n\n// 进度显示辅助函数\nconst getPhaseType = (phase: string) => {\n  switch (phase) {\n    case 'loading':\n      return 'info'\n    case 'processing':\n      return 'warning'\n    case 'encoding':\n      return 'warning'\n    case 'completed':\n      return 'success'\n    default:\n      return 'info'\n  }\n}\n\nconst getPhaseText = (phase: string) => {\n  switch (phase) {\n    case 'loading':\n      return '初始化'\n    case 'processing':\n      return '处理中'\n    case 'encoding':\n      return '编码中'\n    case 'completed':\n      return '完成'\n    default:\n      return '准备中'\n  }\n}\n\n// 导出功能\nconst handleExport = async () => {\n  if (timelineClips.value.length === 0) {\n    ElMessage.warning('请至少添加一个视频片段')\n    return\n  }\n\n  try {\n    // 计算总视频大小（粗略估算）\n    const totalSize = timelineClips.value.length * 20 // 假设每个片段约20MB\n    const estimatedTime = Math.ceil(totalSize / 50) // 每50MB约1分钟\n\n    await ElMessageBox.confirm(\n      `即将在浏览器中合并 ${timelineClips.value.length} 个视频片段。\\n\\n` +\n        `预计处理时间：${estimatedTime}-${estimatedTime + 1} 分钟\\n` +\n        `预计内存占用：约 ${Math.round(totalSize * 1.5)}MB\\n\\n` +\n        `处理期间请勿关闭页面。`,\n      '确认导出',\n      {\n        confirmButtonText: '开始合并',\n        cancelButtonText: '取消',\n        type: 'warning',\n        dangerouslyUseHTMLString: true,\n      },\n    )\n\n    mergeDialogVisible.value = true\n    merging.value = true\n\n    // 初始化FFmpeg\n    await videoMerger.initialize((progress) => {\n      mergeProgress.value = progress\n    })\n\n    // 准备视频片段数据（包含转场信息）\n    const clips = timelineClips.value.map((clip) => ({\n      url: clip.video_url,\n      startTime: clip.start_time,\n      endTime: clip.end_time,\n      duration: clip.end_time - clip.start_time,\n      transition: clip.transition,\n    }))\n\n    // 执行合并\n    const mergedBlob = await videoMerger.mergeVideos(clips)\n\n    // 下载合并后的视频\n    const url = URL.createObjectURL(mergedBlob)\n    const a = document.createElement('a')\n    a.href = url\n    a.download = `merged_video_${Date.now()}.mp4`\n    document.body.appendChild(a)\n    a.click()\n    document.body.removeChild(a)\n    URL.revokeObjectURL(url)\n\n    ElMessage.success('视频合并完成，已开始下载！')\n    mergeDialogVisible.value = false\n  } catch (error: any) {\n    if (error !== 'cancel') {\n      console.error('视频合并失败:', error)\n      ElMessage.error(error.message || '视频合并失败')\n    }\n  } finally {\n    merging.value = false\n  }\n}\n\n// 提交时间线数据到后端进行合成\n// 浏览器端FFmpeg合成\nconst mergeVideoInBrowser = async () => {\n  if (timelineClips.value.length === 0) {\n    ElMessage.warning('时间线上没有视频片段')\n    return\n  }\n\n  try {\n    await ElMessageBox.confirm(\n      '将在浏览器中使用FFmpeg合成视频。\\n注意：处理时间较长，且会占用浏览器资源，请勿关闭页面。\\n适合少量视频场景（1-5个）。\\n是否继续？',\n      '浏览器合成视频',\n      {\n        confirmButtonText: '确定',\n        cancelButtonText: '取消',\n        type: 'warning',\n      },\n    )\n\n    merging.value = true\n    mergeProgress.value = 0\n\n    ElMessage.info('开始加载FFmpeg引擎...')\n\n    // 准备剪辑数据\n    const clips = timelineClips.value.map((clip) => ({\n      url: clip.video_url,\n      startTime: clip.start_time,\n      endTime: clip.end_time,\n    }))\n\n    // 使用FFmpeg合成\n    ElMessage.info('正在合成视频，请稍候...')\n    const mergedBlob = await trimAndMergeVideos(clips, (progress) => {\n      mergeProgress.value = Math.round(progress)\n    })\n\n    // 创建下载链接\n    const url = URL.createObjectURL(mergedBlob)\n    const link = document.createElement('a')\n    link.href = url\n    link.download = `episode_${props.episodeId}_merged.mp4`\n    document.body.appendChild(link)\n    link.click()\n    document.body.removeChild(link)\n    URL.revokeObjectURL(url)\n\n    ElMessage.success('视频合成完成并已下载！')\n    emit('merge-completed', 0)\n  } catch (error: any) {\n    if (error !== 'cancel') {\n      ElMessage.error({\n        message: `合成失败: ${error.message || '未知错误'}。请检查控制台或尝试服务器合成`,\n        duration: 5000,\n      })\n    }\n  } finally {\n    merging.value = false\n    mergeProgress.value = 0\n  }\n}\n\n// 服务器端合成\nconst submitTimelineForMerge = async () => {\n  if (timelineClips.value.length === 0) {\n    ElMessage.warning('时间线上没有视频片段')\n    return\n  }\n\n  try {\n    await ElMessageBox.confirm(\n      '将根据时间线编排的顺序和转场效果合成最终视频。\\n注意：未生成视频的场景将被跳过，只合成已有视频的场景。\\n适合大量场景合成。\\n是否继续？',\n      '服务器合成视频',\n      {\n        confirmButtonText: '确定',\n        cancelButtonText: '取消',\n        type: 'warning',\n        dangerouslyUseHTMLString: false,\n      },\n    )\n\n    serverMerging.value = true\n\n    // 准备时间线数据\n    const timelineData = {\n      episode_id: props.episodeId,\n      clips: timelineClips.value.map((clip, index) => {\n        console.log(`📹 片段 ${index}:`, {\n          storyboard_id: clip.storyboard_id,\n          asset_id: clip.asset_id,\n          transition: clip.transition,\n        })\n        return {\n          storyboard_id: String(clip.storyboard_id),\n          asset_id: clip.asset_id, // 包含素材库ID\n          order: index,\n          start_time: clip.start_time,\n          end_time: clip.end_time,\n          duration: clip.duration,\n          transition: clip.transition || { type: 'none', duration: 0 },\n        }\n      }),\n    }\n    console.log('📤 提交时间线数据:', JSON.stringify(timelineData, null, 2))\n\n    // 调用后端API\n    const { dramaAPI } = await import('@/api/drama')\n    const result = await dramaAPI.finalizeEpisode(props.episodeId, timelineData)\n\n    // 如果有跳过的场景，显示警告\n    if (result.warning) {\n      ElMessage.warning({\n        message: result.warning,\n        duration: 5000,\n      })\n    } else {\n      ElMessage.success('视频合成任务已提交，正在后台处理...')\n    }\n\n    emit('merge-completed', result.merge_id || 0)\n  } catch (error: any) {\n    if (error !== 'cancel') {\n      console.error('提交合成任务失败:', error)\n      ElMessage.error(error.response?.data?.message || '提交失败')\n    }\n  } finally {\n    serverMerging.value = false\n  }\n}\n\n// 暴露方法供父组件调用\nconst updateClipsByStoryboardId = (storyboardId: string | number, newVideoUrl: string) => {\n  console.log('=== updateClipsByStoryboardId 调用 ===')\n  console.log('目标 storyboard_id:', storyboardId, '类型:', typeof storyboardId)\n  console.log('新视频 URL:', newVideoUrl)\n  console.log('当前时间线片段数量:', timelineClips.value.length)\n\n  let updated = false\n  const targetId = String(storyboardId) // 统一转换为字符串进行比较\n\n  timelineClips.value.forEach((clip, index) => {\n    console.log(`片段 ${index}: storyboard_id=${clip.storyboard_id} (类型: ${typeof clip.storyboard_id})`)\n    if (String(clip.storyboard_id) === targetId) {\n      console.log(`✅ 匹配成功！更新片段 ${index} 的视频URL`)\n      console.log('  旧URL:', clip.video_url)\n      console.log('  新URL:', newVideoUrl)\n      clip.video_url = newVideoUrl\n      updated = true\n    }\n  })\n\n  if (updated) {\n    console.log('✅ 时间线视频已更新')\n    ElMessage.success('时间线中的视频已自动更新')\n  } else {\n    console.log('⚠️ 没有找到匹配的时间线片段')\n  }\n}\n\ndefineExpose({\n  updateClipsByStoryboardId,\n})\n</script>\n\n<style scoped lang=\"scss\">\n.video-timeline-editor {\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  background: var(--bg-primary);\n  color: var(--text-primary);\n\n  .editor-toolbar {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    padding: 10px 16px;\n    background: var(--bg-secondary);\n    border: 1px solid var(--border-primary);\n\n    .toolbar-left {\n      display: flex;\n      align-items: center;\n      gap: 16px;\n\n      .time-display {\n        font-family: 'Courier New', monospace;\n        font-size: 14px;\n        color: var(--text-secondary);\n        min-width: 160px;\n      }\n    }\n  }\n\n  .editor-workspace {\n    display: flex;\n    flex: 1;\n    overflow: hidden;\n\n    .preview-panel {\n      flex: 0 0 500px;\n      display: flex;\n      flex-direction: column;\n      background: var(--bg-card);\n      border: 1px solid var(--border-primary);\n\n      .video-preview {\n        flex: 1;\n        position: relative;\n        background: #000;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n\n        video {\n          max-width: 100%;\n          max-height: 100%;\n          object-fit: contain;\n        }\n\n        .preview-overlay {\n          position: absolute;\n          top: 0;\n          left: 0;\n          right: 0;\n          bottom: 0;\n          display: flex;\n          align-items: center;\n          justify-content: center;\n          background: var(--bg-secondary);\n        }\n\n        .video-play-overlay {\n          position: absolute;\n          top: 0;\n          left: 0;\n          right: 0;\n          bottom: 0;\n          display: flex;\n          align-items: center;\n          justify-content: center;\n          background: rgba(0, 0, 0, 0.3);\n          cursor: pointer;\n          transition: opacity 0.3s ease;\n          z-index: 5;\n\n          .el-icon {\n            color: white;\n            filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5));\n          }\n\n          &.hidden {\n            opacity: 0;\n          }\n\n          &:hover {\n            background: rgba(0, 0, 0, 0.4);\n          }\n        }\n\n        .transition-overlay {\n          position: absolute;\n          top: 0;\n          left: 0;\n          right: 0;\n          bottom: 0;\n          pointer-events: none;\n          z-index: 10;\n        }\n\n        // 淡入淡出效果\n        .transition-fade.transition-out {\n          background: black;\n          animation: fadeOut forwards;\n        }\n        .transition-fade.transition-in {\n          background: black;\n          animation: fadeIn forwards;\n        }\n\n        // 黑场过渡\n        .transition-fadeblack.transition-out {\n          background: black;\n          animation: fadeOut forwards;\n        }\n        .transition-fadeblack.transition-in {\n          background: black;\n          animation: fadeIn forwards;\n        }\n\n        // 白场过渡\n        .transition-fadewhite.transition-out {\n          background: white;\n          animation: fadeOut forwards;\n        }\n        .transition-fadewhite.transition-in {\n          background: white;\n          animation: fadeIn forwards;\n        }\n\n        // 左滑\n        .transition-slideleft.transition-out {\n          background: black;\n          animation: slideLeftOut forwards;\n        }\n        .transition-slideleft.transition-in {\n          background: black;\n          animation: slideLeftIn forwards;\n        }\n\n        // 右滑\n        .transition-slideright.transition-out {\n          background: black;\n          animation: slideRightOut forwards;\n        }\n        .transition-slideright.transition-in {\n          background: black;\n          animation: slideRightIn forwards;\n        }\n\n        // 上滑\n        .transition-slideup.transition-out {\n          background: black;\n          animation: slideUpOut forwards;\n        }\n        .transition-slideup.transition-in {\n          background: black;\n          animation: slideUpIn forwards;\n        }\n\n        // 下滑\n        .transition-slidedown.transition-out {\n          background: black;\n          animation: slideDownOut forwards;\n        }\n        .transition-slidedown.transition-in {\n          background: black;\n          animation: slideDownIn forwards;\n        }\n\n        @keyframes fadeOut {\n          from {\n            opacity: 0;\n          }\n          to {\n            opacity: 1;\n          }\n        }\n\n        @keyframes fadeIn {\n          from {\n            opacity: 1;\n          }\n          to {\n            opacity: 0;\n          }\n        }\n\n        @keyframes slideLeftOut {\n          from {\n            transform: translateX(100%);\n          }\n          to {\n            transform: translateX(0);\n          }\n        }\n\n        @keyframes slideLeftIn {\n          from {\n            transform: translateX(0);\n          }\n          to {\n            transform: translateX(-100%);\n          }\n        }\n\n        @keyframes slideRightOut {\n          from {\n            transform: translateX(-100%);\n          }\n          to {\n            transform: translateX(0);\n          }\n        }\n\n        @keyframes slideRightIn {\n          from {\n            transform: translateX(0);\n          }\n          to {\n            transform: translateX(100%);\n          }\n        }\n\n        @keyframes slideUpOut {\n          from {\n            transform: translateY(100%);\n          }\n          to {\n            transform: translateY(0);\n          }\n        }\n\n        @keyframes slideUpIn {\n          from {\n            transform: translateY(0);\n          }\n          to {\n            transform: translateY(-100%);\n          }\n        }\n\n        @keyframes slideDownOut {\n          from {\n            transform: translateY(-100%);\n          }\n          to {\n            transform: translateY(0);\n          }\n        }\n\n        @keyframes slideDownIn {\n          from {\n            transform: translateY(0);\n          }\n          to {\n            transform: translateY(100%);\n          }\n        }\n      }\n\n      .preview-controls {\n        padding: 12px 16px;\n        background: var(--bg-secondary);\n        border: 1px solid var(--border-primary);\n      }\n    }\n\n    .media-library {\n      flex: 1;\n      display: flex;\n      flex-direction: column;\n      background: var(--bg-card);\n      overflow: hidden;\n\n      .library-header {\n        display: flex;\n        justify-content: space-between;\n        align-items: center;\n        padding: 12px 16px;\n        background: var(--bg-secondary);\n        border: 1px solid var(--border-primary);\n\n        .header-left {\n          display: flex;\n          align-items: center;\n          gap: 12px;\n\n          h4 {\n            margin: 0;\n            font-size: 14px;\n            font-weight: 500;\n            color: var(--text-primary);\n          }\n\n          span {\n            font-size: 12px;\n            color: var(--text-muted);\n          }\n        }\n      }\n\n      .media-grid {\n        max-height: 450px;\n        overflow-y: auto;\n        padding: 12px;\n        display: grid;\n        grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));\n        gap: 12px;\n        align-content: start;\n\n        // 自定义滚动条样式\n        &::-webkit-scrollbar {\n          width: 8px;\n        }\n\n        &::-webkit-scrollbar-track {\n          background: var(--bg-secondary);\n          border-radius: 4px;\n        }\n\n        &::-webkit-scrollbar-thumb {\n          background: var(--border-secondary);\n          border-radius: 4px;\n\n          &:hover {\n            background: var(--border-primary);\n          }\n        }\n\n        .media-item {\n          position: relative;\n          background: var(--bg-secondary);\n          border-radius: 6px;\n          overflow: hidden;\n          cursor: move;\n          border: 1px solid var(--border-primary);\n          transition: all 0.3s;\n\n          &:hover {\n            border-color: var(--el-color-primary);\n            transform: translateY(-2px);\n            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);\n          }\n\n          .delete-btn {\n            position: absolute;\n            top: 4px;\n            right: 4px;\n            z-index: 10;\n            opacity: 0;\n            transition: opacity 0.3s;\n          }\n\n          &:hover .delete-btn {\n            opacity: 1;\n          }\n\n          .media-thumbnail {\n            position: relative;\n            width: 100%;\n            aspect-ratio: 16/9;\n            background: var(--bg-card-hover);\n            cursor: pointer;\n\n            video {\n              width: 100%;\n              height: 100%;\n              object-fit: cover;\n              pointer-events: none;\n            }\n\n            .media-duration {\n              position: absolute;\n              bottom: 4px;\n              right: 4px;\n              padding: 2px 6px;\n              background: rgba(0, 0, 0, 0.8);\n              color: white;\n              font-size: 11px;\n              border-radius: 3px;\n              z-index: 1;\n            }\n\n            .media-overlay {\n              position: absolute;\n              top: 0;\n              left: 0;\n              right: 0;\n              bottom: 0;\n              display: flex;\n              align-items: center;\n              justify-content: center;\n              background: rgba(0, 0, 0, 0.6);\n              opacity: 0;\n              transition: opacity 0.2s;\n              z-index: 2;\n\n              .add-to-timeline-btn {\n                transform: translateY(10px);\n                transition: transform 0.2s;\n              }\n            }\n\n            &:hover .media-overlay {\n              opacity: 1;\n\n              .add-to-timeline-btn {\n                transform: translateY(0);\n              }\n            }\n          }\n\n          .media-info {\n            padding: 8px;\n\n            .media-title {\n              font-size: 12px;\n              font-weight: 500;\n              color: var(--text-primary);\n              margin-bottom: 4px;\n            }\n\n            .media-desc {\n              font-size: 11px;\n              color: var(--text-muted);\n              white-space: nowrap;\n              overflow: hidden;\n              text-overflow: ellipsis;\n            }\n          }\n        }\n      }\n    }\n  }\n\n  .timeline-panel {\n    flex: 0 0 280px;\n    display: flex;\n    flex-direction: column;\n    background: var(--bg-card);\n    border: 1px solid var(--border-primary);\n\n    .timeline-header {\n      display: flex;\n      justify-content: space-between;\n      align-items: center;\n      padding: 8px 12px;\n      background: var(--bg-secondary);\n      border: 1px solid var(--border-primary);\n\n      .zoom-controls {\n        display: flex;\n        align-items: center;\n        gap: 8px;\n\n        .zoom-level {\n          font-size: 12px;\n          color: var(--text-muted);\n          min-width: 50px;\n          text-align: right;\n        }\n      }\n    }\n\n    .timeline-container {\n      flex: 1;\n      position: relative;\n      overflow-x: auto;\n      overflow-y: hidden;\n      background: var(--bg-primary);\n\n      .timeline-ruler {\n        height: 30px;\n        background: var(--bg-card);\n        border: 1px solid var(--border-primary);\n        position: relative;\n\n        .ruler-tick {\n          position: absolute;\n          top: 0;\n          bottom: 0;\n\n          .tick-mark {\n            width: 1px;\n            background: var(--border-secondary);\n\n            &.major {\n              height: 20px;\n              background: var(--border-primary);\n            }\n\n            &.minor {\n              height: 10px;\n              margin-top: 10px;\n            }\n          }\n\n          .tick-label {\n            position: absolute;\n            top: 2px;\n            left: 4px;\n            font-size: 10px;\n            color: var(--text-muted);\n            font-family: 'Courier New', monospace;\n          }\n        }\n      }\n\n      .playhead {\n        position: absolute;\n        top: 0;\n        bottom: 0;\n        z-index: 100;\n        pointer-events: none;\n\n        .playhead-line {\n          width: 2px;\n          height: 100%;\n          background: var(--accent);\n          box-shadow: 0 0 8px rgba(14, 165, 233, 0.6);\n          pointer-events: auto;\n          cursor: ew-resize;\n        }\n\n        .playhead-handle {\n          position: absolute;\n          top: 0;\n          left: -6px;\n          width: 14px;\n          height: 14px;\n          background: var(--accent);\n          border-radius: 50%;\n          border: 2px solid var(--bg-card);\n          pointer-events: auto;\n          cursor: ew-resize;\n          transition: transform 0.2s ease;\n\n          &:hover {\n            transform: scale(1.2);\n          }\n        }\n      }\n\n      .timeline-track {\n        position: relative;\n        height: 80px;\n        background: var(--bg-secondary);\n        border: 1px solid var(--border-primary);\n\n        .track-label {\n          position: absolute;\n          left: 0;\n          top: 0;\n          bottom: 0;\n          width: 100px;\n          display: flex;\n          align-items: center;\n          padding-left: 12px;\n          font-size: 12px;\n          color: var(--text-secondary);\n          background: var(--bg-card);\n          border: 1px solid var(--border-primary);\n          z-index: 50;\n        }\n\n        .track-clips {\n          position: relative;\n          height: 100%;\n          padding-left: 100px;\n\n          .track-clip {\n            position: absolute;\n            top: 8px;\n            bottom: 8px;\n            background: var(--accent);\n            border-radius: 4px;\n            border: 2px solid transparent;\n            cursor: move;\n            transition: all 0.15s;\n            overflow: hidden;\n\n            &:hover {\n              border-color: var(--accent-hover);\n              box-shadow: var(--shadow-md);\n            }\n\n            &.selected {\n              border-color: var(--accent);\n              box-shadow: var(--shadow-glow);\n            }\n\n            .clip-content {\n              display: flex;\n              align-items: center;\n              height: 100%;\n              padding: 4px 8px;\n              gap: 8px;\n\n              .clip-thumbnail {\n                width: 60px;\n                height: 100%;\n                background: var(--bg-card-hover);\n                border-radius: 3px;\n                overflow: hidden;\n                flex-shrink: 0;\n\n                video {\n                  width: 100%;\n                  height: 100%;\n                  object-fit: cover;\n                  pointer-events: none;\n                }\n              }\n\n              .clip-info {\n                flex: 1;\n                min-width: 0;\n\n                .clip-title {\n                  font-size: 11px;\n                  font-weight: 500;\n                  color: var(--text-inverse);\n                  margin-bottom: 2px;\n                  white-space: nowrap;\n                  overflow: hidden;\n                  text-overflow: ellipsis;\n                }\n\n                .clip-duration {\n                  font-size: 10px;\n                  color: var(--text-inverse);\n                  opacity: 0.8;\n                }\n              }\n            }\n\n            .clip-resize-left,\n            .clip-resize-right {\n              position: absolute;\n              top: 0;\n              bottom: 0;\n              width: 8px;\n              cursor: ew-resize;\n              z-index: 10;\n\n              &:hover {\n                background: rgba(52, 152, 219, 0.3);\n              }\n            }\n\n            .clip-resize-left {\n              left: 0;\n            }\n\n            .clip-resize-right {\n              right: 0;\n            }\n\n            .clip-remove {\n              position: absolute;\n              top: 4px;\n              right: 4px;\n              width: 18px;\n              height: 18px;\n              background: rgba(0, 0, 0, 0.6);\n              border-radius: 50%;\n              display: flex;\n              align-items: center;\n              justify-content: center;\n              cursor: pointer;\n              opacity: 0;\n              transition: opacity 0.2s;\n\n              &:hover {\n                background: var(--error);\n              }\n            }\n\n            &:hover .clip-remove {\n              opacity: 1;\n            }\n          }\n\n          .transition-indicator {\n            position: absolute;\n            top: 50%;\n            transform: translateY(-50%);\n            width: 30px;\n            height: 30px;\n            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n            border-radius: 50%;\n            display: flex;\n            flex-direction: column;\n            align-items: center;\n            justify-content: center;\n            cursor: pointer;\n            z-index: 60;\n            border: 2px solid #1e1e1e;\n            box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);\n            transition: all 0.2s;\n\n            &:hover {\n              transform: translateY(-50%) scale(1.2);\n              box-shadow: 0 4px 12px rgba(102, 126, 234, 0.6);\n            }\n\n            .el-icon {\n              font-size: 14px;\n              color: white;\n            }\n\n            .transition-label {\n              position: absolute;\n              top: 100%;\n              margin-top: 4px;\n              font-size: 10px;\n              color: var(--text-secondary);\n              white-space: nowrap;\n              background: rgba(0, 0, 0, 0.8);\n              padding: 2px 6px;\n              border-radius: 3px;\n              pointer-events: none;\n              opacity: 0;\n              transition: opacity 0.2s;\n            }\n\n            &:hover .transition-label {\n              opacity: 1;\n            }\n          }\n        }\n      }\n\n      // 音频轨道特殊样式\n      .audio-track {\n        .track-label {\n          display: flex;\n          align-items: center;\n          justify-content: space-between;\n          padding-right: 8px;\n\n          .el-button {\n            color: var(--text-muted);\n\n            &:hover {\n              color: var(--accent);\n            }\n          }\n        }\n\n        .audio-clip {\n          background: #7c3aed;\n\n          &:hover {\n            border-color: #a78bfa;\n            box-shadow: var(--shadow-md);\n          }\n\n          &.selected {\n            border-color: #8b5cf6;\n            box-shadow: var(--shadow-glow);\n          }\n\n          .audio-waveform {\n            width: 60px;\n            height: 100%;\n            background: linear-gradient(135deg, #8b5cf6 0%, var(--accent) 100%);\n            border-radius: 3px;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            flex-shrink: 0;\n\n            .el-icon {\n              font-size: 24px;\n              color: rgba(255, 255, 255, 0.8);\n            }\n          }\n        }\n      }\n    }\n  }\n\n  .merge-progress-container {\n    padding: 20px 0;\n\n    .progress-info {\n      margin-bottom: 20px;\n\n      .progress-phase {\n        margin-bottom: 8px;\n      }\n\n      .progress-message {\n        font-size: 14px;\n        color: var(--text-secondary);\n        font-weight: 500;\n      }\n    }\n\n    .progress-tips {\n      margin-top: 20px;\n      padding: 12px;\n      background: var(--bg-secondary);\n      border-radius: 6px;\n\n      p {\n        margin: 0;\n        display: flex;\n        align-items: center;\n        gap: 8px;\n        font-size: 13px;\n        color: var(--text-secondary);\n\n        .el-icon {\n          font-size: 16px;\n        }\n      }\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "web/src/locales/en-US.ts",
    "content": "export default {\n  nav: {\n    home: 'Home',\n    characters: 'Characters',\n    storyboard: 'Storyboard',\n    videos: 'Videos',\n    assets: 'Assets',\n    settings: 'Settings',\n    dramas: 'Drama Projects'\n  },\n  dashboard: {\n    title: '🎬 Drama Generator',\n    welcome: 'Welcome to AI Drama Generation Platform',\n    subtitle: 'One-stop drama creation tool from script to video',\n    stats: {\n      projects: 'Drama Projects',\n      images: 'Generated Images',\n      videos: 'Generated Videos',\n      tasks: 'Processing Tasks'\n    },\n    quickStart: 'Quick Start',\n    actions: {\n      newProject: 'Create New Project',\n      newProjectDesc: 'Start a brand new drama project',\n      myProjects: 'My Projects',\n      myProjectsDesc: 'View and manage existing projects'\n    }\n  },\n  common: {\n    create: 'Create',\n    edit: 'Edit',\n    delete: 'Delete',\n    save: 'Save',\n    cancel: 'Cancel',\n    confirm: 'Confirm',\n    search: 'Search',\n    filter: 'Filter',\n    reset: 'Reset',\n    submit: 'Submit',\n    close: 'Close',\n    back: 'Back',\n    next: 'Next',\n    previous: 'Previous',\n    selectAll: 'Select All',\n    loading: 'Loading...',\n    success: 'Success',\n    error: 'Error',\n    warning: 'Warning',\n    info: 'Info',\n    actions: 'Actions',\n    status: 'Status',\n    name: 'Name',\n    description: 'Description',\n    createdAt: 'Created At',\n    updatedAt: 'Updated At',\n    perPage: 'Per Page',\n    image:\"Image\",\n  },\n  settings: {\n    title: 'Settings',\n    aiConfig: 'AI Configuration',\n    general: 'General',\n    language: 'Language',\n    systemLanguage: 'System Language',\n    currentLanguage: 'Current Language',\n    languageSwitchNotice: 'Language Switch Notice',\n    languageSwitchDesc: 'After switching system language, the following will be affected:',\n    languageSwitchItem1: 'All prompts generated by backend (storyboard descriptions, character descriptions, scene descriptions, etc.) will use the selected language',\n    languageSwitchItem2: 'Conversations with AI models will use the selected language',\n    languageSwitchItem3: 'Already generated content will not be automatically updated and needs to be regenerated',\n    theme: 'Theme'\n  },\n  aiConfig: {\n    title: 'AI Service Configuration',\n    addConfig: 'Add Configuration',\n    editConfig: 'Edit Configuration',\n    back: 'Back',\n    empty: 'No configurations yet, click Add Configuration to get started',\n    enabled: 'Enabled',\n    disabled: 'Disabled',\n    enable: 'Enable',\n    disable: 'Disable',\n    endpoint: 'Endpoint',\n    queryEndpoint: 'Query Endpoint',\n    tabs: {\n      text: 'Text Generation',\n      image: 'Image Generation',\n      video: 'Video Generation'\n    },\n    form: {\n      name: 'Configuration Name',\n      namePlaceholder: 'e.g., OpenAI GPT-4',\n      provider: 'Provider',\n      providerPlaceholder: 'Select a provider',\n      providerTip: 'Select AI service provider',\n      priority: 'Priority',\n      priorityTip: 'Higher values have higher priority. For the same model, higher priority configurations are used first',\n      model: 'Model',\n      modelPlaceholder: 'Enter or select model name',\n      modelTip: 'Enter model name directly or select from list, supports multiple models',\n      baseUrl: 'Base URL',\n      baseUrlPlaceholder: 'https://api.openai.com',\n      baseUrlTip: 'API service base address, e.g., Chatfire: https://api.chatfire.site/v1, Gemini: https://generativelanguage.googleapis.com (no /v1 needed)',\n      fullEndpoint: 'Full endpoint path',\n      apiKey: 'API Key',\n      apiKeyPlaceholder: 'sk-...',\n      apiKeyTip: 'Your API key',\n      isActive: 'Active Status'\n    },\n    actions: {\n      test: 'Test Connection',\n      delete: 'Delete',\n      edit: 'Edit'\n    },\n    messages: {\n      deleteConfirm: 'Are you sure to delete this configuration?',\n      testSuccess: 'Connection test successful!',\n      testFailed: 'Connection test failed'\n    }\n  },\n  drama: {\n    title: 'My Drama Projects',\n    create: 'Create Project',\n    totalProjects: 'Total {count} projects',\n    createNew: 'Create Project',\n    createDesc: 'Fill in basic information to create your drama project',\n    aiConfig: 'AI Config',\n    aiConfigTip: 'Please configure AI service before creating a project',\n    empty: 'No projects yet',\n    emptyHint: 'Click \"Create Project\" button above to start your first drama',\n    editProject: 'Edit Project',\n    projectName: 'Project Name',\n    projectNamePlaceholder: 'Enter project name',\n    projectDesc: 'Project Description',\n    projectDescPlaceholder: 'Enter project description (optional)',\n    deleteConfirm: 'Are you sure you want to delete this project?',\n    noCover: 'No cover',\n    noDescription: 'No description',\n    status: {\n      draft: 'Draft',\n      production: 'In Production',\n      completed: 'Completed'\n    },\n    actions: {\n      edit: 'Edit',\n      view: 'View',\n      delete: 'Delete'\n    },\n    management: {\n      overview: 'Project Overview',\n      episodes: 'Episode Management',\n      characters: 'Character Management',\n      scenes: 'Scene Management',\n      projectInfo: 'Project Information',\n      projectName: 'Project Name',\n      projectDesc: 'Project Description',\n      noDescription: 'No description',\n      episodeStats: 'Episode Statistics',\n      characterStats: 'Character Statistics',\n      sceneStats: 'Scene Statistics',\n      episodesCreated: 'Episodes Created',\n      charactersCreated: 'Characters Created',\n      sceneLibraryCount: 'Scene Library Count',\n      startFirstEpisode: 'Start creating your first episode!',\n      noEpisodesYet: 'Your project has no episodes yet. Please create an episode to start production.',\n      createFirstEpisode: 'Create First Episode Now',\n      episodeList: 'Episode List',\n      createNewEpisode: 'Create New Episode',\n      noEpisodes: 'No episodes yet',\n      clickToCreate: 'Click the button above to create your first episode',\n      episodeNumber: 'Episode {number}',\n      goToEdit: 'Go to Edit',\n      characterList: 'Character List',\n      noCharacters: 'No characters yet',\n      charactersTip: 'Characters will be automatically created during script generation',\n      sceneList: 'Scene List',\n      noScenes: 'No scenes yet',\n      scenesTip: 'Scenes will be automatically created during storyboard generation',\n      propList: 'Prop List',\n      noProps: 'No props yet',\n      propStats: 'Prop Statistics',\n      propsCreated: 'Props Created'\n    }\n  },\n  character: {\n    title: 'Character Management',\n    create: 'Create Character',\n    edit: 'Edit Character',\n    add: 'Add Character',\n    list: 'Character List',\n    name: 'Character Name',\n    role: 'Role',\n    personality: 'Personality',\n    appearance: 'Appearance',\n    background: 'Background',\n    description: 'Description',\n    image: 'Character Image',\n    generate: 'Generate Character Image',\n    extracting: 'Extracting...',\n    generateImage: 'Generate Image',\n    batch: 'Batch Operations',\n    empty: 'Characters were created during script generation. You can view and edit them here',\n    backToProject: 'Back to Project',\n    saveChanges: 'Save Changes',\n    nextStep: 'Next Step: Generate Character Images'\n  },\n  prop: {\n    title: 'Prop Management',\n    add: 'Add Prop',\n    edit: 'Edit Prop',\n    delete: 'Delete Prop',\n    create: 'Create Prop',\n    name: 'Prop Name',\n    type: 'Type',\n    typePlaceholder: 'e.g., Weapon, Daily Item',\n    description: 'Description',\n    prompt: 'Image Prompt',\n    promptPlaceholder: 'English prompt for AI image generation',\n    extract: 'Extract from Script',\n    extractTitle: 'Extract Props from Script',\n    selectEpisode: 'Select Episode',\n    extractTip: 'AI will analyze the script to automatically extract key props and generate descriptions',\n    startExtract: 'Start Extracting',\n    extractSuccess: 'Prop extraction task submitted, AI analysis will take about 1 minute',\n    generateImage: 'Generate Image'\n  },\n  scriptGenerationPage: {\n    prevStep: 'Previous',\n    characterList: 'Character List',\n    characterName: 'Character Name',\n    position: 'Position',\n    appearanceDesc: 'Appearance Description',\n    personality: 'Personality',\n    uploadScript: 'Upload Script',\n    uploadContent: 'Upload Content',\n    aiParse: 'AI Parse',\n    confirmSave: 'Confirm & Save',\n    uploadNotice: 'Paste or upload your script file, the system will automatically identify and split into episodes and scenes',\n    uploadMethod: 'Upload Method',\n    dragFilesHere: 'Drag files here or',\n    clickUpload: 'click to upload',\n    supportedFormats: 'Supports .txt, .md, .doc, .docx formats',\n    characterListEditable: 'Character List (Editable)',\n    addCharacter: '+ Add Character',\n    characterType: 'Character Type',\n    mainCharacter: 'Main Character',\n    supportingCharacter: 'Supporting Character',\n    minorCharacter: 'Minor Character',\n    characterDesc: 'Character Description',\n    appearanceFeatures: 'Appearance Features',\n    operations: 'Operations',\n    delete: 'Delete',\n    episodeCount: 'Episode Count',\n    generateFullScript: 'Generate complete episode scripts based on outline',\n    outlineCreatedEpisodes: 'The outline has created {count} episodes, but you can reset the episode count and regenerate',\n    episodePreview: 'Episode Preview (Total {count} episodes)',\n    regenerate: 'Regenerate',\n    episodeNumber: 'Episode',\n    title: 'Title',\n    summary: 'Summary',\n    durationSeconds: 'Duration (seconds)',\n    autoGenerateCharacters: 'Auto-generate character list from outline',\n    charactersCreatedInOutline: 'Characters have been created during outline generation, click \"Next\" to view and edit'\n  },\n  script: {\n    title: 'Script Generation',\n    backToProject: 'Back to Project',\n    aiGenerate: 'AI Generate Script',\n    uploadScript: 'Upload Script',\n    steps: {\n      outline: 'Generate Outline',\n      characters: 'Generate Characters',\n      episodes: 'Generate Episodes'\n    },\n    form: {\n      theme: 'Creative Theme',\n      themePlaceholder: 'Describe the theme and story concept of the drama you want to create',\n      genre: 'Genre Preference',\n      genrePlaceholder: 'Select a genre',\n      style: 'Style Requirements',\n      stylePlaceholder: 'e.g., Light and humorous, Tense and thrilling, Warm and healing',\n      episodeCount: 'Episode Count',\n      randomGenerate: 'Random Generate',\n      title: 'Title',\n      titlePlaceholder: 'Enter script title',\n      summary: 'Summary',\n      summaryPlaceholder: 'Enter script summary',\n      genreExample: 'e.g., Urban, Costume',\n      tags: 'Tags',\n      newTag: 'New Tag'\n    },\n    notice: 'Please enter the creative theme and requirements, AI will generate a script outline for you',\n    generateFailed: 'Generation Failed',\n    generating: 'Generating...',\n    nextStep: 'Next Step',\n    prevStep: 'Previous Step',\n    complete: 'Complete',\n    regenerate: 'Regenerate',\n    regenerateOutline: 'Regenerate Outline',\n    outlinePreview: 'Outline Preview (Editable)'\n  },\n  imageDialog: {\n    title: 'AI Image Generation',\n    selectDrama: 'Select Drama',\n    selectScene: 'Select Scene',\n    selectSceneOptional: 'Select Scene (Optional)',\n    sceneLabel: 'Scene {number}: {title}',\n    prompt: 'Prompt',\n    promptPlaceholder: 'Describe the image you want to generate\\nFor example: A beautiful landscape with mountains and rivers at sunset, cinematic lighting, highly detailed',\n    negativePrompt: 'Negative Prompt',\n    negativePromptPlaceholder: 'Describe elements you don\\'t want (optional)\\nFor example: blurry, low quality, watermark',\n    aiService: 'AI Service',\n    selectService: 'Select service',\n    imageSize: 'Image Size',\n    selectSize: 'Select size',\n    square: 'Square',\n    landscape: 'Landscape',\n    portrait: 'Portrait',\n    imageQuality: 'Image Quality',\n    standard: 'Standard',\n    hd: 'HD',\n    style: 'Style',\n    vivid: 'Vivid',\n    natural: 'Natural',\n    advancedSettings: 'Advanced Settings',\n    samplingSteps: 'Sampling Steps',\n    promptRelevance: 'Prompt Relevance',\n    randomSeed: 'Random Seed',\n    leaveBlankRandom: 'Leave blank for random',\n    seedTip: 'Set the same seed to reproduce the image',\n    generate: 'Generate Image',\n    pleaseSelectDrama: 'Please select a drama',\n    pleaseEnterPrompt: 'Please enter a prompt',\n    promptMinLength: 'Prompt must be at least 5 characters',\n    taskSubmitted: 'Image generation task submitted, please check results later',\n    generateFailed: 'Generation failed',\n    weak: 'Weak',\n    moderate: 'Moderate',\n    strong: 'Strong',\n    veryStrong: 'Very Strong'\n  },\n  image: {\n    title: 'AI Image Generation',\n    generate: 'Generate Image',\n    loadFailed: 'Load Failed',\n    generating: 'Generating...',\n    generateFailed: 'Generation Failed'\n  },\n  dramaWorkflow: {\n    returnToList: 'Back',\n    episodeScript: 'Episode {number} Script',\n    storyboardBreakdown: 'Storyboard Breakdown',\n    characterImages: 'Character Images',\n    createChapterPrompt: 'Please create the first chapter to start production',\n    createChapter: 'Create Chapter {number}',\n    nextStepCharacterImages: 'Next: Character Images',\n    nextStep: 'Next',\n    reGenerateShots: 'Re-split',\n    reGenerateShotsConfirm: 'Re-splitting will overwrite existing shots, are you sure?',\n    pleaseWriteScript: 'Please write script content first',\n    splitStoryboardFirst: 'Please split storyboard first',\n    aiSplitting: 'AI Splitting...',\n    aiAutoSplit: 'AI Auto Split',\n    selected: 'Selected',\n    characterCount: 'Characters',\n    generated: 'Generated',\n    batchGenerate: 'Batch Generate'\n  },\n  workflow: {\n    backToProject: 'Back to Project',\n    episodeProduction: 'Episode {number} Production',\n    steps: {\n      content: 'Episode Content',\n      generateImages: 'Generate Images',\n      splitStoryboard: 'Split Storyboard'\n    },\n    scriptPlaceholder: 'Enter episode content...',\n    saveChapter: 'Save Chapter',\n    chapterContent: 'Chapter {number} Content',\n    saved: 'Saved',\n    extractedData: 'Extracted Data',\n    characters: 'Characters',\n    scenes: 'Scenes',\n    extractedCharacters: 'Extracted Characters (This Episode)',\n    extractedScenes: 'Extracted Scenes (This Episode)',\n    extractCharactersAndScenes: 'Extract Characters and Scenes',\n    reExtract: 'Re-extract Characters and Scenes',\n    nextStepGenerateImages: 'Next Step: Generate Images',\n    extractWarning: 'Please click \"Extract Characters and Scenes\" first, then you can generate images after extraction is complete',\n    characterImages: 'Character Images',\n    sceneImages: 'Scene Images',\n    characterCount: '{count} characters need to generate images',\n    sceneCount: '{count} scenes need to generate images',\n    selectAll: 'Select All',\n    batchGenerate: 'Batch Generate',\n    modelConfig: 'AI Model Configuration',\n    editPrompt: 'Edit Prompt',\n    aiGenerate: 'AI Generate',\n    uploadImage: 'Upload Image',\n    selectFromLibrary: 'Select from Library',\n    shotList: 'Shot List',\n    dragFilesHere: 'Drop files here, or',\n    clickToUpload: 'Click to Upload',\n    prevStep: 'Previous Step',\n    nextStepSplitShots: 'Next Step: Split Shots',\n    reExtractConfirmTitle: 'Re-extract Confirmation',\n    reExtractConfirmMessage: 'Re-extraction will overwrite extracted characters and scenes (including generated images). Continue?',\n    startReExtracting: 'Starting re-extraction, please wait...',\n    regenerateShots: 'Regenerate Shots',\n    batchGenerateSelected: 'Batch Generate Selected Scenes',\n    generateAllImagesFirst: 'Please generate all character and scene images before splitting shots',\n    sceneImageGenerating: 'Scene image generating, please wait...',\n    sceneImageComplete: 'Scene image generation completed!',\n    sceneImageStarted: 'Scene image generation started',\n    reSplitShots: 'Re-split Shots',\n    enterProfessional: 'Enter Professional Production',\n    editShot: 'Edit Shot',\n    splitSuccess: 'Shot splitting successful! Entering professional production interface...',\n    reSplitConfirm: 'Are you sure you want to re-split the shots?',\n    deleteCharacter: 'Delete Character',\n    splitStoryboardFirst: 'Please split the storyboard first',\n    aiSplitting: 'AI Splitting...',\n    aiAutoSplit: 'AI Auto Split',\n    batchTaskSubmitted: 'Batch generation task submitted!',\n    batchGenerateFailed: 'Batch generation failed',\n    batchCompleteSuccess: 'Batch generation completed! Successfully generated {count} scenes',\n    batchCompletePartial: 'Generation completed: {success} succeeded, {fail} failed',\n    addToLibrary: 'Add to Character Library',\n    addToLibraryConfirm: 'Are you sure you want to add character \"{name}\" to the global character library? Once added, this character can be used in all projects.',\n    addedToLibrary: 'Added to character library!',\n    addFailed: 'Add failed',\n    shotTitle: 'Shot Title',\n    shotTitlePlaceholder: 'Enter shot title',\n    shotType: 'Shot Type',\n    selectShotType: 'Select shot type',\n    longShot: 'Long Shot',\n    fullShot: 'Full Shot',\n    mediumShot: 'Medium Shot',\n    closeUp: 'Close-up',\n    extremeCloseUp: 'Extreme Close-up',\n    cameraAngle: 'Camera Angle',\n    selectAngle: 'Select angle',\n    eyeLevel: 'Eye Level',\n    lowAngle: 'Low Angle',\n    highAngle: 'High Angle',\n    location: 'Location',\n    locationPlaceholder: 'Scene location',\n    shotDescription: 'Shot Description',\n    shotDescriptionPlaceholder: 'Overall shot description',\n    cameraMovement: 'Camera Movement',\n    selectMovement: 'Select movement',\n    staticShot: 'Static Shot',\n    pushIn: 'Push In',\n    pullOut: 'Pull Out',\n    followShot: 'Follow Shot',\n    sideView: 'Side View',\n    time: 'Time',\n    timeSetting: 'Time Setting',\n    actionDescription: 'Action Description',\n    detailedAction: 'Detailed action description',\n    dialogue: 'Dialogue',\n    characterDialogue: 'Character dialogue',\n    generateImageFirst: 'Please generate character images first',\n    saveAndGenerate: 'Save and Generate',\n    saveConfig: 'Save Configuration',\n    play: 'Play',\n    pause: 'Pause',\n    addAll: 'Add All',\n    addToTimeline: 'Add to Timeline',\n    deleteAsset: 'Delete Asset',\n    confirmDelete: 'Confirm Delete',\n    tip: 'Tip',\n    edit: 'Edit'\n  },\n  tooltip: {\n    editPrompt: 'Edit Prompt',\n    aiGenerate: 'AI Generate',\n    uploadImage: 'Upload Image',\n    selectFromLibrary: 'Select from Library',\n    shotList: 'Shot List',\n    dragFilesHere: 'Drop files here, or',\n    prevStep: 'Previous Step',\n    nextStepSplitShots: 'Next Step: Split Shots',\n    reExtractConfirmTitle: 'Re-extract Confirmation',\n    reExtractConfirmMessage: 'Re-extraction will overwrite extracted characters and scenes (including generated images). Continue?',\n    startReExtracting: 'Starting re-extraction, please wait...',\n    regenerateShots: 'Regenerate Shots',\n    batchGenerateSelected: 'Batch Generate Selected Scenes',\n    generateAllImagesFirst: 'Please generate all character and scene images before splitting shots',\n    sceneImageGenerating: 'Scene image generating, please wait...',\n    sceneImageComplete: 'Scene image generation completed!',\n    sceneImageStarted: 'Scene image generation started',\n    reSplitShots: 'Re-split Shots',\n    editShot: 'Edit Shot',\n    splitSuccess: 'Shot splitting successful! Entering professional production interface...',\n    reSplitConfirm: 'Are you sure you want to re-split the shots?',\n    deleteCharacter: 'Delete Character',\n    splitStoryboardFirst: 'Please split the storyboard first',\n    aiSplitting: 'AI Splitting...',\n    aiAutoSplit: 'AI Auto Split',\n    batchTaskSubmitted: 'Batch generation task submitted!',\n    batchGenerateFailed: 'Batch generation failed',\n    batchCompleteSuccess: 'Batch generation completed! Successfully generated {count} scenes',\n    batchCompletePartial: 'Generation completed: {success} succeeded, {fail} failed',\n    addToLibrary: 'Add to Character Library',\n    addToLibraryConfirm: 'Are you sure you want to add character \"{name}\" to the global character library? Once added, this character can be used in all projects.',\n    addedToLibrary: 'Added to character library!',\n    addFailed: 'Add failed',\n    shotTitle: 'Shot Title',\n    shotTitlePlaceholder: 'Enter shot title',\n    shotType: 'Shot Type',\n    selectShotType: 'Select shot type',\n    longShot: 'Long Shot',\n    fullShot: 'Full Shot',\n    mediumShot: 'Medium Shot',\n    closeUp: 'Close-up',\n    extremeCloseUp: 'Extreme Close-up',\n    cameraAngle: 'Camera Angle',\n    selectAngle: 'Select angle',\n    eyeLevel: 'Eye Level',\n    lowAngle: 'Low Angle',\n    highAngle: 'High Angle',\n    location: 'Location',\n    locationPlaceholder: 'Scene location',\n    shotDescription: 'Shot Description',\n    shotDescriptionPlaceholder: 'Overall shot description',\n    cameraMovement: 'Camera Movement',\n    selectMovement: 'Select movement',\n    staticShot: 'Static Shot',\n    pushIn: 'Push In',\n    pullOut: 'Pull Out',\n    followShot: 'Follow Shot',\n    sideView: 'Side View',\n    time: 'Time',\n    timeSetting: 'Time setting',\n    actionDescription: 'Action Description',\n    detailedAction: 'Detailed action description',\n    dialogue: 'Dialogue',\n    characterDialogue: 'Character dialogue',\n    generateImageFirst: 'Please generate character image first',\n    result: 'Result',\n    actionResult: 'Action result',\n    atmosphere: 'Atmosphere',\n    atmosphereDescription: 'Atmosphere description',\n    loadLibraryFailed: 'Failed to load character library',\n    imagePrompt: 'Image Prompt',\n    imagePromptPlaceholder: 'Prompt for AI image generation',\n    videoPrompt: 'Video Prompt',\n    videoPromptPlaceholder: 'Prompt for AI video generation',\n    bgmHint: 'BGM Hint',\n    bgmAtmosphere: 'BGM atmosphere description',\n    soundEffect: 'Sound Effect',\n    soundEffectDescription: 'Sound effect description',\n    durationSeconds: 'Duration (seconds)',\n    emptyLibrary: 'Character library is empty, please generate or upload character images first',\n    textModelTip: 'Used to generate episode content, characters, scenes and other text',\n    uploadFormatTip: 'Supports jpg/png formats, file size should not exceed 10MB',\n    aiModelConfig: 'AI Model Configuration',\n    textGenModel: 'Text Generation Model',\n    imageGenModel: 'Image Generation Model',\n    selectTextModel: 'Select text generation model',\n    selectImageModel: 'Select image generation model',\n    modelConfigTip: 'For generating character and scene images',\n    modelConfigSaved: 'Model configuration saved',\n    pleaseSelectModels: 'Please select text and image generation models'\n  },\n  professionalEditor: {\n    duration: 'Duration',\n    seconds: 's',\n    videoDuration: 'Video Duration',\n    downloadVideo: 'Download Video'\n  },\n  storyboard: {\n    title: 'Storyboard',\n    edit: 'Storyboard Edit',\n    create: 'Create Storyboard',\n    script: 'Script',\n    scene: 'Scene',\n    shot: 'Shot',\n    shotNumber: 'Shot {number}',\n    untitled: 'Untitled Shot',\n    scriptStructure: 'Script Structure',\n    add: 'Add',\n    noStoryboard: 'No Storyboards',\n    shotProperties: 'Shot Properties',\n    selectScene: 'Select Scene',\n    inDevelopment: 'Feature under development...',\n    generateScript: 'Generate Script',\n    generateImage: 'Generate Image',\n    generateVideo: 'Generate Video',\n    table: {\n      number: 'No.',\n      title: 'Title',\n      shotType: 'Shot Type',\n      movement: 'Movement',\n      location: 'Location',\n      character: 'Character',\n      dialogue: 'Dialogue',\n      action: 'Action',\n      duration: 'Duration',\n      operations: 'Operations'\n    }\n  },\n  timeline: {\n    title: 'Timeline Editor',\n    backToEditor: 'Back',\n    noScenes: 'No available scenes',\n    loadFailed: 'Failed to load storyboards'\n  },\n  editor: {\n    backToEpisode: 'Back to Episode Edit',\n    episode: 'Episode {number}',\n    settings: 'Settings',\n    basicInfo: 'Basic Info',\n    sceneProduction: 'Scene Production',\n    sceneId: 'Scene ID',\n    sceneGenerating: 'Scene image generating...',\n    noBackground: 'No background linked',\n    cast: 'Cast',\n    addCharacter: 'Add Character',\n    removeCharacter: 'Remove Character',\n    noCharacters: 'No characters specified',\n    visualSettings: 'Visual Settings',\n    shotType: 'Shot Type',\n    shotTypePlaceholder: 'Select shot type',\n    movement: 'Camera Movement',\n    movementPlaceholder: 'Camera movement',\n    angle: 'Camera Angle',\n    anglePlaceholder: 'Camera angle',\n    action: 'Action',\n    actionPlaceholder: 'Describe the action...',\n    result: 'Result',\n    resultPlaceholder: 'Describe the result...',\n    dialogue: 'Dialogue',\n    dialoguePlaceholder: 'Enter dialogue...',\n    soundEffects: 'Sound Effects',\n    soundEffectsPlaceholder: 'Describe sound effects...',\n    transitions: 'Transitions',\n    transitionsPlaceholder: 'Select transition',\n    duration: 'Duration',\n    seconds: 's',\n    description: 'Description',\n    descriptionPlaceholder: 'Overall shot description...',\n    bgmPrompt: 'BGM Prompt',\n    bgmPromptPlaceholder: 'Describe BGM atmosphere, e.g., Intense background music',\n    atmosphere: 'Atmosphere',\n    atmospherePlaceholder: 'Describe environment atmosphere, e.g., Dark and oppressive, Bright and warm',\n    lightingEffect: 'Lighting Effect',\n    specialEffects: 'Special Effects',\n    props: 'Props',\n    addProp: 'Add Prop',\n    addPropToShot: 'Add Prop to Shot',\n    removeProp: 'Remove Prop',\n    noProps: 'No Props',\n    noPropsAvailable: 'No props available, please add props in drama management first',\n    updatePropFailed: 'Failed to update prop',\n    emotionalTone: 'Emotional Tone',\n    shotImage: 'Shot Image',\n    noShotSelected: 'No shot selected',\n    selectFrameType: 'Select Frame Type',\n    firstFrame: 'First Frame',\n    lastFrame: 'Last Frame',\n    panelFrame: 'Panel',\n    actionSequence: 'Action Sequence',\n    keyFrame: 'Key Frame',\n    panelCount: 'Panel Count',\n    prompt: 'Prompt',\n    extractPrompt: 'Extract Prompt',\n    promptPlaceholder: 'Click Extract Prompt button, the system will generate image prompts based on storyboard content...',\n    generating: 'Generating...',\n    generateImage: 'Generate Image',\n    uploadImage: 'Upload Image',\n    generationResult: 'Generation Result'\n  },\n  video: {\n    title: 'AI Video Generation',\n    generate: 'Generate Video',\n    merge: 'Merge Video',\n    mediaLibrary: 'Video Media Library',\n    videoCount: '{count} videos',\n    dragToTimeline: 'Drag scenes to timeline to start editing',\n    videoTrack: 'Video Track',\n    audioTrack: 'Audio Track',\n    clearTrack: 'Clear Track',\n    soundAndMusic: 'Sound & Music',\n    soundMusicInDev: 'Sound & Music feature in development',\n    noMergeYet: 'No videos merged yet',\n    mergeInstructions: 'Arrange videos in the timeline editor and click \"Merge Video\" to proceed',\n    selectVideoModel: 'Please select a video model',\n    mergeComplete: 'Video merge completed and downloaded!',\n    mergeTaskSubmitted: 'Video merge task submitted, processing in background...',\n    audio: 'Audio',\n    extractAudio: 'Extract audio from all video clips',\n    model: 'Model',\n    videoGeneration: 'Video Generation',\n    soundAndMusicTab: 'Sound & Music',\n    videoMerge: 'Video Merge',\n    noMergeRecords: 'No merge records',\n    transitionType: 'Transition Type',\n    transitionDuration: 'Transition Duration',\n    selectTransition: 'Select transition',\n    filter: {\n      drama: 'Script',\n      allDramas: 'All Scripts',\n      status: 'Status',\n      allStatus: 'All Status',\n      query: 'Query',\n      reset: 'Reset'\n    },\n    status: {\n      pending: 'Pending',\n      processing: 'Processing',\n      completed: 'Completed',\n      failed: 'Failed'\n    },\n    prompt: 'Prompt',\n    duration: 'Duration',\n    createdAt: 'Created At',\n    actions: {\n      view: 'View Details',\n      download: 'Download',\n      delete: 'Delete'\n    }\n  },\n  asset: {\n    title: 'Asset Library',\n    type: 'Asset Type',\n    upload: 'Upload',\n    import: 'Import',\n    export: 'Export'\n  },\n  genres: {\n    urban: 'Urban',\n    costume: 'Costume',\n    mystery: 'Mystery',\n    romance: 'Romance',\n    comedy: 'Comedy'\n  },\n  message: {\n    deleteConfirm: 'Are you sure to delete?',\n    deleteSuccess: 'Deleted successfully',\n    createSuccess: 'Created successfully',\n    updateSuccess: 'Updated successfully',\n    operationSuccess: 'Operation successful',\n    operationFailed: 'Operation failed',\n    loadingFailed: 'Loading failed',\n    networkError: 'Network error'\n  }\n}\n"
  },
  {
    "path": "web/src/locales/index.ts",
    "content": "import { createI18n } from 'vue-i18n'\nimport zhCN from './zh-CN'\nimport enUS from './en-US'\n\n// 从 localStorage 获取保存的语言，默认为中文\nconst getStoredLanguage = (): string => {\n  const stored = localStorage.getItem('language')\n  if (stored) return stored\n  \n  // 自动检测浏览器语言\n  const browserLang = navigator.language.toLowerCase()\n  if (browserLang.startsWith('zh')) return 'zh-CN'\n  return 'en-US'\n}\n\nconst i18n = createI18n({\n  legacy: false, // 使用 Composition API 模式\n  locale: getStoredLanguage(),\n  fallbackLocale: 'zh-CN',\n  messages: {\n    'zh-CN': zhCN,\n    'en-US': enUS\n  }\n})\n\nexport default i18n\n\n// 导出语言切换函数\nexport const setLanguage = (lang: string) => {\n  i18n.global.locale.value = lang as any\n  localStorage.setItem('language', lang)\n}\n\nexport const getCurrentLanguage = () => {\n  return i18n.global.locale.value\n}\n"
  },
  {
    "path": "web/src/locales/zh-CN.ts",
    "content": "export default {\n  nav: {\n    home: '首页',\n    characters: '角色管理',\n    storyboard: '分镜制作',\n    videos: '视频管理',\n    assets: '资源库',\n    settings: '设置',\n    dramas: '短剧项目'\n  },\n  dashboard: {\n    title: '🎬 Drama Generator',\n    welcome: '欢迎使用 AI 短剧生成平台',\n    subtitle: '从剧本到视频，一站式短剧创作工具',\n    stats: {\n      projects: '短剧项目',\n      images: '生成图片',\n      videos: '生成视频',\n      tasks: '处理中任务'\n    },\n    quickStart: '快速开始',\n    actions: {\n      newProject: '创建新项目',\n      newProjectDesc: '开始一个全新的短剧项目',\n      myProjects: '我的项目',\n      myProjectsDesc: '查看和管理已有项目'\n    }\n  },\n  common: {\n    create: '创建',\n    edit: '编辑',\n    delete: '删除',\n    save: '保存',\n    cancel: '取消',\n    confirm: '确定',\n    search: '搜索',\n    filter: '筛选',\n    reset: '重置',\n    submit: '提交',\n    close: '关闭',\n    back: '返回',\n    next: '下一步',\n    previous: '上一步',\n    selectAll: '全选',\n    loading: '加载中...',\n    success: '成功',\n    failed: '失败',\n    noData: '暂无数据',\n    pleaseSelect: '请选择',\n    add: '添加',\n    view: '查看',\n    upload: '上传',\n    download: '下载',\n    generating: '生成中...',\n    notGenerated: '未生成',\n    generateFailed: '生成失败',\n    clickToRegenerate: '点击重新生成',\n    queuing: '排队中',\n    processing: '处理中',\n    saveAndGenerate: '保存并生成',\n    saveConfig: '保存配置',\n    play: '播放',\n    pause: '暂停',\n    addAll: '一键添加全部',\n    addToTimeline: '添加到时间线',\n    deleteAsset: '删除素材',\n    confirmDelete: '确认删除',\n    tip: '提示',\n    status: '状态',\n    createdAt: '创建时间',\n    updatedAt: '更新时间',\n    name: '名称',\n    description: '描述',\n    image: '图片',\n    perPage: '每页'\n  },\n  settings: {\n    title: '设置',\n    aiConfig: 'AI配置',\n    general: '通用设置',\n    systemLanguage: '系统语言',\n    currentLanguage: '当前语言',\n    languageSwitchNotice: '语言切换提醒',\n    languageSwitchDesc: '切换系统语言后，以下内容将受到影响：',\n    languageSwitchItem1: '后端生成的所有提示词（分镜描述、角色描述、场景描述等）将使用所选语言',\n    languageSwitchItem2: '与AI模型的对话将使用所选语言',\n    languageSwitchItem3: '已生成的内容不会自动更新，需要重新生成',\n    language: '语言',\n    theme: '主题'\n  },\n  aiConfig: {\n    title: 'AI 服务配置',\n    addConfig: '添加配置',\n    editConfig: '编辑配置',\n    back: '返回',\n    empty: '暂无配置，点击添加配置开始使用',\n    enabled: '已启用',\n    disabled: '已禁用',\n    enable: '启用',\n    disable: '禁用',\n    endpoint: '端点',\n    queryEndpoint: '查询端点',\n    tabs: {\n      text: '文本生成',\n      image: '图片生成',\n      video: '视频生成'\n    },\n    form: {\n      name: '配置名称',\n      namePlaceholder: '例如：OpenAI GPT-4',\n      provider: '厂商',\n      providerPlaceholder: '请选择厂商',\n      providerTip: '选择AI服务提供商',\n      priority: '优先级',\n      priorityTip: '数值越大优先级越高，相同模型时优先使用高优先级配置',\n      model: '模型',\n      modelPlaceholder: '输入或选择模型名称',\n      modelTip: '可直接输入模型名称或从列表选择，支持多个模型',\n      baseUrl: 'Base URL',\n      baseUrlPlaceholder: 'https://api.openai.com',\n      baseUrlTip: 'API 服务的基础地址，如 Chatfire: https://api.chatfire.site/v1，Gemini: https://generativelanguage.googleapis.com（无需 /v1）',\n      fullEndpoint: '完整调用路径',\n      apiKey: 'API Key',\n      apiKeyPlaceholder: 'sk-...',\n      apiKeyTip: '您的 API 密钥',\n      isActive: '启用状态'\n    },\n    actions: {\n      test: '测试连接',\n      delete: '删除',\n      edit: '编辑'\n    },\n    messages: {\n      deleteConfirm: '确定要删除此配置吗？',\n      testSuccess: '连接测试成功！',\n      testFailed: '连接测试失败'\n    }\n  },\n  drama: {\n    title: '短剧管理',\n    create: '创建项目',\n    totalProjects: '共 {count} 个项目',\n    createNew: '创建新项目',\n    createDesc: '开始创作您的短剧项目',\n    aiConfig: 'AI配置',\n    aiConfigTip: '请先配置 AI 服务后再创建项目',\n    empty: '暂无项目，点击上方按钮创建新项目',\n    emptyHint: '点击上方\"创建新项目\"按钮开始您的第一部短剧',\n    editProject: '编辑项目',\n    projectName: '项目名称',\n    projectNamePlaceholder: '请输入项目名称',\n    projectDesc: '项目描述',\n    projectDescPlaceholder: '请输入项目描述（可选）',\n    style: '风格',\n    stylePlaceholder: '请选择风格',\n    styles: {\n      ghibli: '吉卜力',\n      guoman: '国漫',\n      wasteland: '末日废土',\n      nostalgia: '怀旧',\n      pixel: '像素艺术',\n      voxel: '方块世界',\n      urban: '都市',\n      guoman3d: '国漫3D',\n      chibi3d: 'Q版3D'\n    },\n    deleteConfirm: '确定要删除这个项目吗？',\n    noCover: '暂无封面',\n    noDescription: '暂无描述',\n    status: {\n      draft: '草稿',\n      production: '制作中',\n      completed: '已完成'\n    },\n    actions: {\n      edit: '编辑',\n      view: '查看',\n      delete: '删除'\n    },\n    management: {\n      overview: '项目概览',\n      episodes: '章节管理',\n      characters: '角色管理',\n      scenes: '场景管理',\n      projectInfo: '项目信息',\n      projectName: '项目名称',\n      projectDesc: '项目描述',\n      noDescription: '暂无描述',\n      episodeStats: '章节统计',\n      characterStats: '角色统计',\n      sceneStats: '场景统计',\n      episodesCreated: '已创建章节',\n      charactersCreated: '已创建角色',\n      sceneLibraryCount: '场景库数量',\n      startFirstEpisode: '开始创作您的第一个章节！',\n      noEpisodesYet: '您的项目还没有章节。请先创建一个章节开始制作。',\n      createFirstEpisode: '立即创建第一个章节',\n      episodeList: '章节列表',\n      createNewEpisode: '创建新章节',\n      noEpisodes: '还没有章节',\n      clickToCreate: '点击上方按钮创建第一个章节',\n      episodeNumber: '第 {number} 章',\n      goToEdit: '进入编辑',\n      characterList: '角色列表',\n      noCharacters: '还没有角色',\n      charactersTip: '角色将在剧本生成阶段自动创建',\n      sceneList: '场景列表',\n      noScenes: '还没有场景',\n      scenesTip: '场景将在分镜生成阶段自动创建',\n      propList: '道具列表',\n      noProps: '还没有道具',\n      propStats: '道具统计',\n      propsCreated: '已创建道具'\n    }\n  },\n  character: {\n    title: '角色管理',\n    create: '创建角色',\n    edit: '编辑角色',\n    add: '添加角色',\n    list: '角色列表',\n    name: '角色名称',\n    role: '角色',\n    personality: '性格',\n    appearance: '外貌',\n    background: '背景',\n    description: '角色描述',\n    image: '角色形象',\n    generate: '生成角色形象',\n    extracting: '提取中...',\n    generateImage: '生成形象',\n    batch: '批量操作',\n    empty: '角色已在剧本生成阶段创建，您可以在此查看和编辑',\n    backToProject: '返回项目',\n    saveChanges: '保存修改',\n    nextStep: '下一步：生成角色图片'\n  },\n  prop: {\n    title: '道具管理',\n    add: '添加道具',\n    edit: '编辑道具',\n    delete: '删除道具',\n    create: '创建道具',\n    name: '道具名称',\n    type: '类型',\n    typePlaceholder: '如：武器、日常用品',\n    description: '道具描述',\n    prompt: '图片提示词',\n    promptPlaceholder: 'AI生成图片的英文提示词',\n    extract: '从剧本提取',\n    extractTitle: '从剧本提取道具',\n    selectEpisode: '选择章节',\n    extractTip: 'AI将分析剧本内容，自动提取关键道具并生成描述',\n    startExtract: '开始提取',\n    extractSuccess: '道具提取任务已提交，AI分析大约需要1分钟',\n    generateImage: '生成图片'\n  },\n  script: {\n    title: '剧本生成',\n    backToProject: '返回项目',\n    aiGenerate: 'AI 生成剧本',\n    uploadScript: '上传剧本',\n    steps: {\n      outline: '生成大纲',\n      characters: '生成角色',\n      episodes: '生成剧集'\n    },\n    form: {\n      theme: '创作主题',\n      themePlaceholder: '描述你想创作的短剧主题和故事概念',\n      genre: '类型偏好',\n      genrePlaceholder: '选择类型',\n      style: '风格要求',\n      stylePlaceholder: '例如：轻松幽默、紧张刺激、温馨治愈',\n      episodeCount: '剧集数量',\n      randomGenerate: '随机生成',\n      title: '标题',\n      titlePlaceholder: '请输入剧本标题',\n      summary: '概要',\n      summaryPlaceholder: '请输入剧本概要',\n      genreExample: '例如：都市、古装',\n      tags: '标签',\n      newTag: '新标签'\n    },\n    notice: '请输入创作主题和相关要求，AI将为您生成剧本大纲',\n    generateFailed: '生成失败',\n    generating: '生成中...',\n    nextStep: '下一步',\n    prevStep: '上一步',\n    complete: '完成',\n    regenerate: '重新生成',\n    regenerateOutline: '重新生成大纲',\n    outlinePreview: '大纲预览（可编辑）'\n  },\n  imageDialog: {\n    title: 'AI 图片生成',\n    selectDrama: '选择剧本',\n    selectScene: '选择场景',\n    selectSceneOptional: '选择场景（可选）',\n    sceneLabel: '场景{number}: {title}',\n    prompt: '提示词',\n    promptPlaceholder: '描述你想生成的图片\\n例如：A beautiful landscape with mountains and rivers at sunset, cinematic lighting, highly detailed',\n    negativePrompt: '反向提示词',\n    negativePromptPlaceholder: '描述不希望出现的元素（可选）\\n例如：blurry, low quality, watermark',\n    aiService: 'AI 服务',\n    selectService: '选择服务',\n    imageSize: '图片尺寸',\n    selectSize: '选择尺寸',\n    square: '正方形',\n    landscape: '横向',\n    portrait: '纵向',\n    imageQuality: '图片质量',\n    standard: '标准',\n    hd: '高清',\n    style: '风格',\n    vivid: '鲜艳',\n    natural: '自然',\n    advancedSettings: '高级设置',\n    samplingSteps: '采样步数',\n    promptRelevance: '提示词相关性',\n    randomSeed: '随机种子',\n    leaveBlankRandom: '留空随机',\n    seedTip: '设置相同种子可复现图片',\n    generate: '生成图片',\n    pleaseSelectDrama: '请选择剧本',\n    pleaseEnterPrompt: '请输入提示词',\n    promptMinLength: '提示词至少5个字符',\n    taskSubmitted: '图片生成任务已提交，请稍后查看结果',\n    generateFailed: '生成失败',\n    weak: '弱',\n    moderate: '适中',\n    strong: '强',\n    veryStrong: '很强'\n  },\n  image: {\n    title: 'AI 图片生成',\n    generate: '生成图片',\n    loadFailed: '加载失败',\n    generating: '生成中...',\n    generateFailed: '生成失败'\n  },\n  dramaWorkflow: {\n    returnToList: '返回',\n    episodeScript: '第{number}集剧本',\n    storyboardBreakdown: '分镜拆解',\n    characterImages: '角色图片',\n    createChapterPrompt: '请创建第一章开始制作',\n    createChapter: '创建第{number}章',\n    nextStepCharacterImages: '下一步：角色图片',\n    nextStep: '下一步',\n    reGenerateShots: '重新拆分',\n    reGenerateShotsConfirm: '重新拂分将覆盖现有镜头，确定继续吗？',\n    pleaseWriteScript: '请先创作剧本内容',\n    splitStoryboardFirst: '请先对剧本进行分镜拆解',\n    aiSplitting: 'AI拆分中...',\n    aiAutoSplit: 'AI自动拆分',\n    selected: '已选',\n    characterCount: '角色数',\n    generated: '已生成',\n    batchGenerate: '批量生成'\n  },\n  workflow: {\n    backToProject: '返回项目',\n    episodeProduction: '第{number}章制作',\n    steps: {\n      content: '章节内容',\n      generateImages: '生成图片',\n      splitStoryboard: '拆分分镜'\n    },\n    scriptPlaceholder: '请输入章节内容...',\n    saveChapter: '保存章节',\n    chapterContent: '第{number}章内容',\n    saved: '已保存',\n    extractedData: '已提取数据',\n    characters: '角色',\n    scenes: '场景',\n    extractedCharacters: '提取的角色（本集）',\n    extractedScenes: '提取的场景（本集）',\n    extractCharactersAndScenes: '提取角色和场景',\n    reExtract: '重新提取角色和场景',\n    nextStepGenerateImages: '下一步：生成图片',\n    extractWarning: '请先点击“提取角色和场景”按钮，完成提取后才能生成图片',\n    characterImages: '角色图片',\n    sceneImages: '场景图片',\n    characterCount: '共 {count} 个角色需要生成图片',\n    sceneCount: '共 {count} 个场景需要生成图片',\n    selectAll: '全选',\n    batchGenerate: '批量生成',\n    modelConfig: 'AI模型配置',\n    editPrompt: '修改提示词',\n    aiGenerate: 'AI生成',\n    uploadImage: '上传图片',\n    selectFromLibrary: '从角色库选择',\n    shotList: '镜头列表',\n    dragFilesHere: '将文件拖到此处，或',\n    clickToUpload: '点击上传',\n    prevStep: '上一步',\n    nextStepSplitShots: '下一步：拆分分镜',\n    reExtractConfirmTitle: '重新提取确认',\n    reExtractConfirmMessage: '重新提取将覆盖已提取的角色和场景（包括已生成的图片），确定继续吗？',\n    startReExtracting: '开始重新提取，请稍候...',\n    regenerateShots: '重新生成分镜',\n    batchGenerateSelected: '批量生成选中场景',\n    generateAllImagesFirst: '请先生成所有角色和场景图片后再进行分镜拆分',\n    sceneImageGenerating: '场景图片生成中，请稍候...',\n    sceneImageComplete: '场景图片生成完成！',\n    sceneImageStarted: '场景图片生成已启动',\n    reSplitShots: '重新拆分',\n    enterProfessional: '进入专业制作',\n    editShot: '编辑镜头',\n    splitSuccess: '分镜拆分成功！正在进入专业制作界面...',\n    reSplitConfirm: '确定要重新拂分分镜吗？',\n    deleteCharacter: '删除角色',\n    splitStoryboardFirst: '请先对章节进行分镜拆解',\n    aiSplitting: 'AI拆分中...',\n    aiAutoSplit: 'AI自动拆分',\n    batchTaskSubmitted: '批量生成任务已提交！',\n    batchGenerateFailed: '批量生成失败',\n    batchCompleteSuccess: '批量生成完成！成功生成 {count} 个场景',\n    batchCompletePartial: '生成完成：成功 {success} 个，失败 {fail} 个',\n    addToLibrary: '添加到角色库',\n    addToLibraryConfirm: '确定要将角色“{name}”添加到全局角色库吗？添加后可以在所有项目中使用该角色形象。',\n    addedToLibrary: '已添加到角色库！',\n    addFailed: '添加失败',\n    shotTitle: '镜头标题',\n    shotTitlePlaceholder: '请输入镜头标题',\n    shotType: '景别',\n    selectShotType: '选择景别',\n    longShot: '远景',\n    fullShot: '全景',\n    mediumShot: '中景',\n    closeUp: '近景',\n    extremeCloseUp: '特写',\n    cameraAngle: '镜头角度',\n    selectAngle: '选择角度',\n    eyeLevel: '平视',\n    lowAngle: '仰视',\n    highAngle: '俯视',\n    location: '地点',\n    locationPlaceholder: '场景地点',\n    shotDescription: '镜头描述',\n    shotDescriptionPlaceholder: '镜头整体描述',\n    cameraMovement: '运镜方式',\n    selectMovement: '选择运镜',\n    staticShot: '固定镜头',\n    pushIn: '推镜',\n    pullOut: '拉镜',\n    followShot: '跟镜',\n    sideView: '侧面',\n    time: '时间',\n    timeSetting: '时间设定',\n    actionDescription: '动作描述',\n    detailedAction: '详细动作描述',\n    dialogue: '对白',\n    characterDialogue: '角色对白',\n    generateImageFirst: '请先生成角色图片',\n    result: '画面结果',\n    actionResult: '动作结果',\n    atmosphere: '环境氛围',\n    atmosphereDescription: '环境氛围描述',\n    loadLibraryFailed: '获取角色库失败',\n    imagePrompt: '图片提示词',\n    imagePromptPlaceholder: '用于AI生成图片的提示词',\n    videoPrompt: '视频提示词',\n    videoPromptPlaceholder: '用于AI生成视频的提示词',\n    bgmHint: '配乐提示',\n    bgmAtmosphere: '配乐氛围描述',\n    soundEffect: '音效',\n    soundEffectDescription: '音效描述',\n    durationSeconds: '时长(秒)',\n    emptyLibrary: '角色库为空，请先生成或上传角色图片',\n    textModelTip: '用于生成章节内容、角色、场景等文本',\n    uploadFormatTip: '支持 jpg/png 格式，文件大小不超过 10MB',\n    aiModelConfig: 'AI模型配置',\n    textGenModel: '文本生成模型',\n    imageGenModel: '图片生成模型',\n    selectTextModel: '选择文本生成模型',\n    selectImageModel: '选择图片生成模型',\n    modelConfigTip: '用于生成角色和场景图片',\n    modelConfigSaved: '模型配置已保存',\n    pleaseSelectModels: '请选择文本和图片生成模型',\n    addScene: '添加场景',\n    extractFromScript: '从剧本提取',\n    sceneImage: '场景图片',\n    sceneName: '场景名称',\n    sceneNamePlaceholder: '请输入场景名称，如：大型商场内部通道',\n    timePlaceholder: '请输入时间，如：下午14:00',\n    sceneDescription: '场景描述',\n    sceneDescriptionPlaceholder: '请输入场景描述',\n    extractSceneDialogTitle: '从剧本提取场景',\n    extractSceneDialogTip: '将从当前章节的剧本内容中自动提取场景信息',\n    startExtract: '开始提取',\n    sceneAddSuccess: '场景添加成功',\n    sceneAddFailed: '场景添加失败',\n    pleaseEnterSceneName: '请输入场景名称',\n    chapterInfoNotExist: '章节信息不存在',\n    sceneExtractSubmitted: '场景提取任务已提交，请稍后刷新查看结果',\n    sceneExtractFailed: '场景提取失败',\n    imageUploadSuccess: '图片上传成功',\n    imageUploadSuccessNoUrl: '图片上传成功，但未获取到图片地址',\n    sceneImageUploadSuccess: '场景图片上传成功！',\n    sceneImageGenerateComplete: '场景图片生成完成！',\n    sceneImageGenerateStarted: '场景图片生成已启动',\n    deleteCharacterConfirm: '确定要删除该角色吗？删除后将无法恢复。',\n    deleteConfirmTitle: '删除确认',\n    confirmButtonText: '确定',\n    cancelButtonText: '取消',\n    extractCancelled: '已取消提取',\n    charactersAndScenesExtractSuccess: '角色和场景提取成功！',\n    charactersAndScenesExtractFailed: '角色和场景提取失败',\n    characterGenerationFailed: '角色生成失败',\n    sceneExtractionFailed: '场景提取失败',\n    characterGenerationTimeout: '角色生成超时',\n    sceneExtractionTimeout: '场景提取超时'\n  },\n  professionalEditor: {\n    duration: '时长',\n    seconds: '秒',\n    videoDuration: '视频时长',\n    downloadVideo: '下载视频'\n  },\n  storyboard: {\n    title: '分镜制作',\n    edit: '分镜编辑',\n    create: '创建分镜',\n    script: '剧本',\n    scene: '场景',\n    shot: '镜头',\n    shotNumber: '镜头 {number}',\n    untitled: '未命名镜头',\n    scriptStructure: '剧本结构',\n    add: '添加',\n    noStoryboard: '暂无分镜',\n    shotProperties: '镜头属性',\n    selectScene: '选择场景',\n    inDevelopment: '功能开发中...',\n    generateScript: '生成分镜脚本',\n    generateImage: '生成分镜图片',\n    generateVideo: '生成视频',\n    table: {\n      number: '编号',\n      title: '标题',\n      shotType: '景别',\n      movement: '运镜',\n      location: '地点',\n      character: '角色',\n      dialogue: '对白',\n      action: '动作',\n      duration: '时长',\n      operations: '操作'\n    }\n  },\n  timeline: {\n    title: '时间线编辑器',\n    backToEditor: '返回',\n    noScenes: '暂无可用场景',\n    loadFailed: '加载分镜失败'\n  },\n  editor: {\n    backToEpisode: '返回剧集编辑',\n    episode: '第{number}集',\n    settings: '设置',\n    basicInfo: '基础信息',\n    sceneProduction: '场景制作',\n    sceneId: '场景ID',\n    sceneGenerating: '场景图片生成中...',\n    noBackground: '未关联背景',\n    cast: '登场角色',\n    addCharacter: '添加角色',\n    removeCharacter: '移除角色',\n    noCharacters: '未指定角色',\n    visualSettings: '视效设置',\n    shotType: '景别',\n    shotTypePlaceholder: '选择景别',\n    movement: '运镜方式',\n    movementPlaceholder: '运镜方式',\n    angle: '镜头角度',\n    anglePlaceholder: '镜头角度',\n    action: '动作描述',\n    actionPlaceholder: '描述角色的动作过程...',\n    result: '动作结果',\n    resultPlaceholder: '描述动作的结果...',\n    dialogue: '对白',\n    dialoguePlaceholder: '输入角色对白...',\n    soundEffects: '音效',\n    soundEffectsPlaceholder: '描述音效...',\n    transitions: '转场效果',\n    transitionsPlaceholder: '选择转场',\n    duration: '时长',\n    seconds: '秒',\n    description: '镜头描述',\n    descriptionPlaceholder: '整体镜头描述...',\n    bgmPrompt: '配乐提示',\n    bgmPromptPlaceholder: '描述配乐氛围，如：紧张激烈的背景音乐',\n    atmosphere: '环境氛围',\n    atmospherePlaceholder: '描述环境氛围，如：昱暗压抑、明亮温馨',\n    lightingEffect: '光照效果',\n    specialEffects: '特效',\n    props: '道具',\n    addProp: '添加道具',\n    addPropToShot: '添加道具到镜头',\n    removeProp: '移除道具',\n    noProps: '无道具',\n    noPropsAvailable: '暂无道具，请先在剧本管理中添加道具',\n    updatePropFailed: '更新道具失败',\n    emotionalTone: '情绪色调',\n    shotImage: '镜头图片',\n    noShotSelected: '未选择镜头',\n    selectFrameType: '选择帧类型',\n    firstFrame: '首帧',\n    lastFrame: '尾帧',\n    panelFrame: '分镜板',\n    actionSequence: '动作序列',\n    keyFrame: '关键帧',\n    panelCount: '格数',\n    prompt: '提示词',\n    extractPrompt: '提取提示词',\n    promptPlaceholder: '点击提取提示词按钮，系统将根据分镜内容生成图片提示词...',\n    generating: '生成中...',\n    generateImage: '生成图片',\n    uploadImage: '上传图片',\n    generationResult: '生成结果',\n    createGridImage: '制作宫格图片',\n    gridImageEditor: '宫格图片编辑器',\n    gridType: '宫格类型',\n    fourGrid: '四宫格',\n    sixGrid: '六宫格',\n    nineGrid: '九宫格',\n    editGridImage: '编辑宫格图片',\n    selectImage: '选择图片',\n    existingImages: '已有图片',\n    uploadNewImage: '上传图片',\n    preview: '预览',\n    replace: '替换',\n    clear: '清空',\n    creating: '制作中...',\n    createSuccess: '宫格图片制作成功',\n    createFailed: '制作失败',\n    allCellsRequired: '请填充所有宫格',\n    replaceImage: '替换图片',\n    noImagesAvailable: '暂无图片'\n  },\n  video: {\n    title: 'AI 视频生成',\n    generate: '生成视频',\n    merge: '合成视频',\n    mediaLibrary: '视频素材库',\n    videoCount: '{count} 个视频',\n    dragToTimeline: '将场景拖拽到时间线开始编辑',\n    videoTrack: '视频轨道',\n    audioTrack: '音频轨道',\n    clearTrack: '清空轨道',\n    soundAndMusic: '音效与配乐',\n    soundMusicInDev: '音效与配乐功能开发中',\n    noMergeYet: '还没有合成过视频',\n    mergeInstructions: '在时间线编辑器中排列好视频后点击“合成视频”即可',\n    selectVideoModel: '请选择视频模型',\n    mergeComplete: '视频合成完成并已下载！',\n    mergeTaskSubmitted: '视频合成任务已提交，正在后台处理...',\n    audio: '音频',\n    extractAudio: '从所有视频片段提取音频',\n    model: '模型',\n    videoGeneration: '视频生成',\n    soundAndMusicTab: '音效与配乐',\n    videoMerge: '视频合成',\n    noMergeRecords: '暂无视频合成记录',\n    transitionType: '转场类型',\n    transitionDuration: '转场时长',\n    selectTransition: '选择转场效果',\n    filter: {\n      drama: '剧本',\n      allDramas: '全部剧本',\n      status: '状态',\n      allStatus: '全部状态',\n      query: '查询',\n      reset: '重置'\n    },\n    status: {\n      pending: '等待中',\n      processing: '生成中',\n      completed: '已完成',\n      failed: '失败'\n    },\n    prompt: '提示词',\n    duration: '时长',\n    createdAt: '创建时间',\n    actions: {\n      view: '查看详情',\n      download: '下载',\n      delete: '删除'\n    }\n  },\n  asset: {\n    title: '资源库',\n    type: '资源类型',\n    upload: '上传',\n    import: '导入',\n    export: '导出'\n  },\n  genres: {\n    urban: '都市',\n    costume: '古装',\n    mystery: '悬疑',\n    romance: '爱情',\n    comedy: '喜剧'\n  },\n  tooltip: {\n    editPrompt: '修改提示词',\n    aiGenerate: 'AI生成',\n    uploadImage: '上传图片',\n    selectFromLibrary: '从角色库选择'\n  },\n  message: {\n    deleteConfirm: '确定要删除吗？',\n    deleteSuccess: '删除成功',\n    createSuccess: '创建成功',\n    updateSuccess: '更新成功',\n    operationSuccess: '操作成功',\n    operationFailed: '操作失败',\n    loadingFailed: '加载失败',\n    networkError: '网络错误'\n  }\n}\n"
  },
  {
    "path": "web/src/main.ts",
    "content": "import { createApp } from 'vue'\nimport { createPinia } from 'pinia'\nimport ElementPlus from 'element-plus'\nimport 'element-plus/dist/index.css'\nimport './assets/styles/element/index.scss'\n\nimport * as ElementPlusIconsVue from '@element-plus/icons-vue'\n\nimport App from './App.vue'\nimport router from './router'\nimport i18n from './locales'\nimport './assets/styles/main.css'\n\n// Apply theme before app mounts to prevent flash\n// 在应用挂载前应用主题，防止闪烁\nconst savedTheme = localStorage.getItem('theme')\nif (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {\n  document.documentElement.classList.add('dark')\n}\n\nconst app = createApp(App)\n\napp.use(createPinia())\napp.use(router)\napp.use(i18n)\napp.use(ElementPlus)\n\nfor (const [key, component] of Object.entries(ElementPlusIconsVue)) {\n  app.component(key, component)\n}\n\napp.mount('#app')\n"
  },
  {
    "path": "web/src/router/index.ts",
    "content": "import type { RouteRecordRaw } from 'vue-router'\nimport { createRouter, createWebHistory } from 'vue-router'\n\nconst routes: RouteRecordRaw[] = [\n  {\n    path: '/',\n    name: 'DramaList',\n    component: () => import('../views/drama/DramaList.vue')\n  },\n  {\n    path: '/dramas/create',\n    name: 'DramaCreate',\n    component: () => import('../views/drama/DramaCreate.vue')\n  },\n  {\n    path: '/dramas/:id',\n    name: 'DramaManagement',\n    component: () => import('../views/drama/DramaManagement.vue')\n  },\n  {\n    path: '/dramas/:id/episode/:episodeNumber',\n    name: 'EpisodeWorkflowNew',\n    component: () => import('../views/drama/EpisodeWorkflow.vue')\n  },\n  {\n    path: '/dramas/:id/characters',\n    name: 'CharacterExtraction',\n    component: () => import('../views/workflow/CharacterExtraction.vue')\n  },\n  {\n    path: '/dramas/:id/images/characters',\n    name: 'CharacterImages',\n    component: () => import('../views/workflow/CharacterImages.vue')\n  },\n  {\n    path: '/dramas/:id/settings',\n    name: 'DramaSettings',\n    component: () => import('../views/workflow/DramaSettings.vue')\n  },\n  {\n    path: '/episodes/:id/edit',\n    name: 'ScriptEdit',\n    component: () => import('../views/script/ScriptEdit.vue')\n  },\n  {\n    path: '/episodes/:id/storyboard',\n    name: 'StoryboardEdit',\n    component: () => import('../views/storyboard/StoryboardEdit.vue')\n  },\n  {\n    path: '/episodes/:id/generate',\n    name: 'Generation',\n    component: () => import('../views/generation/ImageGeneration.vue')\n  },\n  {\n    path: '/timeline/:id',\n    name: 'TimelineEditor',\n    component: () => import('../views/editor/TimelineEditor.vue')\n  },\n  {\n    path: '/dramas/:dramaId/episode/:episodeNumber/professional',\n    name: 'ProfessionalEditor',\n    component: () => import('../views/drama/ProfessionalEditor.vue')\n  },\n  {\n    path: '/settings/ai-config',\n    name: 'AIConfig',\n    component: () => import('../views/settings/AIConfig.vue')\n  }\n]\n\nconst router = createRouter({\n  history: createWebHistory(import.meta.env.BASE_URL),\n  routes\n})\n\n// 开源版本 - 无需认证\n\nexport default router\n"
  },
  {
    "path": "web/src/stores/episode.ts",
    "content": "import { ref, computed, reactive } from 'vue'\nimport { defineStore } from 'pinia'\nimport { dramaAPI } from '@/api/drama'\nimport type { Episode, Character, Scene } from '@/types/drama'\n\ninterface EpisodeCache {\n  data: Episode\n  loading: boolean\n  error: string | null\n  lastFetch: number\n}\n\ninterface EpisodeOperations {\n  refresh: () => Promise<void>\n  set: (params: SetOperationParams) => Promise<void>\n  del: (params: DeleteOperationParams) => Promise<void>\n  saveScript: (content: string) => Promise<void>\n  extractData: () => Promise<void>\n  generateImages: (options?: GenerateImageOptions) => Promise<void>\n  generateStoryboards: () => Promise<void>\n}\n\ninterface SetOperationParams {\n  type: 'character' | 'scene' | 'storyboard'\n  data: any\n}\n\ninterface DeleteOperationParams {\n  type: 'character' | 'scene' | 'storyboard'\n  id: string | number\n}\n\ninterface GenerateImageOptions {\n  characterIds?: number[]\n  sceneIds?: string[]\n}\n\nexport interface CachedEpisode {\n  value: Episode\n  loading: boolean\n  error: string | null\n  refresh: () => Promise<void>\n  set: (params: SetOperationParams) => Promise<void>\n  del: (params: DeleteOperationParams) => Promise<void>\n  saveScript: (content: string) => Promise<void>\n  extractData: () => Promise<void>\n  generateImages: (options?: GenerateImageOptions) => Promise<void>\n  generateStoryboards: () => Promise<void>\n}\n\nexport const useEpisodeStore = defineStore('episode', () => {\n  const caches = reactive<Map<string, EpisodeCache>>(new Map())\n\n  const getCacheByEpisodeId = (episodeId: string): CachedEpisode => {\n    if (!caches.has(episodeId)) {\n      caches.set(episodeId, {\n        data: {} as Episode,\n        loading: false,\n        error: null,\n        lastFetch: 0\n      })\n      fetchEpisode(episodeId)\n    }\n\n    const cache = caches.get(episodeId)!\n\n    const operations: EpisodeOperations = {\n      async refresh() {\n        await fetchEpisode(episodeId, true)\n      },\n\n      async set(params: SetOperationParams) {\n        const { type, data } = params\n        \n        switch (type) {\n          case 'character':\n            await dramaAPI.saveCharacters(cache.data.drama_id, [data], episodeId)\n            await fetchEpisode(episodeId, true)\n            break\n          case 'scene':\n            await dramaAPI.updateScene(data.id, data)\n            await fetchEpisode(episodeId, true)\n            break\n          case 'storyboard':\n            await dramaAPI.updateStoryboard(data.id, data)\n            await fetchEpisode(episodeId, true)\n            break\n        }\n      },\n\n      async del(params: DeleteOperationParams) {\n        const { type, id } = params\n        \n        switch (type) {\n          case 'character':\n            const characters = cache.data.characters?.filter(c => c.id !== id) || []\n            await dramaAPI.saveCharacters(cache.data.drama_id, characters, episodeId)\n            await fetchEpisode(episodeId, true)\n            break\n          case 'scene':\n            break\n          case 'storyboard':\n            break\n        }\n      },\n\n      async saveScript(content: string) {\n        const parts = episodeId.split('-')\n        const dramaId = parts[0]\n        const episodeNumber = parseInt(parts.length > 1 ? parts[1] : cache.data.episode_number?.toString() || '1')\n        \n        await dramaAPI.saveEpisodes(dramaId, [{\n          episode_number: episodeNumber,\n          script_content: content\n        }])\n        \n        await fetchEpisode(episodeId, true)\n      },\n\n      async extractData() {\n        await dramaAPI.extractBackgrounds(episodeId)\n        await fetchEpisode(episodeId, true)\n      },\n\n      async generateImages(options?: GenerateImageOptions) {\n        const promises: Promise<any>[] = []\n        \n        if (options?.characterIds && options.characterIds.length > 0) {\n          options.characterIds.forEach(id => {\n            const character = cache.data.characters?.find(c => c.id === id)\n            if (character) {\n              promises.push(\n                dramaAPI.generateSceneImage({\n                  scene_id: character.id.toString(),\n                  prompt: character.appearance || character.description || character.name,\n                  model: undefined\n                })\n              )\n            }\n          })\n        }\n\n        if (options?.sceneIds && options.sceneIds.length > 0) {\n          options.sceneIds.forEach(sceneId => {\n            promises.push(\n              dramaAPI.generateSceneImage({\n                scene_id: sceneId,\n                model: undefined\n              })\n            )\n          })\n        }\n        \n        if (promises.length > 0) {\n          await Promise.allSettled(promises)\n        }\n\n        await fetchEpisode(episodeId, true)\n      },\n      \n      async generateStoryboards() {\n        await dramaAPI.generateStoryboard(episodeId)\n        await fetchEpisode(episodeId, true)\n      }\n    }\n\n    return {\n      get value() {\n        return cache.data\n      },\n      get loading() {\n        return cache.loading\n      },\n      get error() {\n        return cache.error\n      },\n      ...operations\n    }\n  }\n\n  const fetchEpisode = async (episodeId: string, force = false) => {\n    const cache = caches.get(episodeId)\n    if (!cache) return\n\n    const now = Date.now()\n    if (!force && cache.lastFetch && (now - cache.lastFetch) < 3000) {\n      return\n    }\n\n    cache.loading = true\n    cache.error = null\n\n    try {\n      const parts = episodeId.split('-')\n      const dramaId = parts[0]\n      const episodeNumber = parts.length > 1 ? parseInt(parts[1]) : null\n      \n      const drama = await dramaAPI.get(dramaId)\n      \n      let episode: Episode | undefined\n      if (episodeNumber !== null) {\n        episode = drama.episodes?.find(e => e.episode_number === episodeNumber)\n      } else {\n        episode = drama.episodes?.find(e => e.id === episodeId)\n      }\n      \n      if (episode) {\n        cache.data = episode\n        cache.lastFetch = now\n      } else {\n        cache.error = '未找到章节数据'\n      }\n    } catch (error: any) {\n      cache.error = error.message || '加载章节数据失败'\n      console.error('Failed to fetch episode:', error)\n    } finally {\n      cache.loading = false\n    }\n  }\n\n  const clearCache = (episodeId?: string) => {\n    if (episodeId) {\n      caches.delete(episodeId)\n    } else {\n      caches.clear()\n    }\n  }\n\n  return {\n    getCacheByEpisodeId,\n    clearCache\n  }\n})\n"
  },
  {
    "path": "web/src/types/ai.ts",
    "content": "export interface AIServiceConfig {\n  id: number\n  service_type: AIServiceType\n  provider?: string  // 厂商标识\n  name: string\n  base_url: string\n  api_key: string\n  model: string | string[]  // 支持单个或多个模型\n  endpoint: string\n  query_endpoint?: string  // 异步查询端点（用于视频等异步任务）\n  priority: number  // 优先级，数值越大优先级越高\n  is_active: boolean\n  settings?: string\n  created_at: string\n  updated_at: string\n}\n\nexport type AIServiceType = 'text' | 'image' | 'video'\n\nexport interface CreateAIConfigRequest {\n  service_type: AIServiceType\n  provider?: string  // 厂商标识\n  name: string\n  base_url: string\n  api_key: string\n  model: string | string[]  // 支持单个或多个模型\n  endpoint?: string\n  query_endpoint?: string  // 异步查询端点（用于视频等异步任务）\n  priority?: number  // 优先级，数值越大优先级越高\n  settings?: string\n}\n\nexport interface UpdateAIConfigRequest {\n  name?: string\n  provider?: string  // 厂商标识\n  base_url?: string\n  api_key?: string\n  model?: string | string[]  // 支持单个或多个模型\n  endpoint?: string\n  query_endpoint?: string  // 异步查询端点（用于视频等异步任务）\n  priority?: number  // 优先级，数值越大优先级越高\n  is_active?: boolean\n  settings?: string\n}\n\nexport interface TestConnectionRequest {\n  base_url: string\n  api_key: string\n  model: string | string[]  // 支持单个或多个模型\n  provider?: string  // 厂商标识\n  endpoint?: string\n  query_endpoint?: string  // 异步查询端点（用于视频等异步任务）\n}\n\nexport interface AIServiceProvider {\n  id: number\n  name: string\n  display_name: string\n  service_type: AIServiceType\n  default_url: string\n  description: string\n  is_active: boolean\n  created_at: string\n  updated_at: string\n}\n"
  },
  {
    "path": "web/src/types/asset.ts",
    "content": "export interface Asset {\n  id: number\n  drama_id?: number\n  episode_id?: number\n  storyboard_id?: number\n  storyboard_num?: number\n  name: string\n  description?: string\n  type: AssetType\n  category?: string\n  url: string\n  thumbnail_url?: string\n  local_path?: string\n  file_size?: number\n  mime_type?: string\n  width?: number\n  height?: number\n  duration?: number\n  format?: string\n  image_gen_id?: number\n  video_gen_id?: number\n  tags?: AssetTag[]\n  collections?: AssetCollection[]\n  is_favorite: boolean\n  view_count: number\n  created_at: string\n  updated_at: string\n}\n\nexport type AssetType = 'image' | 'video' | 'audio'\n\nexport interface AssetTag {\n  id: number\n  name: string\n  color?: string\n  created_at: string\n}\n\nexport interface AssetCollection {\n  id: number\n  drama_id?: number\n  name: string\n  description?: string\n  assets?: Asset[]\n  created_at: string\n}\n\nexport interface CreateAssetRequest {\n  drama_id?: number\n  name: string\n  description?: string\n  type: AssetType\n  category?: string\n  url: string\n  thumbnail_url?: string\n  local_path?: string\n  file_size?: number\n  mime_type?: string\n  width?: number\n  height?: number\n  duration?: number\n  format?: string\n  image_gen_id?: number\n  video_gen_id?: number\n  tag_ids?: number[]\n}\n\nexport interface UpdateAssetRequest {\n  name?: string\n  description?: string\n  category?: string\n  thumbnail_url?: string\n  tag_ids?: number[]\n  is_favorite?: boolean\n}\n\nexport interface ListAssetsParams {\n  drama_id?: string\n  episode_id?: number\n  storyboard_id?: number\n  type?: 'image' | 'video' | 'audio'\n  category?: string\n  tag_ids?: number[]\n  is_favorite?: boolean\n  search?: string\n  page?: number\n  page_size?: number\n}\n\nexport const ASSET_CATEGORIES = {\n  image: ['角色', '场景', '道具', '背景', '其他'],\n  video: ['分镜', '特效', '片头', '片尾', '其他'],\n  audio: ['配音', '音效', '背景音乐', '片头曲', '片尾曲', '其他']\n}\n"
  },
  {
    "path": "web/src/types/drama.ts",
    "content": "import { Prop } from './prop'\n\nexport interface Drama {\n  id: string\n\n  title: string\n  description?: string\n  genre?: string\n  style?: string\n  total_episodes: number\n  total_duration: number\n  total_scenes?: number\n  duration?: number\n  status: DramaStatus\n  thumbnail?: string\n  tags?: any\n  metadata?: any\n  created_at: string\n  updated_at: string\n  characters?: Character[]\n  episodes?: Episode[]\n  scenes?: Scene[]\n  props?: Prop[]\n}\n\nexport type DramaStatus = 'draft' | 'planning' | 'production' | 'completed' | 'archived' | 'generating' | 'error'\n\nexport interface Character {\n  id: number\n  drama_id: string\n  name: string\n  role?: string\n  description?: string\n  appearance?: string\n  personality?: string\n  voice_style?: string\n  background?: string\n  reference_images?: any\n  seed_value?: string\n  sort_order?: number\n  image_url?: string\n  local_path?: string\n  image_generation_status?: string\n  image_generation_error?: string\n  created_at: string\n  updated_at: string\n}\n\nexport interface Episode {\n  id: string\n  drama_id: string\n  episode_number: number\n  title: string\n  content: string\n  description?: string\n  script_content?: string\n  duration?: number\n  status: string\n  video_url?: string\n  thumbnail?: string\n  storyboard_count?: number\n  scene_count?: number\n  composition_count?: number\n  video_count?: number\n  timeline_status?: string\n  storyboards?: Storyboard[]\n  scenes?: Scene[]\n  characters?: Character[]\n  shots?: any[]\n  created_at: string\n  updated_at: string\n}\n\nexport interface Storyboard {\n  id: string\n  episode_id: string\n  storyboard_number: number\n  title?: string\n  description?: string\n  location?: string\n  time?: string\n  duration?: number\n  dialogue?: string\n  action?: string\n  atmosphere?: string\n  image_prompt?: string\n  video_prompt?: string\n  characters?: any\n  image_url?: string\n  video_url?: string\n  composed_image?: string\n  scene_id?: string\n  scene?: Scene\n  created_at: string\n  updated_at: string\n  [key: string]: any\n}\n\nexport interface Scene {\n  id: string\n  drama_id: string\n  location: string\n  time: string\n  prompt: string\n  description?: string\n  title?: string\n  storyboard_number?: number\n  storyboard_count?: number\n  image_url?: string\n  local_path?: string\n  video_url?: string\n  status: string\n  image_generation_status?: string\n  image_generation_error?: string\n  created_at: string\n  updated_at: string\n}\n\nexport interface CreateDramaRequest {\n  title: string\n  description?: string\n  genre?: string\n  style?: string\n  tags?: string\n}\n\nexport interface UpdateDramaRequest {\n  title?: string\n  description?: string\n  genre?: string\n  style?: string\n  tags?: string\n  status?: DramaStatus\n}\n\nexport interface DramaListQuery {\n  page?: number\n  page_size?: number\n  status?: DramaStatus\n  genre?: string\n  keyword?: string\n}\n\nexport interface DramaStats {\n  total: number\n  by_status: Array<{\n    status: string\n    count: number\n  }>\n}\n"
  },
  {
    "path": "web/src/types/generation.ts",
    "content": "export interface GenerateCharactersRequest {\n  drama_id: string\n  episode_id?: number\n  outline?: string\n  count?: number\n  temperature?: number\n  model?: string  // 指定使用的文本模型\n}\n\nexport interface ParseScriptRequest {\n  drama_id: string\n  script_content: string\n  auto_split?: boolean\n}\n\nexport interface ParseScriptResult {\n  episodes: ParsedEpisode[]\n  characters: ParsedCharacter[]\n  summary: string\n}\n\nexport interface ParsedCharacter {\n  name: string\n  role: string\n  description: string\n  personality: string\n}\n\nexport interface ParsedEpisode {\n  episode_number: number\n  title: string\n  description: string\n  script_content: string\n  duration: number\n  chapter_start?: number\n  chapter_end?: number\n  start_marker?: string\n  end_marker?: string\n}\n"
  },
  {
    "path": "web/src/types/image.ts",
    "content": "export interface ImageGeneration {\n  id: number\n  storyboard_id?: number\n  scene_id?: string\n  drama_id: string\n  character_id?: number\n  image_type?: string\n  frame_type?: string\n  provider: string\n  prompt: string\n  negative_prompt?: string\n  model: string\n  size?: string\n  quality?: string\n  style?: string\n  steps?: number\n  cfg_scale?: number\n  seed?: number\n  image_url?: string\n  image_generation?: any\n  local_path?: string\n  status: ImageStatus\n  task_id?: string\n  error_msg?: string\n  width?: number\n  height?: number\n  created_at: string\n  updated_at: string\n  completed_at?: string\n}\n\nexport type ImageStatus = 'pending' | 'processing' | 'completed' | 'failed'\n\nexport type ImageProvider = 'openai' | 'dalle' | 'midjourney' | 'stable_diffusion' | 'sd'\n\nexport interface GenerateImageRequest {\n  scene_id?: number\n  storyboard_id?: number\n  drama_id: string\n  image_type?: string\n  frame_type?: string\n  prompt: string\n  negative_prompt?: string\n  reference_images?: string[]\n  provider?: string\n  model?: string\n  size?: string\n  quality?: string\n  style?: string\n  steps?: number\n  cfg_scale?: number\n  seed?: number\n  width?: number\n  height?: number\n}\n\nexport interface ImageGenerationListParams {\n  drama_id?: string\n  scene_id?: string\n  storyboard_id?: number\n  frame_type?: string\n  status?: ImageStatus\n  page?: number\n  page_size?: number\n}\n"
  },
  {
    "path": "web/src/types/prop.ts",
    "content": "export interface Prop {\n    id: number\n    drama_id: number\n    name: string\n    type?: string\n    description?: string\n    prompt?: string\n    image_url?: string\n    reference_images?: any\n    created_at: string\n    updated_at: string\n}\n\nexport interface CreatePropRequest {\n    drama_id: number\n    name: string\n    type?: string\n    description?: string\n    prompt?: string\n    image_url?: string\n}\n\nexport interface UpdatePropRequest {\n    name?: string\n    type?: string\n    description?: string\n    prompt?: string\n    image_url?: string\n}\n"
  },
  {
    "path": "web/src/types/timeline.ts",
    "content": "import type { Asset } from './asset'\n\nexport interface Timeline {\n  id: number\n  drama_id: number\n  episode_id?: number\n  name: string\n  description?: string\n  duration: number\n  fps: number\n  resolution?: string\n  status: TimelineStatus\n  tracks?: TimelineTrack[]\n  created_at: string\n  updated_at: string\n}\n\nexport type TimelineStatus = 'draft' | 'editing' | 'completed' | 'exporting'\n\nexport interface TimelineTrack {\n  id: number\n  timeline_id: number\n  name: string\n  type: TrackType\n  order: number\n  is_locked: boolean\n  is_muted: boolean\n  volume?: number\n  clips?: TimelineClip[]\n  created_at: string\n}\n\nexport type TrackType = 'video' | 'audio' | 'text'\n\nexport interface TimelineClip {\n  id: number\n  track_id: number\n  asset_id?: number\n  asset?: Asset\n  scene_id?: number\n  name: string\n  start_time: number\n  end_time: number\n  duration: number\n  trim_start?: number\n  trim_end?: number\n  speed?: number\n  volume?: number\n  is_muted: boolean\n  fade_in?: number\n  fade_out?: number\n  transition_in_id?: number\n  transition_out_id?: number\n  in_transition?: ClipTransition\n  out_transition?: ClipTransition\n  effects?: ClipEffect[]\n  created_at: string\n}\n\nexport interface ClipTransition {\n  id: number\n  type: TransitionType\n  duration: number\n  easing?: string\n  config?: Record<string, any>\n}\n\nexport type TransitionType = 'fade' | 'crossfade' | 'slide' | 'wipe' | 'zoom' | 'dissolve'\n\nexport interface ClipEffect {\n  id: number\n  clip_id: number\n  type: EffectType\n  name: string\n  is_enabled: boolean\n  order: number\n  config?: Record<string, any>\n}\n\nexport type EffectType = 'filter' | 'color' | 'blur' | 'brightness' | 'contrast' | 'saturation'\n\nexport interface CreateTimelineRequest {\n  drama_id: number\n  episode_id?: number\n  name: string\n  description?: string\n  fps?: number\n  resolution?: string\n}\n\nexport interface UpdateTimelineRequest {\n  name?: string\n  description?: string\n  fps?: number\n  resolution?: string\n  status?: TimelineStatus\n}\n\nexport interface CreateTrackRequest {\n  name: string\n  type: TrackType\n  order?: number\n  volume?: number\n}\n\nexport interface UpdateTrackRequest {\n  name?: string\n  order?: number\n  is_locked?: boolean\n  is_muted?: boolean\n  volume?: number\n}\n\nexport interface CreateClipRequest {\n  track_id: number\n  asset_id?: number\n  scene_id?: number\n  name?: string\n  start_time: number\n  duration: number\n  trim_start?: number\n  trim_end?: number\n  speed?: number\n  volume?: number\n  fade_in?: number\n  fade_out?: number\n}\n\nexport interface UpdateClipRequest {\n  name?: string\n  start_time?: number\n  duration?: number\n  trim_start?: number\n  trim_end?: number\n  speed?: number\n  volume?: number\n  is_muted?: boolean\n  fade_in?: number\n  fade_out?: number\n}\n\nexport interface CreateTransitionRequest {\n  type: TransitionType\n  duration: number\n  easing?: string\n  config?: Record<string, any>\n}\n\nexport const TRANSITION_TYPES = [\n  { label: '淡入淡出', value: 'fade' },\n  { label: '交叉淡化', value: 'crossfade' },\n  { label: '滑动', value: 'slide' },\n  { label: '擦除', value: 'wipe' },\n  { label: '缩放', value: 'zoom' },\n  { label: '溶解', value: 'dissolve' }\n]\n\nexport const EFFECT_TYPES = [\n  { label: '滤镜', value: 'filter' },\n  { label: '色彩', value: 'color' },\n  { label: '模糊', value: 'blur' },\n  { label: '亮度', value: 'brightness' },\n  { label: '对比度', value: 'contrast' },\n  { label: '饱和度', value: 'saturation' }\n]\n"
  },
  {
    "path": "web/src/types/user.ts",
    "content": "export interface User {\n  id: number\n  username: string\n  email: string\n  avatar?: string\n  nickname?: string\n  phone?: string\n  role: string\n  status: number\n  created_at: string\n}\n\nexport interface UserConfig {\n  text_provider: string\n  text_model: string\n  text_api_key_set: boolean\n  image_provider: string\n  image_model: string\n  image_api_key_set: boolean\n  video_provider: string\n  video_model: string\n  video_api_key_set: boolean\n  default_style: string\n  default_resolution: string\n  default_fps: number\n}\n"
  },
  {
    "path": "web/src/types/video.ts",
    "content": "export interface VideoGeneration {\n  id: number\n  storyboard_id?: number\n  scene_id?: string  // 已废弃，保留用于兼容\n  drama_id: string\n  image_gen_id?: number\n  provider: string\n  prompt: string\n  model?: string\n  image_url?: string\n  first_frame_url?: string\n  duration?: number\n  fps?: number\n  resolution?: string\n  aspect_ratio?: string\n  style?: string\n  motion_level?: number\n  camera_motion?: string\n  seed?: number\n  video_url?: string\n  local_path?: string\n  status: VideoStatus\n  task_id?: string\n  error_msg?: string\n  width?: number\n  height?: number\n  created_at: string\n  updated_at: string\n  completed_at?: string\n}\n\nexport type VideoStatus = 'pending' | 'processing' | 'completed' | 'failed'\n\nexport type VideoProvider = 'runway' | 'pika' | 'doubao' | 'openai'\n\nexport interface GenerateVideoRequest {\n  storyboard_id?: number\n  scene_id?: string  // 已废弃，保留用于兼容\n  drama_id: string\n  image_gen_id?: number\n  image_url?: string\n  prompt: string\n  provider?: string\n  model?: string\n  duration?: number\n  fps?: number\n  aspect_ratio?: string\n  style?: string\n  motion_level?: number\n  camera_motion?: string\n  seed?: number\n  reference_mode?: string   // 参考图模式：single, first_last, multiple, none\n  first_frame_url?: string  // 首帧图片URL\n  last_frame_url?: string   // 尾帧图片URL\n  reference_image_urls?: string[]  // 多图参考模式\n}\n\nexport interface VideoGenerationListParams {\n  drama_id?: string\n  storyboard_id?: string\n  scene_id?: string  // 已废弃，保留用于兼容\n  status?: string  // 支持单个状态或逗号分隔的多个状态，如 \"pending,processing\"\n  page?: number\n  page_size?: number\n}\n\nexport const VIDEO_ASPECT_RATIOS = [\n  { label: '16:9 (横屏)', value: '16:9' },\n  { label: '9:16 (竖屏)', value: '9:16' },\n  { label: '1:1 (正方形)', value: '1:1' },\n  { label: '4:3 (传统)', value: '4:3' }\n]\n\nexport const CAMERA_MOTIONS = [\n  { label: '静止', value: 'static' },\n  { label: '推进', value: 'zoom_in' },\n  { label: '拉远', value: 'zoom_out' },\n  { label: '左移', value: 'pan_left' },\n  { label: '右移', value: 'pan_right' },\n  { label: '上移', value: 'tilt_up' },\n  { label: '下移', value: 'tilt_down' },\n  { label: '环绕', value: 'orbit' }\n]\n"
  },
  {
    "path": "web/src/utils/ffmpeg.ts",
    "content": "import { FFmpeg } from '@ffmpeg/ffmpeg'\nimport { fetchFile, toBlobURL } from '@ffmpeg/util'\n\nlet ffmpegInstance: FFmpeg | null = null\nlet loadPromise: Promise<FFmpeg> | null = null\n\nexport interface VideoTrimOptions {\n  startTime: number\n  endTime: number\n}\n\nexport interface VideoMergeOptions {\n  clips: Array<{\n    url: string\n    startTime?: number\n    endTime?: number\n  }>\n}\n\nexport interface ProgressCallback {\n  (progress: number): void\n}\n\nasync function getFFmpeg(): Promise<FFmpeg> {\n  if (ffmpegInstance) {\n    return ffmpegInstance\n  }\n\n  if (loadPromise) {\n    return loadPromise\n  }\n\n  loadPromise = (async () => {\n    const ffmpeg = new FFmpeg()\n\n    ffmpeg.on('log', ({ message }) => {\n      console.log('[FFmpeg]', message)\n    })\n\n    const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd'\n    await ffmpeg.load({\n      coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),\n      wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm')\n    })\n\n    ffmpegInstance = ffmpeg\n    return ffmpeg\n  })()\n\n  return loadPromise\n}\n\nexport async function trimVideo(\n  videoUrl: string,\n  options: VideoTrimOptions,\n  onProgress?: ProgressCallback\n): Promise<Blob> {\n  const ffmpeg = await getFFmpeg()\n\n  if (onProgress) onProgress(10)\n\n  const inputFileName = 'input.mp4'\n  const outputFileName = 'output.mp4'\n\n  await ffmpeg.writeFile(inputFileName, await fetchFile(videoUrl))\n\n  if (onProgress) onProgress(30)\n\n  const args = [\n    '-i', inputFileName,\n    '-ss', options.startTime.toString(),\n    '-to', options.endTime.toString(),\n    '-c', 'copy',\n    '-avoid_negative_ts', '1',\n    outputFileName\n  ]\n\n  await ffmpeg.exec(args)\n\n  if (onProgress) onProgress(80)\n\n  const data = await ffmpeg.readFile(outputFileName) as Uint8Array\n\n  await ffmpeg.deleteFile(inputFileName)\n  await ffmpeg.deleteFile(outputFileName)\n\n  if (onProgress) onProgress(100)\n\n  return new Blob([new Uint8Array(data)], { type: 'video/mp4' })\n}\n\nexport async function mergeVideos(\n  options: VideoMergeOptions,\n  onProgress?: ProgressCallback\n): Promise<Blob> {\n  const ffmpeg = await getFFmpeg()\n\n  if (onProgress) onProgress(5)\n\n  const tempFiles: string[] = []\n  \n  for (let i = 0; i < options.clips.length; i++) {\n    const clip = options.clips[i]\n    const fileName = `clip_${i}.mp4`\n    \n    await ffmpeg.writeFile(fileName, await fetchFile(clip.url))\n    tempFiles.push(fileName)\n    \n    if (onProgress) {\n      onProgress(5 + (i + 1) / options.clips.length * 40)\n    }\n  }\n\n  const listContent = tempFiles.map(file => `file '${file}'`).join('\\n')\n  await ffmpeg.writeFile('filelist.txt', new TextEncoder().encode(listContent))\n\n  if (onProgress) onProgress(50)\n\n  await ffmpeg.exec([\n    '-f', 'concat',\n    '-safe', '0',\n    '-i', 'filelist.txt',\n    '-c', 'copy',\n    'output.mp4'\n  ])\n\n  if (onProgress) onProgress(90)\n\n  const data = await ffmpeg.readFile('output.mp4') as Uint8Array\n\n  for (const file of tempFiles) {\n    await ffmpeg.deleteFile(file)\n  }\n  await ffmpeg.deleteFile('filelist.txt')\n  await ffmpeg.deleteFile('output.mp4')\n\n  if (onProgress) onProgress(100)\n\n  return new Blob([new Uint8Array(data)], { type: 'video/mp4' })\n}\n\nexport async function trimAndMergeVideos(\n  clips: Array<{\n    url: string\n    startTime: number\n    endTime: number\n  }>,\n  onProgress?: ProgressCallback\n): Promise<Blob> {\n  const ffmpeg = await getFFmpeg()\n\n  if (onProgress) onProgress(5)\n\n  const trimmedFiles: string[] = []\n  \n  for (let i = 0; i < clips.length; i++) {\n    const clip = clips[i]\n    const inputName = `input_${i}.mp4`\n    const outputName = `trimmed_${i}.mp4`\n    \n    await ffmpeg.writeFile(inputName, await fetchFile(clip.url))\n    \n    await ffmpeg.exec([\n      '-i', inputName,\n      '-ss', clip.startTime.toString(),\n      '-to', clip.endTime.toString(),\n      '-c', 'copy',\n      '-avoid_negative_ts', '1',\n      outputName\n    ])\n    \n    await ffmpeg.deleteFile(inputName)\n    trimmedFiles.push(outputName)\n    \n    if (onProgress) {\n      onProgress(5 + (i + 1) / clips.length * 60)\n    }\n  }\n\n  const listContent = trimmedFiles.map(file => `file '${file}'`).join('\\n')\n  await ffmpeg.writeFile('filelist.txt', new TextEncoder().encode(listContent))\n\n  if (onProgress) onProgress(70)\n\n  await ffmpeg.exec([\n    '-f', 'concat',\n    '-safe', '0',\n    '-i', 'filelist.txt',\n    '-c', 'copy',\n    'final.mp4'\n  ])\n\n  if (onProgress) onProgress(95)\n\n  const data = await ffmpeg.readFile('final.mp4') as Uint8Array\n\n  for (const file of trimmedFiles) {\n    await ffmpeg.deleteFile(file)\n  }\n  await ffmpeg.deleteFile('filelist.txt')\n  await ffmpeg.deleteFile('final.mp4')\n\n  if (onProgress) onProgress(100)\n\n  return new Blob([new Uint8Array(data)], { type: 'video/mp4' })\n}\n\nexport async function isFFmpegLoaded(): Promise<boolean> {\n  return ffmpegInstance !== null\n}\n\nexport async function unloadFFmpeg(): Promise<void> {\n  if (ffmpegInstance) {\n    await ffmpegInstance.terminate()\n    ffmpegInstance = null\n    loadPromise = null\n  }\n}\n"
  },
  {
    "path": "web/src/utils/image.ts",
    "content": "/**\n * 图片URL工具函数\n */\n\n/**\n * 修复图片URL，处理相对路径和绝对路径\n */\nexport function fixImageUrl(url: string): string {\n  if (!url) return \"\";\n  if (url.startsWith(\"http\") || url.startsWith(\"data:\")) return url;\n  return `${import.meta.env.VITE_API_BASE_URL || \"\"}${url}`;\n}\n\n/**\n * 获取图片URL，优先使用 local_path\n * @param item 包含 local_path 或 image_url 的对象\n * @returns 处理后的图片URL\n */\nexport function getImageUrl(item: any): string {\n  if (!item) return \"\";\n\n  // 优先使用 local_path\n  if (item.local_path) {\n    // local_path 是相对路径（如 images/xxx.jpg），需要添加 /static/ 前缀\n    return `/static/${item.local_path}`;\n  }\n\n  // 回退到 image_url\n  if (item.image_url) {\n    return fixImageUrl(item.image_url);\n  }\n\n  return \"\";\n}\n\n/**\n * 检查是否有图片\n */\nexport function hasImage(item: any): boolean {\n  return !!(item?.local_path || item?.image_url);\n}\n\n/**\n * 获取视频URL，优先使用 local_path\n * @param item 包含 local_path 或 video_url 或 url 的对象\n * @returns 处理后的视频URL\n */\nexport function getVideoUrl(item: any): string {\n  if (!item) return \"\";\n\n  // 优先使用 local_path\n  if (item.local_path) {\n    // 如果 local_path 已经是完整 URL，直接返回\n    if (item.local_path.startsWith(\"http\")) {\n      return item.local_path;\n    }\n    // 否则添加 /static/ 前缀\n    return `/static/${item.local_path}`;\n  }\n\n  // 回退到 video_url\n  if (item.video_url) {\n    return fixImageUrl(item.video_url);\n  }\n\n  // 回退到 url（用于 assets）\n  if (item.url) {\n    return fixImageUrl(item.url);\n  }\n\n  return \"\";\n}\n\n/**\n * 检查是否有视频\n */\nexport function hasVideo(item: any): boolean {\n  return !!(item?.local_path || item?.video_url || item?.url);\n}\n"
  },
  {
    "path": "web/src/utils/request.ts",
    "content": "import type { AxiosError, AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'\nimport axios from 'axios'\nimport { ElMessage } from 'element-plus'\n\ninterface CustomAxiosInstance extends Omit<AxiosInstance, 'get' | 'post' | 'put' | 'patch' | 'delete'> {\n  get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>\n  post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>\n  put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>\n  patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>\n  delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>\n}\n\nconst request = axios.create({\n  baseURL: '/api/v1',\n  timeout: 600000, // 10分钟超时，匹配后端AI生成接口\n  headers: {\n    'Content-Type': 'application/json'\n  }\n}) as CustomAxiosInstance\n\n// 开源版本 - 无需认证token\nrequest.interceptors.request.use(\n  (config: InternalAxiosRequestConfig) => {\n    return config\n  },\n  (error: AxiosError) => {\n    return Promise.reject(error)\n  }\n)\n\nrequest.interceptors.response.use(\n  (response) => {\n    const res = response.data\n    if (res.success) {\n      return res.data\n    } else {\n      // 不在这里显示错误提示，让业务代码自行处理\n      return Promise.reject(new Error(res.error?.message || '请求失败'))\n    }\n  },\n  (error: AxiosError<any>) => {\n    // 不在拦截器中自动显示错误提示，让业务代码根据具体情况处理\n    // 只抛出错误供调用者捕获\n    return Promise.reject(error)\n  }\n)\n\nexport default request\n"
  },
  {
    "path": "web/src/utils/videoMerger.ts",
    "content": "import { FFmpeg } from '@ffmpeg/ffmpeg'\nimport { fetchFile, toBlobURL } from '@ffmpeg/util'\n\nexport interface VideoClip {\n  url: string\n  startTime: number\n  endTime: number\n  duration: number\n  transition?: TransitionEffect\n}\n\nexport type TransitionType = 'fade' | 'fadeblack' | 'fadewhite' | 'slideleft' | 'slideright' | 'slideup' | 'slidedown' | 'wipeleft' | 'wiperight' | 'circleopen' | 'circleclose' | 'none'\n\nexport interface TransitionEffect {\n  type: TransitionType\n  duration: number // 转场时长（秒）\n}\n\nexport interface MergeProgress {\n  phase: 'loading' | 'processing' | 'encoding' | 'completed'\n  progress: number\n  message: string\n}\n\nclass VideoMerger {\n  private ffmpeg: FFmpeg\n  private loaded: boolean = false\n  private onProgress?: (progress: MergeProgress) => void\n\n  constructor() {\n    this.ffmpeg = new FFmpeg()\n  }\n\n  async initialize(onProgress?: (progress: MergeProgress) => void) {\n    if (this.loaded) return\n\n    this.onProgress = onProgress\n    \n    this.onProgress?.({\n      phase: 'loading',\n      progress: 0,\n      message: '正在加载FFmpeg引擎（首次需要下载约30MB）...'\n    })\n\n    // CDN列表（优先使用国内CDN）\n    const cdnList = [\n      'https://unpkg.zhimg.com/@ffmpeg/core@0.12.6/dist/esm',        // 知乎CDN镜像（国内）\n      'https://npm.elemecdn.com/@ffmpeg/core@0.12.6/dist/esm',       // 饿了么CDN（国内）\n      'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm',   // jsDelivr（全球CDN，国内可用）\n      'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm',              // unpkg（国外）\n    ]\n    \n    this.ffmpeg.on('log', ({ message }) => {\n      console.log('[FFmpeg]', message)\n    })\n\n    this.ffmpeg.on('progress', ({ progress, time }) => {\n      this.onProgress?.({\n        phase: 'encoding',\n        progress: Math.round(progress * 100),\n        message: `正在合并视频... ${Math.round(progress * 100)}%`\n      })\n    })\n\n    // 尝试多个CDN源\n    let lastError: Error | null = null\n    for (let i = 0; i < cdnList.length; i++) {\n      const baseURL = cdnList[i]\n      \n      try {\n        this.onProgress?.({\n          phase: 'loading',\n          progress: (i / cdnList.length) * 50,\n          message: `正在从CDN ${i + 1}/${cdnList.length} 加载FFmpeg...`\n        })\n\n        // 添加超时控制\n        const loadPromise = this.ffmpeg.load({\n          coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),\n          wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),\n        })\n\n        const timeoutPromise = new Promise((_, reject) => {\n          setTimeout(() => reject(new Error('加载超时')), 60000) // 60秒超时\n        })\n\n        await Promise.race([loadPromise, timeoutPromise])\n        \n        // 加载成功\n        this.loaded = true\n        \n        this.onProgress?.({\n          phase: 'loading',\n          progress: 100,\n          message: 'FFmpeg加载完成'\n        })\n        \n        return\n      } catch (error) {\n        console.error(`CDN ${i + 1} 加载失败:`, error)\n        lastError = error as Error\n        \n        if (i < cdnList.length - 1) {\n          this.onProgress?.({\n            phase: 'loading',\n            progress: ((i + 1) / cdnList.length) * 50,\n            message: `CDN ${i + 1} 失败，尝试备用源...`\n          })\n        }\n      }\n    }\n\n    // 所有CDN都失败\n    throw new Error(`FFmpeg加载失败: ${lastError?.message || '未知错误'}。请检查网络连接或稍后重试。`)\n  }\n\n  async mergeVideos(clips: VideoClip[]): Promise<Blob> {\n    if (!this.loaded) {\n      await this.initialize(this.onProgress)\n    }\n\n    if (clips.length === 0) {\n      throw new Error('没有视频片段')\n    }\n\n    this.onProgress?.({\n      phase: 'processing',\n      progress: 0,\n      message: '正在下载视频片段...'\n    })\n\n    // 并行下载所有视频文件\n    this.onProgress?.({\n      phase: 'processing',\n      progress: 0,\n      message: `正在下载 ${clips.length} 个视频片段...`\n    })\n\n    const downloadPromises = clips.map((clip, i) => \n      fetchFile(clip.url).then(data => ({ index: i, data }))\n    )\n    \n    const downloads = await Promise.all(downloadPromises)\n    \n    this.onProgress?.({\n      phase: 'processing',\n      progress: 30,\n      message: '下载完成，正在处理视频...'\n    })\n\n    // 写入文件系统并处理\n    const inputFiles: string[] = []\n    for (let i = 0; i < clips.length; i++) {\n      const clip = clips[i]\n      const download = downloads.find(d => d.index === i)!\n      const inputFileName = `input${i}.mp4`\n      const outputFileName = `clip${i}.mp4`\n      \n      // 写入原始视频\n      await this.ffmpeg.writeFile(inputFileName, download.data)\n\n      // 如果需要裁剪，先裁剪视频\n      if (clip.startTime > 0 || clip.endTime < clip.duration) {\n        this.onProgress?.({\n          phase: 'processing',\n          progress: Math.round(30 + (i / clips.length) * 20),\n          message: `正在裁剪视频片段 ${i + 1}/${clips.length}...`\n        })\n\n        await this.ffmpeg.exec([\n          '-i', inputFileName,\n          '-ss', clip.startTime.toString(),\n          '-t', (clip.endTime - clip.startTime).toString(),\n          '-c', 'copy',\n          outputFileName\n        ])\n        \n        inputFiles.push(outputFileName)\n        await this.ffmpeg.deleteFile(inputFileName)\n      } else {\n        inputFiles.push(inputFileName)\n      }\n    }\n\n    this.onProgress?.({\n      phase: 'processing',\n      progress: 50,\n      message: '正在准备合并...'\n    })\n\n    // 检查是否有转场效果\n    const hasTransitions = clips.some(clip => clip.transition && clip.transition.type !== 'none')\n\n    if (!hasTransitions || clips.length === 1) {\n      // 没有转场效果，使用简单的concat方式（更快）\n      const concatContent = inputFiles.map(f => `file '${f}'`).join('\\n')\n      await this.ffmpeg.writeFile('concat.txt', concatContent)\n\n      this.onProgress?.({\n        phase: 'encoding',\n        progress: 0,\n        message: '正在合并视频...'\n      })\n\n      await this.ffmpeg.exec([\n        '-f', 'concat',\n        '-safe', '0',\n        '-i', 'concat.txt',\n        '-c', 'copy',\n        '-movflags', '+faststart',\n        'output.mp4'\n      ])\n    } else {\n      // 有转场效果，使用filter_complex（需要重新编码）\n      this.onProgress?.({\n        phase: 'encoding',\n        progress: 0,\n        message: '正在添加转场效果并合并视频（这需要较长时间）...'\n      })\n\n      await this.mergeWithTransitions(inputFiles, clips)\n    }\n\n    this.onProgress?.({\n      phase: 'completed',\n      progress: 90,\n      message: '正在生成最终文件...'\n    })\n\n    // 读取输出文件\n    const data = await this.ffmpeg.readFile('output.mp4')\n    const blob = new Blob([data], { type: 'video/mp4' })\n\n    // 清理临时文件\n    for (const file of inputFiles) {\n      await this.ffmpeg.deleteFile(file)\n    }\n    await this.ffmpeg.deleteFile('concat.txt')\n    await this.ffmpeg.deleteFile('output.mp4')\n\n    this.onProgress?.({\n      phase: 'completed',\n      progress: 100,\n      message: '合并完成！'\n    })\n\n    return blob\n  }\n\n  private async mergeWithTransitions(inputFiles: string[], clips: VideoClip[]) {\n    // 构建FFmpeg filter_complex命令\n    const filterParts: string[] = []\n    const inputs: string[] = []\n    \n    // 为每个输入添加标签\n    for (let i = 0; i < inputFiles.length; i++) {\n      inputs.push('-i', inputFiles[i])\n      filterParts.push(`[${i}:v]setpts=PTS-STARTPTS[v${i}]`)\n      filterParts.push(`[${i}:a]asetpts=PTS-STARTPTS[a${i}]`)\n    }\n    \n    // 构建转场链\n    let videoChain = 'v0'\n    let audioChain = 'a0'\n    \n    for (let i = 1; i < clips.length; i++) {\n      const transition = clips[i].transition\n      const transType = transition?.type || 'fade'\n      const transDuration = transition?.duration || 1.0\n      \n      const offset = clips.slice(0, i).reduce((sum, c) => sum + c.duration, 0) - transDuration\n      \n      // 视频转场\n      const xfadeFilter = this.getXfadeFilter(transType, transDuration, offset)\n      filterParts.push(`[${videoChain}][v${i}]${xfadeFilter}[v${i}out]`)\n      videoChain = `v${i}out`\n      \n      // 音频交叉淡入淡出\n      filterParts.push(`[${audioChain}][a${i}]acrossfade=d=${transDuration}:c1=tri:c2=tri[a${i}out]`)\n      audioChain = `a${i}out`\n    }\n    \n    const filterComplex = filterParts.join(';')\n    \n    // 执行FFmpeg命令\n    await this.ffmpeg.exec([\n      ...inputs,\n      '-filter_complex', filterComplex,\n      '-map', `[${videoChain}]`,\n      '-map', `[${audioChain}]`,\n      '-c:v', 'libx264',\n      '-preset', 'ultrafast',\n      '-crf', '23',\n      '-c:a', 'aac',\n      '-b:a', '128k',\n      '-movflags', '+faststart',\n      'output.mp4'\n    ])\n  }\n  \n  private getXfadeFilter(type: TransitionType, duration: number, offset: number): string {\n    const xfadeTypes: Record<string, string> = {\n      'fade': 'fade',\n      'fadeblack': 'fadeblack',\n      'fadewhite': 'fadewhite',\n      'slideleft': 'slideleft',\n      'slideright': 'slideright',\n      'slideup': 'slideup',\n      'slidedown': 'slidedown',\n      'wipeleft': 'wipeleft',\n      'wiperight': 'wiperight',\n      'circleopen': 'circleopen',\n      'circleclose': 'circleclose'\n    }\n    \n    const xfadeType = xfadeTypes[type] || 'fade'\n    return `xfade=transition=${xfadeType}:duration=${duration}:offset=${offset}`\n  }\n\n  async terminate() {\n    if (this.loaded) {\n      this.ffmpeg.terminate()\n      this.loaded = false\n    }\n  }\n}\n\nexport const videoMerger = new VideoMerger()\n"
  },
  {
    "path": "web/src/views/dashboard/Dashboard.vue",
    "content": "<template>\n  <div class=\"dashboard-container\">\n    <el-container>\n      <el-header class=\"header\">\n        <div class=\"header-content\">\n          <h2>{{ $t('dashboard.title') }}</h2>\n          <LanguageSwitcher />\n        </div>\n      </el-header>\n      \n      <el-main>\n        <div class=\"welcome-section\">\n          <h1>{{ $t('dashboard.welcome') }}</h1>\n          <p>{{ $t('dashboard.subtitle') }}</p>\n        </div>\n        \n        <el-row :gutter=\"20\" class=\"stats-row\">\n          <el-col :span=\"6\">\n            <el-card shadow=\"hover\">\n              <div class=\"stat-item\">\n                <el-icon :size=\"40\" color=\"#409eff\"><Document /></el-icon>\n                <h3>0</h3>\n                <p>{{ $t('dashboard.stats.projects') }}</p>\n              </div>\n            </el-card>\n          </el-col>\n          \n          <el-col :span=\"6\">\n            <el-card shadow=\"hover\">\n              <div class=\"stat-item\">\n                <el-icon :size=\"40\" color=\"#67c23a\"><Picture /></el-icon>\n                <h3>0</h3>\n                <p>{{ $t('dashboard.stats.images') }}</p>\n              </div>\n            </el-card>\n          </el-col>\n          \n          <el-col :span=\"6\">\n            <el-card shadow=\"hover\">\n              <div class=\"stat-item\">\n                <el-icon :size=\"40\" color=\"#e6a23c\"><VideoPlay /></el-icon>\n                <h3>0</h3>\n                <p>{{ $t('dashboard.stats.videos') }}</p>\n              </div>\n            </el-card>\n          </el-col>\n          \n          <el-col :span=\"6\">\n            <el-card shadow=\"hover\">\n              <div class=\"stat-item\">\n                <el-icon :size=\"40\" color=\"#f56c6c\"><Clock /></el-icon>\n                <h3>0</h3>\n                <p>{{ $t('dashboard.stats.tasks') }}</p>\n              </div>\n            </el-card>\n          </el-col>\n        </el-row>\n        \n        <div class=\"quick-actions\">\n          <h2>{{ $t('dashboard.quickStart') }}</h2>\n          <el-row :gutter=\"20\">\n            <el-col :span=\"8\">\n              <el-card shadow=\"hover\" class=\"action-card\" @click=\"goToDramas\">\n                <el-icon :size=\"50\" color=\"#409eff\"><Plus /></el-icon>\n                <h3>{{ $t('dashboard.actions.newProject') }}</h3>\n                <p>{{ $t('dashboard.actions.newProjectDesc') }}</p>\n              </el-card>\n            </el-col>\n            \n            <el-col :span=\"8\">\n              <el-card shadow=\"hover\" class=\"action-card\" @click=\"goToDramas\">\n                <el-icon :size=\"50\" color=\"#67c23a\"><FolderOpened /></el-icon>\n                <h3>{{ $t('dashboard.actions.myProjects') }}</h3>\n                <p>{{ $t('dashboard.actions.myProjectsDesc') }}</p>\n              </el-card>\n            </el-col>\n            \n          </el-row>\n        </div>\n      </el-main>\n    </el-container>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { useRouter } from 'vue-router'\nimport { Document, Picture, VideoPlay, Clock, Plus, FolderOpened, Setting } from '@element-plus/icons-vue'\nimport LanguageSwitcher from '@/components/LanguageSwitcher.vue'\n\nconst router = useRouter()\n\nconst goToDramas = () => {\n  router.push('/dramas')\n}\n\nconst goToSettings = () => {\n  router.push('/settings/ai-config')\n}\n</script>\n\n<style scoped>\n.dashboard-container {\n  min-height: 100vh;\n  background: #f5f7fa;\n}\n\n.header {\n  background: #fff;\n  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n}\n\n.header-content {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  height: 100%;\n}\n\n.header-content h2 {\n  margin: 0;\n  color: #409eff;\n}\n\n.user-info {\n  display: flex;\n  align-items: center;\n  gap: 15px;\n}\n\n.welcome-section {\n  text-align: center;\n  padding: 40px 0;\n}\n\n.welcome-section h1 {\n  font-size: 36px;\n  margin-bottom: 10px;\n  color: #333;\n}\n\n.welcome-section p {\n  font-size: 18px;\n  color: #666;\n}\n\n.stats-row {\n  margin-bottom: 40px;\n}\n\n.stat-item {\n  text-align: center;\n  padding: 20px 0;\n}\n\n.stat-item h3 {\n  font-size: 32px;\n  margin: 10px 0;\n  color: #333;\n}\n\n.stat-item p {\n  color: #666;\n  margin: 0;\n}\n\n.quick-actions h2 {\n  margin-bottom: 20px;\n  color: #333;\n}\n\n.action-card {\n  cursor: pointer;\n  text-align: center;\n  padding: 30px 20px;\n  transition: all 0.3s;\n}\n\n.action-card:hover {\n  transform: translateY(-5px);\n}\n\n.action-card h3 {\n  margin: 15px 0 10px;\n  color: #333;\n}\n\n.action-card p {\n  color: #666;\n  margin: 0;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/drama/DramaCreate.vue",
    "content": "<template>\n  <!-- Drama Create Page / 创建短剧页面 -->\n  <div class=\"page-container\">\n    <div class=\"content-wrapper animate-fade-in\">\n      <!-- Header / 头部 -->\n      <AppHeader :fixed=\"false\" :show-logo=\"false\">\n        <template #left>\n          <el-button text @click=\"goBack\" class=\"back-btn\">\n            <el-icon><ArrowLeft /></el-icon>\n            <span>返回</span>\n          </el-button>\n          <div class=\"page-title\">\n            <h1>创建新项目</h1>\n            <span class=\"subtitle\">填写基本信息来创建你的短剧项目</span>\n          </div>\n        </template>\n      </AppHeader>\n\n      <!-- Form Card / 表单卡片 -->\n      <div class=\"form-card\">\n\n        <el-form \n          ref=\"formRef\" \n          :model=\"form\" \n          :rules=\"rules\" \n          label-position=\"top\"\n          class=\"create-form\"\n          @submit.prevent=\"handleSubmit\"\n        >\n          <el-form-item label=\"项目标题\" prop=\"title\" required>\n            <el-input \n              v-model=\"form.title\" \n              placeholder=\"给你的短剧起个名字\"\n              size=\"large\"\n              maxlength=\"100\"\n              show-word-limit\n            />\n          </el-form-item>\n\n          <el-form-item label=\"项目描述\" prop=\"description\">\n            <el-input \n              v-model=\"form.description\" \n              type=\"textarea\" \n              :rows=\"5\"\n              placeholder=\"简要描述你的短剧内容、风格或创意（可选）\"\n              maxlength=\"500\"\n              show-word-limit\n              resize=\"none\"\n            />\n          </el-form-item>\n\n          <div class=\"form-actions\">\n            <el-button size=\"large\" @click=\"goBack\">取消</el-button>\n            <el-button \n              type=\"primary\" \n              size=\"large\"\n              :loading=\"loading\"\n              @click=\"handleSubmit\"\n            >\n              <el-icon v-if=\"!loading\"><Plus /></el-icon>\n              创建项目\n            </el-button>\n          </div>\n        </el-form>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, reactive } from 'vue'\nimport { useRouter } from 'vue-router'\nimport { ElMessage, type FormInstance, type FormRules } from 'element-plus'\nimport { ArrowLeft, Plus } from '@element-plus/icons-vue'\nimport { dramaAPI } from '@/api/drama'\nimport type { CreateDramaRequest } from '@/types/drama'\nimport { AppHeader } from '@/components/common'\n\nconst router = useRouter()\nconst formRef = ref<FormInstance>()\nconst loading = ref(false)\n\nconst form = reactive<CreateDramaRequest>({\n  title: '',\n  description: ''\n})\n\nconst rules: FormRules = {\n  title: [\n    { required: true, message: '请输入项目标题', trigger: 'blur' },\n    { min: 1, max: 100, message: '标题长度在 1 到 100 个字符', trigger: 'blur' }\n  ]\n}\n\n// Submit form / 提交表单\nconst handleSubmit = async () => {\n  if (!formRef.value) return\n  \n  await formRef.value.validate(async (valid) => {\n    if (valid) {\n      loading.value = true\n      try {\n        const drama = await dramaAPI.create(form)\n        ElMessage.success('创建成功')\n        router.push(`/dramas/${drama.id}`)\n      } catch (error: any) {\n        ElMessage.error(error.message || '创建失败')\n      } finally {\n        loading.value = false\n      }\n    }\n  })\n}\n\n// Go back / 返回上一页\nconst goBack = () => {\n  router.back()\n}\n</script>\n\n<style scoped>\n/* ========================================\n   Page Layout / 页面布局 - 紧凑边距\n   ======================================== */\n.page-container {\n  min-height: 100vh;\n  background-color: var(--bg-primary);\n  padding: var(--space-2) var(--space-3);\n  transition: background-color var(--transition-normal);\n}\n\n@media (min-width: 768px) {\n  .page-container {\n    padding: var(--space-3) var(--space-4);\n  }\n}\n\n.content-wrapper {\n  max-width: 640px;\n  margin: 0 auto;\n}\n\n/* ========================================\n   Form Card / 表单卡片\n   ======================================== */\n.form-card {\n  background: var(--bg-card);\n  border: 1px solid var(--border-primary);\n  border-radius: var(--radius-xl);\n  overflow: hidden;\n  box-shadow: var(--shadow-card);\n}\n\n/* ========================================\n   Form Styles / 表单样式 - 紧凑内边距\n   ======================================== */\n.create-form {\n  padding: var(--space-4);\n}\n\n.create-form :deep(.el-form-item) {\n  margin-bottom: var(--space-4);\n}\n\n/* ========================================\n   Form Actions / 表单操作区\n   ======================================== */\n.form-actions {\n  display: flex;\n  justify-content: flex-end;\n  gap: var(--space-3);\n  padding-top: var(--space-4);\n  border-top: 1px solid var(--border-primary);\n  margin-top: var(--space-2);\n}\n\n.form-actions .el-button {\n  min-width: 100px;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/drama/DramaList.vue",
    "content": "<template>\n  <!-- Drama List Page - Refactored with modern minimalist design -->\n  <!-- 短剧列表页面 - 使用现代简约设计重构 -->\n  <div class=\"page-container\">\n    <div class=\"content-wrapper animate-fade-in\">\n      <!-- App Header / 应用头部 -->\n      <AppHeader :fixed=\"false\">\n        <template #left>\n          <div class=\"page-title\">\n            <h1>{{ $t(\"drama.title\") }}</h1>\n            <span class=\"subtitle\">{{\n              $t(\"drama.totalProjects\", { count: total })\n            }}</span>\n          </div>\n        </template>\n        <template #right>\n          <el-button\n            type=\"primary\"\n            @click=\"handleCreate\"\n            class=\"header-btn primary\"\n          >\n            <el-icon>\n              <Plus />\n            </el-icon>\n            <span class=\"btn-text\">{{ $t(\"drama.createNew\") }}</span>\n          </el-button>\n        </template>\n      </AppHeader>\n\n      <!-- Project Grid / 项目网格 -->\n      <div\n        v-loading=\"loading\"\n        class=\"projects-grid\"\n        :class=\"{ 'is-empty': !loading && dramas.length === 0 }\"\n      >\n        <!-- Empty state / 空状态 -->\n        <EmptyState\n          v-if=\"!loading && dramas.length === 0\"\n          :title=\"$t('drama.empty')\"\n          :description=\"$t('drama.emptyHint')\"\n          :icon=\"Film\"\n        >\n          <el-button type=\"primary\" @click=\"handleCreate\">\n            <el-icon>\n              <Plus />\n            </el-icon>\n            {{ $t(\"drama.createNew\") }}\n          </el-button>\n        </EmptyState>\n\n        <!-- Project Cards / 项目卡片列表 -->\n        <ProjectCard\n          v-for=\"drama in dramas\"\n          :key=\"drama.id\"\n          :title=\"drama.title\"\n          :description=\"drama.description\"\n          :updated-at=\"drama.updated_at\"\n          :episode-count=\"drama.total_episodes || 0\"\n          @click=\"viewDrama(drama.id)\"\n        >\n          <template #actions>\n            <ActionButton\n              :icon=\"Edit\"\n              :tooltip=\"$t('common.edit')\"\n              @click=\"editDrama(drama.id)\"\n            />\n            <el-popconfirm\n              :title=\"$t('drama.deleteConfirm')\"\n              :confirm-button-text=\"$t('common.confirm')\"\n              :cancel-button-text=\"$t('common.cancel')\"\n              @confirm=\"deleteDrama(drama.id)\"\n            >\n              <template #reference>\n                <el-button :icon=\"Delete\" class=\"action-button danger\" link />\n              </template>\n            </el-popconfirm>\n          </template>\n        </ProjectCard>\n      </div>\n\n      <!-- Edit Dialog / 编辑对话框 -->\n      <el-dialog\n        v-model=\"editDialogVisible\"\n        :title=\"$t('drama.editProject')\"\n        width=\"520px\"\n        :close-on-click-modal=\"false\"\n        class=\"edit-dialog\"\n      >\n        <el-form\n          :model=\"editForm\"\n          label-position=\"top\"\n          v-loading=\"editLoading\"\n          class=\"edit-form\"\n        >\n          <el-form-item :label=\"$t('drama.projectName')\" required>\n            <el-input\n              v-model=\"editForm.title\"\n              :placeholder=\"$t('drama.projectNamePlaceholder')\"\n              size=\"large\"\n            />\n          </el-form-item>\n          <el-form-item :label=\"$t('drama.projectDesc')\">\n            <el-input\n              v-model=\"editForm.description\"\n              type=\"textarea\"\n              :rows=\"4\"\n              :placeholder=\"$t('drama.projectDescPlaceholder')\"\n              resize=\"none\"\n            />\n          </el-form-item>\n          <el-form-item :label=\"$t('drama.style')\" required>\n            <el-select\n              v-model=\"editForm.style\"\n              :placeholder=\"$t('drama.stylePlaceholder')\"\n              size=\"large\"\n              style=\"width: 100%\"\n            >\n              <el-option :label=\"$t('drama.styles.ghibli')\" value=\"ghibli\" />\n              <el-option :label=\"$t('drama.styles.guoman')\" value=\"guoman\" />\n              <el-option\n                :label=\"$t('drama.styles.wasteland')\"\n                value=\"wasteland\"\n              />\n              <el-option\n                :label=\"$t('drama.styles.nostalgia')\"\n                value=\"nostalgia\"\n              />\n              <el-option :label=\"$t('drama.styles.pixel')\" value=\"pixel\" />\n              <el-option :label=\"$t('drama.styles.voxel')\" value=\"voxel\" />\n              <el-option :label=\"$t('drama.styles.urban')\" value=\"urban\" />\n              <el-option\n                :label=\"$t('drama.styles.guoman3d')\"\n                value=\"guoman3d\"\n              />\n              <el-option :label=\"$t('drama.styles.chibi3d')\" value=\"chibi3d\" />\n            </el-select>\n          </el-form-item>\n        </el-form>\n        <template #footer>\n          <div class=\"dialog-footer\">\n            <el-button @click=\"editDialogVisible = false\" size=\"large\">{{\n              $t(\"common.cancel\")\n            }}</el-button>\n            <el-button\n              type=\"primary\"\n              @click=\"saveEdit\"\n              :loading=\"editLoading\"\n              size=\"large\"\n            >\n              {{ $t(\"common.save\") }}\n            </el-button>\n          </div>\n        </template>\n      </el-dialog>\n\n      <!-- Create Drama Dialog / 创建短剧弹窗 -->\n      <CreateDramaDialog v-model=\"createDialogVisible\" @created=\"loadDramas\" />\n    </div>\n\n    <!-- Sticky Pagination / 吸底分页器 -->\n    <div v-if=\"total > 0\" class=\"pagination-sticky\">\n      <div class=\"pagination-inner\">\n        <div class=\"pagination-info\">\n          <span class=\"pagination-total\">{{\n            $t(\"drama.totalProjects\", { count: total })\n          }}</span>\n        </div>\n        <div class=\"pagination-controls\">\n          <el-pagination\n            v-model:current-page=\"queryParams.page\"\n            v-model:page-size=\"queryParams.page_size\"\n            :total=\"total\"\n            :page-sizes=\"[12, 24, 36, 48]\"\n            :pager-count=\"5\"\n            layout=\"prev, pager, next\"\n            @size-change=\"loadDramas\"\n            @current-change=\"loadDramas\"\n          />\n        </div>\n        <div class=\"pagination-size\">\n          <span class=\"size-label\">{{ $t(\"common.perPage\") }}</span>\n          <el-select\n            v-model=\"queryParams.page_size\"\n            size=\"small\"\n            class=\"size-select\"\n            @change=\"loadDramas\"\n          >\n            <el-option :value=\"12\" label=\"12\" />\n            <el-option :value=\"24\" label=\"24\" />\n            <el-option :value=\"36\" label=\"36\" />\n            <el-option :value=\"48\" label=\"48\" />\n          </el-select>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, onMounted } from \"vue\";\nimport { useRouter } from \"vue-router\";\nimport { ElMessage } from \"element-plus\";\nimport {\n  Plus,\n  Film,\n  Setting,\n  Edit,\n  View,\n  Delete,\n  InfoFilled,\n} from \"@element-plus/icons-vue\";\nimport { dramaAPI } from \"@/api/drama\";\nimport type { Drama, DramaListQuery } from \"@/types/drama\";\nimport {\n  AppHeader,\n  ProjectCard,\n  ActionButton,\n  CreateDramaDialog,\n  EmptyState,\n} from \"@/components/common\";\n\nconst router = useRouter();\nconst loading = ref(false);\nconst dramas = ref<Drama[]>([]);\nconst total = ref(0);\n\nconst queryParams = ref<DramaListQuery>({\n  page: 1,\n  page_size: 12,\n});\n\n// Create dialog state / 创建弹窗状态\nconst createDialogVisible = ref(false);\n\n// Load drama list / 加载短剧列表\nconst loadDramas = async () => {\n  loading.value = true;\n  try {\n    const res = await dramaAPI.list(queryParams.value);\n    dramas.value = res.items || [];\n    total.value = res.pagination?.total || 0;\n  } catch (error: any) {\n    ElMessage.error(error.message || \"加载失败\");\n  } finally {\n    loading.value = false;\n  }\n};\n\n// Navigation handlers / 导航处理\nconst handleCreate = () => (createDialogVisible.value = true);\nconst viewDrama = (id: string) => router.push(`/dramas/${id}`);\n\n// Edit dialog state / 编辑对话框状态\nconst editDialogVisible = ref(false);\nconst editLoading = ref(false);\nconst editForm = ref({\n  id: \"\",\n  title: \"\",\n  description: \"\",\n  style: \"ghibli\",\n});\n\n// Open edit dialog / 打开编辑对话框\nconst editDrama = async (id: string) => {\n  editLoading.value = true;\n  editDialogVisible.value = true;\n  try {\n    const drama = await dramaAPI.get(id);\n    editForm.value = {\n      id: drama.id,\n      title: drama.title,\n      description: drama.description || \"\",\n      style: drama.style || \"ghibli\",\n    };\n  } catch (error: any) {\n    ElMessage.error(error.message || \"加载失败\");\n    editDialogVisible.value = false;\n  } finally {\n    editLoading.value = false;\n  }\n};\n\n// Save edit changes / 保存编辑更改\nconst saveEdit = async () => {\n  if (!editForm.value.title) {\n    ElMessage.warning(\"请输入项目名称\");\n    return;\n  }\n\n  editLoading.value = true;\n  try {\n    await dramaAPI.update(editForm.value.id, {\n      title: editForm.value.title,\n      description: editForm.value.description,\n      style: editForm.value.style,\n    });\n    ElMessage.success(\"保存成功\");\n    editDialogVisible.value = false;\n    loadDramas();\n  } catch (error: any) {\n    ElMessage.error(error.message || \"保存失败\");\n  } finally {\n    editLoading.value = false;\n  }\n};\n\n// Delete drama / 删除短剧\nconst deleteDrama = async (id: string) => {\n  try {\n    await dramaAPI.delete(id);\n    ElMessage.success(\"删除成功\");\n    loadDramas();\n  } catch (error: any) {\n    ElMessage.error(error.message || \"删除失败\");\n  }\n};\n\nonMounted(() => {\n  loadDramas();\n});\n</script>\n\n<style scoped>\n/* ========================================\n   Page Layout / 页面布局 - 紧凑边距\n   ======================================== */\n.page-container {\n  min-height: 100vh;\n  background: var(--bg-primary);\n  /* padding: var(--space-2) var(--space-3); */\n  transition: background var(--transition-normal);\n}\n\n@media (min-width: 768px) {\n  .page-container {\n    /* padding: var(--space-3) var(--space-4); */\n  }\n}\n\n@media (min-width: 1024px) {\n  .page-container {\n    /* padding: var(--space-4) var(--space-5); */\n  }\n}\n\n.content-wrapper {\n  margin: 0 auto;\n  width: 100%;\n}\n\n/* ========================================\n   Page Title / 页面标题\n   ======================================== */\n.page-title {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n}\n\n.page-title h1 {\n  margin: 0;\n  font-size: 1.25rem;\n  font-weight: 700;\n  color: var(--text-primary);\n  line-height: 1.3;\n}\n\n.page-title .subtitle {\n  font-size: 0.8125rem;\n  color: var(--text-muted);\n}\n\n/* ========================================\n   Header Buttons / 头部按钮\n   ======================================== */\n.header-btn {\n  border-radius: var(--radius-lg);\n  font-weight: 500;\n}\n\n.header-btn.primary {\n  background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%);\n  border: none;\n  box-shadow: 0 4px 14px rgba(14, 165, 233, 0.35);\n}\n\n.header-btn.primary:hover {\n  transform: translateY(-1px);\n  box-shadow: 0 6px 20px rgba(14, 165, 233, 0.45);\n}\n\n@media (max-width: 640px) {\n  .btn-text {\n    display: none;\n  }\n\n  .header-btn {\n    padding: 0.5rem 0.75rem;\n  }\n}\n\n/* ========================================\n   Projects Grid / 项目网格 - 紧凑间距\n   ======================================== */\n.projects-grid {\n  padding: 12px;\n  display: flex;\n  flex-wrap: wrap;\n  /* grid-template-columns: repeat(2, 1fr); */\n  gap: var(--space-2);\n  margin-bottom: var(--space-4);\n  min-height: 300px;\n  padding-bottom: 4rem;\n}\n\n@media (min-width: 640px) {\n  .projects-grid {\n    grid-template-columns: repeat(3, 1fr);\n    gap: var(--space-2);\n  }\n}\n\n@media (min-width: 900px) {\n  .projects-grid {\n    grid-template-columns: repeat(4, 1fr);\n    gap: var(--space-3);\n  }\n}\n\n@media (min-width: 1200px) {\n  .projects-grid {\n    grid-template-columns: repeat(5, 1fr);\n  }\n}\n\n@media (min-width: 1500px) {\n  .projects-grid {\n    grid-template-columns: repeat(6, 1fr);\n  }\n}\n\n.projects-grid.is-empty {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n/* ========================================\n   Sticky Pagination / 吸底分页器\n   ======================================== */\n.pagination-sticky {\n  /* padding: 12px; */\n  position: fixed;\n  bottom: 0;\n  left: 0;\n  right: 0;\n  z-index: 100;\n  background: rgba(255, 255, 255, 0.85);\n  backdrop-filter: blur(16px);\n  border-top: 1px solid var(--border-primary);\n  box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.05);\n}\n\n.dark .pagination-sticky {\n  background: rgba(10, 15, 26, 0.9);\n  border-top: 1px solid var(--border-primary);\n  box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);\n}\n\n.pagination-inner {\n  display: flex;\n  align-items: center;\n  justify-content: flex-end;\n  margin: 0 auto;\n  padding: var(--space-3) var(--space-4);\n  gap: var(--space-4);\n}\n\n@media (min-width: 768px) {\n  .pagination-inner {\n    padding: var(--space-3) var(--space-6);\n  }\n}\n\n.pagination-info {\n  display: none;\n}\n\n@media (min-width: 768px) {\n  .pagination-info {\n    display: block;\n  }\n}\n\n.pagination-total {\n  font-size: 0.8125rem;\n  color: var(--text-muted);\n  font-weight: 500;\n}\n\n.pagination-controls {\n  display: flex;\n}\n\n.pagination-size {\n  display: flex;\n  align-items: center;\n  gap: var(--space-2);\n}\n\n.size-label {\n  font-size: 0.8125rem;\n  color: var(--text-muted);\n  display: none;\n}\n\n@media (min-width: 768px) {\n  .size-label {\n    display: block;\n  }\n}\n\n.size-select {\n  width: 4.5rem;\n}\n\n.size-select :deep(.el-input__wrapper) {\n  height: 2rem;\n  border-radius: var(--radius-md);\n  background: var(--bg-card);\n}\n\n/* ========================================\n   Edit Dialog / 编辑对话框\n   ======================================== */\n.edit-dialog :deep(.el-dialog) {\n  border-radius: var(--radius-xl);\n}\n\n.edit-dialog :deep(.el-dialog__header) {\n  padding: 1.25rem 1.5rem;\n  border-bottom: 1px solid var(--border-primary);\n  margin-right: 0;\n}\n\n.edit-dialog :deep(.el-dialog__title) {\n  font-size: 1.125rem;\n  font-weight: 600;\n  color: var(--text-primary);\n}\n\n.edit-dialog :deep(.el-dialog__body) {\n  padding: 1.5rem;\n}\n\n.edit-form :deep(.el-form-item__label) {\n  font-weight: 500;\n  color: var(--text-primary);\n  margin-bottom: 0.5rem;\n}\n\n.dialog-footer {\n  display: flex;\n  justify-content: flex-end;\n  gap: 0.75rem;\n}\n\n/* Delete button style */\n.action-button.danger {\n  padding: 0.5rem;\n  color: var(--text-muted);\n}\n\n.action-button.danger:hover {\n  color: #ef4444;\n  background: rgba(239, 68, 68, 0.1);\n}\n</style>\n"
  },
  {
    "path": "web/src/views/drama/DramaManagement.vue",
    "content": "<template>\n  <div class=\"page-container\">\n    <div class=\"content-wrapper animate-fade-in\">\n      <!-- Page Header / 页面头部 -->\n      <AppHeader :fixed=\"false\" :show-logo=\"false\">\n        <template #left>\n          <el-button text @click=\"$router.back()\" class=\"back-btn\">\n            <el-icon><ArrowLeft /></el-icon>\n            <span>{{ $t(\"common.back\") }}</span>\n          </el-button>\n          <div class=\"page-title\">\n            <h1>{{ drama?.title || \"\" }}</h1>\n            <span class=\"subtitle\">{{\n              drama?.description || $t(\"drama.management.overview\")\n            }}</span>\n          </div>\n        </template>\n      </AppHeader>\n\n      <!-- Tabs / 标签页 -->\n      <div class=\"tabs-wrapper\">\n        <el-tabs v-model=\"activeTab\" class=\"management-tabs\">\n          <!-- 项目概览 -->\n          <el-tab-pane :label=\"$t('drama.management.overview')\" name=\"overview\">\n            <div class=\"stats-grid\">\n              <StatCard\n                :label=\"$t('drama.management.episodeStats')\"\n                :value=\"episodesCount\"\n                :icon=\"Document\"\n                icon-color=\"var(--accent)\"\n                icon-bg=\"var(--accent-light)\"\n                value-color=\"var(--accent)\"\n                :description=\"$t('drama.management.episodesCreated')\"\n              />\n              <StatCard\n                :label=\"$t('drama.management.characterStats')\"\n                :value=\"charactersCount\"\n                :icon=\"User\"\n                icon-color=\"var(--success)\"\n                icon-bg=\"var(--success-light)\"\n                value-color=\"var(--success)\"\n                :description=\"$t('drama.management.charactersCreated')\"\n              />\n              <StatCard\n                :label=\"$t('drama.management.sceneStats')\"\n                :value=\"scenesCount\"\n                :icon=\"Picture\"\n                icon-color=\"var(--warning)\"\n                icon-bg=\"var(--warning-light)\"\n                value-color=\"var(--warning)\"\n                :description=\"$t('drama.management.sceneLibraryCount')\"\n              />\n              <StatCard\n                :label=\"$t('drama.management.propStats')\"\n                :value=\"propsCount\"\n                :icon=\"Box\"\n                icon-color=\"var(--primary)\"\n                icon-bg=\"var(--primary-light)\"\n                value-color=\"var(--primary)\"\n                :description=\"$t('drama.management.propsCreated')\"\n              />\n            </div>\n\n            <!-- 引导卡片：无章节时显示 -->\n            <el-alert\n              v-if=\"episodesCount === 0\"\n              :title=\"$t('drama.management.startFirstEpisode')\"\n              type=\"info\"\n              :closable=\"false\"\n              style=\"margin-top: 20px\"\n            >\n              <template #default>\n                <p style=\"margin: 8px 0\">\n                  {{ $t(\"drama.management.noEpisodesYet\") }}\n                </p>\n                <el-button\n                  type=\"primary\"\n                  :icon=\"Plus\"\n                  @click=\"createNewEpisode\"\n                  style=\"margin-top: 8px\"\n                >\n                  {{ $t(\"drama.management.createFirstEpisode\") }}\n                </el-button>\n              </template>\n            </el-alert>\n\n            <el-card shadow=\"never\" class=\"project-info-card\">\n              <template #header>\n                <div class=\"card-header\">\n                  <h3 class=\"card-title\">\n                    {{ $t(\"drama.management.projectInfo\") }}\n                  </h3>\n                  <el-tag :type=\"getStatusType(drama?.status)\" size=\"small\">{{\n                    getStatusText(drama?.status)\n                  }}</el-tag>\n                </div>\n              </template>\n              <el-descriptions :column=\"2\" border class=\"project-descriptions\">\n                <el-descriptions-item\n                  :label=\"$t('drama.management.projectName')\"\n                >\n                  <span class=\"info-value\">{{ drama?.title }}</span>\n                </el-descriptions-item>\n                <el-descriptions-item :label=\"$t('common.createdAt')\">\n                  <span class=\"info-value\">{{\n                    formatDate(drama?.created_at)\n                  }}</span>\n                </el-descriptions-item>\n                <el-descriptions-item\n                  :label=\"$t('drama.management.projectDesc')\"\n                  :span=\"2\"\n                >\n                  <span class=\"info-desc\">{{\n                    drama?.description || $t(\"drama.management.noDescription\")\n                  }}</span>\n                </el-descriptions-item>\n              </el-descriptions>\n            </el-card>\n          </el-tab-pane>\n\n          <!-- 章节管理 -->\n          <el-tab-pane :label=\"$t('drama.management.episodes')\" name=\"episodes\">\n            <div class=\"tab-header\">\n              <h2>{{ $t(\"drama.management.episodeList\") }}</h2>\n              <el-button\n                type=\"primary\"\n                :icon=\"Plus\"\n                @click=\"createNewEpisode\"\n                >{{ $t(\"drama.management.createNewEpisode\") }}</el-button\n              >\n            </div>\n\n            <!-- 空状态引导 -->\n            <el-empty\n              v-if=\"episodesCount === 0\"\n              :description=\"$t('drama.management.noEpisodes')\"\n              style=\"margin-top: 40px\"\n            >\n              <template #image>\n                <el-icon :size=\"80\" class=\"empty-icon\"><Document /></el-icon>\n              </template>\n              <el-button type=\"primary\" :icon=\"Plus\" @click=\"createNewEpisode\">\n                {{ $t(\"drama.management.createFirstEpisode\") }}\n              </el-button>\n            </el-empty>\n\n            <el-table\n              v-else\n              :data=\"sortedEpisodes\"\n              border\n              stripe\n              style=\"margin-top: 16px\"\n            >\n              <el-table-column\n                type=\"index\"\n                :label=\"$t('storyboard.table.number')\"\n                width=\"80\"\n              />\n              <el-table-column\n                prop=\"title\"\n                :label=\"$t('drama.management.episodeList')\"\n                min-width=\"200\"\n              />\n              <el-table-column :label=\"$t('common.status')\" width=\"120\">\n                <template #default=\"{ row }\">\n                  <el-tag :type=\"getEpisodeStatusType(row)\">{{\n                    getEpisodeStatusText(row)\n                  }}</el-tag>\n                </template>\n              </el-table-column>\n              <el-table-column label=\"Shots\" width=\"100\">\n                <template #default=\"{ row }\">\n                  {{ row.shots?.length || 0 }}\n                </template>\n              </el-table-column>\n              <el-table-column :label=\"$t('common.createdAt')\" width=\"180\">\n                <template #default=\"{ row }\">\n                  {{ formatDate(row.created_at) }}\n                </template>\n              </el-table-column>\n              <el-table-column\n                :label=\"$t('storyboard.table.operations')\"\n                width=\"220\"\n                fixed=\"right\"\n              >\n                <template #default=\"{ row }\">\n                  <el-button\n                    size=\"small\"\n                    type=\"primary\"\n                    @click=\"enterEpisodeWorkflow(row)\"\n                  >\n                    {{ $t(\"drama.management.goToEdit\") }}\n                  </el-button>\n                  <el-button\n                    size=\"small\"\n                    type=\"danger\"\n                    @click=\"deleteEpisode(row)\"\n                  >\n                    {{ $t(\"common.delete\") }}\n                  </el-button>\n                </template>\n              </el-table-column>\n            </el-table>\n          </el-tab-pane>\n\n          <!-- 角色管理 -->\n          <el-tab-pane\n            :label=\"$t('drama.management.characters')\"\n            name=\"characters\"\n          >\n            <div class=\"tab-header\">\n              <h2>{{ $t(\"drama.management.characterList\") }}</h2>\n              <div style=\"display: flex; gap: 10px\">\n                <el-button\n                  :icon=\"Document\"\n                  @click=\"openExtractCharacterDialog\"\n                  >{{ $t(\"prop.extract\") }}</el-button\n                >\n                <el-button\n                  type=\"primary\"\n                  :icon=\"Plus\"\n                  @click=\"openAddCharacterDialog\"\n                  >{{ $t(\"character.add\") }}</el-button\n                >\n              </div>\n            </div>\n\n            <el-row :gutter=\"16\" style=\"margin-top: 16px\">\n              <el-col\n                :span=\"6\"\n                v-for=\"character in drama?.characters\"\n                :key=\"character.id\"\n              >\n                <el-card shadow=\"hover\" class=\"character-card\">\n                  <div class=\"character-preview\">\n                    <ImagePreview\n                      v-if=\"character.local_path || character.image_url\"\n                      :image-url=\"getImageUrl(character)\"\n                      :alt=\"character.name\"\n                      :size=\"120\"\n                    />\n                    <el-avatar v-else :size=\"120\">{{\n                      character.name[0]\n                    }}</el-avatar>\n                  </div>\n\n                  <div class=\"character-info\">\n                    <div class=\"character-name\">\n                      <h4>{{ character.name }}</h4>\n                      <el-tag\n                        :type=\"character.role === 'main' ? 'danger' : 'info'\"\n                        size=\"small\"\n                      >\n                        {{\n                          character.role === \"main\"\n                            ? \"Main\"\n                            : character.role === \"supporting\"\n                              ? \"Supporting\"\n                              : \"Minor\"\n                        }}\n                      </el-tag>\n                    </div>\n                    <p class=\"desc\">\n                      {{ character.appearance || character.description }}\n                    </p>\n                  </div>\n\n                  <div class=\"character-actions\">\n                    <el-button size=\"small\" @click=\"editCharacter(character)\">{{\n                      $t(\"common.edit\")\n                    }}</el-button>\n                    <el-button\n                      size=\"small\"\n                      @click=\"generateCharacterImage(character)\"\n                      >{{ $t(\"prop.generateImage\") }}</el-button\n                    >\n                    <el-button\n                      size=\"small\"\n                      type=\"danger\"\n                      @click=\"deleteCharacter(character)\"\n                      >{{ $t(\"common.delete\") }}</el-button\n                    >\n                  </div>\n                </el-card>\n              </el-col>\n            </el-row>\n\n            <el-empty\n              v-if=\"!drama?.characters || drama.characters.length === 0\"\n              :description=\"$t('drama.management.noCharacters')\"\n            />\n          </el-tab-pane>\n\n          <!-- 场景库管理 -->\n          <el-tab-pane :label=\"$t('drama.management.sceneList')\" name=\"scenes\">\n            <div class=\"tab-header\">\n              <h2>{{ $t(\"drama.management.sceneList\") }}</h2>\n            </div>\n\n            <el-row :gutter=\"16\" style=\"margin-top: 16px\">\n              <el-col :span=\"6\" v-for=\"scene in scenes\" :key=\"scene.id\">\n                <el-card shadow=\"hover\" class=\"scene-card\">\n                  <div class=\"scene-preview\">\n                    <ImagePreview\n                      :image-url=\"getImageUrl(scene)\"\n                      :alt=\"scene.location + ' - ' + scene.time\"\n                      :size=\"120\"\n                      :show-placeholder-text=\"false\"\n                    />\n                  </div>\n\n                  <div class=\"scene-info\">\n                    <h4>{{ scene.name }}</h4>\n                    <p class=\"desc\">{{ scene.description }}</p>\n                  </div>\n\n                  <div class=\"scene-actions\">\n                    <el-button size=\"small\" @click=\"editScene(scene)\">{{\n                      $t(\"common.edit\")\n                    }}</el-button>\n                    <el-button\n                      size=\"small\"\n                      @click=\"generateSceneImage(scene)\"\n                      >{{ $t(\"prop.generateImage\") }}</el-button\n                    >\n                    <el-button\n                      size=\"small\"\n                      type=\"danger\"\n                      @click=\"deleteScene(scene)\"\n                      >{{ $t(\"common.delete\") }}</el-button\n                    >\n                  </div>\n                </el-card>\n              </el-col>\n            </el-row>\n\n            <el-empty\n              v-if=\"scenes.length === 0\"\n              :description=\"$t('drama.management.noScenes')\"\n            />\n          </el-tab-pane>\n\n          <!-- 道具管理 -->\n          <el-tab-pane :label=\"$t('drama.management.propList')\" name=\"props\">\n            <div class=\"tab-header\">\n              <h2>{{ $t(\"drama.management.propList\") }}</h2>\n              <div style=\"display: flex; gap: 10px\">\n                <el-button :icon=\"Document\" @click=\"openExtractDialog\">{{\n                  $t(\"prop.extract\")\n                }}</el-button>\n                <el-button\n                  type=\"primary\"\n                  :icon=\"Plus\"\n                  @click=\"openAddPropDialog\"\n                  >{{ $t(\"common.add\") }}</el-button\n                >\n              </div>\n            </div>\n\n            <el-row :gutter=\"16\" style=\"margin-top: 16px\">\n              <el-col :span=\"6\" v-for=\"prop in drama?.props\" :key=\"prop.id\">\n                <el-card shadow=\"hover\" class=\"scene-card\">\n                  <div class=\"scene-preview\">\n                    <ImagePreview\n                      :image-url=\"getImageUrl(prop)\"\n                      :alt=\"prop.name\"\n                      :size=\"120\"\n                      :show-placeholder-text=\"false\"\n                    />\n                  </div>\n\n                  <div class=\"scene-info\">\n                    <h4>{{ prop.name }}</h4>\n                    <el-tag size=\"small\" v-if=\"prop.type\">{{\n                      prop.type\n                    }}</el-tag>\n                    <p class=\"desc\">{{ prop.description || prop.prompt }}</p>\n                  </div>\n\n                  <div class=\"scene-actions\">\n                    <el-button size=\"small\" @click=\"editProp(prop)\">{{\n                      $t(\"common.edit\")\n                    }}</el-button>\n                    <el-button\n                      size=\"small\"\n                      @click=\"generatePropImage(prop)\"\n                      :disabled=\"!prop.prompt\"\n                      >{{ $t(\"prop.generateImage\") }}</el-button\n                    >\n                    <el-button\n                      size=\"small\"\n                      type=\"danger\"\n                      @click=\"deleteProp(prop)\"\n                      >{{ $t(\"common.delete\") }}</el-button\n                    >\n                  </div>\n                </el-card>\n              </el-col>\n            </el-row>\n\n            <el-empty\n              v-if=\"!drama?.props || drama.props.length === 0\"\n              :description=\"$t('drama.management.noProps')\"\n            />\n          </el-tab-pane>\n        </el-tabs>\n      </div>\n\n      <!-- 添加/编辑角色对话框 -->\n      <el-dialog\n        v-model=\"addCharacterDialogVisible\"\n        :title=\"editingCharacter ? $t('character.edit') : $t('character.add')\"\n        width=\"600px\"\n      >\n        <el-form :model=\"newCharacter\" label-width=\"100px\">\n          <el-form-item :label=\"$t('character.image')\">\n            <el-upload\n              class=\"avatar-uploader\"\n              :action=\"`/api/v1/upload/image`\"\n              :show-file-list=\"false\"\n              :on-success=\"handleCharacterAvatarSuccess\"\n              :before-upload=\"beforeAvatarUpload\"\n            >\n              <img\n                v-if=\"hasImage(newCharacter)\"\n                :src=\"getImageUrl(newCharacter)\"\n                class=\"avatar\"\n                style=\"width: 100px; height: 100px; object-fit: cover\"\n              />\n              <el-icon\n                v-else\n                class=\"avatar-uploader-icon\"\n                style=\"\n                  border: 1px dashed #d9d9d9;\n                  border-radius: 6px;\n                  cursor: pointer;\n                  position: relative;\n                  overflow: hidden;\n                  width: 100px;\n                  height: 100px;\n                  font-size: 28px;\n                  color: #8c939d;\n                  text-align: center;\n                  line-height: 100px;\n                \"\n                ><Plus\n              /></el-icon>\n            </el-upload>\n          </el-form-item>\n          <el-form-item :label=\"$t('character.name')\">\n            <el-input\n              v-model=\"newCharacter.name\"\n              :placeholder=\"$t('character.name')\"\n            />\n          </el-form-item>\n          <el-form-item :label=\"$t('character.role')\">\n            <el-select\n              v-model=\"newCharacter.role\"\n              :placeholder=\"$t('common.pleaseSelect')\"\n            >\n              <el-option label=\"Main\" value=\"main\" />\n              <el-option label=\"Supporting\" value=\"supporting\" />\n              <el-option label=\"Minor\" value=\"minor\" />\n            </el-select>\n          </el-form-item>\n          <el-form-item :label=\"$t('character.appearance')\">\n            <el-input\n              v-model=\"newCharacter.appearance\"\n              type=\"textarea\"\n              :rows=\"3\"\n              :placeholder=\"$t('character.appearance')\"\n            />\n          </el-form-item>\n          <el-form-item :label=\"$t('character.personality')\">\n            <el-input\n              v-model=\"newCharacter.personality\"\n              type=\"textarea\"\n              :rows=\"3\"\n              :placeholder=\"$t('character.personality')\"\n            />\n          </el-form-item>\n          <el-form-item :label=\"$t('character.description')\">\n            <el-input\n              v-model=\"newCharacter.description\"\n              type=\"textarea\"\n              :rows=\"3\"\n              :placeholder=\"$t('common.description')\"\n            />\n          </el-form-item>\n        </el-form>\n        <template #footer>\n          <el-button @click=\"addCharacterDialogVisible = false\">{{\n            $t(\"common.cancel\")\n          }}</el-button>\n          <el-button type=\"primary\" @click=\"saveCharacter\">{{\n            $t(\"common.confirm\")\n          }}</el-button>\n        </template>\n      </el-dialog>\n\n      <!-- 添加/编辑场景对话框 -->\n      <el-dialog\n        v-model=\"addSceneDialogVisible\"\n        :title=\"editingScene ? $t('common.edit') : $t('common.add')\"\n        width=\"600px\"\n      >\n        <el-form :model=\"newScene\" label-width=\"100px\">\n          <el-form-item :label=\"$t('common.image')\">\n            <el-upload\n              class=\"avatar-uploader\"\n              :action=\"`/api/v1/upload/image`\"\n              :show-file-list=\"false\"\n              :on-success=\"handleSceneImageSuccess\"\n              :before-upload=\"beforeAvatarUpload\"\n            >\n              <img\n                v-if=\"hasImage(newScene)\"\n                :src=\"getImageUrl(newScene)\"\n                class=\"avatar\"\n                style=\"width: 160px; height: 90px; object-fit: cover\"\n              />\n              <el-icon\n                v-else\n                class=\"avatar-uploader-icon\"\n                style=\"\n                  border: 1px dashed #d9d9d9;\n                  border-radius: 6px;\n                  cursor: pointer;\n                  position: relative;\n                  overflow: hidden;\n                  width: 160px;\n                  height: 90px;\n                  font-size: 28px;\n                  color: #8c939d;\n                  text-align: center;\n                  line-height: 90px;\n                \"\n                ><Plus\n              /></el-icon>\n            </el-upload>\n          </el-form-item>\n          <el-form-item :label=\"$t('common.name')\">\n            <el-input\n              v-model=\"newScene.location\"\n              :placeholder=\"$t('common.name')\"\n            />\n          </el-form-item>\n          <el-form-item :label=\"$t('common.description')\">\n            <el-input\n              v-model=\"newScene.prompt\"\n              type=\"textarea\"\n              :rows=\"4\"\n              :placeholder=\"$t('common.description')\"\n            />\n          </el-form-item>\n        </el-form>\n        <template #footer>\n          <el-button @click=\"addSceneDialogVisible = false\">{{\n            $t(\"common.cancel\")\n          }}</el-button>\n          <el-button type=\"primary\" @click=\"saveScene\">{{\n            $t(\"common.confirm\")\n          }}</el-button>\n        </template>\n      </el-dialog>\n\n      <!-- 添加/编辑道具对话框 -->\n      <el-dialog\n        v-model=\"addPropDialogVisible\"\n        :title=\"editingProp ? $t('common.edit') : $t('common.add')\"\n        width=\"600px\"\n      >\n        <el-form :model=\"newProp\" label-width=\"100px\">\n          <el-form-item :label=\"$t('common.image')\">\n            <el-upload\n              class=\"avatar-uploader\"\n              :action=\"`/api/v1/upload/image`\"\n              :show-file-list=\"false\"\n              :on-success=\"handlePropImageSuccess\"\n              :before-upload=\"beforeAvatarUpload\"\n            >\n              <img\n                v-if=\"hasImage(newProp)\"\n                :src=\"getImageUrl(newProp)\"\n                class=\"avatar\"\n                style=\"width: 100px; height: 100px; object-fit: cover\"\n              />\n              <el-icon\n                v-else\n                class=\"avatar-uploader-icon\"\n                style=\"\n                  border: 1px dashed #d9d9d9;\n                  border-radius: 6px;\n                  cursor: pointer;\n                  position: relative;\n                  overflow: hidden;\n                  width: 100px;\n                  height: 100px;\n                  font-size: 28px;\n                  color: #8c939d;\n                  text-align: center;\n                  line-height: 100px;\n                \"\n                ><Plus\n              /></el-icon>\n            </el-upload>\n          </el-form-item>\n          <el-form-item :label=\"$t('prop.name')\">\n            <el-input v-model=\"newProp.name\" :placeholder=\"$t('prop.name')\" />\n          </el-form-item>\n          <el-form-item :label=\"$t('prop.type')\">\n            <el-input\n              v-model=\"newProp.type\"\n              :placeholder=\"$t('prop.typePlaceholder')\"\n            />\n          </el-form-item>\n          <el-form-item :label=\"$t('prop.description')\">\n            <el-input\n              v-model=\"newProp.description\"\n              type=\"textarea\"\n              :rows=\"3\"\n              :placeholder=\"$t('prop.description')\"\n            />\n          </el-form-item>\n          <el-form-item :label=\"$t('prop.prompt')\">\n            <el-input\n              v-model=\"newProp.prompt\"\n              type=\"textarea\"\n              :rows=\"3\"\n              :placeholder=\"$t('prop.promptPlaceholder')\"\n            />\n          </el-form-item>\n        </el-form>\n        <template #footer>\n          <el-button @click=\"addPropDialogVisible = false\">{{\n            $t(\"common.cancel\")\n          }}</el-button>\n          <el-button type=\"primary\" @click=\"saveProp\">{{\n            $t(\"common.confirm\")\n          }}</el-button>\n        </template>\n      </el-dialog>\n\n      <!-- 从剧本提取道具对话框 -->\n      <el-dialog\n        v-model=\"extractPropsDialogVisible\"\n        :title=\"$t('prop.extractTitle')\"\n        width=\"500px\"\n      >\n        <el-form label-width=\"100px\">\n          <el-form-item :label=\"$t('prop.selectEpisode')\">\n            <el-select\n              v-model=\"selectedExtractEpisodeId\"\n              :placeholder=\"$t('common.pleaseSelect')\"\n              style=\"width: 100%\"\n            >\n              <el-option\n                v-for=\"ep in sortedEpisodes\"\n                :key=\"ep.id\"\n                :label=\"ep.title\"\n                :value=\"ep.id\"\n              />\n            </el-select>\n          </el-form-item>\n          <el-alert\n            :title=\"$t('prop.extractTip')\"\n            type=\"info\"\n            :closable=\"false\"\n            show-icon\n          />\n        </el-form>\n        <template #footer>\n          <el-button @click=\"extractPropsDialogVisible = false\">{{\n            $t(\"common.cancel\")\n          }}</el-button>\n          <el-button\n            type=\"primary\"\n            @click=\"handleExtractProps\"\n            :disabled=\"!selectedExtractEpisodeId\"\n            >{{ $t(\"prop.startExtract\") }}</el-button\n          >\n        </template>\n      </el-dialog>\n\n      <!-- 从剧本提取角色对话框 -->\n      <el-dialog\n        v-model=\"extractCharactersDialogVisible\"\n        :title=\"$t('prop.extractTitle')\"\n        width=\"500px\"\n      >\n        <el-form label-width=\"100px\">\n          <el-form-item :label=\"$t('prop.selectEpisode')\">\n            <el-select\n              v-model=\"selectedExtractEpisodeId\"\n              :placeholder=\"$t('common.pleaseSelect')\"\n              style=\"width: 100%\"\n            >\n              <el-option\n                v-for=\"ep in sortedEpisodes\"\n                :key=\"ep.id\"\n                :label=\"ep.title\"\n                :value=\"ep.id\"\n              />\n            </el-select>\n          </el-form-item>\n          <el-alert\n            :title=\"$t('prop.extractTip')\"\n            type=\"info\"\n            :closable=\"false\"\n            show-icon\n          />\n        </el-form>\n        <template #footer>\n          <el-button @click=\"extractCharactersDialogVisible = false\">{{\n            $t(\"common.cancel\")\n          }}</el-button>\n          <el-button\n            type=\"primary\"\n            @click=\"handleExtractCharacters\"\n            :disabled=\"!selectedExtractEpisodeId\"\n            >{{ $t(\"prop.startExtract\") }}</el-button\n          >\n        </template>\n      </el-dialog>\n\n      <!-- 从剧本提取场景对话框 -->\n      <el-dialog\n        v-model=\"extractScenesDialogVisible\"\n        :title=\"$t('prop.extractTitle')\"\n        width=\"500px\"\n      >\n        <el-form label-width=\"100px\">\n          <el-form-item :label=\"$t('prop.selectEpisode')\">\n            <el-select\n              v-model=\"selectedExtractEpisodeId\"\n              :placeholder=\"$t('common.pleaseSelect')\"\n              style=\"width: 100%\"\n            >\n              <el-option\n                v-for=\"ep in sortedEpisodes\"\n                :key=\"ep.id\"\n                :label=\"ep.title\"\n                :value=\"ep.id\"\n              />\n            </el-select>\n          </el-form-item>\n          <el-alert\n            :title=\"$t('prop.extractTip')\"\n            type=\"info\"\n            :closable=\"false\"\n            show-icon\n          />\n        </el-form>\n        <template #footer>\n          <el-button @click=\"extractScenesDialogVisible = false\">{{\n            $t(\"common.cancel\")\n          }}</el-button>\n          <el-button\n            type=\"primary\"\n            @click=\"handleExtractScenes\"\n            :disabled=\"!selectedExtractEpisodeId\"\n            >{{ $t(\"prop.startExtract\") }}</el-button\n          >\n        </template>\n      </el-dialog>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted, watch } from \"vue\";\nimport { useRouter, useRoute } from \"vue-router\";\nimport { ElMessage, ElMessageBox } from \"element-plus\";\nimport {\n  ArrowLeft,\n  Document,\n  User,\n  Picture,\n  Plus,\n  Box,\n} from \"@element-plus/icons-vue\";\nimport { dramaAPI } from \"@/api/drama\";\nimport { characterLibraryAPI } from \"@/api/character-library\";\nimport { propAPI } from \"@/api/prop\";\nimport type { Drama } from \"@/types/drama\";\nimport {\n  AppHeader,\n  StatCard,\n  EmptyState,\n  ImagePreview,\n} from \"@/components/common\";\nimport { getImageUrl, hasImage } from \"@/utils/image\";\n\nconst router = useRouter();\nconst route = useRoute();\n\nconst drama = ref<Drama>();\nconst activeTab = ref((route.query.tab as string) || \"overview\");\nconst scenes = ref<any[]>([]);\n\nlet pollingTimer: any = null; // Add polling timer definition\n\nconst addCharacterDialogVisible = ref(false);\nconst addSceneDialogVisible = ref(false);\nconst addPropDialogVisible = ref(false);\nconst extractPropsDialogVisible = ref(false);\nconst extractCharactersDialogVisible = ref(false);\nconst extractScenesDialogVisible = ref(false);\n\nconst editingCharacter = ref<any>(null);\nconst editingScene = ref<any>(null);\nconst editingProp = ref<any>(null);\nconst selectedExtractEpisodeId = ref<number | null>(null);\n\nconst newCharacter = ref({\n  name: \"\",\n  role: \"supporting\",\n  appearance: \"\",\n  personality: \"\",\n  description: \"\",\n  image_url: \"\",\n  local_path: \"\",\n});\n\nconst newProp = ref({\n  name: \"\",\n  description: \"\",\n  prompt: \"\",\n  type: \"\",\n  image_url: \"\",\n  local_path: \"\",\n});\n\nconst newScene = ref({\n  location: \"\",\n  prompt: \"\",\n  image_url: \"\",\n  local_path: \"\",\n});\n\nconst episodesCount = computed(() => drama.value?.episodes?.length || 0);\nconst charactersCount = computed(() => drama.value?.characters?.length || 0);\nconst scenesCount = computed(() => scenes.value.length);\nconst propsCount = computed(() => drama.value?.props?.length || 0);\n\nconst sortedEpisodes = computed(() => {\n  if (!drama.value?.episodes) return [];\n  return [...drama.value.episodes].sort(\n    (a, b) => a.episode_number - b.episode_number,\n  );\n});\n\n// Helper for polling\nconst startPolling = (\n  callback: () => Promise<void>,\n  maxAttempts = 20,\n  interval = 3000,\n) => {\n  if (pollingTimer) clearInterval(pollingTimer);\n\n  let attempts = 0;\n  pollingTimer = setInterval(async () => {\n    attempts++;\n    await callback();\n    if (attempts >= maxAttempts) {\n      if (pollingTimer) clearInterval(pollingTimer);\n      pollingTimer = null;\n    }\n  }, interval);\n};\n\n// Clear timer on unmount\nimport { onUnmounted } from \"vue\";\nonUnmounted(() => {\n  if (pollingTimer) clearInterval(pollingTimer);\n});\n\nconst loadDramaData = async () => {\n  try {\n    const data = await dramaAPI.get(route.params.id as string);\n    drama.value = data;\n    loadScenes();\n  } catch (error: any) {\n    ElMessage.error(error.message || \"加载项目数据失败\");\n  }\n};\n\nconst loadScenes = async () => {\n  // 场景数据已经在drama中加载了（后端Preload了Scenes）\n  if (drama.value?.scenes) {\n    scenes.value = drama.value.scenes;\n  } else {\n    scenes.value = [];\n  }\n};\n\nconst getStatusType = (status?: string) => {\n  const map: Record<string, any> = {\n    draft: \"info\",\n    in_progress: \"warning\",\n    completed: \"success\",\n  };\n  return map[status || \"draft\"] || \"info\";\n};\n\nconst getStatusText = (status?: string) => {\n  const map: Record<string, string> = {\n    draft: \"草稿\",\n    in_progress: \"制作中\",\n    completed: \"已完成\",\n  };\n  return map[status || \"draft\"] || \"草稿\";\n};\n\nconst getEpisodeStatusType = (episode: any) => {\n  if (episode.shots && episode.shots.length > 0) return \"success\";\n  if (episode.script_content) return \"warning\";\n  return \"info\";\n};\n\nconst getEpisodeStatusText = (episode: any) => {\n  if (episode.shots && episode.shots.length > 0) return \"已拆分\";\n  if (episode.script_content) return \"已创建\";\n  return \"草稿\";\n};\n\nconst formatDate = (date?: string) => {\n  if (!date) return \"-\";\n  return new Date(date).toLocaleString(\"zh-CN\");\n};\n\nconst createNewEpisode = () => {\n  const nextEpisodeNumber = episodesCount.value + 1;\n  router.push({\n    name: \"EpisodeWorkflowNew\",\n    params: {\n      id: route.params.id,\n      episodeNumber: nextEpisodeNumber,\n    },\n  });\n};\n\nconst enterEpisodeWorkflow = (episode: any) => {\n  router.push({\n    name: \"EpisodeWorkflowNew\",\n    params: {\n      id: route.params.id,\n      episodeNumber: episode.episode_number,\n    },\n  });\n};\n\nconst deleteEpisode = async (episode: any) => {\n  try {\n    await ElMessageBox.confirm(\n      `确定要删除第${episode.episode_number}章吗？此操作将同时删除该章节的所有相关数据（角色、场景、分镜等）。`,\n      \"删除确认\",\n      {\n        confirmButtonText: \"确定\",\n        cancelButtonText: \"取消\",\n        type: \"warning\",\n      },\n    );\n\n    // 过滤掉要删除的章节\n    const existingEpisodes = drama.value?.episodes || [];\n    const updatedEpisodes = existingEpisodes\n      .filter((ep) => ep.episode_number !== episode.episode_number)\n      .map((ep) => ({\n        episode_number: ep.episode_number,\n        title: ep.title,\n        script_content: ep.script_content,\n        description: ep.description,\n        duration: ep.duration,\n        status: ep.status,\n      }));\n\n    // 保存更新后的章节列表\n    await dramaAPI.saveEpisodes(drama.value!.id, updatedEpisodes);\n\n    ElMessage.success(`第${episode.episode_number}章删除成功`);\n    await loadDramaData();\n  } catch (error: any) {\n    if (error !== \"cancel\") {\n      ElMessage.error(error.message || \"删除失败\");\n    }\n  }\n};\n\nconst openAddCharacterDialog = () => {\n  editingCharacter.value = null;\n  newCharacter.value = {\n    name: \"\",\n    role: \"supporting\",\n    appearance: \"\",\n    personality: \"\",\n    description: \"\",\n    image_url: \"\",\n  };\n  addCharacterDialogVisible.value = true;\n};\n\nconst handleCharacterAvatarSuccess = (response: any) => {\n  if (response.data && response.data.url) {\n    newCharacter.value.image_url = response.data.url;\n    newCharacter.value.local_path = response.data.local_path || \"\";\n  }\n};\n\nconst handleSceneImageSuccess = (response: any) => {\n  if (response.data && response.data.url) {\n    newScene.value.image_url = response.data.url;\n    newScene.value.local_path = response.data.local_path || \"\";\n  }\n};\n\nconst beforeAvatarUpload = (file: any) => {\n  const isImage = file.type.startsWith(\"image/\");\n  const isLt10M = file.size / 1024 / 1024 < 10;\n\n  if (!isImage) {\n    ElMessage.error(\"只能上传图片文件!\");\n  }\n  if (!isLt10M) {\n    ElMessage.error(\"图片大小不能超过 10MB!\");\n  }\n  return isImage && isLt10M;\n};\n\nconst generateCharacterImage = async (character: any) => {\n  try {\n    await characterLibraryAPI.generateCharacterImage(character.id);\n    ElMessage.success(\"图片生成任务已提交\");\n    startPolling(loadDramaData);\n  } catch (error: any) {\n    ElMessage.error(error.message || \"生成失败\");\n  }\n};\n\nconst openExtractCharacterDialog = () => {\n  extractCharactersDialogVisible.value = true;\n  if (sortedEpisodes.value.length > 0 && !selectedExtractEpisodeId.value) {\n    selectedExtractEpisodeId.value = sortedEpisodes.value[0].id;\n  }\n};\n\nconst handleExtractCharacters = async () => {\n  if (!selectedExtractEpisodeId.value) return;\n\n  try {\n    const res = await characterLibraryAPI.extractFromEpisode(\n      selectedExtractEpisodeId.value,\n    );\n    extractCharactersDialogVisible.value = false;\n\n    // 自动刷新几次\n    let checkCount = 0;\n    const checkInterval = setInterval(() => {\n      loadDramaData();\n      checkCount++;\n      if (checkCount > 10) clearInterval(checkInterval);\n    }, 5000);\n  } catch (error: any) {\n    ElMessage.error(error.message || \"提取失败\");\n  }\n};\n\nconst generateSceneImage = async (scene: any) => {\n  try {\n    await dramaAPI.generateSceneImage({ scene_id: scene.id });\n    ElMessage.success(\"图片生成任务已提交\");\n    startPolling(loadScenes);\n  } catch (error: any) {\n    ElMessage.error(error.message || \"生成失败\");\n  }\n};\n\nconst openExtractSceneDialog = () => {\n  extractScenesDialogVisible.value = true;\n  if (sortedEpisodes.value.length > 0 && !selectedExtractEpisodeId.value) {\n    selectedExtractEpisodeId.value = sortedEpisodes.value[0].id;\n  }\n};\n\nconst handleExtractScenes = async () => {\n  if (!selectedExtractEpisodeId.value) return;\n\n  try {\n    const res = await dramaAPI.extractBackgrounds(\n      selectedExtractEpisodeId.value.toString(),\n    );\n    extractScenesDialogVisible.value = false;\n\n    // 自动刷新几次\n    let checkCount = 0;\n    const checkInterval = setInterval(() => {\n      loadScenes();\n      checkCount++;\n      if (checkCount > 10) clearInterval(checkInterval);\n    }, 5000);\n  } catch (error: any) {\n    ElMessage.error(error.message || \"提取失败\");\n  }\n};\n\nconst saveCharacter = async () => {\n  if (!newCharacter.value.name.trim()) {\n    ElMessage.warning(\"请输入角色名称\");\n    return;\n  }\n\n  try {\n    if (editingCharacter.value) {\n      // Edit existing character using dedicated update endpoint\n      await dramaAPI.updateCharacter(editingCharacter.value.id, {\n        name: newCharacter.value.name,\n        role: newCharacter.value.role,\n        appearance: newCharacter.value.appearance,\n        personality: newCharacter.value.personality,\n        description: newCharacter.value.description,\n        image_url: newCharacter.value.image_url,\n        local_path: newCharacter.value.local_path,\n      });\n      ElMessage.success(\"角色更新成功\");\n    } else {\n      // Add new character\n      const allCharacters = [\n        ...(drama.value?.characters || []).map((c) => ({\n          name: c.name,\n          role: c.role,\n          appearance: c.appearance,\n          personality: c.personality,\n          description: c.description,\n          image_url: c.image_url,\n          local_path: c.local_path,\n        })),\n        newCharacter.value,\n      ];\n\n      await dramaAPI.saveCharacters(drama.value!.id, allCharacters);\n      ElMessage.success(\"角色添加成功\");\n    }\n\n    addCharacterDialogVisible.value = false;\n    await loadDramaData();\n  } catch (error: any) {\n    ElMessage.error(error.message || \"操作失败\");\n  }\n};\n\nconst editCharacter = (character: any) => {\n  editingCharacter.value = character;\n  newCharacter.value = {\n    name: character.name,\n    role: character.role || \"supporting\",\n    appearance: character.appearance || \"\",\n    personality: character.personality || \"\",\n    description: character.description || \"\",\n    image_url: character.image_url || \"\",\n    local_path: character.local_path || \"\",\n  };\n  addCharacterDialogVisible.value = true;\n};\n\nconst deleteCharacter = async (character: any) => {\n  if (!character.id) {\n    ElMessage.error(\"角色ID不存在，无法删除\");\n    return;\n  }\n\n  try {\n    await ElMessageBox.confirm(\n      `确定要删除角色\"${character.name}\"吗？此操作不可恢复。`,\n      \"删除确认\",\n      {\n        confirmButtonText: \"确定\",\n        cancelButtonText: \"取消\",\n        type: \"warning\",\n      },\n    );\n\n    await characterLibraryAPI.deleteCharacter(character.id);\n    ElMessage.success(\"角色已删除\");\n    await loadDramaData();\n  } catch (error: any) {\n    if (error !== \"cancel\") {\n      console.error(\"删除角色失败:\", error);\n      ElMessage.error(error.message || \"删除失败\");\n    }\n  }\n};\n\nconst openAddSceneDialog = () => {\n  editingScene.value = null;\n  newScene.value = {\n    location: \"\",\n    prompt: \"\",\n    image_url: \"\",\n  };\n  addSceneDialogVisible.value = true;\n};\n\nconst saveScene = async () => {\n  if (!newScene.value.location.trim()) {\n    ElMessage.warning(\"请输入场景名称\");\n    return;\n  }\n\n  try {\n    if (editingScene.value) {\n      // Update existing scene\n      await dramaAPI.updateScene(editingScene.value.id, {\n        location: newScene.value.location,\n        description: newScene.value.prompt,\n        image_url: newScene.value.image_url,\n        local_path: newScene.value.local_path,\n      });\n      // prompt field in Update is description or prompt? Check backend.\n      // UpdateSceneRequest has Description *string.\n      // And also ImagePrompt *string and VideoPrompt *string.\n      // The backend model has Prompt string.\n      // Checking backend handler:\n      /*\n        if req.Description != nil { updates[\"description\"] = req.Description }\n        if req.ImagePrompt != nil { updates[\"image_prompt\"] = req.ImagePrompt }\n      */\n      // But CreateScene uses Prompt.\n      // Let's assume description maps to Prompt or Description.\n      // Wait, UpdateSceneRequest has Description but NO Prompt field?\n      // Let's check backend UpdateSceneRequest struct again.\n      // It has `ImagePrompt` and `VideoPrompt`, and `Description`.\n      // But `Prompt` usually refers to image prompt in Scene model?\n      // `models.Scene` has `Prompt` string.\n      // `CreateScene` sets `Prompt: req.Prompt`.\n      // `UpdateScene` handler:\n      /*\n      \tif req.Description != nil {\n      \t\tupdates[\"description\"] = req.Description\n      \t}\n      */\n      // It seems UpdateScene doesn't support updating the main `Prompt` field directly via UpdateSceneRequest?\n      // Wait, `UpdateScenePrompt` endpoint exists! `/scenes/:id/prompt`\n      // But we probably want to update everything in one go.\n      // I should update UpdateSceneRequest in backend if needed or use UpdateScenePrompt separately.\n      // For now, let's look at scene model:\n      // Scene struct: Location, Time, Description, Prompt...\n      // Let's use `description` for now as it's available in Update.\n      // Or if `prompt` is critical, I might need to call UpdateScenePrompt too.\n      // Let's check `CreateScene` again. It uses `Prompt`.\n\n      // Let's just update prompt via specific endpoint if needed, or mapping description to description.\n      // Actually `newScene.prompt` is mapped to `description` in my current code for Update.\n      // Let's stick with that for now or fix backend to support prompt update in general update.\n    } else {\n      // Create new scene\n      await dramaAPI.createScene({\n        drama_id: drama.value!.id,\n        location: newScene.value.location,\n        prompt: newScene.value.prompt,\n        description: newScene.value.prompt,\n        image_url: newScene.value.image_url,\n        local_path: newScene.value.local_path,\n      });\n    }\n\n    ElMessage.success(editingScene.value ? \"场景更新成功\" : \"场景添加成功\");\n    addSceneDialogVisible.value = false;\n    await loadScenes();\n  } catch (error: any) {\n    ElMessage.error(error.message || \"操作失败\");\n  }\n};\n\nconst editScene = (scene: any) => {\n  editingScene.value = scene;\n  newScene.value = {\n    location: scene.location || scene.name || \"\",\n    prompt: scene.prompt || scene.description || \"\",\n    image_url: scene.image_url || \"\",\n    local_path: scene.local_path || \"\",\n  };\n  addSceneDialogVisible.value = true;\n};\n\nconst deleteScene = async (scene: any) => {\n  if (!scene.id) {\n    ElMessage.error(\"场景ID不存在，无法删除\");\n    return;\n  }\n\n  try {\n    await ElMessageBox.confirm(\n      `确定要删除场景\"${scene.name || scene.location}\"吗？此操作不可恢复。`,\n      \"删除确认\",\n      {\n        confirmButtonText: \"确定\",\n        cancelButtonText: \"取消\",\n        type: \"warning\",\n      },\n    );\n\n    await dramaAPI.deleteScene(scene.id.toString());\n    ElMessage.success(\"场景已删除\");\n    await loadScenes();\n  } catch (error: any) {\n    if (error !== \"cancel\") {\n      console.error(\"删除场景失败:\", error);\n      ElMessage.error(error.message || \"删除失败\");\n    }\n  }\n};\n\nconst openAddPropDialog = () => {\n  editingProp.value = null;\n  newProp.value = {\n    name: \"\",\n    description: \"\",\n    prompt: \"\",\n    type: \"\",\n    image_url: \"\",\n    local_path: \"\",\n  };\n  addPropDialogVisible.value = true;\n};\n\nconst saveProp = async () => {\n  if (!newProp.value.name.trim()) {\n    ElMessage.warning(\"请输入道具名称\");\n    return;\n  }\n\n  try {\n    const propData = {\n      drama_id: drama.value!.id,\n      name: newProp.value.name,\n      description: newProp.value.description,\n      prompt: newProp.value.prompt,\n      type: newProp.value.type,\n      image_url: newProp.value.image_url,\n      local_path: newProp.value.local_path,\n    };\n\n    if (editingProp.value) {\n      await propAPI.update(editingProp.value.id, propData);\n      ElMessage.success(\"道具更新成功\");\n    } else {\n      await propAPI.create(propData as any);\n      ElMessage.success(\"道具添加成功\");\n    }\n\n    addPropDialogVisible.value = false;\n    await loadDramaData();\n  } catch (error: any) {\n    ElMessage.error(error.message || \"操作失败\");\n  }\n};\n\nconst editProp = (prop: any) => {\n  editingProp.value = prop;\n  newProp.value = {\n    name: prop.name,\n    description: prop.description || \"\",\n    prompt: prop.prompt || \"\",\n    type: prop.type || \"\",\n    image_url: prop.image_url || \"\",\n    local_path: prop.local_path || \"\",\n  };\n  addPropDialogVisible.value = true;\n};\n\nconst deleteProp = async (prop: any) => {\n  try {\n    await ElMessageBox.confirm(\n      `确定要删除道具\"${prop.name}\"吗？此操作不可恢复。`,\n      \"删除确认\",\n      {\n        confirmButtonText: \"确定\",\n        cancelButtonText: \"取消\",\n        type: \"warning\",\n      },\n    );\n\n    await propAPI.delete(prop.id);\n    ElMessage.success(\"道具已删除\");\n    await loadDramaData();\n  } catch (error: any) {\n    if (error !== \"cancel\") {\n      ElMessage.error(error.message || \"删除失败\");\n    }\n  }\n};\n\nconst generatePropImage = async (prop: any) => {\n  if (!prop.prompt) {\n    ElMessage.warning(\"请先设置道具的图片提示词\");\n    editProp(prop);\n    return;\n  }\n\n  try {\n    await propAPI.generateImage(prop.id);\n    ElMessage.success(\"图片生成任务已提交\");\n    startPolling(loadDramaData);\n  } catch (error: any) {\n    ElMessage.error(error.message || \"生成失败\");\n  }\n};\n\nconst handlePropImageSuccess = (response: any) => {\n  if (response.data && response.data.url) {\n    newProp.value.image_url = response.data.url;\n    newProp.value.local_path = response.data.local_path || \"\";\n  }\n};\n\nconst openExtractDialog = () => {\n  extractPropsDialogVisible.value = true;\n  if (sortedEpisodes.value.length > 0 && !selectedExtractEpisodeId.value) {\n    selectedExtractEpisodeId.value = sortedEpisodes.value[0].id;\n  }\n};\n\nconst handleExtractProps = async () => {\n  if (!selectedExtractEpisodeId.value) return;\n\n  try {\n    const res = await propAPI.extractFromScript(selectedExtractEpisodeId.value);\n    extractPropsDialogVisible.value = false;\n\n    // 自动刷新几次\n    let checkCount = 0;\n    const checkInterval = setInterval(() => {\n      loadDramaData();\n      checkCount++;\n      if (checkCount > 10) clearInterval(checkInterval);\n    }, 5000);\n  } catch (error: any) {\n    ElMessage.error(error.message || t(\"common.failed\"));\n  }\n};\n\nonMounted(() => {\n  loadDramaData();\n  loadScenes();\n\n  // 如果有query参数指定tab，切换到对应tab\n  if (route.query.tab) {\n    activeTab.value = route.query.tab as string;\n  }\n});\n</script>\n\n<style scoped>\n/* ========================================\n   Page Layout / 页面布局 - 紧凑边距\n   ======================================== */\n.page-container {\n  min-height: 100vh;\n  background: var(--bg-primary);\n  /* padding: var(--space-2) var(--space-3); */\n  transition: background var(--transition-normal);\n}\n\n@media (min-width: 768px) {\n  .page-container {\n    /* padding: var(--space-3) var(--space-4); */\n  }\n}\n\n@media (min-width: 1024px) {\n  .page-container {\n    /* padding: var(--space-4) var(--space-5); */\n  }\n}\n\n.content-wrapper {\n  margin: 0 auto;\n  width: 100%;\n}\n\n/* ========================================\n   Stats Grid / 统计网格 - 紧凑间距\n   ======================================== */\n.stats-grid {\n  display: grid;\n  grid-template-columns: repeat(1, 1fr);\n  gap: var(--space-2);\n  margin-bottom: var(--space-3);\n}\n\n@media (min-width: 640px) {\n  .stats-grid {\n    grid-template-columns: repeat(2, 1fr);\n    gap: var(--space-3);\n  }\n}\n\n@media (min-width: 1024px) {\n  .stats-grid {\n    grid-template-columns: repeat(3, 1fr);\n  }\n}\n\n/* ========================================\n   Tabs Wrapper / 标签页容器 - 紧凑内边距\n   ======================================== */\n.tabs-wrapper {\n  background: var(--bg-card);\n  border: 1px solid var(--border-primary);\n  border-radius: var(--radius-lg);\n  padding: var(--space-3);\n  box-shadow: var(--shadow-card);\n}\n\n@media (min-width: 768px) {\n  .tabs-wrapper {\n    padding: var(--space-4);\n  }\n}\n\n/* ========================================\n   Tab Header / 标签页头部\n   ======================================== */\n.tab-header {\n  display: flex;\n  flex-direction: column;\n  gap: var(--space-3);\n  margin-bottom: var(--space-4);\n}\n\n@media (min-width: 640px) {\n  .tab-header {\n    flex-direction: row;\n    justify-content: space-between;\n    align-items: center;\n  }\n}\n\n.tab-header h2 {\n  margin: 0;\n  font-size: 1.125rem;\n  font-weight: 600;\n  color: var(--text-primary);\n  letter-spacing: -0.01em;\n}\n\n/* ========================================\n   Character & Scene Cards / 角色场景卡片\n   ======================================== */\n.character-card,\n.scene-card {\n  margin-bottom: var(--space-4);\n  border: 1px solid var(--border-primary);\n  border-radius: var(--radius-xl);\n  overflow: hidden;\n  transition: all var(--transition-normal);\n}\n\n.character-card:hover,\n.scene-card:hover {\n  border-color: var(--border-secondary);\n  box-shadow: var(--shadow-card-hover);\n}\n\n.character-card :deep(.el-card__body),\n.scene-card :deep(.el-card__body) {\n  padding: 0;\n}\n\n.character-preview,\n.scene-preview {\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  height: 160px;\n  background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);\n  overflow: hidden;\n}\n\n.character-preview img,\n.scene-preview img {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  transition: transform var(--transition-normal);\n}\n\n.character-card:hover .character-preview img,\n.scene-card:hover .scene-preview img {\n  transform: scale(1.05);\n}\n\n.scene-placeholder {\n  color: rgba(255, 255, 255, 0.7);\n}\n\n.character-info,\n.scene-info {\n  text-align: center;\n  padding: var(--space-4);\n}\n\n.character-name {\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  gap: var(--space-2);\n}\n\n.character-info h4,\n.scene-info h4 {\n  /* margin: 0 0 var(--space-2) 0; */\n  font-size: 1rem;\n  font-weight: 600;\n  color: var(--text-primary);\n}\n\n.desc {\n  font-size: 0.8125rem;\n  color: var(--text-muted);\n  margin: var(--space-2) 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  line-clamp: 2;\n  -webkit-box-orient: vertical;\n}\n\n.character-actions,\n.scene-actions {\n  display: flex;\n  gap: var(--space-2);\n  justify-content: center;\n  padding: 0 var(--space-4) var(--space-4);\n}\n\n.empty-icon {\n  color: var(--accent);\n}\n\n/* ========================================\n   Dark Mode / 深色模式\n   ======================================== */\n.dark .tabs-wrapper {\n  background: var(--bg-card);\n}\n\n.dark :deep(.el-card) {\n  background: var(--bg-card);\n  border-color: var(--border-primary);\n}\n\n.dark :deep(.el-card__header) {\n  background: var(--bg-secondary);\n  border-color: var(--border-primary);\n}\n\n.dark :deep(.el-table) {\n  background: var(--bg-card);\n  --el-table-bg-color: var(--bg-card);\n  --el-table-tr-bg-color: var(--bg-card);\n  --el-table-header-bg-color: var(--bg-secondary);\n  --el-fill-color-lighter: var(--bg-secondary);\n}\n\n.dark :deep(.el-table th),\n.dark :deep(.el-table tr) {\n  background: var(--bg-card);\n}\n\n.dark :deep(.el-table td),\n.dark :deep(.el-table th) {\n  border-color: var(--border-primary);\n}\n\n.dark :deep(.el-table--striped .el-table__body tr.el-table__row--striped td) {\n  background: var(--bg-secondary);\n}\n\n.dark :deep(.el-table__body tr:hover > td) {\n  background: var(--bg-card-hover) !important;\n}\n\n.dark :deep(.el-descriptions) {\n  background: var(--bg-card);\n}\n\n.dark :deep(.el-descriptions__label) {\n  background: var(--bg-secondary);\n  color: var(--text-secondary);\n  border-color: var(--border-primary);\n}\n\n.dark :deep(.el-descriptions__content) {\n  background: var(--bg-card);\n  color: var(--text-primary);\n  border-color: var(--border-primary);\n}\n\n.dark :deep(.el-descriptions__cell) {\n  border-color: var(--border-primary);\n}\n\n/* ========================================\n   Project Info Card / 项目信息卡片\n   ======================================== */\n.project-info-card {\n  margin-top: var(--space-5);\n  border-radius: var(--radius-lg);\n}\n\n.card-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n}\n\n.card-title {\n  margin: 0;\n  font-size: 1rem;\n  font-weight: 600;\n  color: var(--text-primary);\n}\n\n.project-descriptions {\n  width: 100%;\n}\n\n:deep(.project-descriptions .el-descriptions__label) {\n  width: 120px;\n  font-weight: 500;\n  color: var(--text-secondary);\n}\n\n:deep(.project-descriptions .el-descriptions__content) {\n  min-width: 150px;\n}\n\n.info-value {\n  font-weight: 500;\n  color: var(--text-primary);\n}\n\n.info-desc {\n  color: var(--text-secondary);\n  line-height: 1.6;\n}\n\n.dark :deep(.el-dialog) {\n  background: var(--bg-card);\n}\n\n.dark :deep(.el-dialog__header) {\n  background: var(--bg-card);\n}\n\n.dark :deep(.el-form-item__label) {\n  color: var(--text-primary);\n}\n\n.dark :deep(.el-input__wrapper) {\n  background: var(--bg-secondary);\n  box-shadow: 0 0 0 1px var(--border-primary) inset;\n}\n\n.dark :deep(.el-input__inner) {\n  color: var(--text-primary);\n}\n\n.dark :deep(.el-textarea__inner) {\n  background: var(--bg-secondary);\n  color: var(--text-primary);\n  box-shadow: 0 0 0 1px var(--border-primary) inset;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/drama/DramaWorkflow.vue",
    "content": "<template>\n  <div class=\"workflow-container\">\n    <AppHeader :fixed=\"false\" :show-logo=\"false\">\n      <template #left>\n        <el-button text @click=\"goBack\" class=\"back-btn\">\n          <el-icon><ArrowLeft /></el-icon>\n          <span>{{ $t(\"dramaWorkflow.returnToList\") }}</span>\n        </el-button>\n        <h2 class=\"drama-title\">{{ drama?.title }}</h2>\n        <el-tag :type=\"getStatusType(drama?.status)\" size=\"small\">{{\n          getStatusText(drama?.status)\n        }}</el-tag>\n      </template>\n      <template #center>\n        <!-- 步骤进度条 -->\n        <div class=\"custom-steps\">\n          <div\n            class=\"step-item\"\n            :class=\"{ active: currentStep >= 0, current: currentStep === 0 }\"\n          >\n            <div class=\"step-circle\">1</div>\n            <span class=\"step-text\">{{\n              $t(\"dramaWorkflow.episodeScript\", {\n                number: currentEpisodeNumber,\n              })\n            }}</span>\n          </div>\n          <el-icon class=\"step-arrow\"><ArrowRight /></el-icon>\n          <div\n            class=\"step-item\"\n            :class=\"{ active: currentStep >= 1, current: currentStep === 1 }\"\n          >\n            <div class=\"step-circle\">2</div>\n            <span class=\"step-text\">{{\n              $t(\"dramaWorkflow.storyboardBreakdown\")\n            }}</span>\n          </div>\n          <el-icon class=\"step-arrow\"><ArrowRight /></el-icon>\n          <div\n            class=\"step-item\"\n            :class=\"{ active: currentStep >= 2, current: currentStep === 2 }\"\n          >\n            <div class=\"step-circle\">3</div>\n            <span class=\"step-text\">{{\n              $t(\"dramaWorkflow.characterImages\")\n            }}</span>\n          </div>\n        </div>\n      </template>\n    </AppHeader>\n\n    <!-- 当前阶段内容区域 -->\n    <div class=\"stage-area\">\n      <!-- 阶段 0: 剧本生成 -->\n      <el-card\n        v-show=\"currentStep === 0\"\n        shadow=\"never\"\n        class=\"stage-card stage-card-fullscreen\"\n      >\n        <div class=\"stage-body stage-body-fullscreen\">\n          <!-- 初始状态：显示创建第一章按钮 -->\n          <div\n            v-if=\"!hasScript && !showScriptInput\"\n            class=\"create-chapter-prompt\"\n          >\n            <el-empty :description=\"$t('dramaWorkflow.createChapterPrompt')\">\n              <el-button\n                type=\"primary\"\n                size=\"large\"\n                @click=\"startCreateChapter\"\n                :icon=\"Document\"\n              >\n                {{\n                  $t(\"dramaWorkflow.createChapter\", {\n                    number: currentEpisodeNumber,\n                  })\n                }}\n              </el-button>\n            </el-empty>\n          </div>\n\n          <!-- 未生成剧本时显示表单 -->\n          <div v-if=\"!hasScript && showScriptInput\" class=\"generation-form\">\n            <div class=\"script-input-header\">\n              <el-button\n                type=\"primary\"\n                :icon=\"MagicStick\"\n                @click=\"generateScriptByAI\"\n                :loading=\"generatingScript\"\n              >\n                {{ generatingScript ? \"AI生成中...\" : \"随机生成\" }}\n              </el-button>\n            </div>\n\n            <el-input\n              v-model=\"scriptContent\"\n              type=\"textarea\"\n              placeholder=\"请输入剧本内容...\"\n              class=\"script-textarea script-textarea-fullscreen\"\n              :disabled=\"generatingScript\"\n            />\n\n            <div class=\"action-buttons-inline\">\n              <el-button\n                type=\"primary\"\n                size=\"default\"\n                @click=\"saveChapterScript\"\n                :disabled=\"!scriptContent.trim() || generatingScript\"\n              >\n                <el-icon><Check /></el-icon>\n                <span>保存章节</span>\n              </el-button>\n            </div>\n          </div>\n\n          <div v-if=\"hasScript\" class=\"overview-section\">\n            <el-divider />\n\n            <div class=\"episode-info\">\n              <h3>第{{ currentEpisodeNumber }}集剧本内容</h3>\n              <el-tag type=\"success\" size=\"large\">当前正在制作</el-tag>\n            </div>\n            <div class=\"overview-content\">\n              <div class=\"overview-item script-content-display\">\n                <el-input\n                  v-model=\"currentEpisode.script_content\"\n                  type=\"textarea\"\n                  :rows=\"15\"\n                  readonly\n                  class=\"script-display\"\n                />\n              </div>\n            </div>\n\n            <el-divider />\n\n            <div class=\"action-buttons\">\n              <el-button type=\"success\" size=\"large\" @click=\"nextStep\">\n                开始分镜拆解\n                <el-icon><ArrowRight /></el-icon>\n              </el-button>\n            </div>\n          </div>\n        </div>\n      </el-card>\n\n      <!-- 阶段 1: 分镜拆解 -->\n      <el-card v-show=\"currentStep === 1\" shadow=\"never\" class=\"stage-card\">\n        <template #header>\n          <div class=\"stage-header\">\n            <div class=\"header-left\">\n              <el-icon :size=\"48\" color=\"#409eff\"><Film /></el-icon>\n              <div class=\"header-info\">\n                <h2>分镜拆解</h2>\n                <p>将第{{ currentEpisodeNumber }}集剧本拆分为多个镜头</p>\n              </div>\n            </div>\n            <el-tag\n              v-if=\"currentEpisode?.shots?.length\"\n              type=\"success\"\n              size=\"large\"\n            >\n              已拆分 {{ currentEpisode.shots.length }} 个镜头\n            </el-tag>\n          </div>\n        </template>\n\n        <div class=\"stage-body\">\n          <!-- 分镜列表 -->\n          <div\n            v-if=\"currentEpisode?.shots && currentEpisode.shots.length > 0\"\n            class=\"shots-list\"\n          >\n            <div class=\"shots-header\">\n              <h3>镜头列表</h3>\n              <el-button\n                type=\"primary\"\n                @click=\"parseShotsToCharacters\"\n                :loading=\"parsingCharacters\"\n                :icon=\"User\"\n              >\n                解析角色\n              </el-button>\n            </div>\n\n            <el-table\n              :data=\"currentEpisode.shots\"\n              border\n              stripe\n              style=\"margin-top: 16px\"\n            >\n              <el-table-column type=\"index\" label=\"镜头\" width=\"80\" />\n              <el-table-column\n                prop=\"content\"\n                label=\"镜头内容\"\n                show-overflow-tooltip\n              />\n              <el-table-column label=\"时长\" width=\"100\">\n                <template #default=\"{ row }\">\n                  {{ row.duration || \"-\" }}秒\n                </template>\n              </el-table-column>\n              <el-table-column label=\"操作\" width=\"100\" fixed=\"right\">\n                <template #default=\"{ row, $index }\">\n                  <el-button\n                    type=\"primary\"\n                    size=\"small\"\n                    @click=\"editShot(row, $index)\"\n                  >\n                    划分\n                  </el-button>\n                </template>\n              </el-table-column>\n            </el-table>\n\n            <div class=\"action-buttons\" style=\"margin-top: 24px\">\n              <el-button @click=\"regenerateShots\" :icon=\"MagicStick\">\n                {{ $t(\"dramaWorkflow.reGenerateShots\") }}\n              </el-button>\n              <el-button\n                type=\"success\"\n                @click=\"nextStep\"\n                :disabled=\"!hasCharacters\"\n              >\n                {{ $t(\"dramaWorkflow.nextStepCharacterImages\") }}\n                <el-icon><ArrowRight /></el-icon>\n              </el-button>\n            </div>\n          </div>\n\n          <!-- 未拆分时显示 -->\n          <div v-else class=\"empty-shots\">\n            <el-empty :description=\"$t('dramaWorkflow.splitStoryboardFirst')\">\n              <el-button\n                type=\"primary\"\n                @click=\"generateShots\"\n                :loading=\"generatingShots\"\n                :icon=\"MagicStick\"\n              >\n                {{\n                  generatingShots\n                    ? $t(\"dramaWorkflow.aiSplitting\")\n                    : $t(\"dramaWorkflow.aiAutoSplit\")\n                }}\n              </el-button>\n            </el-empty>\n          </div>\n        </div>\n      </el-card>\n\n      <!-- 阶段 2: 角色图片 -->\n      <el-card\n        v-show=\"currentStep === 2\"\n        shadow=\"never\"\n        class=\"stage-card stage-card-fullscreen\"\n      >\n        <div class=\"stage-body stage-body-fullscreen\">\n          <div class=\"batch-toolbar-compact\">\n            <div class=\"toolbar-left\">\n              <el-checkbox\n                v-model=\"selectAllCharacters\"\n                @change=\"handleSelectAllCharacters\"\n                :indeterminate=\"isCharacterIndeterminate\"\n              >\n                {{ $t(\"common.selectAll\") }}\n              </el-checkbox>\n              <span class=\"selection-info\"\n                >{{ $t(\"dramaWorkflow.selected\") }}\n                {{ selectedCharacterIds.length }}/{{\n                  drama?.characters?.length || 0\n                }}</span\n              >\n            </div>\n            <div class=\"toolbar-right\">\n              <span class=\"stats-compact\"\n                >{{ $t(\"dramaWorkflow.characterCount\") }}:\n                {{ drama?.characters?.length || 0 }} |\n                {{ $t(\"dramaWorkflow.generated\") }}:\n                {{ characterImagesCount || 0 }}</span\n              >\n              <el-button\n                type=\"primary\"\n                size=\"small\"\n                :disabled=\"selectedCharacterIds.length === 0\"\n                :loading=\"batchGenerating\"\n                @click=\"batchGenerateCharacterImages\"\n                :icon=\"MagicStick\"\n              >\n                {{ $t(\"dramaWorkflow.batchGenerate\") }}({{\n                  selectedCharacterIds.length\n                }})\n              </el-button>\n              <el-button\n                type=\"success\"\n                size=\"small\"\n                @click=\"nextStep\"\n                :disabled=\"!allCharactersHaveImages\"\n              >\n                {{ $t(\"dramaWorkflow.nextStep\") }}\n                <el-icon><ArrowRight /></el-icon>\n              </el-button>\n            </div>\n          </div>\n\n          <div class=\"character-cards-area-fullscreen\">\n            <el-row :gutter=\"16\">\n              <el-col\n                :span=\"4\"\n                v-for=\"character in drama?.characters\"\n                :key=\"character.id\"\n              >\n                <el-card\n                  shadow=\"hover\"\n                  class=\"character-card\"\n                  :class=\"{\n                    'has-image': character.image_url,\n                    selected: isCharacterSelected(character.id),\n                  }\"\n                >\n                  <el-checkbox\n                    class=\"card-checkbox\"\n                    :model-value=\"isCharacterSelected(character.id)\"\n                    @change=\"toggleCharacterSelection(character.id)\"\n                  />\n                  <div class=\"character-preview\">\n                    <img\n                      v-if=\"hasImage(character)\"\n                      :src=\"getImageUrl(character)\"\n                      :alt=\"character.name\"\n                    />\n                    <el-avatar v-else :size=\"120\">{{\n                      character.name[0]\n                    }}</el-avatar>\n                  </div>\n\n                  <div class=\"character-info\">\n                    <h4>{{ character.name }}</h4>\n                    <el-tag\n                      :type=\"character.role === 'main' ? 'danger' : 'info'\"\n                      size=\"small\"\n                    >\n                      {{\n                        character.role === \"main\"\n                          ? \"主角\"\n                          : character.role === \"supporting\"\n                            ? \"配角\"\n                            : \"次要\"\n                      }}\n                    </el-tag>\n                    <p class=\"desc\">\n                      {{ character.appearance || character.description }}\n                    </p>\n                    <el-button\n                      size=\"small\"\n                      text\n                      type=\"primary\"\n                      @click=\"editCharacterDescription(character)\"\n                      :icon=\"Edit\"\n                    >\n                      编辑描述\n                    </el-button>\n                  </div>\n\n                  <div\n                    v-if=\"character.image_generation_status === 'failed'\"\n                    class=\"error-hint\"\n                    style=\"margin-bottom: 10px\"\n                  >\n                    <el-alert type=\"error\" :closable=\"false\" show-icon>\n                      <template #title> 生成失败 </template>\n                      <template\n                        #default\n                        v-if=\"character.image_generation_error\"\n                      >\n                        {{ character.image_generation_error }}\n                      </template>\n                    </el-alert>\n                  </div>\n\n                  <div class=\"character-actions\">\n                    <el-button\n                      type=\"primary\"\n                      size=\"small\"\n                      :loading=\"generatingCharacterIds.includes(character.id)\"\n                      @click=\"generateCharacterImage(character)\"\n                      :icon=\"MagicStick\"\n                    >\n                      <span v-if=\"generatingCharacterIds.includes(character.id)\"\n                        >生成中...</span\n                      >\n                      <span\n                        v-else-if=\"\n                          character.image_generation_status === 'failed'\n                        \"\n                        >重新生成</span\n                      >\n                      <span v-else>AI生成形象</span>\n                    </el-button>\n                    <el-button\n                      size=\"small\"\n                      @click=\"openUploadDialog(character)\"\n                      :icon=\"UploadFilled\"\n                    >\n                      上传图片\n                    </el-button>\n                    <el-button\n                      size=\"small\"\n                      @click=\"openCharacterLibrary(character)\"\n                      :icon=\"FolderOpened\"\n                    >\n                      从角色库选择\n                    </el-button>\n                    <el-button\n                      v-if=\"hasImage(character)\"\n                      size=\"small\"\n                      type=\"success\"\n                      plain\n                      @click=\"addToCharacterLibrary(character)\"\n                      :icon=\"Plus\"\n                    >\n                      添加到角色库\n                    </el-button>\n                    <el-button\n                      size=\"small\"\n                      type=\"danger\"\n                      plain\n                      @click=\"deleteCharacter(character)\"\n                      :icon=\"Delete\"\n                    >\n                      删除角色\n                    </el-button>\n                  </div>\n                </el-card>\n              </el-col>\n\n              <!-- 添加角色卡片 -->\n              <el-col :span=\"4\">\n                <el-card\n                  shadow=\"hover\"\n                  class=\"character-card add-character-card\"\n                  @click=\"openAddCharacterDialog\"\n                >\n                  <div class=\"add-character-content\">\n                    <el-icon :size=\"40\" color=\"#909399\"><Plus /></el-icon>\n                    <span class=\"add-text\">添加角色</span>\n                  </div>\n                </el-card>\n              </el-col>\n            </el-row>\n          </div>\n        </div>\n      </el-card>\n\n      <!-- 阶段 3: 剧集制作 -->\n      <el-card v-show=\"currentStep === 3\" shadow=\"never\" class=\"stage-card\">\n        <template #header>\n          <div class=\"stage-header\">\n            <div class=\"header-left\">\n              <el-icon :size=\"48\" color=\"#409eff\"><Film /></el-icon>\n              <div class=\"header-info\">\n                <h2>剧集制作</h2>\n                <p>对每一集进行分镜、图片、视频、剪辑</p>\n              </div>\n            </div>\n            <el-tag\n              v-if=\"completedEpisodesCount > 0\"\n              type=\"success\"\n              size=\"large\"\n            >\n              {{ completedEpisodesCount }}/{{\n                drama?.episodes?.length || 0\n              }}\n              已完成\n            </el-tag>\n          </div>\n        </template>\n\n        <div class=\"stage-body\">\n          <div class=\"stats-row\">\n            <div class=\"stat-box\">\n              <div class=\"stat-label\">总剧集数</div>\n              <div class=\"stat-value\">{{ drama?.episodes?.length || 0 }}</div>\n            </div>\n            <div class=\"stat-box\">\n              <div class=\"stat-label\">已完成</div>\n              <div class=\"stat-value\">{{ completedEpisodesCount || 0 }}</div>\n            </div>\n            <div class=\"stat-box\">\n              <div class=\"stat-label\">总进度</div>\n              <div class=\"stat-value\">{{ overallProgress }}%</div>\n            </div>\n          </div>\n\n          <el-divider />\n\n          <h3>剧集列表</h3>\n          <el-table\n            :data=\"sortedEpisodes\"\n            border\n            size=\"small\"\n            max-height=\"400\"\n            style=\"margin-bottom: 24px\"\n          >\n            <el-table-column\n              prop=\"episode_number\"\n              label=\"集数\"\n              width=\"80\"\n              sortable\n            />\n            <el-table-column prop=\"title\" label=\"标题\" width=\"200\" />\n            <el-table-column\n              prop=\"description\"\n              label=\"简介\"\n              show-overflow-tooltip\n            />\n            <el-table-column label=\"状态\" width=\"100\">\n              <template #default=\"{ row }\">\n                <el-tag\n                  :type=\"row.status === 'completed' ? 'success' : 'info'\"\n                  size=\"small\"\n                >\n                  {{ row.status === \"completed\" ? \"已完成\" : \"制作中\" }}\n                </el-tag>\n              </template>\n            </el-table-column>\n            <el-table-column label=\"时长\" width=\"100\">\n              <template #default=\"{ row }\">\n                {{ row.duration ? `${row.duration}秒` : \"-\" }}\n              </template>\n            </el-table-column>\n            <el-table-column label=\"操作\" width=\"120\" fixed=\"right\">\n              <template #default=\"{ row }\">\n                <el-button\n                  type=\"primary\"\n                  size=\"small\"\n                  @click=\"goToEpisodeDetail(row.id)\"\n                >\n                  进入制作\n                </el-button>\n              </template>\n            </el-table-column>\n          </el-table>\n\n          <div class=\"action-area\">\n            <h3>操作</h3>\n            <p class=\"hint-text\">\n              点击进入剧集列表，对每一集进行分镜、背景、合成、视频、剪辑\n            </p>\n            <el-button\n              type=\"primary\"\n              size=\"large\"\n              @click=\"goToEpisodeList\"\n              class=\"main-action-btn\"\n            >\n              <el-icon :size=\"20\"><Film /></el-icon>\n              <span>进入剧集制作</span>\n            </el-button>\n          </div>\n        </div>\n      </el-card>\n    </div>\n\n    <!-- 编辑角色描述对话框 -->\n    <el-dialog\n      v-model=\"editDescDialogVisible\"\n      title=\"编辑角色描述\"\n      width=\"600px\"\n    >\n      <el-form v-if=\"editingCharacter\" label-width=\"100px\">\n        <el-form-item label=\"角色名称\">\n          <el-input v-model=\"editingCharacter.name\" disabled />\n        </el-form-item>\n        <el-form-item label=\"外貌描述\">\n          <el-input\n            v-model=\"editingCharacter.appearance\"\n            type=\"textarea\"\n            :rows=\"4\"\n            placeholder=\"描述角色的外貌特征，如身高、体型、发型、穿着等\"\n            maxlength=\"500\"\n            show-word-limit\n          />\n        </el-form-item>\n        <el-form-item label=\"性格描述\">\n          <el-input\n            v-model=\"editingCharacter.personality\"\n            type=\"textarea\"\n            :rows=\"3\"\n            placeholder=\"描述角色的性格特点\"\n            maxlength=\"300\"\n            show-word-limit\n          />\n        </el-form-item>\n        <el-form-item label=\"角色简介\">\n          <el-input\n            v-model=\"editingCharacter.description\"\n            type=\"textarea\"\n            :rows=\"3\"\n            placeholder=\"角色的背景故事或简介\"\n            maxlength=\"500\"\n            show-word-limit\n          />\n        </el-form-item>\n      </el-form>\n      <template #footer>\n        <el-button @click=\"editDescDialogVisible = false\">取消</el-button>\n        <el-button\n          type=\"primary\"\n          @click=\"saveCharacterDescription\"\n          :loading=\"saving\"\n          >保存</el-button\n        >\n      </template>\n    </el-dialog>\n\n    <!-- 添加角色对话框 -->\n    <el-dialog\n      v-model=\"addCharacterDialogVisible\"\n      title=\"添加新角色\"\n      width=\"600px\"\n    >\n      <el-form :model=\"newCharacter\" label-width=\"80px\">\n        <el-form-item label=\"角色名称\" required>\n          <el-input v-model=\"newCharacter.name\" placeholder=\"请输入角色名称\" />\n        </el-form-item>\n        <el-form-item label=\"角色类型\">\n          <el-select v-model=\"newCharacter.role\" placeholder=\"请选择角色类型\">\n            <el-option label=\"主角\" value=\"main\" />\n            <el-option label=\"配角\" value=\"supporting\" />\n            <el-option label=\"次要\" value=\"minor\" />\n          </el-select>\n        </el-form-item>\n        <el-form-item label=\"外貌描述\">\n          <el-input\n            v-model=\"newCharacter.appearance\"\n            type=\"textarea\"\n            :rows=\"3\"\n            placeholder=\"描述角色的外貌特征，如身高、体型、发型、穿着等\"\n          />\n        </el-form-item>\n        <el-form-item label=\"性格特点\">\n          <el-input\n            v-model=\"newCharacter.personality\"\n            type=\"textarea\"\n            :rows=\"3\"\n            placeholder=\"描述角色的性格特点、行为习惯等\"\n          />\n        </el-form-item>\n        <el-form-item label=\"角色描述\" required>\n          <el-input\n            v-model=\"newCharacter.description\"\n            type=\"textarea\"\n            :rows=\"4\"\n            placeholder=\"请输入角色的详细描述，包括背景故事、角色关系等\"\n          />\n        </el-form-item>\n      </el-form>\n      <template #footer>\n        <el-button @click=\"addCharacterDialogVisible = false\">取消</el-button>\n        <el-button type=\"primary\" @click=\"addCharacter\" :loading=\"saving\"\n          >添加</el-button\n        >\n      </template>\n    </el-dialog>\n\n    <!-- 角色库选择对话框 -->\n    <el-dialog\n      v-model=\"libraryDialogVisible\"\n      title=\"从角色库选择\"\n      width=\"800px\"\n    >\n      <div class=\"library-grid\" v-if=\"characterLibrary.length > 0\">\n        <el-row :gutter=\"16\">\n          <el-col :span=\"6\" v-for=\"item in characterLibrary\" :key=\"item.id\">\n            <el-card\n              shadow=\"hover\"\n              class=\"library-item\"\n              @click=\"selectFromLibrary(item)\"\n              :body-style=\"{ padding: '10px' }\"\n            >\n              <img\n                :src=\"getImageUrl(item)\"\n                :alt=\"item.name\"\n                class=\"library-image\"\n              />\n              <div class=\"library-info\">\n                <div class=\"library-name\">{{ item.name }}</div>\n                <el-tag size=\"small\">{{ item.category || \"未分类\" }}</el-tag>\n              </div>\n            </el-card>\n          </el-col>\n        </el-row>\n      </div>\n      <el-empty v-else description=\"角色库为空，生成形象后可添加到角色库\" />\n    </el-dialog>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted, watch } from \"vue\";\nimport { useRoute, useRouter } from \"vue-router\";\nimport { useI18n } from \"vue-i18n\";\nimport { ElMessage, ElMessageBox } from \"element-plus\";\nimport {\n  MagicStick,\n  Film,\n  User,\n  Picture,\n  ArrowLeft,\n  ArrowRight,\n  Edit,\n  Document,\n  ArrowDown,\n  Upload,\n  UploadFilled,\n  FolderOpened,\n  Plus,\n  WarningFilled,\n  InfoFilled,\n  Check,\n  Delete,\n} from \"@element-plus/icons-vue\";\nimport { dramaAPI } from \"@/api/drama\";\nimport { generationAPI } from \"@/api/generation\";\nimport { characterLibraryAPI } from \"@/api/character-library\";\nimport request from \"@/utils/request\";\nimport type { Drama, DramaStatus } from \"@/types/drama\";\nimport { AppHeader } from \"@/components/common\";\nimport { getImageUrl, hasImage } from \"@/utils/image\";\n\nconst route = useRoute();\nconst router = useRouter();\nconst { t } = useI18n();\nconst drama = ref<Drama>();\nconst currentStep = ref(0);\nconst currentEpisodeNumber = ref(1); // 当前正在创作的集数\nconst generatingCharacterIds = ref<(number | string)[]>([]);\nconst batchGenerating = ref(false);\nconst selectedCharacterIds = ref<(number | string)[]>([]);\nconst selectAllCharacters = ref(false);\nconst generatingScript = ref(false);\nconst scriptContent = ref(\"\");\nconst showScriptInput = ref(false); // 控制是否显示剧本输入框\n\n// 分镜相关状态\nconst generatingShots = ref(false);\nconst parsingCharacters = ref(false);\n\nconst isCharacterIndeterminate = computed(() => {\n  const selectedCount = selectedCharacterIds.value.length;\n  const totalCount = drama.value?.characters?.length || 0;\n  return selectedCount > 0 && selectedCount < totalCount;\n});\n\nconst isCharacterSelected = (id: number | string) => {\n  return selectedCharacterIds.value.includes(id);\n};\n\nconst toggleCharacterSelection = (id: number | string) => {\n  const index = selectedCharacterIds.value.indexOf(id);\n  if (index > -1) {\n    selectedCharacterIds.value.splice(index, 1);\n  } else {\n    selectedCharacterIds.value.push(id);\n  }\n  updateSelectAllCharactersState();\n};\n\nconst handleSelectAllCharacters = (val: boolean) => {\n  if (val && drama.value?.characters) {\n    selectedCharacterIds.value = drama.value.characters.map((c) => c.id);\n  } else {\n    selectedCharacterIds.value = [];\n  }\n};\n\nconst updateSelectAllCharactersState = () => {\n  const totalCount = drama.value?.characters?.length || 0;\n  selectAllCharacters.value =\n    selectedCharacterIds.value.length === totalCount && totalCount > 0;\n};\nconst libraryDialogVisible = ref(false);\nconst selectedCharacter = ref<any>(null);\nconst characterLibrary = ref<any[]>([]);\nconst editDescDialogVisible = ref(false);\nconst editingCharacter = ref<any>(null);\nconst saving = ref(false);\nconst addCharacterDialogVisible = ref(false);\nconst newCharacter = ref({\n  name: \"\",\n  role: \"supporting\",\n  appearance: \"\",\n  personality: \"\",\n  description: \"\",\n});\n\n// 各阶段完成状态\n// 判断当前集是否已有剧本\nconst hasScript = computed(() => {\n  if (!drama.value?.episodes || drama.value.episodes.length === 0) {\n    return false;\n  }\n  // 检查当前集是否存在\n  const currentEpisode = drama.value.episodes.find(\n    (ep) => ep.episode_number === currentEpisodeNumber.value,\n  );\n  return (\n    currentEpisode &&\n    currentEpisode.script_content &&\n    currentEpisode.script_content.length > 0\n  );\n});\n\n// 获取当前集\nconst currentEpisode = computed(() => {\n  if (!drama.value?.episodes) return null;\n  return drama.value.episodes.find(\n    (ep) => ep.episode_number === currentEpisodeNumber.value,\n  );\n});\n\n// 判断是否有角色\nconst hasCharacters = computed(() => {\n  return drama.value?.characters && drama.value.characters.length > 0;\n});\nconst episodesCount = computed(() => drama.value?.episodes?.length || 0);\nconst sortedEpisodes = computed(() => {\n  if (!drama.value?.episodes) return [];\n  return [...drama.value.episodes].sort(\n    (a, b) => a.episode_number - b.episode_number,\n  );\n});\nconst charactersCount = computed(() => drama.value?.characters?.length || 0);\nconst characterImagesCount = computed(() => {\n  return drama.value?.characters?.filter((c) => c.image_url).length || 0;\n});\nconst allCharactersHaveImages = computed(() => {\n  if (!drama.value?.characters || drama.value.characters.length === 0) {\n    return false;\n  }\n  return drama.value.characters.every(\n    (c) => c.image_url && c.image_url.length > 0,\n  );\n});\nconst completedEpisodesCount = computed(() => {\n  return 0;\n});\nconst overallProgress = computed(() => {\n  return 0;\n});\n\n// 修复图片URL协议问题\nconst fixImageUrl = (url: string | undefined | null): string => {\n  if (!url) return \"\";\n  // 如果是blob URL，直接返回\n  if (url.startsWith(\"blob:\")) return url;\n  return url;\n};\n\n// 状态标签\nconst getStatusType = (status?: DramaStatus) => {\n  const types: Partial<Record<DramaStatus, string>> = {\n    draft: \"info\",\n    planning: \"primary\",\n    production: \"warning\",\n    generating: \"warning\",\n    completed: \"success\",\n    archived: \"info\",\n    error: \"danger\",\n  };\n  return status ? types[status] : \"info\";\n};\n\nconst getStatusText = (status?: DramaStatus) => {\n  const texts: Partial<Record<DramaStatus, string>> = {\n    draft: \"草稿\",\n    planning: \"策划中\",\n    production: \"制作中\",\n    generating: \"生成中\",\n    completed: \"已完成\",\n    archived: \"已归档\",\n    error: \"错误\",\n  };\n  return status ? texts[status] : \"未知\";\n};\n\n// 导航\nconst goBack = () => {\n  router.push(\"/dramas\");\n};\n\nconst prevStep = () => {\n  if (currentStep.value > 0) {\n    currentStep.value--;\n    updateUrlState();\n  }\n};\n\nconst nextStep = () => {\n  if (currentStep.value < 2) {\n    currentStep.value++;\n    updateUrlState();\n  }\n};\n\n// 更新URL状态，保存当前步骤\nconst updateUrlState = () => {\n  router.replace({\n    query: {\n      ...route.query,\n      step: currentStep.value.toString(),\n    },\n  });\n};\n\n// 页面跳转\nconst goToScriptGeneration = () => {\n  router.push(`/dramas/${drama.value?.id}/script`);\n};\n\n// AI流式生成剧本\nconst generateScriptByAI = async () => {\n  if (!drama.value?.title) {\n    ElMessage.warning(\"项目标题不存在\");\n    return;\n  }\n\n  generatingScript.value = true;\n  scriptContent.value = \"\";\n\n  try {\n    const response = await fetch(\"/api/v1/ai/generate-script-stream\", {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        drama_title: drama.value.title,\n        drama_id: drama.value.id,\n      }),\n    });\n\n    if (!response.ok) {\n      throw new Error(\"生成失败\");\n    }\n\n    const reader = response.body?.getReader();\n    const decoder = new TextDecoder();\n\n    if (!reader) {\n      throw new Error(\"无法读取响应流\");\n    }\n\n    while (true) {\n      const { done, value } = await reader.read();\n      if (done) break;\n\n      const chunk = decoder.decode(value, { stream: true });\n      scriptContent.value += chunk;\n    }\n\n    ElMessage.success(\"剧本生成完成\");\n  } catch (error: any) {\n    ElMessage.error(error.message || \"生成失败\");\n    scriptContent.value = \"\";\n  } finally {\n    generatingScript.value = false;\n  }\n};\n\n// 保存章节剧本（不解析角色）\nconst saveChapterScript = async () => {\n  if (!scriptContent.value.trim()) {\n    ElMessage.warning(\"请输入章节内容\");\n    return;\n  }\n\n  generatingScript.value = true;\n  try {\n    ElMessage.info(\"正在保存章节...\");\n\n    // 保存当前章节内容，不进行角色解析\n    const existingEpisodes = drama.value?.episodes || [];\n    const episodeIndex = existingEpisodes.findIndex(\n      (ep) => ep.episode_number === currentEpisodeNumber.value,\n    );\n\n    const currentEpisodeData = {\n      episode_number: currentEpisodeNumber.value,\n      title: `第${currentEpisodeNumber.value}章`,\n      script_content: scriptContent.value,\n      description: \"\",\n      duration: 0,\n      status: \"draft\",\n    };\n\n    let episodesToSave;\n    if (episodeIndex > -1) {\n      // 更新现有章节\n      episodesToSave = [...existingEpisodes];\n      episodesToSave[episodeIndex] = {\n        ...existingEpisodes[episodeIndex],\n        ...currentEpisodeData,\n      };\n    } else {\n      // 添加新章节\n      episodesToSave = [\n        ...existingEpisodes.map((ep) => ({\n          episode_number: ep.episode_number,\n          title: ep.title,\n          script_content: ep.script_content,\n          description: ep.description,\n          duration: ep.duration,\n          status: ep.status,\n        })),\n        currentEpisodeData,\n      ];\n    }\n\n    await dramaAPI.saveEpisodes(drama.value!.id, episodesToSave);\n\n    ElMessage.success(`第${currentEpisodeNumber.value}章保存成功`);\n    await loadDramaData();\n  } catch (error: any) {\n    ElMessage.error(error.message || \"保存失败\");\n  } finally {\n    generatingScript.value = false;\n  }\n};\n\n// 编辑角色描述\nconst editCharacterDescription = (character: any) => {\n  editingCharacter.value = { ...character };\n  editDescDialogVisible.value = true;\n};\n\n// 保存角色描述\nconst saveCharacterDescription = async () => {\n  if (!editingCharacter.value) return;\n\n  saving.value = true;\n  try {\n    await characterLibraryAPI.updateCharacter(editingCharacter.value.id, {\n      appearance: editingCharacter.value.appearance,\n      personality: editingCharacter.value.personality,\n      description: editingCharacter.value.description,\n    });\n\n    ElMessage.success(\"角色描述已更新\");\n    editDescDialogVisible.value = false;\n    await loadDramaData();\n  } catch (error: any) {\n    ElMessage.error(error.response?.data?.message || \"保存失败\");\n  } finally {\n    saving.value = false;\n  }\n};\n\n// 集数切换\nconst switchEpisode = (episodeNumber: number) => {\n  currentEpisodeNumber.value = episodeNumber;\n  // 加载该集的剧本内容\n  const episode = drama.value?.episodes?.find(\n    (ep) => ep.episode_number === episodeNumber,\n  );\n  if (episode && episode.script_content) {\n    scriptContent.value = episode.script_content;\n  } else {\n    scriptContent.value = \"\";\n  }\n};\n\n// 开始创建章节\nconst startCreateChapter = () => {\n  showScriptInput.value = true;\n};\n\n// 创建下一集\nconst createNextEpisode = () => {\n  currentEpisodeNumber.value = episodesCount.value + 1;\n  scriptContent.value = \"\";\n  showScriptInput.value = true; // 显示输入框\n  currentStep.value = 0;\n};\n\n// 编辑当前集剧本\nconst editCurrentEpisodeScript = () => {\n  if (currentEpisode.value?.script_content) {\n    scriptContent.value = currentEpisode.value.script_content;\n  }\n};\n\n// AI自动拆分分镜\nconst generateShots = async () => {\n  if (!currentEpisode.value?.script_content) {\n    ElMessage.warning(t(\"dramaWorkflow.pleaseWriteScript\"));\n    return;\n  }\n\n  generatingShots.value = true;\n  try {\n    ElMessage.info(\"AI正在拆分镜头...\");\n\n    // 调用分镜拆分API\n    const result = await generationAPI.generateShots({\n      episode_id: currentEpisode.value.id,\n      script_content: currentEpisode.value.script_content,\n    });\n\n    ElMessage.success(`成功拆分 ${result.shots.length} 个镜头`);\n    await loadDramaData();\n  } catch (error: any) {\n    ElMessage.error(error.response?.data?.message || \"拆分失败\");\n  } finally {\n    generatingShots.value = false;\n  }\n};\n\n// 重新拆分分镜\nconst regenerateShots = async () => {\n  await ElMessageBox.confirm(\n    t(\"dramaWorkflow.reGenerateShotsConfirm\"),\n    t(\"dramaWorkflow.reGenerateShots\"),\n    {\n      confirmButtonText: t(\"common.confirm\"),\n      cancelButtonText: t(\"common.cancel\"),\n      type: \"warning\",\n    },\n  );\n  await generateShots();\n};\n\n// 编辑镜头\nconst editShot = (shot: any, index: number) => {\n  // TODO: 打开镜头编辑对话框\n  ElMessage.info(\"镜头编辑功能开发中\");\n};\n\n// 从分镜解析角色\nconst parseShotsToCharacters = async () => {\n  if (!currentEpisode.value?.shots || currentEpisode.value.shots.length === 0) {\n    ElMessage.warning(\"请先进行分镜拆分\");\n    return;\n  }\n\n  parsingCharacters.value = true;\n  try {\n    ElMessage.info(\"正在解析角色...\");\n\n    // 从所有镜头内容中提取角色\n    const shotsContent = currentEpisode.value.shots\n      .map((s: any) => s.content)\n      .join(\"\\n\");\n\n    const parseResult = await generationAPI.parseScript({\n      drama_id: drama.value!.id,\n      script_content: shotsContent,\n      auto_split: false,\n    });\n\n    if (parseResult.characters && parseResult.characters.length > 0) {\n      const existingCharacters = drama.value?.characters || [];\n      const existingNames = new Set(existingCharacters.map((c) => c.name));\n\n      // 只添加新角色\n      const newCharacters = parseResult.characters.filter(\n        (c: any) => !existingNames.has(c.name),\n      );\n\n      if (newCharacters.length > 0) {\n        const allCharacters = [\n          ...existingCharacters.map((c) => ({\n            name: c.name,\n            role: c.role,\n            appearance: c.appearance,\n            personality: c.personality,\n            description: c.description,\n          })),\n          ...newCharacters,\n        ];\n        await dramaAPI.saveCharacters(drama.value!.id, allCharacters);\n        ElMessage.success(`成功解析 ${newCharacters.length} 个新角色`);\n      } else {\n        ElMessage.info(\"未发现新角色\");\n      }\n    } else {\n      ElMessage.warning(\"未解析到角色信息\");\n    }\n\n    await loadDramaData();\n  } catch (error: any) {\n    ElMessage.error(error.response?.data?.message || \"解析失败\");\n  } finally {\n    parsingCharacters.value = false;\n  }\n};\n\n// 打开添加角色对话框\nconst openAddCharacterDialog = () => {\n  newCharacter.value = {\n    name: \"\",\n    role: \"supporting\",\n    appearance: \"\",\n    personality: \"\",\n    description: \"\",\n  };\n  addCharacterDialogVisible.value = true;\n};\n\n// 添加角色\nconst addCharacter = async () => {\n  if (!newCharacter.value.name.trim()) {\n    ElMessage.warning(\"请输入角色名称\");\n    return;\n  }\n\n  if (!newCharacter.value.description.trim()) {\n    ElMessage.warning(\"请输入角色描述\");\n    return;\n  }\n\n  saving.value = true;\n  try {\n    // 将新角色添加到现有角色列表中，而不是覆盖\n    const existingCharacters = drama.value?.characters || [];\n    const allCharacters = [\n      ...existingCharacters.map((c) => ({\n        name: c.name,\n        role: c.role,\n        appearance: c.appearance,\n        personality: c.personality,\n        description: c.description,\n      })),\n      {\n        name: newCharacter.value.name,\n        role: newCharacter.value.role,\n        appearance: newCharacter.value.appearance,\n        personality: newCharacter.value.personality,\n        description: newCharacter.value.description,\n      },\n    ];\n\n    await dramaAPI.saveCharacters(drama.value!.id, allCharacters);\n\n    ElMessage.success(\"角色添加成功\");\n    addCharacterDialogVisible.value = false;\n    await loadDramaData();\n  } catch (error: any) {\n    ElMessage.error(error.response?.data?.message || \"添加失败\");\n  } finally {\n    saving.value = false;\n  }\n};\n\n// 删除角色\nconst deleteCharacter = async (character: any) => {\n  try {\n    // 检查角色是否在角色库中\n    if (character.library_id) {\n      ElMessage.warning(\"该角色来自角色库，请到角色库中删除\");\n      return;\n    }\n\n    await ElMessageBox.confirm(\n      `确定要删除角色 \"${character.name}\" 吗？此操作不可恢复。`,\n      \"删除角色\",\n      {\n        confirmButtonText: \"确定\",\n        cancelButtonText: \"取消\",\n        type: \"warning\",\n      },\n    );\n\n    saving.value = true;\n    // 从现有角色列表中移除该角色，然后保存\n    const remainingCharacters = drama\n      .value!.characters!.filter((c) => c.id !== character.id)\n      .map((c) => ({\n        name: c.name,\n        role: c.role,\n        appearance: c.appearance,\n        personality: c.personality,\n        description: c.description,\n      }));\n\n    await dramaAPI.saveCharacters(drama.value!.id, remainingCharacters);\n\n    ElMessage.success(\"角色已删除\");\n    await loadDramaData();\n  } catch (error: any) {\n    if (error !== \"cancel\") {\n      ElMessage.error(error.response?.data?.message || \"删除失败\");\n    }\n  } finally {\n    saving.value = false;\n  }\n};\n\nconst generateCharacterImage = async (character: any) => {\n  if (generatingCharacterIds.value.includes(character.id)) return;\n\n  generatingCharacterIds.value.push(character.id);\n  try {\n    const res = await characterLibraryAPI.generateCharacterImage(character.id);\n    ElMessage.success(`${character.name}的形象生成成功`);\n    await loadDramaData();\n  } catch (error: any) {\n    ElMessage.error(\n      error.response?.data?.message || `${character.name}生成失败`,\n    );\n  } finally {\n    const index = generatingCharacterIds.value.indexOf(character.id);\n    if (index > -1) {\n      generatingCharacterIds.value.splice(index, 1);\n    }\n  }\n};\n\nconst batchGenerateCharacterImages = async () => {\n  if (selectedCharacterIds.value.length === 0) {\n    ElMessage.warning(\"请选择要生成的角色\");\n    return;\n  }\n\n  if (selectedCharacterIds.value.length > 10) {\n    ElMessage.warning(\"单次最多生成10个角色\");\n    return;\n  }\n\n  batchGenerating.value = true;\n  generatingCharacterIds.value = [...selectedCharacterIds.value];\n\n  try {\n    await characterLibraryAPI.batchGenerateCharacterImages(\n      selectedCharacterIds.value.map((id) => String(id)),\n    );\n\n    ElMessage.success(\n      `批量生成任务已提交，正在后台生成 ${selectedCharacterIds.value.length} 个角色形象`,\n    );\n\n    // 轮询检查生成状态\n    startCharacterPolling();\n  } catch (error: any) {\n    ElMessage.error(error.response?.data?.message || \"批量生成失败\");\n    batchGenerating.value = false;\n    generatingCharacterIds.value = [];\n  }\n};\n\nlet characterPollingTimer: number | null = null;\n\nconst startCharacterPolling = () => {\n  if (characterPollingTimer) return;\n\n  characterPollingTimer = window.setInterval(async () => {\n    try {\n      await loadDramaData();\n\n      if (!drama.value?.characters) return;\n\n      // 检查每个选中角色的状态\n      let completedCount = 0;\n      let failedCount = 0;\n      const failedCharacters: string[] = [];\n\n      selectedCharacterIds.value.forEach((id) => {\n        const char = drama.value?.characters?.find((c) => c.id === id);\n        if (char) {\n          if (char.image_url) {\n            completedCount++;\n          } else if (char.image_generation_status === \"failed\") {\n            failedCount++;\n            failedCharacters.push(char.name);\n          }\n        }\n      });\n\n      // 如果所有任务都完成（成功或失败），停止轮询\n      if (completedCount + failedCount === selectedCharacterIds.value.length) {\n        stopCharacterPolling();\n\n        if (failedCount > 0) {\n          ElMessage.warning(\n            `批量生成完成：${completedCount}个成功，${failedCount}个失败（${failedCharacters.join(\"、\")}）`,\n          );\n        } else {\n          ElMessage.success(\"批量生成完成\");\n        }\n      }\n    } catch (error) {\n      console.error(\"轮询错误:\", error);\n    }\n  }, 5000); // 每5秒检查一次\n};\n\nconst stopCharacterPolling = () => {\n  if (characterPollingTimer) {\n    clearInterval(characterPollingTimer);\n    characterPollingTimer = null;\n  }\n  batchGenerating.value = false;\n  generatingCharacterIds.value = [];\n  selectedCharacterIds.value = [];\n  selectAllCharacters.value = false;\n};\n\nconst openUploadDialog = (character: any) => {\n  selectedCharacter.value = character;\n\n  // 创建临时文件输入框\n  const input = document.createElement(\"input\");\n  input.type = \"file\";\n  input.accept = \"image/jpeg,image/png,image/jpg\";\n\n  input.onchange = async (e: any) => {\n    const file = e.target.files?.[0];\n    if (!file) return;\n\n    // 验证文件大小（10MB）\n    if (file.size > 10 * 1024 * 1024) {\n      ElMessage.error(\"图片大小不能超过10MB\");\n      return;\n    }\n\n    try {\n      // 创建FormData上传文件\n      const formData = new FormData();\n      formData.append(\"file\", file);\n\n      ElMessage.info(\"正在上传图片...\");\n\n      // 上传到后端MinIO（后端会自动更新数据库）\n      await request.post<{ url: string }>(\n        `/characters/${selectedCharacter.value.id}/upload-image`,\n        formData,\n        {\n          headers: {\n            \"Content-Type\": \"multipart/form-data\",\n          },\n        },\n      );\n\n      ElMessage.success(\"图片上传成功\");\n      await loadDramaData();\n    } catch (error: any) {\n      ElMessage.error(error.message || \"上传失败\");\n    }\n  };\n\n  // 触发文件选择\n  input.click();\n};\n\nconst openCharacterLibrary = async (character: any) => {\n  selectedCharacter.value = character;\n  try {\n    const res = await characterLibraryAPI.list({ page: 1, page_size: 100 });\n    characterLibrary.value = res.items || [];\n  } catch (error: any) {\n    ElMessage.error(error.message || \"加载角色库失败\");\n    characterLibrary.value = [];\n  }\n  libraryDialogVisible.value = true;\n};\n\nconst selectFromLibrary = async (libraryItem: any) => {\n  try {\n    await characterLibraryAPI.applyFromLibrary(\n      selectedCharacter.value.id,\n      libraryItem.id,\n    );\n    ElMessage.success(\"已应用角色库形象\");\n    libraryDialogVisible.value = false;\n    await loadDramaData();\n  } catch (error: any) {\n    ElMessage.error(error.message || \"应用失败\");\n  }\n};\n\nconst addToCharacterLibrary = async (character: any) => {\n  try {\n    await characterLibraryAPI.addCharacterToLibrary(character.id);\n    ElMessage.success(`${character.name}已添加到角色库`);\n  } catch (error: any) {\n    ElMessage.error(error.message || \"添加失败\");\n  }\n};\n\nconst goToEpisodeList = () => {\n  router.push(`/dramas/${drama.value?.id}/episodes`);\n};\n\nconst goToEpisodeDetail = (episodeId: string) => {\n  router.push(`/dramas/${drama.value?.id}/episodes/${episodeId}`);\n};\n\nconst loadDramaData = async () => {\n  const dramaId = route.params.id as string;\n  try {\n    drama.value = await dramaAPI.get(dramaId);\n  } catch (error: any) {\n    ElMessage.error(error.message || \"获取剧本信息失败\");\n    router.push(\"/dramas\");\n  }\n};\n\nonMounted(() => {\n  loadDramaData();\n});\n</script>\n\n<style scoped>\n.workflow-container {\n  min-height: 100vh;\n  background: #f8fafc;\n  transition: background var(--transition-normal);\n}\n\n.dark .workflow-container {\n  background: #0f172a;\n}\n\n.workflow-header {\n  background: var(--bg-card);\n  border-bottom: 1px solid var(--border-primary);\n  padding: 10px 24px;\n  margin-bottom: 0;\n}\n\n.header-single-line {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 32px;\n}\n\n.header-left-section {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  flex-shrink: 0;\n}\n\n.back-btn {\n  color: var(--text-secondary);\n  padding: 0;\n  margin-right: 4px;\n}\n\n.back-btn:hover {\n  color: #0ea5e9;\n}\n\n.drama-title {\n  margin: 0;\n  font-size: 14px;\n  font-weight: 600;\n  color: var(--text-primary);\n  white-space: nowrap;\n}\n\n.steps-inline {\n  flex: 1;\n  display: flex;\n  justify-content: center;\n  min-width: 0;\n}\n\n.custom-steps {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n}\n\n.step-item {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.step-circle {\n  width: 24px;\n  height: 24px;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 12px;\n  font-weight: 600;\n  background: #e4e7ed;\n  color: #909399;\n  transition: all 0.3s;\n}\n\n.step-item.active .step-circle {\n  background: #409eff;\n  color: #ffffff;\n}\n\n.step-item.current .step-circle {\n  background: #409eff;\n  color: #ffffff;\n  box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.2);\n}\n\n.step-text {\n  font-size: 13px;\n  font-weight: 500;\n  color: #909399;\n  transition: color 0.3s;\n}\n\n.step-item.active .step-text {\n  color: #303133;\n}\n\n.step-item.current .step-text {\n  color: #409eff;\n  font-weight: 600;\n}\n\n.step-arrow {\n  font-size: 16px;\n  color: #c0c4cc;\n}\n\n.stage-area {\n  padding: 0;\n}\n\n.stage-card {\n  min-height: 400px;\n  background: #ffffff;\n  border: 1px solid #e4e7ed;\n  border-radius: 8px;\n}\n\n.stage-card-fullscreen {\n  min-height: calc(100vh - 70px);\n  display: flex;\n  flex-direction: column;\n  margin: 0;\n  border: none;\n  border-radius: 0;\n}\n\n.stage-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n.header-left {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n}\n\n.header-info h2 {\n  margin: 0 0 4px 0;\n  font-size: 20px;\n  font-weight: 600;\n}\n\n.header-info p {\n  margin: 0;\n  font-size: 13px;\n  color: #909399;\n}\n\n.stage-body {\n  padding: 20px 0;\n}\n\n.stats-row {\n  display: flex;\n  gap: 24px;\n  justify-content: center;\n  margin: 24px 0;\n}\n\n.stat-box {\n  text-align: center;\n  min-width: 140px;\n  padding: 20px;\n  background: #f5f7fa;\n  border-radius: 8px;\n  border: 1px solid #e4e7ed;\n  transition: all 0.3s ease;\n}\n\n.stat-box:hover {\n  background: #ecf5ff;\n  border-color: #c6e2ff;\n  transform: translateY(-2px);\n  box-shadow: 0 4px 12px rgba(64, 158, 255, 0.1);\n}\n\n.stat-label {\n  font-size: 13px;\n  color: #909399;\n  margin-bottom: 8px;\n  font-weight: 500;\n}\n\n.stat-value {\n  font-size: 32px;\n  font-weight: 600;\n  color: #409eff;\n}\n\n.stage-body-fullscreen {\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  padding: 0;\n}\n\n.action-buttons-inline {\n  display: flex;\n  justify-content: flex-end;\n  flex-shrink: 0;\n}\n\n.action-area {\n  text-align: center;\n  padding: 20px 0;\n  flex-shrink: 0;\n}\n\n.action-area h3 {\n  margin: 0 0 16px 0;\n  font-size: 16px;\n  font-weight: 600;\n}\n\n.main-action-btn {\n  width: 100%;\n  height: 50px;\n  font-size: 16px;\n}\n\n.hint-text {\n  color: #909399;\n  font-size: 13px;\n  text-align: center;\n  margin: 0 0 16px 0;\n  line-height: 1.6;\n}\n\n.warning-hint {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 20px;\n  margin-bottom: 16px;\n  background-color: #fef0f0;\n  border: 1px solid #fbc4c4;\n  border-radius: 8px;\n  color: #f56c6c;\n  font-size: 14px;\n}\n\n/* 角色卡片区域 */\n.character-cards-area {\n  margin: 24px 0;\n}\n\n.character-card {\n  margin-bottom: 16px;\n  border-radius: 12px;\n  transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n  position: relative;\n  background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);\n  border: 2px solid transparent;\n  overflow: hidden;\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);\n}\n\n.character-card::before {\n  content: \"\";\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  height: 4px;\n  background: linear-gradient(90deg, #409eff, #67c23a, #e6a23c);\n  opacity: 0;\n  transition: opacity 0.3s;\n}\n\n.character-card:hover {\n  transform: translateY(-4px);\n  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);\n  border-color: #e4e7ed;\n}\n\n.character-card:hover::before {\n  opacity: 1;\n}\n\n.character-card.has-image {\n  background: linear-gradient(135deg, #f0f9ff 0%, #e8f4f8 100%);\n  border-color: #67c23a;\n}\n\n.character-card.has-image::before {\n  background: linear-gradient(90deg, #67c23a, #85ce61);\n  opacity: 1;\n}\n\n.character-card.selected {\n  background: linear-gradient(135deg, #ecf5ff 0%, #d9ecff 100%);\n  border-color: #409eff;\n  box-shadow: 0 4px 16px rgba(64, 158, 255, 0.25);\n}\n\n.character-card.selected::before {\n  background: linear-gradient(90deg, #409eff, #66b1ff);\n  opacity: 1;\n}\n\n.card-checkbox {\n  position: absolute;\n  top: 12px;\n  right: 12px;\n  z-index: 2;\n  background: rgba(255, 255, 255, 0.9);\n  backdrop-filter: blur(4px);\n  padding: 4px;\n  border-radius: 4px;\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);\n}\n\n.batch-toolbar {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n  padding: 12px 16px;\n  margin-bottom: 20px;\n  background: #ecf5ff;\n  border-radius: 8px;\n  border: 1px solid #d9ecff;\n}\n\n.batch-toolbar-compact {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 10px 16px;\n  background: #f5f7fa;\n  border-radius: 6px;\n  margin-bottom: 12px;\n  flex-shrink: 0;\n}\n\n.toolbar-left {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.toolbar-right {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n}\n\n.stats-compact {\n  color: #909399;\n  font-size: 13px;\n  padding-right: 12px;\n  border-right: 1px solid #dcdfe6;\n}\n\n.selection-info {\n  color: #606266;\n  font-size: 13px;\n}\n\n.character-cards-area-fullscreen {\n  flex: 1;\n  overflow-y: auto;\n  padding-right: 8px;\n}\n\n.character-preview {\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  height: 220px;\n  margin: -2px -2px 12px -2px;\n  background: linear-gradient(135deg, #f5f7fa 0%, #e8eaf0 100%);\n  position: relative;\n  overflow: hidden;\n}\n\n.character-preview::before {\n  content: \"\";\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  background: linear-gradient(\n    135deg,\n    rgba(64, 158, 255, 0.05) 0%,\n    rgba(103, 194, 58, 0.05) 100%\n  );\n  pointer-events: none;\n}\n\n.character-preview img {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n}\n\n.character-info {\n  margin-bottom: 10px;\n  padding: 0 4px;\n  text-align: center;\n}\n\n.character-info h4 {\n  margin: 0 0 6px 0;\n  font-size: 14px;\n  font-weight: 700;\n  color: #303133;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 6px;\n}\n\n.character-info .desc {\n  font-size: 12px;\n  color: #606266;\n  margin: 8px 0 0 0;\n  line-height: 1.5;\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  background: rgba(245, 247, 250, 0.5);\n  padding: 6px 8px;\n  border-radius: 6px;\n  border-left: 3px solid #409eff;\n}\n\n.character-actions {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n  margin-top: 10px;\n  padding: 0 4px 4px;\n}\n\n.character-actions .el-button {\n  width: 100%;\n  border-radius: 8px;\n  font-weight: 500;\n  transition: all 0.3s;\n}\n\n.character-actions .el-button--primary {\n  background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);\n  border: none;\n}\n\n.character-actions .el-button--primary:hover {\n  background: linear-gradient(135deg, #66b1ff 0%, #409eff 100%);\n  transform: translateY(-1px);\n  box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4);\n}\n\n.character-actions .el-button:not(.el-button--primary) {\n  background: #ffffff;\n  border: 1px solid #dcdfe6;\n}\n\n.character-actions .el-button:not(.el-button--primary):hover {\n  background: #f5f7fa;\n  border-color: #409eff;\n  color: #409eff;\n  transform: translateY(-1px);\n}\n\n/* 添加角色卡片样式 */\n.add-character-card {\n  cursor: pointer;\n  border: 2px dashed #dcdfe6;\n  background: linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%);\n  transition: all 0.3s;\n}\n\n.add-character-card:hover {\n  border-color: #409eff;\n  background: linear-gradient(135deg, #ecf5ff 0%, #e6f2ff 100%);\n  transform: translateY(-2px);\n  box-shadow: 0 4px 16px rgba(64, 158, 255, 0.15);\n}\n\n.add-character-content {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  height: 400px;\n  gap: 16px;\n}\n\n.add-character-content .add-text {\n  font-size: 16px;\n  font-weight: 600;\n  color: #606266;\n  transition: color 0.3s;\n}\n\n.add-character-card:hover .add-text {\n  color: #409eff;\n}\n\n.add-character-card:hover .el-icon {\n  color: #409eff !important;\n}\n\n/* 角色库样式 */\n.library-grid {\n  max-height: 500px;\n  overflow-y: auto;\n}\n\n.library-item {\n  cursor: pointer;\n  margin-bottom: 16px;\n  transition: all 0.3s;\n}\n\n.library-item:hover {\n  transform: translateY(-2px);\n  box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);\n  border-color: #409eff;\n}\n\n.library-image {\n  width: 100%;\n  height: 150px;\n  object-fit: cover;\n  border-radius: 4px;\n  margin-bottom: 8px;\n}\n\n.library-info {\n  text-align: center;\n}\n\n.library-name {\n  font-size: 14px;\n  font-weight: 600;\n  color: #303133;\n  margin-bottom: 4px;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.navigation-buttons {\n  display: flex;\n  justify-content: center;\n  gap: 20px;\n  margin: 40px 0 20px;\n}\n\n/* 概览区域样式 */\n.episode-info {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 20px;\n}\n\n.episode-info h3 {\n  margin: 0;\n  font-size: 18px;\n  font-weight: 600;\n  color: #303133;\n}\n\n.script-content-display {\n  width: 100%;\n}\n\n.script-display :deep(.el-textarea__inner) {\n  background: #fafafa;\n  border: 1px solid #e4e7ed;\n  font-family: \"Monaco\", \"Menlo\", \"Ubuntu Mono\", monospace;\n  line-height: 1.8;\n}\n\n.overview-section {\n  margin-top: 24px;\n}\n\n.overview-section h3 {\n  margin: 16px 0 12px 0;\n  font-size: 16px;\n  font-weight: 600;\n  color: #303133;\n}\n\n.action-buttons {\n  display: flex;\n  gap: 12px;\n  justify-content: center;\n  margin: 20px 0;\n}\n\n/* 分镜列表样式 */\n.shots-list {\n  width: 100%;\n}\n\n.shots-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 16px;\n}\n\n.shots-header h3 {\n  margin: 0;\n  font-size: 18px;\n  font-weight: 600;\n  color: #303133;\n}\n\n.empty-shots {\n  padding: 60px 0;\n}\n\n/* 创建章节提示 */\n.create-chapter-prompt {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  min-height: 500px;\n}\n\n.overview-content {\n  background: #fafafa;\n  padding: 16px;\n  border-radius: 8px;\n  border: 1px solid #e4e7ed;\n}\n\n.overview-item {\n  margin-bottom: 12px;\n  line-height: 1.8;\n}\n\n.overview-item:last-child {\n  margin-bottom: 0;\n}\n\n.overview-item .label {\n  font-weight: 600;\n  color: #606266;\n  margin-right: 8px;\n}\n\n.overview-item .value {\n  color: #303133;\n}\n\n.character-list {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 12px;\n  padding: 16px;\n  background: #fafafa;\n  border-radius: 8px;\n  border: 1px solid #e4e7ed;\n}\n\n.character-tag {\n  padding: 8px 16px;\n}\n\n.action-buttons {\n  display: flex;\n  gap: 16px;\n  align-items: center;\n  justify-content: center;\n}\n\n/* 剧本生成表单样式 */\n.generation-form {\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  padding: 12px 16px;\n  gap: 10px;\n}\n\n.script-input-header {\n  display: flex;\n  justify-content: flex-end;\n  align-items: center;\n  flex-shrink: 0;\n}\n\n.script-textarea {\n  font-family: \"Monaco\", \"Menlo\", \"Consolas\", monospace;\n  font-size: 14px;\n  line-height: 1.6;\n}\n\n.script-textarea-fullscreen {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n}\n\n:deep(.script-textarea-fullscreen .el-textarea) {\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n}\n\n:deep(.script-textarea-fullscreen .el-textarea__inner) {\n  flex: 1;\n  height: 100% !important;\n  min-height: 700px !important;\n  resize: none;\n}\n\n:deep(.script-textarea .el-textarea__inner) {\n  background: #ffffff;\n  color: #303133;\n  border: 1px solid #dcdfe6;\n  border-radius: 6px;\n  padding: 16px;\n  font-size: 15px;\n  line-height: 1.8;\n}\n\n:deep(.script-textarea .el-textarea__inner:focus) {\n  border-color: #409eff;\n  box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);\n}\n</style>\n"
  },
  {
    "path": "web/src/views/drama/EpisodeWorkflow.vue",
    "content": "<template>\n  <div class=\"page-container\">\n    <div class=\"content-wrapper animate-fade-in\">\n      <AppHeader :fixed=\"false\" :show-logo=\"false\">\n        <template #left>\n          <el-button text @click=\"$router.back()\" class=\"back-btn\">\n            <el-icon><ArrowLeft /></el-icon>\n            <span>{{ $t(\"workflow.backToProject\") }}</span>\n          </el-button>\n          <h1 class=\"header-title\">\n            {{ $t(\"workflow.episodeProduction\", { number: episodeNumber }) }}\n          </h1>\n        </template>\n        <template #center>\n          <div class=\"custom-steps\">\n            <div\n              class=\"step-item\"\n              :class=\"{ active: currentStep >= 0, current: currentStep === 0 }\"\n            >\n              <div class=\"step-circle\">1</div>\n              <span class=\"step-text\">{{ $t(\"workflow.steps.content\") }}</span>\n            </div>\n            <el-icon class=\"step-arrow\"><ArrowRight /></el-icon>\n            <div\n              class=\"step-item\"\n              :class=\"{ active: currentStep >= 1, current: currentStep === 1 }\"\n            >\n              <div class=\"step-circle\">2</div>\n              <span class=\"step-text\">{{\n                $t(\"workflow.steps.generateImages\")\n              }}</span>\n            </div>\n            <el-icon class=\"step-arrow\"><ArrowRight /></el-icon>\n            <div\n              class=\"step-item\"\n              :class=\"{ active: currentStep >= 2, current: currentStep === 2 }\"\n            >\n              <div class=\"step-circle\">3</div>\n              <span class=\"step-text\">{{\n                $t(\"workflow.steps.splitStoryboard\")\n              }}</span>\n            </div>\n          </div>\n        </template>\n        <template #right>\n          <el-button\n            :icon=\"Setting\"\n            @click=\"showModelConfigDialog\"\n            :title=\"$t('workflow.modelConfig')\"\n          >\n            图文配置\n          </el-button>\n        </template>\n      </AppHeader>\n\n      <div class=\"content-container\">\n        <!-- 阶段 0: 章节内容 + 提取角色场景 -->\n        <el-card\n          v-show=\"currentStep === 0\"\n          shadow=\"never\"\n          class=\"stage-card stage-card-fullscreen\"\n        >\n          <div class=\"stage-body stage-body-fullscreen\">\n            <!-- 未保存时显示输入框 -->\n            <div v-if=\"!hasScript\" class=\"generation-form\">\n              <el-input\n                v-model=\"scriptContent\"\n                type=\"textarea\"\n                :placeholder=\"$t('workflow.scriptPlaceholder')\"\n                class=\"script-textarea script-textarea-fullscreen\"\n              />\n\n              <div class=\"action-buttons-inline\">\n                <el-button\n                  type=\"primary\"\n                  size=\"default\"\n                  @click=\"saveChapterScript\"\n                  :disabled=\"!scriptContent.trim() || generatingScript\"\n                >\n                  <el-icon><Check /></el-icon>\n                  <span>{{ $t(\"workflow.saveChapter\") }}</span>\n                </el-button>\n              </div>\n            </div>\n\n            <!-- 已保存时显示内容 -->\n            <div v-if=\"hasScript\" class=\"overview-section\">\n              <div class=\"episode-info\">\n                <h3>\n                  {{ $t(\"workflow.chapterContent\", { number: episodeNumber }) }}\n                </h3>\n                <el-tag type=\"success\" size=\"large\">{{\n                  $t(\"workflow.saved\")\n                }}</el-tag>\n              </div>\n              <div class=\"overview-content\">\n                <el-input\n                  v-model=\"currentEpisode.script_content\"\n                  type=\"textarea\"\n                  :rows=\"15\"\n                  readonly\n                  class=\"script-display\"\n                />\n              </div>\n\n              <el-divider />\n\n              <!-- 显示已提取的角色和场景 -->\n              <div v-if=\"hasExtractedData\" class=\"extracted-info\">\n                <el-alert\n                  type=\"success\"\n                  :closable=\"false\"\n                  style=\"margin-bottom: 16px\"\n                >\n                  <template #title>\n                    <div style=\"display: flex; align-items: center; gap: 16px\">\n                      <span>✅ {{ $t(\"workflow.extractedData\") }}</span>\n                      <el-tag v-if=\"hasCharacters\" type=\"success\"\n                        >{{ $t(\"workflow.characters\") }}:\n                        {{ charactersCount }}</el-tag\n                      >\n                      <el-tag v-if=\"currentEpisode?.scenes\" type=\"success\"\n                        >{{ $t(\"workflow.scenes\") }}:\n                        {{ currentEpisode.scenes.length }}</el-tag\n                      >\n                    </div>\n                  </template>\n                </el-alert>\n\n                <!-- 角色列表 -->\n                <div v-if=\"hasCharacters\" style=\"margin-bottom: 16px\">\n                  <h4 class=\"extracted-title\">\n                    {{ $t(\"workflow.extractedCharacters\") }}：\n                  </h4>\n                  <div style=\"display: flex; flex-wrap: wrap; gap: 8px\">\n                    <el-tag\n                      v-for=\"char in currentEpisode?.characters\"\n                      :key=\"char.id\"\n                      type=\"info\"\n                    >\n                      {{ char.name }}\n                      <span v-if=\"char.role\" class=\"secondary-text\"\n                        >({{ char.role }})</span\n                      >\n                    </el-tag>\n                  </div>\n                </div>\n\n                <!-- 场景列表 -->\n                <div\n                  v-if=\"\n                    currentEpisode?.scenes && currentEpisode.scenes.length > 0\n                  \"\n                >\n                  <h4 class=\"extracted-title\">\n                    {{ $t(\"workflow.extractedScenes\") }}：\n                  </h4>\n                  <div style=\"display: flex; flex-wrap: wrap; gap: 8px\">\n                    <el-tag\n                      v-for=\"scene in currentEpisode.scenes\"\n                      :key=\"scene.id\"\n                      type=\"warning\"\n                    >\n                      {{ scene.location }}\n                      <span class=\"secondary-text\">· {{ scene.time }}</span>\n                    </el-tag>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </el-card>\n\n        <!-- 阶段 1: 生成图片 -->\n        <el-card v-show=\"currentStep === 1\" class=\"workflow-card\">\n          <div class=\"stage-body\">\n            <!-- 角色图片生成 -->\n            <div class=\"image-gen-section\">\n              <div class=\"section-header\">\n                <div class=\"section-title\">\n                  <h3>\n                    <el-icon><User /></el-icon>\n                    {{ $t(\"workflow.characterImages\") }}\n                  </h3>\n                  <el-alert type=\"info\" :closable=\"false\" style=\"margin: 0\">\n                    {{\n                      $t(\"workflow.characterCount\", { count: charactersCount })\n                    }}\n                  </el-alert>\n                </div>\n                <div class=\"section-actions\">\n                  <el-checkbox\n                    v-model=\"selectAllCharacters\"\n                    @change=\"toggleSelectAllCharacters\"\n                    style=\"margin-right: 12px\"\n                  >\n                    {{ $t(\"workflow.selectAll\") }}\n                  </el-checkbox>\n                  <el-button\n                    type=\"primary\"\n                    @click=\"batchGenerateCharacterImages\"\n                    :loading=\"batchGeneratingCharacters\"\n                    :disabled=\"selectedCharacterIds.length === 0\"\n                    size=\"default\"\n                  >\n                    {{ $t(\"workflow.batchGenerate\") }} ({{\n                      selectedCharacterIds.length\n                    }})\n                  </el-button>\n                </div>\n              </div>\n\n              <div class=\"character-image-list\">\n                <div\n                  v-for=\"char in currentEpisode?.characters\"\n                  :key=\"char.id\"\n                  class=\"character-item\"\n                >\n                  <el-card shadow=\"hover\" class=\"fixed-card\">\n                    <div class=\"card-header\">\n                      <el-checkbox\n                        v-model=\"selectedCharacterIds\"\n                        :value=\"char.id\"\n                        style=\"margin-right: 8px\"\n                      />\n                      <div class=\"header-left\">\n                        <h4>{{ char.name }}</h4>\n                        <el-tag size=\"small\">{{ char.role }}</el-tag>\n                      </div>\n                      <el-button\n                        type=\"danger\"\n                        size=\"small\"\n                        :icon=\"Delete\"\n                        circle\n                        @click=\"deleteCharacter(char.id)\"\n                        :title=\"$t('workflow.deleteCharacter')\"\n                      />\n                    </div>\n\n                    <div class=\"card-image-container\">\n                      <div v-if=\"hasImage(char)\" class=\"char-image\">\n                        <el-image :src=\"getImageUrl(char)\" fit=\"cover\" />\n                      </div>\n                      <div\n                        v-else-if=\"\n                          char.image_generation_status === 'pending' ||\n                          char.image_generation_status === 'processing' ||\n                          generatingCharacterImages[char.id]\n                        \"\n                        class=\"char-placeholder generating\"\n                      >\n                        <el-icon :size=\"64\" class=\"rotating\"\n                          ><Loading\n                        /></el-icon>\n                        <span>{{ $t(\"common.generating\") }}</span>\n                        <el-tag\n                          type=\"warning\"\n                          size=\"small\"\n                          style=\"margin-top: 8px\"\n                          >{{\n                            char.image_generation_status === \"pending\"\n                              ? $t(\"common.queuing\")\n                              : $t(\"common.processing\")\n                          }}</el-tag\n                        >\n                      </div>\n                      <div\n                        v-else-if=\"char.image_generation_status === 'failed'\"\n                        class=\"char-placeholder failed\"\n                      >\n                        <el-icon :size=\"64\"><WarningFilled /></el-icon>\n                        <span>{{ $t(\"common.generateFailed\") }}</span>\n                        <el-tag\n                          type=\"danger\"\n                          size=\"small\"\n                          style=\"margin-top: 8px\"\n                          >{{ $t(\"common.clickToRegenerate\") }}</el-tag\n                        >\n                      </div>\n                      <div v-else class=\"char-placeholder\">\n                        <el-icon :size=\"64\"><User /></el-icon>\n                        <span>{{ $t(\"common.notGenerated\") }}</span>\n                      </div>\n                    </div>\n\n                    <div class=\"card-actions\">\n                      <el-tooltip\n                        :content=\"$t('tooltip.editPrompt')\"\n                        placement=\"top\"\n                      >\n                        <el-button\n                          size=\"small\"\n                          @click=\"openPromptDialog(char, 'character')\"\n                          :icon=\"Edit\"\n                          circle\n                        />\n                      </el-tooltip>\n                      <el-tooltip\n                        :content=\"$t('tooltip.aiGenerate')\"\n                        placement=\"top\"\n                      >\n                        <el-button\n                          type=\"primary\"\n                          size=\"small\"\n                          @click=\"generateCharacterImage(char.id)\"\n                          :loading=\"generatingCharacterImages[char.id]\"\n                          :icon=\"MagicStick\"\n                          circle\n                        />\n                      </el-tooltip>\n                      <el-tooltip\n                        :content=\"$t('tooltip.uploadImage')\"\n                        placement=\"top\"\n                      >\n                        <el-button\n                          size=\"small\"\n                          @click=\"uploadCharacterImage(char.id)\"\n                          :icon=\"Upload\"\n                          circle\n                        />\n                      </el-tooltip>\n                      <el-tooltip\n                        :content=\"$t('tooltip.selectFromLibrary')\"\n                        placement=\"top\"\n                      >\n                        <el-button\n                          size=\"small\"\n                          @click=\"selectFromLibrary(char.id)\"\n                          :icon=\"Picture\"\n                          circle\n                        />\n                      </el-tooltip>\n                      <el-tooltip\n                        :content=\"$t('workflow.addToLibrary')\"\n                        placement=\"top\"\n                      >\n                        <el-button\n                          size=\"small\"\n                          @click=\"addToCharacterLibrary(char)\"\n                          :icon=\"FolderAdd\"\n                          :disabled=\"!char.image_url\"\n                          circle\n                        />\n                      </el-tooltip>\n                    </div>\n                  </el-card>\n                </div>\n              </div>\n            </div>\n\n            <el-divider />\n\n            <!-- 场景图片生成 -->\n            <div class=\"image-gen-section\">\n              <div class=\"section-header\">\n                <div class=\"section-title\">\n                  <h3>\n                    <el-icon><Place /></el-icon>\n                    {{ $t(\"workflow.sceneImages\") }}\n                  </h3>\n                  <el-alert type=\"info\" :closable=\"false\" style=\"margin: 0\">\n                    {{\n                      $t(\"workflow.sceneCount\", {\n                        count: currentEpisode?.scenes?.length || 0,\n                      })\n                    }}\n                  </el-alert>\n                </div>\n                <div class=\"section-actions\">\n                  <!-- <el-button\n                  :icon=\"Document\"\n                  @click=\"openExtractSceneDialog\"\n                  size=\"default\"\n                >\n                  {{ $t(\"workflow.extractFromScript\") }}\n                </el-button> -->\n                  <el-checkbox\n                    v-model=\"selectAllScenes\"\n                    @change=\"toggleSelectAllScenes\"\n                    style=\"margin-left: 12px; margin-right: 12px\"\n                  >\n                    {{ $t(\"workflow.selectAll\") }}\n                  </el-checkbox>\n                  <el-button\n                    type=\"primary\"\n                    @click=\"batchGenerateSceneImages\"\n                    :loading=\"batchGeneratingScenes\"\n                    :disabled=\"selectedSceneIds.length === 0\"\n                    size=\"default\"\n                  >\n                    {{ $t(\"workflow.batchGenerateSelected\") }} ({{\n                      selectedSceneIds.length\n                    }})\n                  </el-button>\n\n                  <el-button\n                    :icon=\"Plus\"\n                    @click=\"openAddSceneDialog\"\n                    size=\"default\"\n                  >\n                    {{ $t(\"workflow.addScene\") }}\n                  </el-button>\n                </div>\n              </div>\n\n              <div class=\"scene-image-list\">\n                <div\n                  v-for=\"scene in currentEpisode?.scenes\"\n                  :key=\"scene.id\"\n                  class=\"scene-item\"\n                >\n                  <el-card shadow=\"hover\" class=\"fixed-card\">\n                    <div class=\"card-header\">\n                      <el-checkbox\n                        v-model=\"selectedSceneIds\"\n                        :value=\"scene.id\"\n                        style=\"margin-right: 8px\"\n                      />\n                      <div class=\"header-left\">\n                        <h4>{{ scene.location }}</h4>\n                        <el-tag size=\"small\">{{ scene.time }}</el-tag>\n                      </div>\n                    </div>\n\n                    <div class=\"card-image-container\">\n                      <div v-if=\"hasImage(scene)\" class=\"scene-image\">\n                        <el-image :src=\"getImageUrl(scene)\" fit=\"cover\" />\n                      </div>\n                      <div\n                        v-else-if=\"\n                          scene.image_generation_status === 'pending' ||\n                          scene.image_generation_status === 'processing' ||\n                          generatingSceneImages[scene.id]\n                        \"\n                        class=\"scene-placeholder generating\"\n                      >\n                        <el-icon :size=\"64\" class=\"rotating\"\n                          ><Loading\n                        /></el-icon>\n                        <span>{{ $t(\"common.generating\") }}</span>\n                        <el-tag\n                          type=\"warning\"\n                          size=\"small\"\n                          style=\"margin-top: 8px\"\n                          >{{\n                            scene.image_generation_status === \"pending\"\n                              ? $t(\"common.queuing\")\n                              : $t(\"common.processing\")\n                          }}</el-tag\n                        >\n                      </div>\n                      <div\n                        v-else-if=\"scene.image_generation_status === 'failed'\"\n                        class=\"scene-placeholder failed\"\n                        @click=\"generateSceneImage(scene.id)\"\n                        style=\"cursor: pointer\"\n                      >\n                        <el-icon :size=\"64\"><WarningFilled /></el-icon>\n                        <span>{{ $t(\"common.generateFailed\") }}</span>\n                        <el-tag\n                          type=\"danger\"\n                          size=\"small\"\n                          style=\"margin-top: 8px\"\n                          >{{ $t(\"common.clickToRegenerate\") }}</el-tag\n                        >\n                      </div>\n                      <div v-else class=\"scene-placeholder\">\n                        <el-icon :size=\"64\"><Place /></el-icon>\n                        <span>{{ $t(\"common.notGenerated\") }}</span>\n                      </div>\n                    </div>\n\n                    <div class=\"card-actions\">\n                      <el-tooltip\n                        :content=\"$t('tooltip.editPrompt')\"\n                        placement=\"top\"\n                      >\n                        <el-button\n                          size=\"small\"\n                          @click=\"openPromptDialog(scene, 'scene')\"\n                          :icon=\"Edit\"\n                          circle\n                        />\n                      </el-tooltip>\n                      <el-tooltip\n                        :content=\"$t('tooltip.aiGenerate')\"\n                        placement=\"top\"\n                      >\n                        <el-button\n                          type=\"primary\"\n                          size=\"small\"\n                          @click=\"generateSceneImage(scene.id)\"\n                          :loading=\"generatingSceneImages[scene.id]\"\n                          :icon=\"MagicStick\"\n                          circle\n                        />\n                      </el-tooltip>\n                      <el-tooltip\n                        :content=\"$t('tooltip.uploadImage')\"\n                        placement=\"top\"\n                      >\n                        <el-button\n                          size=\"small\"\n                          @click=\"uploadSceneImage(scene.id)\"\n                          :icon=\"Upload\"\n                          circle\n                        />\n                      </el-tooltip>\n                    </div>\n                  </el-card>\n                </div>\n              </div>\n            </div>\n          </div>\n        </el-card>\n\n        <!-- 阶段 2: 拆分分镜 -->\n        <el-card v-show=\"currentStep === 2\" shadow=\"never\" class=\"stage-card\">\n          <div class=\"stage-body\">\n            <!-- 分镜列表 -->\n            <div\n              v-if=\"\n                currentEpisode?.storyboards &&\n                currentEpisode.storyboards.length > 0\n              \"\n              class=\"shots-list\"\n            >\n              <div class=\"shots-header\">\n                <h3>{{ $t(\"workflow.shotList\") }}</h3>\n              </div>\n\n              <el-table\n                :data=\"currentEpisode.storyboards\"\n                border\n                stripe\n                style=\"margin-top: 16px\"\n              >\n                <el-table-column\n                  type=\"index\"\n                  :label=\"$t('storyboard.table.number')\"\n                  width=\"60\"\n                />\n                <el-table-column\n                  :label=\"$t('storyboard.table.title')\"\n                  width=\"120\"\n                  show-overflow-tooltip\n                >\n                  <template #default=\"{ row }\">\n                    {{ row.title || \"-\" }}\n                  </template>\n                </el-table-column>\n                <el-table-column\n                  :label=\"$t('storyboard.table.shotType')\"\n                  width=\"80\"\n                >\n                  <template #default=\"{ row }\">\n                    {{ row.shot_type || \"-\" }}\n                  </template>\n                </el-table-column>\n                <el-table-column\n                  :label=\"$t('storyboard.table.movement')\"\n                  width=\"80\"\n                >\n                  <template #default=\"{ row }\">\n                    {{ row.movement || \"-\" }}\n                  </template>\n                </el-table-column>\n                <el-table-column\n                  :label=\"$t('storyboard.table.location')\"\n                  width=\"150\"\n                >\n                  <template #default=\"{ row }\">\n                    <el-popover\n                      placement=\"right\"\n                      :width=\"300\"\n                      trigger=\"hover\"\n                      :content=\"row.action || '-'\"\n                    >\n                      <template #reference>\n                        <!-- 单行打点 -->\n                        <span class=\"overflow-tooltip\">{{\n                          row.location || \"-\"\n                        }}</span>\n                      </template>\n                    </el-popover>\n                  </template>\n                </el-table-column>\n                <el-table-column\n                  :label=\"$t('storyboard.table.character')\"\n                  width=\"100\"\n                >\n                  <template #default=\"{ row }\">\n                    <span v-if=\"row.characters && row.characters.length > 0\">\n                      {{ row.characters.map((c) => c.name || c).join(\", \") }}\n                    </span>\n                    <span v-else>-</span>\n                  </template>\n                </el-table-column>\n                <el-table-column :label=\"$t('storyboard.table.action')\">\n                  <template #default=\"{ row }\">\n                    <el-popover\n                      placement=\"right\"\n                      :width=\"300\"\n                      trigger=\"hover\"\n                      :content=\"row.action || '-'\"\n                    >\n                      <template #reference>\n                        <!-- 单行打点 -->\n                        <span class=\"overflow-tooltip\">{{\n                          row.action || \"-\"\n                        }}</span>\n                      </template>\n                    </el-popover>\n                  </template>\n                </el-table-column>\n                <el-table-column\n                  :label=\"$t('storyboard.table.duration')\"\n                  width=\"80\"\n                >\n                  <template #default=\"{ row }\">\n                    {{ row.duration || \"-\" }}秒\n                  </template>\n                </el-table-column>\n                <el-table-column\n                  :label=\"$t('storyboard.table.operations')\"\n                  width=\"100\"\n                  fixed=\"right\"\n                >\n                  <template #default=\"{ row, $index }\">\n                    <el-button\n                      type=\"primary\"\n                      size=\"small\"\n                      @click=\"editShot(row, $index)\"\n                    >\n                      {{ $t(\"common.edit\") }}\n                    </el-button>\n                  </template>\n                </el-table-column>\n              </el-table>\n            </div>\n\n            <!-- 未拆分时显示 -->\n            <div v-else class=\"empty-shots\">\n              <el-empty :description=\"$t('workflow.splitStoryboardFirst')\">\n                <el-button\n                  type=\"primary\"\n                  @click=\"generateShots\"\n                  :loading=\"generatingShots\"\n                  :icon=\"MagicStick\"\n                >\n                  {{\n                    generatingShots\n                      ? $t(\"workflow.aiSplitting\")\n                      : $t(\"workflow.aiAutoSplit\")\n                  }}\n                </el-button>\n\n                <!-- 任务进度显示 -->\n                <div\n                  v-if=\"generatingShots\"\n                  style=\"\n                    margin-top: 24px;\n                    max-width: 400px;\n                    margin-left: auto;\n                    margin-right: auto;\n                  \"\n                >\n                  <el-progress\n                    :percentage=\"taskProgress\"\n                    :status=\"taskProgress === 100 ? 'success' : undefined\"\n                  >\n                    <template #default=\"{ percentage }\">\n                      <span style=\"font-size: 12px\">{{ percentage }}%</span>\n                    </template>\n                  </el-progress>\n                  <div class=\"task-message\">\n                    {{ taskMessage }}\n                  </div>\n                </div>\n              </el-empty>\n            </div>\n          </div>\n        </el-card>\n      </div>\n\n      <div class=\"actions-container\">\n        <div class=\"action-buttons\" v-show=\"currentStep === 0\">\n          <el-button\n            type=\"primary\"\n            size=\"large\"\n            @click=\"handleExtractCharactersAndBackgrounds\"\n            :loading=\"extractingCharactersAndBackgrounds\"\n            :disabled=\"!hasScript\"\n          >\n            <el-icon><MagicStick /></el-icon>\n            {{\n              hasExtractedData\n                ? $t(\"workflow.reExtract\")\n                : $t(\"workflow.extractCharactersAndScenes\")\n            }}\n          </el-button>\n          <el-button\n            type=\"success\"\n            size=\"large\"\n            @click=\"nextStep\"\n            :disabled=\"!hasExtractedData\"\n          >\n            {{ $t(\"workflow.nextStepGenerateImages\") }}\n            <el-icon><ArrowRight /></el-icon>\n          </el-button>\n          <div v-if=\"!hasExtractedData\" style=\"margin-top: 8px\">\n            <el-alert\n              type=\"warning\"\n              :closable=\"false\"\n              style=\"display: inline-block\"\n            >\n              <template #title>\n                <span style=\"font-size: 12px\">\n                  {{ $t(\"workflow.extractWarning\") }}\n                </span>\n              </template>\n            </el-alert>\n          </div>\n        </div>\n\n        <div class=\"action-buttons\" v-show=\"currentStep === 1\">\n          <el-button size=\"large\" @click=\"prevStep\">\n            <el-icon><ArrowLeft /></el-icon>\n            {{ $t(\"workflow.prevStep\") }}\n          </el-button>\n          <el-button\n            type=\"success\"\n            size=\"large\"\n            @click=\"nextStep\"\n            :disabled=\"!allImagesGenerated\"\n          >\n            {{ $t(\"workflow.nextStepSplitShots\") }}\n            <el-icon><ArrowRight /></el-icon>\n          </el-button>\n          <div v-if=\"!allImagesGenerated\" style=\"margin-top: 8px\">\n            <el-alert\n              type=\"warning\"\n              :closable=\"false\"\n              style=\"display: inline-block\"\n            >\n              <template #title>\n                <span style=\"font-size: 12px\">\n                  {{ $t(\"workflow.generateAllImagesFirst\") }}\n                </span>\n              </template>\n            </el-alert>\n          </div>\n        </div>\n\n        <div class=\"action-buttons\" v-show=\"currentStep === 2\">\n          <el-button size=\"large\" @click=\"prevStep\">\n            <el-icon><ArrowLeft /></el-icon>\n            {{ $t(\"workflow.prevStep\") }}\n          </el-button>\n          <el-button size=\"large\" @click=\"regenerateShots\" :icon=\"MagicStick\">\n            {{ $t(\"workflow.reSplitShots\") }}\n          </el-button>\n          <el-button type=\"success\" size=\"large\" @click=\"goToProfessionalUI\">\n            {{ $t(\"workflow.enterProfessional\") }}\n            <el-icon><ArrowRight /></el-icon>\n          </el-button>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"components-box\">\n      <!-- 镜头编辑对话框 -->\n      <el-dialog\n        v-model=\"shotEditDialogVisible\"\n        :title=\"$t('workflow.editShot')\"\n        width=\"800px\"\n        :close-on-click-modal=\"false\"\n      >\n        <el-form v-if=\"editingShot\" label-width=\"100px\" size=\"default\">\n          <el-form-item :label=\"$t('workflow.shotTitle')\">\n            <el-input\n              v-model=\"editingShot.title\"\n              :placeholder=\"$t('workflow.shotTitlePlaceholder')\"\n            />\n          </el-form-item>\n\n          <el-row :gutter=\"16\">\n            <el-col :span=\"8\">\n              <el-form-item :label=\"$t('workflow.shotType')\">\n                <el-select\n                  v-model=\"editingShot.shot_type\"\n                  :placeholder=\"$t('workflow.selectShotType')\"\n                >\n                  <el-option :label=\"$t('workflow.longShot')\" value=\"远景\" />\n                  <el-option :label=\"$t('workflow.fullShot')\" value=\"全景\" />\n                  <el-option :label=\"$t('workflow.mediumShot')\" value=\"中景\" />\n                  <el-option :label=\"$t('workflow.closeUp')\" value=\"近景\" />\n                  <el-option\n                    :label=\"$t('workflow.extremeCloseUp')\"\n                    value=\"特写\"\n                  />\n                </el-select>\n              </el-form-item>\n            </el-col>\n            <el-col :span=\"8\">\n              <el-form-item :label=\"$t('workflow.cameraAngle')\">\n                <el-select\n                  v-model=\"editingShot.angle\"\n                  :placeholder=\"$t('workflow.selectAngle')\"\n                >\n                  <el-option :label=\"$t('workflow.eyeLevel')\" value=\"平视\" />\n                  <el-option :label=\"$t('workflow.lowAngle')\" value=\"仰视\" />\n                  <el-option :label=\"$t('workflow.highAngle')\" value=\"俯视\" />\n                  <el-option :label=\"$t('workflow.sideView')\" value=\"侧面\" />\n                </el-select>\n              </el-form-item>\n            </el-col>\n            <el-col :span=\"8\">\n              <el-form-item :label=\"$t('workflow.cameraMovement')\">\n                <el-select\n                  v-model=\"editingShot.movement\"\n                  :placeholder=\"$t('workflow.selectMovement')\"\n                >\n                  <el-option\n                    :label=\"$t('workflow.staticShot')\"\n                    value=\"固定镜头\"\n                  />\n                  <el-option :label=\"$t('workflow.pushIn')\" value=\"推镜\" />\n                  <el-option :label=\"$t('workflow.pullOut')\" value=\"拉镜\" />\n                  <el-option :label=\"$t('workflow.followShot')\" value=\"跟镜\" />\n                </el-select>\n              </el-form-item>\n            </el-col>\n          </el-row>\n\n          <el-row :gutter=\"16\">\n            <el-col :span=\"12\">\n              <el-form-item :label=\"$t('workflow.location')\">\n                <el-input\n                  v-model=\"editingShot.location\"\n                  :placeholder=\"$t('workflow.locationPlaceholder')\"\n                />\n              </el-form-item>\n            </el-col>\n            <el-col :span=\"12\">\n              <el-form-item :label=\"$t('workflow.time')\">\n                <el-input\n                  v-model=\"editingShot.time\"\n                  :placeholder=\"$t('workflow.timeSetting')\"\n                />\n              </el-form-item>\n            </el-col>\n          </el-row>\n\n          <el-form-item :label=\"$t('workflow.shotDescription')\">\n            <el-input\n              v-model=\"editingShot.description\"\n              type=\"textarea\"\n              :rows=\"2\"\n              :placeholder=\"$t('workflow.shotDescriptionPlaceholder')\"\n            />\n          </el-form-item>\n\n          <el-form-item :label=\"$t('workflow.actionDescription')\">\n            <el-input\n              v-model=\"editingShot.action\"\n              type=\"textarea\"\n              :rows=\"3\"\n              :placeholder=\"$t('workflow.detailedAction')\"\n            />\n          </el-form-item>\n\n          <el-form-item :label=\"$t('workflow.dialogue')\">\n            <el-input\n              v-model=\"editingShot.dialogue\"\n              type=\"textarea\"\n              :rows=\"2\"\n              :placeholder=\"$t('workflow.characterDialogue')\"\n            />\n          </el-form-item>\n\n          <el-form-item :label=\"$t('workflow.result')\">\n            <el-input\n              v-model=\"editingShot.result\"\n              type=\"textarea\"\n              :rows=\"2\"\n              :placeholder=\"$t('workflow.actionResult')\"\n            />\n          </el-form-item>\n\n          <el-form-item :label=\"$t('workflow.atmosphere')\">\n            <el-input\n              v-model=\"editingShot.atmosphere\"\n              type=\"textarea\"\n              :rows=\"2\"\n              :placeholder=\"$t('workflow.atmosphereDescription')\"\n            />\n          </el-form-item>\n\n          <el-form-item :label=\"$t('workflow.imagePrompt')\">\n            <el-input\n              v-model=\"editingShot.image_prompt\"\n              type=\"textarea\"\n              :rows=\"3\"\n              :placeholder=\"$t('workflow.imagePromptPlaceholder')\"\n            />\n          </el-form-item>\n\n          <el-form-item :label=\"$t('workflow.videoPrompt')\">\n            <el-input\n              v-model=\"editingShot.video_prompt\"\n              type=\"textarea\"\n              :rows=\"3\"\n              :placeholder=\"$t('workflow.videoPromptPlaceholder')\"\n            />\n          </el-form-item>\n\n          <el-row :gutter=\"16\">\n            <el-col :span=\"12\">\n              <el-form-item :label=\"$t('workflow.bgmHint')\">\n                <el-input\n                  v-model=\"editingShot.bgm_prompt\"\n                  :placeholder=\"$t('workflow.bgmAtmosphere')\"\n                />\n              </el-form-item>\n            </el-col>\n            <el-col :span=\"12\">\n              <el-form-item :label=\"$t('workflow.soundEffect')\">\n                <el-input\n                  v-model=\"editingShot.sound_effect\"\n                  :placeholder=\"$t('workflow.soundEffectDescription')\"\n                />\n              </el-form-item>\n            </el-col>\n          </el-row>\n\n          <el-form-item :label=\"$t('workflow.durationSeconds')\">\n            <el-input-number\n              v-model=\"editingShot.duration\"\n              :min=\"1\"\n              :max=\"60\"\n            />\n          </el-form-item>\n        </el-form>\n\n        <template #footer>\n          <el-button @click=\"shotEditDialogVisible = false\">{{\n            $t(\"common.cancel\")\n          }}</el-button>\n          <el-button\n            type=\"primary\"\n            @click=\"saveShotEdit\"\n            :loading=\"savingShot\"\n            >{{ $t(\"common.save\") }}</el-button\n          >\n        </template>\n      </el-dialog>\n\n      <!-- 提示词编辑对话框 -->\n      <el-dialog\n        v-model=\"promptDialogVisible\"\n        :title=\"$t('workflow.editPrompt')\"\n        width=\"600px\"\n      >\n        <el-form label-width=\"80px\">\n          <el-form-item :label=\"$t('common.name')\">\n            <el-input v-model=\"currentEditItem.name\" disabled />\n          </el-form-item>\n          <el-form-item\n            v-if=\"currentEditType === 'scene'\"\n            :label=\"$t('workflow.time')\"\n          >\n            <el-input\n              v-model=\"currentEditItem.time\"\n              :placeholder=\"$t('workflow.timePlaceholder')\"\n            />\n          </el-form-item>\n          <el-form-item :label=\"$t('workflow.imagePrompt')\">\n            <el-input\n              v-model=\"editPrompt\"\n              type=\"textarea\"\n              :rows=\"6\"\n              :placeholder=\"$t('workflow.imagePromptPlaceholder')\"\n            />\n          </el-form-item>\n        </el-form>\n        <template #footer>\n          <el-button @click=\"promptDialogVisible = false\">{{\n            $t(\"common.cancel\")\n          }}</el-button>\n          <el-button type=\"primary\" @click=\"savePrompt\">{{\n            $t(\"common.save\")\n          }}</el-button>\n        </template>\n      </el-dialog>\n\n      <!-- 角色库选择对话框 -->\n      <el-dialog\n        v-model=\"libraryDialogVisible\"\n        :title=\"$t('workflow.selectFromLibrary')\"\n        width=\"800px\"\n      >\n        <div class=\"library-grid\">\n          <div\n            v-for=\"item in libraryItems\"\n            :key=\"item.id\"\n            class=\"library-item\"\n            @click=\"selectLibraryItem(item)\"\n          >\n            <el-image :src=\"getImageUrl(item)\" fit=\"cover\" />\n            <div class=\"library-item-name\">{{ item.name }}</div>\n          </div>\n        </div>\n        <div v-if=\"libraryItems.length === 0\" class=\"empty-library\">\n          <el-empty :description=\"$t('workflow.emptyLibrary')\" />\n        </div>\n      </el-dialog>\n\n      <!-- AI模型配置对话框 -->\n      <el-dialog\n        v-model=\"modelConfigDialogVisible\"\n        :title=\"$t('workflow.aiModelConfig')\"\n        width=\"600px\"\n        :close-on-click-modal=\"false\"\n      >\n        <el-form label-width=\"120px\">\n          <el-form-item :label=\"$t('workflow.textGenModel')\">\n            <el-select\n              v-model=\"selectedTextModel\"\n              :placeholder=\"$t('workflow.selectTextModel')\"\n              style=\"width: 100%\"\n            >\n              <el-option\n                v-for=\"model in textModels\"\n                :key=\"model.modelName\"\n                :label=\"model.modelName\"\n                :value=\"model.modelName\"\n              />\n            </el-select>\n            <div class=\"model-tip\">\n              {{ $t(\"workflow.textModelTip\") }}\n            </div>\n          </el-form-item>\n\n          <el-form-item :label=\"$t('workflow.imageGenModel')\">\n            <el-select\n              v-model=\"selectedImageModel\"\n              :placeholder=\"$t('workflow.selectImageModel')\"\n              style=\"width: 100%\"\n            >\n              <el-option\n                v-for=\"model in imageModels\"\n                :key=\"model.modelName\"\n                :label=\"model.modelName\"\n                :value=\"model.modelName\"\n              />\n            </el-select>\n            <div class=\"model-tip\">\n              {{ $t(\"workflow.modelConfigTip\") }}\n            </div>\n          </el-form-item>\n        </el-form>\n\n        <template #footer>\n          <el-button @click=\"modelConfigDialogVisible = false\">{{\n            $t(\"common.cancel\")\n          }}</el-button>\n          <el-button type=\"primary\" @click=\"saveModelConfig\">{{\n            $t(\"common.saveConfig\")\n          }}</el-button>\n        </template>\n      </el-dialog>\n\n      <!-- 图片上传对话框 -->\n      <el-dialog\n        v-model=\"uploadDialogVisible\"\n        :title=\"$t('tooltip.uploadImage')\"\n        width=\"500px\"\n      >\n        <el-upload\n          class=\"upload-area\"\n          drag\n          :action=\"uploadAction\"\n          :headers=\"uploadHeaders\"\n          :on-success=\"handleUploadSuccess\"\n          :on-error=\"handleUploadError\"\n          :show-file-list=\"false\"\n          accept=\"image/jpeg,image/png,image/jpg\"\n        >\n          <el-icon class=\"el-icon--upload\"><Upload /></el-icon>\n          <div class=\"el-upload__text\">\n            {{ $t(\"workflow.dragFilesHere\")\n            }}<em>{{ $t(\"workflow.clickToUpload\") }}</em>\n          </div>\n          <template #tip>\n            <div class=\"el-upload__tip\">\n              {{ $t(\"workflow.uploadFormatTip\") }}\n            </div>\n          </template>\n        </el-upload>\n      </el-dialog>\n\n      <!-- 添加场景对话框 -->\n      <el-dialog\n        v-model=\"addSceneDialogVisible\"\n        :title=\"$t('workflow.addScene')\"\n        width=\"600px\"\n      >\n        <el-form :model=\"newScene\" label-width=\"100px\">\n          <el-form-item :label=\"$t('workflow.sceneImage')\">\n            <el-upload\n              class=\"avatar-uploader\"\n              :action=\"`/api/v1/upload/image`\"\n              :show-file-list=\"false\"\n              :on-success=\"handleSceneImageSuccess\"\n              :before-upload=\"beforeAvatarUpload\"\n            >\n              <img\n                v-if=\"hasImage(newScene)\"\n                :src=\"getImageUrl(newScene)\"\n                class=\"avatar\"\n                style=\"width: 160px; height: 90px; object-fit: cover\"\n              />\n              <el-icon\n                v-else\n                class=\"avatar-uploader-icon\"\n                style=\"\n                  border: 1px dashed #d9d9d9;\n                  border-radius: 6px;\n                  cursor: pointer;\n                  position: relative;\n                  overflow: hidden;\n                  width: 160px;\n                  height: 90px;\n                  font-size: 28px;\n                  color: #8c939d;\n                  text-align: center;\n                  line-height: 90px;\n                \"\n                ><Plus\n              /></el-icon>\n            </el-upload>\n          </el-form-item>\n          <el-form-item :label=\"$t('workflow.sceneName')\">\n            <el-input\n              v-model=\"newScene.location\"\n              :placeholder=\"$t('workflow.sceneNamePlaceholder')\"\n            />\n          </el-form-item>\n          <el-form-item :label=\"$t('workflow.time')\">\n            <el-input\n              v-model=\"newScene.time\"\n              :placeholder=\"$t('workflow.timePlaceholder')\"\n            />\n          </el-form-item>\n          <el-form-item :label=\"$t('workflow.sceneDescription')\">\n            <el-input\n              v-model=\"newScene.prompt\"\n              type=\"textarea\"\n              :rows=\"4\"\n              :placeholder=\"$t('workflow.sceneDescriptionPlaceholder')\"\n            />\n          </el-form-item>\n        </el-form>\n        <template #footer>\n          <el-button @click=\"addSceneDialogVisible = false\">{{\n            $t(\"common.cancel\")\n          }}</el-button>\n          <el-button type=\"primary\" @click=\"saveScene\">{{\n            $t(\"common.confirm\")\n          }}</el-button>\n        </template>\n      </el-dialog>\n\n      <!-- 从剧本提取场景对话框 -->\n      <el-dialog\n        v-model=\"extractScenesDialogVisible\"\n        :title=\"$t('workflow.extractSceneDialogTitle')\"\n        width=\"500px\"\n      >\n        <el-alert type=\"info\" :closable=\"false\" style=\"margin-bottom: 16px\">\n          {{ $t(\"workflow.extractSceneDialogTip\") }}\n        </el-alert>\n        <template #footer>\n          <el-button @click=\"extractScenesDialogVisible = false\">\n            {{ $t(\"common.cancel\") }}\n          </el-button>\n          <el-button\n            type=\"primary\"\n            @click=\"handleExtractScenes\"\n            :loading=\"extractingScenes\"\n          >\n            {{ $t(\"workflow.startExtract\") }}\n          </el-button>\n        </template>\n      </el-dialog>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted, watch } from \"vue\";\nimport { useRoute, useRouter } from \"vue-router\";\nimport { useI18n } from \"vue-i18n\";\nimport { ElMessage, ElMessageBox } from \"element-plus\";\nimport {\n  User,\n  Location,\n  Picture,\n  MagicStick,\n  ArrowRight,\n  ArrowLeft,\n  Place,\n  Film,\n  Edit,\n  More,\n  Upload,\n  Delete,\n  FolderAdd,\n  Setting,\n  Loading,\n  WarningFilled,\n  Document,\n  Plus,\n} from \"@element-plus/icons-vue\";\nimport { dramaAPI } from \"@/api/drama\";\nimport { generationAPI } from \"@/api/generation\";\nimport { characterLibraryAPI } from \"@/api/character-library\";\nimport { aiAPI } from \"@/api/ai\";\nimport type { AIServiceConfig } from \"@/types/ai\";\nimport { imageAPI } from \"@/api/image\";\nimport type { Drama } from \"@/types/drama\";\nimport { AppHeader } from \"@/components/common\";\nimport { getImageUrl, hasImage } from \"@/utils/image\";\n\nconst route = useRoute();\nconst router = useRouter();\nconst { t: $t } = useI18n();\nconst dramaId = route.params.id as string;\nconst episodeNumber = parseInt(route.params.episodeNumber as string);\n\nconst drama = ref<Drama>();\n\n// 生成 localStorage key\nconst getStepStorageKey = () =>\n  `episode_workflow_step_${dramaId}_${episodeNumber}`;\n\n// 从 localStorage 恢复步骤，如果没有则默认为 0\nconst savedStep = localStorage.getItem(getStepStorageKey());\nconst currentStep = ref(savedStep ? parseInt(savedStep) : 0);\nconst scriptContent = ref(\"\");\nconst generatingScript = ref(false);\nconst generatingShots = ref(false);\nconst extractingCharactersAndBackgrounds = ref(false);\nconst batchGeneratingCharacters = ref(false);\nconst batchGeneratingScenes = ref(false);\nconst generatingCharacterImages = ref<Record<number, boolean>>({});\nconst generatingSceneImages = ref<Record<string, boolean>>({});\n\n// 选择状态\nconst selectedCharacterIds = ref<number[]>([]);\nconst selectedSceneIds = ref<number[]>([]);\nconst selectAllCharacters = ref(false);\nconst selectAllScenes = ref(false);\n\n// 对话框状态\nconst promptDialogVisible = ref(false);\nconst libraryDialogVisible = ref(false);\nconst uploadDialogVisible = ref(false);\nconst modelConfigDialogVisible = ref(false);\nconst addSceneDialogVisible = ref(false);\nconst extractScenesDialogVisible = ref(false);\nconst currentEditItem = ref<any>({ name: \"\" });\nconst currentEditType = ref<\"character\" | \"scene\">(\"character\");\nconst editPrompt = ref(\"\");\nconst libraryItems = ref<any[]>([]);\nconst currentUploadTarget = ref<any>(null);\n\n// 添加场景相关\nconst newScene = ref<any>({\n  location: \"\",\n  time: \"\",\n  prompt: \"\",\n  image_url: \"\",\n  local_path: \"\",\n});\nconst extractingScenes = ref(false);\nconst uploadAction = computed(() => \"/api/v1/upload/image\");\nconst uploadHeaders = computed(() => ({\n  Authorization: `Bearer ${localStorage.getItem(\"token\")}`,\n}));\n\n// AI模型配置\ninterface ModelOption {\n  modelName: string;\n  configName: string;\n  configId: number;\n  priority: number;\n}\n\nconst textModels = ref<ModelOption[]>([]);\nconst imageModels = ref<ModelOption[]>([]);\nconst selectedTextModel = ref<string>(\"\");\nconst selectedImageModel = ref<string>(\"\");\n\nconst hasScript = computed(() => {\n  const currentEp = currentEpisode.value;\n  return (\n    currentEp && currentEp.script_content && currentEp.script_content.length > 0\n  );\n});\n\nconst currentEpisode = computed(() => {\n  if (!drama.value?.episodes) return null;\n  return drama.value.episodes.find((ep) => ep.episode_number === episodeNumber);\n});\n\nconst hasCharacters = computed(() => {\n  return (\n    currentEpisode.value?.characters &&\n    currentEpisode.value.characters.length > 0\n  );\n});\n\nconst charactersCount = computed(() => {\n  return currentEpisode.value?.characters?.length || 0;\n});\n\nconst hasExtractedData = computed(() => {\n  const hasScenes =\n    currentEpisode.value?.scenes && currentEpisode.value.scenes.length > 0;\n  // 只要有角色或场景，就认为已经提取过数据\n  return hasCharacters.value || hasScenes;\n});\n\nconst allImagesGenerated = computed(() => {\n  // 如果没有提取任何数据，允许跳过（可能是空章节或用户想直接进入拆解分镜）\n  if (!hasExtractedData.value) return true;\n\n  const characters = currentEpisode.value?.characters || [];\n  const scenes = currentEpisode.value?.scenes || [];\n\n  // 如果角色和场景都为空，允许跳过\n  if (characters.length === 0 && scenes.length === 0) return true;\n\n  // 检查所有有数据的项是否都已生成图片\n  const allCharsHaveImages =\n    characters.length === 0 || characters.every((char) => char.image_url);\n  const allScenesHaveImages =\n    scenes.length === 0 || scenes.every((scene) => scene.image_url);\n\n  return allCharsHaveImages && allScenesHaveImages;\n});\n\nconst goBack = () => {\n  // 使用 replace 避免在历史记录中留下当前页面\n  router.replace(`/dramas/${dramaId}`);\n};\n\n// 加载AI模型配置\nconst loadAIConfigs = async () => {\n  try {\n    const [textList, imageList] = await Promise.all([\n      aiAPI.list(\"text\"),\n      aiAPI.list(\"image\"),\n    ]);\n\n    // 只使用激活的配置\n    const activeTextList = textList.filter((c) => c.is_active);\n    const activeImageList = imageList.filter((c) => c.is_active);\n\n    // 展开模型列表并去重（保留优先级最高的）\n    const allTextModels = activeTextList\n      .flatMap((config) => {\n        const models = Array.isArray(config.model)\n          ? config.model\n          : [config.model];\n        return models.map((modelName) => ({\n          modelName,\n          configName: config.name,\n          configId: config.id,\n          priority: config.priority || 0,\n        }));\n      })\n      .sort((a, b) => b.priority - a.priority);\n\n    // 按模型名称去重，保留优先级最高的（已排序，第一个就是优先级最高的）\n    const textModelMap = new Map<string, ModelOption>();\n    allTextModels.forEach((model) => {\n      if (!textModelMap.has(model.modelName)) {\n        textModelMap.set(model.modelName, model);\n      }\n    });\n    textModels.value = Array.from(textModelMap.values());\n\n    const allImageModels = activeImageList\n      .flatMap((config) => {\n        const models = Array.isArray(config.model)\n          ? config.model\n          : [config.model];\n        return models.map((modelName) => ({\n          modelName,\n          configName: config.name,\n          configId: config.id,\n          priority: config.priority || 0,\n        }));\n      })\n      .sort((a, b) => b.priority - a.priority);\n\n    // 按模型名称去重，保留优先级最高的\n    const imageModelMap = new Map<string, ModelOption>();\n    allImageModels.forEach((model) => {\n      if (!imageModelMap.has(model.modelName)) {\n        imageModelMap.set(model.modelName, model);\n      }\n    });\n    imageModels.value = Array.from(imageModelMap.values());\n\n    // 设置默认选择（优先级最高的）\n    if (textModels.value.length > 0 && !selectedTextModel.value) {\n      selectedTextModel.value = textModels.value[0].modelName;\n    }\n    if (imageModels.value.length > 0 && !selectedImageModel.value) {\n      // 优先选择包含 nano 的模型\n      const nanoModel = imageModels.value.find((m) =>\n        m.modelName.toLowerCase().includes(\"nano\"),\n      );\n      selectedImageModel.value = nanoModel\n        ? nanoModel.modelName\n        : imageModels.value[0].modelName;\n    }\n\n    // 验证已选择的模型是否还在可用列表中，如果不在则重置为默认值\n    const availableTextModelNames = textModels.value.map((m) => m.modelName);\n    const availableImageModelNames = imageModels.value.map((m) => m.modelName);\n\n    if (\n      selectedTextModel.value &&\n      !availableTextModelNames.includes(selectedTextModel.value)\n    ) {\n      console.warn(\n        `已选择的文本模型 ${selectedTextModel.value} 不在可用列表中，重置为默认值`,\n      );\n      selectedTextModel.value =\n        textModels.value.length > 0 ? textModels.value[0].modelName : \"\";\n      // 更新 localStorage\n      if (selectedTextModel.value) {\n        localStorage.setItem(\n          `ai_text_model_${dramaId}`,\n          selectedTextModel.value,\n        );\n      }\n    }\n\n    if (\n      selectedImageModel.value &&\n      !availableImageModelNames.includes(selectedImageModel.value)\n    ) {\n      console.warn(\n        `已选择的图片模型 ${selectedImageModel.value} 不在可用列表中，重置为默认值`,\n      );\n      // 优先选择包含 nano 的模型\n      const nanoModel = imageModels.value.find((m) =>\n        m.modelName.toLowerCase().includes(\"nano\"),\n      );\n      selectedImageModel.value =\n        imageModels.value.length > 0\n          ? nanoModel\n            ? nanoModel.modelName\n            : imageModels.value[0].modelName\n          : \"\";\n      // 更新 localStorage\n      if (selectedImageModel.value) {\n        localStorage.setItem(\n          `ai_image_model_${dramaId}`,\n          selectedImageModel.value,\n        );\n      }\n    }\n  } catch (error: any) {\n    console.error(\"加载AI配置失败:\", error);\n  }\n};\n\n// 显示模型配置对话框\nconst showModelConfigDialog = () => {\n  modelConfigDialogVisible.value = true;\n  loadAIConfigs();\n};\n\n// 保存模型配置\nconst saveModelConfig = () => {\n  if (!selectedTextModel.value || !selectedImageModel.value) {\n    ElMessage.warning($t(\"workflow.pleaseSelectModels\"));\n    return;\n  }\n\n  // 保存模型名称到localStorage\n  localStorage.setItem(`ai_text_model_${dramaId}`, selectedTextModel.value);\n  localStorage.setItem(`ai_image_model_${dramaId}`, selectedImageModel.value);\n\n  ElMessage.success($t(\"workflow.modelConfigSaved\"));\n  modelConfigDialogVisible.value = false;\n};\n\nconst nextStep = () => {\n  if (currentStep.value < 3) {\n    currentStep.value++;\n  }\n};\n\nconst prevStep = () => {\n  if (currentStep.value > 0) {\n    currentStep.value--;\n  }\n};\n\n// 从localStorage加载已保存的模型配置\nconst loadSavedModelConfig = () => {\n  const savedTextModel = localStorage.getItem(`ai_text_model_${dramaId}`);\n  const savedImageModel = localStorage.getItem(`ai_image_model_${dramaId}`);\n\n  if (savedTextModel) {\n    selectedTextModel.value = savedTextModel;\n  }\n  if (savedImageModel) {\n    selectedImageModel.value = savedImageModel;\n  }\n};\n\nconst loadDramaData = async () => {\n  try {\n    const data = await dramaAPI.get(dramaId);\n    drama.value = data;\n\n    if (!hasScript.value) {\n      scriptContent.value = \"\";\n      // 如果没有剧本内容，重置到第一步\n      currentStep.value = 0;\n    }\n\n    // 检查是否有生成中的角色或场景，自动启动轮询\n    await checkAndStartPolling();\n  } catch (error: any) {\n    ElMessage.error(error.message || \"加载项目数据失败\");\n  }\n};\n\n// 检查并启动轮询\nconst checkAndStartPolling = async () => {\n  if (!currentEpisode.value) return;\n\n  // 检查角色的生成状态\n  for (const char of currentEpisode.value.characters || []) {\n    if (\n      char.image_generation_status === \"pending\" ||\n      char.image_generation_status === \"processing\"\n    ) {\n      // 查找对应的image_generation记录\n      try {\n        const imageGenList = await imageAPI.listImages({\n          drama_id: dramaId,\n          status: char.image_generation_status as any,\n        });\n\n        // 找到这个角色的image_generation记录\n        const charImageGen = imageGenList.items.find(\n          (img) =>\n            img.character_id === char.id &&\n            (img.status === \"pending\" || img.status === \"processing\"),\n        );\n\n        if (charImageGen) {\n          // 启动轮询\n          generatingCharacterImages.value[char.id] = true;\n          pollImageStatus(charImageGen.id, async () => {\n            await loadDramaData();\n            ElMessage.success(`${char.name}的图片生成完成！`);\n          }).finally(() => {\n            generatingCharacterImages.value[char.id] = false;\n          });\n        }\n      } catch (error) {\n        console.error(\"[轮询] 查询角色图片生成记录失败:\", error);\n      }\n    }\n  }\n\n  // 检查场景的生成状态\n  for (const scene of currentEpisode.value.scenes || []) {\n    if (\n      scene.image_generation_status === \"pending\" ||\n      scene.image_generation_status === \"processing\"\n    ) {\n      // 查找对应的image_generation记录\n      try {\n        const imageGenList = await imageAPI.listImages({\n          drama_id: dramaId,\n          status: scene.image_generation_status as any,\n        });\n\n        // 找到这个场景的image_generation记录\n        const sceneImageGen = imageGenList.items.find(\n          (img) =>\n            img.scene_id === scene.id &&\n            (img.status === \"pending\" || img.status === \"processing\"),\n        );\n\n        if (sceneImageGen) {\n          // 启动轮询\n          generatingSceneImages.value[scene.id] = true;\n          pollImageStatus(sceneImageGen.id, async () => {\n            await loadDramaData();\n            ElMessage.success(`${scene.location}的图片生成完成！`);\n          }).finally(() => {\n            generatingSceneImages.value[scene.id] = false;\n          });\n        }\n      } catch (error) {\n        console.error(\"[轮询] 查询场景图片生成记录失败:\", error);\n      }\n    }\n  }\n};\n\nconst saveChapterScript = async () => {\n  try {\n    const existingEpisodes = drama.value?.episodes || [];\n\n    // 查找当前章节\n    const episodeIndex = existingEpisodes.findIndex(\n      (ep) => ep.episode_number === episodeNumber,\n    );\n\n    let updatedEpisodes;\n    if (episodeIndex >= 0) {\n      // 更新已有章节\n      updatedEpisodes = [...existingEpisodes];\n      updatedEpisodes[episodeIndex] = {\n        ...updatedEpisodes[episodeIndex],\n        script_content: scriptContent.value,\n      };\n    } else {\n      // 创建新章节\n      const newEpisode = {\n        episode_number: episodeNumber,\n        title: `第${episodeNumber}集`,\n        script_content: scriptContent.value,\n      };\n      updatedEpisodes = [...existingEpisodes, newEpisode];\n    }\n\n    await dramaAPI.saveEpisodes(dramaId, updatedEpisodes);\n    ElMessage.success(\"章节保存成功！\");\n    await loadDramaData();\n  } catch (error: any) {\n    ElMessage.error(error.message || \"保存失败\");\n  }\n};\n\nconst editCurrentEpisodeScript = () => {\n  scriptContent.value = currentEpisode.value?.script_content || \"\";\n};\n\nconst handleExtractCharactersAndBackgrounds = async () => {\n  // 如果已经提取过，显示确认对话框\n  if (hasExtractedData.value) {\n    try {\n      await ElMessageBox.confirm(\n        $t(\"workflow.reExtractConfirmMessage\"),\n        $t(\"workflow.reExtractConfirmTitle\"),\n        {\n          confirmButtonText: $t(\"common.confirm\"),\n          cancelButtonText: $t(\"common.cancel\"),\n          type: \"warning\",\n          distinguishCancelAndClose: true,\n        },\n      );\n    } catch {\n      ElMessage.info($t(\"workflow.extractCancelled\"));\n      return;\n    }\n  }\n\n  // 显示即将开始的提示\n  if (hasExtractedData.value) {\n    ElMessage.info($t(\"workflow.startReExtracting\"));\n  }\n\n  await extractCharactersAndBackgrounds();\n};\n\n// 轮询检查图片生成状态\nconst pollImageStatus = async (\n  imageGenId: number,\n  onComplete: () => Promise<void>,\n) => {\n  const maxAttempts = 100; // 最多轮询100次\n  const pollInterval = 6000; // 每6秒轮询一次\n\n  for (let i = 0; i < maxAttempts; i++) {\n    try {\n      await new Promise((resolve) => setTimeout(resolve, pollInterval));\n\n      const imageGen = await imageAPI.getImage(imageGenId);\n\n      if (imageGen.status === \"completed\") {\n        // 生成成功\n        await onComplete();\n        return;\n      } else if (imageGen.status === \"failed\") {\n        // 生成失败\n        ElMessage.error(`图片生成失败: ${imageGen.error_msg || \"未知错误\"}`);\n        return;\n      }\n      // 如果是pending或processing，继续轮询\n    } catch (error: any) {\n      console.error(\"[轮询] 检查图片状态失败:\", error);\n      // 继续轮询，不中断\n    }\n  }\n\n  // 超时\n  ElMessage.warning(\"图片生成超时，请稍后刷新页面查看结果\");\n};\n\nconst extractCharactersAndBackgrounds = async () => {\n  if (!currentEpisode.value?.id) {\n    ElMessage.error(\"章节信息不存在\");\n    return;\n  }\n\n  extractingCharactersAndBackgrounds.value = true;\n\n  try {\n    const episodeId = currentEpisode.value.id;\n\n    // 并行创建异步任务\n    const [characterTask, backgroundTask] = await Promise.all([\n      generationAPI.generateCharacters({\n        drama_id: dramaId.toString(),\n        episode_id: episodeId,\n        outline: currentEpisode.value.script_content || \"\",\n        count: 0,\n        model: selectedTextModel.value, // 传递用户选择的文本模型\n      }),\n      dramaAPI.extractBackgrounds(\n        episodeId.toString(),\n        selectedTextModel.value,\n      ), // 传递用户选择的文本模型\n    ]);\n\n    ElMessage.success(\"任务已创建，正在后台处理...\");\n\n    // 并行轮询两个任务\n    await Promise.all([\n      pollExtractTask(characterTask.task_id, \"character\"),\n      pollExtractTask(backgroundTask.task_id, \"background\"),\n    ]);\n\n    ElMessage.success($t(\"workflow.charactersAndScenesExtractSuccess\"));\n    await loadDramaData();\n  } catch (error: any) {\n    console.error($t(\"workflow.charactersAndScenesExtractFailed\") + \":\", error);\n\n    const errorData = error.response?.data?.error;\n    const errorMsg = errorData?.message || error.message || \"提取失败\";\n\n    if (\n      errorMsg.includes(\"no config found\") ||\n      errorMsg.includes(\"AI client\") ||\n      errorMsg.includes(\"failed to get AI client\")\n    ) {\n      ElMessage({\n        type: \"warning\",\n        message: '未配置AI服务，请前往\"设置 > AI服务配置\"添加文本生成服务',\n        duration: 5000,\n        showClose: true,\n      });\n    } else {\n      ElMessage.error(errorMsg);\n    }\n  } finally {\n    extractingCharactersAndBackgrounds.value = false;\n  }\n};\n\n// 轮询提取任务状态\nconst pollExtractTask = async (\n  taskId: string,\n  type: \"character\" | \"background\",\n) => {\n  const maxAttempts = 60; // 最多轮询60次（2分钟）\n  const interval = 2000; // 每2秒查询一次\n\n  for (let i = 0; i < maxAttempts; i++) {\n    await new Promise((resolve) => setTimeout(resolve, interval));\n\n    try {\n      const task = await generationAPI.getTaskStatus(taskId);\n\n      if (task.status === \"completed\") {\n        // 任务完成\n        if (type === \"character\" && task.result) {\n          // 解析角色数据并保存\n          const result =\n            typeof task.result === \"string\"\n              ? JSON.parse(task.result)\n              : task.result;\n          if (result.characters && result.characters.length > 0) {\n            await dramaAPI.saveCharacters(\n              dramaId,\n              result.characters,\n              currentEpisode.value?.id,\n            );\n          }\n        }\n        return;\n      } else if (task.status === \"failed\") {\n        // 任务失败\n        throw new Error(\n          task.error ||\n            (type === \"character\"\n              ? $t(\"workflow.characterGenerationFailed\")\n              : $t(\"workflow.sceneExtractionFailed\")),\n        );\n      }\n      // 否则继续轮询\n    } catch (error: any) {\n      console.error(`轮询${type}任务状态失败:`, error);\n      throw error;\n    }\n  }\n\n  throw new Error(\n    type === \"character\"\n      ? $t(\"workflow.characterGenerationTimeout\")\n      : $t(\"workflow.sceneExtractionTimeout\"),\n  );\n};\n\nconst generateCharacterImage = async (characterId: number) => {\n  generatingCharacterImages.value[characterId] = true;\n\n  try {\n    // 获取用户选择的图片生成模型\n    const model = selectedImageModel.value || undefined;\n    const response = await characterLibraryAPI.generateCharacterImage(\n      characterId.toString(),\n      model,\n    );\n    const imageGenId = response.image_generation?.id;\n\n    if (imageGenId) {\n      ElMessage.info(\"角色图片生成中，请稍候...\");\n      // 轮询检查生成状态\n      await pollImageStatus(imageGenId, async () => {\n        await loadDramaData();\n        ElMessage.success(\"角色图片生成完成！\");\n      });\n    } else {\n      ElMessage.success(\"角色图片生成已启动\");\n      await loadDramaData();\n    }\n  } catch (error: any) {\n    ElMessage.error(error.message || \"生成失败\");\n  } finally {\n    generatingCharacterImages.value[characterId] = false;\n  }\n};\n\nconst toggleSelectAllCharacters = () => {\n  if (selectAllCharacters.value) {\n    selectedCharacterIds.value =\n      currentEpisode.value?.characters?.map((char) => char.id) || [];\n  } else {\n    selectedCharacterIds.value = [];\n  }\n};\n\nconst toggleSelectAllScenes = () => {\n  if (selectAllScenes.value) {\n    selectedSceneIds.value =\n      currentEpisode.value?.scenes?.map((scene) => scene.id) || [];\n  } else {\n    selectedSceneIds.value = [];\n  }\n};\n\nconst batchGenerateCharacterImages = async () => {\n  if (selectedCharacterIds.value.length === 0) {\n    ElMessage.warning(\"请先选择要生成的角色\");\n    return;\n  }\n\n  batchGeneratingCharacters.value = true;\n  try {\n    // 获取用户选择的图片生成模型\n    const model = selectedImageModel.value || undefined;\n\n    // 使用批量生成API\n    await characterLibraryAPI.batchGenerateCharacterImages(\n      selectedCharacterIds.value.map((id) => id.toString()),\n      model,\n    );\n\n    ElMessage.success($t(\"workflow.batchTaskSubmitted\"));\n    await loadDramaData();\n  } catch (error: any) {\n    ElMessage.error(error.message || $t(\"workflow.batchGenerateFailed\"));\n  } finally {\n    batchGeneratingCharacters.value = false;\n  }\n};\n\nconst generateSceneImage = async (sceneId: string) => {\n  generatingSceneImages.value[sceneId] = true;\n\n  try {\n    // 获取用户选择的图片生成模型\n    const model = selectedImageModel.value || undefined;\n    const response = await dramaAPI.generateSceneImage({\n      scene_id: parseInt(sceneId),\n      model,\n    });\n    const imageGenId = response.image_generation?.id;\n\n    if (imageGenId) {\n      ElMessage.info($t(\"workflow.sceneImageGenerating\"));\n      // 轮询检查生成状态\n      await pollImageStatus(imageGenId, async () => {\n        await loadDramaData();\n        ElMessage.success($t(\"workflow.sceneImageComplete\"));\n      });\n    } else {\n      ElMessage.success($t(\"workflow.sceneImageStarted\"));\n      await loadDramaData();\n    }\n  } catch (error: any) {\n    ElMessage.error(error.message || \"生成失败\");\n  } finally {\n    generatingSceneImages.value[sceneId] = false;\n  }\n};\n\nconst batchGenerateSceneImages = async () => {\n  if (selectedSceneIds.value.length === 0) {\n    ElMessage.warning(\"请先选择要生成的场景\");\n    return;\n  }\n\n  batchGeneratingScenes.value = true;\n  try {\n    const promises = selectedSceneIds.value.map((sceneId) =>\n      generateSceneImage(sceneId.toString()),\n    );\n    const results = await Promise.allSettled(promises);\n\n    const successCount = results.filter((r) => r.status === \"fulfilled\").length;\n    const failCount = results.filter((r) => r.status === \"rejected\").length;\n\n    if (failCount === 0) {\n      ElMessage.success(\n        $t(\"workflow.batchCompleteSuccess\", { count: successCount }),\n      );\n    } else {\n      ElMessage.warning(\n        $t(\"workflow.batchCompletePartial\", {\n          success: successCount,\n          fail: failCount,\n        }),\n      );\n    }\n  } catch (error: any) {\n    ElMessage.error(error.message || $t(\"workflow.batchGenerateFailed\"));\n  } finally {\n    batchGeneratingScenes.value = false;\n  }\n};\n\nconst taskProgress = ref(0);\nconst taskMessage = ref(\"\");\nlet pollTimer: any = null;\n\nconst generateShots = async () => {\n  if (!currentEpisode.value?.id) {\n    ElMessage.error(\"章节信息不存在\");\n    return;\n  }\n\n  generatingShots.value = true;\n  taskProgress.value = 0;\n  taskMessage.value = \"初始化任务...\";\n\n  try {\n    const episodeId = currentEpisode.value.id.toString();\n\n    // 【调试日志】输出当前操作的集数信息\n    console.log(\"=== 开始生成分镜 ===\");\n    console.log(\"当前 episodeNumber (路由参数):\", episodeNumber);\n    console.log(\"当前 episodeId (从 currentEpisode 获取):\", episodeId);\n    console.log(\"currentEpisode 完整信息:\", {\n      id: currentEpisode.value?.id,\n      episode_number: currentEpisode.value?.episode_number,\n      title: currentEpisode.value?.title,\n    });\n    console.log(\n      \"所有剧集列表:\",\n      drama.value?.episodes?.map((ep) => ({\n        id: ep.id,\n        episode_number: ep.episode_number,\n        title: ep.title,\n      })),\n    );\n\n    // 创建异步任务\n    const response = await generationAPI.generateStoryboard(\n      episodeId,\n      selectedTextModel.value,\n    );\n\n    taskMessage.value = response.message || \"任务已创建\";\n\n    // 开始轮询任务状态\n    await pollTaskStatus(response.task_id);\n  } catch (error: any) {\n    ElMessage.error(error.message || \"拆分失败\");\n    generatingShots.value = false;\n  }\n};\n\nconst pollTaskStatus = async (taskId: string) => {\n  const checkStatus = async () => {\n    try {\n      const task = await generationAPI.getTaskStatus(taskId);\n\n      taskProgress.value = task.progress;\n      taskMessage.value = task.message || `处理中... ${task.progress}%`;\n\n      if (task.status === \"completed\") {\n        // 任务完成\n        if (pollTimer) {\n          clearInterval(pollTimer);\n          pollTimer = null;\n        }\n        generatingShots.value = false;\n\n        ElMessage.success($t(\"workflow.splitSuccess\"));\n\n        // 跳转到专业编辑器页面\n        router.push({\n          name: \"ProfessionalEditor\",\n          params: {\n            dramaId: dramaId,\n            episodeNumber: episodeNumber,\n          },\n        });\n      } else if (task.status === \"failed\") {\n        // 任务失败\n        if (pollTimer) {\n          clearInterval(pollTimer);\n          pollTimer = null;\n        }\n        generatingShots.value = false;\n        ElMessage.error(task.error || \"分镜拆分失败\");\n      }\n      // 否则继续轮询\n    } catch (error: any) {\n      if (pollTimer) {\n        clearInterval(pollTimer);\n        pollTimer = null;\n      }\n      generatingShots.value = false;\n      ElMessage.error(\"查询任务状态失败: \" + error.message);\n    }\n  };\n\n  // 立即检查一次\n  await checkStatus();\n\n  // 每2秒轮询一次\n  pollTimer = setInterval(checkStatus, 2000);\n};\n\nconst regenerateShots = async () => {\n  await ElMessageBox.confirm($t(\"workflow.reSplitConfirm\"), $t(\"common.tip\"), {\n    type: \"warning\",\n  });\n\n  await generateShots();\n};\n\nconst shotEditDialogVisible = ref(false);\nconst editingShot = ref<any>(null);\nconst editingShotIndex = ref<number>(-1);\nconst savingShot = ref(false);\n\nconst editShot = (shot: any, index: number) => {\n  editingShot.value = { ...shot };\n  editingShotIndex.value = index;\n  shotEditDialogVisible.value = true;\n};\n\nconst saveShotEdit = async () => {\n  if (!editingShot.value) return;\n\n  try {\n    savingShot.value = true;\n\n    // 调用API更新镜头\n    await dramaAPI.updateStoryboard(\n      editingShot.value.id.toString(),\n      editingShot.value,\n    );\n\n    // 更新本地数据\n    if (currentEpisode.value?.storyboards) {\n      currentEpisode.value.storyboards[editingShotIndex.value] = {\n        ...editingShot.value,\n      };\n    }\n\n    ElMessage.success(\"镜头修改成功\");\n    shotEditDialogVisible.value = false;\n  } catch (error: any) {\n    ElMessage.error(\"保存失败: \" + (error.message || \"未知错误\"));\n  } finally {\n    savingShot.value = false;\n  }\n};\n\n// 对话框相关方法\nconst openPromptDialog = (item: any, type: \"character\" | \"scene\") => {\n  currentEditItem.value = item;\n  currentEditItem.value.name = item.name || item.location;\n  currentEditType.value = type;\n  editPrompt.value = item.prompt || item.appearance || item.description || \"\";\n  promptDialogVisible.value = true;\n};\n\nconst savePrompt = async () => {\n  try {\n    if (currentEditType.value === \"character\") {\n      await characterLibraryAPI.updateCharacter(currentEditItem.value.id, {\n        appearance: editPrompt.value,\n      });\n      await generateCharacterImage(currentEditItem.value.id);\n    } else {\n      // 保存场景提示词和时间（合并到一个 API 调用）\n      await dramaAPI.updateScene(currentEditItem.value.id.toString(), {\n        prompt: editPrompt.value,\n        time: currentEditItem.value.time || \"\",\n      });\n\n      ElMessage.success(\"保存成功\");\n      await loadDramaData();\n    }\n    promptDialogVisible.value = false;\n  } catch (error: any) {\n    ElMessage.error(error.message || \"保存失败\");\n  }\n};\n\nconst uploadCharacterImage = (characterId: number) => {\n  currentUploadTarget.value = { id: characterId, type: \"character\" };\n  uploadDialogVisible.value = true;\n};\n\nconst uploadSceneImage = (sceneId: string) => {\n  currentUploadTarget.value = { id: sceneId, type: \"scene\" };\n  uploadDialogVisible.value = true;\n};\n\nconst selectFromLibrary = async (characterId: number) => {\n  try {\n    const result = await characterLibraryAPI.list({ page_size: 50 });\n    libraryItems.value = result.items || [];\n    currentUploadTarget.value = characterId;\n    libraryDialogVisible.value = true;\n  } catch (error: any) {\n    ElMessage.error(error.message || $t(\"workflow.loadLibraryFailed\"));\n  }\n};\n\nconst addToCharacterLibrary = async (character: any) => {\n  if (!character.image_url) {\n    ElMessage.warning($t(\"workflow.generateImageFirst\"));\n    return;\n  }\n\n  try {\n    await ElMessageBox.confirm(\n      $t(\"workflow.addToLibraryConfirm\", { name: character.name }),\n      $t(\"workflow.addToLibrary\"),\n      {\n        confirmButtonText: $t(\"common.confirm\"),\n        cancelButtonText: $t(\"common.cancel\"),\n        type: \"info\",\n      },\n    );\n\n    await characterLibraryAPI.addCharacterToLibrary(character.id.toString());\n    ElMessage.success($t(\"workflow.addedToLibrary\"));\n  } catch (error: any) {\n    if (error !== \"cancel\") {\n      ElMessage.error(error.message || $t(\"workflow.addFailed\"));\n    }\n  }\n};\n\nconst selectLibraryItem = async (item: any) => {\n  try {\n    if (currentUploadTarget.value?.type === \"character\") {\n      await characterLibraryAPI.applyFromLibrary(\n        currentUploadTarget.value.id.toString(),\n        item.id,\n      );\n      ElMessage.success(\"应用角色形象成功！\");\n      await loadDramaData();\n      libraryDialogVisible.value = false;\n    }\n  } catch (error: any) {\n    ElMessage.error(error.message || \"应用失败\");\n  }\n};\n\nconst handleUploadSuccess = async (response: any) => {\n  try {\n    const imageUrl = response.url || response.data?.url;\n    const localPath = response.local_path || response.data?.local_path;\n\n    if (!imageUrl && !localPath) {\n      ElMessage.error(\"上传失败：未获取到图片地址\");\n      return;\n    }\n\n    if (currentUploadTarget.value?.type === \"character\") {\n      await characterLibraryAPI.updateCharacter(\n        currentUploadTarget.value.id.toString(),\n        {\n          image_url: imageUrl,\n          local_path: localPath,\n        },\n      );\n      ElMessage.success(\"上传成功！\");\n    } else if (currentUploadTarget.value?.type === \"scene\") {\n      // 更新场景图片\n      await dramaAPI.updateScene(currentUploadTarget.value.id.toString(), {\n        image_url: imageUrl,\n        local_path: localPath,\n      });\n      ElMessage.success($t(\"workflow.sceneImageUploadSuccess\"));\n    }\n\n    await loadDramaData();\n    uploadDialogVisible.value = false;\n  } catch (error: any) {\n    ElMessage.error(error.message || \"上传失败\");\n  }\n};\n\nconst handleUploadError = () => {\n  ElMessage.error(\"上传失败，请重试\");\n};\n\nconst deleteCharacter = async (characterId: number) => {\n  try {\n    await ElMessageBox.confirm(\n      $t(\"workflow.deleteCharacterConfirm\"),\n      $t(\"workflow.deleteConfirmTitle\"),\n      {\n        type: \"warning\",\n        confirmButtonText: $t(\"workflow.confirmButtonText\"),\n        cancelButtonText: $t(\"workflow.cancelButtonText\"),\n      },\n    );\n\n    await characterLibraryAPI.deleteCharacter(characterId);\n    ElMessage.success(\"角色已删除\");\n    await loadDramaData();\n  } catch (error: any) {\n    if (error !== \"cancel\") {\n      ElMessage.error(error.message || \"删除失败\");\n    }\n  }\n};\n\nconst goToProfessionalUI = () => {\n  if (!currentEpisode.value?.id) {\n    ElMessage.error(\"章节信息不存在\");\n    return;\n  }\n\n  router.push({\n    name: \"ProfessionalEditor\",\n    params: {\n      dramaId: dramaId,\n      episodeNumber: episodeNumber,\n    },\n  });\n};\n\nconst goToCompose = () => {\n  if (!currentEpisode.value?.id) {\n    ElMessage.error(\"章节信息不存在\");\n    return;\n  }\n\n  router.push({\n    name: \"SceneComposition\",\n    params: {\n      id: dramaId,\n      episodeId: currentEpisode.value.id,\n    },\n  });\n};\n\n// 打开添加场景对话框\nconst openAddSceneDialog = () => {\n  newScene.value = {\n    location: \"\",\n    time: \"\",\n    prompt: \"\",\n    image_url: \"\",\n    local_path: \"\",\n  };\n  addSceneDialogVisible.value = true;\n};\n\n// 保存场景\nconst saveScene = async () => {\n  if (!newScene.value.location) {\n    ElMessage.warning($t(\"workflow.pleaseEnterSceneName\"));\n    return;\n  }\n\n  if (!currentEpisode.value?.id) {\n    ElMessage.error($t(\"workflow.chapterInfoNotExist\"));\n    return;\n  }\n\n  try {\n    // 创建场景，关联到当前章节\n    await dramaAPI.createScene({\n      drama_id: parseInt(dramaId),\n      episode_id: parseInt(currentEpisode.value.id),\n      location: newScene.value.location,\n      time: newScene.value.time || \"\",\n      prompt: newScene.value.prompt,\n      image_url: newScene.value.image_url,\n      local_path: newScene.value.local_path,\n    });\n\n    ElMessage.success($t(\"workflow.sceneAddSuccess\"));\n    addSceneDialogVisible.value = false;\n\n    // 重新加载数据以更新场景列表\n    await loadDramaData();\n  } catch (error: any) {\n    ElMessage.error(error.message || $t(\"workflow.sceneAddFailed\"));\n  }\n};\n\n// 处理场景图片上传成功\nconst handleSceneImageSuccess = (response: any) => {\n  console.log(\"场景图片上传响应:\", response);\n\n  // 处理不同的响应结构\n  const imageUrl = response.url || response.data?.url;\n  const localPath = response.local_path || response.data?.local_path;\n\n  if (imageUrl) {\n    newScene.value.image_url = imageUrl;\n  }\n  if (localPath) {\n    newScene.value.local_path = localPath;\n  }\n\n  console.log(\"更新后的 newScene:\", newScene.value);\n\n  if (imageUrl || localPath) {\n    ElMessage.success($t(\"workflow.imageUploadSuccess\"));\n  } else {\n    ElMessage.warning($t(\"workflow.imageUploadSuccessNoUrl\"));\n  }\n};\n\n// 图片上传前的校验\nconst beforeAvatarUpload = (file: File) => {\n  const isImage = file.type.startsWith(\"image/\");\n  const isLt10M = file.size / 1024 / 1024 < 10;\n\n  if (!isImage) {\n    ElMessage.error(\"只能上传图片文件!\");\n    return false;\n  }\n  if (!isLt10M) {\n    ElMessage.error(\"图片大小不能超过 10MB!\");\n    return false;\n  }\n  return true;\n};\n\n// 打开从剧本提取场景对话框\nconst openExtractSceneDialog = () => {\n  extractScenesDialogVisible.value = true;\n};\n\n// 从剧本提取场景\nconst handleExtractScenes = async () => {\n  if (!currentEpisode.value?.id) {\n    ElMessage.error($t(\"workflow.chapterInfoNotExist\"));\n    return;\n  }\n\n  try {\n    extractingScenes.value = true;\n    await dramaAPI.extractBackgrounds(currentEpisode.value.id.toString());\n\n    ElMessage.success($t(\"workflow.sceneExtractSubmitted\"));\n    extractScenesDialogVisible.value = false;\n\n    // 自动刷新几次\n    let checkCount = 0;\n    const maxChecks = 5;\n    const checkInterval = setInterval(async () => {\n      checkCount++;\n      await loadDramaData();\n\n      if (checkCount >= maxChecks) {\n        clearInterval(checkInterval);\n      }\n    }, 3000);\n  } catch (error: any) {\n    ElMessage.error(error.message || $t(\"workflow.sceneExtractFailed\"));\n  } finally {\n    extractingScenes.value = false;\n  }\n};\n\n// 监听步骤变化，保存到 localStorage\nwatch(currentStep, (newStep) => {\n  localStorage.setItem(getStepStorageKey(), newStep.toString());\n});\n\nonMounted(() => {\n  loadDramaData();\n  loadSavedModelConfig();\n  loadAIConfigs();\n});\n</script>\n\n<style scoped lang=\"scss\">\n/* ========================================\n   Page Layout / 页面布局 - 紧凑边距\n   ======================================== */\n.page-container {\n  min-height: 100vh;\n  background: var(--bg-primary);\n  // padding: var(--space-2) var(--space-3);\n  transition: background var(--transition-normal);\n}\n\n@media (min-width: 768px) {\n  .page-container {\n    // padding: var(--space-3) var(--space-4);\n  }\n}\n\n@media (min-width: 1024px) {\n  .page-container {\n    // padding: var(--space-4) var(--space-5);\n  }\n}\n\n.content-wrapper {\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n  margin: 0 auto;\n  width: 100%;\n  height: 100vh;\n  overflow: hidden;\n}\n\n.content-container {\n  height: calc(100% - 134px);\n  overflow-y: auto;\n}\n\n.actions-container {\n  height: 70px;\n  background: var(--bg-card);\n  overflow: hidden;\n}\n\n/* Header styles matching PageHeader component */\n.page-header {\n  margin-bottom: var(--space-3);\n  padding-bottom: var(--space-3);\n  border-bottom: 1px solid var(--border-primary);\n}\n\n.header-content {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: var(--space-4);\n}\n\n.header-left {\n  display: flex;\n  align-items: center;\n  gap: var(--space-4);\n  flex-shrink: 0;\n}\n\n.back-btn {\n  display: inline-flex;\n  align-items: center;\n  gap: 0.375rem;\n  padding: 0.5rem 0.875rem;\n  background: var(--bg-card);\n  border: 1px solid var(--border-primary);\n  border-radius: var(--radius-lg);\n  color: var(--text-secondary);\n  font-size: 0.875rem;\n  font-weight: 500;\n  cursor: pointer;\n  transition: all var(--transition-fast);\n  white-space: nowrap;\n\n  &:hover {\n    background: var(--bg-card-hover);\n    color: var(--text-primary);\n    border-color: var(--border-secondary);\n  }\n}\n\n.nav-divider {\n  width: 1px;\n  height: 2rem;\n  background: var(--border-primary);\n}\n\n.header-title {\n  margin: 0;\n  font-size: 1.5rem;\n  font-weight: 700;\n  color: var(--text-primary);\n  letter-spacing: -0.025em;\n  line-height: 1.2;\n  white-space: nowrap;\n}\n\n.header-center {\n  flex: 1;\n  display: flex;\n  justify-content: center;\n}\n\n.header-right {\n  flex-shrink: 0;\n}\n\n.workflow-card {\n  height: calc(100% - 24px);\n  margin: 12px;\n  background: var(--bg-card);\n  border-radius: var(--radius-lg);\n  box-shadow: var(--shadow-card);\n  border: 1px solid var(--border-primary);\n\n  :deep(.el-card__body) {\n    padding: 0;\n  }\n}\n\n.custom-steps {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n\n  .step-item {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    padding: 8px 16px;\n    border-radius: 20px;\n    background: var(--bg-card-hover);\n    transition: all 0.3s;\n\n    &.active {\n      background: var(--accent-light);\n\n      .step-circle {\n        background: var(--accent);\n        color: var(--text-inverse);\n      }\n    }\n\n    &.current {\n      background: var(--accent);\n      color: var(--text-inverse);\n\n      .step-circle {\n        background: var(--bg-card);\n        color: var(--accent);\n      }\n\n      .step-text {\n        color: var(--text-inverse);\n      }\n    }\n\n    .step-circle {\n      width: 28px;\n      height: 28px;\n      border-radius: 50%;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      background: var(--border-secondary);\n      color: var(--text-secondary);\n      font-weight: 600;\n      transition: all 0.3s;\n    }\n\n    .step-text {\n      font-size: 14px;\n      font-weight: 500;\n      white-space: nowrap;\n    }\n  }\n\n  .step-arrow {\n    color: var(--border-secondary);\n  }\n}\n\n.stage-card {\n  margin: 12px;\n\n  &.stage-card-fullscreen {\n    .stage-body-fullscreen {\n      min-height: calc(100vh - 200px);\n    }\n  }\n}\n\n.stage-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n\n  .header-left {\n    display: flex;\n    align-items: center;\n    gap: 16px;\n\n    .header-info {\n      h2 {\n        margin: 0 0 4px 0;\n        font-size: 20px;\n      }\n\n      p {\n        margin: 0;\n        color: var(--text-muted);\n        font-size: 14px;\n      }\n    }\n  }\n}\n\n.stage-body {\n  background: var(--bg-card);\n}\n\n.action-buttons {\n  display: flex;\n  gap: 12px;\n  margin: 12px 0;\n  flex-wrap: wrap;\n  justify-content: center;\n  align-items: center;\n}\n\n.action-buttons-inline {\n  display: flex;\n  gap: 12px;\n}\n\n.script-textarea {\n  margin: 16px 0;\n\n  &.script-textarea-fullscreen {\n    :deep(textarea) {\n      min-height: 500px;\n      font-size: 14px;\n      line-height: 1.8;\n    }\n  }\n}\n\n.image-gen-section {\n  margin-bottom: 32px;\n\n  .section-header {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    margin-bottom: 16px;\n    padding: 16px;\n    background: var(--bg-secondary);\n    // border-radius: 8px;\n    // border: 1px solid var(--border-primary);\n\n    .section-title {\n      display: flex;\n      align-items: center;\n      gap: 16px;\n\n      h3 {\n        display: flex;\n        align-items: center;\n        gap: 8px;\n        margin: 0;\n        font-size: 16px;\n        font-weight: 600;\n        color: var(--text-primary);\n\n        .el-icon {\n          color: var(--accent);\n          font-size: 18px;\n        }\n      }\n\n      .el-alert {\n        border-radius: 4px;\n      }\n    }\n\n    .section-actions {\n      display: flex;\n      align-items: center;\n    }\n  }\n}\n\n.empty-shots {\n  padding: 60px 0;\n  text-align: center;\n}\n\n.extracted-title {\n  margin-bottom: 8px;\n  color: var(--text-secondary);\n}\n\n.secondary-text {\n  color: var(--text-muted);\n  margin-left: 4px;\n}\n\n.task-message {\n  margin-top: 8px;\n  font-size: 12px;\n  color: var(--text-muted);\n  text-align: center;\n}\n\n.model-tip {\n  margin-top: 8px;\n  font-size: 12px;\n  color: var(--text-muted);\n}\n\n.fixed-card {\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  border-radius: 8px;\n  overflow: hidden;\n  border: 1px solid var(--border-primary);\n  box-shadow: var(--shadow-card);\n  transition: all 0.2s;\n\n  &:hover {\n    box-shadow: var(--shadow-card-hover);\n  }\n\n  :deep(.el-card__body) {\n    flex: 1;\n    padding: 0;\n    display: flex;\n    flex-direction: column;\n  }\n\n  .card-header {\n    padding: 14px;\n    background: var(--bg-secondary);\n    border-bottom: 1px solid var(--border-primary);\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n\n    .header-left {\n      flex: 1;\n      min-width: 0;\n\n      h4 {\n        margin: 0 0 4px 0;\n        font-size: 14px;\n        font-weight: 600;\n        color: var(--text-primary);\n        overflow: hidden;\n        text-overflow: ellipsis;\n        white-space: nowrap;\n      }\n\n      .el-tag {\n        margin-top: 0;\n      }\n    }\n  }\n\n  .card-image-container {\n    flex: 1;\n    width: 100%;\n    min-height: 200px;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    background: var(--bg-secondary);\n\n    .char-image,\n    .scene-image {\n      width: 100%;\n      height: 100%;\n      position: relative;\n      z-index: 1;\n\n      .el-image {\n        width: 100%;\n        height: 100%;\n        border-radius: 0;\n      }\n    }\n\n    .char-placeholder,\n    .scene-placeholder {\n      display: flex;\n      flex-direction: column;\n      align-items: center;\n      justify-content: center;\n      color: var(--text-muted);\n      padding: 20px;\n\n      &.generating {\n        color: var(--warning);\n        background: var(--warning-light);\n\n        .rotating {\n          animation: rotating 2s linear infinite;\n        }\n      }\n\n      &.failed {\n        color: var(--error);\n        background: var(--error-light);\n      }\n      position: relative;\n      z-index: 1;\n\n      .el-icon {\n        opacity: 0.5;\n      }\n\n      span {\n        margin-top: 10px;\n        font-size: 12px;\n      }\n    }\n  }\n\n  .card-actions {\n    padding: 10px;\n    background: var(--bg-card);\n    border-top: 1px solid var(--border-primary);\n    display: flex;\n    justify-content: center;\n    gap: 8px;\n\n    .el-button {\n      margin: 0;\n    }\n  }\n}\n\n.character-image-list,\n.scene-image-list {\n  padding: 5px;\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));\n  gap: 16px;\n  margin-top: 16px;\n\n  .character-item,\n  .scene-item {\n    min-height: 360px;\n  }\n}\n\n// 角色库选择对话框\n.library-grid {\n  display: grid;\n  grid-template-columns: repeat(4, 1fr);\n  gap: 16px;\n  max-height: 500px;\n  overflow-y: auto;\n  padding: 8px;\n\n  .library-item {\n    cursor: pointer;\n    border: 2px solid transparent;\n    border-radius: 8px;\n    overflow: hidden;\n    transition: all 0.3s;\n\n    &:hover {\n      border-color: var(--accent);\n      transform: translateY(-2px);\n      box-shadow: var(--shadow-lg);\n    }\n\n    .el-image {\n      width: 100%;\n      height: 150px;\n    }\n\n    .library-item-name {\n      padding: 8px;\n      text-align: center;\n      font-size: 12px;\n      background: var(--bg-secondary);\n      color: var(--text-primary);\n    }\n  }\n}\n\n.empty-library {\n  padding: 40px 0;\n}\n\n// 上传区域\n.upload-area {\n  :deep(.el-upload-dragger) {\n    width: 100%;\n    height: 200px;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n  }\n}\n\n// 旋转动画\n@keyframes rotating {\n  from {\n    transform: rotate(0deg);\n  }\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n/* ========================================\n   Dark Mode / 深色模式\n   ======================================== */\n:deep(.el-card) {\n  background: var(--bg-card);\n  border-color: var(--border-primary);\n}\n\n:deep(.el-card__header) {\n  background: var(--bg-secondary);\n  border-color: var(--border-primary);\n}\n\n:deep(.el-table) {\n  --el-table-bg-color: var(--bg-card);\n  --el-table-header-bg-color: var(--bg-secondary);\n  --el-table-tr-bg-color: var(--bg-card);\n  --el-table-row-hover-bg-color: var(--bg-card-hover);\n  --el-table-border-color: var(--border-primary);\n  --el-table-text-color: var(--text-primary);\n  background: var(--bg-card);\n}\n\n:deep(.el-table th.el-table__cell),\n:deep(.el-table td.el-table__cell) {\n  background: var(--bg-card);\n  border-color: var(--border-primary);\n}\n\n:deep(\n  .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell\n) {\n  background: var(--bg-secondary);\n}\n\n:deep(.el-table__header-wrapper th) {\n  background: var(--bg-secondary) !important;\n  color: var(--text-secondary);\n}\n\n:deep(.el-dialog) {\n  background: var(--bg-card);\n}\n\n:deep(.el-dialog__header) {\n  background: var(--bg-card);\n}\n\n:deep(.el-form-item__label) {\n  color: var(--text-primary);\n}\n\n:deep(.el-input__wrapper) {\n  background: var(--bg-secondary);\n  box-shadow: 0 0 0 1px var(--border-primary) inset;\n}\n\n:deep(.el-input__inner) {\n  color: var(--text-primary);\n}\n\n:deep(.el-textarea__inner) {\n  background: var(--bg-secondary);\n  color: var(--text-primary);\n  box-shadow: 0 0 0 1px var(--border-primary) inset;\n}\n\n:deep(.el-select-dropdown) {\n  background: var(--bg-elevated);\n  border-color: var(--border-primary);\n}\n\n:deep(.el-upload-dragger) {\n  background: var(--bg-secondary);\n  border-color: var(--border-primary);\n}\n</style>\n"
  },
  {
    "path": "web/src/views/drama/ProfessionalEditor.vue",
    "content": "<template>\n  <div class=\"professional-editor\">\n    <!-- 顶部工具栏 -->\n    <AppHeader\n      :fixed=\"false\"\n      :show-logo=\"false\"\n      @config-updated=\"loadVideoModels\"\n    >\n      <template #left>\n        <el-button text @click=\"goBack\" class=\"back-btn\">\n          <el-icon>\n            <ArrowLeft />\n          </el-icon>\n          <span>{{ $t(\"editor.backToEpisode\") }}</span>\n        </el-button>\n        <span class=\"episode-title\"\n          >{{ drama?.title }} -\n          {{ $t(\"editor.episode\", { number: episodeNumber }) }}</span\n        >\n      </template>\n    </AppHeader>\n\n    <!-- 主编辑区域 -->\n    <div class=\"editor-main\">\n      <!-- 左侧分镜列表 -->\n      <div class=\"storyboard-panel\">\n        <div class=\"panel-header\">\n          <h3>{{ $t(\"storyboard.scriptStructure\") }}</h3>\n          <el-button text :icon=\"Plus\" @click=\"handleAddStoryboard\">{{\n            $t(\"storyboard.add\")\n          }}</el-button>\n        </div>\n\n        <div class=\"storyboard-list\">\n          <div\n            v-for=\"(shot, index) in storyboards\"\n            :key=\"shot.id\"\n            class=\"storyboard-item\"\n            :class=\"{ active: currentStoryboardId === shot.id }\"\n            @click=\"selectStoryboard(shot.id)\"\n          >\n            <div class=\"shot-content\">\n              <div class=\"shot-header\">\n                <div class=\"shot-title-row\">\n                  <span class=\"shot-number\">{{\n                    $t(\"storyboard.shotNumber\", {\n                      number: shot.storyboard_number,\n                    })\n                  }}</span>\n                  <span class=\"shot-title\">{{\n                    shot.title || $t(\"storyboard.untitled\")\n                  }}</span>\n                </div>\n                <div class=\"shot-actions\">\n                  <span class=\"shot-duration\">{{ shot.duration }}s</span>\n                  <el-button\n                    link\n                    type=\"danger\"\n                    :icon=\"Delete\"\n                    @click.stop=\"handleDeleteStoryboard(shot)\"\n                    class=\"delete-btn\"\n                  />\n                </div>\n              </div>\n              <div class=\"shot-action\" v-if=\"shot.action\">\n                {{ shot.action }}\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- 中间时间线编辑区域 -->\n      <div class=\"timeline-area\">\n        <VideoTimelineEditor\n          ref=\"timelineEditorRef\"\n          v-if=\"storyboards.length > 0\"\n          :scenes=\"storyboards\"\n          :episode-id=\"episodeId.toString()\"\n          :drama-id=\"dramaId.toString()\"\n          :assets=\"videoAssets\"\n          @select-scene=\"handleTimelineSelect\"\n          @asset-deleted=\"loadVideoAssets\"\n          @merge-completed=\"handleMergeCompleted\"\n        />\n        <el-empty\n          v-else\n          :description=\"$t('storyboard.noStoryboard')\"\n          class=\"empty-timeline\"\n        />\n      </div>\n\n      <!-- 右侧编辑面板 -->\n      <div class=\"edit-panel\">\n        <el-tabs v-model=\"activeTab\" class=\"edit-tabs\">\n          <!-- 镜头属性标签 -->\n          <el-tab-pane\n            :label=\"$t('storyboard.shotProperties')\"\n            name=\"shot\"\n            v-if=\"currentStoryboard\"\n          >\n            <div v-if=\"currentStoryboard\" class=\"shot-editor-new\">\n              <!-- 场景(Scene) -->\n              <div class=\"scene-section\">\n                <div class=\"section-label\">\n                  {{ $t(\"storyboard.scene\") }} (Scene)\n                  <el-button\n                    size=\"small\"\n                    text\n                    @click=\"showSceneSelector = true\"\n                    >{{ $t(\"storyboard.selectScene\") }}</el-button\n                  >\n                </div>\n                <div\n                  class=\"scene-preview\"\n                  v-if=\"hasImage(currentStoryboard.background)\"\n                  @click=\"showSceneImage\"\n                >\n                  <img\n                    :src=\"getImageUrl(currentStoryboard.background)\"\n                    alt=\"场景\"\n                    style=\"cursor: pointer\"\n                  />\n                  <div class=\"scene-info\">\n                    <div>\n                      {{ currentStoryboard.background.location }} ·\n                      {{ currentStoryboard.background.time }}\n                    </div>\n                    <div class=\"scene-id\">\n                      {{ $t(\"editor.sceneId\") }}:\n                      {{ currentStoryboard.scene_id || \"N/A\" }}\n                    </div>\n                  </div>\n                </div>\n                <div class=\"scene-preview-empty\" v-else>\n                  <el-icon :size=\"48\" color=\"#666\">\n                    <Picture />\n                  </el-icon>\n                  <div>\n                    {{\n                      currentStoryboard.background\n                        ? $t(\"editor.sceneGenerating\")\n                        : $t(\"editor.noBackground\")\n                    }}\n                  </div>\n                </div>\n              </div>\n\n              <!-- 登场角色(Cast) -->\n              <div class=\"cast-section\">\n                <div class=\"section-label\">\n                  {{ $t(\"editor.cast\") }} (Cast)\n                  <el-button\n                    size=\"small\"\n                    text\n                    :icon=\"Plus\"\n                    @click=\"showCharacterSelector = true\"\n                    >{{ $t(\"editor.addCharacter\") }}</el-button\n                  >\n                </div>\n                <div class=\"cast-list\">\n                  <div\n                    v-for=\"char in currentStoryboardCharacters\"\n                    :key=\"char.id\"\n                    class=\"cast-item active\"\n                  >\n                    <div class=\"cast-avatar\" @click=\"showCharacterImage(char)\">\n                      <img\n                        v-if=\"hasImage(char)\"\n                        :src=\"getImageUrl(char)\"\n                        :alt=\"char.name\"\n                      />\n                      <span v-else>{{ char.name?.[0] || \"?\" }}</span>\n                    </div>\n                    <div class=\"cast-name\">{{ char.name }}</div>\n                    <div\n                      class=\"cast-remove\"\n                      @click.stop=\"toggleCharacterInShot(char.id)\"\n                      :title=\"$t('editor.removeCharacter')\"\n                    >\n                      <el-icon :size=\"14\">\n                        <Close />\n                      </el-icon>\n                    </div>\n                  </div>\n                  <div\n                    v-if=\"\n                      !currentStoryboard?.characters ||\n                      currentStoryboard.characters.length === 0\n                    \"\n                    class=\"cast-empty\"\n                  >\n                    {{ $t(\"editor.noCharacters\") }}\n                  </div>\n                </div>\n              </div>\n\n              <!-- 道具(Props) -->\n              <div class=\"cast-section\">\n                <div class=\"section-label\">\n                  {{ $t(\"editor.props\") }} (Props)\n                  <el-button\n                    size=\"small\"\n                    text\n                    :icon=\"Plus\"\n                    @click=\"showPropSelector = true\"\n                    >{{ $t(\"editor.addProp\") }}</el-button\n                  >\n                </div>\n                <div class=\"cast-list\">\n                  <div\n                    v-for=\"prop in currentStoryboardProps\"\n                    :key=\"prop.id\"\n                    class=\"cast-item active\"\n                  >\n                    <div class=\"cast-avatar\">\n                      <img\n                        v-if=\"hasImage(prop)\"\n                        :src=\"getImageUrl(prop)\"\n                        :alt=\"prop.name\"\n                      />\n                      <el-icon v-else>\n                        <Box />\n                      </el-icon>\n                    </div>\n                    <div class=\"cast-name\">{{ prop.name }}</div>\n                    <div\n                      class=\"cast-remove\"\n                      @click.stop=\"togglePropInShot(prop.id)\"\n                      title=\"移除道具\"\n                    >\n                      <el-icon :size=\"14\">\n                        <Close />\n                      </el-icon>\n                    </div>\n                  </div>\n                  <div\n                    v-if=\"\n                      !currentStoryboardProps ||\n                      currentStoryboardProps.length === 0\n                    \"\n                    class=\"cast-empty\"\n                  >\n                    {{ $t(\"editor.noProps\") }}\n                  </div>\n                </div>\n              </div>\n\n              <!-- 视效设置 -->\n              <div class=\"settings-section\">\n                <div class=\"section-label\">\n                  {{ $t(\"editor.visualSettings\") }}\n                </div>\n                <div class=\"settings-grid\">\n                  <div class=\"setting-item\">\n                    <label>{{ $t(\"editor.shotType\") }}</label>\n                    <el-select\n                      v-model=\"currentStoryboard.shot_type\"\n                      clearable\n                      :placeholder=\"$t('editor.shotTypePlaceholder')\"\n                      @change=\"saveStoryboardField('shot_type')\"\n                    >\n                      <el-option label=\"大远景\" value=\"大远景\" />\n                      <el-option label=\"远景\" value=\"远景\" />\n                      <el-option label=\"全景\" value=\"全景\" />\n                      <el-option label=\"中全景\" value=\"中全景\" />\n                      <el-option label=\"中景\" value=\"中景\" />\n                      <el-option label=\"中近景\" value=\"中近景\" />\n                      <el-option label=\"近景\" value=\"近景\" />\n                      <el-option label=\"特写\" value=\"特写\" />\n                      <el-option label=\"大特写\" value=\"大特写\" />\n                    </el-select>\n                  </div>\n\n                  <div class=\"setting-item\">\n                    <label>{{ $t(\"editor.movement\") }}</label>\n                    <el-select\n                      v-model=\"currentStoryboard.movement\"\n                      clearable\n                      :placeholder=\"$t('editor.movementPlaceholder')\"\n                      @change=\"saveStoryboardField('movement')\"\n                    >\n                      <el-option label=\"固定镜头\" value=\"固定镜头\" />\n                      <el-option label=\"推镜\" value=\"推镜\" />\n                      <el-option label=\"拉镜\" value=\"拉镜\" />\n                      <el-option label=\"摇镜\" value=\"摇镜\" />\n                      <el-option label=\"移镜\" value=\"移镜\" />\n                      <el-option label=\"跟镜\" value=\"跟镜\" />\n                      <el-option label=\"升降镜头\" value=\"升降镜头\" />\n                      <el-option label=\"环绕\" value=\"环绕\" />\n                      <el-option label=\"甩镜\" value=\"甩镜\" />\n                      <el-option label=\"变焦\" value=\"变焦\" />\n                      <el-option label=\"手持晃动\" value=\"手持晃动\" />\n                      <el-option label=\"稳定器运动\" value=\"稳定器运动\" />\n                      <el-option label=\"轨道推拉\" value=\"轨道推拉\" />\n                      <el-option label=\"航拍\" value=\"航拍\" />\n                    </el-select>\n                  </div>\n\n                  <div class=\"setting-item\">\n                    <label>{{ $t(\"editor.angle\") }}</label>\n                    <el-select\n                      v-model=\"currentStoryboard.angle\"\n                      clearable\n                      :placeholder=\"$t('editor.anglePlaceholder')\"\n                      @change=\"saveStoryboardField('angle')\"\n                    >\n                      <el-option label=\"平视\" value=\"平视\" />\n                      <el-option label=\"俯视\" value=\"俯视\" />\n                      <el-option label=\"仰视\" value=\"仰视\" />\n                      <el-option\n                        label=\"大俯视（鸟瞰）\"\n                        value=\"大俯视（鸟瞰）\"\n                      />\n                      <el-option label=\"大仰视\" value=\"大仰视\" />\n                      <el-option label=\"正侧面\" value=\"正侧面\" />\n                      <el-option label=\"斜侧面\" value=\"斜侧面\" />\n                      <el-option label=\"背面\" value=\"背面\" />\n                      <el-option\n                        label=\"倾斜（荷兰角）\"\n                        value=\"倾斜（荷兰角）\"\n                      />\n                      <el-option label=\"主观视角\" value=\"主观视角\" />\n                      <el-option label=\"过肩\" value=\"过肩\" />\n                    </el-select>\n                  </div>\n                </div>\n              </div>\n\n              <!-- 叙事内容 -->\n              <div class=\"narrative-section\">\n                <div class=\"section-label\">\n                  {{ $t(\"editor.action\") }} (Action)\n                </div>\n                <el-input\n                  v-model=\"currentStoryboard.action\"\n                  type=\"textarea\"\n                  :rows=\"3\"\n                  :placeholder=\"$t('editor.actionPlaceholder')\"\n                  @blur=\"saveStoryboardField('action')\"\n                />\n              </div>\n\n              <div class=\"narrative-section\">\n                <div class=\"section-label\">\n                  {{ $t(\"editor.result\") }} (Result)\n                </div>\n                <el-input\n                  v-model=\"currentStoryboard.result\"\n                  type=\"textarea\"\n                  :rows=\"2\"\n                  :placeholder=\"$t('editor.resultPlaceholder')\"\n                  @blur=\"saveStoryboardField('result')\"\n                />\n              </div>\n\n              <div class=\"dialogue-section\">\n                <div class=\"section-label\">\n                  {{ $t(\"editor.dialogue\") }} (Dialogue)\n                </div>\n                <el-input\n                  v-model=\"currentStoryboard.dialogue\"\n                  type=\"textarea\"\n                  :rows=\"3\"\n                  :placeholder=\"$t('editor.dialoguePlaceholder')\"\n                  @blur=\"saveStoryboardField('dialogue')\"\n                />\n              </div>\n\n              <div class=\"narrative-section\">\n                <div class=\"section-label\">\n                  {{ $t(\"editor.description\") }} (Description)\n                </div>\n                <el-input\n                  v-model=\"currentStoryboard.description\"\n                  type=\"textarea\"\n                  :rows=\"3\"\n                  :placeholder=\"$t('editor.descriptionPlaceholder')\"\n                  @blur=\"saveStoryboardField('description')\"\n                />\n              </div>\n\n              <!-- 音效设置 -->\n              <div class=\"settings-section\">\n                <div class=\"section-label\">{{ $t(\"editor.soundEffects\") }}</div>\n                <div class=\"audio-controls\">\n                  <el-input\n                    v-model=\"currentStoryboard.sound_effect\"\n                    :placeholder=\"$t('editor.soundEffectsPlaceholder')\"\n                    size=\"small\"\n                    type=\"textarea\"\n                    :rows=\"2\"\n                    @blur=\"saveStoryboardField('sound_effect')\"\n                  />\n                </div>\n              </div>\n\n              <!-- 配乐设置 -->\n              <div class=\"settings-section\">\n                <div class=\"section-label\">{{ $t(\"editor.bgmPrompt\") }}</div>\n                <div class=\"audio-controls\">\n                  <el-input\n                    v-model=\"currentStoryboard.bgm_prompt\"\n                    :placeholder=\"$t('editor.bgmPromptPlaceholder')\"\n                    size=\"small\"\n                    type=\"textarea\"\n                    :rows=\"2\"\n                    @blur=\"saveStoryboardField('bgm_prompt')\"\n                  />\n                </div>\n              </div>\n\n              <!-- 氛围设置 -->\n              <div class=\"settings-section\">\n                <div class=\"section-label\">{{ $t(\"editor.atmosphere\") }}</div>\n                <div class=\"audio-controls\">\n                  <el-input\n                    v-model=\"currentStoryboard.atmosphere\"\n                    :placeholder=\"$t('editor.atmospherePlaceholder')\"\n                    size=\"small\"\n                    type=\"textarea\"\n                    :rows=\"2\"\n                    @blur=\"saveStoryboardField('atmosphere')\"\n                  />\n                </div>\n              </div>\n            </div>\n            <el-empty v-else :description=\"$t('editor.noShotSelected')\" />\n          </el-tab-pane>\n\n          <!-- 图片生成标签 -->\n          <el-tab-pane :label=\"$t('editor.shotImage')\" name=\"image\">\n            <div class=\"tab-content\" v-if=\"currentStoryboard\">\n              <div class=\"image-generation-section\">\n                <!-- 帧类型选择 -->\n                <div class=\"frame-type-selector\">\n                  <div class=\"section-label\">\n                    {{ $t(\"editor.selectFrameType\") }}\n                  </div>\n                  <el-radio-group v-model=\"selectedFrameType\" size=\"small\">\n                    <el-radio-button label=\"first\">{{\n                      $t(\"editor.firstFrame\")\n                    }}</el-radio-button>\n                    <el-radio-button label=\"last\">{{\n                      $t(\"editor.lastFrame\")\n                    }}</el-radio-button>\n                    <!-- <el-radio-button label=\"panel\">{{\n                      $t(\"editor.panelFrame\")\n                    }}</el-radio-button> -->\n                    <el-radio-button label=\"action\">{{\n                      $t(\"editor.actionSequence\")\n                    }}</el-radio-button>\n                    <el-radio-button label=\"key\">{{\n                      $t(\"editor.keyFrame\")\n                    }}</el-radio-button>\n                  </el-radio-group>\n                  <el-input-number\n                    v-if=\"selectedFrameType === 'panel'\"\n                    v-model=\"panelCount\"\n                    :min=\"2\"\n                    :max=\"6\"\n                    size=\"small\"\n                    class=\"panel-count-input\"\n                    style=\"margin-left: 10px; margin-top: 12px\"\n                  />\n                  <span\n                    v-if=\"selectedFrameType === 'panel'\"\n                    class=\"panel-count-label\"\n                    >{{ $t(\"editor.panelCount\") }}</span\n                  >\n                </div>\n\n                <!-- 提示词区域 -->\n                <div class=\"prompt-section\">\n                  <div class=\"section-label\">\n                    {{ $t(\"editor.prompt\") }}\n                    <el-button\n                      size=\"small\"\n                      type=\"primary\"\n                      :disabled=\"\n                        isGeneratingPrompt(\n                          currentStoryboard?.id,\n                          selectedFrameType,\n                        )\n                      \"\n                      :loading=\"\n                        isGeneratingPrompt(\n                          currentStoryboard?.id,\n                          selectedFrameType,\n                        )\n                      \"\n                      @click=\"extractFramePrompt\"\n                      style=\"margin-left: 10px\"\n                    >\n                      {{ $t(\"editor.extractPrompt\") }}\n                    </el-button>\n                  </div>\n                  <el-input\n                    v-model=\"currentFramePrompt\"\n                    type=\"textarea\"\n                    :rows=\"8\"\n                    :placeholder=\"$t('editor.promptPlaceholder')\"\n                  />\n                </div>\n\n                <!-- 生成控制 -->\n                <div class=\"generation-controls\">\n                  <el-button\n                    type=\"success\"\n                    :icon=\"MagicStick\"\n                    :loading=\"generatingImage\"\n                    :disabled=\"!currentFramePrompt\"\n                    @click=\"generateFrameImage\"\n                  >\n                    {{\n                      generatingImage\n                        ? $t(\"editor.generating\")\n                        : $t(\"editor.generateImage\")\n                    }}\n                  </el-button>\n                  <el-button :icon=\"Upload\" @click=\"uploadImage\">{{\n                    $t(\"editor.uploadImage\")\n                  }}</el-button>\n                </div>\n\n                <!-- 生成结果 -->\n                <div\n                  class=\"generation-result\"\n                  v-if=\"\n                    generatedImages.length > 0 || selectedFrameType === 'action'\n                  \"\n                >\n                  <div class=\"section-label\">\n                    {{ $t(\"editor.generationResult\") }} ({{\n                      generatedImages.length\n                    }})\n                  </div>\n                  <div class=\"image-grid\">\n                    <!-- 动作序列入口按钮 -->\n                    <div\n                      v-if=\"selectedFrameType === 'action'\"\n                      class=\"image-item grid-entry-button\"\n                      @click=\"showGridEditor = true\"\n                    >\n                      <div class=\"grid-entry-placeholder\">\n                        <el-icon :size=\"28\" style=\"color: #ccc\">\n                          <Plus />\n                        </el-icon>\n                      </div>\n                      <!-- <div class=\"image-info\">\n                        <span class=\"frame-type-tag\">{{\n                          $t(\"editor.createGridImage\")\n                          }}</span>\n                      </div> -->\n                    </div>\n                    <div\n                      v-for=\"img in generatedImages\"\n                      :key=\"img.id\"\n                      class=\"image-item-wrapper\"\n                    >\n                      <div\n                        class=\"image-item\"\n                        :class=\"{\n                          'action-image-item': img.frame_type === 'action',\n                        }\"\n                      >\n                        <el-image\n                          v-if=\"hasImage(img)\"\n                          :src=\"getImageUrl(img)\"\n                          :preview-src-list=\"\n                            generatedImages\n                              .filter((i) => hasImage(i))\n                              .map((i) => getImageUrl(i)!)\n                          \"\n                          :initial-index=\"\n                            generatedImages\n                              .filter((i) => i.image_url)\n                              .findIndex((i) => i.id === img.id)\n                          \"\n                          fit=\"cover\"\n                          preview-teleported\n                        />\n                        <div v-else class=\"image-placeholder\">\n                          <el-icon :size=\"32\">\n                            <Picture />\n                          </el-icon>\n                          <p>{{ getStatusText(img.status) }}</p>\n                        </div>\n                        <div class=\"image-actions\" v-if=\"hasImage(img)\">\n                          <!-- 动作序列图片裁剪图标 -->\n                          <div\n                            v-if=\"img.frame_type === 'action' && hasImage(img)\"\n                            class=\"crop-icon-overlay\"\n                            @click.stop=\"openCropDialog(img)\"\n                          >\n                            <el-icon :size=\"18\" color=\"var(--text-primary)\">\n                              <Crop />\n                            </el-icon>\n                          </div>\n                          <div v-else></div>\n                          <!-- 删除按钮 -->\n                          <div\n                            v-if=\"hasImage(img)\"\n                            class=\"delete-icon-overlay\"\n                            @click.stop=\"handleDeleteImage(img)\"\n                          >\n                            <el-icon :size=\"18\" color=\"red\">\n                              <DeleteFilled />\n                            </el-icon>\n                          </div>\n                        </div>\n                      </div>\n                      <!-- <div class=\"image-status\">\n                                                <el-tag :type=\"getStatusType(img.status)\" size=\"small\">{{\n                                                    getStatusText(img.status)\n                                                }}</el-tag>\n                                            </div> -->\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n            <el-empty v-else description=\"未选择镜头\" />\n          </el-tab-pane>\n\n          <!-- 视频生成标签 -->\n          <el-tab-pane :label=\"$t('video.videoGeneration')\" name=\"video\">\n            <div class=\"tab-content\" v-if=\"currentStoryboard\">\n              <div class=\"video-generation-section\">\n                <!-- 生成提示词展示 -->\n                <div class=\"video-prompt-box\">\n                  {{ currentStoryboard.video_prompt || \"暂无提示词\" }}\n                </div>\n\n                <!-- 视频参数设置 -->\n                <div class=\"video-params-section\">\n                  <div class=\"param-row\">\n                    <span class=\"param-label\">{{ $t(\"video.model\") }}</span>\n                    <el-select\n                      v-model=\"selectedVideoModel\"\n                      :placeholder=\"$t('video.selectVideoModel')\"\n                      size=\"default\"\n                      style=\"flex: 1\"\n                    >\n                      <el-option\n                        v-for=\"model in videoModelCapabilities\"\n                        :key=\"model.id\"\n                        :label=\"model.name\"\n                        :value=\"model.id\"\n                      >\n                        <div\n                          style=\"\n                            display: flex;\n                            justify-content: space-between;\n                            align-items: center;\n                          \"\n                        >\n                          <span>{{ model.name }}</span>\n                          <div class=\"model-tags\">\n                            <el-tag\n                              v-if=\"model.supportMultipleImages\"\n                              size=\"small\"\n                              type=\"success\"\n                              style=\"margin-left: 4px\"\n                              >多图</el-tag\n                            >\n                            <el-tag\n                              v-if=\"model.supportFirstLastFrame\"\n                              size=\"small\"\n                              type=\"primary\"\n                              style=\"margin-left: 4px\"\n                              >首尾帧</el-tag\n                            >\n                            <el-tag\n                              size=\"small\"\n                              type=\"info\"\n                              style=\"margin-left: 4px\"\n                              >最多{{ model.maxImages }}张</el-tag\n                            >\n                          </div>\n                        </div>\n                      </el-option>\n                    </el-select>\n                  </div>\n\n                  <!-- 参考图模式选择 -->\n                  <div\n                    v-if=\"\n                      selectedVideoModel && availableReferenceModes.length > 0\n                    \"\n                    class=\"param-row\"\n                  >\n                    <span class=\"param-label\">参考图</span>\n                    <el-select\n                      v-model=\"selectedReferenceMode\"\n                      placeholder=\"请选择参考图模式\"\n                      size=\"default\"\n                      style=\"flex: 1\"\n                    >\n                      <el-option\n                        v-for=\"mode in availableReferenceModes\"\n                        :key=\"mode.value\"\n                        :label=\"mode.label\"\n                        :value=\"mode.value\"\n                      >\n                        <div\n                          style=\"\n                            display: flex;\n                            justify-content: space-between;\n                            align-items: center;\n                          \"\n                        >\n                          <span>{{ mode.label }}</span>\n                          <span\n                            v-if=\"mode.description\"\n                            class=\"mode-description\"\n                            >{{ mode.description }}</span\n                          >\n                        </div>\n                      </el-option>\n                    </el-select>\n                  </div>\n\n                  <div class=\"param-row\">\n                    <span class=\"param-label\">{{\n                      $t(\"professionalEditor.duration\")\n                    }}</span>\n                    <div style=\"flex: 1; display: flex; align-items: center\">\n                      <el-slider\n                        v-model=\"videoDuration\"\n                        :min=\"4\"\n                        :max=\"10\"\n                        :step=\"1\"\n                        show-stops\n                        style=\"flex: 1\"\n                      />\n                      <span style=\"margin-left: 10px; min-width: 40px\"\n                        >{{ videoDuration\n                        }}{{ $t(\"professionalEditor.seconds\") }}</span\n                      >\n                    </div>\n                  </div>\n                </div>\n\n                <!-- 选择参考图片 -->\n                <div\n                  v-if=\"\n                    selectedReferenceMode && selectedReferenceMode !== 'none'\n                  \"\n                  class=\"reference-images-section\"\n                  style=\"margin-top: 0\"\n                >\n                  <div\n                    class=\"frame-type-buttons\"\n                    style=\"text-align: center; margin-bottom: 8px\"\n                  >\n                    <el-radio-group\n                      v-model=\"selectedVideoFrameType\"\n                      size=\"default\"\n                    >\n                      <el-radio-button label=\"first\">首帧</el-radio-button>\n                      <el-radio-button label=\"last\">尾帧</el-radio-button>\n                      <!-- <el-radio-button label=\"panel\">分镜板</el-radio-button> -->\n                      <el-radio-button label=\"action\">动作序列</el-radio-button>\n                      <el-radio-button label=\"key\">关键帧</el-radio-button>\n                    </el-radio-group>\n                  </div>\n\n                  <div class=\"frame-type-content\">\n                    <!-- 首帧 -->\n                    <div\n                      v-show=\"selectedVideoFrameType === 'first'\"\n                      class=\"image-scroll-container\"\n                      style=\"\n                        max-height: 280px;\n                        overflow-y: auto;\n                        overflow-x: hidden;\n                      \"\n                    >\n                      <!-- 上一镜头尾帧推荐（紧凑版） -->\n                      <div\n                        v-if=\"previousStoryboardLastFrames.length > 0\"\n                        class=\"previous-frame-section\"\n                      >\n                        <div\n                          style=\"\n                            display: flex;\n                            align-items: center;\n                            gap: 6px;\n                            margin-bottom: 6px;\n                          \"\n                        >\n                          <el-tag size=\"small\" type=\"primary\">\n                            上一镜头 #{{\n                              previousStoryboard?.storyboard_number\n                            }}\n                            尾帧\n                          </el-tag>\n                          <span class=\"hint-text\">点击添加为首帧参考</span>\n                        </div>\n                        <div style=\"display: flex; gap: 8px; flex-wrap: wrap\">\n                          <div\n                            v-for=\"img in previousStoryboardLastFrames\"\n                            :key=\"'prev-' + img.id\"\n                            class=\"reference-item\"\n                            :class=\"{\n                              selected: selectedImagesForVideo.includes(img.id),\n                            }\"\n                            style=\"\n                              position: relative;\n                              border: 2px solid #1890ff;\n                              border-radius: 4px;\n                              overflow: hidden;\n                              cursor: pointer;\n                            \"\n                            @click=\"selectPreviousLastFrame(img)\"\n                          >\n                            <el-image\n                              :src=\"getImageUrl(img)\"\n                              fit=\"cover\"\n                              style=\"\n                                width: 60px;\n                                height: 40px;\n                                display: block;\n                                pointer-events: none;\n                              \"\n                            />\n                            <div\n                              v-if=\"selectedImagesForVideo.includes(img.id)\"\n                              style=\"\n                                position: absolute;\n                                top: 0;\n                                right: 0;\n                                background: #52c41a;\n                                color: #fff;\n                                font-size: 10px;\n                                padding: 1px 4px;\n                              \"\n                            >\n                              ✓\n                            </div>\n                          </div>\n                        </div>\n                      </div>\n\n                      <!-- 当前镜头首帧列表 -->\n                      <div\n                        class=\"reference-grid\"\n                        style=\"\n                          display: grid;\n                          grid-template-columns: repeat(4, 1fr);\n                          gap: 12px;\n                          max-width: 600px;\n                        \"\n                      >\n                        <div\n                          v-for=\"img in videoReferenceImages.filter(\n                            (i) =>\n                              i.status === 'completed' &&\n                              i.image_url &&\n                              i.frame_type === 'first',\n                          )\"\n                          :key=\"img.id\"\n                          class=\"reference-item\"\n                          :class=\"{\n                            selected: selectedImagesForVideo.includes(img.id),\n                          }\"\n                          style=\"position: relative\"\n                          @click=\"handleImageSelect(img.id)\"\n                        >\n                          <el-image\n                            :src=\"getImageUrl(img)\"\n                            fit=\"cover\"\n                            style=\"\n                              max-width: 120px;\n                              width: 100%;\n                              display: block;\n                              pointer-events: none;\n                            \"\n                          />\n                          <div\n                            class=\"preview-icon\"\n                            @click.stop=\"previewImage(getImageUrl(img))\"\n                            style=\"\n                              position: absolute;\n                              top: 4px;\n                              right: 4px;\n                              width: 24px;\n                              height: 24px;\n                              background: rgba(0, 0, 0, 0.6);\n                              border-radius: 4px;\n                              display: flex;\n                              align-items: center;\n                              justify-content: center;\n                              cursor: pointer;\n                              z-index: 10;\n                            \"\n                          >\n                            <el-icon :size=\"14\" color=\"#fff\">\n                              <ZoomIn />\n                            </el-icon>\n                          </div>\n                        </div>\n                      </div>\n                      <el-empty\n                        v-if=\"\n                          !videoReferenceImages.some(\n                            (i) =>\n                              i.status === 'completed' &&\n                              i.image_url &&\n                              i.frame_type === 'first',\n                          ) && previousStoryboardLastFrames.length === 0\n                        \"\n                        description=\"暂无首帧图片\"\n                        size=\"small\"\n                      />\n                    </div>\n\n                    <!-- 关键帧 -->\n                    <div\n                      v-show=\"selectedVideoFrameType === 'key'\"\n                      class=\"image-scroll-container\"\n                      style=\"\n                        max-height: 280px;\n                        overflow-y: auto;\n                        overflow-x: hidden;\n                      \"\n                    >\n                      <div\n                        class=\"reference-grid\"\n                        style=\"\n                          display: grid;\n                          grid-template-columns: repeat(4, 1fr);\n                          gap: 12px;\n                          max-width: 600px;\n                        \"\n                      >\n                        <div\n                          v-for=\"img in videoReferenceImages.filter(\n                            (i) =>\n                              i.status === 'completed' &&\n                              i.image_url &&\n                              i.frame_type === 'key',\n                          )\"\n                          :key=\"img.id\"\n                          class=\"reference-item\"\n                          :class=\"{\n                            selected: selectedImagesForVideo.includes(img.id),\n                          }\"\n                          style=\"position: relative\"\n                          @click=\"handleImageSelect(img.id)\"\n                        >\n                          <el-image\n                            :src=\"getImageUrl(img)\"\n                            fit=\"cover\"\n                            style=\"\n                              max-width: 120px;\n                              width: 100%;\n                              display: block;\n                              pointer-events: none;\n                            \"\n                          />\n                          <div\n                            class=\"preview-icon\"\n                            @click.stop=\"previewImage(getImageUrl(img))\"\n                            style=\"\n                              position: absolute;\n                              top: 4px;\n                              right: 4px;\n                              width: 24px;\n                              height: 24px;\n                              background: rgba(0, 0, 0, 0.6);\n                              border-radius: 4px;\n                              display: flex;\n                              align-items: center;\n                              justify-content: center;\n                              cursor: pointer;\n                              z-index: 10;\n                            \"\n                          >\n                            <el-icon :size=\"14\" color=\"#fff\">\n                              <ZoomIn />\n                            </el-icon>\n                          </div>\n                        </div>\n                      </div>\n                      <el-empty\n                        v-if=\"\n                          !videoReferenceImages.some(\n                            (i) =>\n                              i.status === 'completed' &&\n                              i.image_url &&\n                              i.frame_type === 'key',\n                          )\n                        \"\n                        description=\"暂无关键帧图片\"\n                        size=\"small\"\n                      />\n                    </div>\n\n                    <!-- 尾帧 -->\n                    <div\n                      v-show=\"selectedVideoFrameType === 'last'\"\n                      class=\"image-scroll-container\"\n                      style=\"\n                        max-height: 280px;\n                        overflow-y: auto;\n                        overflow-x: hidden;\n                      \"\n                    >\n                      <div\n                        class=\"reference-grid\"\n                        style=\"\n                          display: grid;\n                          grid-template-columns: repeat(4, 1fr);\n                          gap: 12px;\n                          max-width: 600px;\n                        \"\n                      >\n                        <div\n                          v-for=\"img in videoReferenceImages.filter(\n                            (i) =>\n                              i.status === 'completed' &&\n                              i.image_url &&\n                              i.frame_type === 'last',\n                          )\"\n                          :key=\"img.id\"\n                          class=\"reference-item\"\n                          :class=\"{\n                            selected: selectedImagesForVideo.includes(img.id),\n                          }\"\n                          style=\"position: relative\"\n                          @click=\"handleImageSelect(img.id)\"\n                        >\n                          <el-image\n                            :src=\"getImageUrl(img)\"\n                            fit=\"cover\"\n                            style=\"\n                              max-width: 120px;\n                              width: 100%;\n                              display: block;\n                              pointer-events: none;\n                            \"\n                          />\n                          <div\n                            class=\"preview-icon\"\n                            @click.stop=\"previewImage(getImageUrl(img))\"\n                            style=\"\n                              position: absolute;\n                              top: 4px;\n                              right: 4px;\n                              width: 24px;\n                              height: 24px;\n                              background: rgba(0, 0, 0, 0.6);\n                              border-radius: 4px;\n                              display: flex;\n                              align-items: center;\n                              justify-content: center;\n                              cursor: pointer;\n                              z-index: 10;\n                            \"\n                          >\n                            <el-icon :size=\"14\" color=\"#fff\">\n                              <ZoomIn />\n                            </el-icon>\n                          </div>\n                        </div>\n                      </div>\n                      <el-empty\n                        v-if=\"\n                          !videoReferenceImages.some(\n                            (i) =>\n                              i.status === 'completed' &&\n                              i.image_url &&\n                              i.frame_type === 'last',\n                          )\n                        \"\n                        description=\"暂无尾帧图片\"\n                        size=\"small\"\n                      />\n                    </div>\n\n                    <!-- 分镜板 -->\n                    <div\n                      v-show=\"selectedVideoFrameType === 'panel'\"\n                      class=\"image-scroll-container\"\n                      style=\"\n                        max-height: 280px;\n                        overflow-y: auto;\n                        overflow-x: hidden;\n                      \"\n                    >\n                      <div\n                        class=\"reference-grid\"\n                        style=\"\n                          display: grid;\n                          grid-template-columns: repeat(4, 1fr);\n                          gap: 12px;\n                          max-width: 600px;\n                        \"\n                      >\n                        <div\n                          v-for=\"img in videoReferenceImages.filter(\n                            (i) =>\n                              i.status === 'completed' &&\n                              i.image_url &&\n                              i.frame_type === 'panel',\n                          )\"\n                          :key=\"img.id\"\n                          class=\"reference-item\"\n                          :class=\"{\n                            selected: selectedImagesForVideo.includes(img.id),\n                          }\"\n                          style=\"position: relative\"\n                          @click=\"handleImageSelect(img.id)\"\n                        >\n                          <el-image\n                            :src=\"getImageUrl(img)\"\n                            fit=\"cover\"\n                            style=\"\n                              max-width: 120px;\n                              width: 100%;\n                              display: block;\n                              pointer-events: none;\n                            \"\n                          />\n                          <div\n                            class=\"preview-icon\"\n                            @click.stop=\"previewImage(getImageUrl(img))\"\n                            style=\"\n                              position: absolute;\n                              top: 4px;\n                              right: 4px;\n                              width: 24px;\n                              height: 24px;\n                              background: rgba(0, 0, 0, 0.6);\n                              border-radius: 4px;\n                              display: flex;\n                              align-items: center;\n                              justify-content: center;\n                              cursor: pointer;\n                              z-index: 10;\n                            \"\n                          >\n                            <el-icon :size=\"14\" color=\"#fff\">\n                              <ZoomIn />\n                            </el-icon>\n                          </div>\n                        </div>\n                      </div>\n                      <el-empty\n                        v-if=\"\n                          !videoReferenceImages.some(\n                            (i) =>\n                              i.status === 'completed' &&\n                              i.image_url &&\n                              i.frame_type === 'panel',\n                          )\n                        \"\n                        description=\"暂无分镜板图片\"\n                        size=\"small\"\n                      />\n                    </div>\n\n                    <!-- 动作序列 -->\n                    <div\n                      v-show=\"selectedVideoFrameType === 'action'\"\n                      class=\"image-scroll-container\"\n                      style=\"\n                        max-height: 280px;\n                        overflow-y: auto;\n                        overflow-x: hidden;\n                      \"\n                    >\n                      <div\n                        class=\"reference-grid\"\n                        style=\"\n                          display: grid;\n                          grid-template-columns: repeat(4, 1fr);\n                          gap: 12px;\n                          max-width: 600px;\n                        \"\n                      >\n                        <div\n                          v-for=\"img in videoReferenceImages.filter(\n                            (i) =>\n                              i.status === 'completed' &&\n                              i.image_url &&\n                              i.frame_type === 'action',\n                          )\"\n                          :key=\"img.id\"\n                          class=\"reference-item\"\n                          :class=\"{\n                            selected: selectedImagesForVideo.includes(img.id),\n                          }\"\n                          style=\"position: relative\"\n                          @click=\"handleImageSelect(img.id)\"\n                        >\n                          <el-image\n                            :src=\"getImageUrl(img)\"\n                            fit=\"cover\"\n                            style=\"\n                              max-width: 120px;\n                              width: 100%;\n                              display: block;\n                              pointer-events: none;\n                            \"\n                          />\n                          <div\n                            class=\"preview-icon\"\n                            @click.stop=\"previewImage(getImageUrl(img))\"\n                            style=\"\n                              position: absolute;\n                              top: 4px;\n                              right: 4px;\n                              width: 24px;\n                              height: 24px;\n                              background: rgba(0, 0, 0, 0.6);\n                              border-radius: 4px;\n                              display: flex;\n                              align-items: center;\n                              justify-content: center;\n                              cursor: pointer;\n                              z-index: 10;\n                            \"\n                          >\n                            <el-icon :size=\"14\" color=\"#fff\">\n                              <ZoomIn />\n                            </el-icon>\n                          </div>\n                        </div>\n                      </div>\n                      <el-empty\n                        v-if=\"\n                          !videoReferenceImages.some(\n                            (i) =>\n                              i.status === 'completed' &&\n                              i.image_url &&\n                              i.frame_type === 'action',\n                          )\n                        \"\n                        description=\"暂无动作序列图片\"\n                        size=\"small\"\n                      />\n                    </div>\n                  </div>\n                </div>\n\n                <!-- 参考图片设置 -->\n                <div\n                  v-if=\"\n                    selectedReferenceMode && selectedReferenceMode !== 'none'\n                  \"\n                  class=\"reference-config-section\"\n                  style=\"margin-top: 24px\"\n                >\n                  <!-- 图片框配置区 -->\n                  <div\n                    class=\"image-slots-container\"\n                    style=\"margin-top: 16px; margin-bottom: 24px\"\n                  >\n                    <!-- 单图模式 -->\n                    <div\n                      v-if=\"selectedReferenceMode === 'single'\"\n                      style=\"text-align: center\"\n                    >\n                      <div class=\"reference-mode-title\">单图参考</div>\n                      <div style=\"display: inline-block\">\n                        <div\n                          class=\"image-slot\"\n                          @click=\"\n                            selectedImagesForVideo.length > 0 &&\n                            removeSelectedImage(selectedImagesForVideo[0])\n                          \"\n                        >\n                          <img\n                            v-if=\"selectedImageObjects[0]\"\n                            :src=\"getImageUrl(selectedImageObjects[0])\"\n                            alt=\"\"\n                            style=\"width: 100%; height: 100%; object-fit: cover\"\n                          />\n                          <div v-else class=\"image-slot-placeholder\">\n                            <el-icon :size=\"32\" color=\"#c0c4cc\">\n                              <Plus />\n                            </el-icon>\n                            <div class=\"slot-hint\">点击上方选择图片</div>\n                          </div>\n                          <div\n                            v-if=\"selectedImageObjects[0]\"\n                            class=\"image-slot-remove\"\n                          >\n                            <el-icon :size=\"16\" color=\"#fff\">\n                              <Close />\n                            </el-icon>\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n\n                    <!-- 首尾帧模式 -->\n                    <div\n                      v-else-if=\"selectedReferenceMode === 'first_last'\"\n                      style=\"text-align: center\"\n                    >\n                      <div class=\"reference-mode-title\">首尾帧</div>\n                      <div\n                        style=\"\n                          display: flex;\n                          gap: 20px;\n                          justify-content: center;\n                          align-items: center;\n                        \"\n                      >\n                        <div>\n                          <div class=\"frame-label\">首帧</div>\n                          <div\n                            class=\"image-slot\"\n                            @click=\"\n                              firstFrameSlotImage &&\n                              removeSelectedImage(firstFrameSlotImage.id)\n                            \"\n                          >\n                            <img\n                              v-if=\"firstFrameSlotImage\"\n                              :src=\"firstFrameSlotImage.image_url\"\n                              alt=\"\"\n                              style=\"\n                                width: 100%;\n                                height: 100%;\n                                object-fit: cover;\n                              \"\n                            />\n                            <div v-else class=\"image-slot-placeholder\">\n                              <el-icon :size=\"32\" color=\"#c0c4cc\">\n                                <Plus />\n                              </el-icon>\n                              <div class=\"slot-hint\">选择首帧</div>\n                            </div>\n                            <div\n                              v-if=\"firstFrameSlotImage\"\n                              class=\"image-slot-remove\"\n                            >\n                              <el-icon :size=\"16\" color=\"#fff\">\n                                <Close />\n                              </el-icon>\n                            </div>\n                          </div>\n                        </div>\n                        <el-icon :size=\"24\" color=\"#909399\">\n                          <Right />\n                        </el-icon>\n                        <div>\n                          <div class=\"frame-label\">尾帧</div>\n                          <div\n                            class=\"image-slot\"\n                            @click=\"\n                              lastFrameSlotImage &&\n                              removeSelectedImage(lastFrameSlotImage.id)\n                            \"\n                          >\n                            <img\n                              v-if=\"lastFrameSlotImage\"\n                              :src=\"lastFrameSlotImage.image_url\"\n                              alt=\"\"\n                              style=\"\n                                width: 100%;\n                                height: 100%;\n                                object-fit: cover;\n                              \"\n                            />\n                            <div v-else class=\"image-slot-placeholder\">\n                              <el-icon :size=\"32\" color=\"#c0c4cc\">\n                                <Plus />\n                              </el-icon>\n                              <div class=\"slot-hint\">选择尾帧</div>\n                            </div>\n                            <div\n                              v-if=\"lastFrameSlotImage\"\n                              class=\"image-slot-remove\"\n                            >\n                              <el-icon :size=\"16\" color=\"#fff\">\n                                <Close />\n                              </el-icon>\n                            </div>\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n\n                    <!-- 多图模式 -->\n                    <div\n                      v-else-if=\"selectedReferenceMode === 'multiple'\"\n                      style=\"text-align: center\"\n                    >\n                      <div\n                        style=\"\n                          margin-bottom: 12px;\n                          font-size: 13px;\n                          color: #606266;\n                          font-weight: 500;\n                        \"\n                      >\n                        多图参考 ({{ selectedImagesForVideo.length }}/{{\n                          currentModelCapability?.maxImages || 6\n                        }})\n                      </div>\n                      <div\n                        style=\"\n                          display: flex;\n                          gap: 12px;\n                          justify-content: center;\n                          flex-wrap: wrap;\n                        \"\n                      >\n                        <div\n                          v-for=\"index in currentModelCapability?.maxImages ||\n                          6\"\n                          :key=\"index\"\n                          class=\"image-slot image-slot-small\"\n                          style=\"\n                            position: relative;\n                            width: 80px;\n                            height: 52px;\n                            border: 2px dashed #dcdfe6;\n                            border-radius: 8px;\n                            overflow: hidden;\n                            cursor: pointer;\n                            background: #fff;\n                          \"\n                          @click=\"\n                            selectedImageObjects[index - 1] &&\n                            removeSelectedImage(\n                              selectedImageObjects[index - 1].id,\n                            )\n                          \"\n                        >\n                          <img\n                            v-if=\"selectedImageObjects[index - 1]\"\n                            :src=\"selectedImageObjects[index - 1].image_url\"\n                            alt=\"\"\n                            style=\"width: 100%; height: 100%; object-fit: cover\"\n                          />\n                          <div v-else class=\"image-slot-placeholder\">\n                            <el-icon :size=\"20\" color=\"#c0c4cc\">\n                              <Plus />\n                            </el-icon>\n                            <div\n                              style=\"\n                                margin-top: 4px;\n                                font-size: 10px;\n                                color: #909399;\n                              \"\n                            >\n                              {{ index }}\n                            </div>\n                          </div>\n                          <div\n                            v-if=\"selectedImageObjects[index - 1]\"\n                            class=\"image-slot-remove\"\n                          >\n                            <el-icon :size=\"14\" color=\"#fff\">\n                              <Close />\n                            </el-icon>\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n\n                <!-- 生成控制 -->\n                <div\n                  class=\"generation-controls\"\n                  style=\"margin-top: 32px; text-align: center\"\n                >\n                  <el-button\n                    type=\"primary\"\n                    :icon=\"VideoCamera\"\n                    :loading=\"generatingVideo\"\n                    :disabled=\"\n                      !selectedVideoModel ||\n                      (selectedReferenceMode !== 'none' &&\n                        selectedImagesForVideo.length === 0)\n                    \"\n                    @click=\"generateVideo\"\n                  >\n                    {{ generatingVideo ? \"生成中...\" : \"生成视频\" }}\n                  </el-button>\n                </div>\n\n                <!-- 生成的视频列表 -->\n                <div\n                  class=\"generation-result\"\n                  v-if=\"generatedVideos.length > 0\"\n                  style=\"margin-top: 24px\"\n                >\n                  <div\n                    class=\"section-label\"\n                    style=\"\n                      font-size: 13px;\n                      font-weight: 600;\n                      margin-bottom: 12px;\n                      display: flex;\n                      align-items: center;\n                      gap: 6px;\n                    \"\n                  >\n                    <span></span>\n                    生成结果 ({{ generatedVideos.length }})\n                  </div>\n                  <div class=\"image-grid\">\n                    <div\n                      v-for=\"video in generatedVideos\"\n                      :key=\"video.id\"\n                      class=\"image-item-wrapper\"\n                    >\n                      <div class=\"image-item video-item\">\n                        <div\n                          v-if=\"video.video_url\"\n                          class=\"video-thumbnail\"\n                          @click=\"playVideo(video)\"\n                        >\n                          <video :src=\"getVideoUrl(video)\" preload=\"metadata\" />\n                          <div class=\"play-overlay\">\n                            <el-icon :size=\"40\" color=\"#fff\">\n                              <VideoPlay />\n                            </el-icon>\n                          </div>\n                        </div>\n                        <div v-else class=\"image-placeholder\">\n                          <el-icon :size=\"32\">\n                            <VideoCamera />\n                          </el-icon>\n                          <p>{{ getStatusText(video.status) }}</p>\n                        </div>\n                        <!-- 视频操作按钮 -->\n                        <div class=\"video-actions\">\n                          <div\n                            v-if=\"video.status === 'completed'\"\n                            class=\"add-to-assets-button\"\n                            @click.stop=\"addVideoToAssets(video)\"\n                          >\n                            <el-icon\n                              :size=\"18\"\n                              color=\"var(--text-primary)\"\n                              v-if=\"!addingToAssets.has(video.id)\"\n                            >\n                              <FolderAdd />\n                            </el-icon>\n                            <el-icon\n                              :size=\"18\"\n                              color=\"var(--text-primary)\"\n                              v-else\n                              class=\"is-loading\"\n                            >\n                              <Loading />\n                            </el-icon>\n                          </div>\n                          <div v-else></div>\n                          <!-- 删除按钮 -->\n                          <div\n                            class=\"delete-video-button\"\n                            @click.stop=\"handleDeleteVideo(video)\"\n                          >\n                            <el-icon :size=\"18\" color=\"red\">\n                              <DeleteFilled />\n                            </el-icon>\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n            <el-empty v-else description=\"未选择镜头\" />\n          </el-tab-pane>\n\n          <!-- 音效与配乐标签 -->\n          <el-tab-pane :label=\"$t('video.soundAndMusicTab')\" name=\"audio\">\n            <div class=\"tab-content\">\n              <el-empty :description=\"$t('video.soundMusicInDev')\" />\n            </div>\n          </el-tab-pane>\n\n          <!-- 视频合成列表标签 -->\n          <el-tab-pane :label=\"$t('video.videoMerge')\" name=\"merges\">\n            <div class=\"tab-content\">\n              <div class=\"merges-list\" v-loading=\"loadingMerges\">\n                <el-empty\n                  v-if=\"videoMerges.length === 0\"\n                  :description=\"$t('video.noMergeRecords')\"\n                  :image-size=\"120\"\n                >\n                  <template #description>\n                    <div\n                      style=\"color: #909399; font-size: 14px; margin-top: 12px\"\n                    >\n                      <p style=\"margin: 0\">{{ $t(\"video.noMergeYet\") }}</p>\n                      <p style=\"margin: 8px 0 0 0; font-size: 12px\">\n                        {{ $t(\"video.mergeInstructions\") }}\n                      </p>\n                    </div>\n                  </template>\n                </el-empty>\n                <div v-else class=\"merge-items\">\n                  <div\n                    v-for=\"merge in videoMerges\"\n                    :key=\"merge.id\"\n                    class=\"merge-item\"\n                    :class=\"'merge-status-' + merge.status\"\n                  >\n                    <!-- 状态指示条 -->\n                    <div class=\"status-indicator\"></div>\n\n                    <!-- 主要内容区域 -->\n                    <div class=\"merge-content\">\n                      <!-- 标题和状态 -->\n                      <div class=\"merge-header\">\n                        <div class=\"title-section\">\n                          <el-icon :size=\"20\" class=\"title-icon\">\n                            <VideoCamera v-if=\"merge.status === 'completed'\" />\n                            <Loading\n                              v-else-if=\"merge.status === 'processing'\"\n                              class=\"rotating\"\n                            />\n                            <WarningFilled\n                              v-else-if=\"merge.status === 'failed'\"\n                            />\n                            <Clock v-else />\n                          </el-icon>\n                          <h3 class=\"merge-title\">{{ merge.title }}</h3>\n                        </div>\n                        <el-tag\n                          :type=\"\n                            merge.status === 'completed'\n                              ? 'success'\n                              : merge.status === 'failed'\n                                ? 'danger'\n                                : 'warning'\n                          \"\n                          effect=\"dark\"\n                          size=\"large\"\n                          round\n                        >\n                          {{\n                            merge.status === \"pending\"\n                              ? \"等待中\"\n                              : merge.status === \"processing\"\n                                ? \"合成中\"\n                                : merge.status === \"completed\"\n                                  ? \"已完成\"\n                                  : \"失败\"\n                          }}\n                        </el-tag>\n                      </div>\n\n                      <!-- 详细信息网格 -->\n                      <div class=\"merge-details\">\n                        <div class=\"detail-item\">\n                          <div class=\"detail-icon\">\n                            <el-icon :size=\"16\">\n                              <Timer />\n                            </el-icon>\n                          </div>\n                          <div class=\"detail-content\">\n                            <div class=\"detail-label\">\n                              {{ $t(\"professionalEditor.videoDuration\") }}\n                            </div>\n                            <div class=\"detail-value\">\n                              {{\n                                merge.duration\n                                  ? `${merge.duration}\n                              ${$t(\"professionalEditor.seconds\")}`\n                                  : \"-\"\n                              }}\n                            </div>\n                          </div>\n                        </div>\n                        <div class=\"detail-item\">\n                          <div class=\"detail-icon\">\n                            <el-icon :size=\"16\">\n                              <Calendar />\n                            </el-icon>\n                          </div>\n                          <div class=\"detail-content\">\n                            <div class=\"detail-label\">创建时间</div>\n                            <div class=\"detail-value\">\n                              {{ formatDateTime(merge.created_at) }}\n                            </div>\n                          </div>\n                        </div>\n                        <div class=\"detail-item\" v-if=\"merge.completed_at\">\n                          <div class=\"detail-icon\">\n                            <el-icon :size=\"16\">\n                              <Check />\n                            </el-icon>\n                          </div>\n                          <div class=\"detail-content\">\n                            <div class=\"detail-label\">完成时间</div>\n                            <div class=\"detail-value\">\n                              {{ formatDateTime(merge.completed_at) }}\n                            </div>\n                          </div>\n                        </div>\n                      </div>\n\n                      <!-- 错误提示 -->\n                      <div\n                        class=\"merge-error\"\n                        v-if=\"merge.status === 'failed' && merge.error_msg\"\n                      >\n                        <el-alert type=\"error\" :closable=\"false\" show-icon>\n                          <template #title>\n                            <div style=\"font-size: 13px; line-height: 1.5\">\n                              {{ merge.error_msg }}\n                            </div>\n                          </template>\n                        </el-alert>\n                      </div>\n\n                      <!-- 操作按钮 -->\n                      <div class=\"merge-actions\">\n                        <template\n                          v-if=\"\n                            merge.status === 'completed' && merge.merged_url\n                          \"\n                        >\n                          <el-button\n                            type=\"primary\"\n                            :icon=\"VideoCamera\"\n                            @click=\"\n                              downloadVideo(merge.merged_url, merge.title)\n                            \"\n                            round\n                          >\n                            下载视频\n                          </el-button>\n                          <el-button\n                            :icon=\"View\"\n                            @click=\"previewMergedVideo(merge.merged_url)\"\n                            round\n                          >\n                            在线预览\n                          </el-button>\n                        </template>\n                        <el-button\n                          type=\"danger\"\n                          :icon=\"Delete\"\n                          @click=\"deleteMerge(merge.id)\"\n                          round\n                        >\n                          删除\n                        </el-button>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </el-tab-pane>\n        </el-tabs>\n      </div>\n    </div>\n\n    <!-- 角色选择器对话框 -->\n    <el-dialog\n      v-model=\"showCharacterImagePreview\"\n      :title=\"previewCharacter?.name\"\n      width=\"600px\"\n    >\n      <div class=\"character-image-preview\" v-if=\"previewCharacter\">\n        <img\n          v-if=\"previewCharacter.local_path\"\n          :src=\"getImageUrl(previewCharacter)\"\n          :alt=\"previewCharacter.name\"\n        />\n        <el-empty v-else description=\"暂无图片\" />\n      </div>\n      <!-- ... -->\n    </el-dialog>\n\n    <!-- 场景大图预览对话框 -->\n    <el-dialog\n      v-model=\"showSceneImagePreview\"\n      :title=\"\n        currentStoryboard?.background\n          ? `${currentStoryboard.background.location} · ${currentStoryboard.background.time}`\n          : '场景预览'\n      \"\n      width=\"800px\"\n    >\n      <div\n        class=\"scene-image-preview\"\n        v-if=\"currentStoryboard?.background?.image_url\"\n      >\n        <img :src=\"currentStoryboard.background.image_url\" alt=\"场景\" />\n      </div>\n    </el-dialog>\n\n    <!-- 角色选择对话框 -->\n    <el-dialog\n      v-model=\"showCharacterSelector\"\n      title=\"添加角色到镜头\"\n      width=\"800px\"\n    >\n      <div class=\"character-selector-grid\">\n        <div\n          v-for=\"char in availableCharacters\"\n          :key=\"char.id\"\n          class=\"character-card\"\n          :class=\"{ selected: isCharacterInCurrentShot(char.id) }\"\n          @click=\"toggleCharacterInShot(char.id)\"\n        >\n          <div class=\"character-avatar-large\">\n            <img\n              v-if=\"char.local_path\"\n              :src=\"getImageUrl(char)\"\n              :alt=\"char.name\"\n            />\n            <span v-else>{{ char.name?.[0] || \"?\" }}</span>\n          </div>\n          <div class=\"character-info\">\n            <div class=\"character-name\">{{ char.name }}</div>\n            <div class=\"character-role\">{{ char.role || \"角色\" }}</div>\n          </div>\n          <div class=\"character-check\" v-if=\"isCharacterInCurrentShot(char.id)\">\n            <el-icon color=\"#409eff\" :size=\"24\">\n              <Check />\n            </el-icon>\n          </div>\n        </div>\n        <div v-if=\"availableCharacters.length === 0\" class=\"empty-characters\">\n          <el-empty description=\"暂无角色，请先在剧集中创建角色\" />\n        </div>\n      </div>\n      <template #footer>\n        <el-button @click=\"showCharacterSelector = false\">关闭</el-button>\n      </template>\n    </el-dialog>\n\n    <!-- 道具选择对话框 -->\n    <el-dialog\n      v-model=\"showPropSelector\"\n      :title=\"$t('editor.addPropToShot')\"\n      width=\"800px\"\n    >\n      <div class=\"character-selector-grid\">\n        <div\n          v-for=\"prop in availableProps\"\n          :key=\"prop.id\"\n          class=\"character-card\"\n          :class=\"{ selected: isPropInCurrentShot(prop.id) }\"\n          @click=\"togglePropInShot(prop.id)\"\n        >\n          <div class=\"character-avatar-large\">\n            <img\n              v-if=\"prop.local_path\"\n              :src=\"getImageUrl(prop)\"\n              :alt=\"prop.name\"\n            />\n            <el-icon v-else :size=\"32\">\n              <Box />\n            </el-icon>\n          </div>\n          <div class=\"character-info\">\n            <div class=\"character-name\">{{ prop.name }}</div>\n            <div class=\"character-role\">\n              {{ prop.type || $t(\"editor.props\") }}\n            </div>\n          </div>\n          <div class=\"character-check\" v-if=\"isPropInCurrentShot(prop.id)\">\n            <el-icon color=\"#409eff\" :size=\"24\">\n              <Check />\n            </el-icon>\n          </div>\n        </div>\n        <div v-if=\"availableProps.length === 0\" class=\"empty-characters\">\n          <el-empty :description=\"$t('editor.noPropsAvailable')\" />\n        </div>\n      </div>\n      <template #footer>\n        <el-button @click=\"showPropSelector = false\">{{\n          $t(\"common.close\")\n        }}</el-button>\n      </template>\n    </el-dialog>\n\n    <!-- 场景选择对话框 -->\n    <el-dialog v-model=\"showSceneSelector\" title=\"选择场景背景\" width=\"800px\">\n      <div class=\"scene-selector-grid\">\n        <div\n          v-for=\"scene in availableScenes\"\n          :key=\"scene.id\"\n          class=\"scene-card\"\n          :class=\"{ selected: currentStoryboard?.scene_id === scene.id }\"\n          @click=\"selectScene(scene.id)\"\n        >\n          <div class=\"scene-image\">\n            <img\n              v-if=\"hasImage(scene)\"\n              :src=\"getImageUrl(scene)\"\n              :alt=\"scene.location\"\n            />\n            <el-icon v-else :size=\"48\" color=\"#ccc\">\n              <Picture />\n            </el-icon>\n          </div>\n          <div class=\"scene-info\">\n            <div class=\"scene-location\">{{ scene.location }}</div>\n            <div class=\"scene-time\">{{ scene.time }}</div>\n          </div>\n        </div>\n        <div v-if=\"availableScenes.length === 0\" class=\"empty-scenes\">\n          <el-empty description=\"暂无可用场景\" />\n        </div>\n      </div>\n    </el-dialog>\n\n    <!-- 视频预览对话框 -->\n    <el-dialog\n      v-model=\"showVideoPreview\"\n      title=\"视频预览\"\n      width=\"800px\"\n      :close-on-click-modal=\"true\"\n      destroy-on-close\n    >\n      <div class=\"video-preview-container\" v-if=\"previewVideo\">\n        <video\n          v-if=\"previewVideo.video_url\"\n          :src=\"getVideoUrl(previewVideo)\"\n          controls\n          autoplay\n          style=\"\n            width: 100%;\n            max-height: 70vh;\n            display: block;\n            background: #000;\n            border-radius: 8px;\n          \"\n        />\n        <div v-else style=\"text-align: center; padding: 40px\">\n          <el-icon :size=\"48\" color=\"#ccc\">\n            <VideoCamera />\n          </el-icon>\n          <p style=\"margin-top: 16px; color: #909399\">视频生成中...</p>\n        </div>\n        <div class=\"video-meta\">\n          <div\n            style=\"\n              display: flex;\n              justify-content: space-between;\n              align-items: center;\n            \"\n          >\n            <div>\n              <el-tag :type=\"getStatusType(previewVideo.status)\" size=\"small\">{{\n                getStatusText(previewVideo.status)\n              }}</el-tag>\n              <span\n                v-if=\"previewVideo.duration\"\n                style=\"margin-left: 12px; color: #606266; font-size: 14px\"\n                >{{ $t(\"professionalEditor.duration\") }}:\n                {{ previewVideo.duration\n                }}{{ $t(\"professionalEditor.seconds\") }}</span\n              >\n            </div>\n            <el-button\n              v-if=\"previewVideo.video_url\"\n              size=\"small\"\n              @click=\"window.open(previewVideo.video_url, '_blank')\"\n            >\n              {{ $t(\"professionalEditor.downloadVideo\") }}\n            </el-button>\n          </div>\n          <div\n            v-if=\"previewVideo.prompt\"\n            style=\"\n              margin-top: 12px;\n              font-size: 12px;\n              color: #606266;\n              line-height: 1.6;\n            \"\n          >\n            <strong>提示词：</strong>{{ previewVideo.prompt }}\n          </div>\n        </div>\n      </div>\n    </el-dialog>\n\n    <!-- 宫格图片编辑器组件 -->\n    <GridImageEditor\n      v-model=\"showGridEditor\"\n      :storyboard-id=\"currentStoryboard?.id || 0\"\n      :drama-id=\"dramaId\"\n      :all-images=\"allGeneratedImages\"\n      @success=\"handleGridImageSuccess\"\n    />\n\n    <!-- 图片裁剪对话框 -->\n    <ImageCropDialog\n      v-model=\"showCropDialog\"\n      :image-url=\"cropImageUrl\"\n      @save=\"handleCropSave\"\n    />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport {\n  ref,\n  computed,\n  watch,\n  onMounted,\n  onBeforeUnmount,\n  nextTick,\n} from \"vue\";\nimport { useRoute, useRouter } from \"vue-router\";\nimport { useI18n } from \"vue-i18n\";\nimport { ElMessage, ElMessageBox } from \"element-plus\";\nimport {\n  ArrowLeft,\n  Plus,\n  Picture,\n  VideoPlay,\n  VideoPause,\n  View,\n  Setting,\n  Upload,\n  MagicStick,\n  VideoCamera,\n  ZoomIn,\n  ZoomOut,\n  Top,\n  Bottom,\n  Check,\n  Close,\n  Right,\n  Timer,\n  Calendar,\n  Clock,\n  Loading,\n  WarningFilled,\n  Delete,\n  Connection,\n  Box,\n  Crop,\n  FolderAdd,\n} from \"@element-plus/icons-vue\";\nimport { dramaAPI } from \"@/api/drama\";\nimport { propAPI } from \"@/api/prop\";\nimport { generateFramePrompt, type FrameType } from \"@/api/frame\";\nimport { imageAPI } from \"@/api/image\";\nimport { videoAPI } from \"@/api/video\";\nimport { aiAPI } from \"@/api/ai\";\nimport { assetAPI } from \"@/api/asset\";\nimport { videoMergeAPI } from \"@/api/videoMerge\";\nimport { taskAPI } from \"@/api/task\";\nimport type { ImageGeneration } from \"@/types/image\";\nimport type { VideoGeneration } from \"@/types/video\";\nimport type { AIServiceConfig } from \"@/types/ai\";\nimport type { Asset } from \"@/types/asset\";\nimport type { VideoMerge } from \"@/api/videoMerge\";\nimport VideoTimelineEditor from \"@/components/editor/VideoTimelineEditor.vue\";\nimport GridImageEditor from \"@/components/editor/GridImageEditor.vue\";\nimport type { Drama, Episode, Storyboard } from \"@/types/drama\";\nimport { AppHeader, ImageCropDialog } from \"@/components/common\";\nimport { getImageUrl, hasImage, getVideoUrl } from \"@/utils/image\";\n\nconst route = useRoute();\nconst router = useRouter();\nconst { t: $t } = useI18n();\n\nconst dramaId = Number(route.params.dramaId);\nconst episodeNumber = Number(route.params.episodeNumber);\nconst episodeId = ref<number>(0);\n\nconst drama = ref<Drama | null>(null);\nconst episode = ref<Episode | null>(null);\nconst storyboards = ref<Storyboard[]>([]);\nconst characters = ref<any[]>([]);\nconst availableScenes = ref<any[]>([]);\nconst props = ref<any[]>([]);\nconst showPropSelector = ref(false);\n\nconst currentStoryboardId = ref<string | null>(null);\nconst activeTab = ref(\"shot\");\nconst showSceneSelector = ref(false);\nconst showCharacterSelector = ref(false);\nconst showCharacterImagePreview = ref(false);\nconst previewCharacter = ref<any>(null);\nconst showSceneImagePreview = ref(false);\nconst showSettings = ref(false);\nconst showVideoPreview = ref(false);\nconst previewVideo = ref<VideoGeneration | null>(null);\nconst addingToAssets = ref<Set<number>>(new Set());\n\nconst currentPlayState = ref<\"playing\" | \"paused\">(\"paused\");\nconst currentTime = ref(0);\nconst totalDuration = computed(() => {\n  if (!Array.isArray(storyboards.value)) return 0;\n  return storyboards.value.reduce((sum, s) => sum + (s.duration || 0), 0);\n});\n\nconst selectedCharacters = ref<number[]>([]);\nconst narrativeTab = ref(\"shot-prompt\");\n\n// 图片生成相关状态\nconst selectedFrameType = ref<FrameType>(\"first\");\nconst panelCount = ref(3);\nconst generatingPromptStates = ref<Record<string, boolean>>({}); // 按 \"镜头ID_帧类型\" 记录生成状态\nconst framePrompts = ref<Record<string, string>>({\n  key: \"\",\n  first: \"\",\n  last: \"\",\n  panel: \"\",\n});\nconst currentFramePrompt = ref(\"\");\nconst generatingImage = ref(false);\nconst generatedImages = ref<ImageGeneration[]>([]);\nconst isSwitchingFrameType = ref(false); // 标志位：是否正在切换帧类型\nconst loadingImages = ref(false);\nlet pollingTimer: any = null;\nlet pollingFrameType: FrameType | null = null; // 记录正在轮询的帧类型\n\n// 宫格图片编辑器状态\nconst showGridEditor = ref(false);\n\n// 所有已生成的图片（用于宫格编辑器选择）\nconst allGeneratedImages = ref<ImageGeneration[]>([]);\n\n// 图片裁剪对话框状态\nconst showCropDialog = ref(false);\nconst cropImageUrl = ref<string>(\"\");\nconst cropImageData = ref<ImageGeneration | null>(null);\n\n// 视频生成相关状态\nconst videoDuration = ref(5); // 默认5秒，会根据镜头duration自动更新\nconst selectedVideoFrameType = ref<FrameType>(\"first\");\nconst selectedImagesForVideo = ref<number[]>([]);\nconst selectedLastImageForVideo = ref<number | null>(null);\nconst generatingVideo = ref(false);\nconst generatedVideos = ref<VideoGeneration[]>([]);\nconst videoAssets = ref<Asset[]>([]);\nconst loadingVideos = ref(false);\nconst timelineEditorRef = ref<InstanceType<typeof VideoTimelineEditor> | null>(\n  null,\n);\nconst videoReferenceImages = ref<ImageGeneration[]>([]);\nconst selectedVideoModel = ref<string>(\"\");\nconst selectedReferenceMode = ref<string>(\"\"); // 参考图模式：single, first_last, multiple, none\nconst previewImageUrl = ref<string>(\"\"); // 预览大图的URL\nconst videoModelCapabilities = ref<VideoModelCapability[]>([]);\nlet videoPollingTimer: any = null;\nlet mergePollingTimer: any = null; // 视频合成列表轮询定时器\n\n// 视频合成列表\nconst videoMerges = ref<VideoMerge[]>([]);\nconst loadingMerges = ref(false);\n\n// 视频模型能力配置\ninterface VideoModelCapability {\n  id: string;\n  name: string;\n  supportMultipleImages: boolean; // 支持多张图片\n  supportFirstLastFrame: boolean; // 支持首尾帧\n  supportSingleImage: boolean; // 支持单图\n  supportTextOnly: boolean; // 支持纯文本\n  maxImages: number; // 最多支持几张图片\n}\n\n// 模型能力默认配置（作为后备）\nconst defaultModelCapabilities: Record<\n  string,\n  Omit<VideoModelCapability, \"id\" | \"name\">\n> = {\n  kling: {\n    supportSingleImage: true,\n    supportMultipleImages: false,\n    supportFirstLastFrame: false,\n    supportTextOnly: true,\n    maxImages: 1,\n  },\n  runway: {\n    supportSingleImage: true,\n    supportMultipleImages: false,\n    supportFirstLastFrame: true,\n    supportTextOnly: true,\n    maxImages: 2,\n  },\n  pika: {\n    supportSingleImage: true,\n    supportMultipleImages: true,\n    supportFirstLastFrame: false,\n    supportTextOnly: true,\n    maxImages: 6,\n  },\n  \"doubao-seedance-1-5-pro-251215\": {\n    supportSingleImage: true,\n    supportMultipleImages: false,\n    supportFirstLastFrame: true,\n    supportTextOnly: true,\n    maxImages: 2,\n  },\n  \"doubao-seedance-1-0-lite-i2v-250428\": {\n    supportSingleImage: true,\n    supportMultipleImages: true,\n    supportFirstLastFrame: true,\n    supportTextOnly: false,\n    maxImages: 6,\n  },\n  \"doubao-seedance-1-0-lite-t2v-250428\": {\n    supportSingleImage: false,\n    supportMultipleImages: false,\n    supportFirstLastFrame: false,\n    supportTextOnly: true,\n    maxImages: 0,\n  },\n  \"doubao-seedance-1-0-pro-250528\": {\n    supportSingleImage: true,\n    supportMultipleImages: false,\n    supportFirstLastFrame: true,\n    supportTextOnly: true,\n    maxImages: 2,\n  },\n  \"doubao-seedance-1-0-pro-fast-251015\": {\n    supportSingleImage: true,\n    supportMultipleImages: false,\n    supportFirstLastFrame: false,\n    supportTextOnly: true,\n    maxImages: 1,\n  },\n  \"sora-2\": {\n    supportSingleImage: true,\n    supportMultipleImages: false,\n    supportFirstLastFrame: false,\n    supportTextOnly: true,\n    maxImages: 1,\n  },\n  \"sora-2-pro\": {\n    supportSingleImage: true,\n    supportMultipleImages: false,\n    supportFirstLastFrame: true,\n    supportTextOnly: true,\n    maxImages: 2,\n  },\n  \"MiniMax-Hailuo-2.3\": {\n    supportSingleImage: true,\n    supportMultipleImages: false,\n    supportFirstLastFrame: false,\n    supportTextOnly: true,\n    maxImages: 1,\n  },\n  \"MiniMax-Hailuo-2.3-Fast\": {\n    supportSingleImage: true,\n    supportMultipleImages: false,\n    supportFirstLastFrame: false,\n    supportTextOnly: true,\n    maxImages: 1,\n  },\n  \"MiniMax-Hailuo-02\": {\n    supportSingleImage: true,\n    supportMultipleImages: false,\n    supportFirstLastFrame: false,\n    supportTextOnly: true,\n    maxImages: 1,\n  },\n};\n\n// 从模型名称提取provider\nconst extractProviderFromModel = (modelName: string): string => {\n  if (modelName.startsWith(\"doubao-\") || modelName.startsWith(\"seedance\")) {\n    return \"doubao\";\n  }\n  if (modelName.startsWith(\"runway\")) {\n    return \"runway\";\n  }\n  if (modelName.startsWith(\"pika\")) {\n    return \"pika\";\n  }\n  if (\n    modelName.startsWith(\"MiniMax-\") ||\n    modelName.toLowerCase().startsWith(\"minimax\") ||\n    modelName.startsWith(\"hailuo\")\n  ) {\n    return \"minimax\";\n  }\n  if (modelName.startsWith(\"sora\")) {\n    return \"openai\";\n  }\n  if (modelName.startsWith(\"kling\")) {\n    return \"kling\";\n  }\n\n  // 默认返回doubao\n  return \"doubao\";\n};\n\n// 加载视频AI配置\nconst loadVideoModels = async () => {\n  try {\n    const configs = await aiAPI.list(\"video\");\n\n    // 只显示启用的配置\n    const activeConfigs = configs.filter((c) => c.is_active);\n\n    // 展开模型列表并去重\n    const allModels = activeConfigs\n      .flatMap((config) => {\n        const models = Array.isArray(config.model)\n          ? config.model\n          : [config.model];\n        return models.map((modelName) => ({\n          modelName,\n          configName: config.name,\n          priority: config.priority || 0,\n        }));\n      })\n      .sort((a, b) => b.priority - a.priority);\n\n    // 按模型名称去重\n    const modelMap = new Map<\n      string,\n      { configName: string; priority: number }\n    >();\n    allModels.forEach((model) => {\n      if (!modelMap.has(model.modelName)) {\n        modelMap.set(model.modelName, {\n          configName: model.configName,\n          priority: model.priority,\n        });\n      }\n    });\n\n    // 构建模型能力列表\n    videoModelCapabilities.value = Array.from(modelMap.keys()).map(\n      (modelName) => {\n        const capability = defaultModelCapabilities[modelName] || {\n          supportSingleImage: true,\n          supportMultipleImages: false,\n          supportFirstLastFrame: false,\n          supportTextOnly: true,\n          maxImages: 1,\n        };\n\n        return {\n          id: modelName,\n          name: modelName,\n          ...capability,\n        };\n      },\n    );\n  } catch (error: any) {\n    console.error(\"加载视频模型配置失败:\", error);\n    ElMessage.error(\"加载视频模型失败\");\n  }\n};\n\n// 加载视频素材库\nconst loadVideoAssets = async () => {\n  try {\n    const result = await assetAPI.listAssets({\n      drama_id: dramaId.toString(),\n      episode_id: episodeId.value,\n      type: \"video\",\n      page: 1,\n      page_size: 100,\n    });\n    // 检查数据结构并正确赋值\n    videoAssets.value = result.items || [];\n  } catch (error: any) {\n    console.error(\"加载视频素材库失败:\", error);\n  }\n};\n\n// 当前模型能力\nconst currentModelCapability = computed(() => {\n  return videoModelCapabilities.value.find(\n    (m) => m.id === selectedVideoModel.value,\n  );\n});\n\n// 当前模型支持的参考图模式\nconst availableReferenceModes = computed(() => {\n  const capability = currentModelCapability.value;\n  if (!capability) return [];\n\n  const modes: Array<{ value: string; label: string; description?: string }> =\n    [];\n\n  if (capability.supportTextOnly) {\n    modes.push({ value: \"none\", label: \"纯文本\", description: \"不使用参考图\" });\n  }\n  if (capability.supportSingleImage) {\n    modes.push({\n      value: \"single\",\n      label: \"单图\",\n      description: \"使用单张参考图\",\n    });\n  }\n  if (capability.supportFirstLastFrame) {\n    modes.push({\n      value: \"first_last\",\n      label: \"首尾帧\",\n      description: \"使用首帧和尾帧\",\n    });\n  }\n  if (capability.supportMultipleImages) {\n    modes.push({\n      value: \"multiple\",\n      label: \"多图\",\n      description: `最多${capability.maxImages}张`,\n    });\n  }\n\n  return modes;\n});\n\n// 帧提示词存储key生成函数\nconst getPromptStorageKey = (\n  storyboardId: number | undefined,\n  frameType: FrameType,\n) => {\n  if (!storyboardId) return null;\n  return `frame_prompt_${storyboardId}_${frameType}`;\n};\n\nconst isCharacterSelected = (charId: number) => {\n  return selectedCharacters.value.includes(charId);\n};\n\nconst toggleCharacter = (charId: number) => {\n  const index = selectedCharacters.value.indexOf(charId);\n  if (index > -1) {\n    selectedCharacters.value.splice(index, 1);\n  } else {\n    selectedCharacters.value.push(charId);\n  }\n};\n\nconst currentStoryboard = computed(() => {\n  if (!currentStoryboardId.value) return null;\n  return (\n    storyboards.value.find(\n      (s) => String(s.id) === String(currentStoryboardId.value),\n    ) || null\n  );\n});\n\n// 获取上一个镜头\nconst previousStoryboard = computed(() => {\n  if (!currentStoryboardId.value || storyboards.value.length < 2) return null;\n  const currentIndex = storyboards.value.findIndex(\n    (s) => String(s.id) === String(currentStoryboardId.value),\n  );\n  if (currentIndex <= 0) return null;\n  return storyboards.value[currentIndex - 1];\n});\n\n// 上一个镜头的尾帧图片列表（支持多个）\nconst previousStoryboardLastFrames = ref<any[]>([]);\n\n// 加载上一个镜头的尾帧\nconst loadPreviousStoryboardLastFrame = async () => {\n  if (!previousStoryboard.value) {\n    previousStoryboardLastFrames.value = [];\n    return;\n  }\n  try {\n    const result = await imageAPI.listImages({\n      storyboard_id: previousStoryboard.value.id,\n      frame_type: \"last\",\n      page: 1,\n      page_size: 10,\n    });\n    const images = result.items || [];\n    previousStoryboardLastFrames.value = images.filter(\n      (img: any) => img.status === \"completed\" && img.image_url,\n    );\n  } catch (error) {\n    console.error(\"加载上一镜头尾帧失败:\", error);\n    previousStoryboardLastFrames.value = [];\n  }\n};\n\n// 选择上一镜头尾帧作为首帧参考\nconst selectPreviousLastFrame = (img: any) => {\n  // 检查是否已选中，已选中则取消\n  const currentIndex = selectedImagesForVideo.value.indexOf(img.id);\n  if (currentIndex > -1) {\n    selectedImagesForVideo.value.splice(currentIndex, 1);\n    ElMessage.success(\"已取消首帧参考\");\n    return;\n  }\n\n  // 参考handleImageSelect的逻辑，根据模式处理\n  if (\n    !selectedReferenceMode.value ||\n    selectedReferenceMode.value === \"single\"\n  ) {\n    // 单图模式或未选模式：直接替换\n    selectedImagesForVideo.value = [img.id];\n  } else if (selectedReferenceMode.value === \"first_last\") {\n    // 首尾帧模式：作为首帧参考\n    selectedImagesForVideo.value = [img.id];\n  } else if (selectedReferenceMode.value === \"multiple\") {\n    // 多图模式：添加到列表\n    const capability = currentModelCapability.value;\n    if (\n      capability &&\n      selectedImagesForVideo.value.length >= capability.maxImages\n    ) {\n      ElMessage.warning(`最多只能选择${capability.maxImages}张图片`);\n      return;\n    }\n    selectedImagesForVideo.value.push(img.id);\n  }\n  ElMessage.success(\"已添加为首帧参考\");\n};\n\n// 监听帧类型切换，从存储中加载或清空\nwatch(selectedFrameType, (newType) => {\n  // 切换帧类型时，停止之前的轮询，避免旧结果覆盖新帧类型\n  stopPolling();\n\n  if (!currentStoryboard.value) {\n    currentFramePrompt.value = \"\";\n    generatedImages.value = [];\n    return;\n  }\n\n  // 设置切换标志，防止watch(currentFramePrompt)错误保存\n  isSwitchingFrameType.value = true;\n\n  // 优先从 sessionStorage 中加载该帧类型的提示词（确保数据准确）\n  const storageKey = `frame_prompt_${currentStoryboard.value.id}_${newType}`;\n  const stored = sessionStorage.getItem(storageKey);\n\n  if (stored) {\n    currentFramePrompt.value = stored;\n    framePrompts.value[newType] = stored;\n  } else {\n    // 如果 sessionStorage 中没有，再尝试从 framePrompts 对象中读取\n    currentFramePrompt.value = framePrompts.value[newType] || \"\";\n  }\n\n  // 重新加载该帧类型的图片\n  loadStoryboardImages(currentStoryboard.value.id, newType);\n\n  // 重置切换标志\n  setTimeout(() => {\n    isSwitchingFrameType.value = false;\n  }, 0);\n});\n\n// 监听当前分镜切换，重置提示词\nwatch(currentStoryboard, async (newStoryboard) => {\n  if (!newStoryboard) {\n    currentFramePrompt.value = \"\";\n    generatedImages.value = [];\n    generatedVideos.value = [];\n    videoReferenceImages.value = [];\n    previousStoryboardLastFrames.value = [];\n    return;\n  }\n\n  // 设置切换标志\n  isSwitchingFrameType.value = true;\n\n  // 清空 framePrompts 对象，避免显示上一个镜头的提示词\n  framePrompts.value = {\n    key: \"\",\n    first: \"\",\n    last: \"\",\n    panel: \"\",\n  };\n\n  // 加载当前帧类型的提示词\n  const storageKey = getPromptStorageKey(\n    newStoryboard.id,\n    selectedFrameType.value,\n  );\n  if (storageKey) {\n    const stored = sessionStorage.getItem(storageKey);\n    currentFramePrompt.value = stored || \"\";\n    // 同时更新 framePrompts 对象\n    if (stored) {\n      framePrompts.value[selectedFrameType.value] = stored;\n    }\n  } else {\n    currentFramePrompt.value = \"\";\n  }\n\n  // 重置切换标志\n  setTimeout(() => {\n    isSwitchingFrameType.value = false;\n  }, 0);\n\n  // 加载该分镜的图片列表（根据当前选择的帧类型）\n  await loadStoryboardImages(newStoryboard.id, selectedFrameType.value);\n\n  // 加载所有已生成的图片（用于宫格编辑器）\n  await loadAllGeneratedImages();\n\n  // 加载视频参考图片（所有帧类型）\n  await loadVideoReferenceImages(newStoryboard.id);\n\n  // 加载该分镜的视频列表\n  await loadStoryboardVideos(newStoryboard.id);\n\n  // 加载上一镜头的尾帧\n  await loadPreviousStoryboardLastFrame();\n});\n\n// 监听提示词变化，自动保存到sessionStorage\nwatch(currentFramePrompt, (newPrompt) => {\n  // 如果正在切换帧类型或分镜，不要保存（避免错误保存到新帧类型）\n  if (isSwitchingFrameType.value) return;\n  if (!currentStoryboard.value) return;\n\n  const storageKey = getPromptStorageKey(\n    currentStoryboard.value.id,\n    selectedFrameType.value,\n  );\n  if (storageKey) {\n    if (newPrompt) {\n      sessionStorage.setItem(storageKey, newPrompt);\n    } else {\n      sessionStorage.removeItem(storageKey);\n    }\n  }\n});\n\n// 监听视频模型切换，清空已选图片和参考图模式\nwatch(selectedVideoModel, () => {\n  selectedImagesForVideo.value = [];\n  selectedLastImageForVideo.value = null;\n  selectedReferenceMode.value = \"\";\n});\n\n// 监听镜头切换，自动更新视频时长\nwatch(currentStoryboard, (newStoryboard) => {\n  if (newStoryboard?.duration) {\n    // 如果镜头有duration字段，使用镜头的时长\n    videoDuration.value = Math.round(newStoryboard.duration);\n  } else {\n    // 否则使用默认值5秒\n    videoDuration.value = 5;\n  }\n});\n\n// 监听参考图模式切换，清空已选图片\nwatch(selectedReferenceMode, () => {\n  selectedImagesForVideo.value = [];\n  selectedLastImageForVideo.value = null;\n});\n\n// 当前分镜的角色列表\nconst currentStoryboardCharacters = computed(() => {\n  if (!currentStoryboard.value?.characters) return [];\n\n  // currentStoryboard.characters 是角色对象数组\n  if (\n    Array.isArray(currentStoryboard.value.characters) &&\n    currentStoryboard.value.characters.length > 0\n  ) {\n    const firstItem = currentStoryboard.value.characters[0];\n    // 如果是对象数组（包含id和name），直接返回\n    if (typeof firstItem === \"object\" && firstItem.id) {\n      return currentStoryboard.value.characters;\n    }\n    // 如果是ID数组，从characters中查找匹配的角色\n    if (typeof firstItem === \"number\") {\n      return characters.value.filter((c) =>\n        currentStoryboard.value.characters.includes(c.id),\n      );\n    }\n  }\n\n  return [];\n});\n\n// 可选择的角色列表\nconst availableCharacters = computed(() => {\n  return characters.value || [];\n});\n\n// 可选择的道具列表\nconst availableProps = computed(() => {\n  return props.value || [];\n});\n\n// 当前分镜的道具列表\nconst currentStoryboardProps = computed(() => {\n  if (!currentStoryboard.value?.props) return [];\n  return currentStoryboard.value.props;\n});\n\n// 检查道具是否在当前镜头中\nconst isPropInCurrentShot = (propId: number) => {\n  if (!currentStoryboard.value?.props) return false;\n  return currentStoryboard.value.props.some((p: any) => p.id === propId);\n};\n\n// 切换道具在镜头中的状态\nconst togglePropInShot = async (propId: number) => {\n  if (!currentStoryboard.value) return;\n\n  let newProps = [...(currentStoryboard.value.props || [])];\n  if (isPropInCurrentShot(propId)) {\n    newProps = newProps.filter((p: any) => p.id !== propId);\n  } else {\n    const prop = props.value.find((p) => p.id === propId);\n    if (prop) {\n      newProps.push(prop);\n    }\n  }\n\n  // 乐观更新\n  currentStoryboard.value.props = newProps;\n\n  try {\n    const propIds = newProps.map((p: any) => p.id);\n    await propAPI.associateWithStoryboard(\n      Number(currentStoryboard.value.id),\n      propIds,\n    );\n  } catch (error) {\n    ElMessage.error($t(\"editor.updatePropFailed\"));\n  }\n};\n\n// 检查角色是否已在当前镜头中\nconst isCharacterInCurrentShot = (charId: number) => {\n  if (!currentStoryboard.value?.characters) return false;\n\n  if (\n    Array.isArray(currentStoryboard.value.characters) &&\n    currentStoryboard.value.characters.length > 0\n  ) {\n    const firstItem = currentStoryboard.value.characters[0];\n    if (typeof firstItem === \"object\" && firstItem.id) {\n      return currentStoryboard.value.characters.some((c) => c.id === charId);\n    }\n    if (typeof firstItem === \"number\") {\n      return currentStoryboard.value.characters.includes(charId);\n    }\n  }\n\n  return false;\n};\n\n// 切换角色在镜头中的状态\nconst showCharacterImage = (char: any) => {\n  previewCharacter.value = char;\n  showCharacterImagePreview.value = true;\n};\n\n// 展示场景大图\nconst showSceneImage = () => {\n  if (currentStoryboard.value?.background?.image_url) {\n    showSceneImagePreview.value = true;\n  }\n};\n\n// 保存分镜字段\nconst saveStoryboardField = async (fieldName: string) => {\n  if (!currentStoryboard.value) return;\n  try {\n    const updateData: any = {};\n    updateData[fieldName] = currentStoryboard.value[fieldName];\n\n    await dramaAPI.updateStoryboard(\n      currentStoryboard.value.id.toString(),\n      updateData,\n    );\n  } catch (error: any) {\n    ElMessage.error(\"保存失败: \" + (error.message || \"未知错误\"));\n  }\n};\n\n// 提取帧提示词\n// 提取帧提示词\nconst extractFramePrompt = async () => {\n  if (!currentStoryboard.value) return;\n\n  const storyboardId = currentStoryboard.value.id;\n  // 记录点击时的帧类型，后续任务完成时用于判断是否需要更新当前显示\n  const targetFrameType = selectedFrameType.value;\n\n  if (targetFrameType === \"panel\") {\n    // 如果是分镜板模式，还需要捕获当前的panelCount\n    // 注意：这里简单起见使用当前的panelCount，理想情况下应该传递参数或锁定UI\n  }\n\n  // 设置当前镜头的生成状态为true\n  const stateKey = `${storyboardId}_${targetFrameType}`;\n  generatingPromptStates.value[stateKey] = true;\n\n  try {\n    const params: any = { frame_type: targetFrameType };\n    if (targetFrameType === \"panel\") {\n      params.panel_count = panelCount.value;\n    }\n\n    const { task_id } = await generateFramePrompt(storyboardId, params);\n\n    // 轮询任务状态（独立函数，不依赖组件当前状态）\n    const pollTask = async () => {\n      while (true) {\n        const task = await taskAPI.getStatus(task_id);\n        if (task.status === \"completed\") {\n          let result = task.result;\n          if (typeof result === \"string\") {\n            try {\n              result = JSON.parse(result);\n            } catch (e) {\n              console.error(\"Failed to parse task result\", e);\n              throw new Error(\"解析任务结果失败\");\n            }\n          }\n          return result.response;\n        } else if (task.status === \"failed\") {\n          throw new Error(task.message || task.error || \"生成失败\");\n        }\n        // 等待1秒后继续轮询\n        await new Promise((resolve) => setTimeout(resolve, 1000));\n      }\n    };\n\n    const result = await pollTask();\n\n    // 根据返回结果构建提示词字符串\n    let extractedPrompt = \"\";\n    if (result.single_frame) {\n      extractedPrompt = result.single_frame.prompt;\n    } else if (result.multi_frame && result.multi_frame.frames) {\n      // 多帧情况，将所有帧的prompt合并\n      extractedPrompt = result.multi_frame.frames\n        .map((frame: any) => frame.prompt)\n        .join(\"\\n\\n\");\n    }\n\n    // 更新存储（这一步必须做，无论用户是否还在当前页面）\n    // 更新 session storage\n    const storageKey = getPromptStorageKey(storyboardId, targetFrameType);\n    if (storageKey) {\n      sessionStorage.setItem(storageKey, extractedPrompt);\n    }\n\n    // 如果任务完成时，用户当前的选中状态正好是该镜头+该类型，则立即更新显示\n    if (\n      currentStoryboard.value &&\n      currentStoryboard.value.id === storyboardId &&\n      selectedFrameType.value === targetFrameType\n    ) {\n      currentFramePrompt.value = extractedPrompt;\n      framePrompts.value[targetFrameType] = extractedPrompt;\n    }\n\n    // 更新内存缓存（稍微复杂点，framePrompts 是响应式的且绑定当前镜头，这里只做sessionStorage持久化即可，\n    // 因为切换镜头时会重新读取sessionStorage。\n    // 但为了确保如果用户没切走也能看到，上面已经更新了 currentFramePrompt\n\n    ElMessage.success(`${getFrameTypeLabel(targetFrameType)}提示词提取成功`);\n  } catch (error: any) {\n    ElMessage.error(\"提取失败: \" + (error.message || \"未知错误\"));\n  } finally {\n    // 清除该镜头的生成状态\n    const stateKey = `${storyboardId}_${targetFrameType}`;\n    if (generatingPromptStates.value[stateKey]) {\n      generatingPromptStates.value[stateKey] = false;\n    }\n  }\n};\n\n// 检查是否正在生成提示词\nconst isGeneratingPrompt = (\n  storyboardId: number | undefined,\n  frameType: string,\n) => {\n  if (!storyboardId) return false;\n  return !!generatingPromptStates.value[`${storyboardId}_${frameType}`];\n};\n\n// 获取帧类型的中文标签\nconst getFrameTypeLabel = (frameType: string): string => {\n  const labels: Record<string, string> = {\n    key: \"关键帧\",\n    first: \"首帧\",\n    last: \"尾帧\",\n    panel: \"分镜版\",\n  };\n  return labels[frameType] || frameType;\n};\n\n// 加载分镜的图片列表\nconst loadStoryboardImages = async (\n  storyboardId: string | number,\n  frameType?: string,\n) => {\n  loadingImages.value = true;\n  try {\n    const params: any = {\n      storyboard_id: storyboardId,\n      page: 1,\n      page_size: 50,\n    };\n    // 如果指定了帧类型，添加过滤\n    if (frameType) {\n      params.frame_type = frameType;\n    }\n    const result = await imageAPI.listImages(params);\n    generatedImages.value = result.items || [];\n\n    // 如果有进行中的任务，启动轮询\n    const hasPendingOrProcessing = generatedImages.value.some(\n      (img) => img.status === \"pending\" || img.status === \"processing\",\n    );\n    if (hasPendingOrProcessing) {\n      startPolling();\n    }\n  } catch (error: any) {\n    console.error(\"加载图片列表失败:\", error);\n  } finally {\n    loadingImages.value = false;\n  }\n};\n\n// 启动状态轮询\nconst startPolling = () => {\n  if (pollingTimer) return;\n\n  // 记录开始轮询时的帧类型\n  pollingFrameType = selectedFrameType.value;\n\n  pollingTimer = setInterval(async () => {\n    if (!currentStoryboard.value) {\n      stopPolling();\n      return;\n    }\n\n    // 如果帧类型已切换，停止轮询（防止更新到错误的帧类型）\n    if (selectedFrameType.value !== pollingFrameType) {\n      stopPolling();\n      return;\n    }\n\n    try {\n      const params: any = {\n        storyboard_id: currentStoryboard.value.id,\n        page: 1,\n        page_size: 50,\n      };\n      // 使用轮询开始时记录的帧类型\n      if (pollingFrameType) {\n        params.frame_type = pollingFrameType;\n      }\n      const result = await imageAPI.listImages(params);\n\n      // 再次检查帧类型是否仍然匹配，避免竞态条件\n      if (selectedFrameType.value === pollingFrameType) {\n        generatedImages.value = result.items || [];\n      }\n\n      // 如果没有进行中的任务，停止轮询并刷新视频参考图片\n      const hasPendingOrProcessing = (result.items || []).some(\n        (img: any) => img.status === \"pending\" || img.status === \"processing\",\n      );\n      if (!hasPendingOrProcessing) {\n        stopPolling();\n        // 刷新视频参考图片列表\n        if (currentStoryboard.value) {\n          loadVideoReferenceImages(currentStoryboard.value.id);\n        }\n      }\n    } catch (error) {\n      console.error(\"轮询图片状态失败:\", error);\n    }\n  }, 3000); // 每3秒轮询一次\n};\n\n// 停止轮询\nconst stopPolling = () => {\n  if (pollingTimer) {\n    clearInterval(pollingTimer);\n    pollingTimer = null;\n  }\n  pollingFrameType = null;\n};\n\n// 生成图片\nconst generateFrameImage = async () => {\n  if (!currentStoryboard.value || !currentFramePrompt.value) return;\n\n  generatingImage.value = true;\n  try {\n    // 收集参考图片的 local_path\n    const referenceImages: string[] = [];\n\n    // 1. 添加场景图片（从background字段获取 local_path）\n    if (currentStoryboard.value.background?.local_path) {\n      referenceImages.push(currentStoryboard.value.background.local_path);\n    }\n\n    // 2. 添加当前镜头登场的角色图片（使用 local_path）\n    const storyboardCharacters = currentStoryboardCharacters.value;\n    if (storyboardCharacters && storyboardCharacters.length > 0) {\n      storyboardCharacters.forEach((char: any) => {\n        if (char.local_path) {\n          referenceImages.push(char.local_path);\n        }\n      });\n    }\n\n    const result = await imageAPI.generateImage({\n      drama_id: dramaId.toString(),\n      prompt: currentFramePrompt.value,\n      storyboard_id: currentStoryboard.value.id,\n      image_type: \"storyboard\",\n      frame_type: selectedFrameType.value,\n      reference_images:\n        referenceImages.length > 0 ? referenceImages : undefined,\n    });\n\n    generatedImages.value.unshift(result);\n\n    // 提示信息\n    const refMsg =\n      referenceImages.length > 0\n        ? ` (已添加${referenceImages.length}张参考图)`\n        : \"\";\n    ElMessage.success(`图片生成任务已提交${refMsg}`);\n\n    // 启动轮询\n    startPolling();\n  } catch (error: any) {\n    ElMessage.error(\"生成失败: \" + (error.message || \"未知错误\"));\n  } finally {\n    generatingImage.value = false;\n  }\n};\n\n// 获取状态标签类型\nconst getStatusType = (status: string) => {\n  const statusMap: Record<string, any> = {\n    pending: \"info\",\n    processing: \"warning\",\n    completed: \"success\",\n    failed: \"danger\",\n  };\n  return statusMap[status] || \"info\";\n};\n\n// 播放视频\nconst playVideo = (video: VideoGeneration) => {\n  previewVideo.value = video;\n  showVideoPreview.value = true;\n};\n\n// 添加视频到素材库\nconst addVideoToAssets = async (video: VideoGeneration) => {\n  if (video.status !== \"completed\" || !video.video_url) {\n    ElMessage.warning(\"只能添加已完成的视频到素材库\");\n    return;\n  }\n\n  addingToAssets.value.add(video.id);\n\n  try {\n    // 检查该镜头是否已存在素材\n    let isReplacing = false;\n    if (video.storyboard_id) {\n      const existingAsset = videoAssets.value.find(\n        (asset: any) => asset.storyboard_id === video.storyboard_id,\n      );\n\n      if (existingAsset) {\n        isReplacing = true;\n        // 自动替换：先删除旧素材\n        try {\n          await assetAPI.deleteAsset(existingAsset.id);\n        } catch (error) {\n          console.error(\"删除旧素材失败:\", error);\n        }\n      }\n    }\n\n    // 添加新素材\n    await assetAPI.importFromVideo(video.id);\n    ElMessage.success(\"已添加到素材库\");\n\n    // 重新加载素材库列表\n    await loadVideoAssets();\n\n    // 如果是替换操作，更新时间线中使用该分镜的所有视频片段\n    if (isReplacing && video.storyboard_id && video.video_url) {\n      console.log(\"=== 视频替换，准备更新时间线 ===\");\n      console.log(\"timelineEditorRef.value:\", timelineEditorRef.value);\n      console.log(\"video.storyboard_id:\", video.storyboard_id);\n      console.log(\"video.video_url:\", video.video_url);\n\n      if (timelineEditorRef.value) {\n        timelineEditorRef.value.updateClipsByStoryboardId(\n          video.storyboard_id,\n          video.video_url,\n        );\n      } else {\n        console.warn(\"⚠️ timelineEditorRef.value 为空，无法更新时间线\");\n      }\n    }\n  } catch (error: any) {\n    ElMessage.error(error.message || \"添加失败\");\n  } finally {\n    addingToAssets.value.delete(video.id);\n  }\n};\n\n// 删除视频\nconst handleDeleteVideo = async (video: VideoGeneration) => {\n  if (!currentStoryboard.value) return;\n\n  try {\n    await ElMessageBox.confirm(\n      \"确定要删除这个视频吗？删除后无法恢复。\",\n      \"确认删除\",\n      {\n        confirmButtonText: \"确定\",\n        cancelButtonText: \"取消\",\n        type: \"warning\",\n      },\n    );\n\n    await videoAPI.deleteVideo(video.id);\n    ElMessage.success(\"删除成功\");\n\n    // 重新加载当前镜头的视频列表\n    await loadStoryboardVideos(Number(currentStoryboard.value.id));\n  } catch (error: any) {\n    if (error !== \"cancel\") {\n      console.error(\"删除视频失败:\", error);\n      ElMessage.error(error.message || \"删除失败\");\n    }\n  }\n};\n\n// 获取状态中文文本\nconst getStatusText = (status: string) => {\n  const statusTextMap: Record<string, string> = {\n    pending: \"等待中\",\n    processing: \"生成中\",\n    completed: \"已完成\",\n    failed: \"失败\",\n  };\n  return statusTextMap[status] || status;\n};\n\n// 获取帧类型中文文本\nconst getFrameTypeText = (frameType?: string) => {\n  if (!frameType) return \"\";\n  const frameTypeMap: Record<string, string> = {\n    first: \"首帧\",\n    key: \"关键帧\",\n    last: \"尾帧\",\n    panel: \"分镜板\",\n    action: \"动作序列\",\n  };\n  return frameTypeMap[frameType] || frameType;\n};\n\n// 获取分镜缩略图\nconst getStoryboardThumbnail = (storyboard: any) => {\n  // 优先使用composed_image\n  if (storyboard.composed_image) {\n    return storyboard.composed_image;\n  }\n\n  // 如果没有composed_image，从image_url字段获取\n  if (storyboard.image_url) {\n    return storyboard.image_url;\n  }\n\n  return null;\n};\n\n// 处理图片选择（根据模型能力）\nconst handleImageSelect = (imageId: number) => {\n  if (!selectedReferenceMode.value) {\n    ElMessage.warning(\"请先选择参考图模式\");\n    return;\n  }\n\n  if (!currentModelCapability.value) {\n    ElMessage.warning(\"请先选择视频生成模型\");\n    return;\n  }\n\n  const capability = currentModelCapability.value;\n  const currentIndex = selectedImagesForVideo.value.indexOf(imageId);\n\n  // 已选中，则取消选择\n  if (currentIndex > -1) {\n    selectedImagesForVideo.value.splice(currentIndex, 1);\n    return;\n  }\n\n  // 获取当前点击的图片对象\n  const clickedImage = videoReferenceImages.value.find(\n    (img) => img.id === imageId,\n  );\n  if (!clickedImage) return;\n\n  // 根据选择的参考图模式处理\n  switch (selectedReferenceMode.value) {\n    case \"single\":\n      // 单图模式：只能选1张，直接替换\n      selectedImagesForVideo.value = [imageId];\n      break;\n\n    case \"first_last\":\n      // 首尾帧模式：根据图片类型分别处理\n      const frameType = clickedImage.frame_type;\n\n      if (\n        frameType === \"first\" ||\n        frameType === \"panel\" ||\n        frameType === \"key\"\n      ) {\n        // 首帧：直接替换\n        selectedImagesForVideo.value = [imageId];\n      } else if (frameType === \"last\") {\n        // 尾帧：设置到单独的变量\n        selectedLastImageForVideo.value = imageId;\n      } else {\n        ElMessage.warning(\"首尾帧模式下，请选择首帧或尾帧类型的图片\");\n      }\n      break;\n\n    case \"multiple\":\n      // 多图模式：检查是否超出最大数量\n      if (selectedImagesForVideo.value.length >= capability.maxImages) {\n        ElMessage.warning(`最多只能选择${capability.maxImages}张图片`);\n        return;\n      }\n      selectedImagesForVideo.value.push(imageId);\n      break;\n\n    default:\n      ElMessage.warning(\"未知的参考图模式\");\n  }\n};\n\n// 预览图片（使用已导入的 getImageUrl 工具函数来获取正确的图片URL）\nconst previewImage = (url: string) => {\n  // 使用Element Plus的图片预览\n  const viewer = document.createElement(\"div\");\n  viewer.innerHTML = `\n    <div style=\"position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center;\" onclick=\"this.remove()\">\n      <img src=\"${url}\" style=\"max-width: 90vw; max-height: 90vh; object-fit: contain;\" onclick=\"event.stopPropagation();\" />\n    </div>\n  `;\n  document.body.appendChild(viewer);\n};\n\n// 获取已选图片对象列表\nconst selectedImageObjects = computed(() => {\n  return selectedImagesForVideo.value\n    .map((id) => videoReferenceImages.value.find((img) => img.id === id))\n    .filter((img) => img && img.image_url);\n});\n\n// 首尾帧模式：获取首帧图片\nconst firstFrameSlotImage = computed(() => {\n  if (selectedImagesForVideo.value.length === 0) return null;\n  const firstImageId = selectedImagesForVideo.value[0];\n  // 同时搜索当前镜头图片和上一镜头尾帧\n  return (\n    videoReferenceImages.value.find((img) => img.id === firstImageId) ||\n    previousStoryboardLastFrames.value.find((img) => img.id === firstImageId)\n  );\n});\n\n// 首尾帧模式：获取尾帧图片\nconst lastFrameSlotImage = computed(() => {\n  if (!selectedLastImageForVideo.value) return null;\n  // 同时搜索当前镜头图片和上一镜头尾帧\n  return (\n    videoReferenceImages.value.find(\n      (img) => img.id === selectedLastImageForVideo.value,\n    ) ||\n    previousStoryboardLastFrames.value.find(\n      (img) => img.id === selectedLastImageForVideo.value,\n    )\n  );\n});\n\n// 移除已选择的图片\nconst removeSelectedImage = (imageId: number) => {\n  // 检查是否是尾帧\n  if (selectedLastImageForVideo.value === imageId) {\n    selectedLastImageForVideo.value = null;\n    return;\n  }\n\n  // 检查是否是首帧或其他图片\n  const index = selectedImagesForVideo.value.indexOf(imageId);\n  if (index > -1) {\n    selectedImagesForVideo.value.splice(index, 1);\n  }\n};\n\n// 生成视频\nconst generateVideo = async () => {\n  if (!selectedVideoModel.value) {\n    ElMessage.warning(\"请先选择视频生成模型\");\n    return;\n  }\n\n  if (!currentStoryboard.value) {\n    ElMessage.warning(\"请先选择分镜\");\n    return;\n  }\n\n  // 检查参考图模式\n  if (\n    selectedReferenceMode.value !== \"none\" &&\n    selectedImagesForVideo.value.length === 0\n  ) {\n    ElMessage.warning(\"请选择参考图片\");\n    return;\n  }\n\n  // 获取第一张选中的图片（仅在需要图片的模式下）\n  let selectedImage = null;\n  if (\n    selectedReferenceMode.value !== \"none\" &&\n    selectedImagesForVideo.value.length > 0\n  ) {\n    // 同时搜索当前镜头图片和上一镜头尾帧\n    selectedImage =\n      videoReferenceImages.value.find(\n        (img) => img.id === selectedImagesForVideo.value[0],\n      ) ||\n      previousStoryboardLastFrames.value.find(\n        (img) => img.id === selectedImagesForVideo.value[0],\n      );\n    if (!selectedImage || !selectedImage.image_url) {\n      ElMessage.error(\"请选择有效的参考图片\");\n      return;\n    }\n  }\n\n  generatingVideo.value = true;\n  try {\n    // 从模型名称提取正确的provider\n    const provider = extractProviderFromModel(selectedVideoModel.value);\n\n    // 构建请求参数\n    const requestParams: any = {\n      drama_id: dramaId.toString(),\n      storyboard_id: currentStoryboard.value.id,\n      prompt:\n        currentStoryboard.value.video_prompt ||\n        currentStoryboard.value.action ||\n        currentStoryboard.value.description ||\n        \"\",\n      duration: videoDuration.value,\n      provider: provider,\n      model: selectedVideoModel.value,\n      reference_mode: selectedReferenceMode.value,\n    };\n\n    // 根据参考图模式设置参数\n    switch (selectedReferenceMode.value) {\n      case \"single\":\n        // 单图模式 - 优先使用 local_path\n        if (selectedImage.local_path) {\n          requestParams.image_local_path = selectedImage.local_path;\n        } else if (selectedImage.image_url) {\n          requestParams.image_url = selectedImage.image_url;\n        }\n        requestParams.image_gen_id = selectedImage.id;\n        break;\n\n      case \"first_last\":\n        // 首尾帧模式（同时搜索当前镜头图片和上一镜头尾帧）\n        const firstImage =\n          videoReferenceImages.value.find(\n            (img) => img.id === selectedImagesForVideo.value[0],\n          ) ||\n          previousStoryboardLastFrames.value.find(\n            (img) => img.id === selectedImagesForVideo.value[0],\n          );\n        const lastImage =\n          videoReferenceImages.value.find(\n            (img) => img.id === selectedLastImageForVideo.value,\n          ) ||\n          previousStoryboardLastFrames.value.find(\n            (img) => img.id === selectedLastImageForVideo.value,\n          );\n\n        // 优先使用 local_path\n        if (firstImage?.local_path) {\n          requestParams.first_frame_local_path = firstImage.local_path;\n        } else if (firstImage?.image_url) {\n          requestParams.first_frame_url = firstImage.image_url;\n        }\n        if (lastImage?.local_path) {\n          requestParams.last_frame_local_path = lastImage.local_path;\n        } else if (lastImage?.image_url) {\n          requestParams.last_frame_url = lastImage.image_url;\n        }\n        break;\n\n      case \"multiple\":\n        // 多图模式 - 优先使用 local_path\n        const selectedImages = selectedImagesForVideo.value\n          .map((id) => videoReferenceImages.value.find((img) => img.id === id))\n          .filter((img) => img?.local_path || img?.image_url)\n          .map((img) => img!.local_path || img!.image_url);\n        requestParams.reference_image_urls = selectedImages;\n        break;\n\n      case \"none\":\n        // 无参考图模式\n        break;\n    }\n\n    const result = await videoAPI.generateVideo(requestParams);\n\n    generatedVideos.value.unshift(result);\n    ElMessage.success(\"视频生成任务已提交\");\n\n    // 启动视频轮询\n    startVideoPolling();\n  } catch (error: any) {\n    ElMessage.error(\"生成失败: \" + (error.message || \"未知错误\"));\n  } finally {\n    generatingVideo.value = false;\n  }\n};\n\n// 加载分镜的视频参考图片（所有帧类型）\nconst loadVideoReferenceImages = async (storyboardId: number) => {\n  try {\n    const result = await imageAPI.listImages({\n      storyboard_id: storyboardId,\n      page: 1,\n      page_size: 100,\n    });\n    videoReferenceImages.value = result.items || [];\n  } catch (error: any) {\n    console.error(\"加载视频参考图片失败:\", error);\n  }\n};\n\n// 加载分镜的视频列表\nconst loadStoryboardVideos = async (storyboardId: number) => {\n  loadingVideos.value = true;\n  try {\n    const result = await videoAPI.listVideos({\n      storyboard_id: storyboardId.toString(),\n      page: 1,\n      page_size: 50,\n    });\n    generatedVideos.value = result.items || [];\n\n    // 如果有进行中的任务，启动轮询\n    const hasPendingOrProcessing = generatedVideos.value.some(\n      (v) => v.status === \"pending\" || v.status === \"processing\",\n    );\n    if (hasPendingOrProcessing) {\n      startVideoPolling();\n    }\n  } catch (error: any) {\n    console.error(\"加载视频列表失败:\", error);\n  } finally {\n    loadingVideos.value = false;\n  }\n};\n\n// 启动视频状态轮询\nconst startVideoPolling = () => {\n  if (videoPollingTimer) return;\n\n  videoPollingTimer = setInterval(async () => {\n    if (!currentStoryboard.value) {\n      stopVideoPolling();\n      return;\n    }\n\n    try {\n      // 保存旧的视频列表用于对比\n      const oldVideos = [...generatedVideos.value];\n\n      const result = await videoAPI.listVideos({\n        storyboard_id: currentStoryboard.value.id.toString(),\n        page: 1,\n        page_size: 50,\n      });\n      generatedVideos.value = result.items || [];\n\n      // 检测是否有视频从 processing 变为 completed\n      const hasNewlyCompleted = generatedVideos.value.some((newVideo) => {\n        const oldVideo = oldVideos.find((v) => v.id === newVideo.id);\n        return (\n          oldVideo &&\n          (oldVideo.status === \"pending\" || oldVideo.status === \"processing\") &&\n          newVideo.status === \"completed\"\n        );\n      });\n\n      // 如果有视频完成，重新加载分镜列表以更新 duration\n      if (hasNewlyCompleted && episodeId.value) {\n        try {\n          const storyboardsRes = await dramaAPI.getStoryboards(\n            episodeId.value.toString(),\n          );\n          storyboards.value = storyboardsRes?.storyboards || [];\n        } catch (error) {\n          console.error(\"重新加载分镜列表失败:\", error);\n        }\n      }\n\n      // 如果没有进行中的任务，停止轮询\n      const hasPendingOrProcessing = generatedVideos.value.some(\n        (v) => v.status === \"pending\" || v.status === \"processing\",\n      );\n      if (!hasPendingOrProcessing) {\n        stopVideoPolling();\n      }\n    } catch (error) {\n      console.error(\"轮询视频状态失败:\", error);\n    }\n  }, 5000); // 每5秒轮询一次\n};\n\n// 停止视频轮询\nconst stopVideoPolling = () => {\n  if (videoPollingTimer) {\n    clearInterval(videoPollingTimer);\n    videoPollingTimer = null;\n  }\n};\n\nconst toggleCharacterInShot = async (charId: number) => {\n  if (!currentStoryboard.value) return;\n\n  // 初始化characters数组\n  if (!currentStoryboard.value.characters) {\n    currentStoryboard.value.characters = [];\n  }\n\n  const char = characters.value.find((c) => c.id === charId);\n  if (!char) return;\n\n  // 检查是否已存在\n  const existIndex = currentStoryboard.value.characters.findIndex((c) =>\n    typeof c === \"object\" ? c.id === charId : c === charId,\n  );\n\n  if (existIndex > -1) {\n    // 移除角色\n    currentStoryboard.value.characters.splice(existIndex, 1);\n  } else {\n    // 添加角色（作为对象）\n    currentStoryboard.value.characters.push(char);\n  }\n\n  // 保存到后端\n  try {\n    const characterIds = currentStoryboard.value.characters.map((c) =>\n      typeof c === \"object\" ? c.id : c,\n    );\n\n    await dramaAPI.updateStoryboard(currentStoryboard.value.id.toString(), {\n      character_ids: characterIds,\n    });\n\n    if (existIndex > -1) {\n      ElMessage.success(`已移除角色: ${char.name}`);\n    } else {\n      ElMessage.success(`已添加角色: ${char.name}`);\n    }\n  } catch (error: any) {\n    ElMessage.error(\"保存失败: \" + (error.message || \"未知错误\"));\n    // 回滚操作\n    if (existIndex > -1) {\n      currentStoryboard.value.characters.push(char);\n    } else {\n      currentStoryboard.value.characters.splice(\n        currentStoryboard.value.characters.length - 1,\n        1,\n      );\n    }\n  }\n};\n\nconst removeCharacterFromShot = async (charId: number) => {\n  if (!currentStoryboard.value) return;\n\n  // 初始化characters数组\n  if (!currentStoryboard.value.characters) {\n    currentStoryboard.value.characters = [];\n  }\n\n  const char = characters.value.find((c) => c.id === charId);\n  if (!char) return;\n\n  // 检查是否已存在\n  const existIndex = currentStoryboard.value.characters.findIndex((c) =>\n    typeof c === \"object\" ? c.id === charId : c === charId,\n  );\n\n  if (existIndex > -1) {\n    // 移除角色\n    currentStoryboard.value.characters.splice(existIndex, 1);\n  }\n\n  // 保存到后端\n  try {\n    const characterIds = currentStoryboard.value.characters.map((c) =>\n      typeof c === \"object\" ? c.id : c,\n    );\n\n    await dramaAPI.updateStoryboard(currentStoryboard.value.id.toString(), {\n      character_ids: characterIds,\n    });\n\n    ElMessage.success(`已移除角色: ${char.name}`);\n  } catch (error: any) {\n    ElMessage.error(\"保存失败: \" + (error.message || \"未知错误\"));\n    // 回滚操作\n    currentStoryboard.value.characters.push(char);\n  }\n};\n\nconst loadData = async () => {\n  try {\n    // 加载剧集信息\n    const dramaRes = await dramaAPI.get(dramaId.toString());\n    drama.value = dramaRes;\n\n    // 找到当前章节\n    const ep = dramaRes.episodes?.find(\n      (e) => e.episode_number === episodeNumber,\n    );\n    if (!ep) {\n      ElMessage.error(\"章节不存在\");\n      router.back();\n      return;\n    }\n\n    episode.value = ep;\n    episodeId.value = ep.id;\n\n    // 加载分镜列表\n    const storyboardsRes = await dramaAPI.getStoryboards(ep.id.toString());\n\n    // API返回格式: {storyboards: [...], total: number}\n    storyboards.value = storyboardsRes?.storyboards || [];\n\n    // 默认选中第一个分镜\n    if (storyboards.value.length > 0 && !currentStoryboardId.value) {\n      currentStoryboardId.value = storyboards.value[0].id;\n    }\n\n    // 加载角色列表\n    characters.value = dramaRes.characters || [];\n\n    // 加载可用场景列表\n    availableScenes.value = dramaRes.scenes || [];\n\n    // 加载道具列表\n    props.value = dramaRes.props || [];\n\n    // 加载视频素材库\n    await loadVideoAssets();\n  } catch (error: any) {\n    ElMessage.error(\"加载数据失败: \" + (error.message || \"未知错误\"));\n  }\n};\n\nconst selectScene = async (sceneId: number) => {\n  if (!currentStoryboard.value) return;\n\n  try {\n    // TODO: 调用API更新分镜的scene_id\n    await dramaAPI.updateStoryboard(currentStoryboard.value.id.toString(), {\n      scene_id: sceneId,\n    });\n\n    // 重新加载数据\n    await loadData();\n    showSceneSelector.value = false;\n    ElMessage.success(\"场景关联成功\");\n  } catch (error: any) {\n    ElMessage.error(error.message || \"场景关联失败\");\n  }\n};\n\nconst selectStoryboard = (id: string) => {\n  currentStoryboardId.value = id;\n};\n\nconst handleTimelineSelect = (sceneId: number) => {\n  selectStoryboard(String(sceneId));\n};\n\nconst togglePlay = () => {\n  if (currentPlayState.value === \"playing\") {\n    currentPlayState.value = \"paused\";\n  } else {\n    currentPlayState.value = \"playing\";\n  }\n};\n\nconst formatTime = (seconds: number) => {\n  const mins = Math.floor(seconds / 60);\n  const secs = Math.floor(seconds % 60);\n  return `${mins.toString().padStart(2, \"0\")}:${secs.toString().padStart(2, \"0\")}`;\n};\n\nconst zoomIn = () => {\n  ElMessage.info(\"时间线缩放功能开发中\");\n};\n\nconst zoomOut = () => {\n  ElMessage.info(\"时间线缩放功能开发中\");\n};\n\nconst generateImage = async () => {\n  if (!currentStoryboard.value) return;\n\n  try {\n    ElMessage.info(\"图片生成功能开发中\");\n  } catch (error: any) {\n    ElMessage.error(error.message || \"生成失败\");\n  }\n};\n\nconst uploadImage = () => {\n  if (!currentStoryboard.value) {\n    ElMessage.warning(\"请先选择镜头\");\n    return;\n  }\n\n  // 创建隐藏的文件输入\n  const input = document.createElement(\"input\");\n  input.type = \"file\";\n  input.accept = \"image/*\";\n  input.onchange = async (e: Event) => {\n    const target = e.target as HTMLInputElement;\n    const file = target.files?.[0];\n    if (!file) return;\n\n    // 验证文件大小 (10MB)\n    if (file.size > 10 * 1024 * 1024) {\n      ElMessage.error(\"图片大小不能超过 10MB\");\n      return;\n    }\n\n    try {\n      // 创建 FormData\n      const formData = new FormData();\n      formData.append(\"file\", file);\n\n      // 上传到服务器\n      const response = await fetch(\"/api/v1/upload/image\", {\n        method: \"POST\",\n        body: formData,\n      });\n\n      if (!response.ok) {\n        throw new Error(\"上传失败\");\n      }\n\n      const result = await response.json();\n      const imageUrl = result.data?.url;\n\n      if (imageUrl && currentStoryboard.value) {\n        // 创建图片生成记录（关联到当前镜头和帧类型）\n        await imageAPI.uploadImage({\n          storyboard_id: currentStoryboard.value.id,\n          drama_id: parseInt(dramaId),\n          frame_type: selectedFrameType.value || \"first\",\n          image_url: imageUrl,\n          prompt: currentFramePrompt.value || \"用户上传图片\",\n        });\n\n        // 刷新图片列表\n        await loadStoryboardImages(\n          currentStoryboard.value.id,\n          selectedFrameType.value,\n        );\n\n        ElMessage.success(\"图片上传成功\");\n      }\n    } catch (error: any) {\n      console.error(\"上传图片失败:\", error);\n      ElMessage.error(error.message || \"上传失败\");\n    }\n  };\n  input.click();\n};\n\n// 删除图片\nconst handleDeleteImage = async (img: ImageGeneration) => {\n  if (!currentStoryboard.value) return;\n\n  try {\n    await ElMessageBox.confirm(\"确定要删除这张图片吗？\", \"确认删除\", {\n      confirmButtonText: \"确定\",\n      cancelButtonText: \"取消\",\n      type: \"warning\",\n    });\n\n    await imageAPI.deleteImage(img.id);\n    ElMessage.success(\"删除成功\");\n\n    // 重新加载当前帧类型的图片列表\n    await loadStoryboardImages(\n      currentStoryboard.value.id,\n      selectedFrameType.value,\n    );\n  } catch (error: any) {\n    if (error !== \"cancel\") {\n      console.error(\"删除图片失败:\", error);\n      ElMessage.error(error.message || \"删除失败\");\n    }\n  }\n};\n\n// 加载所有已生成的图片（用于宫格编辑器）\nconst loadAllGeneratedImages = async () => {\n  if (!currentStoryboard.value) return;\n\n  try {\n    const result = await imageAPI.listImages({\n      storyboard_id: currentStoryboard.value.id,\n      page: 1,\n      page_size: 100,\n    });\n    allGeneratedImages.value = result.items || [];\n  } catch (error: any) {\n    console.error(\"加载所有图片失败:\", error);\n  }\n};\n\n// 处理宫格图片创建成功\nconst handleGridImageSuccess = async () => {\n  if (currentStoryboard.value) {\n    // 刷新动作序列图片列表\n    await loadStoryboardImages(currentStoryboard.value.id, \"action\");\n    // 重新加载所有图片\n    await loadAllGeneratedImages();\n  }\n};\n\n// 打开裁剪对话框\nconst openCropDialog = (img: ImageGeneration) => {\n  cropImageData.value = img;\n  cropImageUrl.value = getImageUrl(img) || \"\";\n  showCropDialog.value = true;\n};\n\n// 处理裁剪保存\nconst handleCropSave = async (images: { blob: Blob; frameType: string }[]) => {\n  if (!currentStoryboard.value || !cropImageData.value) return;\n\n  try {\n    // 将 Blob 转换为 base64 data URL\n    const convertBlobToBase64 = (blob: Blob): Promise<string> => {\n      return new Promise((resolve, reject) => {\n        const reader = new FileReader();\n        reader.onloadend = () => resolve(reader.result as string);\n        reader.onerror = reject;\n        reader.readAsDataURL(blob);\n      });\n    };\n\n    // 上传裁剪后的图片并创建新的图片生成记录\n    for (const img of images) {\n      // 将 Blob 转换为 base64\n      const imageUrl = await convertBlobToBase64(img.blob);\n\n      // 调用上传接口\n      await imageAPI.uploadImage({\n        storyboard_id: currentStoryboard.value.id,\n        drama_id: Number(dramaId),\n        frame_type: img.frameType,\n        image_url: imageUrl,\n        prompt: cropImageData.value.prompt || \"\",\n      });\n    }\n\n    ElMessage.success(\"裁剪图片保存成功\");\n\n    // 刷新图片列表 - 刷新所有帧类型的图片，确保新裁剪的图片能在视频生成tab中被选择到\n    if (currentStoryboard.value) {\n      // 刷新当前镜头的所有图片（不限制帧类型）\n      await loadStoryboardImages(currentStoryboard.value.id);\n      // 刷新所有生成的图片列表\n      await loadAllGeneratedImages();\n    }\n  } catch (error) {\n    console.error(\"Failed to save cropped images:\", error);\n    ElMessage.error(\"保存裁剪图片失败\");\n  }\n};\n\nconst goBack = () => {\n  router.replace({\n    name: \"EpisodeWorkflowNew\",\n    params: { id: dramaId, episodeNumber },\n  });\n};\n\nconst handleAddStoryboard = async () => {\n  if (!episodeId.value) return;\n\n  try {\n    const nextShotNumber =\n      storyboards.value.length > 0\n        ? Math.max(...storyboards.value.map((s) => s.storyboard_number)) + 1\n        : 1;\n\n    await dramaAPI.createStoryboard({\n      episode_id: parseInt(episodeId.value),\n      storyboard_number: nextShotNumber,\n      title: `镜头 ${nextShotNumber}`,\n      description: \"新镜头描述\",\n      action: \"动作描述\",\n      dialogue: \"\",\n      duration: 5,\n      scene_id:\n        storyboards.value.length > 0\n          ? storyboards.value[storyboards.value.length - 1].scene_id\n          : undefined,\n    });\n\n    ElMessage.success(\"添加分镜成功\");\n    await loadData(); // Refresh list\n\n    // Select the new storyboard (the last one)\n    if (storyboards.value.length > 0) {\n      selectStoryboard(storyboards.value[storyboards.value.length - 1].id);\n    }\n  } catch (error: any) {\n    console.error(\"添加分镜失败:\", error);\n    ElMessage.error(error.message || \"添加分镜失败\");\n  }\n};\n\nconst handleDeleteStoryboard = async (storyboard: any) => {\n  try {\n    await ElMessageBox.confirm(\n      `确定要删除镜头 ${storyboard.storyboard_number} 吗？此操作不可恢复。`,\n      \"删除确认\",\n      {\n        confirmButtonText: \"确定\",\n        cancelButtonText: \"取消\",\n        type: \"warning\",\n      },\n    );\n\n    await dramaAPI.deleteStoryboard(storyboard.id);\n    ElMessage.success(\"删除分镜成功\");\n\n    // If deleted current storyboard, clear selection or select another\n    if (currentStoryboardId.value === storyboard.id) {\n      currentStoryboardId.value = undefined;\n      currentStoryboard.value = undefined;\n    }\n\n    await loadData();\n  } catch (error: any) {\n    if (error !== \"cancel\") {\n      console.error(\"删除分镜失败:\", error);\n      ElMessage.error(error.message || \"删除分镜失败\");\n    }\n  }\n};\n\n// 加载视频合成列表\nconst loadVideoMerges = async () => {\n  if (!episodeId.value) return;\n\n  try {\n    loadingMerges.value = true;\n    const result = await videoMergeAPI.listMerges({\n      episode_id: episodeId.value.toString(),\n      page: 1,\n      page_size: 20,\n    });\n    videoMerges.value = result.merges;\n\n    // 检查是否有进行中的任务\n    const hasProcessingTasks = result.merges.some(\n      (merge: any) =>\n        merge.status === \"pending\" || merge.status === \"processing\",\n    );\n\n    if (hasProcessingTasks) {\n      startMergePolling();\n    } else {\n      stopMergePolling();\n    }\n  } catch (error: any) {\n    console.error(\"加载视频合成列表失败:\", error);\n    ElMessage.error(\"加载视频合成列表失败\");\n  } finally {\n    loadingMerges.value = false;\n  }\n};\n\n// 启动视频合成列表轮询\nconst startMergePolling = () => {\n  if (mergePollingTimer) return;\n\n  mergePollingTimer = setInterval(async () => {\n    if (!episodeId.value) {\n      stopMergePolling();\n      return;\n    }\n\n    try {\n      const result = await videoMergeAPI.listMerges({\n        episode_id: episodeId.value.toString(),\n        page: 1,\n        page_size: 20,\n      });\n      videoMerges.value = result.merges;\n\n      // 检查是否还有进行中的任务\n      const hasProcessingTasks = result.merges.some(\n        (merge: any) =>\n          merge.status === \"pending\" || merge.status === \"processing\",\n      );\n\n      if (!hasProcessingTasks) {\n        stopMergePolling();\n      }\n    } catch (error) {}\n  }, 3000); // 每3秒轮询一次\n};\n\n// 停止视频合成列表轮询\nconst stopMergePolling = () => {\n  if (mergePollingTimer) {\n    clearInterval(mergePollingTimer);\n    mergePollingTimer = null;\n  }\n};\n\n// 处理视频合成完成事件\nconst handleMergeCompleted = async (mergeId: number) => {\n  // 刷新视频合成列表\n  await loadVideoMerges();\n  // 切换到视频合成标签页\n  activeTab.value = \"merges\";\n};\n\n// 下载视频\nconst downloadVideo = async (url: string, title: string) => {\n  try {\n    const loadingMsg = ElMessage.info({\n      message: \"正在准备下载...\",\n      duration: 0,\n    });\n\n    // 处理相对路径，添加 /static/ 前缀\n    const videoUrl = url.startsWith(\"http\") ? url : `/static/${url}`;\n\n    // 使用fetch获取视频blob\n    const response = await fetch(videoUrl);\n    if (!response.ok) {\n      throw new Error(`HTTP error! status: ${response.status}`);\n    }\n\n    const blob = await response.blob();\n    const blobUrl = window.URL.createObjectURL(blob);\n\n    // 创建下载链接\n    const link = document.createElement(\"a\");\n    link.href = blobUrl;\n    link.download = `${title}.mp4`;\n    link.style.display = \"none\";\n    document.body.appendChild(link);\n    link.click();\n\n    // 清理\n    setTimeout(() => {\n      document.body.removeChild(link);\n      window.URL.revokeObjectURL(blobUrl);\n    }, 100);\n\n    loadingMsg.close();\n    ElMessage.success(\"视频下载已开始\");\n  } catch (error) {\n    console.error(\"下载视频失败:\", error);\n    ElMessage.error(\"视频下载失败，请稍后重试\");\n  }\n};\n\n// 预览合成视频\nconst previewMergedVideo = (url: string) => {\n  // 处理相对路径，添加 /static/ 前缀\n  const videoUrl = url.startsWith(\"http\") ? url : `/static/${url}`;\n  window.open(videoUrl, \"_blank\");\n};\n\n// 删除视频合成记录\nconst deleteMerge = async (mergeId: number) => {\n  try {\n    await ElMessageBox.confirm(\n      \"确定要删除此合成记录吗？此操作不可恢复。\",\n      \"删除确认\",\n      {\n        confirmButtonText: \"确定\",\n        cancelButtonText: \"取消\",\n        type: \"warning\",\n      },\n    );\n\n    await videoMergeAPI.deleteMerge(mergeId);\n    ElMessage.success(\"删除成功\");\n    // 刷新列表\n    await loadVideoMerges();\n  } catch (error: any) {\n    if (error !== \"cancel\") {\n      console.error(\"删除失败:\", error);\n      ElMessage.error(error.response?.data?.message || \"删除失败\");\n    }\n  }\n};\n\n// 格式化日期时间\nconst formatDateTime = (dateStr: string) => {\n  const date = new Date(dateStr);\n  const now = new Date();\n  const diff = now.getTime() - date.getTime();\n  const minutes = Math.floor(diff / 60000);\n  const hours = Math.floor(diff / 3600000);\n  const days = Math.floor(diff / 86400000);\n\n  if (minutes < 1) return \"刚刚\";\n  if (minutes < 60) return `${minutes}分钟前`;\n  if (hours < 24) return `${hours}小时前`;\n  if (days < 7) return `${days}天前`;\n\n  // 超过7天显示完整日期\n  const month = String(date.getMonth() + 1).padStart(2, \"0\");\n  const day = String(date.getDate()).padStart(2, \"0\");\n  const hour = String(date.getHours()).padStart(2, \"0\");\n  const minute = String(date.getMinutes()).padStart(2, \"0\");\n  return `${month}-${day} ${hour}:${minute}`;\n};\n\nonMounted(async () => {\n  await loadData();\n  await loadVideoModels();\n  await loadVideoMerges();\n});\n\n// 组件卸载时停止轮询\nonBeforeUnmount(() => {\n  stopPolling();\n  stopVideoPolling();\n  stopMergePolling();\n});\n</script>\n\n<style scoped lang=\"scss\">\n// 镜头列表项样式\n.storyboard-item {\n  padding: 8px;\n  cursor: pointer;\n  border-radius: 6px;\n  transition: all 0.2s;\n  border: 1px solid var(--border-primary);\n  margin-bottom: 8px;\n  display: flex;\n  gap: 10px;\n  align-items: center;\n  background: var(--bg-card);\n\n  &:hover {\n    background: var(--bg-card-hover);\n    border-color: var(--border-secondary);\n  }\n\n  &.active {\n    background: var(--accent);\n    border-color: var(--accent);\n\n    .shot-number,\n    .shot-title {\n      color: var(--text-inverse) !important;\n    }\n\n    .shot-duration {\n      background: rgba(255, 255, 255, 0.2);\n      color: var(--text-inverse);\n    }\n  }\n\n  .shot-thumbnail {\n    width: 80px;\n    height: 50px;\n    border-radius: 4px;\n    overflow: hidden;\n    background: var(--bg-secondary);\n    flex-shrink: 0;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n\n    img {\n      width: 100%;\n      height: 100%;\n      object-fit: cover;\n    }\n  }\n\n  .shot-content {\n    flex: 1;\n    min-width: 0;\n\n    .shot-header {\n      display: flex;\n      justify-content: space-between;\n      align-items: center;\n      margin-bottom: 4px;\n\n      .shot-number {\n        font-size: 11px;\n        color: var(--text-secondary);\n        font-weight: 500;\n      }\n\n      .shot-duration {\n        font-size: 11px;\n        color: var(--text-secondary);\n        background: var(--bg-secondary);\n        padding: 2px 6px;\n        border-radius: 3px;\n      }\n    }\n\n    .shot-title {\n      font-size: 13px;\n      color: var(--text-primary);\n      font-weight: 500;\n      line-height: 1.3;\n      overflow: hidden;\n      text-overflow: ellipsis;\n      white-space: nowrap;\n    }\n  }\n}\n\n// 视频合成列表样式\n.merges-list {\n  padding: 16px;\n  max-height: calc(100vh - 200px);\n  overflow-y: auto;\n  background: var(--bg-secondary);\n\n  .merge-items {\n    display: flex;\n    flex-direction: column;\n    gap: 16px;\n  }\n\n  .merge-item {\n    position: relative;\n    background: var(--bg-card);\n    border-radius: 12px;\n    overflow: hidden;\n    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);\n    transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n    border: 1px solid var(--border-primary);\n\n    &:hover {\n      transform: translateY(-2px);\n      box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);\n      border-color: var(--accent);\n    }\n\n    .status-indicator {\n      position: absolute;\n      left: 0;\n      top: 0;\n      bottom: 0;\n      width: 4px;\n      transition: all 0.3s;\n    }\n\n    &.merge-status-completed .status-indicator {\n      background: linear-gradient(to bottom, #67c23a, #85ce61);\n    }\n\n    &.merge-status-processing .status-indicator {\n      background: linear-gradient(to bottom, #e6a23c, #f0c78a);\n      animation: pulse 2s ease-in-out infinite;\n    }\n\n    &.merge-status-failed .status-indicator {\n      background: linear-gradient(to bottom, #f56c6c, #f89898);\n    }\n\n    &.merge-status-pending .status-indicator {\n      background: linear-gradient(to bottom, #909399, #b1b3b8);\n    }\n\n    .merge-content {\n      padding: 20px 24px;\n      padding-left: 28px;\n    }\n\n    .merge-header {\n      display: flex;\n      justify-content: space-between;\n      align-items: center;\n      margin-bottom: 16px;\n      padding-bottom: 14px;\n      border-bottom: 1px solid var(--border-primary);\n\n      .title-section {\n        display: flex;\n        align-items: center;\n        gap: 12px;\n        flex: 1;\n\n        .title-icon {\n          display: flex;\n          align-items: center;\n          justify-content: center;\n          width: 38px;\n          height: 38px;\n          border-radius: 10px;\n          background: var(--bg-secondary);\n          color: var(--text-secondary);\n          transition: all 0.3s;\n        }\n\n        .merge-title {\n          margin: 0;\n          font-size: 15px;\n          font-weight: 500;\n          color: var(--text-secondary);\n          line-height: 1.4;\n        }\n      }\n\n      :deep(.el-tag) {\n        font-weight: 500;\n        padding: 4px 12px;\n        font-size: 12px;\n      }\n    }\n\n    &.merge-status-completed .title-icon {\n      background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);\n      color: #fff;\n    }\n\n    &.merge-status-processing .title-icon {\n      background: linear-gradient(135deg, #e6a23c 0%, #f0c78a 100%);\n      color: #fff;\n    }\n\n    &.merge-status-failed .title-icon {\n      background: linear-gradient(135deg, #f56c6c 0%, #f89898 100%);\n      color: #fff;\n    }\n\n    .merge-details {\n      display: grid;\n      grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));\n      gap: 12px;\n      margin-bottom: 16px;\n\n      .detail-item {\n        display: flex;\n        gap: 10px;\n        padding: 12px 14px;\n        background: var(--bg-secondary);\n        border-radius: 8px;\n        border: 1px solid var(--border-primary);\n        transition: all 0.3s;\n\n        &:hover {\n          border-color: var(--accent);\n          transform: translateY(-1px);\n        }\n\n        .detail-icon {\n          display: flex;\n          align-items: center;\n          justify-content: center;\n          width: 28px;\n          height: 28px;\n          border-radius: 6px;\n          background: var(--bg-card);\n          color: var(--accent);\n          flex-shrink: 0;\n        }\n\n        .detail-content {\n          flex: 1;\n          min-width: 0;\n\n          .detail-label {\n            font-size: 11px;\n            color: var(--text-muted);\n            margin-bottom: 3px;\n            font-weight: 500;\n          }\n\n          .detail-value {\n            font-size: 13px;\n            color: var(--text-primary);\n            font-weight: 500;\n            white-space: nowrap;\n            overflow: hidden;\n            text-overflow: ellipsis;\n          }\n        }\n      }\n    }\n\n    .merge-error {\n      margin-bottom: 12px;\n\n      :deep(.el-alert) {\n        border-radius: 8px;\n        border: none;\n        padding: 8px 12px;\n        font-size: 12px;\n      }\n    }\n\n    .merge-actions {\n      display: flex;\n      gap: 8px;\n      margin-top: 12px;\n\n      :deep(.el-button) {\n        flex: 1;\n        max-width: 160px;\n        font-weight: 500;\n        padding: 8px 15px;\n        font-size: 13px;\n      }\n    }\n  }\n}\n\n// 旋转动画\n@keyframes rotating {\n  from {\n    transform: rotate(0deg);\n  }\n\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n.rotating {\n  animation: rotating 2s linear infinite;\n}\n\n// 脉冲动画\n@keyframes pulse {\n  0%,\n  100% {\n    opacity: 1;\n  }\n\n  50% {\n    opacity: 0.6;\n  }\n}\n\n// 白色主题样式\n.shot-editor-new {\n  padding: 16px;\n  height: 100%;\n  overflow-y: auto;\n  // background: #fff;\n\n  .section-label {\n    font-size: 12px;\n    color: #666;\n    margin-bottom: 8px;\n  }\n\n  // 场景预览\n  .scene-section {\n    margin-bottom: 20px;\n  }\n\n  .scene-preview {\n    width: 100%;\n    height: 80px;\n    border-radius: 6px;\n    overflow: hidden;\n    position: relative;\n    background: #f5f5f5;\n    border: 1px solid var(--border-primary);\n\n    img {\n      width: 100%;\n      height: 100%;\n      object-fit: cover;\n    }\n\n    .scene-info {\n      position: absolute;\n      bottom: 0;\n      left: 0;\n      right: 0;\n      padding: 6px 8px;\n      background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);\n      font-size: 11px;\n      color: #fff;\n\n      .scene-id {\n        font-size: 10px;\n        color: #e0e0e0;\n        margin-top: 2px;\n      }\n    }\n  }\n\n  .scene-preview-empty {\n    width: 100%;\n    height: 80px;\n    border-radius: 6px;\n    border: 1px dashed #d0d0d0;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    gap: 6px;\n    background: #fafafa;\n\n    .el-icon {\n      font-size: 32px !important;\n      color: #c0c0c0;\n    }\n\n    div {\n      font-size: 11px;\n      color: #999;\n    }\n  }\n\n  // 角色列表\n  .cast-section {\n    margin-bottom: 20px;\n  }\n\n  .cast-list {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 10px;\n    margin-top: 8px;\n\n    .cast-item {\n      position: relative;\n      display: flex;\n      flex-direction: column;\n      align-items: center;\n      gap: 4px;\n      cursor: pointer;\n      transition: all 0.2s;\n\n      &:hover {\n        .cast-avatar {\n          border-color: #409eff;\n        }\n\n        .cast-remove {\n          opacity: 1;\n          visibility: visible;\n        }\n      }\n\n      &.active {\n        .cast-avatar {\n          border-color: #409eff;\n          background: #409eff;\n        }\n      }\n\n      .cast-avatar {\n        width: 36px;\n        height: 36px;\n        border-radius: 50%;\n        border: 2px solid #e0e0e0;\n        overflow: hidden;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        background: #f5f5f5;\n        font-size: 14px;\n        font-weight: 500;\n        color: #666;\n        transition: all 0.2s;\n\n        img {\n          width: 100%;\n          height: 100%;\n          object-fit: cover;\n        }\n      }\n\n      .cast-name {\n        font-size: 10px;\n        color: #666;\n        max-width: 36px;\n        text-align: center;\n        overflow: hidden;\n        text-overflow: ellipsis;\n        white-space: nowrap;\n      }\n\n      .cast-remove {\n        position: absolute;\n        top: -3px;\n        right: -3px;\n        width: 16px;\n        height: 16px;\n        border-radius: 50%;\n        background: #f56c6c;\n        color: white;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        cursor: pointer;\n        transition: all 0.2s;\n        box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);\n        z-index: 10;\n        opacity: 0;\n        visibility: hidden;\n        font-size: 12px;\n\n        &:hover {\n          background: #f23030;\n          transform: scale(1.1);\n        }\n      }\n    }\n\n    .cast-empty {\n      width: 100%;\n      text-align: center;\n      padding: 15px;\n      color: var(--text-muted);\n      font-size: 11px;\n    }\n  }\n\n  // 视效设置\n  .settings-section {\n    margin-bottom: 16px;\n\n    .settings-grid {\n      display: grid;\n      grid-template-columns: 1fr 1fr 1fr;\n      gap: 10px;\n\n      .setting-item {\n        label {\n          display: block;\n          font-size: 11px;\n          color: var(--text-secondary);\n          margin-bottom: 6px;\n        }\n      }\n    }\n\n    .audio-controls {\n      margin-top: 8px;\n    }\n  }\n\n  // 叙事内容\n  .narrative-section {\n    margin-bottom: 14px;\n  }\n\n  .dialogue-section {\n    margin-bottom: 14px;\n  }\n}\n\n// 场景选择对话框样式\n.scene-selector-grid {\n  display: grid;\n  grid-template-columns: repeat(3, 1fr);\n  gap: 16px;\n  max-height: 500px;\n  overflow-y: auto;\n  padding: 10px;\n\n  .scene-card {\n    border: 2px solid var(--border-primary);\n    border-radius: 8px;\n    overflow: hidden;\n    cursor: pointer;\n    transition: all 0.2s;\n\n    &:hover {\n      border-color: var(--accent);\n      transform: translateY(-2px);\n      box-shadow: var(--shadow-md);\n    }\n\n    &.selected {\n      border-color: var(--accent);\n      background: var(--accent-light);\n    }\n\n    .scene-image {\n      width: 100%;\n      height: 150px;\n      background: var(--bg-secondary);\n      display: flex;\n      align-items: center;\n      justify-content: center;\n\n      img {\n        width: 100%;\n        height: 100%;\n        object-fit: cover;\n      }\n    }\n\n    .scene-info {\n      padding: 12px;\n      background: var(--bg-card);\n\n      .scene-location {\n        font-size: 14px;\n        font-weight: 600;\n        color: var(--text-primary);\n        margin-bottom: 4px;\n      }\n\n      .scene-time {\n        font-size: 12px;\n        color: var(--text-muted);\n      }\n    }\n  }\n\n  .empty-scenes {\n    grid-column: 1 / -1;\n    padding: 40px 0;\n  }\n}\n\n// 更新section-label样式以支持按钮\n.section-label {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n// 角色选择对话框样式\n.character-selector-grid {\n  display: grid;\n  grid-template-columns: repeat(3, 1fr);\n  gap: 16px;\n  max-height: 500px;\n  overflow-y: auto;\n  padding: 12px;\n\n  .character-card {\n    position: relative;\n    border: 2px solid var(--border-primary);\n    border-radius: 8px;\n    padding: 16px;\n    cursor: pointer;\n    transition: all 0.2s;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    gap: 12px;\n\n    &:hover {\n      border-color: var(--accent);\n      transform: translateY(-2px);\n      box-shadow: var(--shadow-md);\n    }\n\n    &.selected {\n      border-color: var(--accent);\n      background: var(--accent-light);\n    }\n\n    .character-avatar-large {\n      width: 80px;\n      height: 80px;\n      border-radius: 50%;\n      overflow: hidden;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      background: var(--bg-secondary);\n      font-size: 32px;\n      font-weight: 600;\n      color: var(--accent);\n\n      img {\n        width: 100%;\n        height: 100%;\n        object-fit: cover;\n      }\n    }\n\n    .character-info {\n      text-align: center;\n\n      .character-name {\n        font-size: 14px;\n        font-weight: 600;\n        color: var(--text-primary);\n        margin-bottom: 4px;\n      }\n\n      .character-role {\n        font-size: 12px;\n        color: var(--text-muted);\n      }\n    }\n\n    .character-check {\n      position: absolute;\n      top: 8px;\n      right: 8px;\n    }\n  }\n\n  .empty-characters {\n    grid-column: 1 / -1;\n    padding: 40px 0;\n  }\n}\n\n// 角色大图预览样式\n.character-image-preview {\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  min-height: 400px;\n\n  img {\n    max-width: 100%;\n    max-height: 500px;\n    border-radius: 8px;\n    object-fit: contain;\n  }\n}\n\n// 场景大图预览样式\n.scene-image-preview {\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  min-height: 450px;\n  background: var(--bg-secondary);\n  border-radius: 8px;\n\n  img {\n    max-width: 100%;\n    max-height: 600px;\n    border-radius: 8px;\n    object-fit: contain;\n    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);\n  }\n}\n\n// 设置部分样式\n.settings-section {\n  margin-bottom: 20px;\n\n  .section-label {\n    font-size: 12px;\n    color: var(--text-secondary);\n    margin-bottom: 12px;\n  }\n\n  .settings-grid {\n    display: grid;\n    grid-template-columns: 1fr 1fr;\n    gap: 12px;\n\n    .setting-item {\n      label {\n        display: block;\n        font-size: 11px;\n        color: var(--text-secondary);\n        margin-bottom: 6px;\n      }\n    }\n  }\n\n  .audio-controls {\n    :deep(.el-textarea__inner) {\n      background: var(--bg-card);\n      border-color: var(--border-primary);\n      color: var(--text-primary);\n\n      &::placeholder {\n        color: var(--text-muted);\n      }\n    }\n\n    :deep(.el-select) {\n      width: 100%;\n    }\n\n    :deep(.el-slider__runway) {\n      background: #e4e7ed;\n    }\n\n    :deep(.el-slider__bar) {\n      background: #409eff;\n    }\n\n    :deep(.el-slider__button) {\n      border-color: #409eff;\n    }\n  }\n}\n\n.professional-editor {\n  height: 100vh;\n  display: flex;\n  flex-direction: column;\n  background: var(--bg-primary);\n  color: var(--text-primary);\n\n  .editor-toolbar {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    padding: 12px 20px;\n    background: var(--bg-card);\n    border-bottom: 1px solid var(--border-primary);\n\n    .toolbar-left {\n      display: flex;\n      align-items: center;\n      gap: 12px;\n\n      .back-btn {\n        color: var(--text-secondary);\n\n        &:hover {\n          color: var(--accent);\n        }\n      }\n\n      .episode-title {\n        font-size: 14px;\n        color: var(--text-primary);\n      }\n    }\n\n    .toolbar-right {\n      display: flex;\n      gap: 8px;\n    }\n  }\n\n  .editor-main {\n    flex: 1;\n    display: flex;\n    overflow: hidden;\n    height: calc(100vh - 60px);\n\n    .storyboard-panel {\n      width: 280px;\n      background: var(--bg-card);\n      border-right: 1px solid var(--border-primary);\n      display: flex;\n      flex-direction: column;\n\n      .panel-header {\n        display: flex;\n        justify-content: space-between;\n        align-items: center;\n        padding: 16px;\n        border-bottom: 1px solid var(--border-primary);\n\n        h3 {\n          margin: 0;\n          font-size: 16px;\n          font-weight: 500;\n        }\n      }\n\n      .storyboard-list {\n        flex: 1;\n        overflow-y: auto;\n        padding: 8px;\n\n        .storyboard-item {\n          display: flex;\n          flex-direction: column;\n          padding: 12px;\n          margin-bottom: 8px;\n          background: var(--bg-secondary);\n          border-radius: 8px;\n          cursor: pointer;\n          transition: all 0.2s;\n\n          &:hover {\n            background: var(--bg-card-hover);\n          }\n\n          &.active {\n            background: var(--accent-light);\n            border-left: 3px solid var(--accent);\n\n            .shot-content {\n              .shot-number,\n              .shot-title {\n                color: var(--accent) !important;\n              }\n\n              .shot-action {\n                color: var(--text-primary) !important;\n              }\n\n              .shot-duration {\n                background: var(--accent-light);\n                color: var(--accent);\n              }\n            }\n          }\n\n          .shot-content {\n            width: 100%;\n\n            .shot-header {\n              display: flex;\n              justify-content: space-between;\n              align-items: center;\n              margin-bottom: 6px;\n              gap: 8px;\n\n              .shot-title-row {\n                display: flex;\n                align-items: baseline;\n                gap: 8px;\n                flex: 1;\n                min-width: 0;\n\n                .shot-number {\n                  font-size: 12px;\n                  font-weight: 600;\n                  color: var(--text-secondary);\n                  flex-shrink: 0;\n                }\n\n                .shot-title {\n                  font-size: 13px;\n                  font-weight: 500;\n                  color: var(--text-primary);\n                  overflow: hidden;\n                  text-overflow: ellipsis;\n                  white-space: nowrap;\n                }\n              }\n\n              .shot-duration {\n                font-size: 11px;\n                color: var(--text-muted);\n                background: var(--bg-card-hover);\n                padding: 2px 8px;\n                border-radius: 4px;\n                flex-shrink: 0;\n              }\n            }\n\n            .shot-action {\n              font-size: 11px;\n              color: var(--text-secondary);\n              line-height: 1.5;\n              overflow: hidden;\n              text-overflow: ellipsis;\n              display: -webkit-box;\n              -webkit-line-clamp: 2;\n              -webkit-box-orient: vertical;\n            }\n          }\n        }\n      }\n    }\n\n    .timeline-area {\n      flex: 1;\n      display: flex;\n      flex-direction: column;\n      background: var(--bg-secondary);\n      overflow: hidden;\n\n      .empty-timeline {\n        flex: 1;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n      }\n    }\n\n    .edit-panel {\n      width: 520px;\n      background: var(--bg-card);\n      border-left: 1px solid var(--border-primary);\n      overflow: hidden;\n      flex-shrink: 0;\n\n      .edit-tabs {\n        height: 100%;\n\n        :deep(.el-tabs__header) {\n          margin: 0;\n          background: var(--bg-secondary);\n          padding: 0 16px;\n          border-bottom: 1px solid var(--border-primary);\n        }\n\n        :deep(.el-tabs__content) {\n          height: calc(100% - 55px);\n          overflow-y: auto;\n        }\n\n        .tab-content {\n          padding: 16px;\n        }\n\n        .scene-editor,\n        .shot-editor {\n          .el-form-item {\n            margin-bottom: 16px;\n          }\n        }\n      }\n    }\n  }\n}\n\n// 通用参数行样式\n.param-row {\n  margin-bottom: 8px;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n\n  &:last-child {\n    margin-bottom: 0;\n  }\n}\n\n.param-label {\n  min-width: 50px;\n  font-size: 12px;\n  color: var(--text-secondary);\n  flex-shrink: 0;\n}\n\n// 图片生成界面样式\n.image-generation-section {\n  .frame-type-selector {\n    margin-bottom: 20px;\n\n    .section-label {\n      font-size: 13px;\n      color: var(--text-primary);\n      font-weight: 500;\n      margin-bottom: 12px;\n    }\n\n    :deep(.el-radio-group) {\n      display: flex;\n      flex-wrap: wrap;\n      gap: 8px;\n    }\n\n    .panel-count-input {\n      width: 80px;\n    }\n  }\n\n  .prompt-section {\n    margin-bottom: 20px;\n\n    .section-label {\n      font-size: 13px;\n      color: var(--text-primary);\n      font-weight: 500;\n      margin-bottom: 12px;\n      display: flex;\n      align-items: center;\n    }\n\n    :deep(.el-textarea__inner) {\n      font-family: \"Monaco\", \"Menlo\", \"Consolas\", monospace;\n      font-size: 12px;\n      line-height: 1.6;\n    }\n  }\n\n  .generation-controls {\n    margin-bottom: 20px;\n    display: flex;\n    gap: 10px;\n  }\n\n  .generation-result {\n    .section-label {\n      font-size: 13px;\n      color: var(--text-primary);\n      font-weight: 600;\n      margin-bottom: 12px;\n      display: flex;\n      align-items: center;\n      gap: 6px;\n\n      &::before {\n        content: \"\";\n        width: 3px;\n        height: 14px;\n        background: linear-gradient(\n          to bottom,\n          var(--accent),\n          var(--accent-hover)\n        );\n        border-radius: 2px;\n      }\n    }\n\n    .image-grid {\n      display: grid;\n      grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));\n      gap: 10px;\n\n      .image-item-wrapper {\n        display: flex;\n        flex-direction: column;\n        gap: 6px;\n      }\n\n      .image-item {\n        position: relative;\n        border-radius: 8px;\n        overflow: hidden;\n        background: var(--bg-card);\n        border: 1px solid var(--border-primary);\n        transition: all 0.2s ease;\n        cursor: pointer;\n        box-shadow: var(--shadow-sm);\n        height: 150px;\n\n        &:hover {\n          transform: translateY(-2px);\n          box-shadow: var(--shadow-md);\n          // border-color: var(--accent);\n\n          .image-actions {\n            transform: translateY(0);\n            opacity: 0.7;\n          }\n        }\n\n        :deep(.el-image) {\n          width: 100%;\n          aspect-ratio: 16 / 9;\n          background: var(--bg-secondary);\n          display: block;\n          height: 100%;\n        }\n\n        .image-placeholder {\n          width: 100%;\n          aspect-ratio: 16 / 9;\n          display: flex;\n          flex-direction: column;\n          align-items: center;\n          justify-content: center;\n          gap: 8px;\n          background: var(--bg-secondary);\n          color: var(--text-muted);\n          position: relative;\n          overflow: hidden;\n          height: 100%;\n\n          &::before {\n            content: \"\";\n            position: absolute;\n            width: 200%;\n            height: 200%;\n            background: linear-gradient(\n              45deg,\n              transparent 30%,\n              var(--border-secondary) 50%,\n              transparent 70%\n            );\n            animation: shimmer 2s infinite;\n            top: -50%;\n            left: -50%;\n          }\n\n          .el-icon {\n            position: relative;\n            z-index: 1;\n            font-size: 24px !important;\n          }\n\n          p {\n            margin: 0;\n            font-size: 11px;\n            font-weight: 500;\n            position: relative;\n            z-index: 1;\n          }\n        }\n\n        .image-actions {\n          position: absolute;\n          bottom: 0;\n          left: 0;\n          width: 100%;\n          display: flex;\n          justify-content: space-between;\n          align-items: center;\n          background-color: var(--bg-primary);\n          opacity: 0;\n          padding: 0 8px;\n          height: 32px;\n          transform: translateY(100%);\n          transition:\n            transform 0.3s ease,\n            opacity 0.2s ease;\n\n          .crop-icon-overlay,\n          .delete-icon-overlay {\n            width: 28px;\n            height: 28px;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            border-radius: 4px;\n            transition: all 0.2s ease;\n\n            &:hover {\n              cursor: pointer;\n              background: var(--bg-secondary);\n              transform: scale(1.1);\n            }\n          }\n        }\n      }\n\n      .image-status {\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        padding: 2px 0;\n\n        :deep(.el-tag) {\n          font-size: 10px;\n          height: 20px;\n          padding: 0 6px;\n        }\n      }\n    }\n\n    @keyframes shimmer {\n      0% {\n        transform: translateX(-100%) translateY(-100%) rotate(45deg);\n      }\n\n      100% {\n        transform: translateX(100%) translateY(100%) rotate(45deg);\n      }\n    }\n  }\n\n  .panel-count-label {\n    margin-left: 5px;\n    font-size: 12px;\n    color: var(--text-muted);\n  }\n\n  .model-tags {\n    font-size: 12px;\n    color: var(--text-muted);\n  }\n\n  .mode-description {\n    font-size: 12px;\n    color: var(--text-muted);\n  }\n}\n\n// 视频生成样式\n.video-generation-section {\n  .section-label {\n    font-size: 14px;\n    font-weight: 600;\n    color: var(--text-primary);\n    margin-bottom: 12px;\n    padding-left: 8px;\n    border-left: 3px solid var(--accent);\n  }\n\n  // 视频生成结果样式\n  .generation-result {\n    margin-top: 24px;\n\n    .section-label {\n      font-size: 13px;\n      color: #303133;\n      font-weight: 600;\n      margin-bottom: 12px;\n      display: flex;\n      align-items: center;\n      gap: 6px;\n\n      // &::before {\n      //   content: '';\n      //   width: 3px;\n      //   height: 14px;\n      //   background: linear-gradient(to bottom, #409eff, #66b1ff);\n      //   border-radius: 2px;\n      // }\n    }\n\n    .image-grid {\n      display: grid;\n      grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));\n      gap: 10px;\n\n      .image-item {\n        position: relative;\n        border-radius: 8px;\n        overflow: hidden;\n        background: #fff;\n        border: 1px solid #e8e8e8;\n        transition: all 0.2s ease;\n        cursor: pointer;\n        height: 150px;\n        box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);\n\n        &:hover {\n          transform: translateY(-2px);\n          box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);\n\n          .video-actions {\n            transform: translateY(0);\n            opacity: 0.7;\n          }\n        }\n\n        .image-placeholder {\n          width: 100%;\n          aspect-ratio: 16 / 9;\n          display: flex;\n          flex-direction: column;\n          align-items: center;\n          justify-content: center;\n          gap: 8px;\n          background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf0 100%);\n          color: #909399;\n          position: relative;\n          overflow: hidden;\n          height: 100%;\n\n          &::before {\n            content: \"\";\n            position: absolute;\n            width: 200%;\n            height: 200%;\n            background: linear-gradient(\n              45deg,\n              transparent 30%,\n              var(--border-secondary) 50%,\n              transparent 70%\n            );\n            animation: shimmer 2s infinite;\n            top: -50%;\n            left: -50%;\n          }\n\n          .el-icon {\n            position: relative;\n            z-index: 1;\n            font-size: 24px !important;\n          }\n\n          p {\n            margin: 0;\n            font-size: 11px;\n            font-weight: 500;\n            position: relative;\n            z-index: 1;\n          }\n        }\n\n        .image-info {\n          position: absolute;\n          bottom: 0;\n          left: 0;\n          right: 0;\n          padding: 6px 8px;\n          background: linear-gradient(\n            to top,\n            rgba(0, 0, 0, 0.75),\n            rgba(0, 0, 0, 0.2) 70%,\n            transparent\n          );\n          display: flex;\n          justify-content: space-between;\n          align-items: center;\n          gap: 4px;\n\n          :deep(.el-tag) {\n            backdrop-filter: blur(8px);\n            font-size: 10px;\n            height: 20px;\n            padding: 0 6px;\n          }\n\n          .frame-type-tag {\n            padding: 2px 6px;\n            border-radius: 4px;\n            font-size: 10px;\n            font-weight: 500;\n            background: rgba(255, 255, 255, 0.25);\n            color: white;\n            backdrop-filter: blur(8px);\n            border: 1px solid rgba(255, 255, 255, 0.3);\n            text-transform: uppercase;\n            letter-spacing: 0.3px;\n          }\n        }\n\n        // 视频缩略图特殊样式\n        &.video-item .video-thumbnail {\n          position: relative;\n          width: 100%;\n          height: 100%;\n          overflow: hidden;\n          cursor: pointer;\n\n          video {\n            width: 100%;\n            height: 100%;\n            object-fit: cover;\n            display: block;\n            pointer-events: none;\n          }\n\n          .play-overlay {\n            position: absolute;\n            top: 0;\n            left: 0;\n            right: 0;\n            bottom: 0;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            background: rgba(0, 0, 0, 0.3);\n            opacity: 0;\n            transition: opacity 0.2s ease;\n\n            .el-icon {\n              filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));\n            }\n          }\n\n          &:hover .play-overlay {\n            opacity: 1;\n          }\n        }\n\n        .video-actions {\n          position: absolute;\n          bottom: 0;\n          left: 0;\n          width: 100%;\n          display: flex;\n          justify-content: space-between;\n          align-items: center;\n          background-color: var(--bg-primary);\n          opacity: 0;\n          padding: 0 8px;\n          height: 32px;\n          transform: translateY(100%);\n          transition:\n            transform 0.3s ease,\n            opacity 0.2s ease;\n\n          .add-to-assets-button,\n          .delete-video-button {\n            width: 28px;\n            height: 28px;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            cursor: pointer;\n            border-radius: 4px;\n            transition: all 0.2s ease;\n\n            &:hover {\n              background: var(--bg-secondary);\n              transform: scale(1.1);\n            }\n\n            .is-loading {\n              animation: rotate 1s linear infinite;\n            }\n          }\n        }\n      }\n    }\n  }\n\n  .reference-mode-title {\n    margin-bottom: 12px;\n    font-size: 13px;\n    color: var(--text-primary);\n    font-weight: 500;\n  }\n\n  .frame-label {\n    margin-bottom: 8px;\n    font-size: 12px;\n    color: var(--text-muted);\n  }\n\n  .slot-hint {\n    margin-top: 8px;\n    font-size: 12px;\n    color: var(--text-muted);\n  }\n\n  .image-slot {\n    position: relative;\n    width: 140px;\n    height: 90px;\n    border: 2px dashed var(--border-primary);\n    border-radius: 8px;\n    overflow: hidden;\n    cursor: pointer;\n    background: var(--bg-card);\n    // display: flex;\n    // align-items: center;\n    // justify-content: center;\n\n    &:hover {\n      border-color: var(--accent);\n    }\n  }\n\n  .video-params-section {\n    margin-bottom: 16px;\n    padding: 12px 16px;\n    background: var(--bg-secondary);\n    border-radius: 8px;\n    border: 1px solid var(--border-primary);\n  }\n\n  .image-slots-container {\n    padding: 12px;\n    background: var(--bg-secondary);\n    border-radius: 8px;\n    border: 1px dashed var(--border-primary);\n  }\n\n  .image-slot {\n    position: relative;\n    width: 140px;\n    height: 90px;\n    border: 2px dashed var(--border-primary);\n    border-radius: 8px;\n    overflow: hidden;\n    cursor: pointer;\n    transition: all 0.3s;\n    background: var(--bg-card);\n\n    &:hover {\n      border-color: var(--accent);\n      box-shadow: var(--shadow-md);\n    }\n\n    &.image-slot-small {\n      width: 80px;\n      height: 52px;\n    }\n  }\n\n  .image-slot-placeholder {\n    width: 100%;\n    height: 100%;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    color: var(--text-muted);\n  }\n\n  .image-slot-remove {\n    position: absolute;\n    top: 4px;\n    right: 4px;\n    width: 24px;\n    height: 24px;\n    background: rgba(0, 0, 0, 0.6);\n    border-radius: 50%;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    cursor: pointer;\n    transition: all 0.2s;\n\n    &:hover {\n      background: rgba(255, 73, 73, 0.9);\n      transform: scale(1.1);\n    }\n  }\n\n  .reference-images-section {\n    margin-top: 12px;\n\n    .frame-type-buttons {\n      margin-bottom: 12px;\n      text-align: center;\n\n      :deep(.el-radio-group) {\n        display: inline-flex;\n        flex-wrap: wrap;\n        gap: 0;\n      }\n\n      :deep(.el-radio-button) {\n        overflow: hidden;\n\n        &:first-child .el-radio-button__inner {\n          border-radius: 6px 0 0 6px;\n        }\n\n        &:last-child .el-radio-button__inner {\n          border-radius: 0 6px 6px 0;\n        }\n      }\n\n      :deep(.el-radio-button__inner) {\n        padding: 6px 12px;\n        font-size: 12px;\n        font-weight: 500;\n        border-color: var(--border-primary);\n        transition: all 0.2s;\n\n        &:hover {\n          // color: var(--accent);\n          border-color: var(--accent);\n        }\n      }\n\n      :deep(.el-radio-button.is-active .el-radio-button__inner) {\n        background: var(--accent);\n        border-color: var(--accent);\n        box-shadow: 0 2px 6px rgba(14, 165, 233, 0.25);\n      }\n    }\n\n    .frame-type-content {\n      padding: 4px 10px;\n      background: var(--bg-card);\n      border-radius: 8px;\n      border: 1px solid var(--border-primary);\n      min-height: 160px;\n    }\n\n    .image-scroll-container {\n      max-height: 220px;\n      overflow-y: auto;\n      overflow-x: hidden;\n      padding-right: 4px;\n\n      &::-webkit-scrollbar {\n        width: 6px;\n      }\n\n      &::-webkit-scrollbar-track {\n        background: #f1f1f1;\n        border-radius: 3px;\n      }\n\n      &::-webkit-scrollbar-thumb {\n        background: #c1c1c1;\n        border-radius: 3px;\n\n        &:hover {\n          background: #a8a8a8;\n        }\n      }\n    }\n\n    .previous-frame-section {\n      margin-bottom: 12px;\n      padding: 8px;\n      background: var(--bg-secondary);\n      border: 1px solid var(--border-primary);\n      border-radius: 6px;\n\n      .hint-text {\n        color: var(--text-muted);\n        font-size: 11px;\n      }\n    }\n\n    .reference-grid {\n      display: grid !important;\n      grid-template-columns: repeat(4, 1fr) !important;\n      gap: 8px !important;\n\n      .reference-item {\n        // padding-top: 4px;\n        margin-top: 6px;\n        position: relative;\n        border-radius: 6px;\n        overflow: hidden;\n        cursor: pointer;\n        border: 2px solid transparent;\n        transition: all 0.2s ease;\n        width: 100% !important;\n        max-width: 120px !important;\n        background: var(--bg-card);\n\n        &:hover {\n          transform: translateY(-4px) scale(1.02);\n          box-shadow: var(--shadow-lg);\n          border-color: var(--accent);\n        }\n\n        &.selected {\n          border-color: var(--accent);\n          box-shadow: var(--shadow-glow);\n        }\n\n        img {\n          width: 100%;\n          max-width: 180px;\n          aspect-ratio: 16 / 9;\n          object-fit: cover;\n          display: block;\n          transition: transform 0.3s;\n        }\n\n        &:hover img {\n          transform: scale(1.05);\n        }\n\n        .reference-label {\n          position: absolute;\n          bottom: 0;\n          left: 0;\n          right: 0;\n          padding: 4px 8px;\n          background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);\n          color: white;\n          font-size: 10px;\n          text-align: center;\n        }\n      }\n    }\n  }\n\n  .generation-controls {\n    margin-top: 40px;\n    padding-top: 0;\n    text-align: center;\n\n    :deep(.el-button) {\n      padding: 12px 32px;\n      font-size: 14px;\n      font-weight: 500;\n      border-radius: 8px;\n      transition: all 0.3s;\n\n      &:hover {\n        transform: translateY(-2px);\n        box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);\n      }\n    }\n  }\n\n  @keyframes shimmer {\n    0% {\n      transform: translateX(-100%) translateY(-100%) rotate(45deg);\n    }\n\n    100% {\n      transform: translateX(100%) translateY(100%) rotate(45deg);\n    }\n  }\n}\n\n// 视频合成列表样式\n.merges-list {\n  min-height: 300px;\n\n  .merge-items {\n    display: flex;\n    flex-direction: column;\n    gap: 16px;\n  }\n\n  .merge-item {\n    position: relative;\n    background: var(--bg-card);\n    border-radius: 12px;\n    overflow: hidden;\n    transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n    border: 1px solid var(--border-primary);\n    box-shadow: var(--shadow-sm);\n\n    &:hover {\n      transform: translateY(-2px);\n      box-shadow: var(--shadow-md);\n      border-color: var(--accent-light);\n    }\n\n    // 状态指示条\n    .status-indicator {\n      position: absolute;\n      left: 0;\n      top: 0;\n      bottom: 0;\n      width: 4px;\n      transition: all 0.3s ease;\n    }\n\n    &.merge-status-pending .status-indicator {\n      background: linear-gradient(to bottom, #909399, #b1b3b8);\n    }\n\n    &.merge-status-processing .status-indicator {\n      background: linear-gradient(to bottom, #e6a23c, #f0c78a);\n      animation: pulse 2s ease-in-out infinite;\n    }\n\n    &.merge-status-completed .status-indicator {\n      background: linear-gradient(to bottom, #67c23a, #95d475);\n    }\n\n    &.merge-status-failed .status-indicator {\n      background: linear-gradient(to bottom, #f56c6c, #f89898);\n    }\n\n    .merge-content {\n      padding: 20px 20px 20px 24px;\n    }\n\n    .merge-header {\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      margin-bottom: 16px;\n      gap: 12px;\n\n      .title-section {\n        display: flex;\n        align-items: center;\n        gap: 12px;\n        flex: 1;\n        min-width: 0;\n\n        .title-icon {\n          color: #409eff;\n          flex-shrink: 0;\n\n          &.rotating {\n            animation: rotate 1.5s linear infinite;\n          }\n        }\n\n        .merge-title {\n          margin: 0;\n          font-size: 16px;\n          font-weight: 600;\n          color: var(--text-secondary);\n          overflow: hidden;\n          text-overflow: ellipsis;\n          white-space: nowrap;\n        }\n      }\n\n      :deep(.el-tag) {\n        flex-shrink: 0;\n        font-weight: 500;\n        letter-spacing: 0.3px;\n      }\n    }\n\n    .merge-details {\n      display: grid;\n      grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));\n      gap: 16px;\n      margin-bottom: 16px;\n      padding: 16px;\n      background: var(--bg-secondary);\n      border-radius: 8px;\n      border: 1px solid var(--border-primary);\n\n      .detail-item {\n        display: flex;\n        align-items: flex-start;\n        gap: 10px;\n\n        .detail-icon {\n          display: flex;\n          align-items: center;\n          justify-content: center;\n          width: 32px;\n          height: 32px;\n          background: var(--bg-card);\n          border-radius: 8px;\n          color: var(--accent);\n          flex-shrink: 0;\n          box-shadow: var(--shadow-xs);\n        }\n\n        .detail-content {\n          flex: 1;\n          min-width: 0;\n\n          .detail-label {\n            font-size: 12px;\n            color: var(--text-muted);\n            margin-bottom: 4px;\n            font-weight: 500;\n          }\n\n          .detail-value {\n            font-size: 14px;\n            color: var(--text-primary);\n            font-weight: 500;\n            overflow: hidden;\n            text-overflow: ellipsis;\n            white-space: nowrap;\n          }\n        }\n      }\n    }\n\n    .merge-error {\n      margin-bottom: 16px;\n\n      :deep(.el-alert) {\n        border-radius: 8px;\n        border-left: 4px solid #f56c6c;\n      }\n    }\n\n    .merge-actions {\n      display: flex;\n      gap: 10px;\n      flex-wrap: wrap;\n      padding-top: 16px;\n      border-top: 1px solid var(--border-primary);\n\n      :deep(.el-button) {\n        font-weight: 500;\n        transition: all 0.3s ease;\n\n        &:hover {\n          transform: translateY(-1px);\n        }\n\n        &.el-button--primary {\n          box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);\n\n          &:hover {\n            box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);\n          }\n        }\n      }\n    }\n  }\n\n  @keyframes pulse {\n    0%,\n    100% {\n      opacity: 1;\n    }\n\n    50% {\n      opacity: 0.6;\n    }\n  }\n\n  @keyframes rotate {\n    from {\n      transform: rotate(0deg);\n    }\n\n    to {\n      transform: rotate(360deg);\n    }\n  }\n}\n\n.video-meta {\n  margin-top: 16px;\n  padding: 12px;\n  border-radius: 8px;\n  border: 1px solid var(--border-primary);\n  background: var(--bg-secondary);\n}\n</style>\n<style>\n.video-prompt-box {\n  margin-bottom: 10px;\n  padding: 8px 10px;\n  background: var(--bg-secondary);\n  border-radius: 6px;\n  border: 1px solid var(--border-primary);\n  font-size: 12px;\n  line-height: 1.5;\n  color: var(--text-secondary);\n  word-break: break-word;\n  max-height: 300px;\n  overflow-y: auto;\n}\n\n/* 动作序列图片裁剪图标样式 */\n.action-image-item {\n  position: relative;\n}\n\n/* .crop-icon-overlay {\n  position: absolute;\n  top: 4px;\n  right: 4px;\n  width: 28px;\n  height: 28px;\n  background: rgba(0, 0, 0, 0.7);\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  cursor: pointer;\n  opacity: 0;\n  transition: all 0.3s ease;\n  z-index: 10;\n} */\n\n.action-image-item:hover .crop-icon-overlay {\n  opacity: 1;\n}\n\n.crop-icon-overlay:hover {\n  /* background: var(--accent); */\n  transform: scale(1.1);\n}\n\n/* 删除按钮样式 */\n/* .delete-icon-overlay {\n  position: absolute;\n  bottom: 4px;\n  left: 4px;\n  width: 28px;\n  height: 28px;\n  background: rgba(220, 38, 38, 0.9);\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  cursor: pointer;\n  opacity: 0;\n  transition: all 0.3s ease;\n  z-index: 10;\n} */\n\n.image-item:hover .delete-icon-overlay {\n  opacity: 1;\n}\n\n.delete-icon-overlay:hover {\n  /* background: rgba(220, 38, 38, 1); */\n  transform: scale(1.1);\n}\n\n/* 宫格图片入口按钮样式 */\n.grid-entry-button {\n  cursor: pointer;\n  transition: all 0.3s ease;\n  border: none !important;\n  min-height: 88px;\n}\n\n.grid-entry-button:hover {\n  transform: translateY(-2px);\n  box-shadow: 0 4px 12px rgba(14, 165, 233, 0.3);\n}\n\n.grid-entry-placeholder {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 100%;\n  height: 100%;\n  border: 2px dashed var(--border-primary);\n  border-radius: 8px;\n  background: var(--bg-secondary);\n  transition: all 0.3s ease;\n}\n\n.grid-entry-button:hover .grid-entry-placeholder {\n  border-color: var(--accent);\n  background: var(--bg-card);\n}\n\n/* 宫格图片编辑器样式 */\n.creation-mode-selector,\n.grid-type-selector {\n  margin-bottom: 16px;\n}\n\n.grid-editor {\n  margin-bottom: 20px;\n}\n\n.grid-container {\n  display: grid;\n  gap: 12px;\n  margin-bottom: 16px;\n  padding: 16px;\n  background: var(--bg-secondary);\n  border-radius: 8px;\n  border: 1px solid var(--border-primary);\n}\n\n.grid-container.grid-4 {\n  grid-template-columns: repeat(2, 1fr);\n}\n\n.grid-container.grid-6 {\n  grid-template-columns: repeat(3, 1fr);\n}\n\n.grid-container.grid-9 {\n  grid-template-columns: repeat(3, 1fr);\n}\n\n.grid-cell {\n  position: relative;\n  aspect-ratio: 1;\n  border: 2px dashed var(--border-primary);\n  border-radius: 8px;\n  overflow: hidden;\n  cursor: pointer;\n  transition: all 0.3s ease;\n  background: var(--bg-card);\n}\n\n.grid-cell:hover {\n  border-color: var(--accent);\n  box-shadow: 0 2px 8px rgba(14, 165, 233, 0.2);\n}\n\n.grid-cell img {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n}\n\n.grid-cell-placeholder {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  height: 100%;\n  color: var(--text-secondary);\n}\n\n.grid-cell-placeholder p {\n  margin-top: 8px;\n  font-size: 12px;\n}\n\n.grid-cell-actions {\n  position: absolute;\n  top: 8px;\n  right: 8px;\n  display: flex;\n  gap: 4px;\n  opacity: 0;\n  transition: opacity 0.3s ease;\n}\n\n.grid-cell:hover .grid-cell-actions {\n  opacity: 1;\n}\n\n.grid-cell-actions .el-icon {\n  width: 28px;\n  height: 28px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: rgba(0, 0, 0, 0.6);\n  border-radius: 4px;\n  color: white;\n  cursor: pointer;\n  transition: all 0.3s ease;\n}\n\n.grid-cell-actions .el-icon:hover {\n  background: rgba(0, 0, 0, 0.8);\n  transform: scale(1.1);\n}\n\n.grid-controls {\n  display: flex;\n  gap: 12px;\n}\n\n/* 图片选择器样式 */\n.image-selector-grid {\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));\n  gap: 12px;\n  max-height: 500px;\n  overflow-y: auto;\n  padding: 12px;\n}\n\n.image-selector-item {\n  position: relative;\n  cursor: pointer;\n  border-radius: 8px;\n  overflow: hidden;\n  transition: all 0.3s ease;\n  border: 2px solid transparent;\n}\n\n.image-selector-item:hover {\n  border-color: var(--accent);\n  box-shadow: 0 2px 8px rgba(14, 165, 233, 0.3);\n  transform: translateY(-2px);\n}\n\n.image-selector-label {\n  position: absolute;\n  bottom: 0;\n  left: 0;\n  right: 0;\n  padding: 4px 8px;\n  background: rgba(0, 0, 0, 0.7);\n  color: white;\n  font-size: 12px;\n  text-align: center;\n}\n\n.grid-preview-container {\n  text-align: center;\n}\n\n.grid-preview-container img {\n  max-width: 100%;\n  border-radius: 8px;\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);\n}\n</style>\n"
  },
  {
    "path": "web/src/views/drama/components/UploadScriptDialog.vue",
    "content": "<template>\n  <el-dialog\n    v-model=\"visible\"\n    title=\"上传剧本\"\n    width=\"800px\"\n    :close-on-click-modal=\"false\"\n    @close=\"handleClose\"\n  >\n    <el-form :model=\"form\" label-width=\"100px\">\n      <el-form-item label=\"剧本内容\" required>\n        <el-input\n          v-model=\"form.script_content\"\n          type=\"textarea\"\n          :rows=\"15\"\n          placeholder=\"粘贴您的剧本内容&#10;系统将自动识别并拆分为剧集和场景\"\n          maxlength=\"50000\"\n          show-word-limit\n        />\n        <div class=\"form-tip\">\n          支持多种剧本格式，系统会智能识别剧集、场景、对话等内容\n        </div>\n      </el-form-item>\n\n      <el-form-item label=\"拆分选项\">\n        <el-checkbox v-model=\"form.auto_split\">自动拆分剧集</el-checkbox>\n        <div class=\"form-tip\">\n          启用后将自动识别剧集分界点，否则作为单集处理\n        </div>\n      </el-form-item>\n    </el-form>\n\n    <template v-if=\"parseResult\">\n      <el-divider>解析结果</el-divider>\n      \n      <div class=\"parse-result\">\n        <el-alert\n          title=\"解析完成\"\n          type=\"success\"\n          :closable=\"false\"\n          show-icon\n        >\n          <template #default>\n            共识别 {{ parseResult.episodes.length }} 个剧集，\n            {{ totalScenes }} 个场景\n          </template>\n        </el-alert>\n\n        <div class=\"summary-box\" v-if=\"parseResult.summary\">\n          <h4>剧本概要</h4>\n          <p>{{ parseResult.summary }}</p>\n        </div>\n\n        <el-collapse v-model=\"activeEpisode\" accordion>\n          <el-collapse-item\n            v-for=\"episode in parseResult.episodes\"\n            :key=\"episode.episode_number\"\n            :title=\"`第${episode.episode_number}集: ${episode.title}`\"\n            :name=\"episode.episode_number\"\n          >\n            <div class=\"episode-info\">\n              <p><strong>场景数：</strong>{{ episode.scenes.length }}</p>\n              \n              <el-table :data=\"episode.scenes\" size=\"small\" border>\n                <el-table-column prop=\"storyboard_number\" label=\"场景号\" width=\"80\" />\n                <el-table-column prop=\"title\" label=\"标题\" width=\"150\" />\n                <el-table-column prop=\"location\" label=\"地点\" width=\"120\" />\n                <el-table-column prop=\"time\" label=\"时间\" width=\"100\" />\n                <el-table-column prop=\"characters\" label=\"角色\" width=\"150\" />\n                <el-table-column label=\"对话\">\n                  <template #default=\"{ row }\">\n                    <div class=\"dialogue-preview\">{{ row.dialogue }}</div>\n                  </template>\n                </el-table-column>\n              </el-table>\n            </div>\n          </el-collapse-item>\n        </el-collapse>\n      </div>\n    </template>\n\n    <template #footer>\n      <el-button @click=\"handleClose\">取消</el-button>\n      <el-button v-if=\"!parseResult\" type=\"primary\" @click=\"handleParse\" :loading=\"parsing\">\n        解析剧本\n      </el-button>\n      <el-button v-else type=\"success\" @click=\"handleSave\" :loading=\"saving\">\n        保存到项目\n      </el-button>\n    </template>\n  </el-dialog>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, reactive } from 'vue'\nimport { ElMessage } from 'element-plus'\nimport { generationAPI } from '@/api/generation'\nimport type { ParseScriptResult } from '@/types/generation'\n\ninterface Props {\n  modelValue: boolean\n  dramaId: string\n}\n\nconst props = defineProps<Props>()\nconst emit = defineEmits<{\n  'update:modelValue': [value: boolean]\n  success: []\n}>()\n\nconst visible = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val)\n})\n\nconst form = reactive({\n  script_content: '',\n  auto_split: true\n})\n\nconst parsing = ref(false)\nconst saving = ref(false)\nconst parseResult = ref<ParseScriptResult>()\nconst activeEpisode = ref<number>()\n\nconst totalScenes = computed(() => {\n  if (!parseResult.value) return 0\n  return parseResult.value.episodes.reduce((sum, ep) => sum + ep.scenes.length, 0)\n})\n\nconst handleParse = async () => {\n  if (!form.script_content.trim()) {\n    ElMessage.warning('请输入剧本内容')\n    return\n  }\n\n  parsing.value = true\n  try {\n    parseResult.value = await generationAPI.parseScript({\n      drama_id: props.dramaId,\n      script_content: form.script_content,\n      auto_split: form.auto_split\n    })\n    ElMessage.success('剧本解析成功')\n  } catch (error: any) {\n    ElMessage.error(error.message || '解析失败')\n  } finally {\n    parsing.value = false\n  }\n}\n\nconst handleSave = async () => {\n  if (!parseResult.value) return\n\n  saving.value = true\n  try {\n    // TODO: 调用保存接口将解析结果保存到数据库\n    ElMessage.success('保存成功')\n    emit('success')\n    handleClose()\n  } catch (error: any) {\n    ElMessage.error(error.message || '保存失败')\n  } finally {\n    saving.value = false\n  }\n}\n\nconst handleClose = () => {\n  visible.value = false\n  form.script_content = ''\n  form.auto_split = true\n  parseResult.value = undefined\n  activeEpisode.value = undefined\n}\n</script>\n\n<style scoped>\n.form-tip {\n  margin-top: 8px;\n  font-size: 12px;\n  color: #909399;\n}\n\n.parse-result {\n  margin-top: 20px;\n}\n\n.summary-box {\n  margin: 20px 0;\n  padding: 15px;\n  background: #f5f7fa;\n  border-radius: 8px;\n}\n\n.summary-box h4 {\n  margin: 0 0 10px 0;\n  font-size: 14px;\n  color: #303133;\n}\n\n.summary-box p {\n  margin: 0;\n  line-height: 1.6;\n  color: #606266;\n}\n\n.episode-info {\n  padding: 10px 0;\n}\n\n.dialogue-preview {\n  max-height: 60px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  display: -webkit-box;\n  -webkit-line-clamp: 3;\n  -webkit-box-orient: vertical;\n  font-size: 12px;\n  line-height: 1.5;\n}\n\n:deep(.el-collapse-item__header) {\n  font-weight: 500;\n  color: #303133;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/editor/TimelineEditor.vue",
    "content": "<template>\n  <div class=\"timeline-editor-page\">\n    <div class=\"editor-header\">\n      <el-button link @click=\"goBack\" class=\"back-button\">\n        <el-icon><ArrowLeft /></el-icon>\n        {{ $t('timeline.backToEditor') }}\n      </el-button>\n      <h2>{{ $t('timeline.title') }}</h2>\n    </div>\n    \n    <div class=\"editor-content\">\n      <VideoTimelineEditor \n        v-if=\"scenes.length > 0\"\n        :scenes=\"scenes\" \n        :episode-id=\"episodeId\" \n      />\n      <el-empty v-else :description=\"$t('timeline.noScenes')\" />\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, onMounted } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { ElMessage } from 'element-plus'\nimport { ArrowLeft } from '@element-plus/icons-vue'\nimport { dramaAPI } from '@/api/drama'\nimport VideoTimelineEditor from '@/components/editor/VideoTimelineEditor.vue'\n\nconst route = useRoute()\nconst router = useRouter()\n\nconst episodeId = route.params.id as string\nconst scenes = ref<any[]>([])\n\nconst loadScenes = async () => {\n  try {\n    const res = await dramaAPI.getStoryboards(episodeId)\n    scenes.value = res.storyboards || []\n  } catch (error: any) {\n    ElMessage.error($t('timeline.loadFailed'))\n  }\n}\n\nconst goBack = () => {\n  router.back()\n}\n\nonMounted(() => {\n  loadScenes()\n})\n</script>\n\n<style scoped lang=\"scss\">\n.timeline-editor-page {\n  height: 100vh;\n  display: flex;\n  flex-direction: column;\n  background: #f5f7fa;\n\n  .editor-header {\n    display: flex;\n    align-items: center;\n    gap: 16px;\n    padding: 16px 24px;\n    background: white;\n    border-bottom: 1px solid #e4e7ed;\n\n    .back-button {\n      display: flex;\n      align-items: center;\n      gap: 4px;\n      color: #606266;\n      font-size: 14px;\n      \n      &:hover {\n        color: #409eff;\n      }\n    }\n\n    h2 {\n      margin: 0;\n      font-size: 18px;\n      font-weight: 500;\n    }\n  }\n\n  .editor-content {\n    flex: 1;\n    overflow: hidden;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/src/views/generation/ImageGeneration.vue",
    "content": "<template>\n  <div class=\"image-generation-container\">\n    <el-page-header @back=\"goBack\" class=\"page-header\">\n      <template #content>\n        <div class=\"header-content\">\n          <h2>{{ $t('image.title') }}</h2>\n        </div>\n      </template>\n      <template #extra>\n        <el-button type=\"primary\" @click=\"showGenerateDialog = true\">\n          <el-icon><Plus /></el-icon>\n          {{ $t('image.generate') }}\n        </el-button>\n      </template>\n    </el-page-header>\n\n    <el-card shadow=\"never\" class=\"filter-card\">\n      <el-form inline>\n        <el-form-item :label=\"$t('video.filter.drama')\">\n          <el-select v-model=\"filters.drama_id\" :placeholder=\"$t('video.filter.allDramas')\" clearable>\n            <el-option\n              v-for=\"drama in dramas\"\n              :key=\"drama.id\"\n              :label=\"drama.title\"\n              :value=\"drama.id\"\n            />\n          </el-select>\n        </el-form-item>\n\n        <el-form-item :label=\"$t('video.filter.status')\">\n          <el-select v-model=\"filters.status\" :placeholder=\"$t('video.filter.allStatus')\" clearable>\n            <el-option :label=\"$t('video.status.processing')\" value=\"processing\" />\n            <el-option :label=\"$t('video.status.completed')\" value=\"completed\" />\n            <el-option :label=\"$t('video.status.failed')\" value=\"failed\" />\n          </el-select>\n        </el-form-item>\n\n        <el-form-item>\n          <el-button type=\"primary\" @click=\"loadImages\">{{ $t('video.filter.query') }}</el-button>\n          <el-button @click=\"resetFilters\">{{ $t('video.filter.reset') }}</el-button>\n        </el-form-item>\n      </el-form>\n    </el-card>\n\n    <el-row :gutter=\"16\" v-loading=\"loading\">\n      <el-col\n        v-for=\"image in images\"\n        :key=\"image.id\"\n        :xs=\"24\"\n        :sm=\"12\"\n        :md=\"8\"\n        :lg=\"6\"\n      >\n        <el-card class=\"image-card\" shadow=\"hover\">\n          <div class=\"image-wrapper\">\n            <el-image\n              v-if=\"image.status === 'completed' && image.image_url\"\n              :src=\"image.image_url\"\n              fit=\"cover\"\n              class=\"image\"\n              :preview-src-list=\"[image.image_url]\"\n            >\n              <template #error>\n                <div class=\"image-placeholder\">\n                  <el-icon><PictureFilled /></el-icon>\n                  <span>{{ $t('image.loadFailed') }}</span>\n                </div>\n              </template>\n            </el-image>\n\n            <div v-else-if=\"image.status === 'processing'\" class=\"image-placeholder processing\">\n              <el-icon class=\"loading-icon\"><Loading /></el-icon>\n              <span>{{ $t('image.generating') }}</span>\n            </div>\n\n            <div v-else-if=\"image.status === 'failed'\" class=\"image-placeholder failed\">\n              <el-icon><CircleClose /></el-icon>\n              <span>{{ $t('image.generateFailed') }}</span>\n            </div>\n\n            <div v-else class=\"image-placeholder\">\n              <el-icon><Picture /></el-icon>\n              <span>等待生成</span>\n            </div>\n\n            <div class=\"image-overlay\">\n              <el-tag :type=\"getStatusType(image.status)\" size=\"small\">\n                {{ getStatusText(image.status) }}\n              </el-tag>\n            </div>\n          </div>\n\n          <div class=\"image-info\">\n            <div class=\"prompt-text\">{{ truncateText(image.prompt, 60) }}</div>\n            <div class=\"meta-info\">\n              <span class=\"provider-tag\">\n                <el-tag size=\"small\" effect=\"plain\">{{ image.provider }}</el-tag>\n              </span>\n              <span class=\"time-text\">{{ formatTime(image.created_at) }}</span>\n            </div>\n          </div>\n\n          <template #footer>\n            <div class=\"card-actions\">\n              <el-button text size=\"small\" @click=\"viewDetails(image)\">\n                <el-icon><View /></el-icon>\n                查看\n              </el-button>\n              <el-button\n                v-if=\"image.status === 'completed'\"\n                text\n                size=\"small\"\n                @click=\"downloadImage(image)\"\n              >\n                <el-icon><Download /></el-icon>\n                下载\n              </el-button>\n              <el-popconfirm\n                title=\"确定删除该图片吗？\"\n                @confirm=\"deleteImage(image.id)\"\n              >\n                <template #reference>\n                  <el-button text size=\"small\" type=\"danger\">\n                    <el-icon><Delete /></el-icon>\n                    删除\n                  </el-button>\n                </template>\n              </el-popconfirm>\n            </div>\n          </template>\n        </el-card>\n      </el-col>\n    </el-row>\n\n    <el-empty v-if=\"!loading && images.length === 0\" description=\"暂无图片，开始生成吧！\" />\n\n    <el-pagination\n      v-if=\"total > 0\"\n      v-model:current-page=\"pagination.page\"\n      v-model:page-size=\"pagination.page_size\"\n      :total=\"total\"\n      :page-sizes=\"[12, 24, 36, 48]\"\n      layout=\"total, sizes, prev, pager, next, jumper\"\n      @current-change=\"loadImages\"\n      @size-change=\"loadImages\"\n      class=\"pagination\"\n    />\n\n    <GenerateImageDialog\n      v-model=\"showGenerateDialog\"\n      :drama-id=\"filters.drama_id\"\n      @success=\"loadImages\"\n    />\n\n    <ImageDetailDialog\n      v-model=\"showDetailDialog\"\n      :image=\"selectedImage\"\n    />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, reactive, onMounted } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { ElMessage } from 'element-plus'\nimport {\n  Plus, Picture, PictureFilled, Loading, CircleClose,\n  View, Download, Delete\n} from '@element-plus/icons-vue'\nimport { imageAPI } from '@/api/image'\nimport { dramaAPI } from '@/api/drama'\nimport type { ImageGeneration, ImageStatus } from '@/types/image'\nimport type { Drama } from '@/types/drama'\nimport GenerateImageDialog from './components/GenerateImageDialog.vue'\nimport ImageDetailDialog from './components/ImageDetailDialog.vue'\n\nconst route = useRoute()\nconst router = useRouter()\n\nconst loading = ref(false)\nconst images = ref<ImageGeneration[]>([])\nconst dramas = ref<Drama[]>([])\nconst total = ref(0)\nconst showGenerateDialog = ref(false)\nconst showDetailDialog = ref(false)\nconst selectedImage = ref<ImageGeneration>()\n\nconst filters = reactive({\n  drama_id: undefined as string | undefined,\n  status: undefined as ImageStatus | undefined\n})\n\nconst pagination = reactive({\n  page: 1,\n  page_size: 12\n})\n\nconst loadImages = async () => {\n  loading.value = true\n  try {\n    const result = await imageAPI.listImages({\n      drama_id: filters.drama_id,\n      status: filters.status,\n      page: pagination.page,\n      page_size: pagination.page_size\n    })\n    images.value = result.items\n    total.value = result.pagination.total\n  } catch (error: any) {\n    ElMessage.error(error.message || '加载失败')\n  } finally {\n    loading.value = false\n  }\n}\n\nconst loadDramas = async () => {\n  try {\n    const result = await dramaAPI.list({ page: 1, page_size: 100 })\n    dramas.value = result.items\n  } catch (error: any) {\n    console.error('Failed to load dramas:', error)\n  }\n}\n\nconst resetFilters = () => {\n  filters.drama_id = undefined\n  filters.status = undefined\n  pagination.page = 1\n  loadImages()\n}\n\nconst viewDetails = (image: ImageGeneration) => {\n  selectedImage.value = image\n  showDetailDialog.value = true\n}\n\nconst downloadImage = (image: ImageGeneration) => {\n  if (!image.image_url) return\n  window.open(image.image_url, '_blank')\n}\n\nconst deleteImage = async (id: number) => {\n  try {\n    await imageAPI.deleteImage(id)\n    ElMessage.success('删除成功')\n    loadImages()\n  } catch (error: any) {\n    ElMessage.error(error.message || '删除失败')\n  }\n}\n\nconst getStatusType = (status: ImageStatus) => {\n  const types: Record<ImageStatus, any> = {\n    pending: 'info',\n    processing: 'warning',\n    completed: 'success',\n    failed: 'danger'\n  }\n  return types[status]\n}\n\nconst getStatusText = (status: ImageStatus) => {\n  const texts: Record<ImageStatus, string> = {\n    pending: '等待中',\n    processing: '生成中',\n    completed: '已完成',\n    failed: '失败'\n  }\n  return texts[status]\n}\n\nconst truncateText = (text: string, length: number) => {\n  if (text.length <= length) return text\n  return text.substring(0, length) + '...'\n}\n\nconst formatTime = (dateString: string) => {\n  const date = new Date(dateString)\n  const now = new Date()\n  const diff = now.getTime() - date.getTime()\n  \n  if (diff < 60000) return '刚刚'\n  if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`\n  if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`\n  return date.toLocaleDateString('zh-CN')\n}\n\nconst goBack = () => {\n  router.back()\n}\n\nonMounted(() => {\n  const dramaId = route.query.drama_id as string\n  if (dramaId) {\n    filters.drama_id = dramaId\n  }\n  \n  loadDramas()\n  loadImages()\n  \n  const interval = setInterval(() => {\n    const hasProcessing = images.value.some(img => img.status === 'processing')\n    if (hasProcessing) {\n      loadImages()\n    }\n  }, 5000)\n  \n  return () => clearInterval(interval)\n})\n</script>\n\n<style scoped>\n.image-generation-container {\n  padding: 24px;\n  max-width: 1600px;\n  margin: 0 auto;\n}\n\n.page-header {\n  margin-bottom: 20px;\n}\n\n.header-content h2 {\n  margin: 0;\n  font-size: 24px;\n}\n\n.filter-card {\n  margin-bottom: 20px;\n}\n\n.image-card {\n  margin-bottom: 16px;\n  transition: all 0.3s;\n}\n\n.image-card:hover {\n  transform: translateY(-4px);\n}\n\n.image-wrapper {\n  position: relative;\n  width: 100%;\n  padding-bottom: 100%;\n  overflow: hidden;\n  border-radius: 8px;\n  background: #f5f7fa;\n}\n\n.image {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n}\n\n.image-placeholder {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  color: #909399;\n  font-size: 14px;\n}\n\n.image-placeholder .el-icon {\n  font-size: 48px;\n  margin-bottom: 8px;\n}\n\n.image-placeholder.processing {\n  color: #e6a23c;\n}\n\n.image-placeholder.failed {\n  color: #f56c6c;\n}\n\n.loading-icon {\n  animation: rotate 1s linear infinite;\n}\n\n@keyframes rotate {\n  from { transform: rotate(0deg); }\n  to { transform: rotate(360deg); }\n}\n\n.image-overlay {\n  position: absolute;\n  top: 8px;\n  right: 8px;\n}\n\n.image-info {\n  padding: 12px 0;\n}\n\n.prompt-text {\n  font-size: 14px;\n  color: #333;\n  margin-bottom: 8px;\n  line-height: 1.5;\n  min-height: 42px;\n}\n\n.meta-info {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  font-size: 12px;\n  color: #909399;\n}\n\n.card-actions {\n  display: flex;\n  justify-content: space-around;\n  gap: 8px;\n}\n\n.pagination {\n  margin-top: 24px;\n  display: flex;\n  justify-content: center;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/generation/VideoGeneration.vue",
    "content": "<template>\n  <div class=\"video-generation-container\">\n    <el-page-header @back=\"goBack\" class=\"page-header\">\n      <template #content>\n        <div class=\"header-content\">\n          <h2>{{ $t('video.title') }}</h2>\n        </div>\n      </template>\n      <template #extra>\n        <el-button type=\"primary\" @click=\"showGenerateDialog = true\">\n          <el-icon><VideoPlay /></el-icon>\n          {{ $t('video.generate') }}\n        </el-button>\n      </template>\n    </el-page-header>\n\n    <el-card shadow=\"never\" class=\"filter-card\">\n      <el-form inline>\n        <el-form-item :label=\"$t('video.filter.drama')\">\n          <el-select v-model=\"filters.drama_id\" :placeholder=\"$t('video.filter.allDramas')\" clearable>\n            <el-option\n              v-for=\"drama in dramas\"\n              :key=\"drama.id\"\n              :label=\"drama.title\"\n              :value=\"drama.id\"\n            />\n          </el-select>\n        </el-form-item>\n\n        <el-form-item :label=\"$t('video.filter.status')\">\n          <el-select v-model=\"filters.status\" :placeholder=\"$t('video.filter.allStatus')\" clearable>\n            <el-option :label=\"$t('video.status.processing')\" value=\"processing\" />\n            <el-option :label=\"$t('video.status.completed')\" value=\"completed\" />\n            <el-option :label=\"$t('video.status.failed')\" value=\"failed\" />\n          </el-select>\n        </el-form-item>\n\n        <el-form-item>\n          <el-button type=\"primary\" @click=\"loadVideos\">{{ $t('video.filter.query') }}</el-button>\n          <el-button @click=\"resetFilters\">{{ $t('video.filter.reset') }}</el-button>\n        </el-form-item>\n      </el-form>\n    </el-card>\n\n    <el-row :gutter=\"16\" v-loading=\"loading\">\n      <el-col\n        v-for=\"video in videos\"\n        :key=\"video.id\"\n        :xs=\"24\"\n        :sm=\"12\"\n        :md=\"8\"\n        :lg=\"6\"\n      >\n        <el-card class=\"video-card\" shadow=\"hover\">\n          <div class=\"video-wrapper\">\n            <video\n              v-if=\"video.status === 'completed' && video.video_url\"\n              :src=\"video.video_url\"\n              class=\"video-player\"\n              controls\n              :poster=\"video.first_frame_url\"\n            >\n              您的浏览器不支持视频播放\n            </video>\n\n            <div v-else-if=\"video.status === 'processing'\" class=\"video-placeholder processing\">\n              <el-icon class=\"loading-icon\"><Loading /></el-icon>\n              <span>生成中...</span>\n              <div class=\"progress-text\">预计需要 1-3 分钟</div>\n            </div>\n\n            <div v-else-if=\"video.status === 'failed'\" class=\"video-placeholder failed\">\n              <el-icon><CircleClose /></el-icon>\n              <span>生成失败</span>\n            </div>\n\n            <div v-else class=\"video-placeholder\">\n              <el-icon><VideoCamera /></el-icon>\n              <span>等待生成</span>\n            </div>\n\n            <div class=\"video-overlay\">\n              <el-tag :type=\"getStatusType(video.status)\" size=\"small\">\n                {{ getStatusText(video.status) }}\n              </el-tag>\n              <el-tag v-if=\"video.duration\" size=\"small\" class=\"duration-tag\">\n                {{ video.duration }}s\n              </el-tag>\n            </div>\n          </div>\n\n          <div class=\"video-info\">\n            <div class=\"prompt-text\">{{ truncateText(video.prompt, 60) }}</div>\n            <div class=\"meta-info\">\n              <span class=\"provider-tag\">\n                <el-tag size=\"small\" effect=\"plain\">{{ video.provider }}</el-tag>\n              </span>\n              <span class=\"time-text\">{{ formatTime(video.created_at) }}</span>\n            </div>\n            <div v-if=\"video.aspect_ratio || video.resolution\" class=\"specs-info\">\n              <span v-if=\"video.aspect_ratio\" class=\"spec-item\">{{ video.aspect_ratio }}</span>\n              <span v-if=\"video.resolution\" class=\"spec-item\">{{ video.resolution }}</span>\n            </div>\n          </div>\n\n          <template #footer>\n            <div class=\"card-actions\">\n              <el-button text size=\"small\" @click=\"viewDetails(video)\">\n                <el-icon><View /></el-icon>\n                查看\n              </el-button>\n              <el-button\n                v-if=\"video.status === 'completed'\"\n                text\n                size=\"small\"\n                @click=\"downloadVideo(video)\"\n              >\n                <el-icon><Download /></el-icon>\n                下载\n              </el-button>\n              <el-popconfirm\n                title=\"确定删除该视频吗？\"\n                @confirm=\"deleteVideo(video.id)\"\n              >\n                <template #reference>\n                  <el-button text size=\"small\" type=\"danger\">\n                    <el-icon><Delete /></el-icon>\n                    删除\n                  </el-button>\n                </template>\n              </el-popconfirm>\n            </div>\n          </template>\n        </el-card>\n      </el-col>\n    </el-row>\n\n    <el-empty v-if=\"!loading && videos.length === 0\" description=\"暂无视频，开始生成吧！\" />\n\n    <el-pagination\n      v-if=\"total > 0\"\n      v-model:current-page=\"pagination.page\"\n      v-model:page-size=\"pagination.page_size\"\n      :total=\"total\"\n      :page-sizes=\"[12, 24, 36, 48]\"\n      layout=\"total, sizes, prev, pager, next, jumper\"\n      @current-change=\"loadVideos\"\n      @size-change=\"loadVideos\"\n      class=\"pagination\"\n    />\n\n    <GenerateVideoDialog\n      v-model=\"showGenerateDialog\"\n      :drama-id=\"filters.drama_id\"\n      @success=\"loadVideos\"\n    />\n\n    <VideoDetailDialog\n      v-model=\"showDetailDialog\"\n      :video=\"selectedVideo\"\n    />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, reactive, onMounted, onUnmounted } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { ElMessage } from 'element-plus'\nimport {\n  VideoPlay, VideoCamera, Loading, CircleClose,\n  View, Download, Delete\n} from '@element-plus/icons-vue'\nimport { videoAPI } from '@/api/video'\nimport { dramaAPI } from '@/api/drama'\nimport type { VideoGeneration, VideoStatus } from '@/types/video'\nimport type { Drama } from '@/types/drama'\nimport GenerateVideoDialog from './components/GenerateVideoDialog.vue'\nimport VideoDetailDialog from './components/VideoDetailDialog.vue'\n\nconst route = useRoute()\nconst router = useRouter()\n\nconst loading = ref(false)\nconst videos = ref<VideoGeneration[]>([])\nconst dramas = ref<Drama[]>([])\nconst total = ref(0)\nconst showGenerateDialog = ref(false)\nconst showDetailDialog = ref(false)\nconst selectedVideo = ref<VideoGeneration>()\nlet pollInterval: number | null = null\n\nconst filters = reactive({\n  drama_id: undefined as string | undefined,\n  status: undefined as VideoStatus | undefined\n})\n\nconst pagination = reactive({\n  page: 1,\n  page_size: 12\n})\n\nconst loadVideos = async () => {\n  loading.value = true\n  try {\n    const result = await videoAPI.listVideos({\n      drama_id: filters.drama_id,\n      status: filters.status,\n      page: pagination.page,\n      page_size: pagination.page_size\n    })\n    videos.value = result.items\n    total.value = result.pagination.total\n  } catch (error: any) {\n    ElMessage.error(error.message || '加载失败')\n  } finally {\n    loading.value = false\n  }\n}\n\nconst loadDramas = async () => {\n  try {\n    const result = await dramaAPI.list({ page: 1, page_size: 100 })\n    dramas.value = result.items\n  } catch (error: any) {\n    console.error('Failed to load dramas:', error)\n  }\n}\n\nconst resetFilters = () => {\n  filters.drama_id = undefined\n  filters.status = undefined\n  pagination.page = 1\n  loadVideos()\n}\n\nconst viewDetails = (video: VideoGeneration) => {\n  selectedVideo.value = video\n  showDetailDialog.value = true\n}\n\nconst downloadVideo = (video: VideoGeneration) => {\n  if (!video.video_url) return\n  window.open(video.video_url, '_blank')\n}\n\nconst deleteVideo = async (id: number) => {\n  try {\n    await videoAPI.deleteVideo(id)\n    ElMessage.success('删除成功')\n    loadVideos()\n  } catch (error: any) {\n    ElMessage.error(error.message || '删除失败')\n  }\n}\n\nconst getStatusType = (status: VideoStatus) => {\n  const types: Record<VideoStatus, any> = {\n    pending: 'info',\n    processing: 'warning',\n    completed: 'success',\n    failed: 'danger'\n  }\n  return types[status]\n}\n\nconst getStatusText = (status: VideoStatus) => {\n  const texts: Record<VideoStatus, string> = {\n    pending: '等待中',\n    processing: '生成中',\n    completed: '已完成',\n    failed: '失败'\n  }\n  return texts[status]\n}\n\nconst truncateText = (text: string, length: number) => {\n  if (text.length <= length) return text\n  return text.substring(0, length) + '...'\n}\n\nconst formatTime = (dateString: string) => {\n  const date = new Date(dateString)\n  const now = new Date()\n  const diff = now.getTime() - date.getTime()\n  \n  if (diff < 60000) return '刚刚'\n  if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`\n  if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`\n  return date.toLocaleDateString('zh-CN')\n}\n\nconst goBack = () => {\n  router.back()\n}\n\nconst startPolling = () => {\n  pollInterval = setInterval(() => {\n    const hasProcessing = videos.value.some(v => v.status === 'processing')\n    if (hasProcessing) {\n      loadVideos()\n    }\n  }, 10000)\n}\n\nconst stopPolling = () => {\n  if (pollInterval) {\n    clearInterval(pollInterval)\n    pollInterval = null\n  }\n}\n\nonMounted(() => {\n  const dramaId = route.query.drama_id as string\n  if (dramaId) {\n    filters.drama_id = dramaId\n  }\n  \n  loadDramas()\n  loadVideos()\n  startPolling()\n})\n\nonUnmounted(() => {\n  stopPolling()\n})\n</script>\n\n<style scoped>\n.video-generation-container {\n  padding: 24px;\n  max-width: 1600px;\n  margin: 0 auto;\n}\n\n.page-header {\n  margin-bottom: 20px;\n}\n\n.header-content h2 {\n  margin: 0;\n  font-size: 24px;\n}\n\n.filter-card {\n  margin-bottom: 20px;\n}\n\n.video-card {\n  margin-bottom: 16px;\n  transition: all 0.3s;\n}\n\n.video-card:hover {\n  transform: translateY(-4px);\n}\n\n.video-wrapper {\n  position: relative;\n  width: 100%;\n  padding-bottom: 56.25%;\n  overflow: hidden;\n  border-radius: 8px;\n  background: #000;\n}\n\n.video-player {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  object-fit: contain;\n}\n\n.video-placeholder {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  color: #909399;\n  font-size: 14px;\n  background: #1a1a1a;\n}\n\n.video-placeholder .el-icon {\n  font-size: 48px;\n  margin-bottom: 8px;\n}\n\n.video-placeholder.processing {\n  color: #e6a23c;\n}\n\n.video-placeholder.failed {\n  color: #f56c6c;\n}\n\n.progress-text {\n  margin-top: 8px;\n  font-size: 12px;\n  color: #999;\n}\n\n.loading-icon {\n  animation: rotate 1s linear infinite;\n}\n\n@keyframes rotate {\n  from { transform: rotate(0deg); }\n  to { transform: rotate(360deg); }\n}\n\n.video-overlay {\n  position: absolute;\n  top: 8px;\n  right: 8px;\n  display: flex;\n  gap: 4px;\n}\n\n.duration-tag {\n  background: rgba(0, 0, 0, 0.6) !important;\n  color: #fff !important;\n  border: none;\n}\n\n.video-info {\n  padding: 12px 0;\n}\n\n.prompt-text {\n  font-size: 14px;\n  color: #333;\n  margin-bottom: 8px;\n  line-height: 1.5;\n  min-height: 42px;\n}\n\n.meta-info {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  font-size: 12px;\n  color: #909399;\n  margin-bottom: 4px;\n}\n\n.specs-info {\n  display: flex;\n  gap: 8px;\n  font-size: 11px;\n  color: #999;\n}\n\n.spec-item {\n  padding: 2px 6px;\n  background: #f5f7fa;\n  border-radius: 3px;\n}\n\n.card-actions {\n  display: flex;\n  justify-content: space-around;\n  gap: 8px;\n}\n\n.pagination {\n  margin-top: 24px;\n  display: flex;\n  justify-content: center;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/generation/components/GenerateImageDialog.vue",
    "content": "<template>\n  <el-dialog\n    v-model=\"visible\"\n:title=\"$t('imageDialog.title')\"\n    width=\"700px\"\n    :close-on-click-modal=\"false\"\n    @close=\"handleClose\"\n  >\n    <el-form :model=\"form\" :rules=\"rules\" ref=\"formRef\" label-width=\"120px\">\n      <el-form-item :label=\"$t('imageDialog.selectDrama')\" prop=\"drama_id\">\n        <el-select v-model=\"form.drama_id\" :placeholder=\"$t('imageDialog.selectDrama')\" @change=\"onDramaChange\">\n          <el-option\n            v-for=\"drama in dramas\"\n            :key=\"drama.id\"\n            :label=\"drama.title\"\n            :value=\"drama.id\"\n          />\n        </el-select>\n      </el-form-item>\n\n      <el-form-item :label=\"$t('imageDialog.selectScene')\" prop=\"scene_id\">\n        <el-select\n          v-model=\"form.scene_id\"\n          :placeholder=\"$t('imageDialog.selectSceneOptional')\"\n          clearable\n          @change=\"onSceneChange\"\n        >\n          <el-option\n            v-for=\"scene in scenes\"\n            :key=\"scene.id\"\n:label=\"$t('imageDialog.sceneLabel', { number: scene.storyboard_number, title: scene.title })\"\n            :value=\"scene.id\"\n          />\n        </el-select>\n      </el-form-item>\n\n      <el-form-item :label=\"$t('imageDialog.prompt')\" prop=\"prompt\">\n        <el-input\n          v-model=\"form.prompt\"\n          type=\"textarea\"\n          :rows=\"6\"\n:placeholder=\"$t('imageDialog.promptPlaceholder')\"\n          maxlength=\"2000\"\n          show-word-limit\n        />\n      </el-form-item>\n\n      <el-form-item :label=\"$t('imageDialog.negativePrompt')\">\n        <el-input\n          v-model=\"form.negative_prompt\"\n          type=\"textarea\"\n          :rows=\"3\"\n:placeholder=\"$t('imageDialog.negativePromptPlaceholder')\"\n          maxlength=\"1000\"\n          show-word-limit\n        />\n      </el-form-item>\n\n      <el-form-item :label=\"$t('imageDialog.aiService')\">\n        <el-select v-model=\"form.provider\" :placeholder=\"$t('imageDialog.selectService')\">\n          <el-option label=\"OpenAI/DALL-E\" value=\"openai\" />\n          <el-option label=\"Stable Diffusion\" value=\"stable_diffusion\" />\n        </el-select>\n      </el-form-item>\n\n      <el-form-item :label=\"$t('imageDialog.imageSize')\">\n        <el-select v-model=\"form.size\" :placeholder=\"$t('imageDialog.selectSize')\">\n          <el-option :label=\"`1024x1024 (${$t('imageDialog.square')})`\" value=\"1024x1024\" />\n          <el-option :label=\"`1792x1024 (${$t('imageDialog.landscape')})`\" value=\"1792x1024\" />\n          <el-option :label=\"`1024x1792 (${$t('imageDialog.portrait')})`\" value=\"1024x1792\" />\n        </el-select>\n      </el-form-item>\n\n      <el-form-item :label=\"$t('imageDialog.imageQuality')\" v-if=\"form.provider === 'openai'\">\n        <el-radio-group v-model=\"form.quality\">\n          <el-radio label=\"standard\">{{ $t('imageDialog.standard') }}</el-radio>\n          <el-radio label=\"hd\">{{ $t('imageDialog.hd') }}</el-radio>\n        </el-radio-group>\n      </el-form-item>\n\n      <el-form-item :label=\"$t('imageDialog.style')\" v-if=\"form.provider === 'openai'\">\n        <el-radio-group v-model=\"form.style\">\n          <el-radio label=\"vivid\">{{ $t('imageDialog.vivid') }}</el-radio>\n          <el-radio label=\"natural\">{{ $t('imageDialog.natural') }}</el-radio>\n        </el-radio-group>\n      </el-form-item>\n\n      <el-collapse v-if=\"form.provider === 'stable_diffusion'\">\n        <el-collapse-item :title=\"$t('imageDialog.advancedSettings')\" name=\"advanced\">\n          <el-form-item :label=\"$t('imageDialog.samplingSteps')\">\n            <el-slider v-model=\"form.steps\" :min=\"10\" :max=\"50\" :marks=\"stepsMarks\" />\n          </el-form-item>\n\n          <el-form-item :label=\"$t('imageDialog.promptRelevance')\">\n            <el-slider v-model=\"form.cfg_scale\" :min=\"1\" :max=\"20\" :step=\"0.5\" :marks=\"cfgMarks\" />\n          </el-form-item>\n\n          <el-form-item :label=\"$t('imageDialog.randomSeed')\">\n            <el-input-number v-model=\"form.seed\" :min=\"-1\" :placeholder=\"$t('imageDialog.leaveBlankRandom')\" />\n            <span class=\"form-tip\">{{ $t('imageDialog.seedTip') }}</span>\n          </el-form-item>\n        </el-collapse-item>\n      </el-collapse>\n    </el-form>\n\n    <template #footer>\n      <el-button @click=\"handleClose\">{{ $t('common.cancel') }}</el-button>\n      <el-button type=\"primary\" :loading=\"generating\" @click=\"handleGenerate\">\n        {{ $t('imageDialog.generate') }}\n      </el-button>\n    </template>\n  </el-dialog>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, reactive, computed, watch } from 'vue'\nimport { ElMessage, type FormInstance, type FormRules } from 'element-plus'\nimport { useI18n } from 'vue-i18n'\nimport { imageAPI } from '@/api/image'\nimport { dramaAPI } from '@/api/drama'\nimport type { Drama, Scene } from '@/types/drama'\nimport type { GenerateImageRequest } from '@/types/image'\n\ninterface Props {\n  modelValue: boolean\n  dramaId?: string\n}\n\nconst props = defineProps<Props>()\nconst emit = defineEmits<{\n  'update:modelValue': [value: boolean]\n  success: []\n}>()\n\nconst { t } = useI18n()\n\nconst visible = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val)\n})\n\nconst formRef = ref<FormInstance>()\nconst generating = ref(false)\nconst dramas = ref<Drama[]>([])\nconst scenes = ref<Scene[]>([])\n\nconst form = reactive<GenerateImageRequest>({\n  drama_id: props.dramaId || '',\n  scene_id: undefined,\n  prompt: '',\n  negative_prompt: '',\n  provider: 'openai',\n  size: '1024x1024',\n  quality: 'standard',\n  style: 'vivid',\n  steps: 30,\n  cfg_scale: 7.5,\n  seed: undefined\n})\n\nconst rules: FormRules = {\n  drama_id: [\n    { required: true, message: t('imageDialog.pleaseSelectDrama'), trigger: 'change' }\n  ],\n  prompt: [\n    { required: true, message: t('imageDialog.pleaseEnterPrompt'), trigger: 'blur' },\n    { min: 5, message: t('imageDialog.promptMinLength'), trigger: 'blur' }\n  ]\n}\n\nconst stepsMarks = {\n  10: '10',\n  20: '20',\n  30: '30',\n  40: '40',\n  50: '50'\n}\n\nconst cfgMarks = {\n  1: t('imageDialog.weak'),\n  7.5: t('imageDialog.moderate'),\n  15: t('imageDialog.strong'),\n  20: t('imageDialog.veryStrong')\n}\n\nwatch(() => props.modelValue, (val) => {\n  if (val) {\n    loadDramas()\n    if (props.dramaId) {\n      form.drama_id = props.dramaId\n      loadScenes(props.dramaId)\n    }\n  }\n})\n\nconst loadDramas = async () => {\n  try {\n    const result = await dramaAPI.list({ page: 1, page_size: 100 })\n    dramas.value = result.items || []\n  } catch (error: any) {\n    console.error('Failed to load dramas:', error)\n  }\n}\n\nconst loadScenes = async (dramaId: string) => {\n  try {\n    const drama = await dramaAPI.get(dramaId)\n    const allScenes: Scene[] = []\n    \n    if (drama.episodes) {\n      for (const episode of drama.episodes) {\n        if (episode.scenes) {\n          allScenes.push(...episode.scenes)\n        }\n      }\n    }\n    \n    scenes.value = allScenes\n  } catch (error: any) {\n    console.error('Failed to load scenes:', error)\n  }\n}\n\nconst onDramaChange = (dramaId: string) => {\n  form.scene_id = undefined\n  scenes.value = []\n  if (dramaId) {\n    loadScenes(dramaId)\n  }\n}\n\nconst onSceneChange = (sceneId: number | undefined) => {\n  if (!sceneId) return\n  \n  const scene = scenes.value.find(s => s.id === sceneId)\n  if (scene && scene.prompt) {\n    form.prompt = scene.prompt\n  }\n}\n\nconst handleGenerate = async () => {\n  if (!formRef.value) return\n\n  await formRef.value.validate(async (valid) => {\n    if (!valid) return\n\n    generating.value = true\n    try {\n      const params: GenerateImageRequest = {\n        drama_id: form.drama_id,\n        prompt: form.prompt,\n        provider: form.provider\n      }\n\n      if (form.scene_id) {\n        params.scene_id = form.scene_id\n      }\n\n      if (form.negative_prompt) {\n        params.negative_prompt = form.negative_prompt\n      }\n\n      if (form.size) {\n        params.size = form.size\n      }\n\n      if (form.provider === 'openai') {\n        if (form.quality) params.quality = form.quality\n        if (form.style) params.style = form.style\n      }\n\n      if (form.provider === 'stable_diffusion') {\n        if (form.steps) params.steps = form.steps\n        if (form.cfg_scale) params.cfg_scale = form.cfg_scale\n        if (form.seed && form.seed > 0) params.seed = form.seed\n      }\n\n      await imageAPI.generateImage(params)\n      \n      ElMessage.success(t('imageDialog.taskSubmitted'))\n      emit('success')\n      handleClose()\n    } catch (error: any) {\n      ElMessage.error(error.message || t('imageDialog.generateFailed'))\n    } finally {\n      generating.value = false\n    }\n  })\n}\n\nconst handleClose = () => {\n  visible.value = false\n  formRef.value?.resetFields()\n}\n</script>\n\n<style scoped>\n.form-tip {\n  margin-left: 12px;\n  font-size: 12px;\n  color: #999;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/generation/components/GenerateVideoDialog.vue",
    "content": "<template>\n  <el-dialog\n    v-model=\"visible\"\n    title=\"AI 视频生成\"\n    width=\"700px\"\n    :close-on-click-modal=\"false\"\n    @close=\"handleClose\"\n  >\n    <el-form :model=\"form\" :rules=\"rules\" ref=\"formRef\" label-width=\"120px\">\n      <el-form-item label=\"选择剧本\" prop=\"drama_id\">\n        <el-select v-model=\"form.drama_id\" placeholder=\"选择剧本\" @change=\"onDramaChange\">\n          <el-option\n            v-for=\"drama in dramas\"\n            :key=\"drama.id\"\n            :label=\"drama.title\"\n            :value=\"drama.id\"\n          />\n        </el-select>\n      </el-form-item>\n\n      <el-form-item label=\"选择图片\" prop=\"image_gen_id\">\n        <el-select\n          v-model=\"form.image_gen_id\"\n          placeholder=\"选择已生成的图片\"\n          clearable\n          @change=\"onImageChange\"\n        >\n          <el-option\n            v-for=\"image in images\"\n            :key=\"image.id\"\n            :label=\"truncateText(image.prompt, 50)\"\n            :value=\"image.id\"\n          >\n            <div class=\"image-option\">\n              <img v-if=\"image.image_url\" :src=\"image.image_url\" class=\"image-thumb\" />\n              <span>{{ truncateText(image.prompt, 40) }}</span>\n            </div>\n          </el-option>\n        </el-select>\n        <div class=\"form-tip\">或直接输入图片 URL</div>\n      </el-form-item>\n\n      <el-form-item label=\"图片 URL\" prop=\"image_url\">\n        <el-input\n          v-model=\"form.image_url\"\n          placeholder=\"https://example.com/image.jpg\"\n          :disabled=\"!!form.image_gen_id\"\n        />\n      </el-form-item>\n\n      <el-form-item label=\"视频提示词\" prop=\"prompt\">\n        <el-input\n          v-model=\"form.prompt\"\n          type=\"textarea\"\n          :rows=\"5\"\n          placeholder=\"描述视频中的动作和运镜&#10;例如：Camera slowly zooms in, wind blowing through hair, cinematic lighting\"\n          maxlength=\"2000\"\n          show-word-limit\n        />\n      </el-form-item>\n\n      <el-form-item label=\"AI 服务\">\n        <el-select v-model=\"form.provider\" placeholder=\"选择服务\">\n          <el-option label=\"豆包视频\" value=\"doubao\" />\n          <el-option label=\"Runway\" value=\"runway\" />\n          <el-option label=\"Pika\" value=\"pika\" />\n        </el-select>\n      </el-form-item>\n\n      <el-form-item label=\"视频时长\">\n        <el-slider\n          v-model=\"form.duration\"\n          :min=\"3\"\n          :max=\"10\"\n          :marks=\"durationMarks\"\n          show-stops\n        />\n        <span class=\"slider-value\">{{ form.duration }} 秒</span>\n      </el-form-item>\n\n      <el-form-item label=\"宽高比\">\n        <el-radio-group v-model=\"form.aspect_ratio\">\n          <el-radio label=\"16:9\">16:9 (横屏)</el-radio>\n          <el-radio label=\"9:16\">9:16 (竖屏)</el-radio>\n          <el-radio label=\"1:1\">1:1 (方形)</el-radio>\n        </el-radio-group>\n      </el-form-item>\n\n      <el-collapse>\n        <el-collapse-item title=\"高级设置\" name=\"advanced\">\n          <el-form-item label=\"运动强度\">\n            <el-slider\n              v-model=\"form.motion_level\"\n              :min=\"0\"\n              :max=\"100\"\n              :marks=\"motionMarks\"\n            />\n            <span class=\"slider-value\">{{ form.motion_level }}</span>\n          </el-form-item>\n\n          <el-form-item label=\"镜头运动\">\n            <el-select v-model=\"form.camera_motion\" placeholder=\"选择镜头运动\" clearable>\n              <el-option label=\"静止\" value=\"static\" />\n              <el-option label=\"推进 (Zoom In)\" value=\"zoom_in\" />\n              <el-option label=\"拉远 (Zoom Out)\" value=\"zoom_out\" />\n              <el-option label=\"左移 (Pan Left)\" value=\"pan_left\" />\n              <el-option label=\"右移 (Pan Right)\" value=\"pan_right\" />\n              <el-option label=\"上移 (Tilt Up)\" value=\"tilt_up\" />\n              <el-option label=\"下移 (Tilt Down)\" value=\"tilt_down\" />\n              <el-option label=\"环绕 (Orbit)\" value=\"orbit\" />\n            </el-select>\n          </el-form-item>\n\n          <el-form-item label=\"风格\" v-if=\"form.provider === 'doubao'\">\n            <el-input v-model=\"form.style\" placeholder=\"例如：电影级、动画风格\" />\n          </el-form-item>\n\n          <el-form-item label=\"随机种子\">\n            <el-input-number v-model=\"form.seed\" :min=\"-1\" placeholder=\"留空随机\" />\n            <span class=\"form-tip\">设置相同种子可复现视频</span>\n          </el-form-item>\n        </el-collapse-item>\n      </el-collapse>\n    </el-form>\n\n    <template #footer>\n      <el-button @click=\"handleClose\">取消</el-button>\n      <el-button type=\"primary\" :loading=\"generating\" @click=\"handleGenerate\">\n        生成视频\n      </el-button>\n    </template>\n  </el-dialog>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, reactive, computed, watch } from 'vue'\nimport { ElMessage, type FormInstance, type FormRules } from 'element-plus'\nimport { videoAPI } from '@/api/video'\nimport { imageAPI } from '@/api/image'\nimport { dramaAPI } from '@/api/drama'\nimport type { Drama } from '@/types/drama'\nimport type { ImageGeneration } from '@/types/image'\nimport type { GenerateVideoRequest } from '@/types/video'\n\ninterface Props {\n  modelValue: boolean\n  dramaId?: string\n}\n\nconst props = defineProps<Props>()\nconst emit = defineEmits<{\n  'update:modelValue': [value: boolean]\n  success: []\n}>()\n\nconst visible = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val)\n})\n\nconst formRef = ref<FormInstance>()\nconst generating = ref(false)\nconst dramas = ref<Drama[]>([])\nconst images = ref<ImageGeneration[]>([])\n\nconst form = reactive<GenerateVideoRequest & { image_gen_id?: number }>({\n  drama_id: props.dramaId || '',\n  image_gen_id: undefined,\n  image_url: '',\n  prompt: '',\n  provider: 'doubao',\n  duration: 5,\n  aspect_ratio: '16:9',\n  motion_level: 50,\n  camera_motion: undefined,\n  style: undefined,\n  seed: undefined\n})\n\nconst rules: FormRules = {\n  drama_id: [\n    { required: true, message: '请选择剧本', trigger: 'change' }\n  ],\n  prompt: [\n    { required: true, message: '请输入视频提示词', trigger: 'blur' },\n    { min: 5, message: '提示词至少5个字符', trigger: 'blur' }\n  ]\n}\n\nconst durationMarks = {\n  3: '3s',\n  5: '5s',\n  7: '7s',\n  10: '10s'\n}\n\nconst motionMarks = {\n  0: '静态',\n  50: '适中',\n  100: '剧烈'\n}\n\nwatch(() => props.modelValue, (val) => {\n  if (val) {\n    loadDramas()\n    if (props.dramaId) {\n      form.drama_id = props.dramaId\n      loadImages(props.dramaId)\n    }\n  }\n})\n\nconst loadDramas = async () => {\n  try {\n    const result = await dramaAPI.list({ page: 1, page_size: 100 })\n    dramas.value = result.items\n  } catch (error: any) {\n    console.error('Failed to load dramas:', error)\n  }\n}\n\nconst loadImages = async (dramaId: string) => {\n  try {\n    const result = await imageAPI.listImages({\n      drama_id: dramaId,\n      status: 'completed',\n      page: 1,\n      page_size: 100\n    })\n    images.value = result.items\n  } catch (error: any) {\n    console.error('Failed to load images:', error)\n  }\n}\n\nconst onDramaChange = (dramaId: string) => {\n  form.image_gen_id = undefined\n  form.image_url = ''\n  images.value = []\n  if (dramaId) {\n    loadImages(dramaId)\n  }\n}\n\nconst onImageChange = (imageGenId: number | undefined) => {\n  if (!imageGenId) {\n    form.image_url = ''\n    return\n  }\n  \n  const image = images.value.find(img => img.id === imageGenId)\n  if (image && image.image_url) {\n    form.image_url = image.image_url\n    form.prompt = image.prompt\n  }\n}\n\nconst truncateText = (text: string, length: number) => {\n  if (text.length <= length) return text\n  return text.substring(0, length) + '...'\n}\n\nconst handleGenerate = async () => {\n  console.log('handleGenerate called')\n  \n  if (!formRef.value) {\n    console.error('formRef is null')\n    ElMessage.error('表单初始化失败，请刷新页面重试')\n    return\n  }\n\n  try {\n    const valid = await formRef.value.validate()\n    console.log('Form validation result:', valid)\n    \n    if (!valid) {\n      console.log('Form validation failed')\n      return\n    }\n\n    generating.value = true\n    console.log('Starting video generation...', form)\n    \n    try {\n      if (form.image_gen_id) {\n        console.log('Generating from image:', form.image_gen_id)\n        await videoAPI.generateFromImage(form.image_gen_id)\n      } else {\n        const params: GenerateVideoRequest = {\n          drama_id: form.drama_id,\n          prompt: form.prompt,\n          provider: form.provider\n        }\n\n        // 判断参考图模式\n        if (form.image_url && form.image_url.trim()) {\n          params.image_url = form.image_url\n          params.reference_mode = 'single'\n        } else {\n          // 纯文本生成，无参考图\n          params.reference_mode = 'none'\n        }\n\n        if (form.duration) params.duration = form.duration\n        if (form.aspect_ratio) params.aspect_ratio = form.aspect_ratio\n        if (form.motion_level !== undefined) params.motion_level = form.motion_level\n        if (form.camera_motion) params.camera_motion = form.camera_motion\n        if (form.style) params.style = form.style\n        if (form.seed && form.seed > 0) params.seed = form.seed\n\n        console.log('Generating video with params:', params)\n        await videoAPI.generateVideo(params)\n      }\n      \n      ElMessage.success('视频生成任务已提交，请稍后查看结果')\n      emit('success')\n      handleClose()\n    } catch (error: any) {\n      console.error('Video generation failed:', error)\n      ElMessage.error(error.response?.data?.message || error.message || '生成失败')\n    } finally {\n      generating.value = false\n    }\n  } catch (error: any) {\n    console.error('Form validation error:', error)\n    ElMessage.warning('请检查表单填写是否完整')\n  }\n}\n\nconst handleClose = () => {\n  visible.value = false\n  formRef.value?.resetFields()\n}\n</script>\n\n<style scoped>\n.form-tip {\n  margin-top: 4px;\n  font-size: 12px;\n  color: #999;\n}\n\n.slider-value {\n  margin-left: 12px;\n  font-size: 14px;\n  font-weight: 500;\n  color: #409eff;\n}\n\n.image-option {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.image-thumb {\n  width: 40px;\n  height: 40px;\n  object-fit: cover;\n  border-radius: 4px;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/generation/components/ImageDetailDialog.vue",
    "content": "<template>\n  <el-dialog\n    v-model=\"visible\"\n    title=\"图片详情\"\n    width=\"900px\"\n    @close=\"handleClose\"\n  >\n    <div v-if=\"image\" class=\"image-detail\">\n      <el-row :gutter=\"20\">\n        <el-col :span=\"14\">\n          <div class=\"image-preview\">\n            <el-image\n              v-if=\"image.status === 'completed' && image.image_url\"\n              :src=\"image.image_url\"\n              fit=\"contain\"\n              class=\"preview-image\"\n              :preview-src-list=\"[image.image_url]\"\n            >\n              <template #error>\n                <div class=\"image-error\">\n                  <el-icon><PictureFilled /></el-icon>\n                  <span>加载失败</span>\n                </div>\n              </template>\n            </el-image>\n\n            <div v-else-if=\"image.status === 'processing'\" class=\"image-status\">\n              <el-icon class=\"loading-icon\"><Loading /></el-icon>\n              <span>生成中，请稍候...</span>\n            </div>\n\n            <div v-else-if=\"image.status === 'failed'\" class=\"image-status error\">\n              <el-icon><CircleClose /></el-icon>\n              <span>生成失败</span>\n              <div class=\"error-message\">{{ image.error_msg }}</div>\n            </div>\n          </div>\n        </el-col>\n\n        <el-col :span=\"10\">\n          <div class=\"image-info\">\n            <el-descriptions :column=\"1\" border>\n              <el-descriptions-item label=\"状态\">\n                <el-tag :type=\"getStatusType(image.status)\">\n                  {{ getStatusText(image.status) }}\n                </el-tag>\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"AI 服务\">\n                {{ image.provider }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"模型\" v-if=\"image.model\">\n                {{ image.model }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"尺寸\" v-if=\"image.size\">\n                {{ image.size }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"分辨率\" v-if=\"image.width && image.height\">\n                {{ image.width }} × {{ image.height }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"质量\" v-if=\"image.quality\">\n                {{ image.quality }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"风格\" v-if=\"image.style\">\n                {{ image.style }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"采样步数\" v-if=\"image.steps\">\n                {{ image.steps }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"CFG Scale\" v-if=\"image.cfg_scale\">\n                {{ image.cfg_scale }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"随机种子\" v-if=\"image.seed\">\n                {{ image.seed }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"创建时间\">\n                {{ formatDateTime(image.created_at) }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"完成时间\" v-if=\"image.completed_at\">\n                {{ formatDateTime(image.completed_at) }}\n              </el-descriptions-item>\n            </el-descriptions>\n\n            <el-divider />\n\n            <div class=\"prompt-section\">\n              <h4>提示词</h4>\n              <div class=\"prompt-text\">{{ image.prompt }}</div>\n            </div>\n\n            <div v-if=\"image.negative_prompt\" class=\"prompt-section\">\n              <h4>反向提示词</h4>\n              <div class=\"prompt-text\">{{ image.negative_prompt }}</div>\n            </div>\n          </div>\n        </el-col>\n      </el-row>\n    </div>\n\n    <template #footer>\n      <el-button @click=\"handleClose\">关闭</el-button>\n      <el-button\n        v-if=\"image?.status === 'completed' && image?.image_url\"\n        type=\"primary\"\n        @click=\"downloadImage\"\n      >\n        <el-icon><Download /></el-icon>\n        下载图片\n      </el-button>\n      <el-button\n        v-if=\"image?.status === 'completed'\"\n        type=\"success\"\n        @click=\"regenerate\"\n      >\n        <el-icon><Refresh /></el-icon>\n        重新生成\n      </el-button>\n    </template>\n  </el-dialog>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { ElMessage } from 'element-plus'\nimport {\n  PictureFilled, Loading, CircleClose,\n  Download, Refresh\n} from '@element-plus/icons-vue'\nimport { imageAPI } from '@/api/image'\nimport type { ImageGeneration, ImageStatus } from '@/types/image'\n\ninterface Props {\n  modelValue: boolean\n  image?: ImageGeneration\n}\n\nconst props = defineProps<Props>()\nconst emit = defineEmits<{\n  'update:modelValue': [value: boolean]\n  regenerate: [image: ImageGeneration]\n}>()\n\nconst visible = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val)\n})\n\nconst getStatusType = (status: ImageStatus) => {\n  const types: Record<ImageStatus, any> = {\n    pending: 'info',\n    processing: 'warning',\n    completed: 'success',\n    failed: 'danger'\n  }\n  return types[status]\n}\n\nconst getStatusText = (status: ImageStatus) => {\n  const texts: Record<ImageStatus, string> = {\n    pending: '等待中',\n    processing: '生成中',\n    completed: '已完成',\n    failed: '失败'\n  }\n  return texts[status]\n}\n\nconst formatDateTime = (dateString: string) => {\n  return new Date(dateString).toLocaleString('zh-CN')\n}\n\nconst downloadImage = () => {\n  if (!props.image?.image_url) return\n  window.open(props.image.image_url, '_blank')\n}\n\nconst regenerate = () => {\n  if (!props.image) return\n  emit('regenerate', props.image)\n  handleClose()\n}\n\nconst handleClose = () => {\n  visible.value = false\n}\n</script>\n\n<style scoped>\n.image-detail {\n  min-height: 400px;\n}\n\n.image-preview {\n  width: 100%;\n  height: 600px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: #f5f7fa;\n  border-radius: 8px;\n  overflow: hidden;\n}\n\n.preview-image {\n  width: 100%;\n  height: 100%;\n}\n\n.image-status {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 12px;\n  color: #909399;\n}\n\n.image-status .el-icon {\n  font-size: 64px;\n}\n\n.image-status.error {\n  color: #f56c6c;\n}\n\n.loading-icon {\n  animation: rotate 1s linear infinite;\n}\n\n@keyframes rotate {\n  from { transform: rotate(0deg); }\n  to { transform: rotate(360deg); }\n}\n\n.error-message {\n  margin-top: 8px;\n  padding: 12px;\n  background: #fef0f0;\n  border: 1px solid #fde2e2;\n  border-radius: 4px;\n  font-size: 14px;\n  color: #f56c6c;\n  max-width: 300px;\n  word-wrap: break-word;\n}\n\n.image-error {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 8px;\n  color: #909399;\n}\n\n.image-error .el-icon {\n  font-size: 48px;\n}\n\n.image-info {\n  height: 600px;\n  overflow-y: auto;\n}\n\n.prompt-section {\n  margin-bottom: 20px;\n}\n\n.prompt-section h4 {\n  margin: 0 0 8px 0;\n  font-size: 14px;\n  font-weight: 600;\n  color: #333;\n}\n\n.prompt-text {\n  padding: 12px;\n  background: #f5f7fa;\n  border-radius: 4px;\n  font-size: 14px;\n  line-height: 1.6;\n  color: #666;\n  white-space: pre-wrap;\n  word-wrap: break-word;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/generation/components/VideoDetailDialog.vue",
    "content": "<template>\n  <el-dialog\n    v-model=\"visible\"\n    title=\"视频详情\"\n    width=\"1000px\"\n    @close=\"handleClose\"\n  >\n    <div v-if=\"video\" class=\"video-detail\">\n      <el-row :gutter=\"20\">\n        <el-col :span=\"16\">\n          <div class=\"video-preview\">\n            <video\n              v-if=\"video.status === 'completed' && video.video_url\"\n              :src=\"video.video_url\"\n              class=\"preview-video\"\n              controls\n              autoplay\n              loop\n              :poster=\"video.first_frame_url\"\n            >\n              您的浏览器不支持视频播放\n            </video>\n\n            <div v-else-if=\"video.status === 'processing'\" class=\"video-status\">\n              <el-icon class=\"loading-icon\"><Loading /></el-icon>\n              <span>生成中，请稍候...</span>\n              <div class=\"status-message\">预计需要 1-3 分钟</div>\n            </div>\n\n            <div v-else-if=\"video.status === 'failed'\" class=\"video-status error\">\n              <el-icon><CircleClose /></el-icon>\n              <span>生成失败</span>\n              <div class=\"error-message\">{{ video.error_msg }}</div>\n            </div>\n\n            <div v-else class=\"video-status\">\n              <el-icon><VideoCamera /></el-icon>\n              <span>等待生成</span>\n            </div>\n          </div>\n        </el-col>\n\n        <el-col :span=\"8\">\n          <div class=\"video-info\">\n            <el-descriptions :column=\"1\" border>\n              <el-descriptions-item label=\"状态\">\n                <el-tag :type=\"getStatusType(video.status)\">\n                  {{ getStatusText(video.status) }}\n                </el-tag>\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"AI 服务\">\n                {{ video.provider }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"模型\" v-if=\"video.model\">\n                {{ video.model }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"时长\" v-if=\"video.duration\">\n                {{ video.duration }} 秒\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"宽高比\" v-if=\"video.aspect_ratio\">\n                {{ video.aspect_ratio }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"分辨率\" v-if=\"video.width && video.height\">\n                {{ video.width }} × {{ video.height }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"FPS\" v-if=\"video.fps\">\n                {{ video.fps }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"运动强度\" v-if=\"video.motion_level !== undefined\">\n                {{ video.motion_level }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"镜头运动\" v-if=\"video.camera_motion\">\n                {{ getCameraMotionText(video.camera_motion) }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"风格\" v-if=\"video.style\">\n                {{ video.style }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"随机种子\" v-if=\"video.seed\">\n                {{ video.seed }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"创建时间\">\n                {{ formatDateTime(video.created_at) }}\n              </el-descriptions-item>\n\n              <el-descriptions-item label=\"完成时间\" v-if=\"video.completed_at\">\n                {{ formatDateTime(video.completed_at) }}\n              </el-descriptions-item>\n            </el-descriptions>\n\n            <el-divider />\n\n            <div class=\"prompt-section\">\n              <h4>视频提示词</h4>\n              <div class=\"prompt-text\">{{ video.prompt }}</div>\n            </div>\n\n            <div v-if=\"video.image_url\" class=\"image-section\">\n              <h4>源图片</h4>\n              <el-image\n                :src=\"video.image_url\"\n                fit=\"contain\"\n                class=\"source-image\"\n                :preview-src-list=\"[video.image_url]\"\n              />\n            </div>\n          </div>\n        </el-col>\n      </el-row>\n    </div>\n\n    <template #footer>\n      <el-button @click=\"handleClose\">关闭</el-button>\n      <el-button\n        v-if=\"video?.status === 'completed' && video?.video_url\"\n        type=\"primary\"\n        @click=\"downloadVideo\"\n      >\n        <el-icon><Download /></el-icon>\n        下载视频\n      </el-button>\n      <el-button\n        v-if=\"video?.status === 'completed'\"\n        type=\"success\"\n        @click=\"regenerate\"\n      >\n        <el-icon><Refresh /></el-icon>\n        重新生成\n      </el-button>\n    </template>\n  </el-dialog>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { ElMessage } from 'element-plus'\nimport {\n  VideoCamera, Loading, CircleClose,\n  Download, Refresh\n} from '@element-plus/icons-vue'\nimport type { VideoGeneration, VideoStatus } from '@/types/video'\nimport { CAMERA_MOTIONS } from '@/types/video'\n\ninterface Props {\n  modelValue: boolean\n  video?: VideoGeneration\n}\n\nconst props = defineProps<Props>()\nconst emit = defineEmits<{\n  'update:modelValue': [value: boolean]\n  regenerate: [video: VideoGeneration]\n}>()\n\nconst visible = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val)\n})\n\nconst getStatusType = (status: VideoStatus) => {\n  const types: Record<VideoStatus, any> = {\n    pending: 'info',\n    processing: 'warning',\n    completed: 'success',\n    failed: 'danger'\n  }\n  return types[status]\n}\n\nconst getStatusText = (status: VideoStatus) => {\n  const texts: Record<VideoStatus, string> = {\n    pending: '等待中',\n    processing: '生成中',\n    completed: '已完成',\n    failed: '失败'\n  }\n  return texts[status]\n}\n\nconst getCameraMotionText = (motion: string) => {\n  const item = CAMERA_MOTIONS.find(m => m.value === motion)\n  return item ? item.label : motion\n}\n\nconst formatDateTime = (dateString: string) => {\n  return new Date(dateString).toLocaleString('zh-CN')\n}\n\nconst downloadVideo = () => {\n  if (!props.video?.video_url) return\n  window.open(props.video.video_url, '_blank')\n}\n\nconst regenerate = () => {\n  if (!props.video) return\n  emit('regenerate', props.video)\n  handleClose()\n}\n\nconst handleClose = () => {\n  visible.value = false\n}\n</script>\n\n<style scoped>\n.video-detail {\n  min-height: 500px;\n}\n\n.video-preview {\n  width: 100%;\n  height: 600px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: #000;\n  border-radius: 8px;\n  overflow: hidden;\n}\n\n.preview-video {\n  width: 100%;\n  height: 100%;\n  object-fit: contain;\n}\n\n.video-status {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 12px;\n  color: #909399;\n}\n\n.video-status .el-icon {\n  font-size: 64px;\n}\n\n.video-status.error {\n  color: #f56c6c;\n}\n\n.loading-icon {\n  animation: rotate 1s linear infinite;\n}\n\n@keyframes rotate {\n  from { transform: rotate(0deg); }\n  to { transform: rotate(360deg); }\n}\n\n.status-message {\n  font-size: 12px;\n  color: #999;\n}\n\n.error-message {\n  margin-top: 8px;\n  padding: 12px;\n  background: #fef0f0;\n  border: 1px solid #fde2e2;\n  border-radius: 4px;\n  font-size: 14px;\n  color: #f56c6c;\n  max-width: 300px;\n  word-wrap: break-word;\n}\n\n.video-info {\n  height: 600px;\n  overflow-y: auto;\n}\n\n.prompt-section,\n.image-section {\n  margin-bottom: 20px;\n}\n\n.prompt-section h4,\n.image-section h4 {\n  margin: 0 0 8px 0;\n  font-size: 14px;\n  font-weight: 600;\n  color: #333;\n}\n\n.prompt-text {\n  padding: 12px;\n  background: #f5f7fa;\n  border-radius: 4px;\n  font-size: 14px;\n  line-height: 1.6;\n  color: #666;\n  white-space: pre-wrap;\n  word-wrap: break-word;\n}\n\n.source-image {\n  width: 100%;\n  border-radius: 4px;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/script/ScriptEdit.vue",
    "content": "<template>\n  <div class=\"script-edit-container\">\n    <el-page-header @back=\"goBack\" title=\"返回\">\n      <template #content>\n        <h2>剧本编辑</h2>\n      </template>\n    </el-page-header>\n    <p>功能开发中...</p>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { useRouter } from 'vue-router'\n\nconst router = useRouter()\n\nconst goBack = () => {\n  router.back()\n}\n</script>\n\n<style scoped>\n.script-edit-container {\n  padding: 20px;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/settings/AIConfig.vue",
    "content": "<template>\n  <div class=\"page-container\">\n    <div class=\"content-wrapper animate-fade-in\">\n      <!-- Page Header / 页面头部 -->\n      <PageHeader\n        :title=\"$t('aiConfig.title')\"\n        :subtitle=\"$t('aiConfig.subtitle') || '管理 AI 服务配置'\"\n        :show-back=\"true\"\n        :back-text=\"$t('common.back')\"\n      >\n        <template #actions>\n          <el-button type=\"primary\" @click=\"showCreateDialog\">\n            <el-icon><Plus /></el-icon>\n            <span>{{ $t(\"aiConfig.addConfig\") }}</span>\n          </el-button>\n        </template>\n      </PageHeader>\n\n      <!-- Tabs / 标签页 -->\n      <div class=\"tabs-wrapper\">\n        <el-tabs\n          v-model=\"activeTab\"\n          @tab-change=\"handleTabChange\"\n          class=\"config-tabs\"\n        >\n          <el-tab-pane :label=\"$t('aiConfig.tabs.text')\" name=\"text\">\n            <ConfigList\n              :configs=\"configs\"\n              :loading=\"loading\"\n              :show-test-button=\"true\"\n              @edit=\"handleEdit\"\n              @delete=\"handleDelete\"\n              @toggle-active=\"handleToggleActive\"\n              @test=\"handleTest\"\n            />\n          </el-tab-pane>\n\n          <el-tab-pane :label=\"$t('aiConfig.tabs.image')\" name=\"image\">\n            <ConfigList\n              :configs=\"configs\"\n              :loading=\"loading\"\n              :show-test-button=\"false\"\n              @edit=\"handleEdit\"\n              @delete=\"handleDelete\"\n              @toggle-active=\"handleToggleActive\"\n            />\n          </el-tab-pane>\n\n          <el-tab-pane :label=\"$t('aiConfig.tabs.video')\" name=\"video\">\n            <ConfigList\n              :configs=\"configs\"\n              :loading=\"loading\"\n              :show-test-button=\"false\"\n              @edit=\"handleEdit\"\n              @delete=\"handleDelete\"\n              @toggle-active=\"handleToggleActive\"\n            />\n          </el-tab-pane>\n        </el-tabs>\n      </div>\n\n      <!-- Edit/Create Dialog / 编辑创建弹窗 -->\n      <el-dialog\n        v-model=\"dialogVisible\"\n        :title=\"isEdit ? $t('aiConfig.editConfig') : $t('aiConfig.addConfig')\"\n        width=\"600px\"\n        :close-on-click-modal=\"false\"\n      >\n        <el-form ref=\"formRef\" :model=\"form\" :rules=\"rules\" label-width=\"100px\">\n          <el-form-item :label=\"$t('aiConfig.form.name')\" prop=\"name\">\n            <el-input\n              v-model=\"form.name\"\n              :placeholder=\"$t('aiConfig.form.namePlaceholder')\"\n            />\n          </el-form-item>\n\n          <el-form-item :label=\"$t('aiConfig.form.provider')\" prop=\"provider\">\n            <el-select\n              v-model=\"form.provider\"\n              :placeholder=\"$t('aiConfig.form.providerPlaceholder')\"\n              @change=\"handleProviderChange\"\n              style=\"width: 100%\"\n            >\n              <el-option\n                v-for=\"provider in availableProviders\"\n                :key=\"provider.id\"\n                :label=\"provider.name\"\n                :value=\"provider.id\"\n                :disabled=\"provider.disabled\"\n              />\n            </el-select>\n            <div class=\"form-tip\">{{ $t(\"aiConfig.form.providerTip\") }}</div>\n          </el-form-item>\n\n          <el-form-item :label=\"$t('aiConfig.form.priority')\" prop=\"priority\">\n            <el-input-number\n              v-model=\"form.priority\"\n              :min=\"0\"\n              :max=\"100\"\n              :step=\"1\"\n              style=\"width: 100%\"\n            />\n            <div class=\"form-tip\">{{ $t(\"aiConfig.form.priorityTip\") }}</div>\n          </el-form-item>\n\n          <el-form-item :label=\"$t('aiConfig.form.model')\" prop=\"model\">\n            <el-select\n              v-model=\"form.model\"\n              :placeholder=\"$t('aiConfig.form.modelPlaceholder')\"\n              multiple\n              filterable\n              allow-create\n              default-first-option\n              collapse-tags\n              collapse-tags-tooltip\n              style=\"width: 100%\"\n            >\n              <el-option\n                v-for=\"model in availableModels\"\n                :key=\"model\"\n                :label=\"model\"\n                :value=\"model\"\n              />\n            </el-select>\n            <div class=\"form-tip\">{{ $t(\"aiConfig.form.modelTip\") }}</div>\n          </el-form-item>\n\n          <el-form-item :label=\"$t('aiConfig.form.baseUrl')\" prop=\"base_url\">\n            <el-input\n              v-model=\"form.base_url\"\n              :placeholder=\"$t('aiConfig.form.baseUrlPlaceholder')\"\n            />\n            <div class=\"form-tip\">\n              {{ $t(\"aiConfig.form.baseUrlTip\") }}\n              <br />\n              {{ $t(\"aiConfig.form.fullEndpoint\") }}: {{ fullEndpointExample }}\n            </div>\n          </el-form-item>\n\n          <el-form-item :label=\"$t('aiConfig.form.apiKey')\" prop=\"api_key\">\n            <el-input\n              v-model=\"form.api_key\"\n              type=\"password\"\n              show-password\n              :placeholder=\"$t('aiConfig.form.apiKeyPlaceholder')\"\n            />\n            <div class=\"form-tip\">{{ $t(\"aiConfig.form.apiKeyTip\") }}</div>\n          </el-form-item>\n\n          <el-form-item v-if=\"isEdit\" :label=\"$t('aiConfig.form.isActive')\">\n            <el-switch v-model=\"form.is_active\" />\n          </el-form-item>\n        </el-form>\n\n        <template #footer>\n          <el-button @click=\"dialogVisible = false\">{{\n            $t(\"common.cancel\")\n          }}</el-button>\n          <el-button\n            v-if=\"form.service_type === 'text'\"\n            @click=\"testConnection\"\n            :loading=\"testing\"\n            >{{ $t(\"aiConfig.actions.test\") }}</el-button\n          >\n          <el-button type=\"primary\" @click=\"handleSubmit\" :loading=\"submitting\">\n            {{ isEdit ? $t(\"common.save\") : $t(\"common.create\") }}\n          </el-button>\n        </template>\n      </el-dialog>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, reactive, onMounted, computed } from \"vue\";\nimport { useRouter } from \"vue-router\";\nimport {\n  ElMessage,\n  ElMessageBox,\n  type FormInstance,\n  type FormRules,\n} from \"element-plus\";\nimport { Plus, ArrowLeft } from \"@element-plus/icons-vue\";\nimport { aiAPI } from \"@/api/ai\";\nimport { PageHeader } from \"@/components/common\";\nimport type {\n  AIServiceConfig,\n  AIServiceType,\n  CreateAIConfigRequest,\n  UpdateAIConfigRequest,\n} from \"@/types/ai\";\nimport ConfigList from \"./components/ConfigList.vue\";\n\nconst router = useRouter();\n\nconst activeTab = ref<AIServiceType>(\"text\");\nconst loading = ref(false);\nconst configs = ref<AIServiceConfig[]>([]);\nconst dialogVisible = ref(false);\nconst isEdit = ref(false);\nconst editingId = ref<number>();\nconst formRef = ref<FormInstance>();\nconst submitting = ref(false);\nconst testing = ref(false);\n\nconst form = reactive<\n  CreateAIConfigRequest & { is_active?: boolean; provider?: string }\n>({\n  service_type: \"text\",\n  provider: \"\",\n  name: \"\",\n  base_url: \"\",\n  api_key: \"\",\n  model: [], // 改为数组支持多选\n  priority: 0, // 默认优先级为0\n  is_active: true,\n});\n\n// 厂商和模型配置\ninterface ProviderConfig {\n  id: string;\n  name: string;\n  models: string[];\n  disabled?: boolean;\n}\n\nconst providerConfigs: Record<AIServiceType, ProviderConfig[]> = {\n  text: [\n    {\n      id: \"openai\",\n      name: \"OpenAI\",\n      models: [\"gpt-5.2\", \"gemini-3-flash-preview\"],\n    },\n    {\n      id: \"chatfire\",\n      name: \"Chatfire\",\n      models: [\n        \"gemini-3-flash-preview\",\n        \"claude-sonnet-4-5-20250929\",\n        \"doubao-seed-1-8-251228\",\n      ],\n    },\n    {\n      id: \"gemini\",\n      name: \"Google Gemini\",\n      models: [\"gemini-2.5-pro\", \"gemini-3-flash-preview\"],\n    },\n  ],\n  image: [\n    {\n      id: \"volcengine\",\n      name: \"火山引擎\",\n      models: [\"doubao-seedream-4-5-251128\", \"doubao-seedream-4-0-250828\"],\n    },\n    {\n      id: \"chatfire\",\n      name: \"Chatfire\",\n      models: [\"doubao-seedream-4-5-251128\", \"nano-banana-pro\"],\n    },\n    {\n      id: \"gemini\",\n      name: \"Google Gemini\",\n      models: [\"gemini-3-pro-image-preview\"],\n    },\n    { id: \"openai\", name: \"OpenAI\", models: [\"dall-e-3\", \"dall-e-2\"] },\n  ],\n  video: [\n    {\n      id: \"volces\",\n      name: \"火山引擎\",\n      models: [\n        \"doubao-seedance-1-5-pro-251215\",\n        \"doubao-seedance-1-0-lite-i2v-250428\",\n        \"doubao-seedance-1-0-lite-t2v-250428\",\n        \"doubao-seedance-1-0-pro-250528\",\n        \"doubao-seedance-1-0-pro-fast-251015\",\n      ],\n    },\n    {\n      id: \"chatfire\",\n      name: \"Chatfire\",\n      models: [\n        \"doubao-seedance-1-5-pro-251215\",\n        \"doubao-seedance-1-0-lite-i2v-250428\",\n        \"doubao-seedance-1-0-lite-t2v-250428\",\n        \"doubao-seedance-1-0-pro-250528\",\n        \"doubao-seedance-1-0-pro-fast-251015\",\n        \"sora-2\",\n        \"sora-2-pro\",\n      ],\n    },\n    { id: \"openai\", name: \"OpenAI\", models: [\"sora-2\", \"sora-2-pro\"] },\n    //    { id: 'minimax', name: 'MiniMax', models: ['MiniMax-Hailuo-2.3', 'MiniMax-Hailuo-2.3-Fast', 'MiniMax-Hailuo-02'] }\n  ],\n};\n\n// 当前可用的厂商列表（只显示有激活配置的）\nconst availableProviders = computed(() => {\n  // 获取当前service_type下所有激活的配置\n  const activeConfigs = configs.value.filter(\n    (c) => c.service_type === form.service_type && c.is_active,\n  );\n\n  // 提取所有激活配置的provider，去重\n  const activeProviderIds = new Set(activeConfigs.map((c) => c.provider));\n\n  // 从providerConfigs中筛选出有激活配置的provider\n  const allProviders = providerConfigs[form.service_type] || [];\n  return allProviders.filter((p) => activeProviderIds.has(p.id));\n});\n\n// 当前可用的模型列表（从已激活的配置中获取）\nconst availableModels = computed(() => {\n  if (!form.provider) return [];\n\n  // 从已激活的配置中提取该 provider 的所有模型\n  const activeConfigsForProvider = configs.value.filter(\n    (c) =>\n      c.provider === form.provider &&\n      c.service_type === form.service_type &&\n      c.is_active,\n  );\n\n  // 提取所有模型，去重\n  const models = new Set<string>();\n  activeConfigsForProvider.forEach((config) => {\n    config.model.forEach((m) => models.add(m));\n  });\n\n  return Array.from(models);\n});\n\n// 完整端点示例\nconst fullEndpointExample = computed(() => {\n  const baseUrl = form.base_url || \"https://api.example.com\";\n  const provider = form.provider;\n  const serviceType = form.service_type;\n\n  let endpoint = \"\";\n\n  if (serviceType === \"text\") {\n    if (provider === \"gemini\" || provider === \"google\") {\n      endpoint = \"/v1beta/models/{model}:generateContent\";\n    } else {\n      endpoint = \"/chat/completions\";\n    }\n  } else if (serviceType === \"image\") {\n    if (provider === \"gemini\" || provider === \"google\") {\n      endpoint = \"/v1beta/models/{model}:generateContent\";\n    } else {\n      endpoint = \"/images/generations\";\n    }\n  } else if (serviceType === \"video\") {\n    if (provider === \"chatfire\") {\n      endpoint = \"/video/generations\";\n    } else if (\n      provider === \"doubao\" ||\n      provider === \"volcengine\" ||\n      provider === \"volces\"\n    ) {\n      endpoint = \"/contents/generations/tasks\";\n    } else if (provider === \"openai\") {\n      endpoint = \"/videos\";\n    } else {\n      endpoint = \"/video/generations\";\n    }\n  }\n\n  return baseUrl + endpoint;\n});\n\nconst rules: FormRules = {\n  name: [{ required: true, message: \"请输入配置名称\", trigger: \"blur\" }],\n  provider: [{ required: true, message: \"请选择厂商\", trigger: \"change\" }],\n  base_url: [\n    { required: true, message: \"请输入 Base URL\", trigger: \"blur\" },\n    { type: \"url\", message: \"请输入正确的 URL 格式\", trigger: \"blur\" },\n  ],\n  api_key: [{ required: true, message: \"请输入 API Key\", trigger: \"blur\" }],\n  model: [\n    {\n      required: true,\n      message: \"请至少选择一个模型\",\n      trigger: \"change\",\n      validator: (rule: any, value: any, callback: any) => {\n        if (Array.isArray(value) && value.length > 0) {\n          callback();\n        } else if (typeof value === \"string\" && value.length > 0) {\n          callback();\n        } else {\n          callback(new Error(\"请至少选择一个模型\"));\n        }\n      },\n    },\n  ],\n};\n\nconst loadConfigs = async () => {\n  loading.value = true;\n  try {\n    configs.value = await aiAPI.list(activeTab.value);\n  } catch (error: any) {\n    ElMessage.error(error.message || \"加载失败\");\n  } finally {\n    loading.value = false;\n  }\n};\n\n// 生成随机配置名称\nconst generateConfigName = (\n  provider: string,\n  serviceType: AIServiceType,\n): string => {\n  const providerNames: Record<string, string> = {\n    chatfire: \"ChatFire\",\n    openai: \"OpenAI\",\n    gemini: \"Gemini\",\n    google: \"Google\",\n  };\n\n  const serviceNames: Record<AIServiceType, string> = {\n    text: \"文本\",\n    image: \"图片\",\n    video: \"视频\",\n  };\n\n  const randomNum = Math.floor(Math.random() * 10000)\n    .toString()\n    .padStart(4, \"0\");\n  const providerName = providerNames[provider] || provider;\n  const serviceName = serviceNames[serviceType] || serviceType;\n\n  return `${providerName}-${serviceName}-${randomNum}`;\n};\n\nconst showCreateDialog = () => {\n  isEdit.value = false;\n  editingId.value = undefined;\n  resetForm();\n  form.service_type = activeTab.value;\n  // 默认选择 chatfire\n  form.provider = \"chatfire\";\n  // 设置默认 base_url\n  form.base_url = \"https://api.chatfire.site/v1\";\n  // 自动生成随机配置名称\n  form.name = generateConfigName(\"chatfire\", activeTab.value);\n  dialogVisible.value = true;\n};\n\nconst handleEdit = (config: AIServiceConfig) => {\n  isEdit.value = true;\n  editingId.value = config.id;\n\n  Object.assign(form, {\n    service_type: config.service_type,\n    provider: config.provider || \"chatfire\", // 直接使用配置中的 provider，默认为 chatfire\n    name: config.name,\n    base_url: config.base_url,\n    api_key: config.api_key,\n    model: Array.isArray(config.model) ? config.model : [config.model], // 统一转换为数组\n    priority: config.priority || 0,\n    is_active: config.is_active,\n  });\n  dialogVisible.value = true;\n};\n\nconst handleDelete = async (config: AIServiceConfig) => {\n  try {\n    await ElMessageBox.confirm(\"确定要删除该配置吗？\", \"警告\", {\n      confirmButtonText: \"确定\",\n      cancelButtonText: \"取消\",\n      type: \"warning\",\n    });\n\n    await aiAPI.delete(config.id);\n    ElMessage.success(\"删除成功\");\n    loadConfigs();\n  } catch (error: any) {\n    if (error !== \"cancel\") {\n      ElMessage.error(error.message || \"删除失败\");\n    }\n  }\n};\n\nconst handleToggleActive = async (config: AIServiceConfig) => {\n  try {\n    const newActiveState = !config.is_active;\n    await aiAPI.update(config.id, { is_active: newActiveState });\n    ElMessage.success(newActiveState ? \"已启用配置\" : \"已禁用配置\");\n    await loadConfigs();\n  } catch (error: any) {\n    ElMessage.error(error.message || \"操作失败\");\n  }\n};\n\nconst testConnection = async () => {\n  if (!formRef.value) return;\n\n  const valid = await formRef.value.validate().catch(() => false);\n  if (!valid) return;\n\n  testing.value = true;\n  try {\n    await aiAPI.testConnection({\n      base_url: form.base_url,\n      api_key: form.api_key,\n      model: form.model,\n      provider: form.provider,\n    });\n    ElMessage.success(\"连接测试成功！\");\n  } catch (error: any) {\n    ElMessage.error(error.message || \"连接测试失败\");\n  } finally {\n    testing.value = false;\n  }\n};\n\nconst handleTest = async (config: AIServiceConfig) => {\n  testing.value = true;\n  try {\n    await aiAPI.testConnection({\n      base_url: config.base_url,\n      api_key: config.api_key,\n      model: config.model,\n      provider: config.provider,\n    });\n    ElMessage.success(\"连接测试成功！\");\n  } catch (error: any) {\n    ElMessage.error(error.message || \"连接测试失败\");\n  } finally {\n    testing.value = false;\n  }\n};\n\nconst handleSubmit = async () => {\n  if (!formRef.value) return;\n\n  await formRef.value.validate(async (valid) => {\n    if (!valid) return;\n\n    submitting.value = true;\n    try {\n      if (isEdit.value && editingId.value) {\n        const updateData: UpdateAIConfigRequest = {\n          name: form.name,\n          provider: form.provider,\n          base_url: form.base_url,\n          api_key: form.api_key,\n          model: form.model,\n          priority: form.priority,\n          is_active: form.is_active,\n        };\n        await aiAPI.update(editingId.value, updateData);\n        ElMessage.success(\"更新成功\");\n      } else {\n        await aiAPI.create(form);\n        ElMessage.success(\"创建成功\");\n      }\n\n      dialogVisible.value = false;\n      loadConfigs();\n    } catch (error: any) {\n      ElMessage.error(error.message || \"操作失败\");\n    } finally {\n      submitting.value = false;\n    }\n  });\n};\n\nconst handleTabChange = (tabName: string | number) => {\n  // 标签页切换时重新加载对应服务类型的配置\n  activeTab.value = tabName as AIServiceType;\n  loadConfigs();\n};\n\nconst handleProviderChange = () => {\n  // 切换厂商时清空已选模型\n  form.model = [];\n\n  // 根据厂商自动设置默认 base_url\n  if (form.provider === \"gemini\" || form.provider === \"google\") {\n    form.base_url = \"https://api.chatfire.site\";\n  } else {\n    // openai, chatfire 等其他厂商\n    form.base_url = \"https://api.chatfire.site/v1\";\n  }\n\n  // 仅在新建配置时自动更新名称\n  if (!isEdit.value) {\n    form.name = generateConfigName(form.provider, form.service_type);\n  }\n};\n\n// getDefaultEndpoint 已移除，端点由后端根据 provider 自动设置\n// 保留该函数定义以避免编译错误\nconst getDefaultEndpoint = (serviceType: AIServiceType): string => {\n  switch (serviceType) {\n    case \"text\":\n      return \"\";\n    case \"image\":\n      return \"/v1/images/generations\";\n    case \"video\":\n      return \"/v1/video/generations\";\n    default:\n      return \"/v1/chat/completions\";\n  }\n};\n\nconst resetForm = () => {\n  const serviceType = form.service_type || \"text\";\n  Object.assign(form, {\n    service_type: serviceType,\n    provider: \"\",\n    name: \"\",\n    base_url: \"\",\n    api_key: \"\",\n    model: [], // 改为空数组\n    priority: 0,\n    is_active: true,\n  });\n  formRef.value?.resetFields();\n};\n\nconst goBack = () => {\n  router.back();\n};\n\nonMounted(() => {\n  loadConfigs();\n});\n</script>\n\n<style scoped>\n/* ========================================\n   Page Layout / 页面布局 - 紧凑边距\n   ======================================== */\n.page-container {\n  min-height: 100vh;\n  background: var(--bg-primary);\n  padding: var(--space-2) var(--space-3);\n  transition: background var(--transition-normal);\n}\n\n@media (min-width: 768px) {\n  .page-container {\n    padding: var(--space-3) var(--space-4);\n  }\n}\n\n@media (min-width: 1024px) {\n  .page-container {\n    padding: var(--space-4) var(--space-5);\n  }\n}\n\n.content-wrapper {\n  max-width: 1200px;\n  margin: 0 auto;\n}\n\n/* ========================================\n   Tabs / 标签页 - 紧凑内边距\n   ======================================== */\n.tabs-wrapper {\n  background: var(--bg-card);\n  border: 1px solid var(--border-primary);\n  border-radius: var(--radius-lg);\n  padding: var(--space-3);\n  box-shadow: var(--shadow-card);\n}\n\n@media (min-width: 768px) {\n  .tabs-wrapper {\n    padding: var(--space-4);\n  }\n}\n\n/* ========================================\n   Form Tips / 表单提示\n   ======================================== */\n.form-tip {\n  font-size: 0.75rem;\n  color: var(--text-muted);\n  margin-top: 0.25rem;\n}\n\n/* ========================================\n   Dialog / 弹窗\n   ======================================== */\n:deep(.el-dialog) {\n  border-radius: 0.75rem;\n}\n\n:deep(.el-dialog__header) {\n  padding: 1.25rem 1.5rem;\n  border-bottom: 1px solid var(--border-primary);\n  margin-right: 0;\n}\n\n:deep(.el-dialog__title) {\n  font-size: 1.125rem;\n  font-weight: 600;\n  color: var(--text-primary);\n}\n\n:deep(.el-dialog__body) {\n  padding: 1.5rem;\n}\n\n:deep(.el-dialog__footer) {\n  padding: 1rem 1.5rem;\n  border-top: 1px solid var(--border-primary);\n}\n\n/* ========================================\n   Dark Mode / 深色模式\n   ======================================== */\n.dark .tabs-wrapper {\n  background: var(--bg-card);\n}\n\n.dark :deep(.el-dialog) {\n  background: var(--bg-card);\n}\n\n.dark :deep(.el-dialog__header) {\n  background: var(--bg-card);\n}\n\n.dark :deep(.el-dialog__body) {\n  background: var(--bg-card);\n}\n\n.dark :deep(.el-form-item__label) {\n  color: var(--text-primary);\n}\n\n.dark :deep(.el-input__wrapper) {\n  background: var(--bg-secondary);\n  box-shadow: 0 0 0 1px var(--border-primary) inset;\n}\n\n.dark :deep(.el-input__inner) {\n  color: var(--text-primary);\n}\n\n.dark :deep(.el-input__inner::placeholder) {\n  color: var(--text-muted);\n}\n\n.dark :deep(.el-select .el-input__wrapper) {\n  background: var(--bg-secondary);\n}\n\n.dark :deep(.el-textarea__inner) {\n  background: var(--bg-secondary);\n  color: var(--text-primary);\n  box-shadow: 0 0 0 1px var(--border-primary) inset;\n}\n\n.dark :deep(.el-input-number) {\n  background: var(--bg-secondary);\n}\n\n.dark :deep(.el-switch__core) {\n  background: var(--bg-secondary);\n  border-color: var(--border-primary);\n}\n\n.dark :deep(.el-button--default) {\n  background: var(--bg-secondary);\n  border-color: var(--border-primary);\n  color: var(--text-primary);\n}\n\n.dark :deep(.el-button--default:hover) {\n  background: var(--bg-card-hover);\n  border-color: var(--border-secondary);\n}\n</style>\n"
  },
  {
    "path": "web/src/views/settings/SystemSettings.vue",
    "content": "<template>\n  <div class=\"system-settings\">\n    <el-card>\n      <template #header>\n        <div class=\"card-header\">\n          <span>{{ $t('settings.systemLanguage') }}</span>\n        </div>\n      </template>\n      \n      <el-form label-width=\"120px\">\n        <el-form-item :label=\"$t('settings.currentLanguage')\">\n          <el-radio-group \n            v-model=\"currentLanguage\" \n            @change=\"handleLanguageChange\"\n            :disabled=\"loading\"\n          >\n            <el-radio label=\"zh\">简体中文</el-radio>\n            <el-radio label=\"en\">English</el-radio>\n          </el-radio-group>\n          <div v-if=\"loading\" style=\"margin-top: 8px; color: var(--el-color-primary);\">\n            <el-icon class=\"is-loading\"><Loading /></el-icon>\n            {{ currentLanguage === 'zh' ? '正在切换语言...' : 'Switching language...' }}\n          </div>\n        </el-form-item>\n        \n        <el-form-item>\n          <el-alert\n            :title=\"$t('settings.languageSwitchNotice')\"\n            type=\"warning\"\n            :closable=\"false\"\n            show-icon\n          >\n            <template #default>\n              <p>{{ $t('settings.languageSwitchDesc') }}</p>\n              <ul>\n                <li>{{ $t('settings.languageSwitchItem1') }}</li>\n                <li>{{ $t('settings.languageSwitchItem2') }}</li>\n                <li>{{ $t('settings.languageSwitchItem3') }}</li>\n              </ul>\n            </template>\n          </el-alert>\n        </el-form-item>\n      </el-form>\n    </el-card>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, onMounted } from 'vue'\nimport { ElMessage, ElMessageBox } from 'element-plus'\nimport { Loading } from '@element-plus/icons-vue'\nimport { settingsAPI } from '@/api/settings'\nimport { useI18n } from 'vue-i18n'\n\nconst { locale } = useI18n()\nconst currentLanguage = ref<'zh' | 'en'>('zh')\nconst loading = ref(false)\n\nconst loadCurrentLanguage = async () => {\n  try {\n    const res = await settingsAPI.getLanguage()\n    currentLanguage.value = res?.language as 'zh' | 'en'\n    // 同步前端语言\n    locale.value = res?.language as 'zh' | 'en'\n    console.log('Current language loaded:', res?.language)\n  } catch (error) {\n    console.error('Failed to load language:', error)\n    const errorMsg = currentLanguage.value === 'zh' \n      ? '获取语言设置失败' \n      : 'Failed to load language settings'\n    ElMessage.error(errorMsg)\n  }\n}\n\nconst handleLanguageChange = async (value: 'zh' | 'en') => {\n  // 双语确认消息\n  const confirmMessage = value === 'zh' \n    ? `切换为中文后，后端生成的所有提示词、角色描述、场景描述等都将使用中文。是否继续？\n\n\nAfter switching to Chinese, all prompts, character descriptions, scene descriptions generated by the backend will use Chinese. Continue?`\n    : `After switching to English, all prompts, character descriptions, scene descriptions generated by the backend will use English. Continue?\n\n\n切换为英文后，后端生成的所有提示词、角色描述、场景描述等都将使用英文。是否继续？`\n  \n  try {\n    await ElMessageBox.confirm(\n      confirmMessage,\n      '切换语言 / Switch Language',\n      {\n        confirmButtonText: '确定 / Confirm',\n        cancelButtonText: '取消 / Cancel',\n        type: 'warning',\n        dangerouslyUseHTMLString: false\n      }\n    )\n\n    loading.value = true\n    console.log('Updating language to:', value)\n    \n    const res = await settingsAPI.updateLanguage(value)\n    console.log('Language update response:', res)\n    \n    // 同时更新前端语言\n    locale.value = value\n    \n    // 使用后端返回的双语消息（request拦截器已经返回了data）\n    ElMessage.success({\n      message: res?.message || (value === 'zh' ? '语言已切换为中文' : 'Language switched to English'),\n      duration: 3000\n    })\n  } catch (error: any) {\n    console.error('Language update error:', error)\n    \n    if (error !== 'cancel') {\n      // 安全获取错误消息\n      let errorMessage = '未知错误'\n      if (error?.message) {\n        errorMessage = error.message\n      } else if (error?.response?.data?.error?.message) {\n        errorMessage = error.response.data.error.message\n      } else if (typeof error === 'string') {\n        errorMessage = error\n      }\n      \n      // 双语错误提示\n      const errorMsg = currentLanguage.value === 'zh'\n        ? `切换语言失败: ${errorMessage}`\n        : `Failed to switch language: ${errorMessage}`\n      \n      ElMessage.error({\n        message: errorMsg,\n        duration: 5000\n      })\n    }\n    \n    // 恢复原来的选择\n    await loadCurrentLanguage()\n  } finally {\n    loading.value = false\n  }\n}\n\nonMounted(() => {\n  loadCurrentLanguage()\n})\n</script>\n\n<style scoped>\n.system-settings {\n  padding: 20px;\n}\n\n.card-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n:deep(.el-alert ul) {\n  margin-top: 10px;\n  padding-left: 20px;\n}\n\n:deep(.el-alert li) {\n  margin: 5px 0;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/settings/components/ConfigList.vue",
    "content": "<template>\n  <div v-loading=\"loading\" class=\"config-list\">\n    <el-empty v-if=\"!loading && configs.length === 0\" :description=\"$t('aiConfig.empty')\" />\n\n    <el-card \n      v-for=\"config in configs\" \n      :key=\"config.id\" \n      class=\"config-card\"\n      shadow=\"hover\"\n    >\n      <div class=\"config-header\">\n        <div class=\"config-title\">\n          <h3>{{ config.name }}</h3>\n          <el-tag v-if=\"config.is_active\" type=\"success\" size=\"small\">{{ $t('aiConfig.enabled') }}</el-tag>\n          <el-tag v-else type=\"info\" size=\"small\">{{ $t('aiConfig.disabled') }}</el-tag>\n        </div>\n        <div class=\"config-actions\">\n          <el-button v-if=\"showTestButton\" text @click=\"$emit('test', config)\" :icon=\"Connection\">\n            {{ $t('aiConfig.actions.test') }}\n          </el-button>\n          <el-button text @click=\"$emit('edit', config)\" :icon=\"Edit\">\n            {{ $t('common.edit') }}\n          </el-button>\n          <el-button \n            text \n            :type=\"config.is_active ? 'warning' : 'success'\"\n            @click=\"$emit('toggle-active', config)\"\n          >\n            {{ config.is_active ? $t('aiConfig.disable') : $t('aiConfig.enable') }}\n          </el-button>\n          <el-popconfirm\n            :title=\"$t('aiConfig.messages.deleteConfirm')\"\n            @confirm=\"$emit('delete', config)\"\n          >\n            <template #reference>\n              <el-button text type=\"danger\" :icon=\"Delete\">\n                {{ $t('common.delete') }}\n              </el-button>\n            </template>\n          </el-popconfirm>\n        </div>\n      </div>\n\n      <div class=\"config-info\">\n        <div class=\"info-item\">\n          <label>Base URL：</label>\n          <span class=\"url-text\">{{ config.base_url }}</span>\n        </div>\n\n        <div class=\"info-item\">\n          <label>{{ $t('aiConfig.endpoint') }}：</label>\n          <span>{{ config.endpoint || '/v1/chat/completions' }}</span>\n        </div>\n\n        <div v-if=\"config.service_type === 'video' && config.query_endpoint\" class=\"info-item\">\n          <label>{{ $t('aiConfig.queryEndpoint') }}：</label>\n          <span>{{ config.query_endpoint }}</span>\n        </div>\n\n        <div class=\"info-item\">\n          <label>优先级：</label>\n          <el-tag size=\"small\" :type=\"(config.priority || 0) >= 50 ? 'danger' : (config.priority || 0) >= 20 ? 'warning' : 'info'\">\n            {{ config.priority || 0 }}\n          </el-tag>\n        </div>\n\n        <div class=\"info-item\">\n          <label>模型：</label>\n          <template v-if=\"Array.isArray(config.model)\">\n            <el-tag \n              v-for=\"(model, index) in config.model\" \n              :key=\"index\" \n              size=\"small\" \n              effect=\"plain\"\n              style=\"margin-right: 4px\"\n            >\n              {{ model }}\n            </el-tag>\n          </template>\n          <el-tag v-else size=\"small\" effect=\"plain\">{{ config.model }}</el-tag>\n        </div>\n\n        <div class=\"info-item\">\n          <label>API Key：</label>\n          <span class=\"api-key\">{{ maskApiKey(config.api_key) }}</span>\n        </div>\n\n        <div class=\"info-item\">\n          <label>创建时间：</label>\n          <span class=\"time-text\">{{ formatDate(config.created_at) }}</span>\n        </div>\n      </div>\n    </el-card>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { Connection, Edit, Delete } from '@element-plus/icons-vue'\nimport type { AIServiceConfig } from '@/types/ai'\n\ndefineProps<{\n  configs: AIServiceConfig[]\n  loading: boolean\n  showTestButton?: boolean\n}>()\n\ndefineEmits<{\n  edit: [config: AIServiceConfig]\n  delete: [config: AIServiceConfig]\n  toggleActive: [config: AIServiceConfig]\n  test: [config: AIServiceConfig]\n}>()\n\nconst maskApiKey = (key: string) => {\n  if (!key) return ''\n  if (key.length <= 8) return '***'\n  return key.substring(0, 4) + '***' + key.substring(key.length - 4)\n}\n\nconst formatDate = (dateString: string) => {\n  return new Date(dateString).toLocaleString('zh-CN')\n}\n</script>\n\n<style scoped>\n.config-list {\n  display: grid;\n  gap: 1rem;\n  min-height: 300px;\n}\n\n.config-card {\n  transition: all 0.2s ease;\n  background: var(--bg-card);\n  border: 1px solid var(--border-primary);\n}\n\n.config-card :deep(.el-card__body) {\n  padding: 1.25rem;\n}\n\n.config-card:hover {\n  border-color: var(--border-secondary);\n}\n\n.config-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 1rem;\n  padding-bottom: 0.75rem;\n  border-bottom: 1px solid var(--border-primary);\n}\n\n.config-title {\n  display: flex;\n  align-items: center;\n  gap: 0.75rem;\n}\n\n.config-title h3 {\n  margin: 0;\n  font-size: 1rem;\n  font-weight: 600;\n  color: var(--text-primary);\n}\n\n.config-actions {\n  display: flex;\n  gap: 0.5rem;\n}\n\n.config-info {\n  display: grid;\n  gap: 0.75rem;\n}\n\n.info-item {\n  display: flex;\n  align-items: center;\n  font-size: 0.875rem;\n  color: var(--text-primary);\n}\n\n.info-item label {\n  min-width: 5.5rem;\n  color: var(--text-muted);\n  font-weight: 500;\n}\n\n.info-item span {\n  color: var(--text-secondary);\n}\n\n.url-text {\n  color: #0ea5e9 !important;\n  word-break: break-all;\n}\n\n.api-key {\n  font-family: monospace;\n  color: var(--text-muted) !important;\n}\n\n.time-text {\n  color: var(--text-muted) !important;\n  font-size: 0.8125rem;\n}\n\n/* Dark mode overrides */\n.dark .config-card {\n  background: var(--bg-card);\n}\n\n.dark .config-card :deep(.el-card__body) {\n  background: transparent;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/storyboard/StoryboardEdit.vue",
    "content": "<template>\n  <div class=\"storyboard-edit-container\">\n    <el-page-header @back=\"goBack\" :title=\"$t('common.back')\">\n      <template #content>\n        <h2>{{ $t('storyboard.edit') }}</h2>\n      </template>\n    </el-page-header>\n    <p>{{ $t('storyboard.inDevelopment') }}</p>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { useRouter } from 'vue-router'\n\nconst router = useRouter()\n\nconst goBack = () => {\n  router.back()\n}\n</script>\n\n<style scoped>\n.storyboard-edit-container {\n  padding: 20px;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/workflow/CharacterExtraction.vue",
    "content": "<template>\n  <div class=\"character-extraction-container\">\n    <el-page-header @back=\"goBack\" :title=\"$t('character.backToProject')\">\n      <template #content>\n        <h2>{{ $t('character.title') }}</h2>\n      </template>\n    </el-page-header>\n\n    <el-card shadow=\"never\" class=\"main-card\">\n      <template #header>\n        <div class=\"card-header\">\n          <h3>{{ $t('character.list') }}</h3>\n          <div class=\"header-actions\">\n            <el-button @click=\"addCharacter\">\n              <el-icon><Plus /></el-icon>\n              {{ $t('character.add') }}\n            </el-button>\n            <el-button type=\"primary\" @click=\"saveCharacters\" :loading=\"saving\">\n              {{ $t('character.saveChanges') }}\n            </el-button>\n          </div>\n        </div>\n      </template>\n\n      <el-empty v-if=\"characters.length === 0\" :description=\"$t('character.empty')\" />\n\n      <el-row :gutter=\"20\" v-else>\n        <el-col :span=\"8\" v-for=\"character in characters\" :key=\"character.id\">\n          <el-card shadow=\"hover\" class=\"character-card\">\n            <template #header>\n              <div class=\"character-header\">\n                <el-avatar :size=\"60\">{{ character.name[0] }}</el-avatar>\n                <div class=\"character-info\">\n                  <h4>{{ character.name }}</h4>\n                  <el-tag size=\"small\">{{ character.role }}</el-tag>\n                </div>\n              </div>\n            </template>\n\n            <div class=\"character-details\">\n              <p><strong>{{ $t('character.personality') }}：</strong>{{ character.personality }}</p>\n              <p><strong>{{ $t('character.appearance') }}：</strong>{{ character.appearance }}</p>\n              <p><strong>{{ $t('character.background') }}：</strong>{{ character.background }}</p>\n            </div>\n\n            <template #footer>\n              <el-button-group style=\"width: 100%\">\n                <el-button size=\"small\" @click=\"editCharacter(character)\">{{ $t('common.edit') }}</el-button>\n                <el-button size=\"small\" type=\"primary\" @click=\"generateCharacterImage(character)\">\n                  {{ $t('character.generateImage') }}\n                </el-button>\n              </el-button-group>\n            </template>\n          </el-card>\n        </el-col>\n      </el-row>\n\n      <div class=\"actions\" v-if=\"characters.length > 0\">\n        <el-button type=\"success\" size=\"large\" @click=\"goToNextStep\">\n          {{ $t('character.nextStep') }}\n        </el-button>\n      </div>\n    </el-card>\n\n    <!-- 编辑对话框 -->\n    <el-dialog v-model=\"editDialogVisible\" title=\"编辑角色\" width=\"600px\">\n      <el-form :model=\"editForm\" label-width=\"80px\">\n        <el-form-item label=\"姓名\">\n          <el-input v-model=\"editForm.name\" />\n        </el-form-item>\n        <el-form-item label=\"角色\">\n          <el-input v-model=\"editForm.role\" />\n        </el-form-item>\n        <el-form-item label=\"性格\">\n          <el-input v-model=\"editForm.personality\" type=\"textarea\" :rows=\"3\" />\n        </el-form-item>\n        <el-form-item label=\"外貌\">\n          <el-input v-model=\"editForm.appearance\" type=\"textarea\" :rows=\"3\" />\n        </el-form-item>\n      </el-form>\n      <template #footer>\n        <el-button @click=\"editDialogVisible = false\">取消</el-button>\n        <el-button type=\"primary\" @click=\"saveCharacter\">保存</el-button>\n      </template>\n    </el-dialog>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, reactive, onMounted } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { ElMessage } from 'element-plus'\nimport { Plus } from '@element-plus/icons-vue'\nimport { MagicStick } from '@element-plus/icons-vue'\nimport { generationAPI } from '@/api/generation'\nimport type { Character } from '@/types/drama'\n\nconst route = useRoute()\nconst router = useRouter()\nconst dramaId = route.params.id as string\n\nconst characters = ref<Character[]>([])\nconst saving = ref(false)\nconst editDialogVisible = ref(false)\nconst editForm = reactive({\n  name: '',\n  role: '',\n  personality: '',\n  appearance: '',\n  background: ''\n})\n\nconst goBack = () => {\n  router.push(`/dramas/${dramaId}`)\n}\n\nconst addCharacter = () => {\n  Object.assign(editForm, {\n    name: '',\n    role: '',\n    personality: '',\n    appearance: '',\n    background: ''\n  })\n  editDialogVisible.value = true\n}\n\nconst saveCharacters = async () => {\n  saving.value = true\n  try {\n    // TODO: 调用保存角色API\n    await new Promise(resolve => setTimeout(resolve, 1000))\n    ElMessage.success('保存成功')\n  } catch (error: any) {\n    ElMessage.error(error.message || '保存失败')\n  } finally {\n    saving.value = false\n  }\n}\n\nconst editCharacter = (character: Character) => {\n  Object.assign(editForm, character)\n  editDialogVisible.value = true\n}\n\nconst saveCharacter = () => {\n  // TODO: 保存角色信息\n  editDialogVisible.value = false\n  ElMessage.success('保存成功')\n}\n\nconst generateCharacterImage = (character: Character) => {\n  router.push(`/dramas/${dramaId}/images/characters?character=${character.id}`)\n}\n\nconst goToNextStep = () => {\n  router.push(`/dramas/${dramaId}/images/characters`)\n}\n\nonMounted(() => {\n  // TODO: 加载已有角色\n})\n</script>\n\n<style scoped>\n.character-extraction-container {\n  padding: 24px;\n  max-width: 1400px;\n  margin: 0 auto;\n}\n\n.main-card {\n  margin-top: 20px;\n}\n\n.card-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n.card-header h3 {\n  margin: 0;\n}\n\n.character-card {\n  margin-bottom: 20px;\n}\n\n.character-header {\n  display: flex;\n  gap: 16px;\n  align-items: center;\n}\n\n.character-info h4 {\n  margin: 0 0 8px 0;\n}\n\n.character-details p {\n  margin: 8px 0;\n  font-size: 14px;\n  color: #606266;\n}\n\n.actions {\n  margin-top: 30px;\n  text-align: center;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/workflow/CharacterImages.vue",
    "content": "<template>\n  <div class=\"character-images-container\">\n    <el-page-header @back=\"goBack\" title=\"返回项目\">\n      <template #content>\n        <h2>角色形象生成</h2>\n      </template>\n      <template #extra>\n        <el-button\n          type=\"primary\"\n          @click=\"batchGenerate\"\n          :loading=\"batchGenerating\"\n          :disabled=\"selectedCharacters.length === 0\"\n        >\n          <el-icon><Picture /></el-icon>\n          批量生成 ({{ selectedCharacters.length }})\n        </el-button>\n        <el-button @click=\"goToCharacterManagement\">\n          <el-icon><Edit /></el-icon>\n          管理角色\n        </el-button>\n      </template>\n    </el-page-header>\n\n    <el-card shadow=\"never\" class=\"main-card\">\n      <div class=\"toolbar\">\n        <el-checkbox\n          v-model=\"selectAll\"\n          @change=\"handleSelectAll\"\n          :indeterminate=\"isIndeterminate\"\n        >\n          全选\n        </el-checkbox>\n        <span class=\"selection-info\"\n          >已选择 {{ selectedCharacters.length }} /\n          {{ characters.length }} 个角色</span\n        >\n      </div>\n\n      <div class=\"character-list\">\n        <el-row :gutter=\"20\">\n          <el-col :span=\"6\" v-for=\"character in characters\" :key=\"character.id\">\n            <el-card\n              shadow=\"hover\"\n              class=\"character-card\"\n              :class=\"{\n                'has-image': character.image_url,\n                selected: isSelected(character.id),\n              }\"\n            >\n              <el-checkbox\n                class=\"card-checkbox\"\n                :model-value=\"isSelected(character.id)\"\n                @change=\"toggleSelection(character.id)\"\n              />\n              <div class=\"character-preview\">\n                <el-image\n                  v-if=\"hasImage(character)\"\n                  :src=\"getImageUrl(character)\"\n                  fit=\"cover\"\n                />\n                <el-avatar v-else :size=\"120\">{{\n                  character.name[0]\n                }}</el-avatar>\n              </div>\n\n              <div class=\"character-info\">\n                <h4>{{ character.name }}</h4>\n                <p class=\"role\">{{ character.role }}</p>\n                <p class=\"desc\">{{ character.appearance }}</p>\n              </div>\n\n              <el-button\n                type=\"primary\"\n                @click=\"generateImage(character)\"\n                :loading=\"generatingIds.includes(character.id)\"\n                :disabled=\"\n                  batchGenerating ||\n                  (generatingIds.length > 0 &&\n                    !generatingIds.includes(character.id))\n                \"\n                style=\"width: 100%\"\n              >\n                <span v-if=\"generatingIds.includes(character.id)\"\n                  >生成中...</span\n                >\n                <span v-else>{{\n                  character.image_url ? \"重新生成\" : \"生成形象\"\n                }}</span>\n              </el-button>\n            </el-card>\n          </el-col>\n        </el-row>\n      </div>\n\n      <div class=\"actions\">\n        <el-button\n          type=\"success\"\n          size=\"large\"\n          @click=\"goToNextStep\"\n          :disabled=\"!allImagesGenerated\"\n        >\n          完成并返回项目\n        </el-button>\n      </div>\n    </el-card>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted } from \"vue\";\nimport { useRoute, useRouter } from \"vue-router\";\nimport { ElMessage } from \"element-plus\";\nimport { Edit, Picture } from \"@element-plus/icons-vue\";\nimport { dramaAPI } from \"@/api/drama\";\nimport { characterLibraryAPI } from \"@/api/character-library\";\nimport type { Character } from \"@/types/drama\";\nimport { getImageUrl, hasImage } from \"@/utils/image\";\n\nconst route = useRoute();\nconst router = useRouter();\nconst dramaId = route.params.id as string;\n\nconst characters = ref<Character[]>([]);\nconst generatingIds = ref<(number | string)[]>([]);\nconst batchGenerating = ref(false);\nconst selectedCharacters = ref<(number | string)[]>([]);\nconst selectAll = ref(false);\n\nconst allImagesGenerated = computed(() => {\n  return (\n    characters.value.length > 0 && characters.value.every((c) => c.image_url)\n  );\n});\n\nconst isIndeterminate = computed(() => {\n  const selectedCount = selectedCharacters.value.length;\n  return selectedCount > 0 && selectedCount < characters.value.length;\n});\n\nconst goBack = () => {\n  router.push(`/dramas/${dramaId}`);\n};\n\nconst goToCharacterManagement = () => {\n  router.push(`/dramas/${dramaId}/characters`);\n};\n\nconst isSelected = (id: number | string) => {\n  return selectedCharacters.value.includes(id);\n};\n\nconst toggleSelection = (id: number | string) => {\n  const index = selectedCharacters.value.indexOf(id);\n  if (index > -1) {\n    selectedCharacters.value.splice(index, 1);\n  } else {\n    selectedCharacters.value.push(id);\n  }\n  updateSelectAllState();\n};\n\nconst handleSelectAll = (val: boolean) => {\n  if (val) {\n    selectedCharacters.value = characters.value.map((c) => c.id);\n  } else {\n    selectedCharacters.value = [];\n  }\n};\n\nconst updateSelectAllState = () => {\n  selectAll.value = selectedCharacters.value.length === characters.value.length;\n};\n\nconst generateImage = async (character: Character) => {\n  if (generatingIds.value.includes(character.id)) return;\n\n  generatingIds.value.push(character.id);\n  try {\n    const result = await characterLibraryAPI.generateCharacterImage(\n      character.id as string,\n    );\n\n    // 更新角色图片\n    const index = characters.value.findIndex((c) => c.id === character.id);\n    if (index !== -1) {\n      characters.value[index].image_url = result.image_url;\n    }\n\n    ElMessage.success(`${character.name}的形象生成成功`);\n  } catch (error: any) {\n    ElMessage.error(\n      error.response?.data?.message || `${character.name}生成失败`,\n    );\n  } finally {\n    const index = generatingIds.value.indexOf(character.id);\n    if (index > -1) {\n      generatingIds.value.splice(index, 1);\n    }\n  }\n};\n\nconst batchGenerate = async () => {\n  if (selectedCharacters.value.length === 0) {\n    ElMessage.warning(\"请选择要生成的角色\");\n    return;\n  }\n\n  if (selectedCharacters.value.length > 10) {\n    ElMessage.warning(\"单次最多生成10个角色\");\n    return;\n  }\n\n  batchGenerating.value = true;\n  generatingIds.value = [...selectedCharacters.value];\n\n  try {\n    await characterLibraryAPI.batchGenerateCharacterImages(\n      selectedCharacters.value.map((id) => String(id)),\n    );\n\n    ElMessage.success(\n      `批量生成任务已提交，正在后台生成 ${selectedCharacters.value.length} 个角色形象`,\n    );\n\n    // 轮询检查生成状态\n    startPolling();\n  } catch (error: any) {\n    ElMessage.error(error.response?.data?.message || \"批量生成失败\");\n    batchGenerating.value = false;\n    generatingIds.value = [];\n  }\n};\n\nlet pollingTimer: number | null = null;\n\nconst startPolling = () => {\n  if (pollingTimer) return;\n\n  pollingTimer = window.setInterval(async () => {\n    try {\n      const drama = await dramaAPI.get(dramaId);\n      if (drama.characters) {\n        // 更新角色列表\n        characters.value = drama.characters;\n\n        // 检查是否所有选中的角色都生成完成\n        const allGenerated = selectedCharacters.value.every((id) => {\n          const char = characters.value.find((c) => c.id === id);\n          return char?.image_url;\n        });\n\n        if (allGenerated) {\n          stopPolling();\n          ElMessage.success(\"批量生成完成\");\n        }\n      }\n    } catch (error) {\n      console.error(\"轮询错误:\", error);\n    }\n  }, 5000); // 每5秒检查一次\n};\n\nconst stopPolling = () => {\n  if (pollingTimer) {\n    clearInterval(pollingTimer);\n    pollingTimer = null;\n  }\n  batchGenerating.value = false;\n  generatingIds.value = [];\n  selectedCharacters.value = [];\n  selectAll.value = false;\n};\n\nconst goToNextStep = () => {\n  router.push(`/dramas/${dramaId}`);\n};\n\nonMounted(async () => {\n  try {\n    const drama = await dramaAPI.get(dramaId);\n    if (drama.characters && drama.characters.length > 0) {\n      characters.value = drama.characters;\n    } else {\n      ElMessage.warning(\"未找到角色信息，请先完成剧本生成\");\n      router.push(`/dramas/${dramaId}`);\n    }\n  } catch (error: any) {\n    ElMessage.error(error.message || \"加载角色失败\");\n    router.push(`/dramas/${dramaId}`);\n  }\n});\n\n// 组件销毁时清理轮询\nimport { onBeforeUnmount } from \"vue\";\nonBeforeUnmount(() => {\n  stopPolling();\n});\n</script>\n\n<style scoped>\n.character-images-container {\n  padding: 24px;\n  max-width: 1400px;\n  margin: 0 auto;\n}\n\n.main-card {\n  margin-top: 20px;\n}\n\n.character-card {\n  margin-bottom: 20px;\n  text-align: center;\n}\n\n.character-card.has-image {\n  border-color: #67c23a;\n}\n\n.character-preview {\n  height: 180px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  margin-bottom: 16px;\n  background: #f5f7fa;\n  border-radius: 8px;\n}\n\n.character-preview img {\n  max-width: 100%;\n  max-height: 180px;\n  border-radius: 8px;\n}\n\n.character-info h4 {\n  margin: 8px 0;\n}\n\n.character-info .role {\n  color: #909399;\n  font-size: 13px;\n  margin: 4px 0;\n}\n\n.character-info .desc {\n  color: #606266;\n  font-size: 12px;\n  margin: 8px 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  -webkit-box-orient: vertical;\n}\n\n.toolbar {\n  margin-bottom: 20px;\n  display: flex;\n  align-items: center;\n  gap: 16px;\n  padding: 12px;\n  background: #f5f7fa;\n  border-radius: 4px;\n}\n\n.selection-info {\n  color: #606266;\n  font-size: 14px;\n}\n\n.character-card {\n  position: relative;\n  transition: all 0.3s;\n}\n\n.character-card.selected {\n  border-color: #409eff;\n  box-shadow: 0 2px 12px 0 rgba(64, 158, 255, 0.3);\n}\n\n.card-checkbox {\n  position: absolute;\n  top: 8px;\n  right: 8px;\n  z-index: 1;\n}\n\n.actions {\n  margin-top: 30px;\n  text-align: center;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/workflow/DramaSettings.vue",
    "content": "<template>\n  <div class=\"drama-settings-container\">\n    <el-page-header @back=\"goBack\" title=\"返回项目\">\n      <template #content>\n        <h2>项目设置</h2>\n      </template>\n    </el-page-header>\n\n    <el-card shadow=\"never\" class=\"main-card\">\n      <el-tabs v-model=\"activeTab\">\n        <el-tab-pane label=\"基本信息\" name=\"basic\">\n          <el-form :model=\"form\" label-width=\"100px\" style=\"max-width: 600px\">\n            <el-form-item label=\"项目标题\">\n              <el-input v-model=\"form.title\" />\n            </el-form-item>\n            <el-form-item label=\"项目描述\">\n              <el-input v-model=\"form.description\" type=\"textarea\" :rows=\"4\" />\n            </el-form-item>\n            <el-form-item label=\"类型\">\n              <el-select v-model=\"form.genre\">\n                <el-option label=\"都市\" value=\"都市\" />\n                <el-option label=\"古装\" value=\"古装\" />\n                <el-option label=\"悬疑\" value=\"悬疑\" />\n                <el-option label=\"爱情\" value=\"爱情\" />\n                <el-option label=\"喜剧\" value=\"喜剧\" />\n              </el-select>\n            </el-form-item>\n            <el-form-item label=\"状态\">\n              <el-select v-model=\"form.status\">\n                <el-option label=\"草稿\" value=\"draft\" />\n                <el-option label=\"策划中\" value=\"planning\" />\n                <el-option label=\"制作中\" value=\"production\" />\n                <el-option label=\"已完成\" value=\"completed\" />\n                <el-option label=\"已归档\" value=\"archived\" />\n              </el-select>\n            </el-form-item>\n            <el-form-item>\n              <el-button type=\"primary\" @click=\"saveSettings\">保存设置</el-button>\n            </el-form-item>\n          </el-form>\n        </el-tab-pane>\n\n        <el-tab-pane label=\"危险操作\" name=\"danger\">\n          <el-alert\n            title=\"警告\"\n            type=\"warning\"\n            description=\"以下操作不可恢复，请谨慎操作\"\n            :closable=\"false\"\n            show-icon\n          />\n          <div class=\"danger-zone\">\n            <el-button type=\"danger\" @click=\"deleteProject\">删除项目</el-button>\n          </div>\n        </el-tab-pane>\n      </el-tabs>\n    </el-card>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, reactive, onMounted } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { ElMessage, ElMessageBox } from 'element-plus'\nimport { dramaAPI } from '@/api/drama'\n\nconst route = useRoute()\nconst router = useRouter()\nconst dramaId = route.params.id as string\n\nconst activeTab = ref('basic')\nconst form = reactive({\n  title: '',\n  description: '',\n  genre: '',\n  status: 'draft' as any\n})\n\nconst goBack = () => {\n  router.push(`/dramas/${dramaId}`)\n}\n\nconst saveSettings = async () => {\n  try {\n    await dramaAPI.update(dramaId, form)\n    ElMessage.success('设置保存成功')\n  } catch (error: any) {\n    ElMessage.error(error.message || '保存失败')\n  }\n}\n\nconst deleteProject = async () => {\n  try {\n    await ElMessageBox.confirm(\n      '确定要删除此项目吗？此操作不可恢复！',\n      '警告',\n      {\n        confirmButtonText: '确定删除',\n        cancelButtonText: '取消',\n        type: 'warning',\n      }\n    )\n    \n    await dramaAPI.delete(dramaId)\n    ElMessage.success('项目已删除')\n    router.push('/dramas')\n  } catch (error: any) {\n    if (error !== 'cancel') {\n      ElMessage.error(error.message || '删除失败')\n    }\n  }\n}\n\nonMounted(async () => {\n  try {\n    const drama = await dramaAPI.get(dramaId)\n    Object.assign(form, drama)\n  } catch (error: any) {\n    ElMessage.error(error.message || '加载失败')\n  }\n})\n</script>\n\n<style scoped>\n.drama-settings-container {\n  padding: 24px;\n  max-width: 1200px;\n  margin: 0 auto;\n}\n\n.main-card {\n  margin-top: 20px;\n}\n\n.danger-zone {\n  margin-top: 20px;\n  padding: 20px;\n  text-align: center;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/workflow/SceneImages.vue",
    "content": "<template>\n  <div class=\"scene-images-container\">\n    <el-page-header @back=\"goBack\" title=\"返回项目\">\n      <template #content>\n        <h2>场景图片生成</h2>\n      </template>\n    </el-page-header>\n\n    <el-card shadow=\"never\" class=\"main-card\">\n      <el-tabs v-model=\"activeEpisode\">\n        <el-tab-pane\n          v-for=\"episode in episodes\"\n          :key=\"episode.id\"\n          :label=\"`第${episode.episode_number}集`\"\n          :name=\"episode.id\"\n        >\n          <el-row :gutter=\"20\">\n            <el-col :span=\"8\" v-for=\"scene in episode.scenes\" :key=\"scene.id\">\n              <el-card\n                shadow=\"hover\"\n                class=\"scene-card\"\n                :class=\"{ 'has-image': scene.image_url }\"\n              >\n                <template #header>\n                  <div class=\"scene-header\">\n                    <span class=\"scene-number\"\n                      >场景 {{ scene.storyboard_number }}</span\n                    >\n                    <el-tag size=\"small\">{{ scene.location }}</el-tag>\n                  </div>\n                </template>\n\n                <div class=\"scene-preview\">\n                  <el-image\n                    v-if=\"hasImage(scene)\"\n                    :src=\"getImageUrl(scene)\"\n                    fit=\"cover\"\n                  />\n                  <div v-else class=\"placeholder\">\n                    <el-icon :size=\"48\"><Picture /></el-icon>\n                    <p>未生成</p>\n                  </div>\n                </div>\n\n                <div class=\"scene-info\">\n                  <h4>{{ scene.title }}</h4>\n                  <p class=\"description\">{{ scene.description }}</p>\n                </div>\n\n                <el-button\n                  type=\"primary\"\n                  @click=\"generateImage(scene)\"\n                  :loading=\"generatingId === scene.id\"\n                  :disabled=\"!!generatingId && generatingId !== scene.id\"\n                  style=\"width: 100%\"\n                >\n                  {{ hasImage(scene) ? \"重新生成\" : \"生成图片\" }}\n                </el-button>\n              </el-card>\n            </el-col>\n          </el-row>\n        </el-tab-pane>\n      </el-tabs>\n\n      <div class=\"actions\">\n        <el-button\n          type=\"success\"\n          size=\"large\"\n          @click=\"goToNextStep\"\n          :disabled=\"!allImagesGenerated\"\n        >\n          下一步：视频生成\n        </el-button>\n      </div>\n    </el-card>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted } from \"vue\";\nimport { useRoute, useRouter } from \"vue-router\";\nimport { ElMessage } from \"element-plus\";\nimport { Picture } from \"@element-plus/icons-vue\";\nimport type { Episode, Scene } from \"@/types/drama\";\nimport { getImageUrl, hasImage } from \"@/utils/image\";\n\nconst route = useRoute();\nconst router = useRouter();\nconst dramaId = route.params.id as string;\n\nconst episodes = ref<Episode[]>([]);\nconst activeEpisode = ref<string>();\nconst generatingId = ref<string>();\n\nconst allImagesGenerated = computed(() => {\n  return episodes.value.every((ep) => ep.scenes?.every((s) => s.image_url));\n});\n\nconst goBack = () => {\n  router.push(`/dramas/${dramaId}`);\n};\n\nconst generateImage = async (scene: Scene) => {\n  generatingId.value = scene.id;\n  try {\n    const { imageAPI } = await import(\"@/api/image\");\n\n    // 构建场景提示词\n    let prompt = `${scene.location}, ${scene.time}`;\n    if (scene.description) {\n      prompt += `, ${scene.description}`;\n    }\n\n    const result = await imageAPI.generateImage({\n      drama_id: dramaId,\n      scene_id: scene.id as number,\n      image_type: \"scene\",\n      prompt: prompt,\n    });\n\n    ElMessage.success(\"场景图片生成任务已提交\");\n  } catch (error: any) {\n    ElMessage.error(error.message || \"生成失败\");\n  } finally {\n    generatingId.value = undefined;\n  }\n};\n\nconst goToNextStep = () => {\n  router.push(`/dramas/${dramaId}/videos`);\n};\n\nonMounted(() => {\n  // TODO: 加载剧集和场景列表\n  episodes.value = [];\n});\n</script>\n\n<style scoped>\n.scene-images-container {\n  padding: 24px;\n  max-width: 1400px;\n  margin: 0 auto;\n}\n\n.main-card {\n  margin-top: 20px;\n}\n\n.scene-card {\n  margin-bottom: 20px;\n}\n\n.scene-card.has-image {\n  border-color: #67c23a;\n}\n\n.scene-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n.scene-number {\n  font-weight: 500;\n}\n\n.scene-preview {\n  height: 200px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: #f5f7fa;\n  border-radius: 8px;\n  margin-bottom: 16px;\n}\n\n.scene-preview img {\n  max-width: 100%;\n  max-height: 200px;\n  border-radius: 8px;\n}\n\n.placeholder {\n  text-align: center;\n  color: #909399;\n}\n\n.placeholder p {\n  margin-top: 8px;\n}\n\n.scene-info h4 {\n  margin: 8px 0;\n}\n\n.scene-info .description {\n  color: #606266;\n  font-size: 13px;\n  margin: 8px 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  -webkit-box-orient: vertical;\n}\n\n.actions {\n  margin-top: 30px;\n  text-align: center;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/workflow/StoryboardGeneration.vue",
    "content": "<template>\n  <div class=\"storyboard-generation\">\n    <el-empty description=\"分镜拆解功能开发中\" />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\ndefineProps<{\n  dramaId: string\n  episodeId: string\n}>()\n\ndefineEmits<{\n  storyboardGenerated: []\n}>()\n</script>\n\n<style scoped lang=\"scss\">\n.storyboard-generation {\n  padding: 24px;\n}\n</style>\n"
  },
  {
    "path": "web/tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\nexport default {\n  content: [\n    \"./index.html\",\n    \"./src/**/*.{vue,js,ts,jsx,tsx}\",\n  ],\n  darkMode: 'class',\n  theme: {\n    extend: {\n      colors: {\n        // Primary brand colors / 主品牌色\n        primary: {\n          50: '#f0f9ff',\n          100: '#e0f2fe',\n          200: '#bae6fd',\n          300: '#7dd3fc',\n          400: '#38bdf8',\n          500: '#0ea5e9',\n          600: '#0284c7',\n          700: '#0369a1',\n          800: '#075985',\n          900: '#0c4a6e',\n        },\n        // Neutral colors for backgrounds / 中性色背景\n        surface: {\n          light: '#ffffff',\n          DEFAULT: '#f8fafc',\n          dark: '#0f172a',\n        },\n        // Card backgrounds / 卡片背景\n        card: {\n          light: '#ffffff',\n          dark: '#1e293b',\n        },\n        // Border colors / 边框色\n        border: {\n          light: '#e2e8f0',\n          dark: '#334155',\n        },\n        // Text colors / 文字色\n        content: {\n          primary: '#0f172a',\n          secondary: '#64748b',\n          muted: '#94a3b8',\n          'primary-dark': '#f1f5f9',\n          'secondary-dark': '#94a3b8',\n          'muted-dark': '#64748b',\n        },\n      },\n      boxShadow: {\n        'card': '0 1px 3px 0 rgb(0 0 0 / 0.05), 0 1px 2px -1px rgb(0 0 0 / 0.05)',\n        'card-hover': '0 10px 15px -3px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.05)',\n        'card-dark': '0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3)',\n        'card-hover-dark': '0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3)',\n      },\n      borderRadius: {\n        'xl': '0.875rem',\n        '2xl': '1rem',\n        '3xl': '1.5rem',\n      },\n      transitionTimingFunction: {\n        'bounce-in': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',\n      },\n    },\n  },\n  plugins: [],\n}\n"
  },
  {
    "path": "web/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"module\": \"ESNext\",\n    \"lib\": [\n      \"ES2020\",\n      \"DOM\",\n      \"DOM.Iterable\"\n    ],\n    \"skipLibCheck\": true,\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"preserve\",\n    \"strict\": false,\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"noFallthroughCasesInSwitch\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\n        \"./src/*\"\n      ]\n    }\n  },\n  \"include\": [\n    \"src/**/*.ts\",\n    \"src/**/*.d.ts\",\n    \"src/**/*.tsx\",\n    \"src/**/*.vue\"\n  ],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.node.json\"\n    }\n  ]\n}"
  },
  {
    "path": "web/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowSyntheticDefaultImports\": true,\n    \"types\": [\"node\"]\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "web/vite.config.ts",
    "content": "import vue from '@vitejs/plugin-vue'\nimport { fileURLToPath, URL } from 'node:url'\nimport { defineConfig } from 'vite'\n\nexport default defineConfig({\n  plugins: [vue()],\n  resolve: {\n    alias: {\n      '@': fileURLToPath(new URL('./src', import.meta.url))\n    }\n  },\n  server: {\n    host: '0.0.0.0',\n    port: 3012,\n    proxy: {\n      '/api': {\n        target: 'http://localhost:5678',\n        changeOrigin: true\n      },\n      '/static': {\n        target: 'http://localhost:5678',\n        changeOrigin: true\n      }\n    }\n  }\n})\n"
  }
]