commit effac6b017c73aecf0d93731694efd7eb24e9c02
Author: srd13569518003 <329724814@qq.com>
Date: Fri Jan 16 17:30:40 2026 +0800
create project
diff --git a/DOCKER_HOST_ACCESS.md b/DOCKER_HOST_ACCESS.md
new file mode 100644
index 0000000..3368f84
--- /dev/null
+++ b/DOCKER_HOST_ACCESS.md
@@ -0,0 +1,79 @@
+# Docker 容器访问宿主机服务指南
+
+## 核心配置
+
+Docker 容器内使用 `http://host.docker.internal:端口号` 访问宿主机服务。
+
+### macOS / Windows
+
+直接使用,无需额外配置。
+
+### Linux
+
+**docker-compose** - 已在 `docker-compose.yml` 配置:
+```yaml
+extra_hosts:
+ - "host.docker.internal:host-gateway"
+```
+
+**docker run** - 需添加参数:
+```bash
+docker run --add-host=host.docker.internal:host-gateway ...
+```
+
+## Ollama 配置示例
+
+### 1. 宿主机启动服务
+
+```bash
+# 监听所有接口(重要)
+export OLLAMA_HOST=0.0.0.0:11434
+ollama serve
+```
+
+### 2. 前端 AI 服务配置
+
+| 字段 | 值 |
+|------|-----|
+| Base URL | `http://host.docker.internal:11434/v1` |
+| Provider | `openai` |
+| Model | `qwen2.5:latest` |
+| API Key | `ollama` 或留空 |
+
+### 3. 其他服务端口
+
+| 服务 | 默认端口 | Base URL |
+|------|---------|----------|
+| Ollama | 11434 | `http://host.docker.internal:11434/v1` |
+| LM Studio | 1234 | `http://host.docker.internal:1234/v1` |
+| vLLM | 8000 | `http://host.docker.internal:8000/v1` |
+
+## 验证和故障排查
+
+### 测试连接
+
+```bash
+# 进入容器测试
+docker exec -it huobao-drama sh
+wget -O- http://host.docker.internal:11434/api/tags
+
+# 查看容器日志
+docker logs huobao-drama -f
+```
+
+### 常见问题
+
+**Connection refused**
+
+1. **宿主机服务未运行** - 检查服务状态
+ ```bash
+ curl http://localhost:11434/api/tags
+ ```
+
+2. **服务未监听 0.0.0.0** - Ollama 默认只监听 127.0.0.1
+ ```bash
+ export OLLAMA_HOST=0.0.0.0:11434
+ ollama serve
+ ```
+
+3. **防火墙阻止** - 检查防火墙规则或临时关闭测试
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..aa9390b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,93 @@
+# 多阶段构建 Dockerfile for Huobao Drama
+
+# ==================== 阶段1: 构建前端 ====================
+FROM node:20-alpine AS frontend-builder
+
+# 配置 npm 镜像源(国内加速)
+RUN npm config set registry https://registry.npmmirror.com
+
+WORKDIR /app/web
+
+# 复制前端依赖文件
+COPY web/package*.json ./
+
+# 安装前端依赖(包括 devDependencies,构建需要)
+RUN npm install
+
+# 复制前端源码
+COPY web/ ./
+
+# 构建前端
+RUN npm run build
+
+# ==================== 阶段2: 构建后端 ====================
+FROM golang:1.23-alpine AS backend-builder
+
+# 配置 Go 代理(国内镜像加速)
+ENV GOPROXY=https://goproxy.cn,direct \
+ GO111MODULE=on
+
+# 安装必要的构建工具(纯 Go 编译,无需 CGO)
+RUN apk add --no-cache \
+ git \
+ ca-certificates \
+ tzdata
+
+WORKDIR /app
+
+# 复制 Go 模块文件
+COPY go.mod go.sum ./
+
+# 下载依赖
+RUN go mod download
+
+# 复制后端源码
+COPY . .
+
+# 复制前端构建产物
+COPY --from=frontend-builder /app/web/dist ./web/dist
+
+# 构建后端可执行文件(纯 Go 编译,使用 modernc.org/sqlite)
+RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o huobao-drama .
+
+# ==================== 阶段3: 运行时镜像 ====================
+FROM alpine:latest
+
+# 安装运行时依赖
+RUN apk add --no-cache \
+ ca-certificates \
+ tzdata \
+ ffmpeg \
+ wget \
+ && rm -rf /var/cache/apk/*
+
+# 设置时区
+ENV TZ=Asia/Shanghai
+
+WORKDIR /app
+
+# 从构建阶段复制可执行文件
+COPY --from=backend-builder /app/huobao-drama .
+
+# 复制前端构建产物
+COPY --from=frontend-builder /app/web/dist ./web/dist
+
+# 复制配置文件模板并创建默认配置
+COPY configs/config.example.yaml ./configs/
+RUN cp ./configs/config.example.yaml ./configs/config.yaml
+
+# 复制数据库迁移文件
+COPY migrations ./migrations/
+
+# 创建数据目录(root 用户运行,无需权限设置)
+RUN mkdir -p /app/data/storage
+
+# 暴露端口
+EXPOSE 5678
+
+# 健康检查
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:5678/health || exit 1
+
+# 启动应用
+CMD ["./huobao-drama"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..dc30db2
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,53 @@
+Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License
+
+Copyright (c) 2026 火宝 (Chatfire)
+
+本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
+
+This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
+
+要查看该许可协议,可访问:
+To view a copy of this license, visit:
+https://creativecommons.org/licenses/by-nc-sa/4.0/
+
+或者写信到:
+Or send a letter to:
+Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
+
+---
+
+个人使用许可 (Personal Use License)
+
+您可以自由地:
+You are free to:
+- 分享 — 在任何媒介以任何形式复制、发行本作品
+ Share — copy and redistribute the material in any medium or format
+- 演绎 — 修改、转换或以本作品为基础进行创作
+ Adapt — remix, transform, and build upon the material
+
+惟须遵守下列条件:
+Under the following terms:
+- 署名 — 您必须给出适当的署名,提供指向本许可协议的链接
+ Attribution — You must give appropriate credit and provide a link to the license
+- 非商业性使用 — 您不得将本作品用于商业目的
+ NonCommercial — You may not use the material for commercial purposes
+- 相同方式共享 — 如果您再混合、转换或者基于本作品进行创作,您必须基于与原先许可协议相同的许可协议分发您贡献的作品
+ ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license
+
+---
+
+商业授权 (Commercial License)
+
+如需将本项目用于商业目的,请联系作者获取商业授权:
+For commercial use, please contact the author for a commercial license:
+
+Email: 18550175439@163.com
+WeChat: dangbao1117
+GitHub: https://github.com/chatfire-AI
+
+---
+
+免责声明 (Disclaimer)
+
+本软件按"原样"提供,不提供任何形式的明示或暗示担保。
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c20a06c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,535 @@
+# 🎬 Huobao Drama - AI短剧生成平台
+
+
+
+**基于 Go + Vue3 的全栈AI短剧自动化生产平台**
+
+
+[](https://golang.org)
+[](https://vuejs.org)
+[](https://creativecommons.org/licenses/by-nc-sa/4.0/)
+
+[功能特性](#功能特性) • [快速开始](#快速开始) • [部署指南](#部署指南)
+
+
+
+---
+
+## 📖 项目简介 / About
+
+Huobao Drama 是一个基于AI的短剧自动化生产平台,实现从剧本生成、角色设计、分镜制作到视频合成的全流程自动化。
+
+Huobao Drama is an AI-powered short drama production platform that automates the entire workflow from script generation, character design, storyboarding to video composition.
+
+### 🎯 核心价值 / Core Features
+
+- **🤖 AI驱动 / AI-Driven**:使用大语言模型解析剧本,提取角色、场景和分镜信息 | Parse scripts using large language models to extract characters, scenes, and storyboards
+- **🎨 智能创作 / Intelligent Creation**:AI绘图生成角色形象和场景背景 | AI-generated character portraits and scene backgrounds
+- **📹 视频生成 / Video Generation**:基于文生视频和图生视频模型自动生成分镜视频 | Automatic storyboard video generation using text-to-video and image-to-video models
+- **🔄 工作流 / Workflow**:完整的短剧制作工作流,从创意到成片一站式完成 | Complete production workflow from idea to final video
+
+### 🛠️ 技术架构
+
+采用**DDD领域驱动设计**,清晰分层:
+
+```
+├── API层 (Gin HTTP)
+├── 应用服务层 (Business Logic)
+├── 领域层 (Domain Models)
+└── 基础设施层 (Database, External Services)
+```
+
+### 🎥 作品展示 / Demo Videos
+
+体验 AI 短剧生成效果:
+
+
+
+**示例作品 1**
+
+
+
+**示例作品 2**
+
+
+
+[点击观看视频 1](https://ffile.chatfire.site/cf/public/20260114094337396.mp4) | [点击观看视频 2](https://ffile.chatfire.site/cf/public/fcede75e8aeafe22031dbf78f86285b8.mp4)
+
+
+
+---
+
+## ✨ 功能特性
+
+### 🎭 角色管理
+- ✅ AI生成角色形象
+- ✅ 批量角色生成
+- ✅ 角色图片上传和管理
+
+### 🎬 分镜制作
+- ✅ 自动生成分镜脚本
+- ✅ 场景描述和镜头设计
+- ✅ 分镜图片生成(文生图)
+- ✅ 帧类型选择(首帧/关键帧/尾帧/分镜板)
+
+### 🎥 视频生成
+- ✅ 图生视频自动生成
+- ✅ 视频合成和剪辑
+- ✅ 转场效果
+
+### 📦 资源管理
+- ✅ 素材库统一管理
+- ✅ 本地存储支持
+- ✅ 资源导入导出
+- ✅ 任务进度追踪
+
+---
+
+## 🚀 快速开始
+
+### 📋 环境要求
+
+| 软件 | 版本要求 | 说明 |
+|------|---------|------|
+| **Go** | 1.23+ | 后端运行环境 |
+| **Node.js** | 18+ | 前端构建环境 |
+| **npm** | 9+ | 包管理工具 |
+| **FFmpeg** | 4.0+ | 视频处理(**必需**) |
+| **SQLite** | 3.x | 数据库(已内置) |
+
+#### 安装 FFmpeg
+
+**macOS:**
+```bash
+brew install ffmpeg
+```
+
+**Ubuntu/Debian:**
+```bash
+sudo apt update
+sudo apt install ffmpeg
+```
+
+**Windows:**
+从 [FFmpeg官网](https://ffmpeg.org/download.html) 下载并配置环境变量
+
+验证安装:
+```bash
+ffmpeg -version
+```
+
+### ⚙️ 配置文件
+
+复制并编辑配置文件:
+
+```bash
+cp configs/config.example.yaml configs/config.yaml
+vim configs/config.yaml
+```
+
+配置文件格式(`configs/config.yaml`):
+
+```yaml
+app:
+ name: "Huobao Drama API"
+ version: "1.0.0"
+ debug: true # 开发环境设为true,生产环境设为false
+
+server:
+ port: 5678
+ host: "0.0.0.0"
+ cors_origins:
+ - "http://localhost:3012"
+ read_timeout: 600
+ write_timeout: 600
+
+database:
+ type: "sqlite"
+ path: "./data/drama_generator.db"
+ max_idle: 10
+ max_open: 100
+
+storage:
+ type: "local"
+ local_path: "./data/storage"
+ base_url: "http://localhost:5678/static"
+
+ai:
+ default_text_provider: "openai"
+ default_image_provider: "openai"
+ default_video_provider: "doubao"
+```
+
+**重要配置项:**
+- `app.debug`: 调试模式开关(开发环境建议设为true)
+- `server.port`: 服务运行端口
+- `server.cors_origins`: 允许跨域访问的前端地址
+- `database.path`: SQLite数据库文件路径
+- `storage.local_path`: 本地文件存储路径
+- `storage.base_url`: 静态资源访问URL
+- `ai.default_*_provider`: AI服务提供商配置(在Web界面中配置具体的API Key)
+
+### 📥 安装依赖
+
+```bash
+# 克隆项目
+git clone https://github.com/chatfire-AI/huobao-drama.git
+cd huobao-drama
+
+# 安装Go依赖
+go mod download
+
+# 安装前端依赖
+cd web
+npm install
+cd ..
+```
+
+### 🎯 启动项目
+
+#### 方式一:开发模式(推荐)
+
+**前后端分离,支持热重载**
+
+```bash
+# 终端1:启动后端服务
+go run main.go
+
+# 终端2:启动前端开发服务器
+cd web
+npm run dev
+```
+
+- 前端地址: `http://localhost:3012`
+- 后端API: `http://localhost:5678/api/v1`
+- 前端自动代理API请求到后端
+
+#### 方式二:单服务模式
+
+**后端同时提供API和前端静态文件**
+
+```bash
+# 1. 构建前端
+cd web
+npm run build
+cd ..
+
+# 2. 启动服务
+go run main.go
+```
+
+访问: `http://localhost:5678`
+
+### 🗄️ 数据库初始化
+
+数据库表会在首次启动时自动创建(使用GORM AutoMigrate),无需手动迁移。
+
+---
+
+## 📦 部署指南
+
+### 🐳 Docker 部署(推荐)
+
+#### 方式一:Docker Compose(推荐)
+
+```bash
+# 启动服务
+docker-compose up -d
+
+# 查看日志
+docker-compose logs -f
+
+# 停止服务
+docker-compose down
+```
+
+#### 方式二:Docker 命令
+
+> **注意**:Linux 用户需添加 `--add-host=host.docker.internal:host-gateway` 以访问宿主机服务
+
+```bash
+# 从 Docker Hub 运行
+docker run -d \
+ --name huobao-drama \
+ -p 5678:5678 \
+ -v $(pwd)/data:/app/data \
+ --restart unless-stopped \
+ huobao/huobao-drama:latest
+
+# 查看日志
+docker logs -f huobao-drama
+```
+
+**本地构建**(可选):
+```bash
+docker build -t huobao-drama:latest .
+docker run -d --name huobao-drama -p 5678:5678 -v $(pwd)/data:/app/data huobao-drama:latest
+```
+
+**Docker 部署优势:**
+- ✅ 开箱即用,内置默认配置
+- ✅ 环境一致性,避免依赖问题
+- ✅ 一键启动,无需安装 Go、Node.js、FFmpeg
+- ✅ 易于迁移和扩展
+- ✅ 自动健康检查和重启
+- ✅ 自动处理文件权限,无需手动配置
+
+#### 🔗 访问宿主机服务(Ollama/本地模型)
+
+容器已配置支持访问宿主机服务,直接使用 `http://host.docker.internal:端口号` 即可。
+
+**配置步骤:**
+
+1. **宿主机启动服务(监听所有接口)**
+ ```bash
+ export OLLAMA_HOST=0.0.0.0:11434 && ollama serve
+ ```
+
+2. **前端 AI 服务配置**
+ - Base URL: `http://host.docker.internal:11434/v1`
+ - Provider: `openai`
+ - Model: `qwen2.5:latest`
+
+---
+
+### 🏭 传统部署方式
+
+#### 1. 编译构建
+
+```bash
+# 1. 构建前端
+cd web
+npm run build
+cd ..
+
+# 2. 编译后端
+go build -o huobao-drama .
+```
+
+生成文件:
+- `huobao-drama` - 后端可执行文件
+- `web/dist/` - 前端静态文件(已嵌入后端)
+
+#### 2. 准备部署文件
+
+需要上传到服务器的文件:
+```
+huobao-drama # 后端可执行文件
+configs/config.yaml # 配置文件
+data/ # 数据目录(可选,首次运行自动创建)
+```
+
+#### 3. 服务器配置
+
+```bash
+# 上传文件到服务器
+scp huobao-drama user@server:/opt/huobao-drama/
+scp configs/config.yaml user@server:/opt/huobao-drama/configs/
+
+# SSH登录服务器
+ssh user@server
+
+# 修改配置文件
+cd /opt/huobao-drama
+vim configs/config.yaml
+# 设置mode为production
+# 配置域名和存储路径
+
+# 创建数据目录并设置权限(重要!)
+# 注意:将 YOUR_USER 替换为实际运行服务的用户名(如 www-data、ubuntu、deploy 等)
+sudo mkdir -p /opt/huobao-drama/data/storage
+sudo chown -R YOUR_USER:YOUR_USER /opt/huobao-drama/data
+sudo chmod -R 755 /opt/huobao-drama/data
+
+# 赋予执行权限
+chmod +x huobao-drama
+
+# 启动服务
+./huobao-drama
+```
+
+#### 4. 使用 systemd 管理服务
+
+创建服务文件 `/etc/systemd/system/huobao-drama.service`:
+
+```ini
+[Unit]
+Description=Huobao Drama Service
+After=network.target
+
+[Service]
+Type=simple
+User=YOUR_USER
+WorkingDirectory=/opt/huobao-drama
+ExecStart=/opt/huobao-drama/huobao-drama
+Restart=on-failure
+RestartSec=10
+
+# 环境变量(可选)
+# Environment="GIN_MODE=release"
+
+[Install]
+WantedBy=multi-user.target
+```
+
+启动服务:
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable huobao-drama
+sudo systemctl start huobao-drama
+sudo systemctl status huobao-drama
+```
+
+**⚠️ 常见问题:SQLite 写权限错误**
+
+如果遇到 `attempt to write a readonly database` 错误:
+
+```bash
+# 1. 确认当前运行服务的用户
+sudo systemctl status huobao-drama | grep "Main PID"
+ps aux | grep huobao-drama
+
+# 2. 修复权限(将 YOUR_USER 替换为实际用户名)
+sudo chown -R YOUR_USER:YOUR_USER /opt/huobao-drama/data
+sudo chmod -R 755 /opt/huobao-drama/data
+
+# 3. 验证权限
+ls -la /opt/huobao-drama/data
+# 应该显示所有者为运行服务的用户
+
+# 4. 重启服务
+sudo systemctl restart huobao-drama
+```
+
+**原因说明**:
+- SQLite 需要对数据库文件 **和** 所在目录都有写权限
+- 需要在目录中创建临时文件(如 `-wal`、`-journal`)
+- **关键**:确保 systemd 配置中的 `User` 与数据目录所有者一致
+
+**常用用户名**:
+- Ubuntu/Debian: `www-data`、`ubuntu`
+- CentOS/RHEL: `nginx`、`apache`
+- 自定义部署: `deploy`、`app`、当前登录用户
+
+#### 5. Nginx 反向代理
+
+```nginx
+server {
+ listen 80;
+ server_name your-domain.com;
+
+ location / {
+ proxy_pass http://localhost:5678;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+
+ # 静态文件直接访问
+ location /static/ {
+ alias /opt/huobao-drama/data/storage/;
+ }
+}
+```
+
+---
+
+## 🎨 技术栈
+
+### 后端技术
+- **语言**: Go 1.23+
+- **Web框架**: Gin 1.9+
+- **ORM**: GORM
+- **数据库**: SQLite
+- **日志**: Zap
+- **视频处理**: FFmpeg
+- **AI服务**: OpenAI、Gemini、火山等
+
+### 前端技术
+- **框架**: Vue 3.4+
+- **语言**: TypeScript 5+
+- **构建工具**: Vite 5
+- **UI组件**: Element Plus
+- **CSS框架**: TailwindCSS
+- **状态管理**: Pinia
+- **路由**: Vue Router 4
+
+### 开发工具
+- **包管理**: Go Modules, npm
+- **代码规范**: ESLint, Prettier
+- **版本控制**: Git
+
+---
+
+## 📝 常见问题
+
+### Q: Docker 容器如何访问宿主机的 Ollama?
+A: 使用 `http://host.docker.internal:11434/v1` 作为 Base URL。注意两点:
+1. 宿主机 Ollama 需监听 `0.0.0.0`:`export OLLAMA_HOST=0.0.0.0:11434 && ollama serve`
+2. Linux 用户使用 `docker run` 需添加:`--add-host=host.docker.internal:host-gateway`
+
+详见:[DOCKER_HOST_ACCESS.md](docs/DOCKER_HOST_ACCESS.md)
+
+### Q: FFmpeg未安装或找不到?
+A: 确保FFmpeg已安装并在PATH环境变量中。运行 `ffmpeg -version` 验证。
+
+### Q: 前端无法连接后端API?
+A: 检查后端是否启动,端口是否正确。开发模式下前端代理配置在 `web/vite.config.ts`。
+
+### Q: 数据库表未创建?
+A: GORM会在首次启动时自动创建表,检查日志确认迁移是否成功。
+
+---
+
+## � 更新日志 / Changelog
+
+### v1.0.2 (2026-01-16)
+
+#### 🚀 重大更新
+- SQLite 纯 Go 驱动(`modernc.org/sqlite`),支持 `CGO_ENABLED=0` 跨平台编译
+- 优化并发性能(WAL 模式),解决 "database is locked" 错误
+- Docker 跨平台支持 `host.docker.internal` 访问宿主机服务
+- 精简文档和部署指南
+
+### v1.0.1 (2026-01-14)
+
+#### 🐛 Bug Fixes / 🔧 Improvements
+- 修复视频生成 API 响应解析问题
+- 添加 OpenAI Sora 视频端点配置
+- 优化错误处理和日志输出
+
+---
+
+## 🤝 贡献指南
+
+欢迎提交 Issue 和 Pull Request!
+
+1. Fork 本项目
+2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
+3. 提交改动 (`git commit -m 'Add some AmazingFeature'`)
+4. 推送到分支 (`git push origin feature/AmazingFeature`)
+5. 开启 Pull Request
+
+---
+
+## API配置站点
+2分钟完成配置:[API聚合站点](https://api.chatfire.site/models)
+
+## 📧 联系方式
+商务联系V:dangbao1117
+## 项目交流群
+
+- 提交 [Issue](../../issues)
+- 发送邮件至项目维护者
+
+---
+
+
+
+**⭐ 如果这个项目对你有帮助,请给一个Star!**
+## Star History
+
+[](https://www.star-history.com/#chatfire-AI/huobao-drama&type=date&legend=top-left)
+Made with ❤️ by Huobao Team
+
+
diff --git a/api/handlers/ai_config.go b/api/handlers/ai_config.go
new file mode 100644
index 0000000..f1207df
--- /dev/null
+++ b/api/handlers/ai_config.go
@@ -0,0 +1,136 @@
+package handlers
+
+import (
+ "strconv"
+
+ "github.com/drama-generator/backend/application/services"
+ "github.com/drama-generator/backend/pkg/config"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+type AIConfigHandler struct {
+ aiService *services.AIService
+ log *logger.Logger
+}
+
+func NewAIConfigHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *AIConfigHandler {
+ return &AIConfigHandler{
+ aiService: services.NewAIService(db, log),
+ log: log,
+ }
+}
+
+func (h *AIConfigHandler) CreateConfig(c *gin.Context) {
+ var req services.CreateAIConfigRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ config, err := h.aiService.CreateConfig(&req)
+ if err != nil {
+ response.InternalError(c, "创建失败")
+ return
+ }
+
+ response.Created(c, config)
+}
+
+func (h *AIConfigHandler) GetConfig(c *gin.Context) {
+
+ configID, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ response.BadRequest(c, "无效的配置ID")
+ return
+ }
+
+ config, err := h.aiService.GetConfig(uint(configID))
+ if err != nil {
+ if err.Error() == "config not found" {
+ response.NotFound(c, "配置不存在")
+ return
+ }
+ response.InternalError(c, "获取失败")
+ return
+ }
+
+ response.Success(c, config)
+}
+
+func (h *AIConfigHandler) ListConfigs(c *gin.Context) {
+
+ serviceType := c.Query("service_type")
+
+ configs, err := h.aiService.ListConfigs(serviceType)
+ if err != nil {
+ response.InternalError(c, "获取列表失败")
+ return
+ }
+
+ response.Success(c, configs)
+}
+
+func (h *AIConfigHandler) UpdateConfig(c *gin.Context) {
+
+ configID, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ response.BadRequest(c, "无效的配置ID")
+ return
+ }
+
+ var req services.UpdateAIConfigRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ config, err := h.aiService.UpdateConfig(uint(configID), &req)
+ if err != nil {
+ if err.Error() == "config not found" {
+ response.NotFound(c, "配置不存在")
+ return
+ }
+ response.InternalError(c, "更新失败")
+ return
+ }
+
+ response.Success(c, config)
+}
+
+func (h *AIConfigHandler) DeleteConfig(c *gin.Context) {
+
+ configID, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ response.BadRequest(c, "无效的配置ID")
+ return
+ }
+
+ if err := h.aiService.DeleteConfig(uint(configID)); err != nil {
+ if err.Error() == "config not found" {
+ response.NotFound(c, "配置不存在")
+ return
+ }
+ response.InternalError(c, "删除失败")
+ return
+ }
+
+ response.Success(c, gin.H{"message": "删除成功"})
+}
+
+func (h *AIConfigHandler) TestConnection(c *gin.Context) {
+ var req services.TestConnectionRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ if err := h.aiService.TestConnection(&req); err != nil {
+ response.BadRequest(c, "连接测试失败: "+err.Error())
+ return
+ }
+
+ response.Success(c, gin.H{"message": "连接测试成功"})
+}
diff --git a/api/handlers/asset.go b/api/handlers/asset.go
new file mode 100644
index 0000000..baa1cd5
--- /dev/null
+++ b/api/handlers/asset.go
@@ -0,0 +1,220 @@
+package handlers
+
+import (
+ "strconv"
+ "strings"
+
+ "github.com/drama-generator/backend/application/services"
+ "github.com/drama-generator/backend/domain/models"
+ "github.com/drama-generator/backend/pkg/config"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+type AssetHandler struct {
+ assetService *services.AssetService
+ log *logger.Logger
+}
+
+func NewAssetHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *AssetHandler {
+ return &AssetHandler{
+ assetService: services.NewAssetService(db, log),
+ log: log,
+ }
+}
+
+func (h *AssetHandler) CreateAsset(c *gin.Context) {
+
+ var req services.CreateAssetRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ asset, err := h.assetService.CreateAsset(&req)
+ if err != nil {
+ h.log.Errorw("Failed to create asset", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, asset)
+}
+
+func (h *AssetHandler) UpdateAsset(c *gin.Context) {
+
+ assetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ response.BadRequest(c, "无效的ID")
+ return
+ }
+
+ var req services.UpdateAssetRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ asset, err := h.assetService.UpdateAsset(uint(assetID), &req)
+ if err != nil {
+ h.log.Errorw("Failed to update asset", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, asset)
+}
+
+func (h *AssetHandler) GetAsset(c *gin.Context) {
+
+ assetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ response.BadRequest(c, "无效的ID")
+ return
+ }
+
+ asset, err := h.assetService.GetAsset(uint(assetID))
+ if err != nil {
+ response.NotFound(c, "素材不存在")
+ return
+ }
+
+ response.Success(c, asset)
+}
+
+func (h *AssetHandler) ListAssets(c *gin.Context) {
+
+ var dramaID *string
+ if dramaIDStr := c.Query("drama_id"); dramaIDStr != "" {
+ dramaID = &dramaIDStr
+ }
+
+ var episodeID *uint
+ if episodeIDStr := c.Query("episode_id"); episodeIDStr != "" {
+ if id, err := strconv.ParseUint(episodeIDStr, 10, 32); err == nil {
+ uid := uint(id)
+ episodeID = &uid
+ }
+ }
+
+ var storyboardID *uint
+ if storyboardIDStr := c.Query("storyboard_id"); storyboardIDStr != "" {
+ if id, err := strconv.ParseUint(storyboardIDStr, 10, 32); err == nil {
+ uid := uint(id)
+ storyboardID = &uid
+ }
+ }
+
+ var assetType *models.AssetType
+ if typeStr := c.Query("type"); typeStr != "" {
+ t := models.AssetType(typeStr)
+ assetType = &t
+ }
+
+ var isFavorite *bool
+ if favoriteStr := c.Query("is_favorite"); favoriteStr != "" {
+ if favoriteStr == "true" {
+ fav := true
+ isFavorite = &fav
+ } else if favoriteStr == "false" {
+ fav := false
+ isFavorite = &fav
+ }
+ }
+
+ var tagIDs []uint
+ if tagIDsStr := c.Query("tag_ids"); tagIDsStr != "" {
+ for _, idStr := range strings.Split(tagIDsStr, ",") {
+ if id, err := strconv.ParseUint(strings.TrimSpace(idStr), 10, 32); err == nil {
+ tagIDs = append(tagIDs, uint(id))
+ }
+ }
+ }
+
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
+
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 || pageSize > 100 {
+ pageSize = 20
+ }
+
+ req := &services.ListAssetsRequest{
+ DramaID: dramaID,
+ EpisodeID: episodeID,
+ StoryboardID: storyboardID,
+ Type: assetType,
+ Category: c.Query("category"),
+ TagIDs: tagIDs,
+ IsFavorite: isFavorite,
+ Search: c.Query("search"),
+ Page: page,
+ PageSize: pageSize,
+ }
+
+ assets, total, err := h.assetService.ListAssets(req)
+ if err != nil {
+ h.log.Errorw("Failed to list assets", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.SuccessWithPagination(c, assets, total, page, pageSize)
+}
+
+func (h *AssetHandler) DeleteAsset(c *gin.Context) {
+
+ assetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ response.BadRequest(c, "无效的ID")
+ return
+ }
+
+ if err := h.assetService.DeleteAsset(uint(assetID)); err != nil {
+ h.log.Errorw("Failed to delete asset", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, nil)
+}
+
+func (h *AssetHandler) ImportFromImageGen(c *gin.Context) {
+
+ imageGenID, err := strconv.ParseUint(c.Param("image_gen_id"), 10, 32)
+ if err != nil {
+ response.BadRequest(c, "无效的ID")
+ return
+ }
+
+ asset, err := h.assetService.ImportFromImageGen(uint(imageGenID))
+ if err != nil {
+ h.log.Errorw("Failed to import from image gen", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, asset)
+}
+
+func (h *AssetHandler) ImportFromVideoGen(c *gin.Context) {
+
+ videoGenID, err := strconv.ParseUint(c.Param("video_gen_id"), 10, 32)
+ if err != nil {
+ response.BadRequest(c, "无效的ID")
+ return
+ }
+
+ asset, err := h.assetService.ImportFromVideoGen(uint(videoGenID))
+ if err != nil {
+ h.log.Errorw("Failed to import from video gen", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, asset)
+}
diff --git a/api/handlers/character_batch.go b/api/handlers/character_batch.go
new file mode 100644
index 0000000..7a75820
--- /dev/null
+++ b/api/handlers/character_batch.go
@@ -0,0 +1,34 @@
+package handlers
+
+import (
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+)
+
+// BatchGenerateCharacterImages 批量生成角色图片
+func (h *CharacterLibraryHandler) BatchGenerateCharacterImages(c *gin.Context) {
+
+ var req struct {
+ CharacterIDs []string `json:"character_ids" binding:"required,min=1"`
+ Model string `json:"model"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ // 限制批量生成数量
+ if len(req.CharacterIDs) > 10 {
+ response.BadRequest(c, "单次最多生成10个角色")
+ return
+ }
+
+ // 异步批量生成
+ go h.libraryService.BatchGenerateCharacterImages(req.CharacterIDs, h.imageService, req.Model)
+
+ response.Success(c, gin.H{
+ "message": "批量生成任务已提交",
+ "count": len(req.CharacterIDs),
+ })
+}
diff --git a/api/handlers/character_library.go b/api/handlers/character_library.go
new file mode 100644
index 0000000..aa97936
--- /dev/null
+++ b/api/handlers/character_library.go
@@ -0,0 +1,275 @@
+package handlers
+
+import (
+ "strconv"
+
+ services2 "github.com/drama-generator/backend/application/services"
+ "github.com/drama-generator/backend/infrastructure/storage"
+ "github.com/drama-generator/backend/pkg/config"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+type CharacterLibraryHandler struct {
+ libraryService *services2.CharacterLibraryService
+ imageService *services2.ImageGenerationService
+ log *logger.Logger
+}
+
+func NewCharacterLibraryHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, transferService *services2.ResourceTransferService, localStorage *storage.LocalStorage) *CharacterLibraryHandler {
+ return &CharacterLibraryHandler{
+ libraryService: services2.NewCharacterLibraryService(db, log),
+ imageService: services2.NewImageGenerationService(db, transferService, localStorage, log),
+ log: log,
+ }
+}
+
+// ListLibraryItems 获取角色库列表
+func (h *CharacterLibraryHandler) ListLibraryItems(c *gin.Context) {
+
+ var query services2.CharacterLibraryQuery
+ if err := c.ShouldBindQuery(&query); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ if query.Page < 1 {
+ query.Page = 1
+ }
+ if query.PageSize < 1 || query.PageSize > 100 {
+ query.PageSize = 20
+ }
+
+ items, total, err := h.libraryService.ListLibraryItems(&query)
+ if err != nil {
+ h.log.Errorw("Failed to list library items", "error", err)
+ response.InternalError(c, "获取角色库失败")
+ return
+ }
+
+ response.SuccessWithPagination(c, items, total, query.Page, query.PageSize)
+}
+
+// CreateLibraryItem 添加到角色库
+func (h *CharacterLibraryHandler) CreateLibraryItem(c *gin.Context) {
+
+ var req services2.CreateLibraryItemRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ item, err := h.libraryService.CreateLibraryItem(&req)
+ if err != nil {
+ h.log.Errorw("Failed to create library item", "error", err)
+ response.InternalError(c, "添加到角色库失败")
+ return
+ }
+
+ response.Created(c, item)
+}
+
+// GetLibraryItem 获取角色库项详情
+func (h *CharacterLibraryHandler) GetLibraryItem(c *gin.Context) {
+
+ itemID := c.Param("id")
+
+ item, err := h.libraryService.GetLibraryItem(itemID)
+ if err != nil {
+ if err.Error() == "library item not found" {
+ response.NotFound(c, "角色库项不存在")
+ return
+ }
+ h.log.Errorw("Failed to get library item", "error", err)
+ response.InternalError(c, "获取失败")
+ return
+ }
+
+ response.Success(c, item)
+}
+
+// DeleteLibraryItem 删除角色库项
+func (h *CharacterLibraryHandler) DeleteLibraryItem(c *gin.Context) {
+
+ itemID := c.Param("id")
+
+ if err := h.libraryService.DeleteLibraryItem(itemID); err != nil {
+ if err.Error() == "library item not found" {
+ response.NotFound(c, "角色库项不存在")
+ return
+ }
+ h.log.Errorw("Failed to delete library item", "error", err)
+ response.InternalError(c, "删除失败")
+ return
+ }
+
+ response.Success(c, gin.H{"message": "删除成功"})
+}
+
+// UploadCharacterImage 上传角色图片
+func (h *CharacterLibraryHandler) UploadCharacterImage(c *gin.Context) {
+
+ characterID := c.Param("id")
+
+ // TODO: 处理文件上传
+ // 这里需要实现文件上传逻辑,保存到OSS或本地
+ // 暂时使用简单的实现
+ var req struct {
+ ImageURL string `json:"image_url" binding:"required"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ if err := h.libraryService.UploadCharacterImage(characterID, req.ImageURL); err != nil {
+ if err.Error() == "character not found" {
+ response.NotFound(c, "角色不存在")
+ return
+ }
+ if err.Error() == "unauthorized" {
+ response.Forbidden(c, "无权限")
+ return
+ }
+ h.log.Errorw("Failed to upload character image", "error", err)
+ response.InternalError(c, "上传失败")
+ return
+ }
+
+ response.Success(c, gin.H{"message": "上传成功"})
+}
+
+// ApplyLibraryItemToCharacter 从角色库应用形象
+func (h *CharacterLibraryHandler) ApplyLibraryItemToCharacter(c *gin.Context) {
+
+ characterID := c.Param("id")
+
+ var req struct {
+ LibraryItemID string `json:"library_item_id" binding:"required"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ if err := h.libraryService.ApplyLibraryItemToCharacter(characterID, req.LibraryItemID); err != nil {
+ if err.Error() == "library item not found" {
+ response.NotFound(c, "角色库项不存在")
+ return
+ }
+ if err.Error() == "character not found" {
+ response.NotFound(c, "角色不存在")
+ return
+ }
+ if err.Error() == "unauthorized" {
+ response.Forbidden(c, "无权限")
+ return
+ }
+ h.log.Errorw("Failed to apply library item", "error", err)
+ response.InternalError(c, "应用失败")
+ return
+ }
+
+ response.Success(c, gin.H{"message": "应用成功"})
+}
+
+// AddCharacterToLibrary 将角色添加到角色库
+func (h *CharacterLibraryHandler) AddCharacterToLibrary(c *gin.Context) {
+
+ characterID := c.Param("id")
+
+ var req struct {
+ Category *string `json:"category"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ // 允许空body
+ req.Category = nil
+ }
+
+ item, err := h.libraryService.AddCharacterToLibrary(characterID, req.Category)
+ if err != nil {
+ if err.Error() == "character not found" {
+ response.NotFound(c, "角色不存在")
+ return
+ }
+ if err.Error() == "unauthorized" {
+ response.Forbidden(c, "无权限")
+ return
+ }
+ if err.Error() == "character has no image" {
+ response.BadRequest(c, "角色还没有形象图片")
+ return
+ }
+ h.log.Errorw("Failed to add character to library", "error", err)
+ response.InternalError(c, "添加失败")
+ return
+ }
+
+ response.Created(c, item)
+}
+
+// UpdateCharacter 更新角色信息
+func (h *CharacterLibraryHandler) UpdateCharacter(c *gin.Context) {
+
+ characterID := c.Param("id")
+
+ var req struct {
+ Name *string `json:"name"`
+ Appearance *string `json:"appearance"`
+ Personality *string `json:"personality"`
+ Description *string `json:"description"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ if err := h.libraryService.UpdateCharacter(characterID, &req); err != nil {
+ if err.Error() == "character not found" {
+ response.NotFound(c, "角色不存在")
+ return
+ }
+ if err.Error() == "unauthorized" {
+ response.Forbidden(c, "无权限")
+ return
+ }
+ h.log.Errorw("Failed to update character", "error", err)
+ response.InternalError(c, "更新失败")
+ return
+ }
+
+ response.Success(c, gin.H{"message": "更新成功"})
+}
+
+// DeleteCharacter 删除单个角色
+func (h *CharacterLibraryHandler) DeleteCharacter(c *gin.Context) {
+
+ characterIDStr := c.Param("id")
+ characterID, err := strconv.ParseUint(characterIDStr, 10, 32)
+ if err != nil {
+ response.BadRequest(c, "无效的角色ID")
+ return
+ }
+
+ if err := h.libraryService.DeleteCharacter(uint(characterID)); err != nil {
+ h.log.Errorw("Failed to delete character", "error", err, "id", characterID)
+ if err.Error() == "character not found" {
+ response.NotFound(c, "角色不存在")
+ return
+ }
+ if err.Error() == "unauthorized" {
+ response.Forbidden(c, "无权删除此角色")
+ return
+ }
+ response.InternalError(c, "删除失败")
+ return
+ }
+
+ response.Success(c, gin.H{"message": "角色已删除"})
+}
diff --git a/api/handlers/character_library_gen.go b/api/handlers/character_library_gen.go
new file mode 100644
index 0000000..b94ddf6
--- /dev/null
+++ b/api/handlers/character_library_gen.go
@@ -0,0 +1,38 @@
+package handlers
+
+import (
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+)
+
+// GenerateCharacterImage AI生成角色形象
+func (h *CharacterLibraryHandler) GenerateCharacterImage(c *gin.Context) {
+
+ characterID := c.Param("id")
+
+ // 获取请求体中的model参数
+ var req struct {
+ Model string `json:"model"`
+ }
+ c.ShouldBindJSON(&req)
+
+ imageGen, err := h.libraryService.GenerateCharacterImage(characterID, h.imageService, req.Model)
+ if err != nil {
+ if err.Error() == "character not found" {
+ response.NotFound(c, "角色不存在")
+ return
+ }
+ if err.Error() == "unauthorized" {
+ response.Forbidden(c, "无权限")
+ return
+ }
+ h.log.Errorw("Failed to generate character image", "error", err)
+ response.InternalError(c, "生成失败")
+ return
+ }
+
+ response.Success(c, gin.H{
+ "message": "角色图片生成已启动",
+ "image_generation": imageGen,
+ })
+}
diff --git a/api/handlers/drama.go b/api/handlers/drama.go
new file mode 100644
index 0000000..e45d329
--- /dev/null
+++ b/api/handlers/drama.go
@@ -0,0 +1,310 @@
+package handlers
+
+import (
+ "github.com/drama-generator/backend/application/services"
+ "github.com/drama-generator/backend/domain/models"
+ "github.com/drama-generator/backend/pkg/config"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+type DramaHandler struct {
+ db *gorm.DB
+ dramaService *services.DramaService
+ videoMergeService *services.VideoMergeService
+ log *logger.Logger
+}
+
+func NewDramaHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, transferService *services.ResourceTransferService) *DramaHandler {
+ return &DramaHandler{
+ db: db,
+ dramaService: services.NewDramaService(db, log),
+ videoMergeService: services.NewVideoMergeService(db, transferService, cfg.Storage.LocalPath, cfg.Storage.BaseURL, log),
+ log: log,
+ }
+}
+
+func (h *DramaHandler) CreateDrama(c *gin.Context) {
+
+ var req services.CreateDramaRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ drama, err := h.dramaService.CreateDrama(&req)
+ if err != nil {
+ response.InternalError(c, "创建失败")
+ return
+ }
+
+ response.Created(c, drama)
+}
+
+func (h *DramaHandler) GetDrama(c *gin.Context) {
+
+ dramaID := c.Param("id")
+
+ drama, err := h.dramaService.GetDrama(dramaID)
+ if err != nil {
+ if err.Error() == "drama not found" {
+ response.NotFound(c, "剧本不存在")
+ return
+ }
+ response.InternalError(c, "获取失败")
+ return
+ }
+
+ response.Success(c, drama)
+}
+
+func (h *DramaHandler) ListDramas(c *gin.Context) {
+
+ var query services.DramaListQuery
+ if err := c.ShouldBindQuery(&query); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ if query.Page < 1 {
+ query.Page = 1
+ }
+ if query.PageSize < 1 || query.PageSize > 100 {
+ query.PageSize = 20
+ }
+
+ dramas, total, err := h.dramaService.ListDramas(&query)
+ if err != nil {
+ response.InternalError(c, "获取列表失败")
+ return
+ }
+
+ response.SuccessWithPagination(c, dramas, total, query.Page, query.PageSize)
+}
+
+func (h *DramaHandler) UpdateDrama(c *gin.Context) {
+
+ dramaID := c.Param("id")
+
+ var req services.UpdateDramaRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ drama, err := h.dramaService.UpdateDrama(dramaID, &req)
+ if err != nil {
+ if err.Error() == "drama not found" {
+ response.NotFound(c, "剧本不存在")
+ return
+ }
+ response.InternalError(c, "更新失败")
+ return
+ }
+
+ response.Success(c, drama)
+}
+
+func (h *DramaHandler) DeleteDrama(c *gin.Context) {
+
+ dramaID := c.Param("id")
+
+ if err := h.dramaService.DeleteDrama(dramaID); err != nil {
+ if err.Error() == "drama not found" {
+ response.NotFound(c, "剧本不存在")
+ return
+ }
+ response.InternalError(c, "删除失败")
+ return
+ }
+
+ response.Success(c, gin.H{"message": "删除成功"})
+}
+
+func (h *DramaHandler) GetDramaStats(c *gin.Context) {
+
+ stats, err := h.dramaService.GetDramaStats()
+ if err != nil {
+ response.InternalError(c, "获取统计失败")
+ return
+ }
+
+ response.Success(c, stats)
+}
+
+func (h *DramaHandler) SaveOutline(c *gin.Context) {
+
+ dramaID := c.Param("id")
+
+ var req services.SaveOutlineRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ if err := h.dramaService.SaveOutline(dramaID, &req); err != nil {
+ if err.Error() == "drama not found" {
+ response.NotFound(c, "剧本不存在")
+ return
+ }
+ response.InternalError(c, "保存失败")
+ return
+ }
+
+ response.Success(c, gin.H{"message": "保存成功"})
+}
+
+func (h *DramaHandler) GetCharacters(c *gin.Context) {
+
+ dramaID := c.Param("id")
+ episodeID := c.Query("episode_id") // 可选:如果提供则只返回该章节的角色
+
+ var episodeIDPtr *string
+ if episodeID != "" {
+ episodeIDPtr = &episodeID
+ }
+
+ characters, err := h.dramaService.GetCharacters(dramaID, episodeIDPtr)
+ if err != nil {
+ if err.Error() == "drama not found" {
+ response.NotFound(c, "剧本不存在")
+ return
+ }
+ if err.Error() == "episode not found" {
+ response.NotFound(c, "章节不存在")
+ return
+ }
+ response.InternalError(c, "获取角色失败")
+ return
+ }
+
+ response.Success(c, characters)
+}
+
+func (h *DramaHandler) SaveCharacters(c *gin.Context) {
+
+ dramaID := c.Param("id")
+
+ var req services.SaveCharactersRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ if err := h.dramaService.SaveCharacters(dramaID, &req); err != nil {
+ if err.Error() == "drama not found" {
+ response.NotFound(c, "剧本不存在")
+ return
+ }
+ response.InternalError(c, "保存失败")
+ return
+ }
+
+ response.Success(c, gin.H{"message": "保存成功"})
+}
+
+func (h *DramaHandler) SaveEpisodes(c *gin.Context) {
+
+ dramaID := c.Param("id")
+
+ var req services.SaveEpisodesRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ if err := h.dramaService.SaveEpisodes(dramaID, &req); err != nil {
+ if err.Error() == "drama not found" {
+ response.NotFound(c, "剧本不存在")
+ return
+ }
+ response.InternalError(c, "保存失败")
+ return
+ }
+
+ response.Success(c, gin.H{"message": "保存成功"})
+}
+
+func (h *DramaHandler) SaveProgress(c *gin.Context) {
+
+ dramaID := c.Param("id")
+
+ var req services.SaveProgressRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ if err := h.dramaService.SaveProgress(dramaID, &req); err != nil {
+ if err.Error() == "drama not found" {
+ response.NotFound(c, "剧本不存在")
+ return
+ }
+ response.InternalError(c, "保存失败")
+ return
+ }
+
+ response.Success(c, gin.H{"message": "保存成功"})
+}
+
+// FinalizeEpisode 完成集数制作(触发视频合成)
+func (h *DramaHandler) FinalizeEpisode(c *gin.Context) {
+
+ episodeID := c.Param("episode_id")
+ if episodeID == "" {
+ response.BadRequest(c, "episode_id不能为空")
+ return
+ }
+
+ // 尝试读取时间线数据(可选)
+ var timelineData *services.FinalizeEpisodeRequest
+ if err := c.ShouldBindJSON(&timelineData); err != nil {
+ // 如果没有请求体或解析失败,使用nil(将使用默认场景顺序)
+ h.log.Warnw("No timeline data provided, will use default scene order", "error", err)
+ timelineData = nil
+ } else if timelineData != nil {
+ h.log.Infow("Received timeline data", "clips_count", len(timelineData.Clips), "episode_id", episodeID)
+ }
+
+ // 触发视频合成任务
+ result, err := h.videoMergeService.FinalizeEpisode(episodeID, timelineData)
+ if err != nil {
+ h.log.Errorw("Failed to finalize episode", "error", err, "episode_id", episodeID)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, result)
+}
+
+// DownloadEpisodeVideo 下载剧集视频
+func (h *DramaHandler) DownloadEpisodeVideo(c *gin.Context) {
+
+ episodeID := c.Param("episode_id")
+ if episodeID == "" {
+ response.BadRequest(c, "episode_id不能为空")
+ return
+ }
+
+ // 查询episode
+ var episode models.Episode
+ if err := h.db.Preload("Drama").Where("id = ?", episodeID).First(&episode).Error; err != nil {
+ response.NotFound(c, "剧集不存在")
+ return
+ }
+
+ // 检查是否有视频
+ if episode.VideoURL == nil || *episode.VideoURL == "" {
+ response.BadRequest(c, "该剧集还没有生成视频")
+ return
+ }
+
+ // 返回视频URL,让前端重定向下载
+ c.JSON(200, gin.H{
+ "video_url": *episode.VideoURL,
+ "title": episode.Title,
+ "episode_number": episode.EpisodeNum,
+ })
+}
diff --git a/api/handlers/frame_prompt.go b/api/handlers/frame_prompt.go
new file mode 100644
index 0000000..33ced9d
--- /dev/null
+++ b/api/handlers/frame_prompt.go
@@ -0,0 +1,55 @@
+package handlers
+
+import (
+ "github.com/drama-generator/backend/application/services"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+)
+
+// FramePromptHandler 处理帧提示词生成请求
+type FramePromptHandler struct {
+ framePromptService *services.FramePromptService
+ log *logger.Logger
+}
+
+// NewFramePromptHandler 创建帧提示词处理器
+func NewFramePromptHandler(framePromptService *services.FramePromptService, log *logger.Logger) *FramePromptHandler {
+ return &FramePromptHandler{
+ framePromptService: framePromptService,
+ log: log,
+ }
+}
+
+// GenerateFramePrompt 生成指定类型的帧提示词
+// POST /api/v1/storyboards/:id/frame-prompt
+func (h *FramePromptHandler) GenerateFramePrompt(c *gin.Context) {
+ storyboardID := c.Param("id")
+
+ var req struct {
+ FrameType string `json:"frame_type" binding:"required"` // first, key, last, panel, action
+ PanelCount int `json:"panel_count"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request body")
+ return
+ }
+
+ // 构建请求
+ serviceReq := services.GenerateFramePromptRequest{
+ StoryboardID: storyboardID,
+ FrameType: services.FrameType(req.FrameType),
+ PanelCount: req.PanelCount,
+ }
+
+ // 生成提示词
+ result, err := h.framePromptService.GenerateFramePrompt(serviceReq)
+ if err != nil {
+ h.log.Errorw("Failed to generate frame prompt", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, result)
+}
diff --git a/api/handlers/frame_prompt_query.go b/api/handlers/frame_prompt_query.go
new file mode 100644
index 0000000..70cd407
--- /dev/null
+++ b/api/handlers/frame_prompt_query.go
@@ -0,0 +1,30 @@
+package handlers
+
+import (
+ "github.com/drama-generator/backend/domain/models"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+// GetStoryboardFramePrompts 查询镜头的所有帧提示词
+// GET /api/v1/storyboards/:id/frame-prompts
+func GetStoryboardFramePrompts(db *gorm.DB, log *logger.Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ storyboardID := c.Param("id")
+
+ var framePrompts []models.FramePrompt
+ if err := db.Where("storyboard_id = ?", storyboardID).
+ Order("created_at DESC").
+ Find(&framePrompts).Error; err != nil {
+ log.Errorw("Failed to query frame prompts", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, gin.H{
+ "frame_prompts": framePrompts,
+ })
+ }
+}
diff --git a/api/handlers/image_generation.go b/api/handlers/image_generation.go
new file mode 100644
index 0000000..7ba5dbf
--- /dev/null
+++ b/api/handlers/image_generation.go
@@ -0,0 +1,224 @@
+package handlers
+
+import (
+ "strconv"
+
+ "github.com/drama-generator/backend/application/services"
+ "github.com/drama-generator/backend/infrastructure/storage"
+ "github.com/drama-generator/backend/pkg/config"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+type ImageGenerationHandler struct {
+ imageService *services.ImageGenerationService
+ taskService *services.TaskService
+ log *logger.Logger
+}
+
+func NewImageGenerationHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, transferService *services.ResourceTransferService, localStorage *storage.LocalStorage) *ImageGenerationHandler {
+ return &ImageGenerationHandler{
+ imageService: services.NewImageGenerationService(db, transferService, localStorage, log),
+ taskService: services.NewTaskService(db, log),
+ log: log,
+ }
+}
+
+func (h *ImageGenerationHandler) GenerateImage(c *gin.Context) {
+
+ var req services.GenerateImageRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ imageGen, err := h.imageService.GenerateImage(&req)
+ if err != nil {
+ h.log.Errorw("Failed to generate image", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, imageGen)
+}
+
+func (h *ImageGenerationHandler) GenerateImagesForScene(c *gin.Context) {
+
+ sceneID := c.Param("scene_id")
+
+ images, err := h.imageService.GenerateImagesForScene(sceneID)
+ if err != nil {
+ h.log.Errorw("Failed to generate images for scene", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, images)
+}
+
+func (h *ImageGenerationHandler) GetBackgroundsForEpisode(c *gin.Context) {
+
+ episodeID := c.Param("episode_id")
+
+ backgrounds, err := h.imageService.GetScencesForEpisode(episodeID)
+ if err != nil {
+ h.log.Errorw("Failed to get backgrounds", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, backgrounds)
+}
+
+func (h *ImageGenerationHandler) ExtractBackgroundsForEpisode(c *gin.Context) {
+ episodeID := c.Param("episode_id")
+
+ // 创建异步任务
+ task, err := h.taskService.CreateTask("background_extraction", episodeID)
+ if err != nil {
+ h.log.Errorw("Failed to create task", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ // 启动后台goroutine处理
+ go h.processBackgroundExtraction(task.ID, episodeID)
+
+ // 立即返回任务ID
+ response.Success(c, gin.H{
+ "task_id": task.ID,
+ "status": "pending",
+ "message": "场景提取任务已创建,正在后台处理...",
+ })
+}
+
+// processBackgroundExtraction 后台处理场景提取
+func (h *ImageGenerationHandler) processBackgroundExtraction(taskID, episodeID string) {
+ h.log.Infow("Starting background extraction", "task_id", taskID, "episode_id", episodeID)
+
+ // 更新任务状态为处理中
+ if err := h.taskService.UpdateTaskStatus(taskID, "processing", 10, "开始提取场景..."); err != nil {
+ h.log.Errorw("Failed to update task status", "error", err)
+ }
+
+ // 调用实际的提取逻辑
+ backgrounds, err := h.imageService.ExtractBackgroundsForEpisode(episodeID)
+ if err != nil {
+ h.log.Errorw("Failed to extract backgrounds", "error", err, "task_id", taskID)
+ if updateErr := h.taskService.UpdateTaskError(taskID, err); updateErr != nil {
+ h.log.Errorw("Failed to update task error", "error", updateErr)
+ }
+ return
+ }
+
+ // 更新任务结果
+ result := gin.H{
+ "backgrounds": backgrounds,
+ "total": len(backgrounds),
+ }
+ if err := h.taskService.UpdateTaskResult(taskID, result); err != nil {
+ h.log.Errorw("Failed to update task result", "error", err)
+ return
+ }
+
+ h.log.Infow("Background extraction completed", "task_id", taskID, "total", len(backgrounds))
+}
+
+func (h *ImageGenerationHandler) BatchGenerateForEpisode(c *gin.Context) {
+
+ episodeID := c.Param("episode_id")
+
+ images, err := h.imageService.BatchGenerateImagesForEpisode(episodeID)
+ if err != nil {
+ h.log.Errorw("Failed to batch generate images", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, images)
+}
+
+func (h *ImageGenerationHandler) GetImageGeneration(c *gin.Context) {
+
+ imageGenID, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ response.BadRequest(c, "无效的ID")
+ return
+ }
+
+ imageGen, err := h.imageService.GetImageGeneration(uint(imageGenID))
+ if err != nil {
+ response.NotFound(c, "图片生成记录不存在")
+ return
+ }
+
+ response.Success(c, imageGen)
+}
+
+func (h *ImageGenerationHandler) ListImageGenerations(c *gin.Context) {
+ var sceneID *uint
+ if sceneIDStr := c.Query("scene_id"); sceneIDStr != "" {
+ id, err := strconv.ParseUint(sceneIDStr, 10, 32)
+ if err == nil {
+ uid := uint(id)
+ sceneID = &uid
+ }
+ }
+
+ var storyboardID *uint
+ if storyboardIDStr := c.Query("storyboard_id"); storyboardIDStr != "" {
+ id, err := strconv.ParseUint(storyboardIDStr, 10, 32)
+ if err == nil {
+ uid := uint(id)
+ storyboardID = &uid
+ }
+ }
+
+ frameType := c.Query("frame_type")
+ status := c.Query("status")
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
+
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 || pageSize > 100 {
+ pageSize = 20
+ }
+
+ var dramaIDUint *uint
+ if dramaIDStr := c.Query("drama_id"); dramaIDStr != "" {
+ did, _ := strconv.ParseUint(dramaIDStr, 10, 32)
+ didUint := uint(did)
+ dramaIDUint = &didUint
+ }
+
+ images, total, err := h.imageService.ListImageGenerations(dramaIDUint, sceneID, storyboardID, frameType, status, page, pageSize)
+
+ if err != nil {
+ h.log.Errorw("Failed to list images", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.SuccessWithPagination(c, images, total, page, pageSize)
+}
+
+func (h *ImageGenerationHandler) DeleteImageGeneration(c *gin.Context) {
+
+ imageGenID, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ response.BadRequest(c, "无效的ID")
+ return
+ }
+
+ if err := h.imageService.DeleteImageGeneration(uint(imageGenID)); err != nil {
+ h.log.Errorw("Failed to delete image", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, nil)
+}
diff --git a/api/handlers/scene.go b/api/handlers/scene.go
new file mode 100644
index 0000000..968cd80
--- /dev/null
+++ b/api/handlers/scene.go
@@ -0,0 +1,75 @@
+package handlers
+
+import (
+ services2 "github.com/drama-generator/backend/application/services"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+type SceneHandler struct {
+ sceneService *services2.StoryboardCompositionService
+ log *logger.Logger
+}
+
+func NewSceneHandler(db *gorm.DB, log *logger.Logger, imageGenService *services2.ImageGenerationService) *SceneHandler {
+ return &SceneHandler{
+ sceneService: services2.NewStoryboardCompositionService(db, log, imageGenService),
+ log: log,
+ }
+}
+
+func (h *SceneHandler) GetStoryboardsForEpisode(c *gin.Context) {
+ episodeID := c.Param("episode_id")
+
+ storyboards, err := h.sceneService.GetScenesForEpisode(episodeID)
+ if err != nil {
+ h.log.Errorw("Failed to get storyboards for episode", "error", err, "episode_id", episodeID)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, gin.H{
+ "storyboards": storyboards,
+ "total": len(storyboards),
+ })
+}
+
+func (h *SceneHandler) UpdateScene(c *gin.Context) {
+ sceneID := c.Param("scene_id")
+
+ var req services2.UpdateSceneRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request")
+ return
+ }
+
+ if err := h.sceneService.UpdateScene(sceneID, &req); err != nil {
+ h.log.Errorw("Failed to update scene", "error", err, "scene_id", sceneID)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, gin.H{"message": "Scene updated successfully"})
+}
+
+func (h *SceneHandler) GenerateSceneImage(c *gin.Context) {
+ var req services2.GenerateSceneImageRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request")
+ return
+ }
+
+ imageGen, err := h.sceneService.GenerateSceneImage(&req)
+ if err != nil {
+ h.log.Errorw("Failed to generate scene image", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, gin.H{
+ "message": "Scene image generation started",
+ "image_generation": imageGen,
+ })
+}
diff --git a/api/handlers/script_generation.go b/api/handlers/script_generation.go
new file mode 100644
index 0000000..6264959
--- /dev/null
+++ b/api/handlers/script_generation.go
@@ -0,0 +1,118 @@
+package handlers
+
+import (
+ "github.com/drama-generator/backend/application/services"
+ "github.com/drama-generator/backend/pkg/config"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+type ScriptGenerationHandler struct {
+ scriptService *services.ScriptGenerationService
+ taskService *services.TaskService
+ log *logger.Logger
+}
+
+func NewScriptGenerationHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *ScriptGenerationHandler {
+ return &ScriptGenerationHandler{
+ scriptService: services.NewScriptGenerationService(db, log),
+ taskService: services.NewTaskService(db, log),
+ log: log,
+ }
+}
+
+func (h *ScriptGenerationHandler) GenerateOutline(c *gin.Context) {
+
+ var req services.GenerateOutlineRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ result, err := h.scriptService.GenerateOutline(&req)
+ if err != nil {
+ h.log.Errorw("Failed to generate outline", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, result)
+}
+
+func (h *ScriptGenerationHandler) GenerateCharacters(c *gin.Context) {
+ var req services.GenerateCharactersRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ // 创建异步任务
+ task, err := h.taskService.CreateTask("character_generation", req.DramaID)
+ if err != nil {
+ h.log.Errorw("Failed to create task", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ // 启动后台goroutine处理
+ go h.processCharacterGeneration(task.ID, &req)
+
+ // 立即返回任务ID
+ response.Success(c, gin.H{
+ "task_id": task.ID,
+ "status": "pending",
+ "message": "角色生成任务已创建,正在后台处理...",
+ })
+}
+
+// processCharacterGeneration 后台处理角色生成
+func (h *ScriptGenerationHandler) processCharacterGeneration(taskID string, req *services.GenerateCharactersRequest) {
+ h.log.Infow("Starting character generation", "task_id", taskID, "drama_id", req.DramaID)
+
+ // 更新任务状态为处理中
+ if err := h.taskService.UpdateTaskStatus(taskID, "processing", 10, "开始生成角色..."); err != nil {
+ h.log.Errorw("Failed to update task status", "error", err)
+ }
+
+ // 调用实际的生成逻辑
+ characters, err := h.scriptService.GenerateCharacters(req)
+ if err != nil {
+ h.log.Errorw("Failed to generate characters", "error", err, "task_id", taskID)
+ if updateErr := h.taskService.UpdateTaskError(taskID, err); updateErr != nil {
+ h.log.Errorw("Failed to update task error", "error", updateErr)
+ }
+ return
+ }
+
+ // 更新任务结果
+ result := gin.H{
+ "characters": characters,
+ "total": len(characters),
+ }
+ if err := h.taskService.UpdateTaskResult(taskID, result); err != nil {
+ h.log.Errorw("Failed to update task result", "error", err)
+ return
+ }
+
+ h.log.Infow("Character generation completed", "task_id", taskID, "total", len(characters))
+}
+
+func (h *ScriptGenerationHandler) GenerateEpisodes(c *gin.Context) {
+
+ var req services.GenerateEpisodesRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ episodes, err := h.scriptService.GenerateEpisodes(&req)
+ if err != nil {
+ h.log.Errorw("Failed to generate episodes", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, episodes)
+}
diff --git a/api/handlers/storyboard.go b/api/handlers/storyboard.go
new file mode 100644
index 0000000..2dc1fda
--- /dev/null
+++ b/api/handlers/storyboard.go
@@ -0,0 +1,96 @@
+package handlers
+
+import (
+ "github.com/drama-generator/backend/application/services"
+ "github.com/drama-generator/backend/pkg/config"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+type StoryboardHandler struct {
+ storyboardService *services.StoryboardService
+ taskService *services.TaskService
+ log *logger.Logger
+}
+
+func NewStoryboardHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *StoryboardHandler {
+ return &StoryboardHandler{
+ storyboardService: services.NewStoryboardService(db, log),
+ taskService: services.NewTaskService(db, log),
+ log: log,
+ }
+}
+
+// GenerateStoryboard 生成分镜头(异步)
+func (h *StoryboardHandler) GenerateStoryboard(c *gin.Context) {
+ episodeID := c.Param("episode_id")
+
+ // 创建异步任务
+ task, err := h.taskService.CreateTask("storyboard_generation", episodeID)
+ if err != nil {
+ h.log.Errorw("Failed to create task", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ // 启动后台goroutine处理
+ go h.processStoryboardGeneration(task.ID, episodeID)
+
+ // 立即返回任务ID
+ response.Success(c, gin.H{
+ "task_id": task.ID,
+ "status": "pending",
+ "message": "分镜生成任务已创建,正在后台处理...",
+ })
+}
+
+// processStoryboardGeneration 后台处理分镜生成
+func (h *StoryboardHandler) processStoryboardGeneration(taskID, episodeID string) {
+ h.log.Infow("Starting storyboard generation", "task_id", taskID, "episode_id", episodeID)
+
+ // 更新任务状态为处理中
+ if err := h.taskService.UpdateTaskStatus(taskID, "processing", 10, "开始生成分镜..."); err != nil {
+ h.log.Errorw("Failed to update task status", "error", err)
+ }
+
+ // 调用实际的生成逻辑
+ result, err := h.storyboardService.GenerateStoryboard(episodeID)
+ if err != nil {
+ h.log.Errorw("Failed to generate storyboard", "error", err, "task_id", taskID)
+ if updateErr := h.taskService.UpdateTaskError(taskID, err); updateErr != nil {
+ h.log.Errorw("Failed to update task error", "error", updateErr)
+ }
+ return
+ }
+
+ // 更新任务结果
+ if err := h.taskService.UpdateTaskResult(taskID, result); err != nil {
+ h.log.Errorw("Failed to update task result", "error", err)
+ return
+ }
+
+ h.log.Infow("Storyboard generation completed", "task_id", taskID, "total", result.Total)
+}
+
+// UpdateStoryboard 更新分镜
+func (h *StoryboardHandler) UpdateStoryboard(c *gin.Context) {
+ storyboardID := c.Param("id")
+
+ var req map[string]interface{}
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request body")
+ return
+ }
+
+ err := h.storyboardService.UpdateStoryboard(storyboardID, req)
+ if err != nil {
+ h.log.Errorw("Failed to update storyboard", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, gin.H{"message": "Storyboard updated successfully"})
+}
diff --git a/api/handlers/task.go b/api/handlers/task.go
new file mode 100644
index 0000000..3e38ae0
--- /dev/null
+++ b/api/handlers/task.go
@@ -0,0 +1,57 @@
+package handlers
+
+import (
+ "github.com/drama-generator/backend/application/services"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+type TaskHandler struct {
+ taskService *services.TaskService
+ log *logger.Logger
+}
+
+func NewTaskHandler(db *gorm.DB, log *logger.Logger) *TaskHandler {
+ return &TaskHandler{
+ taskService: services.NewTaskService(db, log),
+ log: log,
+ }
+}
+
+// GetTaskStatus 获取任务状态
+func (h *TaskHandler) GetTaskStatus(c *gin.Context) {
+ taskID := c.Param("task_id")
+
+ task, err := h.taskService.GetTask(taskID)
+ if err != nil {
+ if err == gorm.ErrRecordNotFound {
+ response.NotFound(c, "任务不存在")
+ return
+ }
+ h.log.Errorw("Failed to get task", "error", err, "task_id", taskID)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, task)
+}
+
+// GetResourceTasks 获取资源相关的所有任务
+func (h *TaskHandler) GetResourceTasks(c *gin.Context) {
+ resourceID := c.Query("resource_id")
+ if resourceID == "" {
+ response.BadRequest(c, "缺少resource_id参数")
+ return
+ }
+
+ tasks, err := h.taskService.GetTasksByResource(resourceID)
+ if err != nil {
+ h.log.Errorw("Failed to get resource tasks", "error", err, "resource_id", resourceID)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, tasks)
+}
diff --git a/api/handlers/upload.go b/api/handlers/upload.go
new file mode 100644
index 0000000..84de200
--- /dev/null
+++ b/api/handlers/upload.go
@@ -0,0 +1,142 @@
+package handlers
+
+import (
+ services2 "github.com/drama-generator/backend/application/services"
+ "github.com/drama-generator/backend/pkg/config"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+)
+
+type UploadHandler struct {
+ uploadService *services2.UploadService
+ characterLibraryService *services2.CharacterLibraryService
+ log *logger.Logger
+}
+
+func NewUploadHandler(cfg *config.Config, log *logger.Logger, characterLibraryService *services2.CharacterLibraryService) (*UploadHandler, error) {
+ uploadService, err := services2.NewUploadService(cfg, log)
+ if err != nil {
+ return nil, err
+ }
+
+ return &UploadHandler{
+ uploadService: uploadService,
+ characterLibraryService: characterLibraryService,
+ log: log,
+ }, nil
+}
+
+// UploadImage 上传图片
+func (h *UploadHandler) UploadImage(c *gin.Context) {
+ // 获取上传的文件
+ file, header, err := c.Request.FormFile("file")
+ if err != nil {
+ response.BadRequest(c, "请选择文件")
+ return
+ }
+ defer file.Close()
+
+ // 检查文件类型
+ contentType := header.Header.Get("Content-Type")
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+
+ // 验证是图片类型
+ allowedTypes := map[string]bool{
+ "image/jpeg": true,
+ "image/jpg": true,
+ "image/png": true,
+ "image/gif": true,
+ "image/webp": true,
+ }
+
+ if !allowedTypes[contentType] {
+ response.BadRequest(c, "只支持图片格式 (jpg, png, gif, webp)")
+ return
+ }
+
+ // 检查文件大小 (10MB)
+ if header.Size > 10*1024*1024 {
+ response.BadRequest(c, "文件大小不能超过10MB")
+ return
+ }
+
+ // 上传到MinIO
+ fileURL, err := h.uploadService.UploadCharacterImage(file, header.Filename, contentType)
+ if err != nil {
+ h.log.Errorw("Failed to upload image", "error", err)
+ response.InternalError(c, "上传失败")
+ return
+ }
+
+ response.Success(c, gin.H{
+ "url": fileURL,
+ "filename": header.Filename,
+ "size": header.Size,
+ })
+}
+
+// UploadCharacterImage 上传角色图片(带角色ID)
+func (h *UploadHandler) UploadCharacterImage(c *gin.Context) {
+ characterID := c.Param("id")
+
+ // 获取上传的文件
+ file, header, err := c.Request.FormFile("file")
+ if err != nil {
+ response.BadRequest(c, "请选择文件")
+ return
+ }
+ defer file.Close()
+
+ // 检查文件类型
+ contentType := header.Header.Get("Content-Type")
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+
+ // 验证是图片类型
+ allowedTypes := map[string]bool{
+ "image/jpeg": true,
+ "image/jpg": true,
+ "image/png": true,
+ "image/gif": true,
+ "image/webp": true,
+ }
+
+ if !allowedTypes[contentType] {
+ response.BadRequest(c, "只支持图片格式 (jpg, png, gif, webp)")
+ return
+ }
+
+ // 检查文件大小 (10MB)
+ if header.Size > 10*1024*1024 {
+ response.BadRequest(c, "文件大小不能超过10MB")
+ return
+ }
+
+ // 上传到MinIO
+ fileURL, err := h.uploadService.UploadCharacterImage(file, header.Filename, contentType)
+ if err != nil {
+ h.log.Errorw("Failed to upload character image", "error", err)
+ response.InternalError(c, "上传失败")
+ return
+ }
+
+ // 更新角色的image_url字段到数据库
+ err = h.characterLibraryService.UploadCharacterImage(characterID, fileURL)
+ if err != nil {
+ h.log.Errorw("Failed to update character image_url", "error", err, "character_id", characterID)
+ response.InternalError(c, "更新角色图片失败")
+ return
+ }
+
+ h.log.Infow("Character image uploaded and saved", "character_id", characterID, "url", fileURL)
+
+ response.Success(c, gin.H{
+ "url": fileURL,
+ "filename": header.Filename,
+ "size": header.Size,
+ })
+}
diff --git a/api/handlers/video_generation.go b/api/handlers/video_generation.go
new file mode 100644
index 0000000..8a89a05
--- /dev/null
+++ b/api/handlers/video_generation.go
@@ -0,0 +1,149 @@
+package handlers
+
+import (
+ "strconv"
+
+ "github.com/drama-generator/backend/application/services"
+ "github.com/drama-generator/backend/infrastructure/storage"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+type VideoGenerationHandler struct {
+ videoService *services.VideoGenerationService
+ log *logger.Logger
+}
+
+func NewVideoGenerationHandler(db *gorm.DB, transferService *services.ResourceTransferService, localStorage *storage.LocalStorage, aiService *services.AIService, log *logger.Logger) *VideoGenerationHandler {
+ return &VideoGenerationHandler{
+ videoService: services.NewVideoGenerationService(db, transferService, localStorage, aiService, log),
+ log: log,
+ }
+}
+
+func (h *VideoGenerationHandler) GenerateVideo(c *gin.Context) {
+
+ var req services.GenerateVideoRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ videoGen, err := h.videoService.GenerateVideo(&req)
+ if err != nil {
+ h.log.Errorw("Failed to generate video", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, videoGen)
+}
+
+func (h *VideoGenerationHandler) GenerateVideoFromImage(c *gin.Context) {
+
+ imageGenID, err := strconv.ParseUint(c.Param("image_gen_id"), 10, 32)
+ if err != nil {
+ response.BadRequest(c, "无效的图片ID")
+ return
+ }
+
+ videoGen, err := h.videoService.GenerateVideoFromImage(uint(imageGenID))
+ if err != nil {
+ h.log.Errorw("Failed to generate video from image", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, videoGen)
+}
+
+func (h *VideoGenerationHandler) BatchGenerateForEpisode(c *gin.Context) {
+
+ episodeID := c.Param("episode_id")
+
+ videos, err := h.videoService.BatchGenerateVideosForEpisode(episodeID)
+ if err != nil {
+ h.log.Errorw("Failed to batch generate videos", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, videos)
+}
+
+func (h *VideoGenerationHandler) GetVideoGeneration(c *gin.Context) {
+
+ videoGenID, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ response.BadRequest(c, "无效的ID")
+ return
+ }
+
+ videoGen, err := h.videoService.GetVideoGeneration(uint(videoGenID))
+ if err != nil {
+ response.NotFound(c, "视频生成记录不存在")
+ return
+ }
+
+ response.Success(c, videoGen)
+}
+
+func (h *VideoGenerationHandler) ListVideoGenerations(c *gin.Context) {
+ var storyboardID *uint
+ // 优先使用storyboard_id参数
+ if storyboardIDStr := c.Query("storyboard_id"); storyboardIDStr != "" {
+ id, err := strconv.ParseUint(storyboardIDStr, 10, 32)
+ if err == nil {
+ uid := uint(id)
+ storyboardID = &uid
+ }
+ }
+ status := c.Query("status")
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
+
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 || pageSize > 100 {
+ pageSize = 20
+ }
+
+ var dramaIDUint *uint
+ if dramaIDStr := c.Query("drama_id"); dramaIDStr != "" {
+ did, _ := strconv.ParseUint(dramaIDStr, 10, 32)
+ didUint := uint(did)
+ dramaIDUint = &didUint
+ }
+
+ // 计算offset:(page - 1) * pageSize
+ offset := (page - 1) * pageSize
+ videos, total, err := h.videoService.ListVideoGenerations(dramaIDUint, storyboardID, status, pageSize, offset)
+
+ if err != nil {
+ h.log.Errorw("Failed to list videos", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.SuccessWithPagination(c, videos, total, page, pageSize)
+}
+
+func (h *VideoGenerationHandler) DeleteVideoGeneration(c *gin.Context) {
+
+ videoGenID, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ response.BadRequest(c, "无效的ID")
+ return
+ }
+
+ if err := h.videoService.DeleteVideoGeneration(uint(videoGenID)); err != nil {
+ h.log.Errorw("Failed to delete video", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, nil)
+}
diff --git a/api/handlers/video_merge.go b/api/handlers/video_merge.go
new file mode 100644
index 0000000..7350b6b
--- /dev/null
+++ b/api/handlers/video_merge.go
@@ -0,0 +1,104 @@
+package handlers
+
+import (
+ "strconv"
+
+ services2 "github.com/drama-generator/backend/application/services"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+type VideoMergeHandler struct {
+ mergeService *services2.VideoMergeService
+ log *logger.Logger
+}
+
+func NewVideoMergeHandler(db *gorm.DB, transferService *services2.ResourceTransferService, storagePath, baseURL string, log *logger.Logger) *VideoMergeHandler {
+ return &VideoMergeHandler{
+ mergeService: services2.NewVideoMergeService(db, transferService, storagePath, baseURL, log),
+ log: log,
+ }
+}
+
+func (h *VideoMergeHandler) MergeVideos(c *gin.Context) {
+ var req services2.MergeVideoRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request")
+ return
+ }
+
+ merge, err := h.mergeService.MergeVideos(&req)
+ if err != nil {
+ h.log.Errorw("Failed to merge videos", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, gin.H{
+ "message": "Video merge task created",
+ "merge": merge,
+ })
+}
+
+func (h *VideoMergeHandler) GetMerge(c *gin.Context) {
+ mergeIDStr := c.Param("merge_id")
+ mergeID, err := strconv.ParseUint(mergeIDStr, 10, 32)
+ if err != nil {
+ response.BadRequest(c, "Invalid merge ID")
+ return
+ }
+
+ merge, err := h.mergeService.GetMerge(uint(mergeID))
+ if err != nil {
+ h.log.Errorw("Failed to get merge", "error", err)
+ response.NotFound(c, "Merge not found")
+ return
+ }
+
+ response.Success(c, gin.H{"merge": merge})
+}
+
+func (h *VideoMergeHandler) ListMerges(c *gin.Context) {
+ episodeID := c.Query("episode_id")
+ status := c.Query("status")
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
+
+ var episodeIDPtr *string
+ if episodeID != "" {
+ episodeIDPtr = &episodeID
+ }
+
+ merges, total, err := h.mergeService.ListMerges(episodeIDPtr, status, page, pageSize)
+ if err != nil {
+ h.log.Errorw("Failed to list merges", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, gin.H{
+ "merges": merges,
+ "total": total,
+ "page": page,
+ "page_size": pageSize,
+ })
+}
+
+func (h *VideoMergeHandler) DeleteMerge(c *gin.Context) {
+ mergeIDStr := c.Param("merge_id")
+ mergeID, err := strconv.ParseUint(mergeIDStr, 10, 32)
+ if err != nil {
+ response.BadRequest(c, "Invalid merge ID")
+ return
+ }
+
+ if err := h.mergeService.DeleteMerge(uint(mergeID)); err != nil {
+ h.log.Errorw("Failed to delete merge", "error", err)
+ response.InternalError(c, err.Error())
+ return
+ }
+
+ response.Success(c, gin.H{"message": "Merge deleted successfully"})
+}
diff --git a/api/middlewares/cors.go b/api/middlewares/cors.go
new file mode 100644
index 0000000..b5c1bd1
--- /dev/null
+++ b/api/middlewares/cors.go
@@ -0,0 +1,34 @@
+package middlewares
+
+import (
+ "github.com/gin-gonic/gin"
+)
+
+func CORSMiddleware(allowedOrigins []string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ origin := c.Request.Header.Get("Origin")
+
+ allowed := false
+ for _, o := range allowedOrigins {
+ if o == "*" || o == origin {
+ allowed = true
+ break
+ }
+ }
+
+ if allowed {
+ c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
+ }
+
+ c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
+ c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
+ c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
+
+ if c.Request.Method == "OPTIONS" {
+ c.AbortWithStatus(204)
+ return
+ }
+
+ c.Next()
+ }
+}
diff --git a/api/middlewares/logger.go b/api/middlewares/logger.go
new file mode 100644
index 0000000..04dc9d2
--- /dev/null
+++ b/api/middlewares/logger.go
@@ -0,0 +1,30 @@
+package middlewares
+
+import (
+ "time"
+
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/gin-gonic/gin"
+)
+
+func LoggerMiddleware(log *logger.Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ start := time.Now()
+ path := c.Request.URL.Path
+ query := c.Request.URL.RawQuery
+
+ c.Next()
+
+ duration := time.Since(start)
+
+ log.Infow("HTTP Request",
+ "method", c.Request.Method,
+ "path", path,
+ "query", query,
+ "status", c.Writer.Status(),
+ "duration", duration.Milliseconds(),
+ "ip", c.ClientIP(),
+ "user_agent", c.Request.UserAgent(),
+ )
+ }
+}
diff --git a/api/middlewares/ratelimit.go b/api/middlewares/ratelimit.go
new file mode 100644
index 0000000..c4cdfbc
--- /dev/null
+++ b/api/middlewares/ratelimit.go
@@ -0,0 +1,52 @@
+package middlewares
+
+import (
+ "sync"
+ "time"
+
+ "github.com/drama-generator/backend/pkg/response"
+ "github.com/gin-gonic/gin"
+)
+
+type rateLimiter struct {
+ mu sync.Mutex
+ requests map[string][]time.Time
+ limit int
+ window time.Duration
+}
+
+var limiter = &rateLimiter{
+ requests: make(map[string][]time.Time),
+ limit: 100,
+ window: time.Minute,
+}
+
+func RateLimitMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ ip := c.ClientIP()
+
+ limiter.mu.Lock()
+ defer limiter.mu.Unlock()
+
+ now := time.Now()
+ requests := limiter.requests[ip]
+
+ var validRequests []time.Time
+ for _, t := range requests {
+ if now.Sub(t) < limiter.window {
+ validRequests = append(validRequests, t)
+ }
+ }
+
+ if len(validRequests) >= limiter.limit {
+ response.Error(c, 429, "RATE_LIMIT_EXCEEDED", "请求过于频繁,请稍后再试")
+ c.Abort()
+ return
+ }
+
+ validRequests = append(validRequests, now)
+ limiter.requests[ip] = validRequests
+
+ c.Next()
+ }
+}
diff --git a/api/routes/routes.go b/api/routes/routes.go
new file mode 100644
index 0000000..00ec503
--- /dev/null
+++ b/api/routes/routes.go
@@ -0,0 +1,212 @@
+package routes
+
+import (
+ handlers2 "github.com/drama-generator/backend/api/handlers"
+ middlewares2 "github.com/drama-generator/backend/api/middlewares"
+ services2 "github.com/drama-generator/backend/application/services"
+ storage2 "github.com/drama-generator/backend/infrastructure/storage"
+ "github.com/drama-generator/backend/pkg/config"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+func SetupRouter(cfg *config.Config, db *gorm.DB, log *logger.Logger, localStorage interface{}) *gin.Engine {
+ r := gin.New()
+
+ r.Use(gin.Recovery())
+ r.Use(middlewares2.LoggerMiddleware(log))
+ r.Use(middlewares2.CORSMiddleware(cfg.Server.CORSOrigins))
+
+ // 静态文件服务(用户上传的文件)
+ r.Static("/static", cfg.Storage.LocalPath)
+
+ r.GET("/health", func(c *gin.Context) {
+ c.JSON(200, gin.H{
+ "status": "ok",
+ "app": cfg.App.Name,
+ "version": cfg.App.Version,
+ })
+ })
+
+ aiService := services2.NewAIService(db, log)
+ localStoragePtr := localStorage.(*storage2.LocalStorage)
+ transferService := services2.NewResourceTransferService(db, log)
+ dramaHandler := handlers2.NewDramaHandler(db, cfg, log, nil)
+ aiConfigHandler := handlers2.NewAIConfigHandler(db, cfg, log)
+ scriptGenHandler := handlers2.NewScriptGenerationHandler(db, cfg, log)
+ imageGenService := services2.NewImageGenerationService(db, transferService, localStoragePtr, log)
+ imageGenHandler := handlers2.NewImageGenerationHandler(db, cfg, log, transferService, localStoragePtr)
+ videoGenHandler := handlers2.NewVideoGenerationHandler(db, transferService, localStoragePtr, aiService, log)
+ videoMergeHandler := handlers2.NewVideoMergeHandler(db, nil, cfg.Storage.LocalPath, cfg.Storage.BaseURL, log)
+ assetHandler := handlers2.NewAssetHandler(db, cfg, log)
+ characterLibraryService := services2.NewCharacterLibraryService(db, log)
+ characterLibraryHandler := handlers2.NewCharacterLibraryHandler(db, cfg, log, transferService, localStoragePtr)
+ uploadHandler, err := handlers2.NewUploadHandler(cfg, log, characterLibraryService)
+ if err != nil {
+ log.Fatalw("Failed to create upload handler", "error", err)
+ }
+ storyboardHandler := handlers2.NewStoryboardHandler(db, cfg, log)
+ sceneHandler := handlers2.NewSceneHandler(db, log, imageGenService)
+ taskHandler := handlers2.NewTaskHandler(db, log)
+ framePromptService := services2.NewFramePromptService(db, log)
+ framePromptHandler := handlers2.NewFramePromptHandler(framePromptService, log)
+
+ api := r.Group("/api/v1")
+ {
+ api.Use(middlewares2.RateLimitMiddleware())
+
+ dramas := api.Group("/dramas")
+ {
+ dramas.GET("", dramaHandler.ListDramas)
+ dramas.POST("", dramaHandler.CreateDrama)
+ dramas.GET("/stats", dramaHandler.GetDramaStats)
+ dramas.GET("/:id/characters", dramaHandler.GetCharacters)
+ dramas.PUT("/:id/characters", dramaHandler.SaveCharacters)
+ dramas.PUT("/:id/outline", dramaHandler.SaveOutline)
+ dramas.PUT("/:id/episodes", dramaHandler.SaveEpisodes)
+ dramas.PUT("/:id/progress", dramaHandler.SaveProgress)
+ dramas.GET("/:id", dramaHandler.GetDrama)
+ dramas.PUT("/:id", dramaHandler.UpdateDrama)
+ dramas.DELETE("/:id", dramaHandler.DeleteDrama)
+ }
+
+ aiConfigs := api.Group("/ai-configs")
+ {
+ aiConfigs.GET("", aiConfigHandler.ListConfigs)
+ aiConfigs.POST("", aiConfigHandler.CreateConfig)
+ aiConfigs.POST("/test", aiConfigHandler.TestConnection)
+ aiConfigs.GET("/:id", aiConfigHandler.GetConfig)
+ aiConfigs.PUT("/:id", aiConfigHandler.UpdateConfig)
+ aiConfigs.DELETE("/:id", aiConfigHandler.DeleteConfig)
+ }
+
+ generation := api.Group("/generation")
+ {
+ generation.POST("/outline", scriptGenHandler.GenerateOutline)
+ generation.POST("/characters", scriptGenHandler.GenerateCharacters)
+ generation.POST("/episodes", scriptGenHandler.GenerateEpisodes)
+ }
+
+ // 角色库路由
+ characterLibrary := api.Group("/character-library")
+ {
+ characterLibrary.GET("", characterLibraryHandler.ListLibraryItems)
+ characterLibrary.POST("", characterLibraryHandler.CreateLibraryItem)
+ characterLibrary.GET("/:id", characterLibraryHandler.GetLibraryItem)
+ characterLibrary.DELETE("/:id", characterLibraryHandler.DeleteLibraryItem)
+ }
+
+ // 角色图片相关路由
+ characters := api.Group("/characters")
+ {
+ characters.PUT("/:id", characterLibraryHandler.UpdateCharacter)
+ characters.DELETE("/:id", characterLibraryHandler.DeleteCharacter)
+ characters.POST("/batch-generate-images", characterLibraryHandler.BatchGenerateCharacterImages)
+ characters.POST("/:id/generate-image", characterLibraryHandler.GenerateCharacterImage)
+ characters.POST("/:id/upload-image", uploadHandler.UploadCharacterImage)
+ characters.PUT("/:id/image", characterLibraryHandler.UploadCharacterImage)
+ characters.PUT("/:id/image-from-library", characterLibraryHandler.ApplyLibraryItemToCharacter)
+ characters.POST("/:id/add-to-library", characterLibraryHandler.AddCharacterToLibrary)
+ }
+
+ // 文件上传路由
+ upload := api.Group("/upload")
+ {
+ upload.POST("/image", uploadHandler.UploadImage)
+ }
+
+ // 分镜头路由
+ episodes := api.Group("/episodes")
+ {
+ // 分镜头
+ episodes.POST("/:episode_id/storyboards", storyboardHandler.GenerateStoryboard)
+ episodes.GET("/:episode_id/storyboards", sceneHandler.GetStoryboardsForEpisode)
+ episodes.POST("/:episode_id/finalize", dramaHandler.FinalizeEpisode)
+ episodes.GET("/:episode_id/download", dramaHandler.DownloadEpisodeVideo)
+ }
+
+ // 任务路由
+ tasks := api.Group("/tasks")
+ {
+ tasks.GET("/:task_id", taskHandler.GetTaskStatus)
+ tasks.GET("", taskHandler.GetResourceTasks)
+ }
+
+ // 场景路由
+ scenes := api.Group("/scenes")
+ {
+ scenes.PUT("/:scene_id", sceneHandler.UpdateScene)
+ scenes.POST("/generate-image", sceneHandler.GenerateSceneImage)
+ }
+
+ images := api.Group("/images")
+ {
+ images.GET("", imageGenHandler.ListImageGenerations)
+ images.POST("", imageGenHandler.GenerateImage)
+ images.GET("/:id", imageGenHandler.GetImageGeneration)
+ images.DELETE("/:id", imageGenHandler.DeleteImageGeneration)
+ images.POST("/scene/:scene_id", imageGenHandler.GenerateImagesForScene)
+ images.GET("/episode/:episode_id/backgrounds", imageGenHandler.GetBackgroundsForEpisode)
+ images.POST("/episode/:episode_id/backgrounds/extract", imageGenHandler.ExtractBackgroundsForEpisode)
+ images.POST("/episode/:episode_id/batch", imageGenHandler.BatchGenerateForEpisode)
+ }
+
+ videos := api.Group("/videos")
+ {
+ videos.GET("", videoGenHandler.ListVideoGenerations)
+ videos.POST("", videoGenHandler.GenerateVideo)
+ videos.GET("/:id", videoGenHandler.GetVideoGeneration)
+ videos.DELETE("/:id", videoGenHandler.DeleteVideoGeneration)
+ videos.POST("/image/:image_gen_id", videoGenHandler.GenerateVideoFromImage)
+ videos.POST("/episode/:episode_id/batch", videoGenHandler.BatchGenerateForEpisode)
+ }
+
+ videoMerges := api.Group("/video-merges")
+ {
+ videoMerges.GET("", videoMergeHandler.ListMerges)
+ videoMerges.POST("", videoMergeHandler.MergeVideos)
+ videoMerges.GET("/:merge_id", videoMergeHandler.GetMerge)
+ videoMerges.DELETE("/:merge_id", videoMergeHandler.DeleteMerge)
+ }
+
+ assets := api.Group("/assets")
+ {
+ assets.GET("", assetHandler.ListAssets)
+ assets.POST("", assetHandler.CreateAsset)
+ assets.GET("/:id", assetHandler.GetAsset)
+ assets.PUT("/:id", assetHandler.UpdateAsset)
+ assets.DELETE("/:id", assetHandler.DeleteAsset)
+ assets.POST("/import/image/:image_gen_id", assetHandler.ImportFromImageGen)
+ assets.POST("/import/video/:video_gen_id", assetHandler.ImportFromVideoGen)
+ }
+
+ storyboards := api.Group("/storyboards")
+ {
+ storyboards.PUT("/:id", storyboardHandler.UpdateStoryboard)
+ storyboards.POST("/:id/frame-prompt", framePromptHandler.GenerateFramePrompt)
+ storyboards.GET("/:id/frame-prompts", handlers2.GetStoryboardFramePrompts(db, log))
+ }
+ }
+
+ // 前端静态文件服务(放在API路由之后,避免冲突)
+ // 服务前端构建产物
+ r.Static("/assets", "./web/dist/assets")
+ r.StaticFile("/favicon.ico", "./web/dist/favicon.ico")
+
+ // NoRoute处理:对于所有未匹配的路由
+ r.NoRoute(func(c *gin.Context) {
+ path := c.Request.URL.Path
+
+ // 如果是API路径,返回404
+ if len(path) >= 4 && path[:4] == "/api" {
+ c.JSON(404, gin.H{"error": "API endpoint not found"})
+ return
+ }
+
+ // SPA fallback - 返回index.html
+ c.File("./web/dist/index.html")
+ })
+
+ return r
+}
diff --git a/application/services/ai_service.go b/application/services/ai_service.go
new file mode 100644
index 0000000..2dc868e
--- /dev/null
+++ b/application/services/ai_service.go
@@ -0,0 +1,398 @@
+package services
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/drama-generator/backend/domain/models"
+ "github.com/drama-generator/backend/pkg/ai"
+ "github.com/drama-generator/backend/pkg/logger"
+ "gorm.io/gorm"
+)
+
+type AIService struct {
+ db *gorm.DB
+ log *logger.Logger
+}
+
+func NewAIService(db *gorm.DB, log *logger.Logger) *AIService {
+ return &AIService{
+ db: db,
+ log: log,
+ }
+}
+
+type CreateAIConfigRequest struct {
+ ServiceType string `json:"service_type" binding:"required,oneof=text image video"`
+ Name string `json:"name" binding:"required,min=1,max=100"`
+ Provider string `json:"provider" binding:"required"`
+ BaseURL string `json:"base_url" binding:"required,url"`
+ APIKey string `json:"api_key" binding:"required"`
+ Model models.ModelField `json:"model" binding:"required"`
+ Endpoint string `json:"endpoint"`
+ QueryEndpoint string `json:"query_endpoint"`
+ Priority int `json:"priority"`
+ IsDefault bool `json:"is_default"`
+ Settings string `json:"settings"`
+}
+
+type UpdateAIConfigRequest struct {
+ Name string `json:"name" binding:"omitempty,min=1,max=100"`
+ Provider string `json:"provider"`
+ BaseURL string `json:"base_url" binding:"omitempty,url"`
+ APIKey string `json:"api_key"`
+ Model *models.ModelField `json:"model"`
+ Endpoint string `json:"endpoint"`
+ QueryEndpoint string `json:"query_endpoint"`
+ Priority *int `json:"priority"`
+ IsDefault bool `json:"is_default"`
+ IsActive bool `json:"is_active"`
+ Settings string `json:"settings"`
+}
+
+type TestConnectionRequest struct {
+ BaseURL string `json:"base_url" binding:"required,url"`
+ APIKey string `json:"api_key" binding:"required"`
+ Model models.ModelField `json:"model" binding:"required"`
+ Provider string `json:"provider"`
+ Endpoint string `json:"endpoint"`
+}
+
+func (s *AIService) CreateConfig(req *CreateAIConfigRequest) (*models.AIServiceConfig, error) {
+ // 根据 provider 和 service_type 自动设置 endpoint
+ endpoint := req.Endpoint
+ queryEndpoint := req.QueryEndpoint
+
+ if endpoint == "" {
+ switch req.Provider {
+ case "gemini", "google":
+ if req.ServiceType == "text" {
+ endpoint = "/v1beta/models/{model}:generateContent"
+ } else if req.ServiceType == "image" {
+ endpoint = "/v1beta/models/{model}:generateContent"
+ }
+ case "openai":
+ if req.ServiceType == "text" {
+ endpoint = "/chat/completions"
+ } else if req.ServiceType == "image" {
+ endpoint = "/images/generations"
+ } else if req.ServiceType == "video" {
+ endpoint = "/videos"
+ if queryEndpoint == "" {
+ queryEndpoint = "/videos/{taskId}"
+ }
+ }
+ case "chatfire":
+ if req.ServiceType == "text" {
+ endpoint = "/chat/completions"
+ } else if req.ServiceType == "image" {
+ endpoint = "/images/generations"
+ } else if req.ServiceType == "video" {
+ endpoint = "/video/generations"
+ if queryEndpoint == "" {
+ queryEndpoint = "/video/task/{taskId}"
+ }
+ }
+ case "doubao", "volcengine", "volces":
+ if req.ServiceType == "video" {
+ endpoint = "/contents/generations/tasks"
+ if queryEndpoint == "" {
+ queryEndpoint = "/generations/tasks/{taskId}"
+ }
+ }
+ default:
+ // 默认使用 OpenAI 格式
+ if req.ServiceType == "text" {
+ endpoint = "/chat/completions"
+ } else if req.ServiceType == "image" {
+ endpoint = "/images/generations"
+ }
+ }
+ }
+
+ config := &models.AIServiceConfig{
+ ServiceType: req.ServiceType,
+ Name: req.Name,
+ Provider: req.Provider,
+ BaseURL: req.BaseURL,
+ APIKey: req.APIKey,
+ Model: req.Model,
+ Endpoint: endpoint,
+ QueryEndpoint: queryEndpoint,
+ Priority: req.Priority,
+ IsDefault: req.IsDefault,
+ IsActive: true,
+ Settings: req.Settings,
+ }
+
+ if err := s.db.Create(config).Error; err != nil {
+ s.log.Errorw("Failed to create AI config", "error", err)
+ return nil, err
+ }
+
+ s.log.Infow("AI config created", "config_id", config.ID, "provider", req.Provider, "endpoint", endpoint)
+ return config, nil
+}
+
+func (s *AIService) GetConfig(configID uint) (*models.AIServiceConfig, error) {
+ var config models.AIServiceConfig
+ err := s.db.Where("id = ? ", configID).First(&config).Error
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("config not found")
+ }
+ return nil, err
+ }
+ return &config, nil
+}
+
+func (s *AIService) ListConfigs(serviceType string) ([]models.AIServiceConfig, error) {
+ var configs []models.AIServiceConfig
+ query := s.db
+
+ if serviceType != "" {
+ query = query.Where("service_type = ?", serviceType)
+ }
+
+ err := query.Order("priority DESC, created_at DESC").Find(&configs).Error
+ if err != nil {
+ s.log.Errorw("Failed to list AI configs", "error", err)
+ return nil, err
+ }
+
+ return configs, nil
+}
+
+func (s *AIService) UpdateConfig(configID uint, req *UpdateAIConfigRequest) (*models.AIServiceConfig, error) {
+ var config models.AIServiceConfig
+ if err := s.db.Where("id = ? ", configID).First(&config).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("config not found")
+ }
+ return nil, err
+ }
+
+ tx := s.db.Begin()
+
+ // 不再需要is_default独占逻辑
+
+ updates := make(map[string]interface{})
+ if req.Name != "" {
+ updates["name"] = req.Name
+ }
+ if req.Provider != "" {
+ updates["provider"] = req.Provider
+ }
+ if req.BaseURL != "" {
+ updates["base_url"] = req.BaseURL
+ }
+ if req.APIKey != "" {
+ updates["api_key"] = req.APIKey
+ }
+ if req.Model != nil && len(*req.Model) > 0 {
+ updates["model"] = *req.Model
+ }
+ if req.Priority != nil {
+ updates["priority"] = *req.Priority
+ }
+
+ // 如果提供了 provider,根据 provider 和 service_type 自动设置 endpoint
+ if req.Provider != "" && req.Endpoint == "" {
+ provider := req.Provider
+ serviceType := config.ServiceType
+
+ switch provider {
+ case "gemini", "google":
+ if serviceType == "text" || serviceType == "image" {
+ updates["endpoint"] = "/v1beta/models/{model}:generateContent"
+ }
+ case "openai":
+ if serviceType == "text" {
+ updates["endpoint"] = "/chat/completions"
+ } else if serviceType == "image" {
+ updates["endpoint"] = "/images/generations"
+ } else if serviceType == "video" {
+ updates["endpoint"] = "/videos"
+ updates["query_endpoint"] = "/videos/{taskId}"
+ }
+ case "chatfire":
+ if serviceType == "text" {
+ updates["endpoint"] = "/chat/completions"
+ } else if serviceType == "image" {
+ updates["endpoint"] = "/images/generations"
+ } else if serviceType == "video" {
+ updates["endpoint"] = "/video/generations"
+ updates["query_endpoint"] = "/video/task/{taskId}"
+ }
+ }
+ } else if req.Endpoint != "" {
+ updates["endpoint"] = req.Endpoint
+ }
+
+ // 允许清空query_endpoint,所以不检查是否为空
+ updates["query_endpoint"] = req.QueryEndpoint
+ if req.Settings != "" {
+ updates["settings"] = req.Settings
+ }
+ updates["is_default"] = req.IsDefault
+ updates["is_active"] = req.IsActive
+
+ if err := tx.Model(&config).Updates(updates).Error; err != nil {
+ tx.Rollback()
+ s.log.Errorw("Failed to update AI config", "error", err)
+ return nil, err
+ }
+
+ if err := tx.Commit().Error; err != nil {
+ return nil, err
+ }
+
+ s.log.Infow("AI config updated", "config_id", configID)
+ return &config, nil
+}
+
+func (s *AIService) DeleteConfig(configID uint) error {
+ result := s.db.Where("id = ? ", configID).Delete(&models.AIServiceConfig{})
+
+ if result.Error != nil {
+ s.log.Errorw("Failed to delete AI config", "error", result.Error)
+ return result.Error
+ }
+
+ if result.RowsAffected == 0 {
+ return errors.New("config not found")
+ }
+
+ s.log.Infow("AI config deleted", "config_id", configID)
+ return nil
+}
+
+func (s *AIService) TestConnection(req *TestConnectionRequest) error {
+ s.log.Infow("TestConnection called", "baseURL", req.BaseURL, "provider", req.Provider, "endpoint", req.Endpoint, "modelCount", len(req.Model))
+
+ // 使用第一个模型进行测试
+ model := ""
+ if len(req.Model) > 0 {
+ model = req.Model[0]
+ }
+ s.log.Infow("Using model for test", "model", model, "provider", req.Provider)
+
+ // 根据 provider 参数选择客户端
+ var client ai.AIClient
+ var endpoint string
+
+ switch req.Provider {
+ case "gemini", "google":
+ // Gemini
+ s.log.Infow("Using Gemini client", "baseURL", req.BaseURL)
+ endpoint = "/v1beta/models/{model}:generateContent"
+ client = ai.NewGeminiClient(req.BaseURL, req.APIKey, model, endpoint)
+ case "openai", "chatfire":
+ // OpenAI 格式(包括 chatfire 等)
+ s.log.Infow("Using OpenAI-compatible client", "baseURL", req.BaseURL, "provider", req.Provider)
+ endpoint = req.Endpoint
+ if endpoint == "" {
+ endpoint = "/chat/completions"
+ }
+ client = ai.NewOpenAIClient(req.BaseURL, req.APIKey, model, endpoint)
+ default:
+ // 默认使用 OpenAI 格式
+ s.log.Infow("Using default OpenAI-compatible client", "baseURL", req.BaseURL)
+ endpoint = req.Endpoint
+ if endpoint == "" {
+ endpoint = "/chat/completions"
+ }
+ client = ai.NewOpenAIClient(req.BaseURL, req.APIKey, model, endpoint)
+ }
+
+ s.log.Infow("Calling TestConnection on client", "endpoint", endpoint)
+ err := client.TestConnection()
+ if err != nil {
+ s.log.Errorw("TestConnection failed", "error", err)
+ } else {
+ s.log.Infow("TestConnection succeeded")
+ }
+ return err
+}
+
+func (s *AIService) GetDefaultConfig(serviceType string) (*models.AIServiceConfig, error) {
+ var config models.AIServiceConfig
+ // 按优先级降序获取第一个配置
+ err := s.db.Where("service_type = ?", serviceType).
+ Order("priority DESC, created_at DESC").
+ First(&config).Error
+
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("no config found")
+ }
+ return nil, err
+ }
+
+ return &config, nil
+}
+
+// GetConfigForModel 根据服务类型和模型名称获取优先级最高的配置
+func (s *AIService) GetConfigForModel(serviceType string, modelName string) (*models.AIServiceConfig, error) {
+ var configs []models.AIServiceConfig
+ err := s.db.Where("service_type = ?", serviceType).
+ Order("priority DESC, created_at DESC").
+ Find(&configs).Error
+
+ if err != nil {
+ return nil, err
+ }
+
+ // 查找包含指定模型的配置
+ for _, config := range configs {
+ for _, model := range config.Model {
+ if model == modelName {
+ return &config, nil
+ }
+ }
+ }
+
+ return nil, errors.New("no config found for model: " + modelName)
+}
+
+func (s *AIService) GetAIClient(serviceType string) (ai.AIClient, error) {
+ config, err := s.GetDefaultConfig(serviceType)
+ if err != nil {
+ return nil, err
+ }
+
+ // 使用第一个模型
+ model := ""
+ if len(config.Model) > 0 {
+ model = config.Model[0]
+ }
+
+ // 使用数据库配置中的 endpoint,如果为空则根据 provider 设置默认值
+ endpoint := config.Endpoint
+ if endpoint == "" {
+ switch config.Provider {
+ case "gemini", "google":
+ endpoint = "/v1beta/models/{model}:generateContent"
+ default:
+ endpoint = "/chat/completions"
+ }
+ }
+
+ // 根据 provider 创建对应的客户端
+ switch config.Provider {
+ case "gemini", "google":
+ return ai.NewGeminiClient(config.BaseURL, config.APIKey, model, endpoint), nil
+ default:
+ // openai, chatfire 等其他厂商都使用 OpenAI 格式
+ return ai.NewOpenAIClient(config.BaseURL, config.APIKey, model, endpoint), nil
+ }
+}
+
+func (s *AIService) GenerateText(prompt string, systemPrompt string, options ...func(*ai.ChatCompletionRequest)) (string, error) {
+ client, err := s.GetAIClient("text")
+ if err != nil {
+ return "", fmt.Errorf("failed to get AI client: %w", err)
+ }
+
+ return client.GenerateText(prompt, systemPrompt, options...)
+}
diff --git a/application/services/asset_service.go b/application/services/asset_service.go
new file mode 100644
index 0000000..3171bee
--- /dev/null
+++ b/application/services/asset_service.go
@@ -0,0 +1,287 @@
+package services
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ models "github.com/drama-generator/backend/domain/models"
+ "github.com/drama-generator/backend/pkg/logger"
+ "gorm.io/gorm"
+)
+
+type AssetService struct {
+ db *gorm.DB
+ log *logger.Logger
+}
+
+func NewAssetService(db *gorm.DB, log *logger.Logger) *AssetService {
+ return &AssetService{
+ db: db,
+ log: log,
+ }
+}
+
+type CreateAssetRequest struct {
+ DramaID *string `json:"drama_id"`
+ Name string `json:"name" binding:"required"`
+ Description *string `json:"description"`
+ Type models.AssetType `json:"type" binding:"required"`
+ Category *string `json:"category"`
+ URL string `json:"url" binding:"required"`
+ ThumbnailURL *string `json:"thumbnail_url"`
+ LocalPath *string `json:"local_path"`
+ FileSize *int64 `json:"file_size"`
+ MimeType *string `json:"mime_type"`
+ Width *int `json:"width"`
+ Height *int `json:"height"`
+ Duration *int `json:"duration"`
+ Format *string `json:"format"`
+ ImageGenID *uint `json:"image_gen_id"`
+ VideoGenID *uint `json:"video_gen_id"`
+ TagIDs []uint `json:"tag_ids"`
+}
+
+type UpdateAssetRequest struct {
+ Name *string `json:"name"`
+ Description *string `json:"description"`
+ Category *string `json:"category"`
+ ThumbnailURL *string `json:"thumbnail_url"`
+ TagIDs []uint `json:"tag_ids"`
+ IsFavorite *bool `json:"is_favorite"`
+}
+
+type ListAssetsRequest struct {
+ DramaID *string `json:"drama_id"`
+ EpisodeID *uint `json:"episode_id"`
+ StoryboardID *uint `json:"storyboard_id"`
+ Type *models.AssetType `json:"type"`
+ Category string `json:"category"`
+ TagIDs []uint `json:"tag_ids"`
+ IsFavorite *bool `json:"is_favorite"`
+ Search string `json:"search"`
+ Page int `json:"page"`
+ PageSize int `json:"page_size"`
+}
+
+func (s *AssetService) CreateAsset(req *CreateAssetRequest) (*models.Asset, error) {
+ var dramaID *uint
+ if req.DramaID != nil && *req.DramaID != "" {
+ id, err := strconv.ParseUint(*req.DramaID, 10, 32)
+ if err == nil {
+ uid := uint(id)
+ dramaID = &uid
+ }
+ }
+
+ if dramaID != nil {
+ var drama models.Drama
+ if err := s.db.Where("id = ?", *dramaID).First(&drama).Error; err != nil {
+ return nil, fmt.Errorf("drama not found")
+ }
+ }
+
+ asset := &models.Asset{
+ DramaID: dramaID,
+ Name: req.Name,
+ Description: req.Description,
+ Type: req.Type,
+ Category: req.Category,
+ URL: req.URL,
+ ThumbnailURL: req.ThumbnailURL,
+ LocalPath: req.LocalPath,
+ FileSize: req.FileSize,
+ MimeType: req.MimeType,
+ Width: req.Width,
+ Height: req.Height,
+ Duration: req.Duration,
+ Format: req.Format,
+ ImageGenID: req.ImageGenID,
+ VideoGenID: req.VideoGenID,
+ }
+
+ if err := s.db.Create(asset).Error; err != nil {
+ return nil, fmt.Errorf("failed to create asset: %w", err)
+ }
+
+ return asset, nil
+}
+
+func (s *AssetService) UpdateAsset(assetID uint, req *UpdateAssetRequest) (*models.Asset, error) {
+ var asset models.Asset
+ if err := s.db.Where("id = ?", assetID).First(&asset).Error; err != nil {
+ return nil, fmt.Errorf("asset not found")
+ }
+
+ updates := make(map[string]interface{})
+ if req.Name != nil {
+ updates["name"] = *req.Name
+ }
+ if req.Description != nil {
+ updates["description"] = *req.Description
+ }
+ if req.Category != nil {
+ updates["category"] = *req.Category
+ }
+ if req.ThumbnailURL != nil {
+ updates["thumbnail_url"] = *req.ThumbnailURL
+ }
+ if req.IsFavorite != nil {
+ updates["is_favorite"] = *req.IsFavorite
+ }
+
+ if len(updates) > 0 {
+ if err := s.db.Model(&asset).Updates(updates).Error; err != nil {
+ return nil, fmt.Errorf("failed to update asset: %w", err)
+ }
+ }
+
+ if err := s.db.First(&asset, assetID).Error; err != nil {
+ return nil, err
+ }
+
+ return &asset, nil
+}
+
+func (s *AssetService) GetAsset(assetID uint) (*models.Asset, error) {
+ var asset models.Asset
+ if err := s.db.Where("id = ? ", assetID).First(&asset).Error; err != nil {
+ return nil, err
+ }
+
+ s.db.Model(&asset).UpdateColumn("view_count", gorm.Expr("view_count + ?", 1))
+
+ return &asset, nil
+}
+
+func (s *AssetService) ListAssets(req *ListAssetsRequest) ([]models.Asset, int64, error) {
+ query := s.db.Model(&models.Asset{})
+
+ if req.DramaID != nil {
+ var dramaID uint64
+ dramaID, _ = strconv.ParseUint(*req.DramaID, 10, 32)
+ query = query.Where("drama_id = ?", uint(dramaID))
+ }
+
+ if req.EpisodeID != nil {
+ query = query.Where("episode_id = ?", *req.EpisodeID)
+ }
+
+ if req.StoryboardID != nil {
+ query = query.Where("storyboard_id = ?", *req.StoryboardID)
+ }
+
+ if req.Type != nil {
+ query = query.Where("type = ?", *req.Type)
+ }
+
+ if req.Category != "" {
+ query = query.Where("category = ?", req.Category)
+ }
+
+ if req.IsFavorite != nil {
+ query = query.Where("is_favorite = ?", *req.IsFavorite)
+ }
+
+ if req.Search != "" {
+ searchTerm := "%" + strings.ToLower(req.Search) + "%"
+ query = query.Where("LOWER(name) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm)
+ }
+
+ var total int64
+ if err := query.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+
+ var assets []models.Asset
+ offset := (req.Page - 1) * req.PageSize
+ if err := query.Order("created_at DESC").
+ Offset(offset).Limit(req.PageSize).Find(&assets).Error; err != nil {
+ return nil, 0, err
+ }
+
+ return assets, total, nil
+}
+
+func (s *AssetService) DeleteAsset(assetID uint) error {
+ result := s.db.Where("id = ?", assetID).Delete(&models.Asset{})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return fmt.Errorf("asset not found")
+ }
+ return nil
+}
+
+func (s *AssetService) ImportFromImageGen(imageGenID uint) (*models.Asset, error) {
+ var imageGen models.ImageGeneration
+ if err := s.db.Where("id = ? ", imageGenID).First(&imageGen).Error; err != nil {
+ return nil, fmt.Errorf("image generation not found")
+ }
+
+ if imageGen.Status != models.ImageStatusCompleted || imageGen.ImageURL == nil {
+ return nil, fmt.Errorf("image is not ready")
+ }
+
+ dramaID := imageGen.DramaID
+ asset := &models.Asset{
+ Name: fmt.Sprintf("Image_%d", imageGen.ID),
+ Type: models.AssetTypeImage,
+ URL: *imageGen.ImageURL,
+ DramaID: &dramaID,
+ ImageGenID: &imageGenID,
+ Width: imageGen.Width,
+ Height: imageGen.Height,
+ }
+
+ if err := s.db.Create(asset).Error; err != nil {
+ return nil, fmt.Errorf("failed to create asset: %w", err)
+ }
+
+ return asset, nil
+}
+
+func (s *AssetService) ImportFromVideoGen(videoGenID uint) (*models.Asset, error) {
+ var videoGen models.VideoGeneration
+ if err := s.db.Preload("Storyboard.Episode").Where("id = ? ", videoGenID).First(&videoGen).Error; err != nil {
+ return nil, fmt.Errorf("video generation not found")
+ }
+
+ if videoGen.Status != models.VideoStatusCompleted || videoGen.VideoURL == nil {
+ return nil, fmt.Errorf("video is not ready")
+ }
+
+ dramaID := videoGen.DramaID
+
+ var episodeID *uint
+ var storyboardNum *int
+ if videoGen.Storyboard != nil {
+ episodeID = &videoGen.Storyboard.Episode.ID
+ storyboardNum = &videoGen.Storyboard.StoryboardNumber
+ }
+
+ asset := &models.Asset{
+ Name: fmt.Sprintf("Video_%d", videoGen.ID),
+ Type: models.AssetTypeVideo,
+ URL: *videoGen.VideoURL,
+ DramaID: &dramaID,
+ EpisodeID: episodeID,
+ StoryboardID: videoGen.StoryboardID,
+ StoryboardNum: storyboardNum,
+ VideoGenID: &videoGenID,
+ Duration: videoGen.Duration,
+ Width: videoGen.Width,
+ Height: videoGen.Height,
+ }
+
+ if videoGen.FirstFrameURL != nil {
+ asset.ThumbnailURL = videoGen.FirstFrameURL
+ }
+
+ if err := s.db.Create(asset).Error; err != nil {
+ return nil, fmt.Errorf("failed to create asset: %w", err)
+ }
+
+ return asset, nil
+}
diff --git a/application/services/character_library_service.go b/application/services/character_library_service.go
new file mode 100644
index 0000000..a7850b8
--- /dev/null
+++ b/application/services/character_library_service.go
@@ -0,0 +1,473 @@
+package services
+
+import (
+ "errors"
+ "fmt"
+ "time"
+
+ models "github.com/drama-generator/backend/domain/models"
+ "github.com/drama-generator/backend/pkg/logger"
+ "gorm.io/gorm"
+)
+
+type CharacterLibraryService struct {
+ db *gorm.DB
+ log *logger.Logger
+}
+
+func NewCharacterLibraryService(db *gorm.DB, log *logger.Logger) *CharacterLibraryService {
+ return &CharacterLibraryService{
+ db: db,
+ log: log,
+ }
+}
+
+type CreateLibraryItemRequest struct {
+ Name string `json:"name" binding:"required,min=1,max=100"`
+ Category *string `json:"category"`
+ ImageURL string `json:"image_url" binding:"required"`
+ Description *string `json:"description"`
+ Tags *string `json:"tags"`
+ SourceType string `json:"source_type"`
+}
+
+type CharacterLibraryQuery struct {
+ Page int `form:"page,default=1"`
+ PageSize int `form:"page_size,default=20"`
+ Category string `form:"category"`
+ SourceType string `form:"source_type"`
+ Keyword string `form:"keyword"`
+}
+
+// ListLibraryItems 获取用户角色库列表
+func (s *CharacterLibraryService) ListLibraryItems(query *CharacterLibraryQuery) ([]models.CharacterLibrary, int64, error) {
+ var items []models.CharacterLibrary
+ var total int64
+
+ db := s.db.Model(&models.CharacterLibrary{})
+
+ // 筛选条件
+ if query.Category != "" {
+ db = db.Where("category = ?", query.Category)
+ }
+
+ if query.SourceType != "" {
+ db = db.Where("source_type = ?", query.SourceType)
+ }
+
+ if query.Keyword != "" {
+ db = db.Where("name LIKE ? OR description LIKE ?", "%"+query.Keyword+"%", "%"+query.Keyword+"%")
+ }
+
+ // 获取总数
+ if err := db.Count(&total).Error; err != nil {
+ s.log.Errorw("Failed to count character library", "error", err)
+ return nil, 0, err
+ }
+
+ // 分页查询
+ offset := (query.Page - 1) * query.PageSize
+ err := db.Order("created_at DESC").
+ Offset(offset).
+ Limit(query.PageSize).
+ Find(&items).Error
+
+ if err != nil {
+ s.log.Errorw("Failed to list character library", "error", err)
+ return nil, 0, err
+ }
+
+ return items, total, nil
+}
+
+// CreateLibraryItem 添加到角色库
+func (s *CharacterLibraryService) CreateLibraryItem(req *CreateLibraryItemRequest) (*models.CharacterLibrary, error) {
+ sourceType := req.SourceType
+ if sourceType == "" {
+ sourceType = "generated"
+ }
+
+ item := &models.CharacterLibrary{
+ Name: req.Name,
+ Category: req.Category,
+ ImageURL: req.ImageURL,
+ Description: req.Description,
+ Tags: req.Tags,
+ SourceType: sourceType,
+ }
+
+ if err := s.db.Create(item).Error; err != nil {
+ s.log.Errorw("Failed to create library item", "error", err)
+ return nil, err
+ }
+
+ s.log.Infow("Library item created", "item_id", item.ID)
+ return item, nil
+}
+
+// GetLibraryItem 获取角色库项
+func (s *CharacterLibraryService) GetLibraryItem(itemID string) (*models.CharacterLibrary, error) {
+ var item models.CharacterLibrary
+ err := s.db.Where("id = ? ", itemID).First(&item).Error
+
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("library item not found")
+ }
+ s.log.Errorw("Failed to get library item", "error", err)
+ return nil, err
+ }
+
+ return &item, nil
+}
+
+// DeleteLibraryItem 删除角色库项
+func (s *CharacterLibraryService) DeleteLibraryItem(itemID string) error {
+ result := s.db.Where("id = ? ", itemID).Delete(&models.CharacterLibrary{})
+
+ if result.Error != nil {
+ s.log.Errorw("Failed to delete library item", "error", result.Error)
+ return result.Error
+ }
+
+ if result.RowsAffected == 0 {
+ return errors.New("library item not found")
+ }
+
+ s.log.Infow("Library item deleted", "item_id", itemID)
+ return nil
+}
+
+// ApplyLibraryItemToCharacter 将角色库形象应用到角色
+func (s *CharacterLibraryService) ApplyLibraryItemToCharacter(characterID string, libraryItemID string) error {
+ // 验证角色库项存在且属于该用户
+ var libraryItem models.CharacterLibrary
+ if err := s.db.Where("id = ? ", libraryItemID).First(&libraryItem).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("library item not found")
+ }
+ return err
+ }
+
+ // 查找角色
+ var character models.Character
+ if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("character not found")
+ }
+ return err
+ }
+
+ // 查询Drama验证权限
+ var drama models.Drama
+ if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("unauthorized")
+ }
+ return err
+ }
+
+ // 更新角色的image_url
+ if err := s.db.Model(&character).Update("image_url", libraryItem.ImageURL).Error; err != nil {
+ s.log.Errorw("Failed to update character image", "error", err)
+ return err
+ }
+
+ s.log.Infow("Library item applied to character", "character_id", characterID, "library_item_id", libraryItemID)
+ return nil
+}
+
+// UploadCharacterImage 上传角色图片
+func (s *CharacterLibraryService) UploadCharacterImage(characterID string, imageURL string) error {
+ // 查找角色
+ var character models.Character
+ if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("character not found")
+ }
+ return err
+ }
+
+ // 查询Drama验证权限
+ var drama models.Drama
+ if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("unauthorized")
+ }
+ return err
+ }
+
+ // 更新图片URL
+ if err := s.db.Model(&character).Update("image_url", imageURL).Error; err != nil {
+ s.log.Errorw("Failed to update character image", "error", err)
+ return err
+ }
+
+ s.log.Infow("Character image uploaded", "character_id", characterID)
+ return nil
+}
+
+// AddCharacterToLibrary 将角色添加到角色库
+func (s *CharacterLibraryService) AddCharacterToLibrary(characterID string, category *string) (*models.CharacterLibrary, error) {
+ // 查找角色
+ var character models.Character
+ if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("character not found")
+ }
+ return nil, err
+ }
+
+ // 查询Drama验证权限
+ var drama models.Drama
+ if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("unauthorized")
+ }
+ return nil, err
+ }
+
+ // 检查是否有图片
+ if character.ImageURL == nil || *character.ImageURL == "" {
+ return nil, fmt.Errorf("角色还没有形象图片")
+ }
+
+ // 创建角色库项
+ charLibrary := &models.CharacterLibrary{
+ Name: character.Name,
+ ImageURL: *character.ImageURL,
+ Description: character.Description,
+ SourceType: "character",
+ }
+
+ if err := s.db.Create(charLibrary).Error; err != nil {
+ s.log.Errorw("Failed to add character to library", "error", err)
+ return nil, err
+ }
+
+ s.log.Infow("Character added to library", "character_id", characterID, "library_item_id", charLibrary.ID)
+ return charLibrary, nil
+}
+
+// DeleteCharacter 删除单个角色
+func (s *CharacterLibraryService) DeleteCharacter(characterID uint) error {
+ // 查找角色
+ var character models.Character
+ if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("character not found")
+ }
+ return err
+ }
+
+ // 验证权限:检查角色所属的drama是否属于当前用户
+ var drama models.Drama
+ if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("unauthorized")
+ }
+ return err
+ }
+
+ // 删除角色
+ if err := s.db.Delete(&character).Error; err != nil {
+ s.log.Errorw("Failed to delete character", "error", err, "id", characterID)
+ return err
+ }
+
+ s.log.Infow("Character deleted", "id", characterID)
+ return nil
+}
+
+// GenerateCharacterImage AI生成角色形象
+func (s *CharacterLibraryService) GenerateCharacterImage(characterID string, imageService *ImageGenerationService, modelName string) (*models.ImageGeneration, error) {
+ // 查找角色
+ var character models.Character
+ if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("character not found")
+ }
+ return nil, err
+ }
+
+ // 查询Drama验证权限
+ var drama models.Drama
+ if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("unauthorized")
+ }
+ return nil, err
+ }
+
+ // 构建生成提示词 - 使用详细的外貌描述,添加干净背景要求
+ prompt := ""
+
+ // 优先使用appearance字段,它包含了最详细的外貌描述
+ if character.Appearance != nil && *character.Appearance != "" {
+ prompt = *character.Appearance
+ } else if character.Description != nil && *character.Description != "" {
+ prompt = *character.Description
+ } else {
+ prompt = character.Name
+ }
+
+ // 添加角色画像和风格要求
+ prompt += ", character portrait, full body or upper body shot"
+
+ // 添加干净背景要求 - 确保背景简洁不干扰主体
+ prompt += ", simple clean background, plain solid color background, white or light gray background"
+ prompt += ", studio lighting, professional photography"
+
+ // 添加质量和风格要求
+ prompt += ", high quality, detailed, anime style, character design"
+ prompt += ", no complex background, no scenery, focus on character"
+
+ // 调用图片生成服务
+ dramaIDStr := fmt.Sprintf("%d", character.DramaID)
+ imageType := "character"
+ req := &GenerateImageRequest{
+ DramaID: dramaIDStr,
+ CharacterID: &character.ID,
+ ImageType: imageType,
+ Prompt: prompt,
+ Provider: "openai", // 或从配置读取
+ Model: modelName, // 使用用户指定的模型
+ Size: "2560x1440", // 3,686,400像素,满足API最低要求(16:9比例)
+ Quality: "standard",
+ }
+
+ imageGen, err := imageService.GenerateImage(req)
+ if err != nil {
+ s.log.Errorw("Failed to generate character image", "error", err)
+ return nil, fmt.Errorf("图片生成失败: %w", err)
+ }
+
+ // 异步处理:在后台监听图片生成完成,然后更新角色image_url
+ go s.waitAndUpdateCharacterImage(character.ID, imageGen.ID)
+
+ // 立即返回ImageGeneration对象,让前端可以轮询状态
+ s.log.Infow("Character image generation started", "character_id", characterID, "image_gen_id", imageGen.ID)
+ return imageGen, nil
+}
+
+// waitAndUpdateCharacterImage 后台异步等待图片生成完成并更新角色image_url
+func (s *CharacterLibraryService) waitAndUpdateCharacterImage(characterID uint, imageGenID uint) {
+ maxAttempts := 60
+ pollInterval := 5 * time.Second
+
+ for i := 0; i < maxAttempts; i++ {
+ time.Sleep(pollInterval)
+
+ // 查询图片生成状态
+ var imageGen models.ImageGeneration
+ if err := s.db.First(&imageGen, imageGenID).Error; err != nil {
+ s.log.Errorw("Failed to query image generation status", "error", err, "image_gen_id", imageGenID)
+ continue
+ }
+
+ // 检查是否完成
+ if imageGen.Status == models.ImageStatusCompleted && imageGen.ImageURL != nil && *imageGen.ImageURL != "" {
+ // 更新角色的image_url
+ if err := s.db.Model(&models.Character{}).Where("id = ?", characterID).Update("image_url", *imageGen.ImageURL).Error; err != nil {
+ s.log.Errorw("Failed to update character image_url", "error", err, "character_id", characterID)
+ return
+ }
+ s.log.Infow("Character image updated successfully", "character_id", characterID, "image_url", *imageGen.ImageURL)
+ return
+ }
+
+ // 检查是否失败
+ if imageGen.Status == models.ImageStatusFailed {
+ s.log.Errorw("Character image generation failed", "character_id", characterID, "image_gen_id", imageGenID, "error", imageGen.ErrorMsg)
+ return
+ }
+ }
+
+ s.log.Warnw("Character image generation timeout", "character_id", characterID, "image_gen_id", imageGenID)
+}
+
+// UpdateCharacter 更新角色信息
+func (s *CharacterLibraryService) UpdateCharacter(characterID string, req interface{}) error {
+ // 查找角色
+ var character models.Character
+ if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("character not found")
+ }
+ return err
+ }
+
+ // 验证权限:查询角色所属的drama是否属于该用户
+ var drama models.Drama
+ if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("unauthorized")
+ }
+ return err
+ }
+
+ // 构建更新数据
+ updates := make(map[string]interface{})
+
+ // 使用类型断言获取请求数据
+ if reqMap, ok := req.(*struct {
+ Name *string `json:"name"`
+ Appearance *string `json:"appearance"`
+ Personality *string `json:"personality"`
+ Description *string `json:"description"`
+ }); ok {
+ if reqMap.Name != nil && *reqMap.Name != "" {
+ updates["name"] = *reqMap.Name
+ }
+ if reqMap.Appearance != nil {
+ updates["appearance"] = *reqMap.Appearance
+ }
+ if reqMap.Personality != nil {
+ updates["personality"] = *reqMap.Personality
+ }
+ if reqMap.Description != nil {
+ updates["description"] = *reqMap.Description
+ }
+ }
+
+ if len(updates) == 0 {
+ return errors.New("no fields to update")
+ }
+
+ // 更新角色信息
+ if err := s.db.Model(&character).Updates(updates).Error; err != nil {
+ s.log.Errorw("Failed to update character", "error", err, "character_id", characterID)
+ return err
+ }
+
+ s.log.Infow("Character updated", "character_id", characterID)
+ return nil
+}
+
+// BatchGenerateCharacterImages 批量生成角色图片(并发执行)
+func (s *CharacterLibraryService) BatchGenerateCharacterImages(characterIDs []string, imageService *ImageGenerationService, modelName string) {
+ s.log.Infow("Starting batch character image generation",
+ "count", len(characterIDs),
+ "model", modelName)
+
+ // 使用 goroutine 并发生成所有角色图片
+ for _, characterID := range characterIDs {
+ // 为每个角色启动单独的 goroutine
+ go func(charID string) {
+ imageGen, err := s.GenerateCharacterImage(charID, imageService, modelName)
+ if err != nil {
+ s.log.Errorw("Failed to generate character image in batch",
+ "character_id", charID,
+ "error", err)
+ return
+ }
+
+ s.log.Infow("Character image generated in batch",
+ "character_id", charID,
+ "image_gen_id", imageGen.ID)
+ }(characterID)
+ }
+
+ s.log.Infow("Batch character image generation tasks submitted",
+ "total", len(characterIDs))
+}
diff --git a/application/services/drama_service.go b/application/services/drama_service.go
new file mode 100644
index 0000000..f585d00
--- /dev/null
+++ b/application/services/drama_service.go
@@ -0,0 +1,630 @@
+package services
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+ "time"
+
+ "github.com/drama-generator/backend/domain/models"
+ "github.com/drama-generator/backend/pkg/logger"
+ "gorm.io/gorm"
+)
+
+type DramaService struct {
+ db *gorm.DB
+ log *logger.Logger
+}
+
+func NewDramaService(db *gorm.DB, log *logger.Logger) *DramaService {
+ return &DramaService{
+ db: db,
+ log: log,
+ }
+}
+
+type CreateDramaRequest struct {
+ Title string `json:"title" binding:"required,min=1,max=100"`
+ Description string `json:"description"`
+ Genre string `json:"genre"`
+ Tags string `json:"tags"`
+}
+
+type UpdateDramaRequest struct {
+ Title string `json:"title" binding:"omitempty,min=1,max=100"`
+ Description string `json:"description"`
+ Genre string `json:"genre"`
+ Tags string `json:"tags"`
+ Status string `json:"status" binding:"omitempty,oneof=draft planning production completed archived"`
+}
+
+type DramaListQuery struct {
+ Page int `form:"page,default=1"`
+ PageSize int `form:"page_size,default=20"`
+ Status string `form:"status"`
+ Genre string `form:"genre"`
+ Keyword string `form:"keyword"`
+}
+
+func (s *DramaService) CreateDrama(req *CreateDramaRequest) (*models.Drama, error) {
+ drama := &models.Drama{
+ Title: req.Title,
+ Status: "draft",
+ }
+
+ if req.Description != "" {
+ drama.Description = &req.Description
+ }
+ if req.Genre != "" {
+ drama.Genre = &req.Genre
+ }
+
+ if err := s.db.Create(drama).Error; err != nil {
+ s.log.Errorw("Failed to create drama", "error", err)
+ return nil, err
+ }
+
+ s.log.Infow("Drama created", "drama_id", drama.ID)
+ return drama, nil
+}
+
+func (s *DramaService) GetDrama(dramaID string) (*models.Drama, error) {
+ var drama models.Drama
+ err := s.db.Where("id = ? ", dramaID).
+ Preload("Characters"). // 加载Drama级别的角色
+ Preload("Scenes"). // 加载Drama级别的场景
+ Preload("Episodes.Characters"). // 加载每个章节关联的角色
+ Preload("Episodes.Scenes"). // 加载每个章节关联的场景
+ Preload("Episodes.Storyboards", func(db *gorm.DB) *gorm.DB {
+ return db.Order("storyboards.storyboard_number ASC")
+ }).
+ First(&drama).Error
+
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("drama not found")
+ }
+ s.log.Errorw("Failed to get drama", "error", err)
+ return nil, err
+ }
+
+ // 统计每个剧集的时长(基于场景时长之和)
+ for i := range drama.Episodes {
+ totalDuration := 0
+ for _, scene := range drama.Episodes[i].Storyboards {
+ totalDuration += scene.Duration
+ }
+ // 更新剧集时长(秒转分钟,向上取整)
+ durationMinutes := (totalDuration + 59) / 60
+ drama.Episodes[i].Duration = durationMinutes
+
+ // 如果数据库中的时长与计算的不一致,更新数据库
+ if drama.Episodes[i].Duration != durationMinutes {
+ s.db.Model(&models.Episode{}).Where("id = ?", drama.Episodes[i].ID).Update("duration", durationMinutes)
+ }
+
+ // 查询角色的图片生成状态
+ for j := range drama.Episodes[i].Characters {
+ var imageGen models.ImageGeneration
+ err := s.db.Where("character_id = ? AND (status = ? OR status = ?)",
+ drama.Episodes[i].Characters[j].ID, "pending", "processing").
+ Order("created_at DESC").
+ First(&imageGen).Error
+
+ if err == nil {
+ // 找到生成中的记录,设置状态
+ statusStr := string(imageGen.Status)
+ drama.Episodes[i].Characters[j].ImageGenerationStatus = &statusStr
+ if imageGen.ErrorMsg != nil {
+ drama.Episodes[i].Characters[j].ImageGenerationError = imageGen.ErrorMsg
+ }
+ } else if errors.Is(err, gorm.ErrRecordNotFound) {
+ // 检查是否有失败的记录
+ err := s.db.Where("character_id = ? AND status = ?",
+ drama.Episodes[i].Characters[j].ID, "failed").
+ Order("created_at DESC").
+ First(&imageGen).Error
+
+ if err == nil {
+ statusStr := string(imageGen.Status)
+ drama.Episodes[i].Characters[j].ImageGenerationStatus = &statusStr
+ if imageGen.ErrorMsg != nil {
+ drama.Episodes[i].Characters[j].ImageGenerationError = imageGen.ErrorMsg
+ }
+ }
+ }
+ }
+
+ // 查询场景的图片生成状态
+ for j := range drama.Episodes[i].Scenes {
+ var imageGen models.ImageGeneration
+ err := s.db.Where("scene_id = ? AND (status = ? OR status = ?)",
+ drama.Episodes[i].Scenes[j].ID, "pending", "processing").
+ Order("created_at DESC").
+ First(&imageGen).Error
+
+ if err == nil {
+ // 找到生成中的记录,设置状态
+ statusStr := string(imageGen.Status)
+ drama.Episodes[i].Scenes[j].ImageGenerationStatus = &statusStr
+ if imageGen.ErrorMsg != nil {
+ drama.Episodes[i].Scenes[j].ImageGenerationError = imageGen.ErrorMsg
+ }
+ } else if errors.Is(err, gorm.ErrRecordNotFound) {
+ // 检查是否有失败的记录
+ err := s.db.Where("scene_id = ? AND status = ?",
+ drama.Episodes[i].Scenes[j].ID, "failed").
+ Order("created_at DESC").
+ First(&imageGen).Error
+
+ if err == nil {
+ statusStr := string(imageGen.Status)
+ drama.Episodes[i].Scenes[j].ImageGenerationStatus = &statusStr
+ if imageGen.ErrorMsg != nil {
+ drama.Episodes[i].Scenes[j].ImageGenerationError = imageGen.ErrorMsg
+ }
+ }
+ }
+ }
+ }
+
+ // 整合所有剧集的场景到Drama级别的Scenes字段
+ sceneMap := make(map[uint]*models.Scene) // 用于去重
+ for i := range drama.Episodes {
+ for j := range drama.Episodes[i].Scenes {
+ scene := &drama.Episodes[i].Scenes[j]
+ sceneMap[scene.ID] = scene
+ }
+ }
+
+ // 将整合的场景添加到drama.Scenes
+ drama.Scenes = make([]models.Scene, 0, len(sceneMap))
+ for _, scene := range sceneMap {
+ drama.Scenes = append(drama.Scenes, *scene)
+ }
+
+ return &drama, nil
+}
+
+func (s *DramaService) ListDramas(query *DramaListQuery) ([]models.Drama, int64, error) {
+ var dramas []models.Drama
+ var total int64
+
+ db := s.db.Model(&models.Drama{})
+
+ if query.Status != "" {
+ db = db.Where("status = ?", query.Status)
+ }
+
+ if query.Genre != "" {
+ db = db.Where("genre = ?", query.Genre)
+ }
+
+ if query.Keyword != "" {
+ db = db.Where("title LIKE ? OR description LIKE ?", "%"+query.Keyword+"%", "%"+query.Keyword+"%")
+ }
+
+ if err := db.Count(&total).Error; err != nil {
+ s.log.Errorw("Failed to count dramas", "error", err)
+ return nil, 0, err
+ }
+
+ offset := (query.Page - 1) * query.PageSize
+ err := db.Order("updated_at DESC").
+ Offset(offset).
+ Limit(query.PageSize).
+ Preload("Episodes.Storyboards", func(db *gorm.DB) *gorm.DB {
+ return db.Order("storyboards.storyboard_number ASC")
+ }).
+ Find(&dramas).Error
+
+ if err != nil {
+ s.log.Errorw("Failed to list dramas", "error", err)
+ return nil, 0, err
+ }
+
+ // 统计每个剧本的每个剧集的时长(基于场景时长之和)
+ for i := range dramas {
+ for j := range dramas[i].Episodes {
+ totalDuration := 0
+ for _, scene := range dramas[i].Episodes[j].Storyboards {
+ totalDuration += scene.Duration
+ }
+ // 更新剧集时长(秒转分钟,向上取整)
+ durationMinutes := (totalDuration + 59) / 60
+ dramas[i].Episodes[j].Duration = durationMinutes
+ }
+ }
+
+ return dramas, total, nil
+}
+
+func (s *DramaService) UpdateDrama(dramaID string, req *UpdateDramaRequest) (*models.Drama, error) {
+ var drama models.Drama
+ if err := s.db.Where("id = ? ", dramaID).First(&drama).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("drama not found")
+ }
+ return nil, err
+ }
+
+ updates := make(map[string]interface{})
+
+ if req.Title != "" {
+ updates["title"] = req.Title
+ }
+ if req.Description != "" {
+ updates["description"] = req.Description
+ }
+ if req.Genre != "" {
+ updates["genre"] = req.Genre
+ }
+ if req.Tags != "" {
+ updates["tags"] = req.Tags
+ }
+ if req.Status != "" {
+ updates["status"] = req.Status
+ }
+
+ updates["updated_at"] = time.Now()
+
+ if err := s.db.Model(&drama).Updates(updates).Error; err != nil {
+ s.log.Errorw("Failed to update drama", "error", err)
+ return nil, err
+ }
+
+ s.log.Infow("Drama updated", "drama_id", dramaID)
+ return &drama, nil
+}
+
+func (s *DramaService) DeleteDrama(dramaID string) error {
+ result := s.db.Where("id = ? ", dramaID).Delete(&models.Drama{})
+
+ if result.Error != nil {
+ s.log.Errorw("Failed to delete drama", "error", result.Error)
+ return result.Error
+ }
+
+ if result.RowsAffected == 0 {
+ return errors.New("drama not found")
+ }
+
+ s.log.Infow("Drama deleted", "drama_id", dramaID)
+ return nil
+}
+
+func (s *DramaService) GetDramaStats() (map[string]interface{}, error) {
+ var total int64
+ var byStatus []struct {
+ Status string
+ Count int64
+ }
+
+ if err := s.db.Model(&models.Drama{}).Count(&total).Error; err != nil {
+ return nil, err
+ }
+
+ if err := s.db.Model(&models.Drama{}).
+ Select("status, count(*) as count").
+ Group("status").
+ Scan(&byStatus).Error; err != nil {
+ return nil, err
+ }
+
+ stats := map[string]interface{}{
+ "total": total,
+ "by_status": byStatus,
+ }
+
+ return stats, nil
+}
+
+type SaveOutlineRequest struct {
+ Title string `json:"title" binding:"required"`
+ Summary string `json:"summary" binding:"required"`
+ Genre string `json:"genre"`
+ Tags []string `json:"tags"`
+}
+
+type SaveCharactersRequest struct {
+ Characters []models.Character `json:"characters" binding:"required"`
+ EpisodeID *uint `json:"episode_id"` // 可选:如果提供则关联到指定章节
+}
+
+type SaveProgressRequest struct {
+ CurrentStep string `json:"current_step" binding:"required"`
+ StepData map[string]interface{} `json:"step_data"`
+}
+
+type SaveEpisodesRequest struct {
+ Episodes []models.Episode `json:"episodes" binding:"required"`
+}
+
+func (s *DramaService) SaveOutline(dramaID string, req *SaveOutlineRequest) error {
+ var drama models.Drama
+ if err := s.db.Where("id = ? ", dramaID).First(&drama).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("drama not found")
+ }
+ return err
+ }
+
+ updates := map[string]interface{}{
+ "title": req.Title,
+ "description": req.Summary,
+ "updated_at": time.Now(),
+ }
+
+ if req.Genre != "" {
+ updates["genre"] = req.Genre
+ }
+
+ if len(req.Tags) > 0 {
+ tagsJSON, err := json.Marshal(req.Tags)
+ if err != nil {
+ s.log.Errorw("Failed to marshal tags", "error", err)
+ return err
+ }
+ updates["tags"] = tagsJSON
+ }
+
+ if err := s.db.Model(&drama).Updates(updates).Error; err != nil {
+ s.log.Errorw("Failed to save outline", "error", err)
+ return err
+ }
+
+ s.log.Infow("Outline saved", "drama_id", dramaID)
+ return nil
+}
+
+func (s *DramaService) GetCharacters(dramaID string, episodeID *string) ([]models.Character, error) {
+ var drama models.Drama
+ if err := s.db.Where("id = ? ", dramaID).First(&drama).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("drama not found")
+ }
+ return nil, err
+ }
+
+ var characters []models.Character
+
+ // 如果指定了episodeID,只获取该章节关联的角色
+ if episodeID != nil {
+ var episode models.Episode
+ if err := s.db.Preload("Characters").Where("id = ? AND drama_id = ?", *episodeID, dramaID).First(&episode).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("episode not found")
+ }
+ return nil, err
+ }
+ characters = episode.Characters
+ } else {
+ // 如果没有指定episodeID,获取项目的所有角色
+ if err := s.db.Where("drama_id = ?", dramaID).Find(&characters).Error; err != nil {
+ s.log.Errorw("Failed to get characters", "error", err)
+ return nil, err
+ }
+ }
+
+ // 查询每个角色的图片生成任务状态
+ for i := range characters {
+ // 查询该角色最新的图片生成任务
+ var imageGen models.ImageGeneration
+ err := s.db.Where("character_id = ?", characters[i].ID).
+ Order("created_at DESC").
+ First(&imageGen).Error
+
+ if err == nil {
+ // 如果有进行中的任务,填充状态信息
+ if imageGen.Status == models.ImageStatusPending || imageGen.Status == models.ImageStatusProcessing {
+ statusStr := string(imageGen.Status)
+ characters[i].ImageGenerationStatus = &statusStr
+ } else if imageGen.Status == models.ImageStatusFailed {
+ statusStr := "failed"
+ characters[i].ImageGenerationStatus = &statusStr
+ if imageGen.ErrorMsg != nil {
+ characters[i].ImageGenerationError = imageGen.ErrorMsg
+ }
+ }
+ }
+ }
+
+ return characters, nil
+}
+
+func (s *DramaService) SaveCharacters(dramaID string, req *SaveCharactersRequest) error {
+ // 转换dramaID
+ id, err := strconv.ParseUint(dramaID, 10, 32)
+ if err != nil {
+ return fmt.Errorf("invalid drama ID")
+ }
+ dramaIDUint := uint(id)
+
+ var drama models.Drama
+ if err := s.db.Where("id = ? ", dramaIDUint).First(&drama).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("drama not found")
+ }
+ return err
+ }
+
+ // 如果指定了EpisodeID,验证章节存在性
+ if req.EpisodeID != nil {
+ var episode models.Episode
+ if err := s.db.Where("id = ? AND drama_id = ?", *req.EpisodeID, dramaIDUint).First(&episode).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("episode not found")
+ }
+ return err
+ }
+ }
+
+ // 获取该项目已存在的所有角色
+ var existingCharacters []models.Character
+ if err := s.db.Where("drama_id = ?", dramaIDUint).Find(&existingCharacters).Error; err != nil {
+ s.log.Errorw("Failed to get existing characters", "error", err)
+ return err
+ }
+
+ // 创建角色名称到角色的映射
+ existingCharMap := make(map[string]*models.Character)
+ for i := range existingCharacters {
+ existingCharMap[existingCharacters[i].Name] = &existingCharacters[i]
+ }
+
+ // 收集需要关联到章节的角色ID
+ var characterIDs []uint
+
+ // 创建新角色或复用已有角色
+ for _, char := range req.Characters {
+ if existingChar, exists := existingCharMap[char.Name]; exists {
+ // 角色已存在,直接复用
+ s.log.Infow("Character already exists, reusing", "name", char.Name, "character_id", existingChar.ID)
+ characterIDs = append(characterIDs, existingChar.ID)
+ continue
+ }
+
+ // 角色不存在,创建新角色
+ character := models.Character{
+ DramaID: dramaIDUint,
+ Name: char.Name,
+ Role: char.Role,
+ Description: char.Description,
+ Personality: char.Personality,
+ Appearance: char.Appearance,
+ }
+
+ if err := s.db.Create(&character).Error; err != nil {
+ s.log.Errorw("Failed to create character", "error", err, "name", char.Name)
+ continue
+ }
+
+ s.log.Infow("New character created", "character_id", character.ID, "name", char.Name)
+ characterIDs = append(characterIDs, character.ID)
+ }
+
+ // 如果指定了EpisodeID,建立角色与章节的关联
+ if req.EpisodeID != nil && len(characterIDs) > 0 {
+ var episode models.Episode
+ if err := s.db.First(&episode, *req.EpisodeID).Error; err != nil {
+ return err
+ }
+
+ // 获取角色对象
+ var characters []models.Character
+ if err := s.db.Where("id IN ?", characterIDs).Find(&characters).Error; err != nil {
+ s.log.Errorw("Failed to get characters", "error", err)
+ return err
+ }
+
+ // 使用GORM的Association API建立多对多关系(会自动去重)
+ if err := s.db.Model(&episode).Association("Characters").Append(&characters); err != nil {
+ s.log.Errorw("Failed to associate characters with episode", "error", err)
+ return err
+ }
+
+ s.log.Infow("Characters associated with episode", "episode_id", *req.EpisodeID, "character_count", len(characterIDs))
+ }
+
+ if err := s.db.Model(&drama).Update("updated_at", time.Now()).Error; err != nil {
+ s.log.Errorw("Failed to update drama timestamp", "error", err)
+ }
+
+ s.log.Infow("Characters saved", "drama_id", dramaID, "count", len(req.Characters))
+ return nil
+}
+
+func (s *DramaService) SaveEpisodes(dramaID string, req *SaveEpisodesRequest) error {
+ // 转换dramaID
+ id, err := strconv.ParseUint(dramaID, 10, 32)
+ if err != nil {
+ return fmt.Errorf("invalid drama ID")
+ }
+ dramaIDUint := uint(id)
+
+ var drama models.Drama
+ if err := s.db.Where("id = ? ", dramaIDUint).First(&drama).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("drama not found")
+ }
+ return err
+ }
+
+ // 删除旧剧集
+ if err := s.db.Where("drama_id = ?", dramaIDUint).Delete(&models.Episode{}).Error; err != nil {
+ s.log.Errorw("Failed to delete old episodes", "error", err)
+ return err
+ }
+
+ // 创建新剧集(不包含场景,场景由后续步骤生成)
+ for _, ep := range req.Episodes {
+ episode := models.Episode{
+ DramaID: dramaIDUint,
+ EpisodeNum: ep.EpisodeNum,
+ Title: ep.Title,
+ Description: ep.Description,
+ ScriptContent: ep.ScriptContent,
+ Duration: ep.Duration,
+ Status: "draft",
+ }
+
+ if err := s.db.Create(&episode).Error; err != nil {
+ s.log.Errorw("Failed to create episode", "error", err, "episode", ep.EpisodeNum)
+ continue
+ }
+ }
+
+ if err := s.db.Model(&drama).Update("updated_at", time.Now()).Error; err != nil {
+ s.log.Errorw("Failed to update drama timestamp", "error", err)
+ }
+
+ s.log.Infow("Episodes saved", "drama_id", dramaID, "count", len(req.Episodes))
+ return nil
+}
+
+func (s *DramaService) SaveProgress(dramaID string, req *SaveProgressRequest) error {
+ var drama models.Drama
+ if err := s.db.Where("id = ? ", dramaID).First(&drama).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("drama not found")
+ }
+ return err
+ }
+
+ // 构建metadata对象
+ metadata := make(map[string]interface{})
+
+ // 保留现有metadata
+ if drama.Metadata != nil {
+ if err := json.Unmarshal(drama.Metadata, &metadata); err != nil {
+ s.log.Warnw("Failed to unmarshal existing metadata", "error", err)
+ }
+ }
+
+ // 更新progress信息
+ metadata["current_step"] = req.CurrentStep
+ if req.StepData != nil {
+ metadata["step_data"] = req.StepData
+ }
+
+ // 序列化metadata
+ metadataJSON, err := json.Marshal(metadata)
+ if err != nil {
+ s.log.Errorw("Failed to marshal metadata", "error", err)
+ return err
+ }
+
+ updates := map[string]interface{}{
+ "metadata": metadataJSON,
+ "updated_at": time.Now(),
+ }
+
+ if err := s.db.Model(&drama).Updates(updates).Error; err != nil {
+ s.log.Errorw("Failed to save progress", "error", err)
+ return err
+ }
+
+ s.log.Infow("Progress saved", "drama_id", dramaID, "step", req.CurrentStep)
+ return nil
+}
diff --git a/application/services/frame_prompt_service.go b/application/services/frame_prompt_service.go
new file mode 100644
index 0000000..fe384ce
--- /dev/null
+++ b/application/services/frame_prompt_service.go
@@ -0,0 +1,428 @@
+package services
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/drama-generator/backend/domain/models"
+ "github.com/drama-generator/backend/pkg/logger"
+ "gorm.io/gorm"
+)
+
+// FramePromptService 处理帧提示词生成
+type FramePromptService struct {
+ db *gorm.DB
+ aiService *AIService
+ log *logger.Logger
+}
+
+// NewFramePromptService 创建帧提示词服务
+func NewFramePromptService(db *gorm.DB, log *logger.Logger) *FramePromptService {
+ return &FramePromptService{
+ db: db,
+ aiService: NewAIService(db, log),
+ log: log,
+ }
+}
+
+// FrameType 帧类型
+type FrameType string
+
+const (
+ FrameTypeFirst FrameType = "first" // 首帧
+ FrameTypeKey FrameType = "key" // 关键帧
+ FrameTypeLast FrameType = "last" // 尾帧
+ FrameTypePanel FrameType = "panel" // 分镜板(3格组合)
+ FrameTypeAction FrameType = "action" // 动作序列(5格)
+)
+
+// GenerateFramePromptRequest 生成帧提示词请求
+type GenerateFramePromptRequest struct {
+ StoryboardID string `json:"storyboard_id"`
+ FrameType FrameType `json:"frame_type"`
+ // 可选参数
+ PanelCount int `json:"panel_count,omitempty"` // 分镜板格数,默认3
+}
+
+// FramePromptResponse 帧提示词响应
+type FramePromptResponse struct {
+ FrameType FrameType `json:"frame_type"`
+ SingleFrame *SingleFramePrompt `json:"single_frame,omitempty"` // 单帧提示词
+ MultiFrame *MultiFramePrompt `json:"multi_frame,omitempty"` // 多帧提示词
+}
+
+// SingleFramePrompt 单帧提示词
+type SingleFramePrompt struct {
+ Prompt string `json:"prompt"`
+ Description string `json:"description"`
+}
+
+// MultiFramePrompt 多帧提示词
+type MultiFramePrompt struct {
+ Layout string `json:"layout"` // horizontal_3, grid_2x2 等
+ Frames []SingleFramePrompt `json:"frames"`
+}
+
+// GenerateFramePrompt 生成指定类型的帧提示词并保存到frame_prompts表
+func (s *FramePromptService) GenerateFramePrompt(req GenerateFramePromptRequest) (*FramePromptResponse, error) {
+ // 查询分镜信息
+ var storyboard models.Storyboard
+ if err := s.db.Preload("Characters").First(&storyboard, req.StoryboardID).Error; err != nil {
+ return nil, fmt.Errorf("storyboard not found: %w", err)
+ }
+
+ // 获取场景信息
+ var scene *models.Scene
+ if storyboard.SceneID != nil {
+ scene = &models.Scene{}
+ if err := s.db.First(scene, *storyboard.SceneID).Error; err != nil {
+ s.log.Warnw("Scene not found", "scene_id", *storyboard.SceneID)
+ scene = nil
+ }
+ }
+
+ response := &FramePromptResponse{
+ FrameType: req.FrameType,
+ }
+
+ // 生成提示词
+ switch req.FrameType {
+ case FrameTypeFirst:
+ response.SingleFrame = s.generateFirstFrame(storyboard, scene)
+ // 保存单帧提示词
+ s.saveFramePrompt(req.StoryboardID, string(req.FrameType), response.SingleFrame.Prompt, response.SingleFrame.Description, "")
+ case FrameTypeKey:
+ response.SingleFrame = s.generateKeyFrame(storyboard, scene)
+ s.saveFramePrompt(req.StoryboardID, string(req.FrameType), response.SingleFrame.Prompt, response.SingleFrame.Description, "")
+ case FrameTypeLast:
+ response.SingleFrame = s.generateLastFrame(storyboard, scene)
+ s.saveFramePrompt(req.StoryboardID, string(req.FrameType), response.SingleFrame.Prompt, response.SingleFrame.Description, "")
+ case FrameTypePanel:
+ count := req.PanelCount
+ if count == 0 {
+ count = 3
+ }
+ response.MultiFrame = s.generatePanelFrames(storyboard, scene, count)
+ // 保存多帧提示词(合并为一条记录)
+ var prompts []string
+ for _, frame := range response.MultiFrame.Frames {
+ prompts = append(prompts, frame.Prompt)
+ }
+ combinedPrompt := strings.Join(prompts, "\n---\n")
+ s.saveFramePrompt(req.StoryboardID, string(req.FrameType), combinedPrompt, "分镜板组合提示词", response.MultiFrame.Layout)
+ case FrameTypeAction:
+ response.MultiFrame = s.generateActionSequence(storyboard, scene)
+ var prompts []string
+ for _, frame := range response.MultiFrame.Frames {
+ prompts = append(prompts, frame.Prompt)
+ }
+ combinedPrompt := strings.Join(prompts, "\n---\n")
+ s.saveFramePrompt(req.StoryboardID, string(req.FrameType), combinedPrompt, "动作序列组合提示词", response.MultiFrame.Layout)
+ default:
+ return nil, fmt.Errorf("unsupported frame type: %s", req.FrameType)
+ }
+
+ return response, nil
+}
+
+// saveFramePrompt 保存帧提示词到数据库
+func (s *FramePromptService) saveFramePrompt(storyboardID, frameType, prompt, description, layout string) {
+ framePrompt := models.FramePrompt{
+ StoryboardID: uint(mustParseUint(storyboardID)),
+ FrameType: frameType,
+ Prompt: prompt,
+ }
+
+ if description != "" {
+ framePrompt.Description = &description
+ }
+ if layout != "" {
+ framePrompt.Layout = &layout
+ }
+
+ // 先删除同类型的旧记录(保持最新)
+ s.db.Where("storyboard_id = ? AND frame_type = ?", storyboardID, frameType).Delete(&models.FramePrompt{})
+
+ // 插入新记录
+ if err := s.db.Create(&framePrompt).Error; err != nil {
+ s.log.Warnw("Failed to save frame prompt", "error", err, "storyboard_id", storyboardID, "frame_type", frameType)
+ }
+}
+
+// mustParseUint 辅助函数
+func mustParseUint(s string) uint64 {
+ var result uint64
+ fmt.Sscanf(s, "%d", &result)
+ return result
+}
+
+// generateFirstFrame 生成首帧提示词
+func (s *FramePromptService) generateFirstFrame(sb models.Storyboard, scene *models.Scene) *SingleFramePrompt {
+ // 构建上下文信息
+ contextInfo := s.buildStoryboardContext(sb, scene)
+
+ // 构建AI提示词
+ systemPrompt := `你是一个专业的图像生成提示词专家。请根据提供的镜头信息,生成适合用于AI图像生成的提示词。
+
+重要:这是镜头的首帧 - 一个完全静态的画面,展示动作发生之前的初始状态。
+
+要求:
+1. 直接输出提示词,不要任何解释说明
+2. 可以使用中文或英文,用逗号分隔关键词
+3. 只描述静态视觉元素:场景环境、角色姿态、表情、氛围、光线
+4. 不要包含任何动作动词(如:猛然、弹起、坐直、抓住等)
+5. 描述角色处于动作发生前的状态(如:躺在床上、站立、坐着等静态姿态)
+6. 适合动画风格(anime style)
+
+示例格式:
+Anime style, 城市公寓卧室, 凌晨, 昏暗房间, 床上, 年轻男子躺着, 表情平静, 闭眼睡眠, 柔和光线, 静谧氛围, 中景, 平视`
+
+ userPrompt := fmt.Sprintf(`镜头信息:
+%s
+
+请直接生成首帧的图像提示词,不要任何解释:`, contextInfo)
+
+ // 调用AI生成
+ prompt, err := s.aiService.GenerateText(userPrompt, systemPrompt)
+ if err != nil {
+ s.log.Warnw("AI generation failed, using fallback", "error", err)
+ // 降级方案:使用简单拼接
+ prompt = s.buildFallbackPrompt(sb, scene, "first frame, static shot")
+ }
+
+ // 如果AI返回空字符串,使用降级方案
+ prompt = strings.TrimSpace(prompt)
+ if prompt == "" {
+ s.log.Warnw("AI returned empty prompt, using fallback", "storyboard_id", sb.ID)
+ prompt = s.buildFallbackPrompt(sb, scene, "first frame, static shot")
+ }
+
+ return &SingleFramePrompt{
+ Prompt: prompt,
+ Description: "镜头开始的静态画面,展示初始状态",
+ }
+}
+
+// generateKeyFrame 生成关键帧提示词
+func (s *FramePromptService) generateKeyFrame(sb models.Storyboard, scene *models.Scene) *SingleFramePrompt {
+ // 构建上下文信息
+ contextInfo := s.buildStoryboardContext(sb, scene)
+
+ // 构建AI提示词
+ systemPrompt := `你是一个专业的图像生成提示词专家。请根据提供的镜头信息,生成适合用于AI图像生成的提示词。
+
+重要:这是镜头的关键帧 - 捕捉动作最激烈、最精彩的瞬间。
+
+要求:
+1. 直接输出提示词,不要任何解释说明
+2. 可以使用中文或英文,用逗号分隔关键词
+3. 重点描述动作的高潮瞬间:身体姿态、运动轨迹、力量感
+4. 包含动态元素:动作模糊、速度线、冲击感
+5. 强调表情和情绪的极致状态
+6. 适合动画风格(anime style)
+
+示例格式:
+Anime style, 城市街道, 白天, 男子全力冲刺, 身体前倾, 动作模糊, 速度线, 汗水飞溅, 表情坚毅, 紧张氛围, 动态镜头, 中景`
+
+ userPrompt := fmt.Sprintf(`镜头信息:
+%s
+
+请直接生成关键帧的图像提示词,不要任何解释:`, contextInfo)
+
+ // 调用AI生成
+ prompt, err := s.aiService.GenerateText(userPrompt, systemPrompt)
+ if err != nil {
+ s.log.Warnw("AI generation failed, using fallback", "error", err)
+ prompt = s.buildFallbackPrompt(sb, scene, "key frame, dynamic action")
+ }
+
+ // 如果AI返回空字符串,使用降级方案
+ prompt = strings.TrimSpace(prompt)
+ if prompt == "" {
+ s.log.Warnw("AI returned empty prompt, using fallback", "storyboard_id", sb.ID)
+ prompt = s.buildFallbackPrompt(sb, scene, "key frame, dynamic action")
+ }
+
+ return &SingleFramePrompt{
+ Prompt: prompt,
+ Description: "动作高潮瞬间,展示关键动作",
+ }
+}
+
+// generateLastFrame 生成尾帧提示词
+func (s *FramePromptService) generateLastFrame(sb models.Storyboard, scene *models.Scene) *SingleFramePrompt {
+ // 构建上下文信息
+ contextInfo := s.buildStoryboardContext(sb, scene)
+
+ // 构建AI提示词
+ systemPrompt := `你是一个专业的图像生成提示词专家。请根据提供的镜头信息,生成适合用于AI图像生成的提示词。
+
+重要:这是镜头的尾帧 - 一个静态画面,展示动作结束后的最终状态和结果。
+
+要求:
+1. 直接输出提示词,不要任何解释说明
+2. 可以使用中文或英文,用逗号分隔关键词
+3. 只描述静态的最终状态:角色姿态、表情、环境变化
+4. 不要包含动作过程,只展示动作的结果和余韵
+5. 强调情绪的余波和氛围的沉淀
+6. 适合动画风格(anime style)
+
+示例格式:
+Anime style, 房间内, 黄昏, 男子坐在椅子上, 身体放松, 表情疲惫, 长出一口气, 汗水滴落, 平静氛围, 静态镜头, 中景`
+
+ userPrompt := fmt.Sprintf(`镜头信息:
+%s
+
+请直接生成尾帧的图像提示词,不要任何解释:`, contextInfo)
+
+ // 调用AI生成
+ prompt, err := s.aiService.GenerateText(userPrompt, systemPrompt)
+ if err != nil {
+ s.log.Warnw("AI generation failed, using fallback", "error", err)
+ prompt = s.buildFallbackPrompt(sb, scene, "last frame, final state")
+ }
+
+ // 如果AI返回空字符串,使用降级方案
+ prompt = strings.TrimSpace(prompt)
+ if prompt == "" {
+ s.log.Warnw("AI returned empty prompt, using fallback", "storyboard_id", sb.ID)
+ prompt = s.buildFallbackPrompt(sb, scene, "last frame, final state")
+ }
+
+ return &SingleFramePrompt{
+ Prompt: prompt,
+ Description: "镜头结束画面,展示最终状态和结果",
+ }
+}
+
+// generatePanelFrames 生成分镜板(多格组合)
+func (s *FramePromptService) generatePanelFrames(sb models.Storyboard, scene *models.Scene, count int) *MultiFramePrompt {
+ layout := fmt.Sprintf("horizontal_%d", count)
+
+ frames := make([]SingleFramePrompt, count)
+
+ // 固定生成:首帧 -> 关键帧 -> 尾帧
+ if count == 3 {
+ frames[0] = *s.generateFirstFrame(sb, scene)
+ frames[0].Description = "第1格:初始状态"
+
+ frames[1] = *s.generateKeyFrame(sb, scene)
+ frames[1].Description = "第2格:动作高潮"
+
+ frames[2] = *s.generateLastFrame(sb, scene)
+ frames[2].Description = "第3格:最终状态"
+ } else if count == 4 {
+ // 4格:首帧 -> 中间帧1 -> 中间帧2 -> 尾帧
+ frames[0] = *s.generateFirstFrame(sb, scene)
+ frames[1] = *s.generateKeyFrame(sb, scene)
+ frames[2] = *s.generateKeyFrame(sb, scene)
+ frames[3] = *s.generateLastFrame(sb, scene)
+ }
+
+ return &MultiFramePrompt{
+ Layout: layout,
+ Frames: frames,
+ }
+}
+
+// generateActionSequence 生成动作序列(5-8格)
+func (s *FramePromptService) generateActionSequence(sb models.Storyboard, scene *models.Scene) *MultiFramePrompt {
+ // 将动作分解为5个步骤
+ frames := make([]SingleFramePrompt, 5)
+
+ // 简化实现:均匀分布从首帧到尾帧
+ frames[0] = *s.generateFirstFrame(sb, scene)
+ frames[1] = *s.generateKeyFrame(sb, scene)
+ frames[2] = *s.generateKeyFrame(sb, scene)
+ frames[3] = *s.generateKeyFrame(sb, scene)
+ frames[4] = *s.generateLastFrame(sb, scene)
+
+ return &MultiFramePrompt{
+ Layout: "horizontal_5",
+ Frames: frames,
+ }
+}
+
+// buildStoryboardContext 构建镜头上下文信息
+func (s *FramePromptService) buildStoryboardContext(sb models.Storyboard, scene *models.Scene) string {
+ var parts []string
+
+ // 镜头描述(最重要)
+ if sb.Description != nil && *sb.Description != "" {
+ parts = append(parts, fmt.Sprintf("镜头描述: %s", *sb.Description))
+ }
+
+ // 场景信息
+ if scene != nil {
+ parts = append(parts, fmt.Sprintf("场景: %s, %s", scene.Location, scene.Time))
+ } else if sb.Location != nil && sb.Time != nil {
+ parts = append(parts, fmt.Sprintf("场景: %s, %s", *sb.Location, *sb.Time))
+ }
+
+ // 角色
+ if len(sb.Characters) > 0 {
+ var charNames []string
+ for _, char := range sb.Characters {
+ charNames = append(charNames, char.Name)
+ }
+ parts = append(parts, fmt.Sprintf("角色: %s", strings.Join(charNames, ", ")))
+ }
+
+ // 动作
+ if sb.Action != nil && *sb.Action != "" {
+ parts = append(parts, fmt.Sprintf("动作: %s", *sb.Action))
+ }
+
+ // 结果
+ if sb.Result != nil && *sb.Result != "" {
+ parts = append(parts, fmt.Sprintf("结果: %s", *sb.Result))
+ }
+
+ // 对白
+ if sb.Dialogue != nil && *sb.Dialogue != "" {
+ parts = append(parts, fmt.Sprintf("对白: %s", *sb.Dialogue))
+ }
+
+ // 氛围
+ if sb.Atmosphere != nil && *sb.Atmosphere != "" {
+ parts = append(parts, fmt.Sprintf("氛围: %s", *sb.Atmosphere))
+ }
+
+ // 镜头参数
+ if sb.ShotType != nil {
+ parts = append(parts, fmt.Sprintf("景别: %s", *sb.ShotType))
+ }
+ if sb.Angle != nil {
+ parts = append(parts, fmt.Sprintf("角度: %s", *sb.Angle))
+ }
+ if sb.Movement != nil {
+ parts = append(parts, fmt.Sprintf("运镜: %s", *sb.Movement))
+ }
+
+ return strings.Join(parts, "\n")
+}
+
+// buildFallbackPrompt 构建降级提示词(AI失败时使用)
+func (s *FramePromptService) buildFallbackPrompt(sb models.Storyboard, scene *models.Scene, suffix string) string {
+ var parts []string
+
+ // 场景
+ if scene != nil {
+ parts = append(parts, fmt.Sprintf("%s, %s", scene.Location, scene.Time))
+ }
+
+ // 角色
+ if len(sb.Characters) > 0 {
+ for _, char := range sb.Characters {
+ parts = append(parts, char.Name)
+ }
+ }
+
+ // 氛围
+ if sb.Atmosphere != nil {
+ parts = append(parts, *sb.Atmosphere)
+ }
+
+ parts = append(parts, "anime style", suffix)
+ return strings.Join(parts, ", ")
+}
diff --git a/application/services/image_generation_service.go b/application/services/image_generation_service.go
new file mode 100644
index 0000000..a67ad6f
--- /dev/null
+++ b/application/services/image_generation_service.go
@@ -0,0 +1,984 @@
+package services
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ models "github.com/drama-generator/backend/domain/models"
+ "github.com/drama-generator/backend/infrastructure/storage"
+ "github.com/drama-generator/backend/pkg/ai"
+ "github.com/drama-generator/backend/pkg/image"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/utils"
+ "gorm.io/gorm"
+)
+
+type ImageGenerationService struct {
+ db *gorm.DB
+ aiService *AIService
+ transferService *ResourceTransferService
+ localStorage *storage.LocalStorage
+ log *logger.Logger
+}
+
+// truncateImageURL 截断图片 URL,避免 base64 格式的 URL 占满日志
+func truncateImageURL(url string) string {
+ if url == "" {
+ return ""
+ }
+ // 如果是 data URI 格式(base64),只显示前缀
+ if strings.HasPrefix(url, "data:") {
+ if len(url) > 50 {
+ return url[:50] + "...[base64 data]"
+ }
+ }
+ // 普通 URL 如果过长也截断
+ if len(url) > 100 {
+ return url[:100] + "..."
+ }
+ return url
+}
+
+func NewImageGenerationService(db *gorm.DB, transferService *ResourceTransferService, localStorage *storage.LocalStorage, log *logger.Logger) *ImageGenerationService {
+ return &ImageGenerationService{
+ db: db,
+ aiService: NewAIService(db, log),
+ transferService: transferService,
+ localStorage: localStorage,
+ log: log,
+ }
+}
+
+// GetDB 获取数据库连接
+func (s *ImageGenerationService) GetDB() *gorm.DB {
+ return s.db
+}
+
+type GenerateImageRequest struct {
+ StoryboardID *uint `json:"storyboard_id"`
+ DramaID string `json:"drama_id" binding:"required"`
+ SceneID *uint `json:"scene_id"`
+ CharacterID *uint `json:"character_id"`
+ ImageType string `json:"image_type"` // character, scene, storyboard
+ FrameType *string `json:"frame_type"` // first, key, last, panel, action
+ Prompt string `json:"prompt" binding:"required,min=5,max=2000"`
+ NegativePrompt *string `json:"negative_prompt"`
+ Provider string `json:"provider"`
+ Model string `json:"model"`
+ Size string `json:"size"`
+ Quality string `json:"quality"`
+ Style *string `json:"style"`
+ Steps *int `json:"steps"`
+ CfgScale *float64 `json:"cfg_scale"`
+ Seed *int64 `json:"seed"`
+ Width *int `json:"width"`
+ Height *int `json:"height"`
+ ReferenceImages []string `json:"reference_images"` // 参考图片URL列表
+}
+
+func (s *ImageGenerationService) GenerateImage(request *GenerateImageRequest) (*models.ImageGeneration, error) {
+ var drama models.Drama
+ if err := s.db.Where("id = ? ", request.DramaID).First(&drama).Error; err != nil {
+ return nil, fmt.Errorf("drama not found")
+ }
+
+ // 注意:SceneID可能指向Scene或Storyboard表,调用方已经做过权限验证,这里不再重复验证
+
+ provider := request.Provider
+ if provider == "" {
+ provider = "openai"
+ }
+
+ // 序列化参考图片
+ var referenceImagesJSON []byte
+ if len(request.ReferenceImages) > 0 {
+ referenceImagesJSON, _ = json.Marshal(request.ReferenceImages)
+ }
+
+ // 转换DramaID
+ dramaIDParsed, err := strconv.ParseUint(request.DramaID, 10, 32)
+ if err != nil {
+ return nil, fmt.Errorf("invalid drama ID")
+ }
+
+ // 设置默认图片类型
+ imageType := request.ImageType
+ if imageType == "" {
+ imageType = string(models.ImageTypeStoryboard)
+ }
+
+ imageGen := &models.ImageGeneration{
+ StoryboardID: request.StoryboardID,
+ DramaID: uint(dramaIDParsed),
+ SceneID: request.SceneID,
+ CharacterID: request.CharacterID,
+ ImageType: imageType,
+ FrameType: request.FrameType,
+ Provider: provider,
+ Prompt: request.Prompt,
+ NegPrompt: request.NegativePrompt,
+ Model: request.Model,
+ Size: request.Size,
+ ReferenceImages: referenceImagesJSON,
+ Quality: request.Quality,
+ Style: request.Style,
+ Steps: request.Steps,
+ CfgScale: request.CfgScale,
+ Seed: request.Seed,
+ Width: request.Width,
+ Height: request.Height,
+ Status: models.ImageStatusPending,
+ }
+
+ if err := s.db.Create(imageGen).Error; err != nil {
+ return nil, fmt.Errorf("failed to create record: %w", err)
+ }
+
+ go s.ProcessImageGeneration(imageGen.ID)
+
+ return imageGen, nil
+}
+
+func (s *ImageGenerationService) ProcessImageGeneration(imageGenID uint) {
+ var imageGen models.ImageGeneration
+ if err := s.db.First(&imageGen, imageGenID).Error; err != nil {
+ s.log.Errorw("Failed to load image generation", "error", err, "id", imageGenID)
+ return
+ }
+
+ s.db.Model(&imageGen).Update("status", models.ImageStatusProcessing)
+
+ // 如果关联了background,同步更新background为generating状态
+ if imageGen.StoryboardID != nil {
+ if err := s.db.Model(&models.Scene{}).Where("id = ?", *imageGen.StoryboardID).Update("status", "generating").Error; err != nil {
+ s.log.Warnw("Failed to update background status to generating", "scene_id", *imageGen.StoryboardID, "error", err)
+ } else {
+ s.log.Infow("Background status updated to generating", "scene_id", *imageGen.StoryboardID)
+ }
+ }
+
+ client, err := s.getImageClientWithModel(imageGen.Provider, imageGen.Model)
+ if err != nil {
+ s.log.Errorw("Failed to get image client", "error", err, "provider", imageGen.Provider, "model", imageGen.Model)
+ s.updateImageGenError(imageGenID, err.Error())
+ return
+ }
+
+ // 解析参考图片
+ var referenceImages []string
+ if len(imageGen.ReferenceImages) > 0 {
+ if err := json.Unmarshal(imageGen.ReferenceImages, &referenceImages); err == nil {
+ s.log.Infow("Using reference images for generation",
+ "id", imageGenID,
+ "reference_count", len(referenceImages),
+ "references", referenceImages)
+ }
+ }
+
+ s.log.Infow("Starting image generation", "id", imageGenID, "prompt", imageGen.Prompt, "provider", imageGen.Provider)
+
+ var opts []image.ImageOption
+ if imageGen.NegPrompt != nil && *imageGen.NegPrompt != "" {
+ opts = append(opts, image.WithNegativePrompt(*imageGen.NegPrompt))
+ }
+ if imageGen.Size != "" {
+ opts = append(opts, image.WithSize(imageGen.Size))
+ }
+ if imageGen.Quality != "" {
+ opts = append(opts, image.WithQuality(imageGen.Quality))
+ }
+ if imageGen.Style != nil && *imageGen.Style != "" {
+ opts = append(opts, image.WithStyle(*imageGen.Style))
+ }
+ if imageGen.Steps != nil {
+ opts = append(opts, image.WithSteps(*imageGen.Steps))
+ }
+ if imageGen.CfgScale != nil {
+ opts = append(opts, image.WithCfgScale(*imageGen.CfgScale))
+ }
+ if imageGen.Seed != nil {
+ opts = append(opts, image.WithSeed(*imageGen.Seed))
+ }
+ if imageGen.Model != "" {
+ opts = append(opts, image.WithModel(imageGen.Model))
+ }
+ if imageGen.Width != nil && imageGen.Height != nil {
+ opts = append(opts, image.WithDimensions(*imageGen.Width, *imageGen.Height))
+ }
+ // 添加参考图片
+ if len(referenceImages) > 0 {
+ opts = append(opts, image.WithReferenceImages(referenceImages))
+ }
+
+ result, err := client.GenerateImage(imageGen.Prompt, opts...)
+ if err != nil {
+ s.log.Errorw("Image generation API call failed", "error", err, "id", imageGenID, "prompt", imageGen.Prompt)
+ s.updateImageGenError(imageGenID, err.Error())
+ return
+ }
+
+ s.log.Infow("Image generation API call completed", "id", imageGenID, "completed", result.Completed, "has_url", result.ImageURL != "")
+
+ if !result.Completed {
+ s.db.Model(&imageGen).Updates(map[string]interface{}{
+ "status": models.ImageStatusProcessing,
+ "task_id": result.TaskID,
+ })
+ go s.pollTaskStatus(imageGenID, client, result.TaskID)
+ return
+ }
+
+ s.completeImageGeneration(imageGenID, result)
+}
+
+func (s *ImageGenerationService) pollTaskStatus(imageGenID uint, client image.ImageClient, taskID string) {
+ maxAttempts := 60
+ pollInterval := 5 * time.Second
+
+ for i := 0; i < maxAttempts; i++ {
+ time.Sleep(pollInterval)
+
+ result, err := client.GetTaskStatus(taskID)
+ if err != nil {
+ s.log.Errorw("Failed to get task status", "error", err, "task_id", taskID)
+ continue
+ }
+
+ if result.Completed {
+ s.completeImageGeneration(imageGenID, result)
+ return
+ }
+
+ if result.Error != "" {
+ s.updateImageGenError(imageGenID, result.Error)
+ return
+ }
+ }
+
+ s.updateImageGenError(imageGenID, "timeout: image generation took too long")
+}
+
+func (s *ImageGenerationService) completeImageGeneration(imageGenID uint, result *image.ImageResult) {
+ now := time.Now()
+
+ // 下载图片到本地存储(仅用于缓存,不更新数据库)
+ // 仅下载 HTTP/HTTPS URL,跳过 data URI
+ if s.localStorage != nil && result.ImageURL != "" &&
+ (strings.HasPrefix(result.ImageURL, "http://") || strings.HasPrefix(result.ImageURL, "https://")) {
+ _, err := s.localStorage.DownloadFromURL(result.ImageURL, "images")
+ if err != nil {
+ errStr := err.Error()
+ if len(errStr) > 200 {
+ errStr = errStr[:200] + "..."
+ }
+ s.log.Warnw("Failed to download image to local storage",
+ "error", errStr,
+ "id", imageGenID,
+ "original_url", truncateImageURL(result.ImageURL))
+ } else {
+ s.log.Infow("Image downloaded to local storage for caching",
+ "id", imageGenID,
+ "original_url", truncateImageURL(result.ImageURL))
+ }
+ }
+
+ // 数据库中保持使用原始URL
+ updates := map[string]interface{}{
+ "status": models.ImageStatusCompleted,
+ "image_url": result.ImageURL,
+ "completed_at": now,
+ }
+
+ if result.Width > 0 {
+ updates["width"] = result.Width
+ }
+ if result.Height > 0 {
+ updates["height"] = result.Height
+ }
+
+ // 更新image_generation记录
+ var imageGen models.ImageGeneration
+ if err := s.db.Where("id = ?", imageGenID).First(&imageGen).Error; err != nil {
+ s.log.Errorw("Failed to load image generation", "error", err, "id", imageGenID)
+ return
+ }
+
+ s.db.Model(&models.ImageGeneration{}).Where("id = ?", imageGenID).Updates(updates)
+ s.log.Infow("Image generation completed", "id", imageGenID)
+
+ // 如果关联了storyboard,同步更新storyboard的composed_image
+ if imageGen.StoryboardID != nil {
+ if err := s.db.Model(&models.Storyboard{}).Where("id = ?", *imageGen.StoryboardID).Update("composed_image", result.ImageURL).Error; err != nil {
+ s.log.Errorw("Failed to update storyboard composed_image", "error", err, "storyboard_id", *imageGen.StoryboardID)
+ } else {
+ s.log.Infow("Storyboard updated with composed image",
+ "storyboard_id", *imageGen.StoryboardID,
+ "composed_image", truncateImageURL(result.ImageURL))
+ }
+ }
+
+ // 如果关联了scene,同步更新scene的image_url和status(仅当ImageType是scene时)
+ if imageGen.SceneID != nil && imageGen.ImageType == string(models.ImageTypeScene) {
+ sceneUpdates := map[string]interface{}{
+ "status": "generated",
+ "image_url": result.ImageURL,
+ }
+ if err := s.db.Model(&models.Scene{}).Where("id = ?", *imageGen.SceneID).Updates(sceneUpdates).Error; err != nil {
+ s.log.Errorw("Failed to update scene", "error", err, "scene_id", *imageGen.SceneID)
+ } else {
+ s.log.Infow("Scene updated with generated image",
+ "scene_id", *imageGen.SceneID,
+ "image_url", truncateImageURL(result.ImageURL))
+ }
+ }
+
+ // 如果关联了角色,同步更新角色的image_url
+ if imageGen.CharacterID != nil {
+ if err := s.db.Model(&models.Character{}).Where("id = ?", *imageGen.CharacterID).Update("image_url", result.ImageURL).Error; err != nil {
+ s.log.Errorw("Failed to update character image_url", "error", err, "character_id", *imageGen.CharacterID)
+ } else {
+ s.log.Infow("Character updated with generated image",
+ "character_id", *imageGen.CharacterID,
+ "image_url", truncateImageURL(result.ImageURL))
+ }
+ }
+}
+
+func (s *ImageGenerationService) updateImageGenError(imageGenID uint, errorMsg string) {
+ // 先获取image_generation记录
+ var imageGen models.ImageGeneration
+ if err := s.db.Where("id = ?", imageGenID).First(&imageGen).Error; err != nil {
+ s.log.Errorw("Failed to load image generation", "error", err, "id", imageGenID)
+ return
+ }
+
+ // 更新image_generation状态
+ s.db.Model(&models.ImageGeneration{}).Where("id = ?", imageGenID).Updates(map[string]interface{}{
+ "status": models.ImageStatusFailed,
+ "error_msg": errorMsg,
+ })
+ s.log.Errorw("Image generation failed", "id", imageGenID, "error", errorMsg)
+
+ // 如果关联了scene,同步更新scene为失败状态
+ if imageGen.SceneID != nil {
+ s.db.Model(&models.Scene{}).Where("id = ?", *imageGen.SceneID).Update("status", "failed")
+ s.log.Warnw("Scene marked as failed", "scene_id", *imageGen.SceneID)
+ }
+}
+
+func (s *ImageGenerationService) getImageClient(provider string) (image.ImageClient, error) {
+ config, err := s.aiService.GetDefaultConfig("image")
+ if err != nil {
+ return nil, fmt.Errorf("no image AI config found: %w", err)
+ }
+
+ // 使用第一个模型
+ model := ""
+ if len(config.Model) > 0 {
+ model = config.Model[0]
+ }
+
+ // 使用配置中的 provider,如果没有则使用传入的 provider
+ actualProvider := config.Provider
+ if actualProvider == "" {
+ actualProvider = provider
+ }
+
+ // 根据 provider 自动设置默认端点
+ var endpoint string
+ var queryEndpoint string
+
+ switch actualProvider {
+ case "openai", "dalle":
+ endpoint = "/images/generations"
+ return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil
+ case "chatfire":
+ endpoint = "/images/generations"
+ return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil
+ case "volcengine", "volces", "doubao":
+ endpoint = "/images/generations"
+ queryEndpoint = ""
+ return image.NewVolcEngineImageClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil
+ case "gemini", "google":
+ endpoint = "/v1beta/models/{model}:generateContent"
+ return image.NewGeminiImageClient(config.BaseURL, config.APIKey, model, endpoint), nil
+ default:
+ endpoint = "/images/generations"
+ return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil
+ }
+}
+
+// getImageClientWithModel 根据模型名称获取图片客户端
+func (s *ImageGenerationService) getImageClientWithModel(provider string, modelName string) (image.ImageClient, error) {
+ var config *models.AIServiceConfig
+ var err error
+
+ // 如果指定了模型,尝试获取对应的配置
+ if modelName != "" {
+ config, err = s.aiService.GetConfigForModel("image", modelName)
+ if err != nil {
+ s.log.Warnw("Failed to get config for model, using default", "model", modelName, "error", err)
+ config, err = s.aiService.GetDefaultConfig("image")
+ if err != nil {
+ return nil, fmt.Errorf("no image AI config found: %w", err)
+ }
+ }
+ } else {
+ config, err = s.aiService.GetDefaultConfig("image")
+ if err != nil {
+ return nil, fmt.Errorf("no image AI config found: %w", err)
+ }
+ }
+
+ // 使用指定的模型或配置中的第一个模型
+ model := modelName
+ if model == "" && len(config.Model) > 0 {
+ model = config.Model[0]
+ }
+
+ // 使用配置中的 provider,如果没有则使用传入的 provider
+ actualProvider := config.Provider
+ if actualProvider == "" {
+ actualProvider = provider
+ }
+
+ // 根据 provider 自动设置默认端点
+ var endpoint string
+ var queryEndpoint string
+
+ switch actualProvider {
+ case "openai", "dalle":
+ endpoint = "/images/generations"
+ return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil
+ case "chatfire":
+ endpoint = "/images/generations"
+ return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil
+ case "volcengine", "volces", "doubao":
+ endpoint = "/images/generations"
+ queryEndpoint = ""
+ return image.NewVolcEngineImageClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil
+ case "gemini", "google":
+ endpoint = "/v1beta/models/{model}:generateContent"
+ return image.NewGeminiImageClient(config.BaseURL, config.APIKey, model, endpoint), nil
+ default:
+ endpoint = "/images/generations"
+ return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil
+ }
+}
+
+func (s *ImageGenerationService) GetImageGeneration(imageGenID uint) (*models.ImageGeneration, error) {
+ var imageGen models.ImageGeneration
+ if err := s.db.Where("id = ? ", imageGenID).First(&imageGen).Error; err != nil {
+ return nil, err
+ }
+ return &imageGen, nil
+}
+
+func (s *ImageGenerationService) ListImageGenerations(dramaID *uint, sceneID *uint, storyboardID *uint, frameType string, status string, page, pageSize int) ([]models.ImageGeneration, int64, error) {
+ query := s.db.Model(&models.ImageGeneration{})
+
+ if dramaID != nil {
+ query = query.Where("drama_id = ?", *dramaID)
+ }
+
+ if sceneID != nil {
+ query = query.Where("scene_id = ?", *sceneID)
+ }
+
+ if storyboardID != nil {
+ query = query.Where("storyboard_id = ?", *storyboardID)
+ }
+
+ if frameType != "" {
+ query = query.Where("frame_type = ?", frameType)
+ }
+
+ if status != "" {
+ query = query.Where("status = ?", status)
+ }
+
+ var total int64
+ if err := query.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+
+ var images []models.ImageGeneration
+ offset := (page - 1) * pageSize
+ if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&images).Error; err != nil {
+ return nil, 0, err
+ }
+
+ return images, total, nil
+}
+
+func (s *ImageGenerationService) DeleteImageGeneration(imageGenID uint) error {
+ result := s.db.Where("id = ? ", imageGenID).Delete(&models.ImageGeneration{})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return fmt.Errorf("image generation not found")
+ }
+ return nil
+}
+
+func (s *ImageGenerationService) GenerateImagesForScene(sceneID string) ([]*models.ImageGeneration, error) {
+ // 转换sceneID
+ sid, err := strconv.ParseUint(sceneID, 10, 32)
+ if err != nil {
+ return nil, fmt.Errorf("invalid scene ID")
+ }
+ sceneIDUint := uint(sid)
+
+ var scene models.Scene
+ if err := s.db.Where("id = ?", sceneIDUint).First(&scene).Error; err != nil {
+ return nil, fmt.Errorf("scene not found")
+ }
+
+ // 构建场景图片生成提示词
+ prompt := scene.Prompt
+ if prompt == "" {
+ // 如果Prompt为空,使用Location和Time构建
+ prompt = fmt.Sprintf("%s场景,%s", scene.Location, scene.Time)
+ }
+
+ req := &GenerateImageRequest{
+ SceneID: &sceneIDUint,
+ DramaID: fmt.Sprintf("%d", scene.DramaID),
+ ImageType: string(models.ImageTypeScene),
+ Prompt: prompt,
+ }
+
+ imageGen, err := s.GenerateImage(req)
+ if err != nil {
+ return nil, err
+ }
+
+ return []*models.ImageGeneration{imageGen}, nil
+}
+
+// BackgroundInfo 背景信息结构
+type BackgroundInfo struct {
+ Location string `json:"location"`
+ Time string `json:"time"`
+ Atmosphere string `json:"atmosphere"`
+ Prompt string `json:"prompt"`
+ StoryboardNumbers []int `json:"storyboard_numbers"`
+ SceneIDs []uint `json:"scene_ids"`
+ StoryboardCount int `json:"scene_count"`
+}
+
+func (s *ImageGenerationService) BatchGenerateImagesForEpisode(episodeID string) ([]*models.ImageGeneration, error) {
+ var ep models.Episode
+ if err := s.db.Preload("Drama").Where("id = ?", episodeID).First(&ep).Error; err != nil {
+ return nil, fmt.Errorf("episode not found")
+ }
+ // 从数据库读取已保存的场景
+ var scenes []models.Storyboard
+ if err := s.db.Where("episode_id = ?", episodeID).Find(&scenes).Error; err != nil {
+ return nil, fmt.Errorf("failed to get scenes: %w", err)
+ }
+
+ backgrounds := s.extractUniqueBackgrounds(scenes)
+ s.log.Infow("Extracted unique backgrounds",
+ "episode_id", episodeID,
+ "background_count", len(backgrounds))
+
+ // 为每个背景生成图片
+ var results []*models.ImageGeneration
+ for _, bg := range scenes {
+ if bg.ImagePrompt == nil || *bg.ImagePrompt == "" {
+ s.log.Warnw("Background has no prompt, skipping", "scene_id", bg.ID)
+ continue
+ }
+
+ // 更新背景状态为处理中
+ s.db.Model(bg).Update("status", "generating")
+
+ req := &GenerateImageRequest{
+ StoryboardID: &bg.ID,
+ DramaID: fmt.Sprintf("%d", ep.DramaID),
+ Prompt: *bg.ImagePrompt,
+ }
+
+ imageGen, err := s.GenerateImage(req)
+ if err != nil {
+ s.log.Errorw("Failed to generate image for background",
+ "scene_id", bg.ID,
+ "location", bg.Location,
+ "error", err)
+ s.db.Model(bg).Update("status", "failed")
+ continue
+ }
+
+ s.log.Infow("Background image generation started",
+ "scene_id", bg.ID,
+ "image_gen_id", imageGen.ID,
+ "location", bg.Location,
+ "time", bg.Time)
+
+ results = append(results, imageGen)
+ }
+
+ return results, nil
+}
+
+// GetScencesForEpisode 获取项目的场景列表(项目级)
+func (s *ImageGenerationService) GetScencesForEpisode(episodeID string) ([]*models.Scene, error) {
+ var episode models.Episode
+ if err := s.db.Preload("Drama").Where("id = ?", episodeID).First(&episode).Error; err != nil {
+ return nil, fmt.Errorf("episode not found")
+ }
+
+ // 场景是项目级的,通过drama_id查询
+ var scenes []*models.Scene
+ if err := s.db.Where("drama_id = ?", episode.DramaID).Order("location ASC, time ASC").Find(&scenes).Error; err != nil {
+ return nil, fmt.Errorf("failed to load scenes: %w", err)
+ }
+
+ return scenes, nil
+}
+
+// ExtractBackgroundsForEpisode 从剧本内容中提取场景并保存到项目级别数据库
+func (s *ImageGenerationService) ExtractBackgroundsForEpisode(episodeID string) ([]*models.Scene, error) {
+ var episode models.Episode
+ if err := s.db.Preload("Drama").Where("id = ?", episodeID).First(&episode).Error; err != nil {
+ return nil, fmt.Errorf("episode not found")
+ }
+
+ // 检查是否有剧本内容
+ if episode.ScriptContent == nil || *episode.ScriptContent == "" {
+ return nil, fmt.Errorf("剧本内容为空,无法提取场景")
+ }
+
+ dramaID := episode.DramaID
+
+ // 使用AI从剧本内容中提取场景
+ backgroundsInfo, err := s.extractBackgroundsFromScript(*episode.ScriptContent, dramaID)
+ if err != nil {
+ s.log.Errorw("Failed to extract backgrounds from script", "error", err)
+ return nil, err
+ }
+
+ // 保存到数据库(不涉及Storyboard关联,因为此时还没有生成分镜)
+ var scenes []*models.Scene
+ err = s.db.Transaction(func(tx *gorm.DB) error {
+ // 先删除该章节的所有场景(实现重新提取覆盖功能)
+ if err := tx.Where("episode_id = ?", episode.ID).Delete(&models.Scene{}).Error; err != nil {
+ s.log.Errorw("Failed to delete old scenes", "error", err)
+ return err
+ }
+ s.log.Infow("Deleted old scenes for re-extraction", "episode_id", episode.ID)
+
+ // 创建新提取的场景
+ for _, bgInfo := range backgroundsInfo {
+ // 保存新场景到数据库(章节级)
+ episodeIDVal := episode.ID
+ scene := &models.Scene{
+ DramaID: dramaID,
+ EpisodeID: &episodeIDVal,
+ Location: bgInfo.Location,
+ Time: bgInfo.Time,
+ Prompt: bgInfo.Prompt,
+ StoryboardCount: 1, // 默认为1
+ Status: "pending",
+ }
+ if err := tx.Create(scene).Error; err != nil {
+ return err
+ }
+ scenes = append(scenes, scene)
+
+ s.log.Infow("Created new scene from script",
+ "scene_id", scene.ID,
+ "location", scene.Location,
+ "time", scene.Time)
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ s.log.Infow("Saved scenes to database",
+ "episode_id", episodeID,
+ "total_storyboards", len(episode.Storyboards),
+ "unique_scenes", len(scenes))
+
+ return scenes, nil
+}
+
+// extractBackgroundsFromScript 从剧本内容中使用AI提取场景信息
+func (s *ImageGenerationService) extractBackgroundsFromScript(scriptContent string, dramaID uint) ([]BackgroundInfo, error) {
+ if scriptContent == "" {
+ return []BackgroundInfo{}, nil
+ }
+
+ // 获取AI客户端
+ client, err := s.aiService.GetAIClient("text")
+ if err != nil {
+ return nil, fmt.Errorf("failed to get AI client: %w", err)
+ }
+
+ // 构建AI提示词
+ prompt := fmt.Sprintf(`【任务】分析以下剧本内容,提取出所有需要的场景背景信息。
+
+【剧本内容】
+%s
+
+【要求】
+1. 识别剧本中所有不同的场景(地点+时间组合)
+2. 为每个场景生成详细的**中文**图片生成提示词(Prompt)
+3. **重要**:场景描述必须是**纯背景**,不能包含人物、角色、动作等元素
+4. Prompt要求:
+ - **必须使用中文**,不能包含英文字符
+ - 详细描述场景环境、建筑、物品、光线、氛围等
+ - **禁止描述人物、角色、动作、对话等**
+ - 适合AI图片生成模型使用
+ - 风格统一为:电影感、细节丰富、动漫风格、高质量
+5. location、time、atmosphere和prompt字段都使用中文
+6. 提取场景的氛围描述(atmosphere)
+
+【输出JSON格式】
+{
+ "backgrounds": [
+ {
+ "location": "地点名称(中文)",
+ "time": "时间描述(中文)",
+ "atmosphere": "氛围描述(中文)",
+ "prompt": "一个电影感的动漫风格纯背景场景,展现[地点描述]在[时间]的环境。画面呈现[环境细节、建筑、物品、光线等,不包含人物]。风格:细节丰富,高质量,氛围光照。情绪:[环境情绪描述]。"
+ }
+ ]
+}
+
+【示例】
+正确示例(注意:不包含人物):
+{
+ "backgrounds": [
+ {
+ "location": "维修店内部",
+ "time": "深夜",
+ "atmosphere": "昏暗、孤独、工业感",
+ "prompt": "一个电影感的动漫风格纯背景场景,展现凌乱的维修店内部在深夜的环境。昏暗的日光灯照射下,工作台上散落着各种扳手、螺丝刀和机械零件,墙上挂着油污斑斑的工具挂板和褪色海报,地面有油渍痕迹,角落堆放着废旧轮胎。风格:细节丰富,高质量,昏暗氛围。情绪:孤独、工业感。"
+ },
+ {
+ "location": "城市街道",
+ "time": "黄昏",
+ "atmosphere": "温暖、繁忙、生活气息",
+ "prompt": "一个电影感的动漫风格纯背景场景,展现繁华的城市街道在黄昏时分的环境。夕阳的余晖洒在街道的沥青路面上,两旁的商铺霓虹灯开始点亮,街边有自行车停靠架和公交站牌,远处高楼林立,天空呈现橙红色渐变。风格:细节丰富,高质量,温暖氛围。情绪:生活气息、繁忙。"
+ }
+ ]
+}
+
+【错误示例(包含人物,禁止)】:
+❌ "展现主角站在街道上的场景" - 包含人物
+❌ "人们匆匆而过" - 包含人物
+❌ "角色在房间里活动" - 包含人物
+
+请严格按照JSON格式输出,确保所有字段都使用中文。`, scriptContent)
+
+ response, err := client.GenerateText(prompt, "", ai.WithTemperature(0.7), ai.WithMaxTokens(8000))
+ if err != nil {
+ s.log.Errorw("Failed to extract backgrounds with AI", "error", err)
+ return nil, fmt.Errorf("AI提取场景失败: %w", err)
+ }
+ s.log.Infow("AI backgrounds extraction response", "length", len(response))
+
+ // 解析JSON响应
+ var result struct {
+ Backgrounds []BackgroundInfo `json:"backgrounds"`
+ }
+ if err := utils.SafeParseAIJSON(response, &result); err != nil {
+ s.log.Errorw("Failed to parse AI response", "error", err, "response", response[:minInt(500, len(response))])
+ return nil, fmt.Errorf("解析AI响应失败: %w", err)
+ }
+
+ s.log.Infow("Extracted backgrounds from script",
+ "drama_id", dramaID,
+ "backgrounds_count", len(result.Backgrounds))
+
+ return result.Backgrounds, nil
+}
+
+// extractBackgroundsWithAI 使用AI智能分析场景并提取唯一背景
+func (s *ImageGenerationService) extractBackgroundsWithAI(storyboards []models.Storyboard) ([]BackgroundInfo, error) {
+ if len(storyboards) == 0 {
+ return []BackgroundInfo{}, nil
+ }
+
+ // 构建场景列表文本,使用SceneNumber而不是索引
+ var scenesText string
+ for _, storyboard := range storyboards {
+ location := ""
+ if storyboard.Location != nil {
+ location = *storyboard.Location
+ }
+ time := ""
+ if storyboard.Time != nil {
+ time = *storyboard.Time
+ }
+ action := ""
+ if storyboard.Action != nil {
+ action = *storyboard.Action
+ }
+ description := ""
+ if storyboard.Description != nil {
+ description = *storyboard.Description
+ }
+
+ scenesText += fmt.Sprintf("镜头%d:\n地点: %s\n时间: %s\n动作: %s\n描述: %s\n\n",
+ storyboard.StoryboardNumber, location, time, action, description)
+ }
+
+ // 构建AI提示词
+ prompt := fmt.Sprintf(`【任务】分析以下分镜头场景,提取出所有需要生成的唯一背景,并返回每个背景对应的场景编号。
+
+【分镜头列表】
+%s
+
+【要求】
+1. 合并相同或相似的场景背景(地点和时间相同或相近)
+2. 为每个唯一背景生成**中文**图片生成提示词(Prompt)
+3. Prompt要求:
+ - **必须使用中文**,不能包含英文字符
+ - 详细描述场景、时间、氛围、风格
+ - 适合AI图片生成模型使用
+ - 风格统一为:电影感、细节丰富、动漫风格、高质量
+4. **重要**:必须返回使用该背景的场景编号数组(scene_numbers)
+5. location、time和prompt字段都使用中文
+6. 每个场景都必须分配到某个背景,确保所有场景编号都被包含
+
+【输出JSON格式】
+{
+ "backgrounds": [
+ {
+ "location": "地点名称(中文)",
+ "time": "时间描述(中文)",
+ "prompt": "一个电影感的动漫风格背景,展现[地点描述]在[时间]的场景。画面呈现[细节描述]。风格:细节丰富,高质量,氛围光照。情绪:[情绪描述]。",
+ "scene_numbers": [1, 2, 3]
+ }
+ ]
+}
+
+【示例】
+正确示例:
+{
+ "backgrounds": [
+ {
+ "location": "维修店",
+ "time": "深夜",
+ "prompt": "一个电影感的动漫风格背景,展现凌乱的维修店内部在深夜的场景。昏暗的灯光下,工作台上散落着各种工具和零件,墙上挂着油污的海报。风格:细节丰富,高质量,昏暗氛围。情绪:孤独、工业感。",
+ "scene_numbers": [1, 5, 6, 10, 15]
+ },
+ {
+ "location": "城市全景",
+ "time": "深夜·酸雨",
+ "prompt": "一个电影感的动漫风格背景,展现沿海城市全景在深夜酸雨中的场景。霓虹灯在雨中模糊,高楼大厦笼罩在灰绿色的雨幕中,街道反射着五颜六色的光。风格:细节丰富,高质量,赛博朋克氛围。情绪:压抑、科幻、末世感。",
+ "scene_numbers": [2, 7]
+ }
+ ]
+}
+
+请严格按照JSON格式输出,确保:
+1. prompt字段使用中文
+2. scene_numbers包含所有使用该背景的场景编号
+3. 所有场景都被分配到某个背景`, scenesText)
+
+ // 调用AI服务
+ text, err := s.aiService.GenerateText(prompt, "")
+ if err != nil {
+ return nil, fmt.Errorf("AI analysis failed: %w", err)
+ }
+
+ // 解析AI返回的JSON
+ var result struct {
+ Scenes []struct {
+ Location string `json:"location"`
+ Time string `json:"time"`
+ Prompt string `json:"prompt"`
+ StoryboardNumber []int `json:"storyboard_number"`
+ } `json:"backgrounds"`
+ }
+
+ if err := utils.SafeParseAIJSON(text, &result); err != nil {
+ return nil, fmt.Errorf("failed to parse AI response: %w", err)
+ }
+
+ // 构建场景编号到场景ID的映射
+ storyboardNumberToID := make(map[int]uint)
+ for _, scene := range storyboards {
+ storyboardNumberToID[scene.StoryboardNumber] = scene.ID
+ }
+
+ // 转换为BackgroundInfo
+ var backgrounds []BackgroundInfo
+ for _, bg := range result.Scenes {
+ // 将场景编号转换为场景ID
+ var sceneIDs []uint
+ for _, storyboardNum := range bg.StoryboardNumber {
+ if storyboardID, ok := storyboardNumberToID[storyboardNum]; ok {
+ sceneIDs = append(sceneIDs, storyboardID)
+ }
+ }
+
+ backgrounds = append(backgrounds, BackgroundInfo{
+ Location: bg.Location,
+ Time: bg.Time,
+ Prompt: bg.Prompt,
+ StoryboardNumbers: bg.StoryboardNumber,
+ SceneIDs: sceneIDs,
+ StoryboardCount: len(sceneIDs),
+ })
+ }
+
+ s.log.Infow("AI extracted backgrounds",
+ "total_scenes", len(storyboards),
+ "extracted_backgrounds", len(backgrounds))
+
+ return backgrounds, nil
+}
+
+// extractUniqueBackgrounds 从分镜头中提取唯一背景(代码逻辑,作为AI提取的备份)
+func (s *ImageGenerationService) extractUniqueBackgrounds(scenes []models.Storyboard) []BackgroundInfo {
+ backgroundMap := make(map[string]*BackgroundInfo)
+
+ for _, scene := range scenes {
+ if scene.Location == nil || scene.Time == nil {
+ continue
+ }
+
+ // 使用 location + time 作为唯一标识
+ key := *scene.Location + "|" + *scene.Time
+
+ if bg, exists := backgroundMap[key]; exists {
+ // 背景已存在,添加scene ID
+ bg.SceneIDs = append(bg.SceneIDs, scene.ID)
+ bg.StoryboardCount++
+ } else {
+ // 新背景 - 使用ImagePrompt构建背景提示词
+ prompt := ""
+ if scene.ImagePrompt != nil {
+ prompt = *scene.ImagePrompt
+ }
+ backgroundMap[key] = &BackgroundInfo{
+ Location: *scene.Location,
+ Time: *scene.Time,
+ Prompt: prompt,
+ SceneIDs: []uint{scene.ID},
+ StoryboardCount: 1,
+ }
+ }
+ }
+
+ // 转换为切片
+ var backgrounds []BackgroundInfo
+ for _, bg := range backgroundMap {
+ backgrounds = append(backgrounds, *bg)
+ }
+
+ return backgrounds
+}
diff --git a/application/services/resource_transfer_service.go b/application/services/resource_transfer_service.go
new file mode 100644
index 0000000..401d019
--- /dev/null
+++ b/application/services/resource_transfer_service.go
@@ -0,0 +1,21 @@
+package services
+
+import (
+ "github.com/drama-generator/backend/pkg/logger"
+ "gorm.io/gorm"
+)
+
+type ResourceTransferService struct {
+ db *gorm.DB
+ log *logger.Logger
+}
+
+func NewResourceTransferService(db *gorm.DB, log *logger.Logger) *ResourceTransferService {
+ return &ResourceTransferService{
+ db: db,
+ log: log,
+ }
+}
+
+// ResourceTransferService 现在只保留基本结构,MinIO相关功能已移除
+// 如需资源转存功能,请使用本地存储
diff --git a/application/services/script_generation_service.go b/application/services/script_generation_service.go
new file mode 100644
index 0000000..8a0479c
--- /dev/null
+++ b/application/services/script_generation_service.go
@@ -0,0 +1,511 @@
+package services
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+
+ "github.com/drama-generator/backend/domain/models"
+ "github.com/drama-generator/backend/pkg/ai"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/utils"
+ "gorm.io/gorm"
+)
+
+type ScriptGenerationService struct {
+ db *gorm.DB
+ aiService *AIService
+ log *logger.Logger
+}
+
+func NewScriptGenerationService(db *gorm.DB, log *logger.Logger) *ScriptGenerationService {
+ return &ScriptGenerationService{
+ db: db,
+ aiService: NewAIService(db, log),
+ log: log,
+ }
+}
+
+type GenerateOutlineRequest struct {
+ DramaID string `json:"drama_id" binding:"required"`
+ Theme string `json:"theme" binding:"required,min=2,max=500"`
+ Genre string `json:"genre"`
+ Style string `json:"style"`
+ Length int `json:"length"`
+ Temperature float64 `json:"temperature"`
+}
+
+type GenerateCharactersRequest struct {
+ DramaID string `json:"drama_id" binding:"required"`
+ Outline string `json:"outline"`
+ Count int `json:"count"`
+ Temperature float64 `json:"temperature"`
+}
+
+type GenerateEpisodesRequest struct {
+ DramaID string `json:"drama_id" binding:"required"`
+ Outline string `json:"outline"`
+ EpisodeCount int `json:"episode_count" binding:"required,min=1,max=100"`
+ Temperature float64 `json:"temperature"`
+}
+
+type OutlineResult struct {
+ Title string `json:"title"`
+ Summary string `json:"summary"`
+ Genre string `json:"genre"`
+ Tags []string `json:"tags"`
+ Characters []CharacterOutline `json:"characters"`
+ Episodes []EpisodeOutline `json:"episodes"`
+ KeyScenes []string `json:"key_scenes"`
+}
+
+type CharacterOutline struct {
+ Name string `json:"name"`
+ Role string `json:"role"`
+ Description string `json:"description"`
+ Personality string `json:"personality"`
+ Appearance string `json:"appearance"`
+}
+
+type EpisodeOutline struct {
+ EpisodeNumber int `json:"episode_number"`
+ Title string `json:"title"`
+ Summary string `json:"summary"`
+ Scenes []string `json:"scenes"`
+ Duration int `json:"duration"`
+}
+
+func (s *ScriptGenerationService) GenerateOutline(req *GenerateOutlineRequest) (*OutlineResult, error) {
+ var drama models.Drama
+ if err := s.db.Where("id = ?", req.DramaID).First(&drama).Error; err != nil {
+ return nil, fmt.Errorf("drama not found")
+ }
+
+ systemPrompt := `你是专业短剧编剧。根据主题和剧集数量,创作完整的短剧大纲,规划好每一集的剧情走向。
+
+要求:
+1. 剧情紧凑,矛盾冲突强烈,节奏快
+2. 必须规划好每一集的核心剧情
+3. 每集有明确冲突和转折点,集与集之间有连贯性和悬念
+
+**重要:必须输出完整有效的JSON,确保所有字段完整,特别是episodes数组必须完整闭合!**
+
+JSON格式(紧凑,summary和episodes字段必须完整):
+{"title":"剧名","summary":"200-250字剧情概述,包含故事背景、主要矛盾、核心冲突、完整走向","genre":"类型","tags":["标签1","标签2","标签3"],"episodes":[{"episode_number":1,"title":"标题","summary":"80字剧情概要"},{"episode_number":2,"title":"标题","summary":"80字剧情概要"}],"key_scenes":["场景1","场景2","场景3"]}
+
+关键要求:
+- summary控制在200-250字,简洁清晰
+- episodes必须生成用户要求的完整集数
+- 每集summary控制在80字左右
+- 确保JSON完整闭合,不要截断
+- 不要添加任何JSON外的文字说明`
+
+ userPrompt := fmt.Sprintf(`请为以下主题创作短剧大纲:
+
+主题:%s`, req.Theme)
+
+ if req.Genre != "" {
+ userPrompt += fmt.Sprintf("\n类型偏好:%s", req.Genre)
+ }
+
+ if req.Style != "" {
+ userPrompt += fmt.Sprintf("\n风格要求:%s", req.Style)
+ }
+
+ length := req.Length
+ if length == 0 {
+ length = 5
+ }
+ userPrompt += fmt.Sprintf("\n剧集数量:%d集", length)
+ userPrompt += fmt.Sprintf("\n\n**重要:必须在episodes数组中规划完整的%d集剧情,每集都要有明确的故事内容!**", length)
+
+ temperature := req.Temperature
+ if temperature == 0 {
+ temperature = 0.8
+ }
+
+ // 调整token限制:基础2000 + 每集约150 tokens(包含80-100字概要)
+ maxTokens := 2000 + (length * 150)
+ if maxTokens > 8000 {
+ maxTokens = 8000
+ }
+
+ s.log.Infow("Generating outline with episodes",
+ "episode_count", length,
+ "max_tokens", maxTokens)
+
+ text, err := s.aiService.GenerateText(
+ userPrompt,
+ systemPrompt,
+ ai.WithTemperature(temperature),
+ ai.WithMaxTokens(maxTokens),
+ )
+
+ if err != nil {
+ s.log.Errorw("Failed to generate outline", "error", err)
+ return nil, fmt.Errorf("生成失败: %w", err)
+ }
+
+ s.log.Infow("AI response received", "length", len(text), "preview", text[:minInt(200, len(text))])
+
+ var result OutlineResult
+ if err := utils.SafeParseAIJSON(text, &result); err != nil {
+ s.log.Errorw("Failed to parse outline JSON", "error", err, "raw_response", text[:minInt(500, len(text))])
+ return nil, fmt.Errorf("解析 AI 返回结果失败: %w", err)
+ }
+
+ // 将Tags转换为JSON格式存储
+ tagsJSON, err := json.Marshal(result.Tags)
+ if err != nil {
+ s.log.Errorw("Failed to marshal tags", "error", err)
+ tagsJSON = []byte("[]")
+ }
+
+ if err := s.db.Model(&drama).Updates(map[string]interface{}{
+ "title": result.Title,
+ "description": result.Summary,
+ "genre": result.Genre,
+ "tags": tagsJSON,
+ }).Error; err != nil {
+ s.log.Errorw("Failed to update drama", "error", err)
+ }
+
+ s.log.Infow("Outline generated", "drama_id", req.DramaID)
+ return &result, nil
+}
+
+func (s *ScriptGenerationService) GenerateCharacters(req *GenerateCharactersRequest) ([]models.Character, error) {
+ var drama models.Drama
+ if err := s.db.Where("id = ? ", req.DramaID).First(&drama).Error; err != nil {
+ return nil, fmt.Errorf("drama not found")
+ }
+
+ count := req.Count
+ if count == 0 {
+ count = 5
+ }
+
+ systemPrompt := `你是一个专业的角色分析师,擅长从剧本中提取和分析角色信息。
+
+你的任务是根据提供的剧本内容,提取并整理剧中出现的所有角色的详细设定。
+
+要求:
+1. 仔细阅读剧本,识别所有出现的角色
+2. 根据剧本中的对话、行为和描述,总结角色的性格特点
+3. 提取角色在剧本中的关键信息:背景、动机、目标、关系等
+4. 角色之间的关系必须基于剧本中的实际描述
+5. 外貌描述必须极其详细,如果剧本中有描述则使用,如果没有则根据角色设定合理推断,便于AI绘画生成角色形象
+6. 优先提取主要角色和重要配角,次要角色可以简略
+
+请严格按照以下 JSON 格式输出,不要添加任何其他文字:
+
+{
+ "characters": [
+ {
+ "name": "角色名",
+ "role": "主角/重要配角/配角",
+ "description": "角色背景和简介(200-300字,包括:出身背景、成长经历、核心动机、与其他角色的关系、在故事中的作用)",
+ "personality": "性格特点(详细描述,100-150字,包括:主要性格特征、行为习惯、价值观、优点缺点、情绪表达方式、对待他人的态度等)",
+ "appearance": "外貌描述(极其详细,150-200字,必须包括:确切年龄、精确身高、体型身材、肤色质感、发型发色发长、眼睛颜色形状、面部特征(如眉毛、鼻子、嘴唇)、着装风格、服装颜色材质、配饰细节、标志性特征、整体气质风格等,描述要具体到可以直接用于AI绘画)",
+ "voice_style": "说话风格和语气特点(详细描述,50-80字,包括:语速语调、用词习惯、口头禅、说话时的情绪特征等)"
+ }
+ ]
+}
+
+注意:
+- 必须基于剧本内容提取角色,不要凭空创作
+- 优先提取主要角色和重要配角,数量根据剧本实际情况确定
+- description、personality、appearance、voice_style都必须详细描述,字数要充足
+- appearance外貌描述是重中之重,必须极其详细具体,要能让AI准确生成角色形象
+- 如果剧本中角色信息不完整,可以根据角色设定合理补充,但要符合剧本整体风格`
+
+ outlineText := req.Outline
+ if outlineText == "" {
+ outlineText = fmt.Sprintf("剧名:%s\n简介:%s\n类型:%s", drama.Title, drama.Description, drama.Genre)
+ }
+
+ userPrompt := fmt.Sprintf(`剧本内容:
+%s
+
+请从剧本中提取并整理最多 %d 个主要角色的详细设定。`, outlineText, count)
+
+ temperature := req.Temperature
+ if temperature == 0 {
+ temperature = 0.7
+ }
+
+ text, err := s.aiService.GenerateText(
+ userPrompt,
+ systemPrompt,
+ ai.WithTemperature(temperature),
+ ai.WithMaxTokens(3000),
+ )
+
+ if err != nil {
+ s.log.Errorw("Failed to generate characters", "error", err)
+ return nil, fmt.Errorf("生成失败: %w", err)
+ }
+
+ s.log.Infow("AI response received", "length", len(text), "preview", text[:minInt(200, len(text))])
+
+ var result struct {
+ Characters []struct {
+ Name string `json:"name"`
+ Role string `json:"role"`
+ Description string `json:"description"`
+ Personality string `json:"personality"`
+ Appearance string `json:"appearance"`
+ VoiceStyle string `json:"voice_style"`
+ } `json:"characters"`
+ }
+
+ if err := utils.SafeParseAIJSON(text, &result); err != nil {
+ s.log.Errorw("Failed to parse characters JSON", "error", err, "raw_response", text[:minInt(500, len(text))])
+ return nil, fmt.Errorf("解析 AI 返回结果失败: %w", err)
+ }
+
+ var characters []models.Character
+ for _, char := range result.Characters {
+ // 检查角色是否已存在
+ var existingChar models.Character
+ err := s.db.Where("drama_id = ? AND name = ?", req.DramaID, char.Name).First(&existingChar).Error
+ if err == nil {
+ // 角色已存在,直接使用已存在的角色,不覆盖
+ s.log.Infow("Character already exists, skipping", "drama_id", req.DramaID, "name", char.Name)
+ characters = append(characters, existingChar)
+ continue
+ }
+
+ // 角色不存在,创建新角色
+ dramaID, _ := strconv.ParseUint(req.DramaID, 10, 32)
+ character := models.Character{
+ DramaID: uint(dramaID),
+ Name: char.Name,
+ Role: &char.Role,
+ Description: &char.Description,
+ Personality: &char.Personality,
+ Appearance: &char.Appearance,
+ VoiceStyle: &char.VoiceStyle,
+ }
+
+ if err := s.db.Create(&character).Error; err != nil {
+ s.log.Errorw("Failed to create character", "error", err)
+ continue
+ }
+
+ characters = append(characters, character)
+ }
+
+ s.log.Infow("Characters generated", "drama_id", req.DramaID, "total_count", len(characters), "new_count", len(characters))
+ return characters, nil
+}
+
+func (s *ScriptGenerationService) GenerateEpisodes(req *GenerateEpisodesRequest) ([]models.Episode, error) {
+ var drama models.Drama
+ if err := s.db.Where("id = ? ", req.DramaID).First(&drama).Error; err != nil {
+ return nil, fmt.Errorf("drama not found")
+ }
+
+ // 获取角色信息
+ var characters []models.Character
+ s.db.Where("drama_id = ?", req.DramaID).Find(&characters)
+
+ var characterList string
+ if len(characters) > 0 {
+ characterList = "\n角色设定:\n"
+ for _, char := range characters {
+ characterList += fmt.Sprintf("- %s", char.Name)
+ if char.Role != nil {
+ characterList += fmt.Sprintf("(%s)", *char.Role)
+ }
+ if char.Description != nil {
+ characterList += fmt.Sprintf(":%s", *char.Description)
+ }
+ if char.Personality != nil {
+ characterList += fmt.Sprintf(" | 性格:%s", *char.Personality)
+ }
+ characterList += "\n"
+ }
+ } else {
+ characterList = "\n(注意:尚未设定角色,请根据大纲创作合理的角色出场)\n"
+ }
+
+ systemPrompt := `你是一个专业的短剧编剧。你擅长根据分集规划创作详细的剧情内容。
+
+你的任务是根据大纲中的分集规划,将每一集的概要扩展为详细的剧情叙述。每集约180秒(3分钟),需要充实的内容。
+
+工作流程:
+1. 大纲中已提供每集的剧情规划(80-100字概要)
+2. 你需要将每集概要扩展为400-500字的详细剧情叙述
+3. 严格按照分集规划的数量和走向展开,不能遗漏任何一集
+
+详细要求:
+1. script_content用400-500字详细叙述,包括:
+ - 具体场景和环境描写
+ - 角色的行动、对话要点、情绪变化
+ - 冲突的产生过程和激化细节
+ - 关键情节点和转折
+ - 为下一集埋下的伏笔
+2. 每集有明确的冲突和转折点
+3. 集与集之间有连贯性和悬念
+4. 充分展现角色性格和关系演变
+5. 内容详实,足以支撑180秒时长
+
+JSON格式(紧凑):
+{"episodes":[{"episode_number":1,"title":"标题","description":"简短梗概","script_content":"400-500字详细剧情叙述","duration":210}]}
+
+格式说明:
+1. script_content为叙述文,不是场景对话格式
+2. 每集包含开场铺垫、冲突发展、高潮转折、结局悬念
+3. duration根据剧情复杂度设置在150-300秒
+
+关键要求:
+- 大纲规划了几集就必须生成几集
+- 严格按照分集规划的故事线展开
+- 每一集都要有完整的400-500字详细内容
+- 绝对不能遗漏任何一集`
+
+ outlineText := req.Outline
+ if outlineText == "" {
+ outlineText = fmt.Sprintf("剧名:%s\n简介:%s\n类型:%s", drama.Title, drama.Description, drama.Genre)
+ }
+
+ userPrompt := fmt.Sprintf(`剧本大纲:
+%s
+%s
+请基于以上大纲和角色,创作 %d 集的详细剧本。
+
+**重要要求:**
+- 必须生成完整的 %d 集,从第1集到第%d集,不能遗漏
+- 每集约3-5分钟(150-300秒)
+- 每集的duration字段要根据剧本内容长度合理设置,不要都设置为同一个值
+- 返回的JSON中episodes数组必须包含 %d 个元素`, outlineText, characterList, req.EpisodeCount, req.EpisodeCount, req.EpisodeCount, req.EpisodeCount)
+
+ temperature := req.Temperature
+ if temperature == 0 {
+ temperature = 0.7
+ }
+
+ // 根据剧集数量调整token限制
+ // 模型支持128k上下文,每集400-500字约需800-1000 tokens(包含JSON结构)
+ baseTokens := 3000 // 基础(系统提示+角色列表+大纲)
+ perEpisodeTokens := 900 // 每集约900 tokens(支持400-500字详细内容)
+ maxTokens := baseTokens + (req.EpisodeCount * perEpisodeTokens)
+
+ // 128k上下文,可以设置较大的token限制
+ // 10集约12000 tokens,20集约21000 tokens,都在安全范围内
+ if maxTokens > 32000 {
+ maxTokens = 32000 // 保守限制在32k,留足够空间
+ }
+
+ s.log.Infow("Generating episodes with token limit",
+ "episode_count", req.EpisodeCount,
+ "max_tokens", maxTokens,
+ "estimated_per_episode", perEpisodeTokens)
+
+ text, err := s.aiService.GenerateText(
+ userPrompt,
+ systemPrompt,
+ ai.WithTemperature(0.8),
+ ai.WithMaxTokens(maxTokens),
+ )
+
+ if err != nil {
+ s.log.Errorw("Failed to generate episodes", "error", err)
+ return nil, fmt.Errorf("生成失败: %w", err)
+ }
+
+ s.log.Infow("AI response received", "length", len(text), "preview", text[:minInt(200, len(text))])
+
+ var result struct {
+ Episodes []struct {
+ EpisodeNumber int `json:"episode_number"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ ScriptContent string `json:"script_content"`
+ Duration int `json:"duration"`
+ } `json:"episodes"`
+ }
+
+ if err := utils.SafeParseAIJSON(text, &result); err != nil {
+ s.log.Errorw("Failed to parse episodes JSON", "error", err, "raw_response", text[:minInt(500, len(text))])
+ return nil, fmt.Errorf("解析 AI 返回结果失败: %w", err)
+ }
+
+ // 检查生成的集数是否符合要求
+ if len(result.Episodes) < req.EpisodeCount {
+ s.log.Warnw("AI generated fewer episodes than requested",
+ "requested", req.EpisodeCount,
+ "generated", len(result.Episodes))
+ }
+
+ // 记录每集的详细信息
+ for i, ep := range result.Episodes {
+ s.log.Infow("Episode parsed from AI",
+ "index", i,
+ "episode_number", ep.EpisodeNumber,
+ "title", ep.Title,
+ "description_length", len(ep.Description),
+ "script_content_length", len(ep.ScriptContent),
+ "duration", ep.Duration)
+ }
+
+ var episodes []models.Episode
+ for _, ep := range result.Episodes {
+ duration := ep.Duration
+ if duration == 0 {
+ // AI未返回时长时使用默认值
+ duration = 180
+ s.log.Warnw("Episode duration not provided by AI, using default",
+ "episode_number", ep.EpisodeNumber,
+ "default_duration", 180)
+ } else {
+ s.log.Infow("Episode duration from AI",
+ "episode_number", ep.EpisodeNumber,
+ "duration", duration)
+ }
+
+ // 记录即将保存的数据
+ s.log.Infow("Creating episode in database",
+ "episode_number", ep.EpisodeNumber,
+ "title", ep.Title,
+ "script_content_length", len(ep.ScriptContent),
+ "script_content_empty", ep.ScriptContent == "")
+
+ dramaID, err := strconv.ParseUint(req.DramaID, 10, 32)
+ if err != nil {
+ return nil, fmt.Errorf("invalid drama ID")
+ }
+
+ episode := models.Episode{
+ DramaID: uint(dramaID),
+ EpisodeNum: ep.EpisodeNumber,
+ Title: ep.Title,
+ Description: &ep.Description,
+ ScriptContent: &ep.ScriptContent,
+ Duration: duration,
+ Status: "draft",
+ }
+
+ if err := s.db.Create(&episode).Error; err != nil {
+ s.log.Errorw("Failed to create episode", "error", err)
+ continue
+ }
+
+ episodes = append(episodes, episode)
+ }
+
+ s.log.Infow("Episodes generated", "drama_id", req.DramaID, "count", len(episodes))
+ return episodes, nil
+}
+
+// GenerateScenesForEpisode 已废弃,使用 StoryboardService.GenerateStoryboard 替代
+// ParseScript 已废弃,使用 GenerateCharacters 替代
+
+// minInt 返回两个整数中较小的一个
+func minInt(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/application/services/storyboard_composition_service.go b/application/services/storyboard_composition_service.go
new file mode 100644
index 0000000..9dc9626
--- /dev/null
+++ b/application/services/storyboard_composition_service.go
@@ -0,0 +1,395 @@
+package services
+
+import (
+ "encoding/json"
+ "fmt"
+
+ models "github.com/drama-generator/backend/domain/models"
+ "github.com/drama-generator/backend/pkg/logger"
+ "gorm.io/gorm"
+)
+
+type StoryboardCompositionService struct {
+ db *gorm.DB
+ log *logger.Logger
+ imageGen *ImageGenerationService
+}
+
+func NewStoryboardCompositionService(db *gorm.DB, log *logger.Logger, imageGen *ImageGenerationService) *StoryboardCompositionService {
+ return &StoryboardCompositionService{
+ db: db,
+ log: log,
+ imageGen: imageGen,
+ }
+}
+
+type SceneCharacterInfo struct {
+ ID uint `json:"id"`
+ Name string `json:"name"`
+ ImageURL *string `json:"image_url,omitempty"`
+}
+
+type SceneBackgroundInfo struct {
+ ID uint `json:"id"`
+ Location string `json:"location"`
+ Time string `json:"time"`
+ ImageURL *string `json:"image_url,omitempty"`
+ Status string `json:"status"`
+}
+
+type SceneCompositionInfo struct {
+ ID uint `json:"id"`
+ StoryboardNumber int `json:"storyboard_number"`
+ Title *string `json:"title"`
+ Description *string `json:"description"`
+ Location *string `json:"location"`
+ Time *string `json:"time"`
+ Duration int `json:"duration"`
+ Dialogue *string `json:"dialogue"`
+ Action *string `json:"action"`
+ Atmosphere *string `json:"atmosphere"`
+ ImagePrompt *string `json:"image_prompt,omitempty"`
+ VideoPrompt *string `json:"video_prompt,omitempty"`
+ Characters []SceneCharacterInfo `json:"characters"`
+ Background *SceneBackgroundInfo `json:"background"`
+ SceneID *uint `json:"scene_id"`
+ ComposedImage *string `json:"composed_image,omitempty"`
+ VideoURL *string `json:"video_url,omitempty"`
+ ImageGenerationID *uint `json:"image_generation_id,omitempty"`
+ ImageGenerationStatus *string `json:"image_generation_status,omitempty"`
+ VideoGenerationID *uint `json:"video_generation_id,omitempty"`
+ VideoGenerationStatus *string `json:"video_generation_status,omitempty"`
+}
+
+func (s *StoryboardCompositionService) GetScenesForEpisode(episodeID string) ([]SceneCompositionInfo, error) {
+ // 验证权限
+ var episode models.Episode
+ err := s.db.Preload("Drama").Where("id = ?", episodeID).First(&episode).Error
+ if err != nil {
+ s.log.Errorw("Episode not found", "episode_id", episodeID, "error", err)
+ return nil, fmt.Errorf("episode not found")
+ }
+
+ s.log.Infow("GetScenesForEpisode auth check",
+ "episode_id", episodeID,
+ "drama_id", episode.DramaID)
+
+ // 获取分镜列表
+ var storyboards []models.Storyboard
+ if err := s.db.Where("episode_id = ?", episodeID).
+ Preload("Characters").
+ Order("storyboard_number ASC").
+ Find(&storyboards).Error; err != nil {
+ return nil, fmt.Errorf("failed to load storyboards: %w", err)
+ }
+
+ // 获取所有角色(用于匹配角色信息)
+ var characters []models.Character
+ if err := s.db.Where("drama_id = ?", episode.DramaID).Find(&characters).Error; err != nil {
+ s.log.Warnw("Failed to load characters", "error", err)
+ }
+
+ // 创建角色ID到角色信息的映射
+ charIDToInfo := make(map[uint]*models.Character)
+ for i := range characters {
+ charIDToInfo[characters[i].ID] = &characters[i]
+ }
+
+ // 获取所有场景ID
+ var sceneIDs []uint
+ for _, storyboard := range storyboards {
+ if storyboard.SceneID != nil {
+ sceneIDs = append(sceneIDs, *storyboard.SceneID)
+ }
+ }
+
+ // 批量获取场景信息
+ var scenes []models.Scene
+ sceneMap := make(map[uint]*models.Scene)
+ if len(sceneIDs) > 0 {
+ if err := s.db.Where("id IN ?", sceneIDs).Find(&scenes).Error; err == nil {
+ for i := range scenes {
+ sceneMap[scenes[i].ID] = &scenes[i]
+ }
+ }
+ }
+
+ // 获取分镜的合成图片(从 image_generations 表)
+ storyboardIDs := make([]uint, len(storyboards))
+ for i, storyboard := range storyboards {
+ storyboardIDs[i] = storyboard.ID
+ }
+
+ imageGenMap := make(map[uint]string) // storyboard_id -> image_url
+ imageGenTaskMap := make(map[uint]*models.ImageGeneration) // storyboard_id -> processing task
+ if len(storyboardIDs) > 0 {
+ var imageGens []models.ImageGeneration
+ // 查询已完成的图片生成记录,每个镜头只取最新的一条
+ if err := s.db.Where("storyboard_id IN ? AND status = ?", storyboardIDs, models.ImageStatusCompleted).
+ Order("created_at DESC").
+ Find(&imageGens).Error; err == nil {
+ // 为每个镜头保留最新的一条记录
+ for _, ig := range imageGens {
+ if ig.StoryboardID != nil {
+ if _, exists := imageGenMap[*ig.StoryboardID]; !exists {
+ if ig.ImageURL != nil {
+ imageGenMap[*ig.StoryboardID] = *ig.ImageURL
+ }
+ }
+ }
+ }
+ }
+
+ // 查询进行中的图片生成任务
+ var processingImageGens []models.ImageGeneration
+ if err := s.db.Where("storyboard_id IN ? AND status = ?", storyboardIDs, models.ImageStatusProcessing).
+ Order("created_at DESC").
+ Find(&processingImageGens).Error; err == nil {
+ for _, ig := range processingImageGens {
+ if ig.StoryboardID != nil {
+ if _, exists := imageGenTaskMap[*ig.StoryboardID]; !exists {
+ igCopy := ig
+ imageGenTaskMap[*ig.StoryboardID] = &igCopy
+ }
+ }
+ }
+ }
+ }
+
+ // 批量查询进行中的视频生成任务
+ videoGenTaskMap := make(map[uint]*models.VideoGeneration) // storyboard_id -> processing task
+ if len(storyboardIDs) > 0 {
+ var processingVideoGens []models.VideoGeneration
+ if err := s.db.Where("scene_id IN ? AND status = ?", storyboardIDs, models.VideoStatusProcessing).
+ Order("created_at DESC").
+ Find(&processingVideoGens).Error; err == nil {
+ for _, vg := range processingVideoGens {
+ if vg.StoryboardID != nil {
+ if _, exists := videoGenTaskMap[*vg.StoryboardID]; !exists {
+ vgCopy := vg
+ videoGenTaskMap[*vg.StoryboardID] = &vgCopy
+ }
+ }
+ }
+ }
+ }
+
+ // 构建返回结果
+ var result []SceneCompositionInfo
+ for _, storyboard := range storyboards {
+ storyboardInfo := SceneCompositionInfo{
+ ID: storyboard.ID,
+ StoryboardNumber: storyboard.StoryboardNumber,
+ Title: storyboard.Title,
+ Description: storyboard.Description,
+ Location: storyboard.Location,
+ Time: storyboard.Time,
+ Duration: storyboard.Duration,
+ Action: storyboard.Action,
+ Dialogue: storyboard.Dialogue,
+ Atmosphere: storyboard.Atmosphere,
+ ImagePrompt: storyboard.ImagePrompt,
+ VideoPrompt: storyboard.VideoPrompt,
+ SceneID: storyboard.SceneID,
+ }
+
+ // 直接使用关联的角色信息
+ if len(storyboard.Characters) > 0 {
+ for _, char := range storyboard.Characters {
+ storyboardChar := SceneCharacterInfo{
+ ID: char.ID,
+ Name: char.Name,
+ ImageURL: char.ImageURL,
+ }
+ storyboardInfo.Characters = append(storyboardInfo.Characters, storyboardChar)
+ }
+ }
+
+ // 添加场景信息
+ if storyboard.SceneID != nil {
+ if scene, ok := sceneMap[*storyboard.SceneID]; ok {
+ storyboardInfo.Background = &SceneBackgroundInfo{
+ ID: scene.ID,
+ Location: scene.Location,
+ Time: scene.Time,
+ ImageURL: scene.ImageURL,
+ Status: scene.Status,
+ }
+ }
+ }
+
+ // 添加合成图片
+ if imageURL, ok := imageGenMap[storyboard.ID]; ok {
+ storyboardInfo.ComposedImage = &imageURL
+ }
+
+ // 添加视频URL
+ if storyboard.VideoURL != nil {
+ storyboardInfo.VideoURL = storyboard.VideoURL
+ }
+
+ // 添加进行中的图片生成任务信息
+ if imageTask, ok := imageGenTaskMap[storyboard.ID]; ok {
+ storyboardInfo.ImageGenerationID = &imageTask.ID
+ statusStr := string(imageTask.Status)
+ storyboardInfo.ImageGenerationStatus = &statusStr
+ }
+
+ // 添加进行中的视频生成任务信息
+ if videoTask, ok := videoGenTaskMap[storyboard.ID]; ok {
+ storyboardInfo.VideoGenerationID = &videoTask.ID
+ statusStr := string(videoTask.Status)
+ storyboardInfo.VideoGenerationStatus = &statusStr
+ }
+
+ result = append(result, storyboardInfo)
+ }
+
+ return result, nil
+}
+
+type UpdateSceneRequest struct {
+ SceneID *uint `json:"scene_id"`
+ Characters []uint `json:"characters"` // 改为存储角色ID数组
+ Location *string `json:"location"`
+ Time *string `json:"time"`
+ Action *string `json:"action"`
+ Dialogue *string `json:"dialogue"`
+ Description *string `json:"description"`
+ Duration *int `json:"duration"`
+ ImagePrompt *string `json:"image_prompt"`
+ VideoPrompt *string `json:"video_prompt"`
+}
+
+func (s *StoryboardCompositionService) UpdateScene(sceneID string, req *UpdateSceneRequest) error {
+ // 获取分镜并验证权限
+ var storyboard models.Storyboard
+ err := s.db.Preload("Episode.Drama").Where("id = ?", sceneID).First(&storyboard).Error
+ if err != nil {
+ return fmt.Errorf("scene not found")
+ }
+
+ // 构建更新数据
+ updates := make(map[string]interface{})
+
+ // 更新背景ID
+ if req.SceneID != nil {
+ updates["scene_id"] = req.SceneID
+ }
+
+ // 更新角色列表(直接存储ID数组)
+ if req.Characters != nil {
+ charactersJSON, err := json.Marshal(req.Characters)
+ if err != nil {
+ return fmt.Errorf("failed to serialize characters: %w", err)
+ }
+ updates["characters"] = charactersJSON
+ }
+
+ // 更新场景信息字段
+ if req.Location != nil {
+ updates["location"] = req.Location
+ }
+ if req.Time != nil {
+ updates["time"] = req.Time
+ }
+ if req.Action != nil {
+ updates["action"] = req.Action
+ }
+ if req.Dialogue != nil {
+ updates["dialogue"] = req.Dialogue
+ }
+ if req.Description != nil {
+ updates["description"] = req.Description
+ }
+ if req.Duration != nil {
+ updates["duration"] = *req.Duration
+ }
+ if req.ImagePrompt != nil {
+ updates["image_prompt"] = req.ImagePrompt
+ }
+ if req.VideoPrompt != nil {
+ updates["video_prompt"] = req.VideoPrompt
+ }
+
+ // 执行更新
+ if len(updates) > 0 {
+ if err := s.db.Model(&models.Storyboard{}).Where("id = ?", sceneID).Updates(updates).Error; err != nil {
+ return fmt.Errorf("failed to update scene: %w", err)
+ }
+ }
+
+ s.log.Infow("Scene updated", "scene_id", sceneID, "updates", updates)
+ return nil
+}
+
+type GenerateSceneImageRequest struct {
+ SceneID uint `json:"scene_id"`
+ Prompt string `json:"prompt"`
+ Model string `json:"model"`
+}
+
+func (s *StoryboardCompositionService) GenerateSceneImage(req *GenerateSceneImageRequest) (*models.ImageGeneration, error) {
+ // 获取场景并验证权限
+ var scene models.Scene
+ err := s.db.Where("id = ?", req.SceneID).First(&scene).Error
+ if err != nil {
+ return nil, fmt.Errorf("scene not found")
+ }
+
+ // 验证权限:通过DramaID查询Drama
+ var drama models.Drama
+ if err := s.db.Where("id = ? ", scene.DramaID).First(&drama).Error; err != nil {
+ return nil, fmt.Errorf("unauthorized")
+ }
+
+ // 构建场景图片生成提示词
+ prompt := req.Prompt
+ if prompt == "" {
+ // 使用场景的Prompt字段
+ prompt = scene.Prompt
+ if prompt == "" {
+ // 如果Prompt为空,使用Location和Time构建
+ prompt = fmt.Sprintf("%s场景,%s", scene.Location, scene.Time)
+ }
+ s.log.Infow("Using scene prompt", "scene_id", req.SceneID, "prompt", prompt)
+ }
+
+ // 使用imageGen服务直接生成
+ if s.imageGen != nil {
+ genReq := &GenerateImageRequest{
+ SceneID: &req.SceneID,
+ DramaID: fmt.Sprintf("%d", scene.DramaID),
+ ImageType: string(models.ImageTypeScene),
+ Prompt: prompt,
+ Model: req.Model, // 使用用户指定的模型
+ Size: "2560x1440", // 3,686,400像素,满足doubao模型最低要求(16:9比例)
+ Quality: "standard",
+ }
+ imageGen, err := s.imageGen.GenerateImage(genReq)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate image: %w", err)
+ }
+
+ // 更新场景的image_url
+ if imageGen.ImageURL != nil {
+ scene.ImageURL = imageGen.ImageURL
+ scene.Status = "generated"
+ if err := s.db.Save(&scene).Error; err != nil {
+ s.log.Errorw("Failed to update scene image url", "error", err)
+ }
+ }
+
+ s.log.Infow("Scene image generation created", "scene_id", req.SceneID, "image_gen_id", imageGen.ID)
+ return imageGen, nil
+ }
+
+ return nil, fmt.Errorf("image generation service not available")
+}
+
+func getStringValue(s *string) string {
+ if s != nil {
+ return *s
+ }
+ return ""
+}
diff --git a/application/services/storyboard_service.go b/application/services/storyboard_service.go
new file mode 100644
index 0000000..0d8f491
--- /dev/null
+++ b/application/services/storyboard_service.go
@@ -0,0 +1,741 @@
+package services
+
+import (
+ "strconv"
+
+ "fmt"
+ "strings"
+
+ models "github.com/drama-generator/backend/domain/models"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/utils"
+ "gorm.io/gorm"
+)
+
+type StoryboardService struct {
+ db *gorm.DB
+ aiService *AIService
+ log *logger.Logger
+}
+
+func NewStoryboardService(db *gorm.DB, log *logger.Logger) *StoryboardService {
+ return &StoryboardService{
+ db: db,
+ aiService: NewAIService(db, log),
+ log: log,
+ }
+}
+
+type Storyboard struct {
+ ShotNumber int `json:"shot_number"`
+ Title string `json:"title"` // 镜头标题
+ ShotType string `json:"shot_type"` // 景别
+ Angle string `json:"angle"` // 镜头角度
+ Time string `json:"time"` // 时间
+ Location string `json:"location"` // 地点
+ SceneID *uint `json:"scene_id"` // 背景ID(AI直接返回,可为null)
+ Movement string `json:"movement"` // 运镜
+ Action string `json:"action"` // 动作
+ Dialogue string `json:"dialogue"` // 对话/独白
+ Result string `json:"result"` // 画面结果
+ Atmosphere string `json:"atmosphere"` // 环境氛围
+ Emotion string `json:"emotion"` // 情绪
+ Duration int `json:"duration"` // 时长(秒)
+ BgmPrompt string `json:"bgm_prompt"` // 配乐提示词
+ SoundEffect string `json:"sound_effect"` // 音效描述
+ Characters []uint `json:"characters"` // 涉及的角色ID列表
+ IsPrimary bool `json:"is_primary"` // 是否主镜
+}
+
+type GenerateStoryboardResult struct {
+ Storyboards []Storyboard `json:"storyboards"`
+ Total int `json:"total"`
+}
+
+func (s *StoryboardService) GenerateStoryboard(episodeID string) (*GenerateStoryboardResult, error) {
+ // 从数据库获取剧集信息
+ var episode struct {
+ ID string
+ ScriptContent *string
+ Description *string
+ DramaID string
+ }
+
+ err := s.db.Table("episodes").
+ Select("episodes.id, episodes.script_content, episodes.description, episodes.drama_id").
+ Joins("INNER JOIN dramas ON dramas.id = episodes.drama_id").
+ Where("episodes.id = ?", episodeID).
+ First(&episode).Error
+
+ if err != nil {
+ return nil, fmt.Errorf("剧集不存在或无权限访问")
+ }
+
+ // 获取剧本内容
+ var scriptContent string
+ if episode.ScriptContent != nil && *episode.ScriptContent != "" {
+ scriptContent = *episode.ScriptContent
+ } else if episode.Description != nil && *episode.Description != "" {
+ scriptContent = *episode.Description
+ } else {
+ return nil, fmt.Errorf("剧本内容为空,请先生成剧集内容")
+ }
+
+ // 获取该剧本的所有角色
+ var characters []models.Character
+ if err := s.db.Where("drama_id = ?", episode.DramaID).Order("name ASC").Find(&characters).Error; err != nil {
+ return nil, fmt.Errorf("获取角色列表失败: %w", err)
+ }
+
+ // 构建角色列表字符串(包含ID和名称)
+ characterList := "无角色"
+ if len(characters) > 0 {
+ var charInfoList []string
+ for _, char := range characters {
+ charInfoList = append(charInfoList, fmt.Sprintf(`{"id": %d, "name": "%s"}`, char.ID, char.Name))
+ }
+ characterList = fmt.Sprintf("[%s]", strings.Join(charInfoList, ", "))
+ }
+
+ // 获取该项目已提取的场景列表(项目级)
+ var scenes []models.Scene
+ if err := s.db.Where("drama_id = ?", episode.DramaID).Order("location ASC, time ASC").Find(&scenes).Error; err != nil {
+ s.log.Warnw("Failed to get scenes", "error", err)
+ }
+
+ // 构建场景列表字符串(包含ID、地点、时间)
+ sceneList := "无场景"
+ if len(scenes) > 0 {
+ var sceneInfoList []string
+ for _, bg := range scenes {
+ sceneInfoList = append(sceneInfoList, fmt.Sprintf(`{"id": %d, "location": "%s", "time": "%s"}`, bg.ID, bg.Location, bg.Time))
+ }
+ sceneList = fmt.Sprintf("[%s]", strings.Join(sceneInfoList, ", "))
+ }
+
+ s.log.Infow("Generating storyboard",
+ "episode_id", episodeID,
+ "drama_id", episode.DramaID,
+ "script_length", len(scriptContent),
+ "character_count", len(characters),
+ "characters", characterList,
+ "scene_count", len(scenes),
+ "scenes", sceneList)
+
+ // 构建分镜头生成提示词
+ prompt := fmt.Sprintf(`【角色】你是一位资深影视分镜师,精通罗伯特·麦基的镜头拆解理论,擅长构建情绪节奏。
+
+【任务】将小说剧本按**独立动作单元**拆解为分镜头方案。
+
+【本剧可用角色列表】
+%s
+
+**重要**:在characters字段中,只能使用上述角色列表中的角色ID(数字),不得自创角色或使用其他ID。
+
+【本剧已提取的场景背景列表】
+%s
+
+**重要**:在scene_id字段中,必须从上述背景列表中选择最匹配的背景ID(数字)。如果没有合适的背景,则填null。
+
+【剧本原文】
+%s
+
+【分镜要素】每个镜头聚焦单一动作,描述要详尽具体:
+1. **镜头标题(title)**:用3-5个字概括该镜头的核心内容或情绪
+ - 例如:"噩梦惊醒"、"对视沉思"、"逃离现场"、"意外发现"
+2. **时间**:[清晨/午后/深夜/具体时分+详细光线描述]
+ - 例如:"深夜22:30·月光从破窗斜射入室内,形成明暗分界"
+3. **地点**:[场景完整描述+空间布局+环境细节]
+ - 例如:"废弃码头仓库·锈蚀货架林立,地面积水反射微弱灯光,墙角堆放腐朽木箱"
+4. **镜头设计**:
+ - **景别(shot_type)**:[远景/全景/中景/近景/特写]
+ - **镜头角度(angle)**:[平视/仰视/俯视/侧面/背面]
+ - **运镜方式(movement)**:[固定镜头/推镜/拉镜/摇镜/跟镜/移镜]
+5. **人物行为**:**详细动作描述**,包含[谁+具体怎么做+肢体细节+表情状态]
+ - 例如:"陈峥弯腰用撬棍撬动保险箱门,手臂青筋暴起,眉头紧锁,汗水滑落脸颊"
+6. **对话/独白**:提取该镜头中的完整对话或独白内容(如无对话则为空字符串)
+7. **画面结果**:动作的即时后果+视觉细节+氛围变化
+ - 例如:"保险箱门弹开发出金属碰撞声,扬起灰尘在光束中飘散,箱内空无一物只有陈旧报纸,陈峥表情从期待转为失望"
+8. **环境氛围**:光线质感+色调+声音环境+整体氛围
+ - 例如:"昏暗冷色调,只有手电筒光束晃动,远处传来海浪拍打声,压抑沉闷"
+9. **配乐提示(bgm_prompt)**:描述该镜头配乐的氛围、节奏、情绪(如无特殊要求则为空字符串)
+ - 例如:"低沉紧张的弦乐,节奏缓慢,营造压抑氛围"
+10. **音效描述(sound_effect)**:描述该镜头的关键音效(如无特殊音效则为空字符串)
+ - 例如:"金属碰撞声、脚步声、海浪拍打声"
+11. **观众情绪**:[情绪类型]([强度:↑↑↑/↑↑/↑/→/↓] + [落点:悬置/释放/反转])
+
+【输出格式】请以JSON格式输出,每个镜头包含以下字段(**所有描述性字段都要详细完整**):
+{
+ "storyboards": [
+ {
+ "shot_number": 1,
+ "title": "噩梦惊醒",
+ "shot_type": "全景",
+ "angle": "俯视45度角",
+ "time": "深夜22:30·月光从破窗斜射入仓库,在地面积水中形成银白色反光,墙角昏暗不清",
+ "location": "废弃码头仓库·锈蚀货架林立,地面积水反射微弱灯光,墙角堆放腐朽木箱和渔网,空气中弥漫潮湿霉味",
+ "scene_id": 1,
+ "movement": "固定镜头",
+ "action": "陈峥弯腰双手握住撬棍用力撬动保险箱门,手臂青筋暴起,眉头紧锁,汗水从额头滑落脸颊,呼吸急促",
+ "dialogue": "(独白)这么多年了,里面到底藏着什么秘密?",
+ "result": "保险箱门突然弹开发出刺耳金属声,扬起灰尘在手电筒光束中飘散,箱内空无一物只有几张发黄的旧报纸,陈峥表情从期待转为震惊和失望,瞳孔放大",
+ "atmosphere": "昏暗冷色调·青灰色为主,只有手电筒光束在黑暗中晃动,远处传来海浪拍打码头的沉闷声,整体氛围压抑沉重",
+ "emotion": "好奇感↑↑转失望↓(情绪反转)",
+ "duration": 9,
+ "bgm_prompt": "低沉紧张的弦乐,节奏缓慢,营造压抑悬疑氛围",
+ "sound_effect": "金属碰撞声、灰尘飘散声、海浪拍打声",
+ "characters": [159],
+ "is_primary": true
+ },
+ {
+ "shot_number": 2,
+ "title": "对视沉思",
+ "shot_type": "近景",
+ "angle": "平视",
+ "time": "深夜22:31·仓库内光线昏暗,只有手电筒光从侧面照亮两人脸部轮廓",
+ "location": "废弃码头仓库·保险箱旁,背景是模糊的货架剪影",
+ "scene_id": 1,
+ "movement": "推镜",
+ "action": "陈峥缓缓转身,目光与身后的李芳对视,李芳手握手电筒,光束在两人之间晃动,眼神中透露疑惑和警惕",
+ "dialogue": "陈峥:\"我们被耍了,这里根本没有我们要找的东西。\" 李芳:\"现在怎么办?我们的时间不多了。\"",
+ "result": "两人站在昏暗中陷入沉思,手电筒光束照在地面形成圆形光斑,背景传来微弱的金属摩擦声,气氛紧张凝重",
+ "atmosphere": "低调光线·暗部占画面70%,侧面硬光勾勒人物轮廓,冷暖光对比强烈,海风吹过产生呼啸声,营造紧迫感",
+ "emotion": "紧张感↑↑·警惕↑↑(悬置)",
+ "duration": 7,
+ "bgm_prompt": "紧张感逐渐升级的音效,低频持续音",
+ "sound_effect": "呼吸声、金属摩擦声、海风呼啸声",
+ "characters": [159, 160],
+ "is_primary": true
+ }
+ ]
+}
+
+**dialogue字段说明**:
+- 如果有对话,格式为:角色名:\"台词内容\"
+- 多人对话用空格分隔:角色A:\"...\" 角色B:\"...\"
+- 独白格式为:(独白)内容
+- 旁白格式为:(旁白)内容
+- 无对话时填写空字符串:""
+- **对话内容必须从原剧本中提取,保持原汁原味**
+
+**角色和背景要求**:
+- characters字段必须包含该镜头中出现的所有角色ID(数字数组格式)
+- 只提取实际出现的角色ID,不出现角色则为空数组[]
+- **角色ID必须严格使用【本剧可用角色列表】中的id字段(数字),不得使用其他ID或自创角色**
+- 例如:如果镜头中出现李明(id:159)和王芳(id:160),则characters字段应为[159, 160]
+- scene_id字段必须从【本剧已提取的场景背景列表】中选择最匹配的背景ID(数字)
+- 如果列表中没有合适的背景,则scene_id填null
+- 例如:如果镜头发生在"城市公寓卧室·凌晨",应选择id为1的场景背景
+
+**duration时长估算规则(秒)**:
+- **所有镜头时长必须在4-12秒范围内**,确保节奏合理流畅
+- **综合估算原则**:时长由对话内容、动作复杂度、情绪节奏三方面综合决定
+
+**估算步骤**:
+1. **基础时长**(从场景内容判断):
+ - 纯对话场景(无明显动作):基础4秒
+ - 纯动作场景(无对话):基础5秒
+ - 对话+动作混合场景:基础6秒
+
+2. **对话调整**(根据台词字数增加时长):
+ - 无对话:+0秒
+ - 短对话(1-20字):+1-2秒
+ - 中等对话(21-50字):+2-4秒
+ - 长对话(51字以上):+4-6秒
+
+3. **动作调整**(根据动作复杂度增加时长):
+ - 无动作/静态:+0秒
+ - 简单动作(表情、转身、拿物品):+0-1秒
+ - 一般动作(走动、开门、坐下):+1-2秒
+ - 复杂动作(打斗、追逐、大幅度移动):+2-4秒
+ - 环境展示(全景扫描、氛围营造):+2-5秒
+
+4. **最终时长** = 基础时长 + 对话调整 + 动作调整,确保结果在4-12秒范围内
+
+**示例**:
+- "陈峥转身离开"(简单动作,无对话):5 + 0 + 1 = 6秒
+- "李芳:\"你要去哪里?\""(短对话,无动作):4 + 2 + 0 = 6秒
+- "陈峥推开房门,李芳:\"终于找到你了,这些年你去哪了?\""(一般动作+中等对话):6 + 3 + 2 = 11秒
+- "两人在雨中激烈搏斗,陈峥:\"住手!\""(复杂动作+短对话):6 + 2 + 4 = 12秒
+
+**重要**:准确估算每个镜头时长,所有分镜时长之和将作为剧集总时长
+
+**特别要求**:
+- **【极其重要】必须100%%完整拆解整个剧本,不得省略、跳过、压缩任何剧情内容**
+- **从剧本第一个字到最后一个字,逐句逐段转换为分镜**
+- **每个对话、每个动作、每个场景转换都必须有对应的分镜**
+- 剧本越长,分镜数量越多(短剧本15-30个,中等剧本30-60个,长剧本60-100个甚至更多)
+- **宁可分镜多,也不要遗漏剧情**:一个长场景可拆分为多个连续分镜
+- 每个镜头只描述一个主要动作
+- 区分主镜(is_primary: true)和链接镜(is_primary: false)
+- 确保情绪节奏有变化
+- **duration字段至关重要**:准确估算每个镜头时长,这将用于计算整集时长
+- 严格按照JSON格式输出
+
+**【禁止行为】**:
+- ❌ 禁止用一个镜头概括多个场景
+- ❌ 禁止跳过任何对话或独白
+- ❌ 禁止省略剧情发展过程
+- ❌ 禁止合并本应分开的镜头
+- ✅ 正确做法:剧本有多少内容,就拆解出对应数量的分镜,确保观众看完所有分镜能完整了解剧情
+
+**【关键】场景描述详细度要求**(这些描述将直接用于视频生成模型):
+1. **时间(time)字段**:必须包含≥15字的详细描述
+ - ✓ 好例子:"深夜22:30·月光从破窗斜射入仓库,在地面积水中形成银白色反光,墙角昏暗不清"
+ - ✗ 差例子:"深夜"
+
+2. **地点(location)字段**:必须包含≥20字的详细场景描述
+ - ✓ 好例子:"废弃码头仓库·锈蚀货架林立,地面积水反射微弱灯光,墙角堆放腐朽木箱和渔网,空气中弥漫潮湿霉味"
+ - ✗ 差例子:"仓库"
+
+3. **动作(action)字段**:必须包含≥25字的详细动作描述,包括肢体细节和表情
+ - ✓ 好例子:"陈峥弯腰双手握住撬棍用力撬动保险箱门,手臂青筋暴起,眉头紧锁,汗水从额头滑落脸颊,呼吸急促"
+ - ✗ 差例子:"陈峥打开保险箱"
+
+4. **结果(result)字段**:必须包含≥25字的详细视觉结果描述
+ - ✓ 好例子:"保险箱门突然弹开发出刺耳金属声,扬起灰尘在手电筒光束中飘散,箱内空无一物只有几张发黄的旧报纸,陈峥表情从期待转为震惊和失望,瞳孔放大"
+ - ✗ 差例子:"门打开了"
+
+5. **氛围(atmosphere)字段**:必须包含≥20字的环境氛围描述,包括光线、色调、声音
+ - ✓ 好例子:"昏暗冷色调·青灰色为主,只有手电筒光束在黑暗中晃动,远处传来海浪拍打码头的沉闷声,整体氛围压抑沉重"
+ - ✗ 差例子:"昏暗"
+
+**描述原则**:
+- 所有描述性字段要像为盲人讲述画面一样详细
+- 包含感官细节:视觉、听觉、触觉、嗅觉
+- 描述光线、色彩、质感、动态
+- 为视频生成AI提供足够的画面构建信息
+- 避免抽象词汇,使用具象的视觉化描述`, characterList, sceneList, scriptContent)
+
+ // 调用AI服务生成
+ text, err := s.aiService.GenerateText(prompt, "")
+ if err != nil {
+ s.log.Errorw("Failed to generate storyboard", "error", err)
+ return nil, fmt.Errorf("生成分镜头失败: %w", err)
+ }
+
+ // 解析JSON结果
+ var result GenerateStoryboardResult
+ if err := utils.SafeParseAIJSON(text, &result); err != nil {
+ s.log.Errorw("Failed to parse storyboard JSON", "error", err, "response", text[:min(500, len(text))])
+ return nil, fmt.Errorf("解析分镜头结果失败: %w", err)
+ }
+
+ result.Total = len(result.Storyboards)
+
+ // 计算总时长(所有分镜时长之和)
+ totalDuration := 0
+ for _, sb := range result.Storyboards {
+ totalDuration += sb.Duration
+ }
+
+ s.log.Infow("Storyboard generated",
+ "episode_id", episodeID,
+ "count", result.Total,
+ "total_duration_seconds", totalDuration)
+
+ // 保存分镜头到数据库
+ if err := s.saveStoryboards(episodeID, result.Storyboards); err != nil {
+ s.log.Errorw("Failed to save storyboards", "error", err)
+ return nil, fmt.Errorf("保存分镜头失败: %w", err)
+ }
+
+ // 更新剧集时长(秒转分钟,向上取整)
+ durationMinutes := (totalDuration + 59) / 60
+ if err := s.db.Model(&models.Episode{}).Where("id = ?", episodeID).Update("duration", durationMinutes).Error; err != nil {
+ s.log.Errorw("Failed to update episode duration", "error", err)
+ // 不中断流程,只记录错误
+ } else {
+ s.log.Infow("Episode duration updated",
+ "episode_id", episodeID,
+ "duration_seconds", totalDuration,
+ "duration_minutes", durationMinutes)
+ }
+
+ return &result, nil
+}
+
+// generateImagePrompt 生成专门用于图片生成的提示词(首帧静态画面)
+func (s *StoryboardService) generateImagePrompt(sb Storyboard) string {
+ var parts []string
+
+ // 1. 完整的场景背景描述
+ if sb.Location != "" {
+ locationDesc := sb.Location
+ if sb.Time != "" {
+ locationDesc += ", " + sb.Time
+ }
+ parts = append(parts, locationDesc)
+ }
+
+ // 2. 角色初始静态姿态(去除动作过程,只保留起始状态)
+ if sb.Action != "" {
+ initialPose := extractInitialPose(sb.Action)
+ if initialPose != "" {
+ parts = append(parts, initialPose)
+ }
+ }
+
+ // 3. 情绪氛围
+ if sb.Emotion != "" {
+ parts = append(parts, sb.Emotion)
+ }
+
+ // 4. 动漫风格
+ parts = append(parts, "anime style, first frame")
+
+ if len(parts) > 0 {
+ return strings.Join(parts, ", ")
+ }
+ return "anime scene"
+}
+
+// extractInitialPose 提取初始静态姿态(去除动作过程)
+func extractInitialPose(action string) string {
+ // 去除动作过程关键词,保留初始状态描述
+ processWords := []string{
+ "然后", "接着", "接下来", "随后", "紧接着",
+ "向下", "向上", "向前", "向后", "向左", "向右",
+ "开始", "继续", "逐渐", "慢慢", "快速", "突然", "猛然",
+ }
+
+ result := action
+ for _, word := range processWords {
+ if idx := strings.Index(result, word); idx > 0 {
+ // 在动作过程词之前截断
+ result = result[:idx]
+ break
+ }
+ }
+
+ // 清理末尾标点
+ result = strings.TrimRight(result, ",。,. ")
+ return strings.TrimSpace(result)
+}
+
+// extractSimpleLocation 提取简化的场景地点(去除详细描述)
+func extractSimpleLocation(location string) string {
+ // 在"·"符号处截断,只保留主场景名称
+ if idx := strings.Index(location, "·"); idx > 0 {
+ return strings.TrimSpace(location[:idx])
+ }
+
+ // 如果有逗号,只保留第一部分
+ if idx := strings.Index(location, ","); idx > 0 {
+ return strings.TrimSpace(location[:idx])
+ }
+ if idx := strings.Index(location, ","); idx > 0 {
+ return strings.TrimSpace(location[:idx])
+ }
+
+ // 限制长度不超过15个字符
+ maxLen := 15
+ if len(location) > maxLen {
+ return strings.TrimSpace(location[:maxLen])
+ }
+
+ return strings.TrimSpace(location)
+}
+
+// extractSimplePose 提取简单的核心姿态关键词(不超过10个字)
+func extractSimplePose(action string) string {
+ // 只提取前面最多10个字符作为核心姿态
+ runes := []rune(action)
+ maxLen := 10
+ if len(runes) > maxLen {
+ // 在标点符号处截断
+ truncated := runes[:maxLen]
+ for i := maxLen - 1; i >= 0; i-- {
+ if truncated[i] == ',' || truncated[i] == '。' || truncated[i] == ',' || truncated[i] == '.' {
+ truncated = runes[:i]
+ break
+ }
+ }
+ return strings.TrimSpace(string(truncated))
+ }
+ return strings.TrimSpace(action)
+}
+
+// extractFirstFramePose 从动作描述中提取首帧静态姿态
+func extractFirstFramePose(action string) string {
+ // 去除表示动作过程的关键词,保留初始状态
+ processWords := []string{
+ "然后", "接着", "向下", "向前", "走向", "冲向", "转身",
+ "开始", "继续", "逐渐", "慢慢", "快速", "突然",
+ }
+
+ pose := action
+ for _, word := range processWords {
+ // 简单处理:在这些词之前截断
+ if idx := strings.Index(pose, word); idx > 0 {
+ pose = pose[:idx]
+ break
+ }
+ }
+
+ // 清理末尾标点
+ pose = strings.TrimRight(pose, ",。,.")
+ return strings.TrimSpace(pose)
+}
+
+// extractCompositionType 从镜头类型中提取构图类型(去除运镜)
+func extractCompositionType(shotType string) string {
+ // 去除运镜相关描述
+ cameraMovements := []string{
+ "晃动", "摇晃", "推进", "拉远", "跟随", "环绕",
+ "运镜", "摄影", "移动", "旋转",
+ }
+
+ comp := shotType
+ for _, movement := range cameraMovements {
+ comp = strings.ReplaceAll(comp, movement, "")
+ }
+
+ // 清理多余的标点和空格
+ comp = strings.ReplaceAll(comp, "··", "·")
+ comp = strings.ReplaceAll(comp, "·", " ")
+ comp = strings.TrimSpace(comp)
+
+ return comp
+}
+
+// generateVideoPrompt 生成专门用于视频生成的提示词(包含运镜和动态元素)
+func (s *StoryboardService) generateVideoPrompt(sb Storyboard) string {
+ var parts []string
+
+ // 1. 人物动作
+ if sb.Action != "" {
+ parts = append(parts, fmt.Sprintf("Action: %s", sb.Action))
+ }
+
+ // 2. 对话
+ if sb.Dialogue != "" {
+ parts = append(parts, fmt.Sprintf("Dialogue: %s", sb.Dialogue))
+ }
+
+ // 3. 镜头运动(视频特有)
+ if sb.Movement != "" {
+ parts = append(parts, fmt.Sprintf("Camera movement: %s", sb.Movement))
+ }
+
+ // 4. 镜头类型和角度
+ if sb.ShotType != "" {
+ parts = append(parts, fmt.Sprintf("Shot type: %s", sb.ShotType))
+ }
+ if sb.Angle != "" {
+ parts = append(parts, fmt.Sprintf("Camera angle: %s", sb.Angle))
+ }
+
+ // 5. 场景环境
+ if sb.Location != "" {
+ locationDesc := sb.Location
+ if sb.Time != "" {
+ locationDesc += ", " + sb.Time
+ }
+ parts = append(parts, fmt.Sprintf("Scene: %s", locationDesc))
+ }
+
+ // 6. 环境氛围
+ if sb.Atmosphere != "" {
+ parts = append(parts, fmt.Sprintf("Atmosphere: %s", sb.Atmosphere))
+ }
+
+ // 7. 情绪和结果
+ if sb.Emotion != "" {
+ parts = append(parts, fmt.Sprintf("Mood: %s", sb.Emotion))
+ }
+ if sb.Result != "" {
+ parts = append(parts, fmt.Sprintf("Result: %s", sb.Result))
+ }
+
+ // 8. 音频元素
+ if sb.BgmPrompt != "" {
+ parts = append(parts, fmt.Sprintf("BGM: %s", sb.BgmPrompt))
+ }
+ if sb.SoundEffect != "" {
+ parts = append(parts, fmt.Sprintf("Sound effects: %s", sb.SoundEffect))
+ }
+
+ // 9. 视频风格要求
+ parts = append(parts, "Style: cinematic anime style, smooth camera motion, natural character movement")
+
+ if len(parts) > 0 {
+ return strings.Join(parts, ". ")
+ }
+ return "Anime style video scene"
+}
+
+func (s *StoryboardService) saveStoryboards(episodeID string, storyboards []Storyboard) error {
+ // 开启事务
+ return s.db.Transaction(func(tx *gorm.DB) error {
+ // 获取该剧集所有的分镜ID
+ var storyboardIDs []uint
+ if err := tx.Model(&models.Storyboard{}).
+ Where("episode_id = ?", episodeID).
+ Pluck("id", &storyboardIDs).Error; err != nil {
+ return err
+ }
+
+ // 如果有分镜,先清理关联的image_generations的storyboard_id
+ if len(storyboardIDs) > 0 {
+ if err := tx.Model(&models.ImageGeneration{}).
+ Where("storyboard_id IN ?", storyboardIDs).
+ Update("storyboard_id", nil).Error; err != nil {
+ return err
+ }
+ }
+
+ // 删除该剧集已有的分镜头
+ if err := tx.Where("episode_id = ?", episodeID).Delete(&models.Storyboard{}).Error; err != nil {
+ return err
+ }
+
+ // 注意:不删除背景,因为背景是在分镜拆解前就提取好的
+ // AI会直接返回scene_id,不需要在这里做字符串匹配
+
+ // 保存新的分镜头
+ for _, sb := range storyboards {
+ // 构建描述信息,包含对话
+ description := fmt.Sprintf("【镜头类型】%s\n【运镜】%s\n【动作】%s\n【对话】%s\n【结果】%s\n【情绪】%s",
+ sb.ShotType, sb.Movement, sb.Action, sb.Dialogue, sb.Result, sb.Emotion)
+
+ // 生成两种专用提示词
+ imagePrompt := s.generateImagePrompt(sb) // 专用于图片生成
+ videoPrompt := s.generateVideoPrompt(sb) // 专用于视频生成
+
+ // 处理 dialogue 字段
+ var dialoguePtr *string
+ if sb.Dialogue != "" {
+ dialoguePtr = &sb.Dialogue
+ }
+
+ // 使用AI直接返回的SceneID
+ if sb.SceneID != nil {
+ s.log.Infow("Background ID from AI",
+ "shot_number", sb.ShotNumber,
+ "scene_id", *sb.SceneID)
+ }
+
+ epID, _ := strconv.ParseUint(episodeID, 10, 32)
+
+ // 处理 title 字段
+ var titlePtr *string
+ if sb.Title != "" {
+ titlePtr = &sb.Title
+ }
+
+ // 处理shot_type、angle、movement字段
+ var shotTypePtr, anglePtr, movementPtr *string
+ if sb.ShotType != "" {
+ shotTypePtr = &sb.ShotType
+ }
+ if sb.Angle != "" {
+ anglePtr = &sb.Angle
+ }
+ if sb.Movement != "" {
+ movementPtr = &sb.Movement
+ }
+
+ // 处理bgm_prompt、sound_effect字段
+ var bgmPromptPtr, soundEffectPtr *string
+ if sb.BgmPrompt != "" {
+ bgmPromptPtr = &sb.BgmPrompt
+ }
+ if sb.SoundEffect != "" {
+ soundEffectPtr = &sb.SoundEffect
+ }
+
+ // 处理result、atmosphere字段
+ var resultPtr, atmospherePtr *string
+ if sb.Result != "" {
+ resultPtr = &sb.Result
+ }
+ if sb.Atmosphere != "" {
+ atmospherePtr = &sb.Atmosphere
+ }
+
+ scene := models.Storyboard{
+ EpisodeID: uint(epID),
+ SceneID: sb.SceneID,
+ StoryboardNumber: sb.ShotNumber,
+ Title: titlePtr,
+ Location: &sb.Location,
+ Time: &sb.Time,
+ ShotType: shotTypePtr,
+ Angle: anglePtr,
+ Movement: movementPtr,
+ Description: &description,
+ Action: &sb.Action,
+ Result: resultPtr,
+ Atmosphere: atmospherePtr,
+ Dialogue: dialoguePtr,
+ ImagePrompt: &imagePrompt,
+ VideoPrompt: &videoPrompt,
+ BgmPrompt: bgmPromptPtr,
+ SoundEffect: soundEffectPtr,
+ Duration: sb.Duration,
+ }
+
+ if err := tx.Create(&scene).Error; err != nil {
+ s.log.Errorw("Failed to create scene", "error", err, "shot_number", sb.ShotNumber)
+ return err
+ }
+
+ // 关联角色
+ if len(sb.Characters) > 0 {
+ var characters []models.Character
+ if err := tx.Where("id IN ?", sb.Characters).Find(&characters).Error; err != nil {
+ s.log.Warnw("Failed to load characters for association", "error", err, "character_ids", sb.Characters)
+ } else if len(characters) > 0 {
+ if err := tx.Model(&scene).Association("Characters").Append(characters); err != nil {
+ s.log.Warnw("Failed to associate characters", "error", err, "shot_number", sb.ShotNumber)
+ } else {
+ s.log.Infow("Characters associated successfully",
+ "shot_number", sb.ShotNumber,
+ "character_ids", sb.Characters,
+ "count", len(characters))
+ }
+ }
+ }
+ }
+
+ s.log.Infow("Storyboards saved successfully", "episode_id", episodeID, "count", len(storyboards))
+ return nil
+ })
+}
+
+// UpdateStoryboardCharacters 更新分镜的角色关联
+func (s *StoryboardService) UpdateStoryboardCharacters(storyboardID string, characterIDs []uint) error {
+ // 查找分镜
+ var storyboard models.Storyboard
+ if err := s.db.First(&storyboard, storyboardID).Error; err != nil {
+ return fmt.Errorf("storyboard not found: %w", err)
+ }
+
+ // 清除现有的角色关联
+ if err := s.db.Model(&storyboard).Association("Characters").Clear(); err != nil {
+ return fmt.Errorf("failed to clear characters: %w", err)
+ }
+
+ // 如果有新的角色ID,加载并关联
+ if len(characterIDs) > 0 {
+ var characters []models.Character
+ if err := s.db.Where("id IN ?", characterIDs).Find(&characters).Error; err != nil {
+ return fmt.Errorf("failed to find characters: %w", err)
+ }
+
+ if err := s.db.Model(&storyboard).Association("Characters").Append(characters); err != nil {
+ return fmt.Errorf("failed to associate characters: %w", err)
+ }
+ }
+
+ s.log.Infow("Storyboard characters updated", "storyboard_id", storyboardID, "character_count", len(characterIDs))
+ return nil
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/application/services/storyboard_update_full.go b/application/services/storyboard_update_full.go
new file mode 100644
index 0000000..d2e1b55
--- /dev/null
+++ b/application/services/storyboard_update_full.go
@@ -0,0 +1,138 @@
+package services
+
+import (
+ "fmt"
+
+ "github.com/drama-generator/backend/domain/models"
+)
+
+// UpdateStoryboard 更新分镜的所有字段,并重新生成提示词
+func (s *StoryboardService) UpdateStoryboard(storyboardID string, updates map[string]interface{}) error {
+ // 查找分镜
+ var storyboard models.Storyboard
+ if err := s.db.First(&storyboard, storyboardID).Error; err != nil {
+ return fmt.Errorf("storyboard not found: %w", err)
+ }
+
+ // 构建用于重新生成提示词的Storyboard结构
+ sb := Storyboard{
+ ShotNumber: storyboard.StoryboardNumber,
+ }
+
+ // 从updates中提取字段并更新
+ updateData := make(map[string]interface{})
+
+ if val, ok := updates["title"].(string); ok && val != "" {
+ updateData["title"] = val
+ sb.Title = val
+ }
+ if val, ok := updates["shot_type"].(string); ok && val != "" {
+ updateData["shot_type"] = val
+ sb.ShotType = val
+ }
+ if val, ok := updates["angle"].(string); ok && val != "" {
+ updateData["angle"] = val
+ sb.Angle = val
+ }
+ if val, ok := updates["movement"].(string); ok && val != "" {
+ updateData["movement"] = val
+ sb.Movement = val
+ }
+ if val, ok := updates["location"].(string); ok && val != "" {
+ updateData["location"] = val
+ sb.Location = val
+ }
+ if val, ok := updates["time"].(string); ok && val != "" {
+ updateData["time"] = val
+ sb.Time = val
+ }
+ if val, ok := updates["action"].(string); ok && val != "" {
+ updateData["action"] = val
+ sb.Action = val
+ }
+ if val, ok := updates["dialogue"].(string); ok && val != "" {
+ updateData["dialogue"] = val
+ sb.Dialogue = val
+ }
+ if val, ok := updates["result"].(string); ok && val != "" {
+ updateData["result"] = val
+ sb.Result = val
+ }
+ if val, ok := updates["atmosphere"].(string); ok && val != "" {
+ updateData["atmosphere"] = val
+ sb.Atmosphere = val
+ }
+ if val, ok := updates["description"].(string); ok && val != "" {
+ updateData["description"] = val
+ }
+ if val, ok := updates["bgm_prompt"].(string); ok && val != "" {
+ updateData["bgm_prompt"] = val
+ sb.BgmPrompt = val
+ }
+ if val, ok := updates["sound_effect"].(string); ok && val != "" {
+ updateData["sound_effect"] = val
+ sb.SoundEffect = val
+ }
+ if val, ok := updates["duration"].(float64); ok {
+ updateData["duration"] = int(val)
+ sb.Duration = int(val)
+ }
+
+ // 使用当前数据库值填充缺失字段(用于生成提示词)
+ if sb.Title == "" && storyboard.Title != nil {
+ sb.Title = *storyboard.Title
+ }
+ if sb.ShotType == "" && storyboard.ShotType != nil {
+ sb.ShotType = *storyboard.ShotType
+ }
+ if sb.Angle == "" && storyboard.Angle != nil {
+ sb.Angle = *storyboard.Angle
+ }
+ if sb.Movement == "" && storyboard.Movement != nil {
+ sb.Movement = *storyboard.Movement
+ }
+ if sb.Location == "" && storyboard.Location != nil {
+ sb.Location = *storyboard.Location
+ }
+ if sb.Time == "" && storyboard.Time != nil {
+ sb.Time = *storyboard.Time
+ }
+ if sb.Action == "" && storyboard.Action != nil {
+ sb.Action = *storyboard.Action
+ }
+ if sb.Dialogue == "" && storyboard.Dialogue != nil {
+ sb.Dialogue = *storyboard.Dialogue
+ }
+ if sb.Result == "" && storyboard.Result != nil {
+ sb.Result = *storyboard.Result
+ }
+ if sb.Atmosphere == "" && storyboard.Atmosphere != nil {
+ sb.Atmosphere = *storyboard.Atmosphere
+ }
+ if sb.BgmPrompt == "" && storyboard.BgmPrompt != nil {
+ sb.BgmPrompt = *storyboard.BgmPrompt
+ }
+ if sb.SoundEffect == "" && storyboard.SoundEffect != nil {
+ sb.SoundEffect = *storyboard.SoundEffect
+ }
+ if sb.Duration == 0 {
+ sb.Duration = storyboard.Duration
+ }
+
+ // 只重新生成video_prompt
+ // image_prompt不自动更新,因为可能对应多张已生成的帧图片
+ videoPrompt := s.generateVideoPrompt(sb)
+
+ updateData["video_prompt"] = videoPrompt
+
+ // 更新数据库
+ if err := s.db.Model(&storyboard).Updates(updateData).Error; err != nil {
+ return fmt.Errorf("failed to update storyboard: %w", err)
+ }
+
+ s.log.Infow("Storyboard updated successfully",
+ "storyboard_id", storyboardID,
+ "fields_updated", len(updateData))
+
+ return nil
+}
diff --git a/application/services/task_service.go b/application/services/task_service.go
new file mode 100644
index 0000000..41be4c0
--- /dev/null
+++ b/application/services/task_service.go
@@ -0,0 +1,113 @@
+package services
+
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/drama-generator/backend/domain/models"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type TaskService struct {
+ db *gorm.DB
+ log *logger.Logger
+}
+
+func NewTaskService(db *gorm.DB, log *logger.Logger) *TaskService {
+ return &TaskService{
+ db: db,
+ log: log,
+ }
+}
+
+// CreateTask 创建新任务
+func (s *TaskService) CreateTask(taskType, resourceID string) (*models.AsyncTask, error) {
+ task := &models.AsyncTask{
+ ID: uuid.New().String(),
+ Type: taskType,
+ Status: "pending",
+ Progress: 0,
+ ResourceID: resourceID,
+ }
+
+ if err := s.db.Create(task).Error; err != nil {
+ return nil, fmt.Errorf("failed to create task: %w", err)
+ }
+
+ return task, nil
+}
+
+// UpdateTaskStatus 更新任务状态
+func (s *TaskService) UpdateTaskStatus(taskID, status string, progress int, message string) error {
+ updates := map[string]interface{}{
+ "status": status,
+ "progress": progress,
+ "message": message,
+ "updated_at": time.Now(),
+ }
+
+ if status == "completed" || status == "failed" {
+ now := time.Now()
+ updates["completed_at"] = &now
+ }
+
+ return s.db.Model(&models.AsyncTask{}).
+ Where("id = ?", taskID).
+ Updates(updates).Error
+}
+
+// UpdateTaskError 更新任务错误
+func (s *TaskService) UpdateTaskError(taskID string, err error) error {
+ now := time.Now()
+ return s.db.Model(&models.AsyncTask{}).
+ Where("id = ?", taskID).
+ Updates(map[string]interface{}{
+ "status": "failed",
+ "error": err.Error(),
+ "progress": 0,
+ "completed_at": &now,
+ "updated_at": time.Now(),
+ }).Error
+}
+
+// UpdateTaskResult 更新任务结果
+func (s *TaskService) UpdateTaskResult(taskID string, result interface{}) error {
+ resultJSON, err := json.Marshal(result)
+ if err != nil {
+ return fmt.Errorf("failed to marshal result: %w", err)
+ }
+
+ now := time.Now()
+ return s.db.Model(&models.AsyncTask{}).
+ Where("id = ?", taskID).
+ Updates(map[string]interface{}{
+ "status": "completed",
+ "progress": 100,
+ "result": string(resultJSON),
+ "completed_at": &now,
+ "updated_at": time.Now(),
+ }).Error
+}
+
+// GetTask 获取任务信息
+func (s *TaskService) GetTask(taskID string) (*models.AsyncTask, error) {
+ var task models.AsyncTask
+ if err := s.db.Where("id = ?", taskID).First(&task).Error; err != nil {
+ return nil, err
+ }
+ return &task, nil
+}
+
+// GetTasksByResource 获取资源相关的所有任务
+func (s *TaskService) GetTasksByResource(resourceID string) ([]*models.AsyncTask, error) {
+ var tasks []*models.AsyncTask
+ if err := s.db.Where("resource_id = ?", resourceID).
+ Order("created_at DESC").
+ Find(&tasks).Error; err != nil {
+ return nil, err
+ }
+ return tasks, nil
+}
diff --git a/application/services/upload_service.go b/application/services/upload_service.go
new file mode 100644
index 0000000..5789c69
--- /dev/null
+++ b/application/services/upload_service.go
@@ -0,0 +1,109 @@
+package services
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/drama-generator/backend/pkg/config"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/google/uuid"
+)
+
+type UploadService struct {
+ storagePath string
+ baseURL string
+ log *logger.Logger
+}
+
+func NewUploadService(cfg *config.Config, log *logger.Logger) (*UploadService, error) {
+ // 确保存储目录存在
+ if err := os.MkdirAll(cfg.Storage.LocalPath, 0755); err != nil {
+ return nil, fmt.Errorf("failed to create storage directory: %w", err)
+ }
+
+ return &UploadService{
+ storagePath: cfg.Storage.LocalPath,
+ baseURL: cfg.Storage.BaseURL,
+ log: log,
+ }, nil
+}
+
+// UploadFile 上传文件到本地存储
+func (s *UploadService) UploadFile(file io.Reader, fileName, contentType string, category string) (string, error) {
+ // 创建分类目录
+ categoryPath := filepath.Join(s.storagePath, category)
+ if err := os.MkdirAll(categoryPath, 0755); err != nil {
+ return "", fmt.Errorf("failed to create category directory: %w", err)
+ }
+
+ // 生成唯一文件名
+ ext := filepath.Ext(fileName)
+ uniqueID := uuid.New().String()
+ timestamp := time.Now().Format("20060102_150405")
+ newFileName := fmt.Sprintf("%s_%s%s", timestamp, uniqueID, ext)
+ filePath := filepath.Join(categoryPath, newFileName)
+
+ // 创建文件
+ dst, err := os.Create(filePath)
+ if err != nil {
+ s.log.Errorw("Failed to create file", "error", err, "path", filePath)
+ return "", fmt.Errorf("创建文件失败: %w", err)
+ }
+ defer dst.Close()
+
+ // 写入文件
+ if _, err := io.Copy(dst, file); err != nil {
+ s.log.Errorw("Failed to write file", "error", err, "path", filePath)
+ return "", fmt.Errorf("写入文件失败: %w", err)
+ }
+
+ // 构建访问URL
+ fileURL := fmt.Sprintf("%s/%s/%s", s.baseURL, category, newFileName)
+
+ s.log.Infow("File uploaded successfully", "path", filePath, "url", fileURL)
+ return fileURL, nil
+}
+
+// UploadCharacterImage 上传角色图片
+func (s *UploadService) UploadCharacterImage(file io.Reader, fileName, contentType string) (string, error) {
+ return s.UploadFile(file, fileName, contentType, "characters")
+}
+
+// DeleteFile 删除本地文件
+func (s *UploadService) DeleteFile(fileURL string) error {
+ // 从URL中提取相对路径
+ // URL格式: http://localhost:8080/static/characters/20060102_150405_uuid.jpg
+ relPath := s.extractRelativePathFromURL(fileURL)
+ if relPath == "" {
+ return fmt.Errorf("invalid file URL")
+ }
+
+ filePath := filepath.Join(s.storagePath, relPath)
+ err := os.Remove(filePath)
+ if err != nil {
+ s.log.Errorw("Failed to delete file", "error", err, "path", filePath)
+ return fmt.Errorf("删除文件失败: %w", err)
+ }
+
+ s.log.Infow("File deleted successfully", "path", filePath)
+ return nil
+}
+
+// extractRelativePathFromURL 从URL中提取相对路径
+func (s *UploadService) extractRelativePathFromURL(fileURL string) string {
+ // 从baseURL后面提取路径
+ // 例如: http://localhost:8080/static/characters/xxx.jpg -> characters/xxx.jpg
+ if len(fileURL) <= len(s.baseURL) {
+ return ""
+ }
+ return fileURL[len(s.baseURL)+1:] // +1 for the '/'
+}
+
+// GetPresignedURL 本地存储不需要预签名URL,直接返回原URL
+func (s *UploadService) GetPresignedURL(objectName string, expiry time.Duration) (string, error) {
+ // 本地存储通过静态文件服务直接访问,不需要预签名
+ return fmt.Sprintf("%s/%s", s.baseURL, objectName), nil
+}
diff --git a/application/services/video_generation_service.go b/application/services/video_generation_service.go
new file mode 100644
index 0000000..9e1ae77
--- /dev/null
+++ b/application/services/video_generation_service.go
@@ -0,0 +1,566 @@
+package services
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "time"
+
+ models "github.com/drama-generator/backend/domain/models"
+ "github.com/drama-generator/backend/infrastructure/storage"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/video"
+ "gorm.io/gorm"
+)
+
+type VideoGenerationService struct {
+ db *gorm.DB
+ transferService *ResourceTransferService
+ log *logger.Logger
+ localStorage *storage.LocalStorage
+ aiService *AIService
+}
+
+func NewVideoGenerationService(db *gorm.DB, transferService *ResourceTransferService, localStorage *storage.LocalStorage, aiService *AIService, log *logger.Logger) *VideoGenerationService {
+ service := &VideoGenerationService{
+ db: db,
+ localStorage: localStorage,
+ transferService: transferService,
+ aiService: aiService,
+ log: log,
+ }
+
+ go service.RecoverPendingTasks()
+
+ return service
+}
+
+type GenerateVideoRequest struct {
+ StoryboardID *uint `json:"storyboard_id"`
+ DramaID string `json:"drama_id" binding:"required"`
+ ImageGenID *uint `json:"image_gen_id"`
+
+ // 参考图模式:single, first_last, multiple, none
+ ReferenceMode string `json:"reference_mode"`
+
+ // 单图模式
+ ImageURL string `json:"image_url"`
+
+ // 首尾帧模式
+ FirstFrameURL *string `json:"first_frame_url"`
+ LastFrameURL *string `json:"last_frame_url"`
+
+ // 多图模式
+ ReferenceImageURLs []string `json:"reference_image_urls"`
+
+ Prompt string `json:"prompt" binding:"required,min=5,max=2000"`
+ Provider string `json:"provider"`
+ Model string `json:"model"`
+ Duration *int `json:"duration"`
+ FPS *int `json:"fps"`
+ AspectRatio *string `json:"aspect_ratio"`
+ Style *string `json:"style"`
+ MotionLevel *int `json:"motion_level"`
+ CameraMotion *string `json:"camera_motion"`
+ Seed *int64 `json:"seed"`
+}
+
+func (s *VideoGenerationService) GenerateVideo(request *GenerateVideoRequest) (*models.VideoGeneration, error) {
+ if request.StoryboardID != nil {
+ var storyboard models.Storyboard
+ if err := s.db.Preload("Episode").Where("id = ?", *request.StoryboardID).First(&storyboard).Error; err != nil {
+ return nil, fmt.Errorf("storyboard not found")
+ }
+ if fmt.Sprintf("%d", storyboard.Episode.DramaID) != request.DramaID {
+ return nil, fmt.Errorf("storyboard does not belong to drama")
+ }
+ }
+
+ if request.ImageGenID != nil {
+ var imageGen models.ImageGeneration
+ if err := s.db.Where("id = ?", *request.ImageGenID).First(&imageGen).Error; err != nil {
+ return nil, fmt.Errorf("image generation not found")
+ }
+ }
+
+ provider := request.Provider
+ if provider == "" {
+ provider = "doubao"
+ }
+
+ dramaID, _ := strconv.ParseUint(request.DramaID, 10, 32)
+
+ videoGen := &models.VideoGeneration{
+ StoryboardID: request.StoryboardID,
+ DramaID: uint(dramaID),
+ ImageGenID: request.ImageGenID,
+ Provider: provider,
+ Prompt: request.Prompt,
+ Model: request.Model,
+ Duration: request.Duration,
+ FPS: request.FPS,
+ AspectRatio: request.AspectRatio,
+ Style: request.Style,
+ MotionLevel: request.MotionLevel,
+ CameraMotion: request.CameraMotion,
+ Seed: request.Seed,
+ Status: models.VideoStatusPending,
+ }
+
+ // 根据参考图模式处理不同的参数
+ if request.ReferenceMode != "" {
+ videoGen.ReferenceMode = &request.ReferenceMode
+ }
+
+ switch request.ReferenceMode {
+ case "single":
+ // 单图模式
+ if request.ImageURL != "" {
+ videoGen.ImageURL = &request.ImageURL
+ }
+ case "first_last":
+ // 首尾帧模式
+ if request.FirstFrameURL != nil {
+ videoGen.FirstFrameURL = request.FirstFrameURL
+ }
+ if request.LastFrameURL != nil {
+ videoGen.LastFrameURL = request.LastFrameURL
+ }
+ case "multiple":
+ // 多图模式
+ if len(request.ReferenceImageURLs) > 0 {
+ referenceImagesJSON, err := json.Marshal(request.ReferenceImageURLs)
+ if err == nil {
+ referenceImagesStr := string(referenceImagesJSON)
+ videoGen.ReferenceImageURLs = &referenceImagesStr
+ }
+ }
+ case "none":
+ // 无参考图,纯文本生成
+ default:
+ // 向后兼容:如果没有指定模式,根据提供的参数自动判断
+ if request.ImageURL != "" {
+ videoGen.ImageURL = &request.ImageURL
+ mode := "single"
+ videoGen.ReferenceMode = &mode
+ } else if request.FirstFrameURL != nil || request.LastFrameURL != nil {
+ videoGen.FirstFrameURL = request.FirstFrameURL
+ videoGen.LastFrameURL = request.LastFrameURL
+ mode := "first_last"
+ videoGen.ReferenceMode = &mode
+ } else if len(request.ReferenceImageURLs) > 0 {
+ referenceImagesJSON, err := json.Marshal(request.ReferenceImageURLs)
+ if err == nil {
+ referenceImagesStr := string(referenceImagesJSON)
+ videoGen.ReferenceImageURLs = &referenceImagesStr
+ mode := "multiple"
+ videoGen.ReferenceMode = &mode
+ }
+ }
+ }
+
+ if err := s.db.Create(videoGen).Error; err != nil {
+ return nil, fmt.Errorf("failed to create record: %w", err)
+ }
+
+ go s.ProcessVideoGeneration(videoGen.ID)
+
+ return videoGen, nil
+}
+
+func (s *VideoGenerationService) ProcessVideoGeneration(videoGenID uint) {
+ var videoGen models.VideoGeneration
+ if err := s.db.First(&videoGen, videoGenID).Error; err != nil {
+ s.log.Errorw("Failed to load video generation", "error", err, "id", videoGenID)
+ return
+ }
+
+ s.db.Model(&videoGen).Update("status", models.VideoStatusProcessing)
+
+ client, err := s.getVideoClient(videoGen.Provider, videoGen.Model)
+ if err != nil {
+ s.log.Errorw("Failed to get video client", "error", err, "provider", videoGen.Provider, "model", videoGen.Model)
+ s.updateVideoGenError(videoGenID, err.Error())
+ return
+ }
+
+ s.log.Infow("Starting video generation", "id", videoGenID, "prompt", videoGen.Prompt, "provider", videoGen.Provider)
+
+ var opts []video.VideoOption
+ if videoGen.Model != "" {
+ opts = append(opts, video.WithModel(videoGen.Model))
+ }
+ if videoGen.Duration != nil {
+ opts = append(opts, video.WithDuration(*videoGen.Duration))
+ }
+ if videoGen.FPS != nil {
+ opts = append(opts, video.WithFPS(*videoGen.FPS))
+ }
+ if videoGen.AspectRatio != nil {
+ opts = append(opts, video.WithAspectRatio(*videoGen.AspectRatio))
+ }
+ if videoGen.Style != nil {
+ opts = append(opts, video.WithStyle(*videoGen.Style))
+ }
+ if videoGen.MotionLevel != nil {
+ opts = append(opts, video.WithMotionLevel(*videoGen.MotionLevel))
+ }
+ if videoGen.CameraMotion != nil {
+ opts = append(opts, video.WithCameraMotion(*videoGen.CameraMotion))
+ }
+ if videoGen.Seed != nil {
+ opts = append(opts, video.WithSeed(*videoGen.Seed))
+ }
+
+ // 根据参考图模式添加相应的选项
+ if videoGen.ReferenceMode != nil {
+ switch *videoGen.ReferenceMode {
+ case "first_last":
+ // 首尾帧模式
+ if videoGen.FirstFrameURL != nil {
+ opts = append(opts, video.WithFirstFrame(*videoGen.FirstFrameURL))
+ }
+ if videoGen.LastFrameURL != nil {
+ opts = append(opts, video.WithLastFrame(*videoGen.LastFrameURL))
+ }
+ case "multiple":
+ // 多图模式
+ if videoGen.ReferenceImageURLs != nil {
+ var imageURLs []string
+ if err := json.Unmarshal([]byte(*videoGen.ReferenceImageURLs), &imageURLs); err == nil {
+ opts = append(opts, video.WithReferenceImages(imageURLs))
+ }
+ }
+ }
+ }
+
+ // 构造imageURL参数(单图模式使用,其他模式传空字符串)
+ imageURL := ""
+ if videoGen.ImageURL != nil {
+ imageURL = *videoGen.ImageURL
+ }
+
+ result, err := client.GenerateVideo(imageURL, videoGen.Prompt, opts...)
+ if err != nil {
+ s.log.Errorw("Video generation API call failed", "error", err, "id", videoGenID)
+ s.updateVideoGenError(videoGenID, err.Error())
+ return
+ }
+
+ if result.TaskID != "" {
+ s.db.Model(&videoGen).Updates(map[string]interface{}{
+ "task_id": result.TaskID,
+ "status": models.VideoStatusProcessing,
+ })
+ go s.pollTaskStatus(videoGenID, result.TaskID, videoGen.Provider, videoGen.Model)
+ return
+ }
+
+ if result.VideoURL != "" {
+ s.completeVideoGeneration(videoGenID, result.VideoURL, &result.Duration, &result.Width, &result.Height, nil)
+ return
+ }
+
+ s.updateVideoGenError(videoGenID, "no task ID or video URL returned")
+}
+
+func (s *VideoGenerationService) pollTaskStatus(videoGenID uint, taskID string, provider string, model string) {
+ client, err := s.getVideoClient(provider, model)
+ if err != nil {
+ s.log.Errorw("Failed to get video client for polling", "error", err)
+ s.updateVideoGenError(videoGenID, "failed to get video client")
+ return
+ }
+
+ maxAttempts := 300
+ interval := 10 * time.Second
+
+ for attempt := 0; attempt < maxAttempts; attempt++ {
+ time.Sleep(interval)
+
+ var videoGen models.VideoGeneration
+ if err := s.db.First(&videoGen, videoGenID).Error; err != nil {
+ s.log.Errorw("Failed to load video generation", "error", err, "id", videoGenID)
+ return
+ }
+
+ if videoGen.Status != models.VideoStatusProcessing {
+ s.log.Infow("Video generation status changed, stopping poll", "id", videoGenID, "status", videoGen.Status)
+ return
+ }
+
+ result, err := client.GetTaskStatus(taskID)
+ if err != nil {
+ s.log.Errorw("Failed to get task status", "error", err, "task_id", taskID)
+ continue
+ }
+
+ if result.Completed {
+ if result.VideoURL != "" {
+ s.completeVideoGeneration(videoGenID, result.VideoURL, &result.Duration, &result.Width, &result.Height, nil)
+ return
+ }
+ s.updateVideoGenError(videoGenID, "task completed but no video URL")
+ return
+ }
+
+ if result.Error != "" {
+ s.updateVideoGenError(videoGenID, result.Error)
+ return
+ }
+
+ s.log.Infow("Video generation in progress", "id", videoGenID, "attempt", attempt+1)
+ }
+
+ s.updateVideoGenError(videoGenID, "polling timeout")
+}
+
+func (s *VideoGenerationService) completeVideoGeneration(videoGenID uint, videoURL string, duration *int, width *int, height *int, firstFrameURL *string) {
+ // 下载视频到本地存储(仅用于缓存,不更新数据库)
+ if s.localStorage != nil && videoURL != "" {
+ _, err := s.localStorage.DownloadFromURL(videoURL, "videos")
+ if err != nil {
+ s.log.Warnw("Failed to download video to local storage",
+ "error", err,
+ "id", videoGenID,
+ "original_url", videoURL)
+ } else {
+ s.log.Infow("Video downloaded to local storage for caching",
+ "id", videoGenID,
+ "original_url", videoURL)
+ }
+ }
+
+ // 下载首帧图片到本地存储(仅用于缓存,不更新数据库)
+ if firstFrameURL != nil && *firstFrameURL != "" && s.localStorage != nil {
+ _, err := s.localStorage.DownloadFromURL(*firstFrameURL, "video_frames")
+ if err != nil {
+ s.log.Warnw("Failed to download first frame to local storage",
+ "error", err,
+ "id", videoGenID,
+ "original_url", *firstFrameURL)
+ } else {
+ s.log.Infow("First frame downloaded to local storage for caching",
+ "id", videoGenID,
+ "original_url", *firstFrameURL)
+ }
+ }
+
+ // 数据库中保持使用原始URL
+ updates := map[string]interface{}{
+ "status": models.VideoStatusCompleted,
+ "video_url": videoURL,
+ }
+ if duration != nil {
+ updates["duration"] = *duration
+ }
+ if width != nil {
+ updates["width"] = *width
+ }
+ if height != nil {
+ updates["height"] = *height
+ }
+ if firstFrameURL != nil {
+ updates["first_frame_url"] = *firstFrameURL
+ }
+
+ if err := s.db.Model(&models.VideoGeneration{}).Where("id = ?", videoGenID).Updates(updates).Error; err != nil {
+ s.log.Errorw("Failed to update video generation", "error", err, "id", videoGenID)
+ return
+ }
+
+ var videoGen models.VideoGeneration
+ if err := s.db.First(&videoGen, videoGenID).Error; err == nil {
+ if videoGen.StoryboardID != nil {
+ if err := s.db.Model(&models.Storyboard{}).Where("id = ?", *videoGen.StoryboardID).Update("video_url", videoURL).Error; err != nil {
+ s.log.Warnw("Failed to update storyboard video_url", "storyboard_id", *videoGen.StoryboardID, "error", err)
+ }
+ }
+ }
+
+ s.log.Infow("Video generation completed", "id", videoGenID, "url", videoURL)
+}
+
+func (s *VideoGenerationService) updateVideoGenError(videoGenID uint, errorMsg string) {
+ if err := s.db.Model(&models.VideoGeneration{}).Where("id = ?", videoGenID).Updates(map[string]interface{}{
+ "status": models.VideoStatusFailed,
+ "error_msg": errorMsg,
+ }).Error; err != nil {
+ s.log.Errorw("Failed to update video generation error", "error", err, "id", videoGenID)
+ }
+}
+
+func (s *VideoGenerationService) getVideoClient(provider string, modelName string) (video.VideoClient, error) {
+ // 根据模型名称获取AI配置
+ var config *models.AIServiceConfig
+ var err error
+
+ if modelName != "" {
+ config, err = s.aiService.GetConfigForModel("video", modelName)
+ if err != nil {
+ s.log.Warnw("Failed to get config for model, using default", "model", modelName, "error", err)
+ config, err = s.aiService.GetDefaultConfig("video")
+ if err != nil {
+ return nil, fmt.Errorf("no video AI config found: %w", err)
+ }
+ }
+ } else {
+ config, err = s.aiService.GetDefaultConfig("video")
+ if err != nil {
+ return nil, fmt.Errorf("no video AI config found: %w", err)
+ }
+ }
+
+ // 使用配置中的信息创建客户端
+ baseURL := config.BaseURL
+ apiKey := config.APIKey
+ model := modelName
+ if model == "" && len(config.Model) > 0 {
+ model = config.Model[0]
+ }
+
+ // 根据配置中的 provider 创建对应的客户端
+ var endpoint string
+ var queryEndpoint string
+
+ switch config.Provider {
+ case "chatfire":
+ endpoint = "/video/generations"
+ queryEndpoint = "/video/task/{taskId}"
+ return video.NewChatfireClient(baseURL, apiKey, model, endpoint, queryEndpoint), nil
+ case "doubao", "volcengine", "volces":
+ endpoint = "/contents/generations/tasks"
+ queryEndpoint = "/contents/generations/tasks/{taskId}"
+ return video.NewVolcesArkClient(baseURL, apiKey, model, endpoint, queryEndpoint), nil
+ case "openai":
+ // OpenAI Sora 使用 /v1/videos 端点
+ return video.NewOpenAISoraClient(baseURL, apiKey, model), nil
+ case "runway":
+ return video.NewRunwayClient(baseURL, apiKey, model), nil
+ case "pika":
+ return video.NewPikaClient(baseURL, apiKey, model), nil
+ case "minimax":
+ return video.NewMinimaxClient(baseURL, apiKey, model), nil
+ default:
+ return nil, fmt.Errorf("unsupported video provider: %s", provider)
+ }
+}
+
+func (s *VideoGenerationService) RecoverPendingTasks() {
+ var pendingVideos []models.VideoGeneration
+ if err := s.db.Where("status = ? AND task_id != ''", models.VideoStatusProcessing).Find(&pendingVideos).Error; err != nil {
+ s.log.Errorw("Failed to load pending video tasks", "error", err)
+ return
+ }
+
+ s.log.Infow("Recovering pending video generation tasks", "count", len(pendingVideos))
+
+ for _, videoGen := range pendingVideos {
+ go s.pollTaskStatus(videoGen.ID, *videoGen.TaskID, videoGen.Provider, videoGen.Model)
+ }
+}
+
+func (s *VideoGenerationService) GetVideoGeneration(id uint) (*models.VideoGeneration, error) {
+ var videoGen models.VideoGeneration
+ if err := s.db.First(&videoGen, id).Error; err != nil {
+ return nil, err
+ }
+ return &videoGen, nil
+}
+
+func (s *VideoGenerationService) ListVideoGenerations(dramaID *uint, storyboardID *uint, status string, limit int, offset int) ([]*models.VideoGeneration, int64, error) {
+ var videos []*models.VideoGeneration
+ var total int64
+
+ query := s.db.Model(&models.VideoGeneration{})
+
+ if dramaID != nil {
+ query = query.Where("drama_id = ?", *dramaID)
+ }
+ if storyboardID != nil {
+ query = query.Where("storyboard_id = ?", *storyboardID)
+ }
+ if status != "" {
+ query = query.Where("status = ?", status)
+ }
+
+ if err := query.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+
+ if err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&videos).Error; err != nil {
+ return nil, 0, err
+ }
+
+ return videos, total, nil
+}
+
+func (s *VideoGenerationService) GenerateVideoFromImage(imageGenID uint) (*models.VideoGeneration, error) {
+ var imageGen models.ImageGeneration
+ if err := s.db.First(&imageGen, imageGenID).Error; err != nil {
+ return nil, fmt.Errorf("image generation not found")
+ }
+
+ if imageGen.Status != models.ImageStatusCompleted || imageGen.ImageURL == nil {
+ return nil, fmt.Errorf("image is not ready")
+ }
+
+ // 获取关联的Storyboard以获取时长
+ var duration *int
+ if imageGen.StoryboardID != nil {
+ var storyboard models.Storyboard
+ if err := s.db.Where("id = ?", *imageGen.StoryboardID).First(&storyboard).Error; err == nil {
+ duration = &storyboard.Duration
+ s.log.Infow("Using storyboard duration for video generation",
+ "storyboard_id", *imageGen.StoryboardID,
+ "duration", storyboard.Duration)
+ }
+ }
+
+ req := &GenerateVideoRequest{
+ DramaID: fmt.Sprintf("%d", imageGen.DramaID),
+ StoryboardID: imageGen.StoryboardID,
+ ImageGenID: &imageGenID,
+ ImageURL: *imageGen.ImageURL,
+ Prompt: imageGen.Prompt,
+ Provider: "doubao",
+ Duration: duration,
+ }
+
+ return s.GenerateVideo(req)
+}
+
+func (s *VideoGenerationService) BatchGenerateVideosForEpisode(episodeID string) ([]*models.VideoGeneration, error) {
+ var episode models.Episode
+ if err := s.db.Preload("Storyboards").Where("id = ?", episodeID).First(&episode).Error; err != nil {
+ return nil, fmt.Errorf("episode not found")
+ }
+
+ var results []*models.VideoGeneration
+ for _, storyboard := range episode.Storyboards {
+ if storyboard.ImagePrompt == nil {
+ continue
+ }
+
+ var imageGen models.ImageGeneration
+ if err := s.db.Where("storyboard_id = ? AND status = ?", storyboard.ID, models.ImageStatusCompleted).
+ Order("created_at DESC").First(&imageGen).Error; err != nil {
+ s.log.Warnw("No completed image for storyboard", "storyboard_id", storyboard.ID)
+ continue
+ }
+
+ videoGen, err := s.GenerateVideoFromImage(imageGen.ID)
+ if err != nil {
+ s.log.Errorw("Failed to generate video", "storyboard_id", storyboard.ID, "error", err)
+ continue
+ }
+
+ results = append(results, videoGen)
+ }
+
+ return results, nil
+}
+
+func (s *VideoGenerationService) DeleteVideoGeneration(id uint) error {
+ return s.db.Delete(&models.VideoGeneration{}, id).Error
+}
diff --git a/application/services/video_merge_service.go b/application/services/video_merge_service.go
new file mode 100644
index 0000000..1a14f2f
--- /dev/null
+++ b/application/services/video_merge_service.go
@@ -0,0 +1,557 @@
+package services
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "time"
+
+ models "github.com/drama-generator/backend/domain/models"
+ "github.com/drama-generator/backend/infrastructure/external/ffmpeg"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/drama-generator/backend/pkg/video"
+ "gorm.io/gorm"
+)
+
+type VideoMergeService struct {
+ db *gorm.DB
+ aiService *AIService
+ transferService *ResourceTransferService
+ ffmpeg *ffmpeg.FFmpeg
+ storagePath string
+ baseURL string
+ log *logger.Logger
+}
+
+func NewVideoMergeService(db *gorm.DB, transferService *ResourceTransferService, storagePath, baseURL string, log *logger.Logger) *VideoMergeService {
+ return &VideoMergeService{
+ db: db,
+ aiService: NewAIService(db, log),
+ transferService: transferService,
+ ffmpeg: ffmpeg.NewFFmpeg(log),
+ storagePath: storagePath,
+ baseURL: baseURL,
+ log: log,
+ }
+}
+
+type MergeVideoRequest struct {
+ EpisodeID string `json:"episode_id" binding:"required"`
+ DramaID string `json:"drama_id" binding:"required"`
+ Title string `json:"title"`
+ Scenes []models.SceneClip `json:"scenes" binding:"required,min=1"`
+ Provider string `json:"provider"`
+ Model string `json:"model"`
+}
+
+func (s *VideoMergeService) MergeVideos(req *MergeVideoRequest) (*models.VideoMerge, error) {
+ // 验证episode权限
+ var episode models.Episode
+ if err := s.db.Preload("Drama").Where("id = ?", req.EpisodeID).First(&episode).Error; err != nil {
+ return nil, fmt.Errorf("episode not found")
+ }
+
+ // 验证所有场景都有视频
+ for i, scene := range req.Scenes {
+ if scene.VideoURL == "" {
+ return nil, fmt.Errorf("scene %d has no video", i+1)
+ }
+ }
+
+ provider := req.Provider
+ if provider == "" {
+ provider = "doubao"
+ }
+
+ // 序列化场景列表
+ scenesJSON, err := json.Marshal(req.Scenes)
+ if err != nil {
+ return nil, fmt.Errorf("failed to serialize scenes: %w", err)
+ }
+
+ s.log.Infow("Serialized scenes to JSON",
+ "scenes_count", len(req.Scenes),
+ "scenes_json", string(scenesJSON))
+
+ epID, _ := strconv.ParseUint(req.EpisodeID, 10, 32)
+ dramaID, _ := strconv.ParseUint(req.DramaID, 10, 32)
+
+ videoMerge := &models.VideoMerge{
+ EpisodeID: uint(epID),
+ DramaID: uint(dramaID),
+ Title: req.Title,
+ Provider: provider,
+ Model: &req.Model,
+ Scenes: scenesJSON,
+ Status: models.VideoMergeStatusPending,
+ }
+
+ if err := s.db.Create(videoMerge).Error; err != nil {
+ return nil, fmt.Errorf("failed to create merge record: %w", err)
+ }
+
+ go s.processMergeVideo(videoMerge.ID)
+
+ return videoMerge, nil
+}
+
+func (s *VideoMergeService) processMergeVideo(mergeID uint) {
+ var videoMerge models.VideoMerge
+ if err := s.db.First(&videoMerge, mergeID).Error; err != nil {
+ s.log.Errorw("Failed to load video merge", "error", err, "id", mergeID)
+ return
+ }
+
+ s.db.Model(&videoMerge).Update("status", models.VideoMergeStatusProcessing)
+
+ client, err := s.getVideoClient(videoMerge.Provider)
+ if err != nil {
+ s.updateMergeError(mergeID, err.Error())
+ return
+ }
+
+ // 解析场景列表
+ var scenes []models.SceneClip
+ if err := json.Unmarshal(videoMerge.Scenes, &scenes); err != nil {
+ s.updateMergeError(mergeID, fmt.Sprintf("failed to parse scenes: %v", err))
+ return
+ }
+
+ // 调用视频合并API
+ result, err := s.mergeVideoClips(client, scenes)
+ if err != nil {
+ s.updateMergeError(mergeID, err.Error())
+ return
+ }
+
+ if !result.Completed {
+ s.db.Model(&videoMerge).Updates(map[string]interface{}{
+ "status": models.VideoMergeStatusProcessing,
+ "task_id": result.TaskID,
+ })
+ go s.pollMergeStatus(mergeID, client, result.TaskID)
+ return
+ }
+
+ s.completeMerge(mergeID, result)
+}
+
+func (s *VideoMergeService) mergeVideoClips(client video.VideoClient, scenes []models.SceneClip) (*video.VideoResult, error) {
+ if len(scenes) == 0 {
+ return nil, fmt.Errorf("no scenes to merge")
+ }
+
+ // 按Order字段排序场景
+ sort.Slice(scenes, func(i, j int) bool {
+ return scenes[i].Order < scenes[j].Order
+ })
+
+ s.log.Infow("Merging video clips with FFmpeg", "scene_count", len(scenes))
+
+ // 计算总时长
+ var totalDuration float64
+ for _, scene := range scenes {
+ totalDuration += scene.Duration
+ }
+
+ // 准备FFmpeg合成选项
+ clips := make([]ffmpeg.VideoClip, len(scenes))
+ for i, scene := range scenes {
+ clips[i] = ffmpeg.VideoClip{
+ URL: scene.VideoURL,
+ Duration: scene.Duration,
+ StartTime: scene.StartTime,
+ EndTime: scene.EndTime,
+ Transition: scene.Transition,
+ }
+
+ s.log.Infow("Clip added to merge queue",
+ "order", scene.Order,
+ "index", i,
+ "duration", scene.Duration,
+ "start_time", scene.StartTime,
+ "end_time", scene.EndTime)
+ }
+
+ // 创建视频输出目录
+ videoDir := filepath.Join(s.storagePath, "videos", "merged")
+ if err := os.MkdirAll(videoDir, 0755); err != nil {
+ return nil, fmt.Errorf("failed to create video directory: %w", err)
+ }
+
+ // 生成输出文件名
+ fileName := fmt.Sprintf("merged_%d.mp4", time.Now().Unix())
+ outputPath := filepath.Join(videoDir, fileName)
+
+ // 使用FFmpeg合成视频
+ mergedPath, err := s.ffmpeg.MergeVideos(&ffmpeg.MergeOptions{
+ OutputPath: outputPath,
+ Clips: clips,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("ffmpeg merge failed: %w", err)
+ }
+
+ s.log.Infow("Video merged successfully", "path", mergedPath)
+
+ // 生成访问URL(相对路径)
+ relPath := filepath.Join("videos", "merged", fileName)
+ videoURL := fmt.Sprintf("%s/%s", s.baseURL, relPath)
+
+ result := &video.VideoResult{
+ VideoURL: videoURL, // 返回可访问的URL
+ Duration: int(totalDuration),
+ Completed: true,
+ Status: "completed",
+ }
+
+ return result, nil
+}
+
+func (s *VideoMergeService) pollMergeStatus(mergeID uint, client video.VideoClient, taskID string) {
+ maxAttempts := 240
+ pollInterval := 5 * time.Second
+
+ for i := 0; i < maxAttempts; i++ {
+ time.Sleep(pollInterval)
+
+ result, err := client.GetTaskStatus(taskID)
+ if err != nil {
+ s.log.Errorw("Failed to get merge task status", "error", err, "task_id", taskID)
+ continue
+ }
+
+ if result.Completed {
+ s.completeMerge(mergeID, result)
+ return
+ }
+
+ if result.Error != "" {
+ s.updateMergeError(mergeID, result.Error)
+ return
+ }
+ }
+
+ s.updateMergeError(mergeID, "timeout: video merge took too long")
+}
+
+func (s *VideoMergeService) completeMerge(mergeID uint, result *video.VideoResult) {
+ now := time.Now()
+
+ // 获取merge记录
+ var videoMerge models.VideoMerge
+ if err := s.db.First(&videoMerge, mergeID).Error; err != nil {
+ s.log.Errorw("Failed to load video merge for completion", "error", err, "id", mergeID)
+ return
+ }
+
+ finalVideoURL := result.VideoURL
+
+ // 使用本地存储,不再使用MinIO
+ s.log.Infow("Video merge completed, using local storage", "merge_id", mergeID, "local_path", result.VideoURL)
+
+ updates := map[string]interface{}{
+ "status": models.VideoMergeStatusCompleted,
+ "merged_url": finalVideoURL,
+ "completed_at": now,
+ }
+
+ if result.Duration > 0 {
+ updates["duration"] = result.Duration
+ }
+
+ s.db.Model(&models.VideoMerge{}).Where("id = ?", mergeID).Updates(updates)
+
+ // 更新episode的状态和最终视频URL
+ if videoMerge.EpisodeID != 0 {
+ s.db.Model(&models.Episode{}).Where("id = ?", videoMerge.EpisodeID).Updates(map[string]interface{}{
+ "status": "completed",
+ "video_url": finalVideoURL,
+ })
+ s.log.Infow("Episode finalized", "episode_id", videoMerge.EpisodeID, "video_url", finalVideoURL)
+ }
+
+ s.log.Infow("Video merge completed", "id", mergeID, "url", finalVideoURL)
+}
+
+func (s *VideoMergeService) updateMergeError(mergeID uint, errorMsg string) {
+ s.db.Model(&models.VideoMerge{}).Where("id = ?", mergeID).Updates(map[string]interface{}{
+ "status": models.VideoMergeStatusFailed,
+ "error_msg": errorMsg,
+ })
+ s.log.Errorw("Video merge failed", "id", mergeID, "error", errorMsg)
+}
+
+func (s *VideoMergeService) getVideoClient(provider string) (video.VideoClient, error) {
+ config, err := s.aiService.GetDefaultConfig("video")
+ if err != nil {
+ return nil, fmt.Errorf("failed to get video config: %w", err)
+ }
+
+ // 使用第一个模型
+ model := ""
+ if len(config.Model) > 0 {
+ model = config.Model[0]
+ }
+
+ // 根据配置中的 provider 创建对应的客户端
+ var endpoint string
+ var queryEndpoint string
+
+ switch config.Provider {
+ case "runway":
+ return video.NewRunwayClient(config.BaseURL, config.APIKey, model), nil
+ case "pika":
+ return video.NewPikaClient(config.BaseURL, config.APIKey, model), nil
+ case "openai", "sora":
+ return video.NewOpenAISoraClient(config.BaseURL, config.APIKey, model), nil
+ case "minimax":
+ return video.NewMinimaxClient(config.BaseURL, config.APIKey, model), nil
+ case "chatfire":
+ endpoint = "/video/generations"
+ queryEndpoint = "/video/task/{taskId}"
+ return video.NewChatfireClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil
+ case "doubao", "volces", "ark":
+ endpoint = "/contents/generations/tasks"
+ queryEndpoint = "/generations/tasks/{taskId}"
+ return video.NewVolcesArkClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil
+ default:
+ endpoint = "/contents/generations/tasks"
+ queryEndpoint = "/generations/tasks/{taskId}"
+ return video.NewVolcesArkClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil
+ }
+}
+
+func (s *VideoMergeService) GetMerge(mergeID uint) (*models.VideoMerge, error) {
+ var merge models.VideoMerge
+ if err := s.db.Where("id = ? ", mergeID).First(&merge).Error; err != nil {
+ return nil, err
+ }
+ return &merge, nil
+}
+
+func (s *VideoMergeService) ListMerges(episodeID *string, status string, page, pageSize int) ([]models.VideoMerge, int64, error) {
+ query := s.db.Model(&models.VideoMerge{})
+
+ if episodeID != nil && *episodeID != "" {
+ query = query.Where("episode_id = ?", *episodeID)
+ }
+
+ if status != "" {
+ query = query.Where("status = ?", status)
+ }
+
+ var total int64
+ if err := query.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+
+ var merges []models.VideoMerge
+ offset := (page - 1) * pageSize
+ if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&merges).Error; err != nil {
+ return nil, 0, err
+ }
+
+ return merges, total, nil
+}
+
+func (s *VideoMergeService) DeleteMerge(mergeID uint) error {
+ result := s.db.Where("id = ? ", mergeID).Delete(&models.VideoMerge{})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return fmt.Errorf("merge not found")
+ }
+ return nil
+}
+
+// TimelineClip 时间线片段数据
+type TimelineClip struct {
+ AssetID string `json:"asset_id"` // 素材库视频ID(优先使用)
+ StoryboardID string `json:"storyboard_id"` // 分镜ID(fallback)
+ Order int `json:"order"`
+ StartTime float64 `json:"start_time"`
+ EndTime float64 `json:"end_time"`
+ Duration float64 `json:"duration"`
+ Transition map[string]interface{} `json:"transition"`
+}
+
+// FinalizeEpisodeRequest 完成剧集制作请求
+type FinalizeEpisodeRequest struct {
+ EpisodeID string `json:"episode_id"`
+ Clips []TimelineClip `json:"clips"`
+}
+
+// FinalizeEpisode 完成集数制作,根据时间线场景顺序合成最终视频
+func (s *VideoMergeService) FinalizeEpisode(episodeID string, timelineData *FinalizeEpisodeRequest) (map[string]interface{}, error) {
+ // 验证episode存在且属于该用户
+ var episode models.Episode
+ if err := s.db.Preload("Drama").Preload("Storyboards").Where("id = ?", episodeID).First(&episode).Error; err != nil {
+ return nil, fmt.Errorf("episode not found")
+ }
+
+ // 构建分镜ID映射
+ sceneMap := make(map[string]models.Storyboard)
+ for _, scene := range episode.Storyboards {
+ sceneMap[fmt.Sprintf("%d", scene.ID)] = scene
+ }
+
+ // 根据时间线数据构建场景片段
+ var sceneClips []models.SceneClip
+ var skippedScenes []int
+
+ if timelineData != nil && len(timelineData.Clips) > 0 {
+ // 使用前端提供的时间线数据
+ for _, clip := range timelineData.Clips {
+ // 优先使用素材库中的视频(通过AssetID)
+ var videoURL string
+ var sceneID uint
+
+ if clip.AssetID != "" {
+ // 从素材库获取视频URL
+ var asset models.Asset
+ if err := s.db.Where("id = ? AND type = ?", clip.AssetID, models.AssetTypeVideo).First(&asset).Error; err == nil {
+ videoURL = asset.URL
+ // 如果asset关联了storyboard,使用关联的storyboard_id
+ if asset.StoryboardID != nil {
+ sceneID = *asset.StoryboardID
+ }
+ s.log.Infow("Using video from asset library", "asset_id", clip.AssetID, "video_url", videoURL)
+ } else {
+ s.log.Warnw("Asset not found, will try storyboard video", "asset_id", clip.AssetID, "error", err)
+ }
+ }
+
+ // 如果没有从素材库获取到视频,尝试从storyboard获取
+ if videoURL == "" && clip.StoryboardID != "" {
+ scene, exists := sceneMap[clip.StoryboardID]
+ if !exists {
+ s.log.Warnw("Storyboard not found in episode, skipping", "storyboard_id", clip.StoryboardID)
+ continue
+ }
+
+ if scene.VideoURL != nil && *scene.VideoURL != "" {
+ videoURL = *scene.VideoURL
+ sceneID = scene.ID
+ s.log.Infow("Using video from storyboard", "storyboard_id", clip.StoryboardID, "video_url", videoURL)
+ }
+ }
+
+ // 如果仍然没有视频URL,跳过该片段
+ if videoURL == "" {
+ s.log.Warnw("No video available for clip, skipping", "clip", clip)
+ if clip.StoryboardID != "" {
+ if scene, exists := sceneMap[clip.StoryboardID]; exists {
+ skippedScenes = append(skippedScenes, scene.StoryboardNumber)
+ }
+ }
+ continue
+ }
+
+ sceneClip := models.SceneClip{
+ SceneID: sceneID,
+ VideoURL: videoURL,
+ Duration: clip.Duration,
+ Order: clip.Order,
+ StartTime: clip.StartTime,
+ EndTime: clip.EndTime,
+ Transition: clip.Transition,
+ }
+ s.log.Infow("Adding scene clip with transition",
+ "scene_id", sceneID,
+ "order", clip.Order,
+ "transition", clip.Transition)
+ sceneClips = append(sceneClips, sceneClip)
+ }
+ } else {
+ // 没有时间线数据,使用默认场景顺序
+ if len(episode.Storyboards) == 0 {
+ return nil, fmt.Errorf("no scenes found for this episode")
+ }
+
+ order := 0
+ for _, scene := range episode.Storyboards {
+ // 优先从素材库查找该分镜关联的视频
+ var videoURL string
+ var asset models.Asset
+ if err := s.db.Where("storyboard_id = ? AND type = ? AND episode_id = ?",
+ scene.ID, models.AssetTypeVideo, episode.ID).
+ Order("created_at DESC").
+ First(&asset).Error; err == nil {
+ videoURL = asset.URL
+ s.log.Infow("Using video from asset library for storyboard",
+ "storyboard_id", scene.ID,
+ "asset_id", asset.ID,
+ "video_url", videoURL)
+ } else if scene.VideoURL != nil && *scene.VideoURL != "" {
+ // 如果素材库没有,使用storyboard的video_url作为fallback
+ videoURL = *scene.VideoURL
+ s.log.Infow("Using fallback video from storyboard",
+ "storyboard_id", scene.ID,
+ "video_url", videoURL)
+ }
+
+ // 跳过没有视频的场景
+ if videoURL == "" {
+ s.log.Warnw("Scene has no video, skipping", "storyboard_number", scene.StoryboardNumber)
+ skippedScenes = append(skippedScenes, scene.StoryboardNumber)
+ continue
+ }
+
+ clip := models.SceneClip{
+ SceneID: scene.ID,
+ VideoURL: videoURL,
+ Duration: float64(scene.Duration),
+ Order: order,
+ }
+ sceneClips = append(sceneClips, clip)
+ order++
+ }
+ }
+
+ // 检查是否至少有一个场景可以合成
+ if len(sceneClips) == 0 {
+ return nil, fmt.Errorf("no scenes with videos available for merging")
+ }
+
+ // 创建视频合成任务
+ title := fmt.Sprintf("%s - 第%d集", episode.Drama.Title, episode.EpisodeNum)
+
+ finalReq := &MergeVideoRequest{
+ EpisodeID: episodeID,
+ DramaID: fmt.Sprintf("%d", episode.DramaID),
+ Title: title,
+ Scenes: sceneClips,
+ Provider: "doubao", // 默认使用doubao
+ }
+
+ // 执行视频合成
+ videoMerge, err := s.MergeVideos(finalReq)
+ if err != nil {
+ return nil, fmt.Errorf("failed to start video merge: %w", err)
+ }
+
+ // 更新episode状态为processing
+ s.db.Model(&episode).Updates(map[string]interface{}{
+ "status": "processing",
+ })
+
+ result := map[string]interface{}{
+ "message": "视频合成任务已创建,正在后台处理",
+ "merge_id": videoMerge.ID,
+ "episode_id": episodeID,
+ "scenes_count": len(sceneClips),
+ }
+
+ // 如果有跳过的场景,添加提示信息
+ if len(skippedScenes) > 0 {
+ result["skipped_scenes"] = skippedScenes
+ result["warning"] = fmt.Sprintf("已跳过 %d 个未生成视频的场景(场景编号:%v)", len(skippedScenes), skippedScenes)
+ }
+
+ return result, nil
+}
diff --git a/configs/config.example.yaml b/configs/config.example.yaml
new file mode 100644
index 0000000..ae25c33
--- /dev/null
+++ b/configs/config.example.yaml
@@ -0,0 +1,28 @@
+app:
+ name: "Huobao Drama API"
+ version: "1.0.0"
+ debug: true
+
+server:
+ port: 5678
+ host: "0.0.0.0"
+ cors_origins:
+ - "http://localhost:3012"
+ read_timeout: 600
+ write_timeout: 600
+
+database:
+ type: "sqlite"
+ path: "./data/drama_generator.db"
+ max_idle: 10
+ max_open: 100
+
+storage:
+ type: "local"
+ local_path: "./data/storage"
+ base_url: "http://localhost:5678/static"
+
+ai:
+ default_text_provider: "openai"
+ default_image_provider: "openai"
+ default_video_provider: "doubao"
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..1610fc8
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,40 @@
+services:
+ huobao-drama:
+ image: huobao-drama:latest
+ container_name: huobao-drama
+ build:
+ context: .
+ dockerfile: Dockerfile
+ ports:
+ - "5678:5678"
+ volumes:
+ # 持久化数据目录(使用命名卷,容器内以 root 运行)
+ - huobao-data:/app/data
+ # 挂载配置文件(可选,如需自定义配置请取消注释)
+ # - ./configs/config.yaml:/app/configs/config.yaml:ro
+ # 注意:如果使用本地目录挂载,需要确保目录权限正确
+ # 例如:- ./data:/app/data (需要 chmod 777 ./data)
+ environment:
+ - TZ=Asia/Shanghai
+ # 访问宿主机服务说明:
+ # 使用 host.docker.internal 代替 127.0.0.1
+ # 例如:http://host.docker.internal:11434 (Ollama)
+ extra_hosts:
+ - "host.docker.internal:host-gateway" # 统一支持所有平台
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5678/health"]
+ interval: 30s
+ timeout: 3s
+ retries: 3
+ start_period: 10s
+ networks:
+ - huobao-network
+
+volumes:
+ huobao-data:
+ driver: local
+
+networks:
+ huobao-network:
+ driver: bridge
diff --git a/domain/models/ai_config.go b/domain/models/ai_config.go
new file mode 100644
index 0000000..3236b6a
--- /dev/null
+++ b/domain/models/ai_config.go
@@ -0,0 +1,124 @@
+package models
+
+import (
+ "database/sql/driver"
+ "encoding/json"
+ "errors"
+ "time"
+)
+
+type AIServiceConfig struct {
+ ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
+ ServiceType string `gorm:"type:varchar(50);not null" json:"service_type"` // text, image, video
+ Provider string `gorm:"type:varchar(50)" json:"provider"` // openai, gemini, volcengine, etc.
+ Name string `gorm:"type:varchar(100);not null" json:"name"`
+ BaseURL string `gorm:"type:varchar(255);not null" json:"base_url"`
+ APIKey string `gorm:"type:varchar(255);not null" json:"api_key"`
+ Model ModelField `gorm:"type:text" json:"model"`
+ Endpoint string `gorm:"type:varchar(255)" json:"endpoint"`
+ QueryEndpoint string `gorm:"type:varchar(255)" json:"query_endpoint"`
+ Priority int `gorm:"default:0" json:"priority"` // 优先级,数值越大优先级越高
+ IsDefault bool `gorm:"default:false" json:"is_default"`
+ IsActive bool `gorm:"default:true" json:"is_active"`
+ Settings string `gorm:"type:text" json:"settings"`
+ CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"`
+ UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"`
+}
+
+func (c *AIServiceConfig) TableName() string {
+ return "ai_service_configs"
+}
+
+type AIServiceProvider struct {
+ ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
+ Name string `gorm:"type:varchar(100);not null;uniqueIndex" json:"name"`
+ DisplayName string `gorm:"type:varchar(100);not null" json:"display_name"`
+ ServiceType string `gorm:"type:varchar(50);not null" json:"service_type"`
+ DefaultURL string `gorm:"type:varchar(255)" json:"default_url"`
+ Description string `gorm:"type:text" json:"description"`
+ IsActive bool `gorm:"default:true" json:"is_active"`
+ CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"`
+ UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"`
+}
+
+func (p *AIServiceProvider) TableName() string {
+ return "ai_service_providers"
+}
+
+// ModelField 自定义类型,支持字符串或字符串数组
+type ModelField []string
+
+// Value 实现 driver.Valuer 接口,用于存储到数据库
+func (m ModelField) Value() (driver.Value, error) {
+ if len(m) == 0 {
+ return nil, nil
+ }
+ data, err := json.Marshal(m)
+ if err != nil {
+ return nil, err
+ }
+ return string(data), nil
+}
+
+// Scan 实现 sql.Scanner 接口,用于从数据库读取
+func (m *ModelField) Scan(value interface{}) error {
+ if value == nil {
+ *m = []string{}
+ return nil
+ }
+
+ var data []byte
+ switch v := value.(type) {
+ case []byte:
+ data = v
+ case string:
+ data = []byte(v)
+ default:
+ return errors.New("unsupported type for ModelField")
+ }
+
+ // 尝试解析为数组
+ var arr []string
+ if err := json.Unmarshal(data, &arr); err == nil {
+ *m = arr
+ return nil
+ }
+
+ // 如果解析失败,尝试作为单个字符串处理
+ var str string
+ if err := json.Unmarshal(data, &str); err == nil {
+ *m = []string{str}
+ return nil
+ }
+
+ // 兼容旧数据:直接作为字符串
+ *m = []string{string(data)}
+ return nil
+}
+
+// MarshalJSON 实现 json.Marshaler 接口
+func (m ModelField) MarshalJSON() ([]byte, error) {
+ if len(m) == 0 {
+ return json.Marshal([]string{})
+ }
+ return json.Marshal([]string(m))
+}
+
+// UnmarshalJSON 实现 json.Unmarshaler 接口,支持字符串或数组
+func (m *ModelField) UnmarshalJSON(data []byte) error {
+ // 尝试解析为数组
+ var arr []string
+ if err := json.Unmarshal(data, &arr); err == nil {
+ *m = arr
+ return nil
+ }
+
+ // 尝试解析为单个字符串
+ var str string
+ if err := json.Unmarshal(data, &str); err == nil {
+ *m = []string{str}
+ return nil
+ }
+
+ return errors.New("model field must be string or array of strings")
+}
diff --git a/domain/models/asset.go b/domain/models/asset.go
new file mode 100644
index 0000000..dc5f5dd
--- /dev/null
+++ b/domain/models/asset.go
@@ -0,0 +1,57 @@
+package models
+
+import (
+ "time"
+
+ "gorm.io/gorm"
+)
+
+type Asset struct {
+ ID uint `gorm:"primarykey" json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ DramaID *uint `gorm:"index" json:"drama_id,omitempty"`
+ Drama *Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"`
+
+ EpisodeID *uint `gorm:"index" json:"episode_id,omitempty"`
+ StoryboardID *uint `gorm:"index" json:"storyboard_id,omitempty"`
+ StoryboardNum *int `json:"storyboard_num,omitempty"`
+
+ Name string `gorm:"type:varchar(200);not null" json:"name"`
+ Description *string `gorm:"type:text" json:"description,omitempty"`
+ Type AssetType `gorm:"type:varchar(20);not null;index" json:"type"`
+ Category *string `gorm:"type:varchar(50);index" json:"category,omitempty"`
+ URL string `gorm:"type:varchar(1000);not null" json:"url"`
+ ThumbnailURL *string `gorm:"type:varchar(1000)" json:"thumbnail_url,omitempty"`
+ LocalPath *string `gorm:"type:varchar(500)" json:"local_path,omitempty"`
+
+ FileSize *int64 `json:"file_size,omitempty"`
+ MimeType *string `gorm:"type:varchar(100)" json:"mime_type,omitempty"`
+ Width *int `json:"width,omitempty"`
+ Height *int `json:"height,omitempty"`
+ Duration *int `json:"duration,omitempty"`
+ Format *string `gorm:"type:varchar(50)" json:"format,omitempty"`
+
+ ImageGenID *uint `gorm:"index" json:"image_gen_id,omitempty"`
+ ImageGen ImageGeneration `gorm:"foreignKey:ImageGenID" json:"image_gen,omitempty"`
+
+ VideoGenID *uint `gorm:"index" json:"video_gen_id,omitempty"`
+ VideoGen VideoGeneration `gorm:"foreignKey:VideoGenID" json:"video_gen,omitempty"`
+
+ IsFavorite bool `gorm:"default:false" json:"is_favorite"`
+ ViewCount int `gorm:"default:0" json:"view_count"`
+}
+
+type AssetType string
+
+const (
+ AssetTypeImage AssetType = "image"
+ AssetTypeVideo AssetType = "video"
+ AssetTypeAudio AssetType = "audio"
+)
+
+func (Asset) TableName() string {
+ return "assets"
+}
diff --git a/domain/models/character_library.go b/domain/models/character_library.go
new file mode 100644
index 0000000..0d037d0
--- /dev/null
+++ b/domain/models/character_library.go
@@ -0,0 +1,25 @@
+package models
+
+import (
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// CharacterLibrary 角色库模型
+type CharacterLibrary struct {
+ ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
+ Name string `gorm:"type:varchar(100);not null" json:"name"`
+ Category *string `gorm:"type:varchar(50)" json:"category"`
+ ImageURL string `gorm:"type:varchar(500);not null" json:"image_url"`
+ Description *string `gorm:"type:text" json:"description"`
+ Tags *string `gorm:"type:varchar(500)" json:"tags"`
+ SourceType string `gorm:"type:varchar(20);default:'generated'" json:"source_type"` // generated, uploaded
+ CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"`
+ UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+}
+
+func (c *CharacterLibrary) TableName() string {
+ return "character_libraries"
+}
diff --git a/domain/models/drama.go b/domain/models/drama.go
new file mode 100644
index 0000000..15b3c72
--- /dev/null
+++ b/domain/models/drama.go
@@ -0,0 +1,148 @@
+package models
+
+import (
+ "time"
+
+ "gorm.io/datatypes"
+ "gorm.io/gorm"
+)
+
+type Drama struct {
+ ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
+ Title string `gorm:"type:varchar(200);not null" json:"title"`
+ Description *string `gorm:"type:text" json:"description"`
+ Genre *string `gorm:"type:varchar(50)" json:"genre"`
+ Style string `gorm:"type:varchar(50);default:'realistic'" json:"style"`
+ TotalEpisodes int `gorm:"default:1" json:"total_episodes"`
+ TotalDuration int `gorm:"default:0" json:"total_duration"`
+ Status string `gorm:"type:varchar(20);default:'draft';not null" json:"status"`
+ Thumbnail *string `gorm:"type:varchar(500)" json:"thumbnail"`
+ Tags datatypes.JSON `gorm:"type:json" json:"tags"`
+ Metadata datatypes.JSON `gorm:"type:json" json:"metadata"`
+ CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"`
+ UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ Episodes []Episode `gorm:"foreignKey:DramaID" json:"episodes,omitempty"`
+ Characters []Character `gorm:"foreignKey:DramaID" json:"characters,omitempty"`
+ Scenes []Scene `gorm:"foreignKey:DramaID" json:"scenes,omitempty"`
+}
+
+func (d *Drama) TableName() string {
+ return "dramas"
+}
+
+type Character struct {
+ ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
+ DramaID uint `gorm:"not null;index" json:"drama_id"`
+ Name string `gorm:"type:varchar(100);not null" json:"name"`
+ Role *string `gorm:"type:varchar(50)" json:"role"`
+ Description *string `gorm:"type:text" json:"description"`
+ Appearance *string `gorm:"type:text" json:"appearance"`
+ Personality *string `gorm:"type:text" json:"personality"`
+ VoiceStyle *string `gorm:"type:varchar(200)" json:"voice_style"`
+ ImageURL *string `gorm:"type:varchar(500)" json:"image_url"`
+ ReferenceImages datatypes.JSON `gorm:"type:json" json:"reference_images"`
+ SeedValue *string `gorm:"type:varchar(100)" json:"seed_value"`
+ SortOrder int `gorm:"default:0" json:"sort_order"`
+ CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"`
+ UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ // 多对多关系:角色可以属于多个章节
+ Episodes []Episode `gorm:"many2many:episode_characters;" json:"episodes,omitempty"`
+
+ // 运行时字段(不存储到数据库)
+ ImageGenerationStatus *string `gorm:"-" json:"image_generation_status,omitempty"`
+ ImageGenerationError *string `gorm:"-" json:"image_generation_error,omitempty"`
+}
+
+func (c *Character) TableName() string {
+ return "characters"
+}
+
+type Episode struct {
+ ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
+ DramaID uint `gorm:"not null;index" json:"drama_id"`
+ EpisodeNum int `gorm:"column:episode_number;not null" json:"episode_number"`
+ Title string `gorm:"type:varchar(200);not null" json:"title"`
+ ScriptContent *string `gorm:"type:longtext" json:"script_content"`
+ Description *string `gorm:"type:text" json:"description"`
+ Duration int `gorm:"default:0" json:"duration"` // 总时长(秒)
+ Status string `gorm:"type:varchar(20);default:'draft'" json:"status"`
+ VideoURL *string `gorm:"type:varchar(500)" json:"video_url"`
+ Thumbnail *string `gorm:"type:varchar(500)" json:"thumbnail"`
+ CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"`
+ UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ // 关联
+ Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"`
+ Storyboards []Storyboard `gorm:"foreignKey:EpisodeID" json:"storyboards,omitempty"`
+ Characters []Character `gorm:"many2many:episode_characters;" json:"characters,omitempty"`
+ Scenes []Scene `gorm:"foreignKey:EpisodeID" json:"scenes,omitempty"`
+}
+
+func (e *Episode) TableName() string {
+ return "episodes"
+}
+
+type Storyboard struct {
+ ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
+ EpisodeID uint `gorm:"not null;index:idx_storyboards_episode_id" json:"episode_id"`
+ SceneID *uint `gorm:"index:idx_storyboards_scene_id;column:scene_id" json:"scene_id"`
+ StoryboardNumber int `gorm:"not null;column:storyboard_number" json:"storyboard_number"`
+ Title *string `gorm:"size:255" json:"title"`
+ Location *string `gorm:"size:255" json:"location"`
+ Time *string `gorm:"size:255" json:"time"`
+ ShotType *string `gorm:"size:100" json:"shot_type"`
+ Angle *string `gorm:"size:100" json:"angle"`
+ Movement *string `gorm:"size:100" json:"movement"`
+ Action *string `gorm:"type:text" json:"action"`
+ Result *string `gorm:"type:text" json:"result"`
+ Atmosphere *string `gorm:"type:text" json:"atmosphere"`
+ ImagePrompt *string `gorm:"type:text" json:"image_prompt"`
+ VideoPrompt *string `gorm:"type:text" json:"video_prompt"`
+ BgmPrompt *string `gorm:"type:text" json:"bgm_prompt"`
+ SoundEffect *string `gorm:"size:255" json:"sound_effect"`
+ Dialogue *string `gorm:"type:text" json:"dialogue"`
+ Description *string `gorm:"type:text" json:"description"`
+ Duration int `gorm:"default:5" json:"duration"`
+ ComposedImage *string `gorm:"type:text" json:"composed_image"`
+ VideoURL *string `gorm:"type:text" json:"video_url"`
+ Status string `gorm:"type:varchar(20);default:'pending'" json:"status"`
+ CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
+ UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ Episode Episode `gorm:"foreignKey:EpisodeID;constraint:OnDelete:CASCADE" json:"episode,omitempty"`
+ Background *Scene `gorm:"foreignKey:SceneID" json:"background,omitempty"`
+ Characters []Character `gorm:"many2many:storyboard_characters;" json:"characters,omitempty"`
+}
+
+func (s *Storyboard) TableName() string {
+ return "storyboards"
+}
+
+type Scene struct {
+ ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
+ DramaID uint `gorm:"not null;index:idx_scenes_drama_id" json:"drama_id"`
+ EpisodeID *uint `gorm:"index:idx_scenes_episode_id" json:"episode_id"` // 场景所属章节
+ Location string `gorm:"type:varchar(200);not null" json:"location"`
+ Time string `gorm:"type:varchar(100);not null" json:"time"`
+ Prompt string `gorm:"type:text;not null" json:"prompt"`
+ StoryboardCount int `gorm:"default:1" json:"storyboard_count"`
+ ImageURL *string `gorm:"type:varchar(500)" json:"image_url"`
+ Status string `gorm:"type:varchar(20);default:'pending'" json:"status"` // pending, generated, failed
+ CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"`
+ UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ // 运行时字段(不存储到数据库)
+ ImageGenerationStatus *string `gorm:"-" json:"image_generation_status,omitempty"`
+ ImageGenerationError *string `gorm:"-" json:"image_generation_error,omitempty"`
+}
+
+func (s *Scene) TableName() string {
+ return "scenes"
+}
diff --git a/domain/models/frame_prompt.go b/domain/models/frame_prompt.go
new file mode 100644
index 0000000..8ea547e
--- /dev/null
+++ b/domain/models/frame_prompt.go
@@ -0,0 +1,28 @@
+package models
+
+import "time"
+
+// FramePrompt 帧提示词存储表
+type FramePrompt struct {
+ ID uint `gorm:"primarykey" json:"id"`
+ StoryboardID uint `gorm:"not null;index:idx_frame_prompts_storyboard" json:"storyboard_id"`
+ FrameType string `gorm:"size:20;not null;index:idx_frame_prompts_type" json:"frame_type"` // first, key, last, panel, action
+ Prompt string `gorm:"type:text;not null" json:"prompt"`
+ Description *string `gorm:"type:text" json:"description,omitempty"`
+ Layout *string `gorm:"size:50" json:"layout,omitempty"` // 仅用于panel/action类型,如 horizontal_3
+ CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
+ UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
+}
+
+func (FramePrompt) TableName() string {
+ return "frame_prompts"
+}
+
+// FrameType 帧类型常量
+const (
+ FrameTypeFirst = "first"
+ FrameTypeKey = "key"
+ FrameTypeLast = "last"
+ FrameTypePanel = "panel"
+ FrameTypeAction = "action"
+)
diff --git a/domain/models/image_generation.go b/domain/models/image_generation.go
new file mode 100644
index 0000000..cd37ef2
--- /dev/null
+++ b/domain/models/image_generation.go
@@ -0,0 +1,75 @@
+package models
+
+import (
+ "time"
+
+ "gorm.io/datatypes"
+)
+
+type ImageGeneration struct {
+ ID uint `gorm:"primarykey" json:"id"`
+ StoryboardID *uint `gorm:"index" json:"storyboard_id,omitempty"`
+ DramaID uint `gorm:"not null;index" json:"drama_id"`
+ SceneID *uint `gorm:"index" json:"scene_id,omitempty"`
+ CharacterID *uint `gorm:"index" json:"character_id,omitempty"`
+ ImageType string `gorm:"size:20;index;default:'storyboard'" json:"image_type"`
+ FrameType *string `gorm:"size:20" json:"frame_type,omitempty"`
+ Provider string `gorm:"size:50;not null" json:"provider"`
+ Prompt string `gorm:"type:text;not null" json:"prompt"`
+ NegPrompt *string `gorm:"column:negative_prompt;type:text" json:"negative_prompt,omitempty"`
+ Model string `gorm:"size:100" json:"model"`
+ Size string `gorm:"size:20" json:"size"`
+ Quality string `gorm:"size:20" json:"quality"`
+ Style *string `gorm:"size:50" json:"style,omitempty"`
+ Steps *int `json:"steps,omitempty"`
+ CfgScale *float64 `json:"cfg_scale,omitempty"`
+ Seed *int64 `json:"seed,omitempty"`
+ ImageURL *string `gorm:"type:text" json:"image_url,omitempty"`
+ MinioURL *string `gorm:"type:text" json:"minio_url,omitempty"`
+ LocalPath *string `gorm:"type:text" json:"local_path,omitempty"`
+ Status ImageGenerationStatus `gorm:"size:20;not null;default:'pending'" json:"status"`
+ TaskID *string `gorm:"size:200" json:"task_id,omitempty"`
+ ErrorMsg *string `gorm:"type:text" json:"error_msg,omitempty"`
+ Width *int `json:"width,omitempty"`
+ Height *int `json:"height,omitempty"`
+ ReferenceImages datatypes.JSON `gorm:"type:json" json:"reference_images,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ CompletedAt *time.Time `json:"completed_at,omitempty"`
+
+ Storyboard *Storyboard `gorm:"foreignKey:StoryboardID" json:"storyboard,omitempty"`
+ Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"`
+ Scene *Scene `gorm:"foreignKey:SceneID" json:"scene,omitempty"`
+ Character *Character `gorm:"foreignKey:CharacterID" json:"character,omitempty"`
+}
+
+func (ImageGeneration) TableName() string {
+ return "image_generations"
+}
+
+type ImageGenerationStatus string
+
+const (
+ ImageStatusPending ImageGenerationStatus = "pending"
+ ImageStatusProcessing ImageGenerationStatus = "processing"
+ ImageStatusCompleted ImageGenerationStatus = "completed"
+ ImageStatusFailed ImageGenerationStatus = "failed"
+)
+
+type ImageProvider string
+
+const (
+ ProviderOpenAI ImageProvider = "openai"
+ ProviderMidjourney ImageProvider = "midjourney"
+ ProviderStableDiffusion ImageProvider = "stable_diffusion"
+ ProviderDALLE ImageProvider = "dalle"
+)
+
+// ImageType 图片类型
+type ImageType string
+
+const (
+ ImageTypeCharacter ImageType = "character" // 角色图片
+ ImageTypeScene ImageType = "scene" // 场景图片
+ ImageTypeStoryboard ImageType = "storyboard" // 分镜图片
+)
diff --git a/domain/models/task.go b/domain/models/task.go
new file mode 100644
index 0000000..489e880
--- /dev/null
+++ b/domain/models/task.go
@@ -0,0 +1,23 @@
+package models
+
+import (
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// AsyncTask 异步任务模型
+type AsyncTask struct {
+ ID string `gorm:"primaryKey;size:36" json:"id"`
+ Type string `gorm:"size:50;not null;index" json:"type"` // 任务类型:storyboard_generation
+ Status string `gorm:"size:20;not null;index" json:"status"` // pending, processing, completed, failed
+ Progress int `gorm:"default:0" json:"progress"` // 0-100
+ Message string `gorm:"size:500" json:"message,omitempty"` // 当前状态消息
+ Error string `gorm:"type:text" json:"error,omitempty"` // 错误信息
+ Result string `gorm:"type:text" json:"result,omitempty"` // JSON格式的结果数据
+ ResourceID string `gorm:"size:36;index" json:"resource_id"` // 关联资源ID(如episode_id)
+ CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
+ UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
+ CompletedAt *time.Time `json:"completed_at,omitempty"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+}
diff --git a/domain/models/timeline.go b/domain/models/timeline.go
new file mode 100644
index 0000000..20af9af
--- /dev/null
+++ b/domain/models/timeline.go
@@ -0,0 +1,178 @@
+package models
+
+import (
+ "time"
+
+ "gorm.io/gorm"
+)
+
+type Timeline struct {
+ ID uint `gorm:"primarykey" json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ DramaID uint `gorm:"not null;index" json:"drama_id"`
+ Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"`
+
+ EpisodeID *uint `gorm:"index" json:"episode_id,omitempty"`
+ Episode *Episode `gorm:"foreignKey:EpisodeID" json:"episode,omitempty"`
+
+ Name string `gorm:"type:varchar(200);not null" json:"name"`
+ Description *string `gorm:"type:text" json:"description,omitempty"`
+
+ Duration int `gorm:"default:0" json:"duration"`
+ FPS int `gorm:"default:30" json:"fps"`
+ Resolution *string `gorm:"type:varchar(50)" json:"resolution,omitempty"`
+
+ Status TimelineStatus `gorm:"type:varchar(20);not null;default:'draft';index" json:"status"`
+
+ Tracks []TimelineTrack `gorm:"foreignKey:TimelineID" json:"tracks,omitempty"`
+}
+
+type TimelineStatus string
+
+const (
+ TimelineStatusDraft TimelineStatus = "draft"
+ TimelineStatusEditing TimelineStatus = "editing"
+ TimelineStatusCompleted TimelineStatus = "completed"
+ TimelineStatusExporting TimelineStatus = "exporting"
+)
+
+func (Timeline) TableName() string {
+ return "timelines"
+}
+
+type TimelineTrack struct {
+ ID uint `gorm:"primarykey" json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ TimelineID uint `gorm:"not null;index" json:"timeline_id"`
+ Timeline Timeline `gorm:"foreignKey:TimelineID" json:"-"`
+
+ Name string `gorm:"type:varchar(100);not null" json:"name"`
+ Type TrackType `gorm:"type:varchar(20);not null" json:"type"`
+ Order int `gorm:"not null;default:0" json:"order"`
+ IsLocked bool `gorm:"default:false" json:"is_locked"`
+ IsMuted bool `gorm:"default:false" json:"is_muted"`
+ Volume *int `gorm:"default:100" json:"volume,omitempty"`
+
+ Clips []TimelineClip `gorm:"foreignKey:TrackID" json:"clips,omitempty"`
+}
+
+type TrackType string
+
+const (
+ TrackTypeVideo TrackType = "video"
+ TrackTypeAudio TrackType = "audio"
+ TrackTypeText TrackType = "text"
+)
+
+func (TimelineTrack) TableName() string {
+ return "timeline_tracks"
+}
+
+type TimelineClip struct {
+ ID uint `gorm:"primarykey" json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ TrackID uint `gorm:"not null;index" json:"track_id"`
+ Track TimelineTrack `gorm:"foreignKey:TrackID" json:"-"`
+
+ AssetID *uint `gorm:"index" json:"asset_id,omitempty"`
+ Asset Asset `gorm:"foreignKey:AssetID" json:"asset,omitempty"`
+
+ StoryboardID *uint `gorm:"index" json:"storyboard_id,omitempty"`
+ Storyboard *Storyboard `gorm:"foreignKey:StoryboardID" json:"storyboard,omitempty"`
+
+ Name string `gorm:"type:varchar(200)" json:"name"`
+
+ StartTime int `gorm:"not null" json:"start_time"`
+ EndTime int `gorm:"not null" json:"end_time"`
+ Duration int `gorm:"not null" json:"duration"`
+
+ TrimStart *int `json:"trim_start,omitempty"`
+ TrimEnd *int `json:"trim_end,omitempty"`
+
+ Speed *float64 `gorm:"default:1.0" json:"speed,omitempty"`
+
+ Volume *int `json:"volume,omitempty"`
+ IsMuted bool `gorm:"default:false" json:"is_muted"`
+ FadeIn *int `json:"fade_in,omitempty"`
+ FadeOut *int `json:"fade_out,omitempty"`
+
+ TransitionIn *uint `gorm:"index" json:"transition_in_id,omitempty"`
+ TransitionOut *uint `gorm:"index" json:"transition_out_id,omitempty"`
+ InTransition ClipTransition `gorm:"foreignKey:TransitionIn" json:"in_transition,omitempty"`
+ OutTransition ClipTransition `gorm:"foreignKey:TransitionOut" json:"out_transition,omitempty"`
+
+ Effects []ClipEffect `gorm:"foreignKey:ClipID" json:"effects,omitempty"`
+}
+
+func (TimelineClip) TableName() string {
+ return "timeline_clips"
+}
+
+type ClipTransition struct {
+ ID uint `gorm:"primarykey" json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ Type TransitionType `gorm:"type:varchar(50);not null" json:"type"`
+ Duration int `gorm:"not null;default:500" json:"duration"`
+ Easing *string `gorm:"type:varchar(50)" json:"easing,omitempty"`
+
+ Config map[string]interface{} `gorm:"serializer:json" json:"config,omitempty"`
+}
+
+type TransitionType string
+
+const (
+ TransitionTypeFade TransitionType = "fade"
+ TransitionTypeCrossFade TransitionType = "crossfade"
+ TransitionTypeSlide TransitionType = "slide"
+ TransitionTypeWipe TransitionType = "wipe"
+ TransitionTypeZoom TransitionType = "zoom"
+ TransitionTypeDissolve TransitionType = "dissolve"
+)
+
+func (ClipTransition) TableName() string {
+ return "clip_transitions"
+}
+
+type ClipEffect struct {
+ ID uint `gorm:"primarykey" json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ ClipID uint `gorm:"not null;index" json:"clip_id"`
+ Clip TimelineClip `gorm:"foreignKey:ClipID" json:"-"`
+
+ Type EffectType `gorm:"type:varchar(50);not null" json:"type"`
+ Name string `gorm:"type:varchar(100)" json:"name"`
+ IsEnabled bool `gorm:"default:true" json:"is_enabled"`
+ Order int `gorm:"default:0" json:"order"`
+
+ Config map[string]interface{} `gorm:"serializer:json" json:"config,omitempty"`
+}
+
+type EffectType string
+
+const (
+ EffectTypeFilter EffectType = "filter"
+ EffectTypeColor EffectType = "color"
+ EffectTypeBlur EffectType = "blur"
+ EffectTypeBrightness EffectType = "brightness"
+ EffectTypeContrast EffectType = "contrast"
+ EffectTypeSaturation EffectType = "saturation"
+)
+
+func (ClipEffect) TableName() string {
+ return "clip_effects"
+}
diff --git a/domain/models/video_generation.go b/domain/models/video_generation.go
new file mode 100644
index 0000000..03f8d59
--- /dev/null
+++ b/domain/models/video_generation.go
@@ -0,0 +1,79 @@
+package models
+
+import (
+ "time"
+
+ "gorm.io/gorm"
+)
+
+type VideoGeneration struct {
+ ID uint `gorm:"primarykey" json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ StoryboardID *uint `gorm:"index" json:"storyboard_id,omitempty"`
+ Storyboard *Storyboard `gorm:"foreignKey:StoryboardID" json:"storyboard,omitempty"`
+
+ DramaID uint `gorm:"not null;index" json:"drama_id"`
+ Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"`
+
+ Provider string `gorm:"type:varchar(50);not null;index" json:"provider"`
+ Prompt string `gorm:"type:text;not null" json:"prompt"`
+ Model string `gorm:"type:varchar(100)" json:"model,omitempty"`
+
+ ImageGenID *uint `gorm:"index" json:"image_gen_id,omitempty"`
+ ImageGen ImageGeneration `gorm:"foreignKey:ImageGenID" json:"image_gen,omitempty"`
+
+ // 参考图模式:single(单图), first_last(首尾帧), multiple(多图), none(无)
+ ReferenceMode *string `gorm:"type:varchar(20)" json:"reference_mode,omitempty"`
+
+ ImageURL *string `gorm:"type:varchar(1000)" json:"image_url,omitempty"`
+ FirstFrameURL *string `gorm:"type:varchar(1000)" json:"first_frame_url,omitempty"`
+ LastFrameURL *string `gorm:"type:varchar(1000)" json:"last_frame_url,omitempty"`
+ ReferenceImageURLs *string `gorm:"type:text" json:"reference_image_urls,omitempty"` // JSON数组存储多张参考图
+
+ Duration *int `json:"duration,omitempty"`
+ FPS *int `json:"fps,omitempty"`
+ Resolution *string `gorm:"type:varchar(50)" json:"resolution,omitempty"`
+ AspectRatio *string `gorm:"type:varchar(20)" json:"aspect_ratio,omitempty"`
+ Style *string `gorm:"type:varchar(100)" json:"style,omitempty"`
+ MotionLevel *int `json:"motion_level,omitempty"`
+ CameraMotion *string `gorm:"type:varchar(100)" json:"camera_motion,omitempty"`
+ Seed *int64 `json:"seed,omitempty"`
+
+ VideoURL *string `gorm:"type:varchar(1000)" json:"video_url,omitempty"`
+ MinioURL *string `gorm:"type:varchar(1000)" json:"minio_url,omitempty"`
+ LocalPath *string `gorm:"type:varchar(500)" json:"local_path,omitempty"`
+
+ Status VideoStatus `gorm:"type:varchar(20);not null;default:'pending';index" json:"status"`
+ TaskID *string `gorm:"type:varchar(200);index" json:"task_id,omitempty"`
+
+ ErrorMsg *string `gorm:"type:text" json:"error_msg,omitempty"`
+ CompletedAt *time.Time `json:"completed_at,omitempty"`
+
+ Width *int `json:"width,omitempty"`
+ Height *int `json:"height,omitempty"`
+}
+
+type VideoStatus string
+
+const (
+ VideoStatusPending VideoStatus = "pending"
+ VideoStatusProcessing VideoStatus = "processing"
+ VideoStatusCompleted VideoStatus = "completed"
+ VideoStatusFailed VideoStatus = "failed"
+)
+
+type VideoProvider string
+
+const (
+ VideoProviderRunway VideoProvider = "runway"
+ VideoProviderPika VideoProvider = "pika"
+ VideoProviderDoubao VideoProvider = "doubao"
+ VideoProviderOpenAI VideoProvider = "openai"
+)
+
+func (VideoGeneration) TableName() string {
+ return "video_generations"
+}
diff --git a/domain/models/video_merge.go b/domain/models/video_merge.go
new file mode 100644
index 0000000..0f44f2f
--- /dev/null
+++ b/domain/models/video_merge.go
@@ -0,0 +1,52 @@
+package models
+
+import (
+ "time"
+
+ "gorm.io/datatypes"
+ "gorm.io/gorm"
+)
+
+type VideoMergeStatus string
+
+const (
+ VideoMergeStatusPending VideoMergeStatus = "pending"
+ VideoMergeStatusProcessing VideoMergeStatus = "processing"
+ VideoMergeStatusCompleted VideoMergeStatus = "completed"
+ VideoMergeStatusFailed VideoMergeStatus = "failed"
+)
+
+type VideoMerge struct {
+ ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
+ EpisodeID uint `gorm:"not null;index" json:"episode_id"`
+ DramaID uint `gorm:"not null;index" json:"drama_id"`
+ Title string `gorm:"type:varchar(200)" json:"title"`
+ Provider string `gorm:"type:varchar(50);not null" json:"provider"`
+ Model *string `gorm:"type:varchar(100)" json:"model,omitempty"`
+ Status VideoMergeStatus `gorm:"type:varchar(20);not null;default:'pending'" json:"status"`
+ Scenes datatypes.JSON `gorm:"type:json;not null" json:"scenes"`
+ MergedURL *string `gorm:"type:varchar(500)" json:"merged_url,omitempty"`
+ Duration *int `gorm:"type:int" json:"duration,omitempty"`
+ TaskID *string `gorm:"type:varchar(100)" json:"task_id,omitempty"`
+ ErrorMsg *string `gorm:"type:text" json:"error_msg,omitempty"`
+ CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"`
+ CompletedAt *time.Time `json:"completed_at,omitempty"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ Episode Episode `gorm:"foreignKey:EpisodeID" json:"episode,omitempty"`
+ Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"`
+}
+
+type SceneClip struct {
+ SceneID uint `json:"scene_id"`
+ VideoURL string `json:"video_url"`
+ StartTime float64 `json:"start_time"`
+ EndTime float64 `json:"end_time"`
+ Duration float64 `json:"duration"`
+ Order int `json:"order"`
+ Transition map[string]interface{} `json:"transition"`
+}
+
+func (v *VideoMerge) TableName() string {
+ return "video_merges"
+}
diff --git a/drama.png b/drama.png
new file mode 100644
index 0000000..d20193d
Binary files /dev/null and b/drama.png differ
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..07052f7
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,73 @@
+module github.com/drama-generator/backend
+
+go 1.23.0
+
+require (
+ github.com/gin-gonic/gin v1.9.1
+ github.com/google/uuid v1.6.0
+ github.com/robfig/cron/v3 v3.0.1
+ github.com/spf13/viper v1.17.0
+ go.uber.org/zap v1.26.0
+ gorm.io/datatypes v1.2.0
+ gorm.io/driver/mysql v1.5.2
+ gorm.io/driver/sqlite v1.6.0
+ gorm.io/gorm v1.30.0
+ modernc.org/sqlite v1.34.4
+)
+
+require (
+ github.com/bytedance/sonic v1.9.1 // indirect
+ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/fsnotify/fsnotify v1.6.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.2 // indirect
+ github.com/gin-contrib/sse v0.1.0 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.14.0 // indirect
+ github.com/go-sql-driver/mysql v1.7.0 // indirect
+ github.com/goccy/go-json v0.10.2 // indirect
+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+ github.com/hashicorp/hcl v1.0.0 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.11 // indirect
+ github.com/leodido/go-urn v1.2.4 // indirect
+ github.com/magiconair/properties v1.8.7 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-sqlite3 v1.14.22 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/pelletier/go-toml/v2 v2.1.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/sagikazarmark/locafero v0.3.0 // indirect
+ github.com/sagikazarmark/slog-shim v0.1.0 // indirect
+ github.com/sourcegraph/conc v0.3.0 // indirect
+ github.com/spf13/afero v1.10.0 // indirect
+ github.com/spf13/cast v1.5.1 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ github.com/stretchr/testify v1.9.0 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.2.11 // indirect
+ go.uber.org/goleak v1.2.1 // indirect
+ go.uber.org/multierr v1.10.0 // indirect
+ golang.org/x/arch v0.3.0 // indirect
+ golang.org/x/crypto v0.36.0 // indirect
+ golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect
+ golang.org/x/net v0.38.0 // indirect
+ golang.org/x/sys v0.34.0 // indirect
+ golang.org/x/text v0.26.0 // indirect
+ google.golang.org/protobuf v1.31.0 // indirect
+ gopkg.in/ini.v1 v1.67.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
+ modernc.org/libc v1.55.3 // indirect
+ modernc.org/mathutil v1.6.0 // indirect
+ modernc.org/memory v1.8.0 // indirect
+ modernc.org/strutil v1.2.0 // indirect
+ modernc.org/token v1.1.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..e4e8281
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,638 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
+cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
+cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
+cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
+cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
+cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
+cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
+cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
+cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
+cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
+cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
+cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
+cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
+cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
+cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
+cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
+cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
+cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
+cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
+cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
+cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
+cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
+cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
+cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
+cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
+cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
+cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
+cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
+cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
+cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
+cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
+cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
+cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
+cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
+cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
+dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
+github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
+github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
+github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
+github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
+github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
+github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
+github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
+github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
+github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
+github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
+github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
+github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
+github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
+github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
+github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
+github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
+github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
+github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
+github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
+github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
+github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
+github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
+github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
+github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA=
+github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
+github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
+github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
+github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
+github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
+github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE=
+github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
+github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ=
+github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U=
+github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
+github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
+github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
+github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
+github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY=
+github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
+github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
+github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI=
+github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
+github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
+go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
+go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
+go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
+go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
+go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
+go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
+go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
+golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
+golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
+golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
+golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
+golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
+golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
+golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
+golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
+golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
+golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
+golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
+golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
+golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
+golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
+golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
+golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
+golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
+golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
+golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
+golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
+golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
+golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
+golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
+golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
+google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
+google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
+google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
+google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
+google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
+google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
+google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
+google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
+google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
+google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
+google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
+google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
+google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
+google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
+google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
+google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
+google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
+google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
+google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
+google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
+google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/datatypes v1.2.0 h1:5YT+eokWdIxhJgWHdrb2zYUimyk0+TaFth+7a0ybzco=
+gorm.io/datatypes v1.2.0/go.mod h1:o1dh0ZvjIjhH/bngTpypG6lVRJ5chTBxE09FH/71k04=
+gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
+gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
+gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
+gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0=
+gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig=
+gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
+gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
+gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
+modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
+modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
+modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
+modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
+modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
+modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
+modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
+modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
+modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
+modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
+modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
+modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
+modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
+modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
+rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
diff --git a/infrastructure/database/custom_logger.go b/infrastructure/database/custom_logger.go
new file mode 100644
index 0000000..7496e1b
--- /dev/null
+++ b/infrastructure/database/custom_logger.go
@@ -0,0 +1,103 @@
+package database
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "gorm.io/gorm/logger"
+)
+
+// CustomLogger 自定义 GORM logger,截断过长的 SQL 参数(如 base64 数据)
+type CustomLogger struct {
+ logger.Interface
+}
+
+// NewCustomLogger 创建自定义 logger
+func NewCustomLogger() logger.Interface {
+ return &CustomLogger{
+ Interface: logger.Default.LogMode(logger.Silent),
+ }
+}
+
+// Trace 重写 Trace 方法,禁用 SQL 日志输出
+func (l *CustomLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {
+ // 不输出任何 SQL 日志
+ // 如果需要调试,可以临时取消注释下面的代码
+ /*
+ sql, rows := fc()
+ sql = truncateLongValues(sql)
+ elapsed := time.Since(begin)
+ if err != nil {
+ l.Interface.Error(ctx, "SQL error: %v [%v] %s", err, elapsed, sql)
+ } else {
+ l.Interface.Info(ctx, "[%.3fms] [rows:%d] %s", float64(elapsed.Nanoseconds())/1e6, rows, sql)
+ }
+ */
+}
+
+// truncateLongValues 截断 SQL 中的长字符串值
+func truncateLongValues(sql string) string {
+ // 查找 base64 格式的数据 (data:image/...;base64,...)
+ if strings.Contains(sql, "data:image/") && strings.Contains(sql, ";base64,") {
+ parts := strings.Split(sql, "\"")
+ for i, part := range parts {
+ if strings.HasPrefix(part, "data:image/") && strings.Contains(part, ";base64,") {
+ if len(part) > 100 {
+ // 保留前50字符,添加截断标记
+ parts[i] = part[:50] + "...[base64 data truncated]"
+ }
+ }
+ }
+ sql = strings.Join(parts, "\"")
+ }
+
+ // 截断其他过长的值
+ if len(sql) > 5000 {
+ // 查找 VALUES 或 SET 后的内容
+ if idx := strings.Index(sql, " VALUES "); idx > 0 && len(sql) > idx+5000 {
+ sql = sql[:idx+5000] + "...[truncated]"
+ } else if idx := strings.Index(sql, " SET "); idx > 0 && len(sql) > idx+3000 {
+ sql = sql[:idx+3000] + "...[truncated]"
+ } else if len(sql) > 5000 {
+ sql = sql[:5000] + "...[truncated]"
+ }
+ }
+
+ return sql
+}
+
+// Info 实现 Info 方法
+func (l *CustomLogger) Info(ctx context.Context, msg string, data ...interface{}) {
+ l.Interface.Info(ctx, msg, data...)
+}
+
+// Warn 实现 Warn 方法
+func (l *CustomLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
+ l.Interface.Warn(ctx, msg, data...)
+}
+
+// Error 实现 Error 方法
+func (l *CustomLogger) Error(ctx context.Context, msg string, data ...interface{}) {
+ // 检查并截断 data 中的长字符串
+ truncatedData := make([]interface{}, len(data))
+ for i, d := range data {
+ if str, ok := d.(string); ok && len(str) > 200 {
+ if strings.HasPrefix(str, "data:image/") {
+ truncatedData[i] = str[:50] + "...[base64 data]"
+ } else {
+ truncatedData[i] = str[:200] + "..."
+ }
+ } else {
+ truncatedData[i] = d
+ }
+ }
+ l.Interface.Error(ctx, msg, truncatedData...)
+}
+
+// LogMode 实现 LogMode 方法
+func (l *CustomLogger) LogMode(level logger.LogLevel) logger.Interface {
+ newLogger := *l
+ newLogger.Interface = l.Interface.LogMode(level)
+ return &newLogger
+}
diff --git a/infrastructure/database/database.go b/infrastructure/database/database.go
new file mode 100644
index 0000000..87c9d9a
--- /dev/null
+++ b/infrastructure/database/database.go
@@ -0,0 +1,97 @@
+package database
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/drama-generator/backend/domain/models"
+ "github.com/drama-generator/backend/pkg/config"
+ "gorm.io/driver/mysql"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ _ "modernc.org/sqlite"
+)
+
+func NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
+ dsn := cfg.DSN()
+
+ if cfg.Type == "sqlite" {
+ dbDir := filepath.Dir(dsn)
+ if err := os.MkdirAll(dbDir, 0755); err != nil {
+ return nil, fmt.Errorf("failed to create database directory: %w", err)
+ }
+ }
+
+ gormConfig := &gorm.Config{
+ Logger: NewCustomLogger(),
+ }
+
+ var db *gorm.DB
+ var err error
+
+ if cfg.Type == "sqlite" {
+ // 使用 modernc.org/sqlite 纯 Go 驱动(无需 CGO)
+ // 添加并发优化参数:WAL 模式、busy_timeout、cache
+ dsnWithParams := dsn + "?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL&cache=shared"
+ db, err = gorm.Open(sqlite.Dialector{
+ DriverName: "sqlite",
+ DSN: dsnWithParams,
+ }, gormConfig)
+ } else {
+ db, err = gorm.Open(mysql.Open(dsn), gormConfig)
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to database: %w", err)
+ }
+
+ sqlDB, err := db.DB()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get database instance: %w", err)
+ }
+
+ // SQLite 连接池配置(限制并发连接数)
+ if cfg.Type == "sqlite" {
+ sqlDB.SetMaxIdleConns(1)
+ sqlDB.SetMaxOpenConns(1) // SQLite 单写入,限制为 1
+ } else {
+ sqlDB.SetMaxIdleConns(cfg.MaxIdle)
+ sqlDB.SetMaxOpenConns(cfg.MaxOpen)
+ }
+ sqlDB.SetConnMaxLifetime(time.Hour)
+
+ if err := sqlDB.Ping(); err != nil {
+ return nil, fmt.Errorf("failed to ping database: %w", err)
+ }
+
+ return db, nil
+}
+
+func AutoMigrate(db *gorm.DB) error {
+ return db.AutoMigrate(
+ // 核心模型
+ &models.Drama{},
+ &models.Episode{},
+ &models.Character{},
+ &models.Scene{},
+ &models.Storyboard{},
+
+ // 生成相关
+ &models.ImageGeneration{},
+ &models.VideoGeneration{},
+ &models.VideoMerge{},
+
+ // AI配置
+ &models.AIServiceConfig{},
+ &models.AIServiceProvider{},
+
+ // 资源管理
+ &models.Asset{},
+ &models.CharacterLibrary{},
+
+ // 任务管理
+ &models.AsyncTask{},
+ )
+}
diff --git a/infrastructure/external/ffmpeg/ffmpeg.go b/infrastructure/external/ffmpeg/ffmpeg.go
new file mode 100644
index 0000000..624964e
--- /dev/null
+++ b/infrastructure/external/ffmpeg/ffmpeg.go
@@ -0,0 +1,612 @@
+package ffmpeg
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/drama-generator/backend/pkg/logger"
+)
+
+type FFmpeg struct {
+ log *logger.Logger
+ tempDir string
+}
+
+func NewFFmpeg(log *logger.Logger) *FFmpeg {
+ tempDir := filepath.Join(os.TempDir(), "drama-video-merge")
+ os.MkdirAll(tempDir, 0755)
+
+ return &FFmpeg{
+ log: log,
+ tempDir: tempDir,
+ }
+}
+
+type VideoClip struct {
+ URL string
+ Duration float64
+ StartTime float64
+ EndTime float64
+ Transition map[string]interface{}
+}
+
+type MergeOptions struct {
+ OutputPath string
+ Clips []VideoClip
+}
+
+func (f *FFmpeg) MergeVideos(opts *MergeOptions) (string, error) {
+ if len(opts.Clips) == 0 {
+ return "", fmt.Errorf("no video clips to merge")
+ }
+
+ f.log.Infow("Starting video merge with trimming", "clips_count", len(opts.Clips))
+
+ // 下载并裁剪所有视频片段
+ trimmedPaths := make([]string, 0, len(opts.Clips))
+ downloadedPaths := make([]string, 0, len(opts.Clips))
+
+ for i, clip := range opts.Clips {
+ // 下载原始视频
+ downloadPath := filepath.Join(f.tempDir, fmt.Sprintf("download_%d_%d.mp4", time.Now().Unix(), i))
+ localPath, err := f.downloadVideo(clip.URL, downloadPath)
+ if err != nil {
+ f.cleanup(downloadedPaths)
+ f.cleanup(trimmedPaths)
+ return "", fmt.Errorf("failed to download clip %d: %w", i, err)
+ }
+ downloadedPaths = append(downloadedPaths, localPath)
+
+ // 裁剪视频片段(根据StartTime和EndTime)
+ trimmedPath := filepath.Join(f.tempDir, fmt.Sprintf("trimmed_%d_%d.mp4", time.Now().Unix(), i))
+ err = f.trimVideo(localPath, trimmedPath, clip.StartTime, clip.EndTime)
+ if err != nil {
+ f.cleanup(downloadedPaths)
+ f.cleanup(trimmedPaths)
+ return "", fmt.Errorf("failed to trim clip %d: %w", i, err)
+ }
+ trimmedPaths = append(trimmedPaths, trimmedPath)
+
+ f.log.Infow("Clip trimmed",
+ "index", i,
+ "start", clip.StartTime,
+ "end", clip.EndTime,
+ "duration", clip.EndTime-clip.StartTime)
+ }
+
+ // 清理下载的原始文件
+ f.cleanup(downloadedPaths)
+
+ // 确保输出目录存在
+ outputDir := filepath.Dir(opts.OutputPath)
+ if err := os.MkdirAll(outputDir, 0755); err != nil {
+ f.cleanup(trimmedPaths)
+ return "", fmt.Errorf("failed to create output directory: %w", err)
+ }
+
+ // 合并裁剪后的视频片段(支持转场效果)
+ err := f.concatenateVideosWithTransitions(trimmedPaths, opts.Clips, opts.OutputPath)
+
+ // 清理裁剪后的临时文件
+ f.cleanup(trimmedPaths)
+
+ if err != nil {
+ return "", fmt.Errorf("failed to concatenate videos: %w", err)
+ }
+
+ f.log.Infow("Video merge completed", "output", opts.OutputPath)
+ return opts.OutputPath, nil
+}
+
+func (f *FFmpeg) downloadVideo(url, destPath string) (string, error) {
+ f.log.Infow("Downloading video", "url", url, "dest", destPath)
+
+ resp, err := http.Get(url)
+ if err != nil {
+ return "", fmt.Errorf("failed to download: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("bad status: %s", resp.Status)
+ }
+
+ out, err := os.Create(destPath)
+ if err != nil {
+ return "", fmt.Errorf("failed to create file: %w", err)
+ }
+ defer out.Close()
+
+ _, err = io.Copy(out, resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to save file: %w", err)
+ }
+
+ return destPath, nil
+}
+
+func (f *FFmpeg) trimVideo(inputPath, outputPath string, startTime, endTime float64) error {
+ f.log.Infow("Trimming video",
+ "input", inputPath,
+ "output", outputPath,
+ "start", startTime,
+ "end", endTime)
+
+ // 如果startTime和endTime都为0,或者endTime <= startTime,复制整个视频
+ // 使用重新编码而非-c copy以确保输出文件完整性
+ if (startTime == 0 && endTime == 0) || endTime <= startTime {
+ f.log.Infow("No valid trim range, re-encoding entire video")
+
+ cmd := exec.Command("ffmpeg",
+ "-i", inputPath,
+ "-c:v", "libx264",
+ "-preset", "fast",
+ "-crf", "23",
+ "-c:a", "aac",
+ "-b:a", "128k",
+ "-movflags", "+faststart",
+ "-y",
+ outputPath,
+ )
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ f.log.Errorw("FFmpeg re-encode failed", "error", err, "output", string(output))
+ return fmt.Errorf("ffmpeg re-encode failed: %w, output: %s", err, string(output))
+ }
+
+ f.log.Infow("Video re-encoded successfully", "output", outputPath)
+ return nil
+ }
+
+ // 使用FFmpeg裁剪视频
+ // -ss: 开始时间(秒)
+ // -to/-t: 结束时间或持续时间
+ // 使用重新编码而非-c copy以确保输出文件完整性,避免Windows环境下流信息丢失
+ var cmd *exec.Cmd
+ if endTime > 0 {
+ // 有明确的结束时间
+ cmd = exec.Command("ffmpeg",
+ "-i", inputPath,
+ "-ss", fmt.Sprintf("%.2f", startTime),
+ "-to", fmt.Sprintf("%.2f", endTime),
+ "-c:v", "libx264",
+ "-preset", "fast",
+ "-crf", "23",
+ "-c:a", "aac",
+ "-b:a", "128k",
+ "-movflags", "+faststart",
+ "-y",
+ outputPath,
+ )
+ } else {
+ // 只有开始时间,裁剪到视频末尾
+ cmd = exec.Command("ffmpeg",
+ "-i", inputPath,
+ "-ss", fmt.Sprintf("%.2f", startTime),
+ "-c:v", "libx264",
+ "-preset", "fast",
+ "-crf", "23",
+ "-c:a", "aac",
+ "-b:a", "128k",
+ "-movflags", "+faststart",
+ "-y",
+ outputPath,
+ )
+ }
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ f.log.Errorw("FFmpeg trim failed", "error", err, "output", string(output))
+ return fmt.Errorf("ffmpeg trim failed: %w, output: %s", err, string(output))
+ }
+
+ f.log.Infow("Video trimmed successfully", "output", outputPath)
+ return nil
+}
+
+func (f *FFmpeg) concatenateVideosWithTransitions(inputPaths []string, clips []VideoClip, outputPath string) error {
+ if len(inputPaths) == 0 {
+ return fmt.Errorf("no input paths")
+ }
+
+ // 如果只有一个视频,直接复制
+ if len(inputPaths) == 1 {
+ f.log.Infow("Only one clip, copying directly")
+ return f.copyFile(inputPaths[0], outputPath)
+ }
+
+ // 检查是否有转场效果
+ hasTransitions := false
+ for _, clip := range clips {
+ if clip.Transition != nil && len(clip.Transition) > 0 {
+ hasTransitions = true
+ break
+ }
+ }
+
+ // 如果没有转场效果,使用简单拼接
+ if !hasTransitions {
+ f.log.Infow("No transitions, using simple concatenation")
+ return f.concatenateVideos(inputPaths, outputPath)
+ }
+
+ // 使用xfade滤镜添加转场效果
+ f.log.Infow("Merging with transitions", "clips_count", len(inputPaths))
+ return f.mergeWithXfade(inputPaths, clips, outputPath)
+}
+
+func (f *FFmpeg) concatenateVideos(inputPaths []string, outputPath string) error {
+ // 创建文件列表
+ listFile := filepath.Join(f.tempDir, fmt.Sprintf("filelist_%d.txt", time.Now().Unix()))
+ defer os.Remove(listFile)
+
+ var content strings.Builder
+ for _, path := range inputPaths {
+ content.WriteString(fmt.Sprintf("file '%s'\n", path))
+ }
+
+ if err := os.WriteFile(listFile, []byte(content.String()), 0644); err != nil {
+ return fmt.Errorf("failed to create file list: %w", err)
+ }
+
+ // 使用FFmpeg合并视频
+ // -f concat: 使用concat demuxer
+ // -safe 0: 允许不安全的文件路径
+ // -i: 输入文件列表
+ // -c copy: 直接复制流,不重新编码(速度快)
+ cmd := exec.Command("ffmpeg",
+ "-f", "concat",
+ "-safe", "0",
+ "-i", listFile,
+ "-c", "copy",
+ "-y", // 覆盖输出文件
+ outputPath,
+ )
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ f.log.Errorw("FFmpeg failed", "error", err, "output", string(output))
+ return fmt.Errorf("ffmpeg execution failed: %w, output: %s", err, string(output))
+ }
+
+ f.log.Infow("FFmpeg concatenation completed", "output", outputPath)
+ return nil
+}
+
+func (f *FFmpeg) mergeWithXfade(inputPaths []string, clips []VideoClip, outputPath string) error {
+ // 使用xfade滤镜进行转场
+ // 构建输入参数
+ args := []string{}
+ for _, path := range inputPaths {
+ args = append(args, "-i", path)
+ }
+
+ // 检测每个视频是否有音频流
+ audioStreams := make([]bool, len(inputPaths))
+ hasAnyAudio := false
+ for i, path := range inputPaths {
+ audioStreams[i] = f.hasAudioStream(path)
+ if audioStreams[i] {
+ hasAnyAudio = true
+ }
+ f.log.Infow("Audio stream detection", "index", i, "path", path, "has_audio", audioStreams[i])
+ }
+ f.log.Infow("Overall audio detection", "has_any_audio", hasAnyAudio, "audio_streams", audioStreams)
+
+ // 检测视频分辨率,找到最大分辨率作为目标分辨率
+ maxWidth := 0
+ maxHeight := 0
+ for i, path := range inputPaths {
+ width, height := f.getVideoResolution(path)
+ if width > maxWidth {
+ maxWidth = width
+ }
+ if height > maxHeight {
+ maxHeight = height
+ }
+ f.log.Infow("Video resolution detection", "index", i, "width", width, "height", height)
+ }
+ f.log.Infow("Target resolution", "width", maxWidth, "height", maxHeight)
+
+ // 为每个视频流添加缩放滤镜,统一分辨率
+ var scaleFilters []string
+ for i := 0; i < len(inputPaths); i++ {
+ // 使用scale滤镜缩放到目标分辨率,pad添加黑边保持长宽比
+ scaleFilters = append(scaleFilters,
+ fmt.Sprintf("[%d:v]scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2[v%d]",
+ i, maxWidth, maxHeight, maxWidth, maxHeight, i))
+ }
+
+ // 构建filter_complex
+ // 例如: [0:v][1:v]xfade=transition=fade:duration=1:offset=5[v01];[v01][2:v]xfade=transition=fade:duration=1:offset=10[out]
+ // 构建转场滤镜,使用缩放后的视频流
+ var transitionFilters []string
+ var offset float64 = 0
+
+ for i := 0; i < len(inputPaths)-1; i++ {
+ // 获取当前片段的时长
+ clipDuration := clips[i].Duration
+ if clips[i].EndTime > 0 && clips[i].StartTime >= 0 {
+ clipDuration = clips[i].EndTime - clips[i].StartTime
+ }
+
+ // 获取转场类型和时长
+ transitionType := "fade" // 默认淡入淡出
+ transitionDuration := 1.0 // 默认转场时长为1秒
+
+ if clips[i].Transition != nil {
+ // 读取转场类型
+ if tType, ok := clips[i].Transition["type"].(string); ok && tType != "" {
+ transitionType = f.mapTransitionType(tType)
+ f.log.Infow("Using transition type", "type", tType, "mapped", transitionType)
+ }
+ // 读取转场时长
+ if tDuration, ok := clips[i].Transition["duration"].(float64); ok && tDuration > 0 {
+ transitionDuration = tDuration
+ }
+ }
+
+ // 计算转场开始的时间点
+ // 转场在两个片段的交界处,从前一个片段结束前 transitionDuration/2 开始
+ // 这样转场效果会平均分布在两个片段的交界处
+ offset += clipDuration - (transitionDuration / 2)
+ if offset < 0 {
+ offset = 0
+ }
+
+ f.log.Infow("Transition settings",
+ "clip_index", i,
+ "type", transitionType,
+ "duration", transitionDuration,
+ "offset", offset,
+ "clip_duration", clipDuration)
+
+ var inputLabel, outputLabel string
+ if i == 0 {
+ inputLabel = fmt.Sprintf("[v0][v1]")
+ } else {
+ inputLabel = fmt.Sprintf("[vx%02d][v%d]", i-1, i+1)
+ }
+
+ if i == len(inputPaths)-2 {
+ outputLabel = "[outv]"
+ } else {
+ outputLabel = fmt.Sprintf("[vx%02d]", i)
+ }
+
+ filterPart := fmt.Sprintf("%sxfade=transition=%s:duration=%.1f:offset=%.1f%s",
+ inputLabel, transitionType, transitionDuration, offset, outputLabel)
+ transitionFilters = append(transitionFilters, filterPart)
+ }
+
+ // 合并缩放和转场滤镜
+ var videoFilters []string
+ videoFilters = append(videoFilters, scaleFilters...)
+ videoFilters = append(videoFilters, transitionFilters...)
+ filterComplex := strings.Join(videoFilters, ";")
+
+ // 音频处理:如果有任何视频包含音频流,则处理音频
+ var fullFilter string
+ if hasAnyAudio {
+ // 为没有音频的视频生成静音轨道,确保所有输入音频流一致
+ var silenceFilters []string
+ for i := 0; i < len(inputPaths); i++ {
+ if !audioStreams[i] {
+ // 计算该视频的时长
+ clipDuration := clips[i].Duration
+ if clips[i].EndTime > 0 && clips[i].StartTime >= 0 {
+ clipDuration = clips[i].EndTime - clips[i].StartTime
+ }
+ // anullsrc是源滤镜,不接受输入,使用duration参数指定时长
+ silenceFilters = append(silenceFilters,
+ fmt.Sprintf("anullsrc=channel_layout=stereo:sample_rate=44100:duration=%.2f[a%d]", clipDuration, i))
+ }
+ }
+
+ // 拼接所有音频流(包括生成的静音流)
+ var audioConcat strings.Builder
+ for i := 0; i < len(inputPaths); i++ {
+ if audioStreams[i] {
+ audioConcat.WriteString(fmt.Sprintf("[%d:a]", i))
+ } else {
+ audioConcat.WriteString(fmt.Sprintf("[a%d]", i))
+ }
+ }
+ audioConcat.WriteString(fmt.Sprintf("concat=n=%d:v=0:a=1[outa]", len(inputPaths)))
+
+ // 构建完整滤镜:先生成静音流,再拼接音频
+ if len(silenceFilters) > 0 {
+ fullFilter = filterComplex + ";" + strings.Join(silenceFilters, ";") + ";" + audioConcat.String()
+ } else {
+ fullFilter = filterComplex + ";" + audioConcat.String()
+ }
+ } else {
+ // 所有视频都无音频流,只处理视频
+ fullFilter = filterComplex
+ }
+
+ // 构建完整命令
+ args = append(args,
+ "-filter_complex", fullFilter,
+ "-map", "[outv]",
+ )
+
+ // 仅在有任何音频时映射音频输出
+ if hasAnyAudio {
+ args = append(args, "-map", "[outa]")
+ }
+
+ args = append(args,
+ "-c:v", "libx264",
+ "-preset", "medium",
+ "-crf", "23",
+ )
+
+ // 仅在有任何音频时设置音频编码参数
+ if hasAnyAudio {
+ args = append(args,
+ "-c:a", "aac",
+ "-b:a", "128k",
+ )
+ }
+
+ args = append(args,
+ "-y",
+ outputPath,
+ )
+
+ f.log.Infow("Running FFmpeg with transitions", "filter", fullFilter, "has_any_audio", hasAnyAudio)
+
+ cmd := exec.Command("ffmpeg", args...)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ f.log.Errorw("FFmpeg xfade failed", "error", err, "output", string(output))
+ return fmt.Errorf("ffmpeg xfade failed: %w, output: %s", err, string(output))
+ }
+
+ f.log.Infow("Video merged with transitions successfully")
+ return nil
+}
+
+func (f *FFmpeg) mapTransitionType(transType string) string {
+ // 将前端传入的转场类型映射为FFmpeg xfade支持的类型
+ // FFmpeg xfade支持的完整转场列表: https://ffmpeg.org/ffmpeg-filters.html#xfade
+ switch strings.ToLower(transType) {
+ // 淡入淡出类
+ case "fade", "fadein", "fadeout":
+ return "fade"
+ case "fadeblack":
+ return "fadeblack"
+ case "fadewhite":
+ return "fadewhite"
+ case "fadegrays":
+ return "fadegrays"
+
+ // 滑动类
+ case "slideleft":
+ return "slideleft"
+ case "slideright":
+ return "slideright"
+ case "slideup":
+ return "slideup"
+ case "slidedown":
+ return "slidedown"
+
+ // 擦除类
+ case "wipeleft":
+ return "wipeleft"
+ case "wiperight":
+ return "wiperight"
+ case "wipeup":
+ return "wipeup"
+ case "wipedown":
+ return "wipedown"
+
+ // 圆形类
+ case "circleopen":
+ return "circleopen"
+ case "circleclose":
+ return "circleclose"
+
+ // 矩形打开/关闭类
+ case "horzopen":
+ return "horzopen"
+ case "horzclose":
+ return "horzclose"
+ case "vertopen":
+ return "vertopen"
+ case "vertclose":
+ return "vertclose"
+
+ // 其他特效
+ case "dissolve":
+ return "dissolve"
+ case "distance":
+ return "distance"
+ case "pixelize":
+ return "pixelize"
+
+ default:
+ return "fade" // 默认淡入淡出
+ }
+}
+
+func (f *FFmpeg) hasAudioStream(videoPath string) bool {
+ cmd := exec.Command("ffprobe",
+ "-v", "error",
+ "-select_streams", "a:0",
+ "-show_entries", "stream=codec_type",
+ "-of", "default=noprint_wrappers=1:nokey=1",
+ videoPath,
+ )
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return false
+ }
+
+ result := strings.TrimSpace(string(output))
+ return result == "audio"
+}
+
+func (f *FFmpeg) getVideoResolution(videoPath string) (int, int) {
+ cmd := exec.Command("ffprobe",
+ "-v", "error",
+ "-select_streams", "v:0",
+ "-show_entries", "stream=width,height",
+ "-of", "csv=p=0",
+ videoPath,
+ )
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ f.log.Warnw("Failed to get video resolution", "path", videoPath, "error", err)
+ return 1920, 1080 // 默认分辨率
+ }
+
+ result := strings.TrimSpace(string(output))
+ parts := strings.Split(result, ",")
+ if len(parts) != 2 {
+ f.log.Warnw("Invalid resolution format", "output", result)
+ return 1920, 1080
+ }
+
+ var width, height int
+ fmt.Sscanf(parts[0], "%d", &width)
+ fmt.Sscanf(parts[1], "%d", &height)
+
+ if width <= 0 || height <= 0 {
+ return 1920, 1080
+ }
+
+ return width, height
+}
+
+func (f *FFmpeg) copyFile(src, dst string) error {
+ cmd := exec.Command("cp", src, dst)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ f.log.Errorw("File copy failed", "error", err, "output", string(output))
+ return fmt.Errorf("copy failed: %w", err)
+ }
+ return nil
+}
+
+func (f *FFmpeg) cleanup(paths []string) {
+ for _, path := range paths {
+ if err := os.Remove(path); err != nil {
+ f.log.Warnw("Failed to cleanup file", "path", path, "error", err)
+ }
+ }
+}
+
+func (f *FFmpeg) CleanupTempDir() error {
+ return os.RemoveAll(f.tempDir)
+}
diff --git a/infrastructure/scheduler/resource_transfer_scheduler.go b/infrastructure/scheduler/resource_transfer_scheduler.go
new file mode 100644
index 0000000..64795ea
--- /dev/null
+++ b/infrastructure/scheduler/resource_transfer_scheduler.go
@@ -0,0 +1,240 @@
+package scheduler
+
+import (
+ "time"
+
+ "github.com/drama-generator/backend/application/services"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/robfig/cron/v3"
+ "gorm.io/gorm"
+)
+
+type ResourceTransferScheduler struct {
+ cron *cron.Cron
+ transferService *services.ResourceTransferService
+ db *gorm.DB
+ log *logger.Logger
+ running bool
+}
+
+func NewResourceTransferScheduler(
+ transferService *services.ResourceTransferService,
+ db *gorm.DB,
+ log *logger.Logger,
+) *ResourceTransferScheduler {
+ return &ResourceTransferScheduler{
+ cron: cron.New(cron.WithSeconds()),
+ transferService: transferService,
+ db: db,
+ log: log,
+ running: false,
+ }
+}
+
+// Start 启动定时任务
+func (s *ResourceTransferScheduler) Start() error {
+ if s.running {
+ s.log.Warn("Resource transfer scheduler already running")
+ return nil
+ }
+
+ s.log.Info("Starting resource transfer scheduler...")
+
+ // 每小时执行一次资源转存任务
+ _, err := s.cron.AddFunc("0 0 * * * *", func() {
+ s.log.Info("Starting scheduled resource transfer task")
+ s.transferPendingResources()
+ })
+ if err != nil {
+ return err
+ }
+
+ // 每天凌晨2点执行完整扫描
+ _, err = s.cron.AddFunc("0 0 2 * * *", func() {
+ s.log.Info("Starting daily full resource scan and transfer")
+ s.transferAllPendingResources()
+ })
+ if err != nil {
+ return err
+ }
+
+ s.cron.Start()
+ s.running = true
+ s.log.Info("Resource transfer scheduler started successfully")
+
+ return nil
+}
+
+// Stop 停止定时任务
+func (s *ResourceTransferScheduler) Stop() {
+ if !s.running {
+ return
+ }
+
+ s.log.Info("Stopping resource transfer scheduler...")
+ ctx := s.cron.Stop()
+ <-ctx.Done()
+ s.running = false
+ s.log.Info("Resource transfer scheduler stopped")
+}
+
+// transferPendingResources 转存最近生成的待转存资源(最近24小时)
+func (s *ResourceTransferScheduler) transferPendingResources() {
+ s.log.Info("Scanning for pending resources to transfer (last 24 hours)...")
+
+ // 查找最近24小时内完成的、还未转存的图片和视频
+ type DramaCount struct {
+ DramaID string
+ Count int64
+ }
+
+ // 统计每个剧本的待转存图片数量
+ var imageDramas []DramaCount
+ s.db.Raw(`
+ SELECT drama_id, COUNT(*) as count
+ FROM image_generations
+ WHERE status = 'completed'
+ AND image_url IS NOT NULL
+ AND image_url != ''
+ AND (minio_url IS NULL OR minio_url = '')
+ AND completed_at >= ?
+ GROUP BY drama_id
+ `, time.Now().Add(-24*time.Hour)).Scan(&imageDramas)
+
+ // 转存图片
+ imageCount := 0
+ for _, drama := range imageDramas {
+ count, err := s.transferService.BatchTransferImagesToMinio(drama.DramaID, 50) // 每个剧本最多转50个
+ if err != nil {
+ s.log.Errorw("Failed to transfer images for drama",
+ "drama_id", drama.DramaID,
+ "error", err)
+ continue
+ }
+ imageCount += count
+ s.log.Infow("Transferred images for drama",
+ "drama_id", drama.DramaID,
+ "count", count)
+ }
+
+ // 统计每个剧本的待转存视频数量
+ var videoDramas []DramaCount
+ s.db.Raw(`
+ SELECT drama_id, COUNT(*) as count
+ FROM video_generations
+ WHERE status = 'completed'
+ AND video_url IS NOT NULL
+ AND video_url != ''
+ AND (minio_url IS NULL OR minio_url = '')
+ AND completed_at >= ?
+ GROUP BY drama_id
+ `, time.Now().Add(-24*time.Hour)).Scan(&videoDramas)
+
+ // 转存视频
+ videoCount := 0
+ for _, drama := range videoDramas {
+ count, err := s.transferService.BatchTransferVideosToMinio(drama.DramaID, 50) // 每个剧本最多转50个
+ if err != nil {
+ s.log.Errorw("Failed to transfer videos for drama",
+ "drama_id", drama.DramaID,
+ "error", err)
+ continue
+ }
+ videoCount += count
+ s.log.Infow("Transferred videos for drama",
+ "drama_id", drama.DramaID,
+ "count", count)
+ }
+
+ s.log.Infow("Scheduled resource transfer task completed",
+ "images", imageCount,
+ "videos", videoCount)
+}
+
+// transferAllPendingResources 转存所有待转存的资源(全量扫描)
+func (s *ResourceTransferScheduler) transferAllPendingResources() {
+ s.log.Info("Starting full scan for all pending resources...")
+
+ // 查找所有待转存的资源
+ type DramaCount struct {
+ DramaID string
+ Count int64
+ }
+
+ // 统计所有剧本的待转存图片
+ var imageDramas []DramaCount
+ s.db.Raw(`
+ SELECT drama_id, COUNT(*) as count
+ FROM image_generations
+ WHERE status = 'completed'
+ AND image_url IS NOT NULL
+ AND image_url != ''
+ AND (minio_url IS NULL OR minio_url = '')
+ GROUP BY drama_id
+ `).Scan(&imageDramas)
+
+ s.log.Infow("Found dramas with pending images", "count", len(imageDramas))
+
+ // 转存所有待转存图片
+ totalImageCount := 0
+ for _, drama := range imageDramas {
+ count, err := s.transferService.BatchTransferImagesToMinio(drama.DramaID, 0) // 0表示全部转存
+ if err != nil {
+ s.log.Errorw("Failed to transfer images for drama",
+ "drama_id", drama.DramaID,
+ "error", err)
+ continue
+ }
+ totalImageCount += count
+ s.log.Infow("Transferred all images for drama",
+ "drama_id", drama.DramaID,
+ "count", count)
+ }
+
+ // 统计所有剧本的待转存视频
+ var videoDramas []DramaCount
+ s.db.Raw(`
+ SELECT drama_id, COUNT(*) as count
+ FROM video_generations
+ WHERE status = 'completed'
+ AND video_url IS NOT NULL
+ AND video_url != ''
+ AND (minio_url IS NULL OR minio_url = '')
+ GROUP BY drama_id
+ `).Scan(&videoDramas)
+
+ s.log.Infow("Found dramas with pending videos", "count", len(videoDramas))
+
+ // 转存所有待转存视频
+ totalVideoCount := 0
+ for _, drama := range videoDramas {
+ count, err := s.transferService.BatchTransferVideosToMinio(drama.DramaID, 0) // 0表示全部转存
+ if err != nil {
+ s.log.Errorw("Failed to transfer videos for drama",
+ "drama_id", drama.DramaID,
+ "error", err)
+ continue
+ }
+ totalVideoCount += count
+ s.log.Infow("Transferred all videos for drama",
+ "drama_id", drama.DramaID,
+ "count", count)
+ }
+
+ s.log.Infow("Full resource scan and transfer completed",
+ "total_images", totalImageCount,
+ "total_videos", totalVideoCount,
+ "drama_count", len(imageDramas)+len(videoDramas))
+}
+
+// RunNow 立即执行一次转存任务(用于手动触发)
+func (s *ResourceTransferScheduler) RunNow() {
+ s.log.Info("Manually triggering resource transfer task...")
+ go s.transferPendingResources()
+}
+
+// RunFullScan 立即执行一次全量扫描(用于手动触发)
+func (s *ResourceTransferScheduler) RunFullScan() {
+ s.log.Info("Manually triggering full resource scan...")
+ go s.transferAllPendingResources()
+}
diff --git a/infrastructure/storage/local_storage.go b/infrastructure/storage/local_storage.go
new file mode 100644
index 0000000..45a4ec6
--- /dev/null
+++ b/infrastructure/storage/local_storage.go
@@ -0,0 +1,137 @@
+package storage
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+type LocalStorage struct {
+ basePath string
+ baseURL string
+}
+
+func NewLocalStorage(basePath, baseURL string) (*LocalStorage, error) {
+ if err := os.MkdirAll(basePath, 0755); err != nil {
+ return nil, fmt.Errorf("failed to create storage directory: %w", err)
+ }
+
+ return &LocalStorage{
+ basePath: basePath,
+ baseURL: baseURL,
+ }, nil
+}
+
+func (s *LocalStorage) Upload(file io.Reader, filename string, category string) (string, error) {
+ dir := filepath.Join(s.basePath, category)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return "", fmt.Errorf("failed to create category directory: %w", err)
+ }
+
+ timestamp := time.Now().Format("20060102_150405")
+ newFilename := fmt.Sprintf("%s_%s", timestamp, filename)
+ filePath := filepath.Join(dir, newFilename)
+
+ dst, err := os.Create(filePath)
+ if err != nil {
+ return "", fmt.Errorf("failed to create file: %w", err)
+ }
+ defer dst.Close()
+
+ if _, err := io.Copy(dst, file); err != nil {
+ return "", fmt.Errorf("failed to save file: %w", err)
+ }
+
+ url := fmt.Sprintf("%s/%s/%s", s.baseURL, category, newFilename)
+ return url, nil
+}
+
+func (s *LocalStorage) Delete(url string) error {
+ return nil
+}
+
+func (s *LocalStorage) GetURL(path string) string {
+ return fmt.Sprintf("%s/%s", s.baseURL, path)
+}
+
+// DownloadFromURL 从远程URL下载文件到本地存储
+func (s *LocalStorage) DownloadFromURL(url, category string) (string, error) {
+ // 发送HTTP请求下载文件
+ resp, err := http.Get(url)
+ if err != nil {
+ return "", fmt.Errorf("failed to download file: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("failed to download file: HTTP %d", resp.StatusCode)
+ }
+
+ // 从URL或Content-Type推断文件扩展名
+ ext := getFileExtension(url, resp.Header.Get("Content-Type"))
+
+ // 创建目录
+ dir := filepath.Join(s.basePath, category)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return "", fmt.Errorf("failed to create category directory: %w", err)
+ }
+
+ // 生成唯一文件名
+ timestamp := time.Now().Format("20060102_150405_000")
+ filename := fmt.Sprintf("%s%s", timestamp, ext)
+ filePath := filepath.Join(dir, filename)
+
+ // 保存文件
+ dst, err := os.Create(filePath)
+ if err != nil {
+ return "", fmt.Errorf("failed to create file: %w", err)
+ }
+ defer dst.Close()
+
+ if _, err := io.Copy(dst, resp.Body); err != nil {
+ return "", fmt.Errorf("failed to save file: %w", err)
+ }
+
+ // 返回本地URL
+ localURL := fmt.Sprintf("%s/%s/%s", s.baseURL, category, filename)
+ return localURL, nil
+}
+
+// getFileExtension 从URL或Content-Type推断文件扩展名
+func getFileExtension(url, contentType string) string {
+ // 首先尝试从URL获取扩展名
+ if idx := strings.LastIndex(url, "."); idx != -1 {
+ ext := url[idx:]
+ // 只取扩展名部分,忽略查询参数
+ if qIdx := strings.Index(ext, "?"); qIdx != -1 {
+ ext = ext[:qIdx]
+ }
+ if len(ext) <= 5 { // 合理的扩展名长度
+ return ext
+ }
+ }
+
+ // 根据Content-Type推断扩展名
+ switch {
+ case strings.Contains(contentType, "image/jpeg"):
+ return ".jpg"
+ case strings.Contains(contentType, "image/png"):
+ return ".png"
+ case strings.Contains(contentType, "image/gif"):
+ return ".gif"
+ case strings.Contains(contentType, "image/webp"):
+ return ".webp"
+ case strings.Contains(contentType, "video/mp4"):
+ return ".mp4"
+ case strings.Contains(contentType, "video/webm"):
+ return ".webm"
+ case strings.Contains(contentType, "video/quicktime"):
+ return ".mov"
+ default:
+ return ".bin"
+ }
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..3c6669f
--- /dev/null
+++ b/main.go
@@ -0,0 +1,103 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/drama-generator/backend/api/routes"
+ "github.com/drama-generator/backend/infrastructure/database"
+ "github.com/drama-generator/backend/infrastructure/storage"
+ "github.com/drama-generator/backend/pkg/config"
+ "github.com/drama-generator/backend/pkg/logger"
+ "github.com/gin-gonic/gin"
+)
+
+func main() {
+ cfg, err := config.LoadConfig()
+ if err != nil {
+ log.Fatalf("Failed to load config: %v", err)
+ }
+
+ logr := logger.NewLogger(cfg.App.Debug)
+ defer logr.Sync()
+
+ logr.Info("Starting Drama Generator API Server...")
+
+ db, err := database.NewDatabase(cfg.Database)
+ if err != nil {
+ logr.Fatal("Failed to connect to database", "error", err)
+ }
+ logr.Info("Database connected successfully")
+
+ // 自动迁移数据库表结构
+ if err := database.AutoMigrate(db); err != nil {
+ logr.Fatal("Failed to migrate database", "error", err)
+ }
+ logr.Info("Database tables migrated successfully")
+
+ // 初始化本地存储
+ var localStorage *storage.LocalStorage
+ if cfg.Storage.Type == "local" {
+ localStorage, err = storage.NewLocalStorage(cfg.Storage.LocalPath, cfg.Storage.BaseURL)
+ if err != nil {
+ logr.Fatal("Failed to initialize local storage", "error", err)
+ }
+ logr.Info("Local storage initialized successfully", "path", cfg.Storage.LocalPath)
+ }
+
+ if cfg.App.Debug {
+ gin.SetMode(gin.DebugMode)
+ } else {
+ gin.SetMode(gin.ReleaseMode)
+ }
+
+ router := routes.SetupRouter(cfg, db, logr, localStorage)
+
+ srv := &http.Server{
+ Addr: fmt.Sprintf(":%d", cfg.Server.Port),
+ Handler: router,
+ ReadTimeout: 10 * time.Minute,
+ WriteTimeout: 10 * time.Minute,
+ }
+
+ go func() {
+ logr.Infow("🚀 Server starting...",
+ "port", cfg.Server.Port,
+ "mode", gin.Mode())
+ logr.Info("📍 Access URLs:")
+ logr.Info(fmt.Sprintf(" Frontend: http://localhost:%d", cfg.Server.Port))
+ logr.Info(fmt.Sprintf(" API: http://localhost:%d/api/v1", cfg.Server.Port))
+ logr.Info(fmt.Sprintf(" Health: http://localhost:%d/health", cfg.Server.Port))
+ logr.Info("📁 Static files:")
+ logr.Info(fmt.Sprintf(" Uploads: http://localhost:%d/static", cfg.Server.Port))
+ logr.Info(fmt.Sprintf(" Assets: http://localhost:%d/assets", cfg.Server.Port))
+ logr.Info("✅ Server is ready!")
+
+ if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ logr.Fatal("Failed to start server", "error", err)
+ }
+ }()
+
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+ <-quit
+
+ logr.Info("Shutting down server...")
+
+ // 清理资源
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ if err := srv.Shutdown(ctx); err != nil {
+ logr.Fatal("Server forced to shutdown", "error", err)
+ }
+
+ logr.Info("Server exited")
+}
diff --git a/migrations/init.sql b/migrations/init.sql
new file mode 100644
index 0000000..3662adb
--- /dev/null
+++ b/migrations/init.sql
@@ -0,0 +1,499 @@
+-- AI短剧生成平台 - SQLite数据库初始化脚本 (开源版本 - 无用户认证)
+-- 创建时间: 2026-01-07
+-- 说明: 此版本适配SQLite,移除外键约束,适合单机部署
+
+-- ======================================
+-- 1. 剧本相关表
+-- ======================================
+
+-- 剧本表
+CREATE TABLE IF NOT EXISTS dramas (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ description TEXT,
+ genre TEXT,
+ style TEXT NOT NULL DEFAULT 'realistic',
+ total_episodes INTEGER NOT NULL DEFAULT 1,
+ total_duration INTEGER NOT NULL DEFAULT 0, -- 总时长(秒)
+ status TEXT NOT NULL DEFAULT 'draft', -- draft, in_progress, completed
+ thumbnail TEXT,
+ tags TEXT, -- JSON存储
+ metadata TEXT, -- JSON存储
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_dramas_status ON dramas(status);
+CREATE INDEX IF NOT EXISTS idx_dramas_deleted_at ON dramas(deleted_at);
+
+-- 章节表
+CREATE TABLE IF NOT EXISTS episodes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ drama_id INTEGER NOT NULL,
+ episode_number INTEGER NOT NULL,
+ title TEXT NOT NULL,
+ script_content TEXT,
+ description TEXT,
+ duration INTEGER NOT NULL DEFAULT 0, -- 时长(秒)
+ status TEXT NOT NULL DEFAULT 'draft',
+ video_url TEXT,
+ thumbnail TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_episodes_drama_id ON episodes(drama_id);
+CREATE INDEX IF NOT EXISTS idx_episodes_status ON episodes(status);
+CREATE INDEX IF NOT EXISTS idx_episodes_deleted_at ON episodes(deleted_at);
+
+-- 角色表
+CREATE TABLE IF NOT EXISTS characters (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ drama_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ role TEXT,
+ description TEXT,
+ appearance TEXT,
+ personality TEXT,
+ voice_style TEXT,
+ image_url TEXT,
+ reference_images TEXT, -- JSON存储
+ seed_value TEXT,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_characters_drama_id ON characters(drama_id);
+CREATE INDEX IF NOT EXISTS idx_characters_deleted_at ON characters(deleted_at);
+
+-- 场景表
+CREATE TABLE IF NOT EXISTS scenes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ drama_id INTEGER NOT NULL,
+ location TEXT NOT NULL,
+ time TEXT NOT NULL,
+ prompt TEXT NOT NULL,
+ storyboard_count INTEGER NOT NULL DEFAULT 1,
+ image_url TEXT,
+ status TEXT NOT NULL DEFAULT 'pending', -- pending, generated, failed
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_scenes_drama_id ON scenes(drama_id);
+CREATE INDEX IF NOT EXISTS idx_scenes_status ON scenes(status);
+CREATE INDEX IF NOT EXISTS idx_scenes_deleted_at ON scenes(deleted_at);
+
+-- 分镜表
+CREATE TABLE IF NOT EXISTS storyboards (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ episode_id INTEGER NOT NULL,
+ scene_id INTEGER,
+ storyboard_number INTEGER NOT NULL,
+ title TEXT,
+ description TEXT,
+ location TEXT,
+ time TEXT,
+ duration INTEGER NOT NULL DEFAULT 0, -- 时长(秒)
+ dialogue TEXT,
+ action TEXT,
+ atmosphere TEXT,
+ image_prompt TEXT,
+ video_prompt TEXT,
+ characters TEXT, -- JSON存储
+ composed_image TEXT,
+ video_url TEXT,
+ status TEXT NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_storyboards_episode_id ON storyboards(episode_id);
+CREATE INDEX IF NOT EXISTS idx_storyboards_scene_id ON storyboards(scene_id);
+CREATE INDEX IF NOT EXISTS idx_storyboards_storyboard_number ON storyboards(storyboard_number);
+CREATE INDEX IF NOT EXISTS idx_storyboards_status ON storyboards(status);
+CREATE INDEX IF NOT EXISTS idx_storyboards_deleted_at ON storyboards(deleted_at);
+
+-- ======================================
+-- 2. AI生成相关表
+-- ======================================
+
+-- 图片生成记录表
+CREATE TABLE IF NOT EXISTS image_generations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ storyboard_id INTEGER, -- 修正:引用storyboards表
+ drama_id INTEGER NOT NULL,
+ provider TEXT NOT NULL, -- openai, midjourney, stable_diffusion
+ prompt TEXT NOT NULL,
+ negative_prompt TEXT,
+ model TEXT,
+ size TEXT,
+ quality TEXT,
+ style TEXT,
+ steps INTEGER,
+ cfg_scale REAL,
+ seed INTEGER,
+ image_url TEXT,
+ minio_url TEXT,
+ local_path TEXT,
+ status TEXT NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed
+ task_id TEXT,
+ error_msg TEXT,
+ width INTEGER,
+ height INTEGER,
+ reference_images TEXT, -- JSON存储
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ completed_at DATETIME,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_image_generations_storyboard_id ON image_generations(storyboard_id);
+CREATE INDEX IF NOT EXISTS idx_image_generations_drama_id ON image_generations(drama_id);
+CREATE INDEX IF NOT EXISTS idx_image_generations_status ON image_generations(status);
+CREATE INDEX IF NOT EXISTS idx_image_generations_task_id ON image_generations(task_id);
+CREATE INDEX IF NOT EXISTS idx_image_generations_deleted_at ON image_generations(deleted_at);
+
+-- 视频生成记录表
+CREATE TABLE IF NOT EXISTS video_generations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ storyboard_id INTEGER, -- 修正:引用storyboards表
+ drama_id INTEGER NOT NULL,
+ provider TEXT NOT NULL, -- runway, pika, doubao, openai
+ prompt TEXT NOT NULL,
+ model TEXT,
+ image_gen_id INTEGER,
+ image_url TEXT,
+ first_frame_url TEXT,
+ duration INTEGER, -- 时长(秒)
+ fps INTEGER,
+ resolution TEXT,
+ aspect_ratio TEXT,
+ style TEXT,
+ motion_level INTEGER,
+ camera_motion TEXT,
+ seed INTEGER,
+ video_url TEXT,
+ minio_url TEXT,
+ local_path TEXT,
+ status TEXT NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed
+ task_id TEXT,
+ error_msg TEXT,
+ completed_at DATETIME,
+ width INTEGER,
+ height INTEGER,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_video_generations_storyboard_id ON video_generations(storyboard_id);
+CREATE INDEX IF NOT EXISTS idx_video_generations_drama_id ON video_generations(drama_id);
+CREATE INDEX IF NOT EXISTS idx_video_generations_provider ON video_generations(provider);
+CREATE INDEX IF NOT EXISTS idx_video_generations_status ON video_generations(status);
+CREATE INDEX IF NOT EXISTS idx_video_generations_task_id ON video_generations(task_id);
+CREATE INDEX IF NOT EXISTS idx_video_generations_image_gen_id ON video_generations(image_gen_id);
+CREATE INDEX IF NOT EXISTS idx_video_generations_deleted_at ON video_generations(deleted_at);
+
+-- 视频合成记录表
+CREATE TABLE IF NOT EXISTS video_merges (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ episode_id INTEGER NOT NULL,
+ drama_id INTEGER NOT NULL,
+ title TEXT,
+ provider TEXT NOT NULL,
+ model TEXT,
+ status TEXT NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed
+ scenes TEXT NOT NULL, -- JSON存储:场景片段列表
+ merged_url TEXT,
+ duration INTEGER, -- 总时长(秒)
+ task_id TEXT,
+ error_msg TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ completed_at DATETIME,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_video_merges_episode_id ON video_merges(episode_id);
+CREATE INDEX IF NOT EXISTS idx_video_merges_drama_id ON video_merges(drama_id);
+CREATE INDEX IF NOT EXISTS idx_video_merges_status ON video_merges(status);
+CREATE INDEX IF NOT EXISTS idx_video_merges_deleted_at ON video_merges(deleted_at);
+
+-- ======================================
+-- 3. 角色库表
+-- ======================================
+
+-- 角色库表 (开源版本 - 全局共享)
+CREATE TABLE IF NOT EXISTS character_libraries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ category TEXT,
+ image_url TEXT NOT NULL,
+ description TEXT,
+ tags TEXT,
+ source_type TEXT NOT NULL DEFAULT 'generated', -- generated, uploaded
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_character_libraries_category ON character_libraries(category);
+CREATE INDEX IF NOT EXISTS idx_character_libraries_deleted_at ON character_libraries(deleted_at);
+
+-- ======================================
+-- 4. 时间线相关表
+-- ======================================
+
+-- 时间线表
+CREATE TABLE IF NOT EXISTS timelines (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ drama_id INTEGER NOT NULL,
+ episode_id INTEGER,
+ name TEXT NOT NULL,
+ description TEXT,
+ duration INTEGER NOT NULL DEFAULT 0, -- 总时长(秒)
+ fps INTEGER NOT NULL DEFAULT 30,
+ resolution TEXT,
+ status TEXT NOT NULL DEFAULT 'draft', -- draft, editing, completed, exporting
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_timelines_drama_id ON timelines(drama_id);
+CREATE INDEX IF NOT EXISTS idx_timelines_episode_id ON timelines(episode_id);
+CREATE INDEX IF NOT EXISTS idx_timelines_status ON timelines(status);
+CREATE INDEX IF NOT EXISTS idx_timelines_deleted_at ON timelines(deleted_at);
+
+-- 时间线轨道表
+CREATE TABLE IF NOT EXISTS timeline_tracks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timeline_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ type TEXT NOT NULL, -- video, audio, text
+ track_order INTEGER NOT NULL DEFAULT 0,
+ is_locked INTEGER NOT NULL DEFAULT 0,
+ is_muted INTEGER NOT NULL DEFAULT 0,
+ volume INTEGER DEFAULT 100,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_timeline_tracks_timeline_id ON timeline_tracks(timeline_id);
+CREATE INDEX IF NOT EXISTS idx_timeline_tracks_type ON timeline_tracks(type);
+CREATE INDEX IF NOT EXISTS idx_timeline_tracks_deleted_at ON timeline_tracks(deleted_at);
+
+-- 时间线片段表
+CREATE TABLE IF NOT EXISTS timeline_clips (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ track_id INTEGER NOT NULL,
+ asset_id INTEGER,
+ storyboard_id INTEGER, -- 修正:引用storyboards而非scenes
+ name TEXT,
+ start_time INTEGER NOT NULL, -- 开始时间(毫秒)
+ end_time INTEGER NOT NULL, -- 结束时间(毫秒)
+ duration INTEGER NOT NULL, -- 时长(毫秒)
+ trim_start INTEGER, -- 裁剪开始(毫秒)
+ trim_end INTEGER, -- 裁剪结束(毫秒)
+ speed REAL DEFAULT 1.0,
+ volume INTEGER,
+ is_muted INTEGER NOT NULL DEFAULT 0,
+ fade_in INTEGER, -- 淡入时长(毫秒)
+ fade_out INTEGER, -- 淡出时长(毫秒)
+ transition_in_id INTEGER,
+ transition_out_id INTEGER,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_timeline_clips_track_id ON timeline_clips(track_id);
+CREATE INDEX IF NOT EXISTS idx_timeline_clips_asset_id ON timeline_clips(asset_id);
+CREATE INDEX IF NOT EXISTS idx_timeline_clips_storyboard_id ON timeline_clips(storyboard_id);
+CREATE INDEX IF NOT EXISTS idx_timeline_clips_transition_in ON timeline_clips(transition_in_id);
+CREATE INDEX IF NOT EXISTS idx_timeline_clips_transition_out ON timeline_clips(transition_out_id);
+CREATE INDEX IF NOT EXISTS idx_timeline_clips_deleted_at ON timeline_clips(deleted_at);
+
+-- 片段转场表
+CREATE TABLE IF NOT EXISTS clip_transitions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ type TEXT NOT NULL, -- fade, crossfade, slide, wipe, zoom, dissolve
+ duration INTEGER NOT NULL DEFAULT 500, -- 转场时长(毫秒)
+ easing TEXT,
+ config TEXT, -- JSON存储
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_clip_transitions_type ON clip_transitions(type);
+CREATE INDEX IF NOT EXISTS idx_clip_transitions_deleted_at ON clip_transitions(deleted_at);
+
+-- 片段效果表
+CREATE TABLE IF NOT EXISTS clip_effects (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ clip_id INTEGER NOT NULL,
+ type TEXT NOT NULL, -- filter, color, blur, brightness, contrast, saturation
+ name TEXT,
+ is_enabled INTEGER NOT NULL DEFAULT 1,
+ effect_order INTEGER NOT NULL DEFAULT 0,
+ config TEXT, -- JSON存储
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_clip_effects_clip_id ON clip_effects(clip_id);
+CREATE INDEX IF NOT EXISTS idx_clip_effects_type ON clip_effects(type);
+CREATE INDEX IF NOT EXISTS idx_clip_effects_deleted_at ON clip_effects(deleted_at);
+
+-- ======================================
+-- 5. 资源管理相关表
+-- ======================================
+
+-- 资源表
+CREATE TABLE IF NOT EXISTS assets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ drama_id INTEGER,
+ name TEXT NOT NULL,
+ description TEXT,
+ type TEXT NOT NULL, -- image, video, audio
+ category TEXT,
+ url TEXT NOT NULL,
+ thumbnail_url TEXT,
+ local_path TEXT,
+ file_size INTEGER,
+ mime_type TEXT,
+ width INTEGER,
+ height INTEGER,
+ duration INTEGER, -- 时长(秒)
+ format TEXT,
+ image_gen_id INTEGER,
+ video_gen_id INTEGER,
+ is_favorite INTEGER NOT NULL DEFAULT 0,
+ view_count INTEGER NOT NULL DEFAULT 0,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_assets_drama_id ON assets(drama_id);
+CREATE INDEX IF NOT EXISTS idx_assets_type ON assets(type);
+CREATE INDEX IF NOT EXISTS idx_assets_category ON assets(category);
+CREATE INDEX IF NOT EXISTS idx_assets_image_gen_id ON assets(image_gen_id);
+CREATE INDEX IF NOT EXISTS idx_assets_video_gen_id ON assets(video_gen_id);
+CREATE INDEX IF NOT EXISTS idx_assets_deleted_at ON assets(deleted_at);
+
+-- 资源标签表
+CREATE TABLE IF NOT EXISTS asset_tags (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ color TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_asset_tags_deleted_at ON asset_tags(deleted_at);
+
+-- 资源集合表
+CREATE TABLE IF NOT EXISTS asset_collections (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ drama_id INTEGER,
+ name TEXT NOT NULL,
+ description TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_asset_collections_drama_id ON asset_collections(drama_id);
+CREATE INDEX IF NOT EXISTS idx_asset_collections_deleted_at ON asset_collections(deleted_at);
+
+-- 资源标签关系表(多对多)
+CREATE TABLE IF NOT EXISTS asset_tag_relations (
+ asset_id INTEGER NOT NULL,
+ asset_tag_id INTEGER NOT NULL,
+ PRIMARY KEY (asset_id, asset_tag_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_asset_tag_relations_asset_id ON asset_tag_relations(asset_id);
+CREATE INDEX IF NOT EXISTS idx_asset_tag_relations_tag_id ON asset_tag_relations(asset_tag_id);
+
+-- 资源集合关系表(多对多)
+CREATE TABLE IF NOT EXISTS asset_collection_relations (
+ asset_id INTEGER NOT NULL,
+ asset_collection_id INTEGER NOT NULL,
+ PRIMARY KEY (asset_id, asset_collection_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_asset_collection_relations_asset_id ON asset_collection_relations(asset_id);
+CREATE INDEX IF NOT EXISTS idx_asset_collection_relations_collection_id ON asset_collection_relations(asset_collection_id);
+
+-- ======================================
+-- 6. AI服务配置表 (开源版本 - 全局配置)
+-- ======================================
+
+-- AI服务配置表 (全局配置,无用户隔离)
+CREATE TABLE IF NOT EXISTS ai_service_configs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ service_type TEXT NOT NULL, -- text, image, video
+ provider TEXT, -- openai, gemini, volcengine, etc.
+ name TEXT NOT NULL,
+ base_url TEXT NOT NULL,
+ api_key TEXT NOT NULL,
+ model TEXT,
+ endpoint TEXT,
+ query_endpoint TEXT,
+ priority INTEGER NOT NULL DEFAULT 0,
+ is_default INTEGER NOT NULL DEFAULT 0,
+ is_active INTEGER NOT NULL DEFAULT 1,
+ settings TEXT, -- JSON存储
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_ai_service_configs_service_type ON ai_service_configs(service_type);
+CREATE INDEX IF NOT EXISTS idx_ai_service_configs_deleted_at ON ai_service_configs(deleted_at);
+
+-- AI服务提供商表
+CREATE TABLE IF NOT EXISTS ai_service_providers (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ display_name TEXT NOT NULL,
+ service_type TEXT NOT NULL, -- text, image, video
+ default_url TEXT,
+ description TEXT,
+ is_active INTEGER NOT NULL DEFAULT 1,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_ai_service_providers_service_type ON ai_service_providers(service_type);
+CREATE INDEX IF NOT EXISTS idx_ai_service_providers_deleted_at ON ai_service_providers(deleted_at);
+
+-- ======================================
+-- 7. 初始数据
+-- ======================================
+
+-- 插入默认AI服务提供商
+INSERT OR IGNORE INTO ai_service_providers (name, display_name, service_type, default_url, description) VALUES
+('openai', 'OpenAI', 'text', 'https://api.openai.com/v1', 'OpenAI GPT模型'),
+('openai-dalle', 'OpenAI DALL-E', 'image', 'https://api.openai.com/v1', 'OpenAI DALL-E图片生成'),
+('openai-sora', 'OpenAI Sora', 'video', 'https://api.openai.com/v1', 'OpenAI Sora视频生成'),
+('midjourney', 'Midjourney', 'image', '', 'Midjourney图片生成'),
+('doubao-image', '豆包(火山引擎)', 'image', 'https://ark.cn-beijing.volces.com', '火山引擎豆包图片生成'),
+('gemini-image', 'Google Gemini', 'image', 'https://generativelanguage.googleapis.com', 'Google Gemini原生图片生成(base64)'),
+('runway', 'Runway', 'video', '', 'Runway视频生成'),
+('pika', 'Pika Labs', 'video', '', 'Pika视频生成'),
+('doubao', '豆包(火山引擎)', 'video', 'https://ark.cn-beijing.volces.com', '火山引擎豆包视频生成'),
+('minimax', 'MiniMax', 'video', '', 'MiniMax视频生成');
diff --git a/pkg/ai/client.go b/pkg/ai/client.go
new file mode 100644
index 0000000..81da6ed
--- /dev/null
+++ b/pkg/ai/client.go
@@ -0,0 +1,7 @@
+package ai
+
+// AIClient 定义文本生成客户端接口
+type AIClient interface {
+ GenerateText(prompt string, systemPrompt string, options ...func(*ChatCompletionRequest)) (string, error)
+ TestConnection() error
+}
diff --git a/pkg/ai/gemini_client.go b/pkg/ai/gemini_client.go
new file mode 100644
index 0000000..348387f
--- /dev/null
+++ b/pkg/ai/gemini_client.go
@@ -0,0 +1,195 @@
+package ai
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+type GeminiClient struct {
+ BaseURL string
+ APIKey string
+ Model string
+ Endpoint string
+ HTTPClient *http.Client
+}
+
+type GeminiTextRequest struct {
+ Contents []GeminiContent `json:"contents"`
+ SystemInstruction *GeminiInstruction `json:"systemInstruction,omitempty"`
+}
+
+type GeminiContent struct {
+ Parts []GeminiPart `json:"parts"`
+ Role string `json:"role,omitempty"`
+}
+
+type GeminiPart struct {
+ Text string `json:"text"`
+}
+
+type GeminiInstruction struct {
+ Parts []GeminiPart `json:"parts"`
+}
+
+type GeminiTextResponse struct {
+ Candidates []struct {
+ Content struct {
+ Parts []struct {
+ Text string `json:"text"`
+ } `json:"parts"`
+ Role string `json:"role"`
+ } `json:"content"`
+ FinishReason string `json:"finishReason"`
+ Index int `json:"index"`
+ SafetyRatings []struct {
+ Category string `json:"category"`
+ Probability string `json:"probability"`
+ } `json:"safetyRatings"`
+ } `json:"candidates"`
+ UsageMetadata struct {
+ PromptTokenCount int `json:"promptTokenCount"`
+ CandidatesTokenCount int `json:"candidatesTokenCount"`
+ TotalTokenCount int `json:"totalTokenCount"`
+ } `json:"usageMetadata"`
+}
+
+func NewGeminiClient(baseURL, apiKey, model, endpoint string) *GeminiClient {
+ if baseURL == "" {
+ baseURL = "https://generativelanguage.googleapis.com"
+ }
+ if endpoint == "" {
+ endpoint = "/v1beta/models/{model}:generateContent"
+ }
+ if model == "" {
+ model = "gemini-3-pro"
+ }
+ return &GeminiClient{
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ Model: model,
+ Endpoint: endpoint,
+ HTTPClient: &http.Client{
+ Timeout: 10 * time.Minute,
+ },
+ }
+}
+
+func (c *GeminiClient) GenerateText(prompt string, systemPrompt string, options ...func(*ChatCompletionRequest)) (string, error) {
+ model := c.Model
+
+ // 构建请求体
+ reqBody := GeminiTextRequest{
+ Contents: []GeminiContent{
+ {
+ Parts: []GeminiPart{{Text: prompt}},
+ Role: "user",
+ },
+ },
+ }
+
+ // 使用 systemInstruction 字段处理系统提示
+ if systemPrompt != "" {
+ reqBody.SystemInstruction = &GeminiInstruction{
+ Parts: []GeminiPart{{Text: systemPrompt}},
+ }
+ }
+
+ jsonData, err := json.Marshal(reqBody)
+ if err != nil {
+ fmt.Printf("Gemini: Failed to marshal request: %v\n", err)
+ return "", fmt.Errorf("marshal request: %w", err)
+ }
+
+ // 替换端点中的 {model} 占位符
+ endpoint := c.BaseURL + c.Endpoint
+ endpoint = strings.ReplaceAll(endpoint, "{model}", model)
+ url := fmt.Sprintf("%s?key=%s", endpoint, c.APIKey)
+
+ // 打印请求信息(隐藏 API Key)
+ safeURL := strings.Replace(url, c.APIKey, "***", 1)
+ fmt.Printf("Gemini: Sending request to: %s\n", safeURL)
+ requestPreview := string(jsonData)
+ if len(jsonData) > 300 {
+ requestPreview = string(jsonData[:300]) + "..."
+ }
+ fmt.Printf("Gemini: Request body: %s\n", requestPreview)
+
+ req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
+ if err != nil {
+ fmt.Printf("Gemini: Failed to create request: %v\n", err)
+ return "", fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+
+ fmt.Printf("Gemini: Executing HTTP request...\n")
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ fmt.Printf("Gemini: HTTP request failed: %v\n", err)
+ return "", fmt.Errorf("send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ fmt.Printf("Gemini: Received response with status: %d\n", resp.StatusCode)
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ fmt.Printf("Gemini: Failed to read response body: %v\n", err)
+ return "", fmt.Errorf("read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ fmt.Printf("Gemini: API error (status %d): %s\n", resp.StatusCode, string(body))
+ return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ // 打印响应体用于调试
+ bodyPreview := string(body)
+ if len(body) > 500 {
+ bodyPreview = string(body[:500]) + "..."
+ }
+ fmt.Printf("Gemini: Response body: %s\n", bodyPreview)
+
+ var result GeminiTextResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ errorPreview := string(body)
+ if len(body) > 200 {
+ errorPreview = string(body[:200])
+ }
+ fmt.Printf("Gemini: Failed to parse response: %v\n", err)
+ return "", fmt.Errorf("parse response: %w, body preview: %s", err, errorPreview)
+ }
+
+ fmt.Printf("Gemini: Successfully parsed response, candidates count: %d\n", len(result.Candidates))
+
+ if len(result.Candidates) == 0 {
+ fmt.Printf("Gemini: No candidates in response\n")
+ return "", fmt.Errorf("no candidates in response")
+ }
+
+ if len(result.Candidates[0].Content.Parts) == 0 {
+ fmt.Printf("Gemini: No parts in first candidate\n")
+ return "", fmt.Errorf("no parts in response")
+ }
+
+ responseText := result.Candidates[0].Content.Parts[0].Text
+ fmt.Printf("Gemini: Generated text: %s\n", responseText)
+
+ return responseText, nil
+}
+
+func (c *GeminiClient) TestConnection() error {
+ fmt.Printf("Gemini: TestConnection called with BaseURL=%s, Model=%s, Endpoint=%s\n", c.BaseURL, c.Model, c.Endpoint)
+ _, err := c.GenerateText("Hello", "")
+ if err != nil {
+ fmt.Printf("Gemini: TestConnection failed: %v\n", err)
+ } else {
+ fmt.Printf("Gemini: TestConnection succeeded\n")
+ }
+ return err
+}
diff --git a/pkg/ai/openai_client.go b/pkg/ai/openai_client.go
new file mode 100644
index 0000000..1264e1d
--- /dev/null
+++ b/pkg/ai/openai_client.go
@@ -0,0 +1,227 @@
+package ai
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+type OpenAIClient struct {
+ BaseURL string
+ APIKey string
+ Model string
+ Endpoint string
+ HTTPClient *http.Client
+}
+
+type ChatMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+}
+
+type ChatCompletionRequest struct {
+ Model string `json:"model"`
+ Messages []ChatMessage `json:"messages"`
+ Temperature float64 `json:"temperature,omitempty"`
+ MaxTokens int `json:"max_tokens,omitempty"`
+ TopP float64 `json:"top_p,omitempty"`
+ Stream bool `json:"stream,omitempty"`
+}
+
+type ChatCompletionResponse struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ Created int64 `json:"created"`
+ Model string `json:"model"`
+ Choices []struct {
+ Index int `json:"index"`
+ Message struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"message"`
+ FinishReason string `json:"finish_reason"`
+ } `json:"choices"`
+ Usage struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ } `json:"usage"`
+}
+
+type ErrorResponse struct {
+ Error struct {
+ Message string `json:"message"`
+ Type string `json:"type"`
+ Code string `json:"code"`
+ } `json:"error"`
+}
+
+func NewOpenAIClient(baseURL, apiKey, model, endpoint string) *OpenAIClient {
+ if endpoint == "" {
+ endpoint = "/v1/chat/completions"
+ }
+
+ return &OpenAIClient{
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ Model: model,
+ Endpoint: endpoint,
+ HTTPClient: &http.Client{
+ Timeout: 10 * time.Minute,
+ },
+ }
+}
+
+func (c *OpenAIClient) ChatCompletion(messages []ChatMessage, options ...func(*ChatCompletionRequest)) (*ChatCompletionResponse, error) {
+ req := &ChatCompletionRequest{
+ Model: c.Model,
+ Messages: messages,
+ }
+
+ for _, option := range options {
+ option(req)
+ }
+
+ return c.sendChatRequest(req)
+}
+
+func (c *OpenAIClient) sendChatRequest(req *ChatCompletionRequest) (*ChatCompletionResponse, error) {
+ jsonData, err := json.Marshal(req)
+ if err != nil {
+ fmt.Printf("OpenAI: Failed to marshal request: %v\n", err)
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ url := c.BaseURL + c.Endpoint
+
+ // 打印请求信息
+ fmt.Printf("OpenAI: Sending request to: %s\n", url)
+ fmt.Printf("OpenAI: BaseURL=%s, Endpoint=%s, Model=%s\n", c.BaseURL, c.Endpoint, c.Model)
+ requestPreview := string(jsonData)
+ if len(jsonData) > 300 {
+ requestPreview = string(jsonData[:300]) + "..."
+ }
+ fmt.Printf("OpenAI: Request body: %s\n", requestPreview)
+
+ httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
+ if err != nil {
+ fmt.Printf("OpenAI: Failed to create request: %v\n", err)
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ httpReq.Header.Set("Content-Type", "application/json")
+ httpReq.Header.Set("Authorization", "Bearer "+c.APIKey)
+
+ fmt.Printf("OpenAI: Executing HTTP request...\n")
+ resp, err := c.HTTPClient.Do(httpReq)
+ if err != nil {
+ fmt.Printf("OpenAI: HTTP request failed: %v\n", err)
+ return nil, fmt.Errorf("failed to send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ fmt.Printf("OpenAI: Received response with status: %d\n", resp.StatusCode)
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ fmt.Printf("OpenAI: Failed to read response body: %v\n", err)
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ fmt.Printf("OpenAI: API error (status %d): %s\n", resp.StatusCode, string(body))
+ var errResp ErrorResponse
+ if err := json.Unmarshal(body, &errResp); err != nil {
+ return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
+ }
+ return nil, fmt.Errorf("API error: %s", errResp.Error.Message)
+ }
+
+ // 打印响应体用于调试
+ bodyPreview := string(body)
+ if len(body) > 500 {
+ bodyPreview = string(body[:500]) + "..."
+ }
+ fmt.Printf("OpenAI: Response body: %s\n", bodyPreview)
+
+ var chatResp ChatCompletionResponse
+ if err := json.Unmarshal(body, &chatResp); err != nil {
+ errorPreview := string(body)
+ if len(body) > 200 {
+ errorPreview = string(body[:200])
+ }
+ fmt.Printf("OpenAI: Failed to parse response: %v\n", err)
+ return nil, fmt.Errorf("failed to unmarshal response: %w, body preview: %s", err, errorPreview)
+ }
+
+ fmt.Printf("OpenAI: Successfully parsed response, choices count: %d\n", len(chatResp.Choices))
+
+ return &chatResp, nil
+}
+
+func WithTemperature(temp float64) func(*ChatCompletionRequest) {
+ return func(req *ChatCompletionRequest) {
+ req.Temperature = temp
+ }
+}
+
+func WithMaxTokens(tokens int) func(*ChatCompletionRequest) {
+ return func(req *ChatCompletionRequest) {
+ req.MaxTokens = tokens
+ }
+}
+
+func WithTopP(topP float64) func(*ChatCompletionRequest) {
+ return func(req *ChatCompletionRequest) {
+ req.TopP = topP
+ }
+}
+
+func (c *OpenAIClient) GenerateText(prompt string, systemPrompt string, options ...func(*ChatCompletionRequest)) (string, error) {
+ messages := []ChatMessage{}
+
+ if systemPrompt != "" {
+ messages = append(messages, ChatMessage{
+ Role: "system",
+ Content: systemPrompt,
+ })
+ }
+
+ messages = append(messages, ChatMessage{
+ Role: "user",
+ Content: prompt,
+ })
+
+ resp, err := c.ChatCompletion(messages, options...)
+ if err != nil {
+ return "", err
+ }
+
+ if len(resp.Choices) == 0 {
+ return "", fmt.Errorf("no response from API")
+ }
+
+ return resp.Choices[0].Message.Content, nil
+}
+
+func (c *OpenAIClient) TestConnection() error {
+ fmt.Printf("OpenAI: TestConnection called with BaseURL=%s, Endpoint=%s, Model=%s\n", c.BaseURL, c.Endpoint, c.Model)
+
+ messages := []ChatMessage{
+ {
+ Role: "user",
+ Content: "Hello",
+ },
+ }
+
+ _, err := c.ChatCompletion(messages, WithMaxTokens(10))
+ if err != nil {
+ fmt.Printf("OpenAI: TestConnection failed: %v\n", err)
+ } else {
+ fmt.Printf("OpenAI: TestConnection succeeded\n")
+ }
+ return err
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
new file mode 100644
index 0000000..cbe3fca
--- /dev/null
+++ b/pkg/config/config.go
@@ -0,0 +1,89 @@
+package config
+
+import (
+ "fmt"
+
+ "github.com/spf13/viper"
+)
+
+type Config struct {
+ App AppConfig `mapstructure:"app"`
+ Server ServerConfig `mapstructure:"server"`
+ Database DatabaseConfig `mapstructure:"database"`
+ Storage StorageConfig `mapstructure:"storage"`
+ AI AIConfig `mapstructure:"ai"`
+}
+
+type AppConfig struct {
+ Name string `mapstructure:"name"`
+ Version string `mapstructure:"version"`
+ Debug bool `mapstructure:"debug"`
+}
+
+type ServerConfig struct {
+ Port int `mapstructure:"port"`
+ Host string `mapstructure:"host"`
+ CORSOrigins []string `mapstructure:"cors_origins"`
+ ReadTimeout int `mapstructure:"read_timeout"`
+ WriteTimeout int `mapstructure:"write_timeout"`
+}
+
+type DatabaseConfig struct {
+ Type string `mapstructure:"type"` // sqlite, mysql
+ Path string `mapstructure:"path"` // SQLite数据库文件路径
+ Host string `mapstructure:"host"`
+ Port int `mapstructure:"port"`
+ User string `mapstructure:"user"`
+ Password string `mapstructure:"password"`
+ Database string `mapstructure:"database"`
+ Charset string `mapstructure:"charset"`
+ MaxIdle int `mapstructure:"max_idle"`
+ MaxOpen int `mapstructure:"max_open"`
+}
+
+type StorageConfig struct {
+ Type string `mapstructure:"type"` // local, minio
+ LocalPath string `mapstructure:"local_path"` // 本地存储路径
+ BaseURL string `mapstructure:"base_url"` // 访问URL前缀
+}
+
+type AIConfig struct {
+ DefaultTextProvider string `mapstructure:"default_text_provider"`
+ DefaultImageProvider string `mapstructure:"default_image_provider"`
+ DefaultVideoProvider string `mapstructure:"default_video_provider"`
+}
+
+func LoadConfig() (*Config, error) {
+ viper.SetConfigName("config")
+ viper.SetConfigType("yaml")
+ viper.AddConfigPath("./configs")
+ viper.AddConfigPath(".")
+
+ viper.AutomaticEnv()
+
+ if err := viper.ReadInConfig(); err != nil {
+ return nil, fmt.Errorf("failed to read config: %w", err)
+ }
+
+ var config Config
+ if err := viper.Unmarshal(&config); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal config: %w", err)
+ }
+
+ return &config, nil
+}
+
+func (c *DatabaseConfig) DSN() string {
+ if c.Type == "sqlite" {
+ return c.Path
+ }
+ // MySQL DSN
+ return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local",
+ c.User,
+ c.Password,
+ c.Host,
+ c.Port,
+ c.Database,
+ c.Charset,
+ )
+}
diff --git a/pkg/image/gemini_image_client.go b/pkg/image/gemini_image_client.go
new file mode 100644
index 0000000..3ffa427
--- /dev/null
+++ b/pkg/image/gemini_image_client.go
@@ -0,0 +1,277 @@
+package image
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+type GeminiImageClient struct {
+ BaseURL string
+ APIKey string
+ Model string
+ Endpoint string
+ HTTPClient *http.Client
+}
+
+type GeminiImageRequest struct {
+ Contents []struct {
+ Parts []GeminiPart `json:"parts"`
+ } `json:"contents"`
+ GenerationConfig struct {
+ ResponseModalities []string `json:"responseModalities"`
+ } `json:"generationConfig"`
+}
+
+type GeminiPart struct {
+ Text string `json:"text,omitempty"`
+ InlineData *GeminiInlineData `json:"inlineData,omitempty"`
+}
+
+type GeminiInlineData struct {
+ MimeType string `json:"mimeType"`
+ Data string `json:"data"` // base64 编码的图片数据
+}
+
+type GeminiImageResponse struct {
+ Candidates []struct {
+ Content struct {
+ Parts []struct {
+ InlineData struct {
+ MimeType string `json:"mimeType"`
+ Data string `json:"data"`
+ } `json:"inlineData,omitempty"`
+ Text string `json:"text,omitempty"`
+ } `json:"parts"`
+ } `json:"content"`
+ } `json:"candidates"`
+ UsageMetadata struct {
+ PromptTokenCount int `json:"promptTokenCount"`
+ CandidatesTokenCount int `json:"candidatesTokenCount"`
+ TotalTokenCount int `json:"totalTokenCount"`
+ } `json:"usageMetadata"`
+}
+
+// downloadImageToBase64 下载图片 URL 并转换为 base64
+func downloadImageToBase64(imageURL string) (string, string, error) {
+ resp, err := http.Get(imageURL)
+ if err != nil {
+ return "", "", fmt.Errorf("download image: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", "", fmt.Errorf("download image failed with status: %d", resp.StatusCode)
+ }
+
+ imageData, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", "", fmt.Errorf("read image data: %w", err)
+ }
+
+ // 根据 Content-Type 确定 mimeType
+ mimeType := resp.Header.Get("Content-Type")
+ if mimeType == "" {
+ mimeType = "image/jpeg"
+ }
+
+ base64Data := base64.StdEncoding.EncodeToString(imageData)
+ return base64Data, mimeType, nil
+}
+
+func NewGeminiImageClient(baseURL, apiKey, model, endpoint string) *GeminiImageClient {
+ if baseURL == "" {
+ baseURL = "https://generativelanguage.googleapis.com"
+ }
+ if endpoint == "" {
+ endpoint = "/v1beta/models/{model}:generateContent"
+ }
+ if model == "" {
+ model = "gemini-3-pro-image-preview"
+ }
+ return &GeminiImageClient{
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ Model: model,
+ Endpoint: endpoint,
+ HTTPClient: &http.Client{
+ Timeout: 10 * time.Minute,
+ },
+ }
+}
+
+func (c *GeminiImageClient) GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error) {
+ options := &ImageOptions{
+ Size: "1024x1024",
+ Quality: "standard",
+ }
+
+ for _, opt := range opts {
+ opt(options)
+ }
+
+ model := c.Model
+ if options.Model != "" {
+ model = options.Model
+ }
+
+ promptText := prompt
+ if options.NegativePrompt != "" {
+ promptText += fmt.Sprintf("\n\nNegative prompt: %s", options.NegativePrompt)
+ }
+ if options.Size != "" {
+ promptText += fmt.Sprintf("\n\nImage size: %s", options.Size)
+ }
+
+ // 构建请求的 parts,支持参考图
+ parts := []GeminiPart{}
+
+ // 如果有参考图,先添加参考图
+ if len(options.ReferenceImages) > 0 {
+ for _, refImg := range options.ReferenceImages {
+ var base64Data string
+ var mimeType string
+ var err error
+
+ // 检查是否是 HTTP/HTTPS URL
+ if strings.HasPrefix(refImg, "http://") || strings.HasPrefix(refImg, "https://") {
+ // 下载图片并转换为 base64
+ base64Data, mimeType, err = downloadImageToBase64(refImg)
+ if err != nil {
+ continue
+ }
+ } else if strings.HasPrefix(refImg, "data:") {
+ // 如果是 data URI 格式,需要解析
+ // 格式: data:image/jpeg;base64,xxxxx
+ mimeType = "image/jpeg"
+ parts := []byte(refImg)
+ for i := 0; i < len(parts); i++ {
+ if parts[i] == ',' {
+ base64Data = refImg[i+1:]
+ // 提取 mime type
+ if i > 11 {
+ mimeTypeEnd := i
+ for j := 5; j < i; j++ {
+ if parts[j] == ';' {
+ mimeTypeEnd = j
+ break
+ }
+ }
+ mimeType = refImg[5:mimeTypeEnd]
+ }
+ break
+ }
+ }
+ } else {
+ // 假设已经是 base64 编码
+ base64Data = refImg
+ mimeType = "image/jpeg"
+ }
+
+ if base64Data != "" {
+ parts = append(parts, GeminiPart{
+ InlineData: &GeminiInlineData{
+ MimeType: mimeType,
+ Data: base64Data,
+ },
+ })
+ }
+ }
+ }
+
+ // 添加文本提示词
+ parts = append(parts, GeminiPart{
+ Text: promptText,
+ })
+
+ reqBody := GeminiImageRequest{
+ Contents: []struct {
+ Parts []GeminiPart `json:"parts"`
+ }{
+ {
+ Parts: parts,
+ },
+ },
+ GenerationConfig: struct {
+ ResponseModalities []string `json:"responseModalities"`
+ }{
+ ResponseModalities: []string{"IMAGE"},
+ },
+ }
+
+ jsonData, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+
+ endpoint := c.BaseURL + c.Endpoint
+ endpoint = replaceModelPlaceholder(endpoint, model)
+ url := fmt.Sprintf("%s?key=%s", endpoint, c.APIKey)
+
+ req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ bodyStr := string(body)
+ if len(bodyStr) > 1000 {
+ bodyStr = fmt.Sprintf("%s ... %s", bodyStr[:500], bodyStr[len(bodyStr)-500:])
+ }
+ return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, bodyStr)
+ }
+
+ var result GeminiImageResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse response: %w", err)
+ }
+
+ if len(result.Candidates) == 0 || len(result.Candidates[0].Content.Parts) == 0 {
+ return nil, fmt.Errorf("no image generated in response")
+ }
+
+ base64Data := result.Candidates[0].Content.Parts[0].InlineData.Data
+ if base64Data == "" {
+ return nil, fmt.Errorf("no base64 image data in response")
+ }
+
+ dataURI := fmt.Sprintf("data:image/jpeg;base64,%s", base64Data)
+
+ return &ImageResult{
+ Status: "completed",
+ ImageURL: dataURI,
+ Completed: true,
+ Width: 1024,
+ Height: 1024,
+ }, nil
+}
+
+func (c *GeminiImageClient) GetTaskStatus(taskID string) (*ImageResult, error) {
+ return nil, fmt.Errorf("not supported for Gemini (synchronous generation)")
+}
+
+func replaceModelPlaceholder(endpoint, model string) string {
+ result := endpoint
+ if bytes.Contains([]byte(result), []byte("{model}")) {
+ result = string(bytes.ReplaceAll([]byte(result), []byte("{model}"), []byte(model)))
+ }
+ return result
+}
diff --git a/pkg/image/image_client.go b/pkg/image/image_client.go
new file mode 100644
index 0000000..ae5f2f6
--- /dev/null
+++ b/pkg/image/image_client.go
@@ -0,0 +1,93 @@
+package image
+
+type ImageClient interface {
+ GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error)
+ GetTaskStatus(taskID string) (*ImageResult, error)
+}
+
+type ImageResult struct {
+ TaskID string
+ Status string
+ ImageURL string
+ Width int
+ Height int
+ Error string
+ Completed bool
+}
+
+type ImageOptions struct {
+ NegativePrompt string
+ Size string
+ Quality string
+ Style string
+ Steps int
+ CfgScale float64
+ Seed int64
+ Model string
+ Width int
+ Height int
+ ReferenceImages []string // 参考图片URL列表
+}
+
+type ImageOption func(*ImageOptions)
+
+func WithNegativePrompt(prompt string) ImageOption {
+ return func(o *ImageOptions) {
+ o.NegativePrompt = prompt
+ }
+}
+
+func WithSize(size string) ImageOption {
+ return func(o *ImageOptions) {
+ o.Size = size
+ }
+}
+
+func WithQuality(quality string) ImageOption {
+ return func(o *ImageOptions) {
+ o.Quality = quality
+ }
+}
+
+func WithStyle(style string) ImageOption {
+ return func(o *ImageOptions) {
+ o.Style = style
+ }
+}
+
+func WithSteps(steps int) ImageOption {
+ return func(o *ImageOptions) {
+ o.Steps = steps
+ }
+}
+
+func WithCfgScale(scale float64) ImageOption {
+ return func(o *ImageOptions) {
+ o.CfgScale = scale
+ }
+}
+
+func WithSeed(seed int64) ImageOption {
+ return func(o *ImageOptions) {
+ o.Seed = seed
+ }
+}
+
+func WithModel(model string) ImageOption {
+ return func(o *ImageOptions) {
+ o.Model = model
+ }
+}
+
+func WithDimensions(width, height int) ImageOption {
+ return func(o *ImageOptions) {
+ o.Width = width
+ o.Height = height
+ }
+}
+
+func WithReferenceImages(images []string) ImageOption {
+ return func(o *ImageOptions) {
+ o.ReferenceImages = images
+ }
+}
diff --git a/pkg/image/openai_image_client.go b/pkg/image/openai_image_client.go
new file mode 100644
index 0000000..c238113
--- /dev/null
+++ b/pkg/image/openai_image_client.go
@@ -0,0 +1,128 @@
+package image
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+type OpenAIImageClient struct {
+ BaseURL string
+ APIKey string
+ Model string
+ Endpoint string
+ HTTPClient *http.Client
+}
+
+type DALLERequest struct {
+ Model string `json:"model"`
+ Prompt string `json:"prompt"`
+ Size string `json:"size,omitempty"`
+ Quality string `json:"quality,omitempty"`
+ N int `json:"n"`
+ Image []string `json:"image,omitempty"`
+}
+
+type DALLEResponse struct {
+ Created int64 `json:"created"`
+ Data []struct {
+ URL string `json:"url"`
+ RevisedPrompt string `json:"revised_prompt,omitempty"`
+ } `json:"data"`
+}
+
+func NewOpenAIImageClient(baseURL, apiKey, model, endpoint string) *OpenAIImageClient {
+ if endpoint == "" {
+ endpoint = "/v1/images/generations"
+ }
+ return &OpenAIImageClient{
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ Model: model,
+ Endpoint: endpoint,
+ HTTPClient: &http.Client{
+ Timeout: 10 * time.Minute,
+ },
+ }
+}
+
+func (c *OpenAIImageClient) GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error) {
+ options := &ImageOptions{
+ Size: "1920x1920",
+ Quality: "standard",
+ }
+
+ for _, opt := range opts {
+ opt(options)
+ }
+
+ model := c.Model
+ if options.Model != "" {
+ model = options.Model
+ }
+
+ reqBody := DALLERequest{
+ Model: model,
+ Prompt: prompt,
+ Size: options.Size,
+ Quality: options.Quality,
+ N: 1,
+ Image: options.ReferenceImages,
+ }
+
+ jsonData, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+
+ url := c.BaseURL + c.Endpoint
+ fmt.Printf("[OpenAI Image] Request URL: %s\n", url)
+ fmt.Printf("[OpenAI Image] Request Body: %s\n", string(jsonData))
+
+ req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ fmt.Printf("OpenAI API Response: %s\n", string(body))
+
+ var result DALLEResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse response: %w, body: %s", err, string(body))
+ }
+
+ if len(result.Data) == 0 {
+ return nil, fmt.Errorf("no image generated, response: %s", string(body))
+ }
+
+ return &ImageResult{
+ Status: "completed",
+ ImageURL: result.Data[0].URL,
+ Completed: true,
+ }, nil
+}
+
+func (c *OpenAIImageClient) GetTaskStatus(taskID string) (*ImageResult, error) {
+ return nil, fmt.Errorf("not supported for OpenAI/DALL-E")
+}
diff --git a/pkg/image/volcengine_image_client.go b/pkg/image/volcengine_image_client.go
new file mode 100644
index 0000000..87b920d
--- /dev/null
+++ b/pkg/image/volcengine_image_client.go
@@ -0,0 +1,158 @@
+package image
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+type VolcEngineImageClient struct {
+ BaseURL string
+ APIKey string
+ Model string
+ Endpoint string
+ QueryEndpoint string
+ HTTPClient *http.Client
+}
+
+type VolcEngineImageRequest struct {
+ Model string `json:"model"`
+ Prompt string `json:"prompt"`
+ Image []string `json:"image,omitempty"`
+ SequentialImageGeneration string `json:"sequential_image_generation,omitempty"`
+ Size string `json:"size,omitempty"`
+ Watermark bool `json:"watermark,omitempty"`
+}
+
+type VolcEngineImageResponse struct {
+ Model string `json:"model"`
+ Created int64 `json:"created"`
+ Data []struct {
+ URL string `json:"url"`
+ Size string `json:"size"`
+ } `json:"data"`
+ Usage struct {
+ GeneratedImages int `json:"generated_images"`
+ OutputTokens int `json:"output_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ } `json:"usage"`
+ Error interface{} `json:"error,omitempty"`
+}
+
+func NewVolcEngineImageClient(baseURL, apiKey, model, endpoint, queryEndpoint string) *VolcEngineImageClient {
+ if endpoint == "" {
+ endpoint = "/api/v3/images/generations"
+ }
+ if queryEndpoint == "" {
+ queryEndpoint = endpoint
+ }
+ return &VolcEngineImageClient{
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ Model: model,
+ Endpoint: endpoint,
+ QueryEndpoint: queryEndpoint,
+ HTTPClient: &http.Client{
+ Timeout: 10 * time.Minute,
+ },
+ }
+}
+
+func (c *VolcEngineImageClient) GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error) {
+ options := &ImageOptions{
+ Size: "1024x1024",
+ Quality: "standard",
+ }
+
+ for _, opt := range opts {
+ opt(options)
+ }
+
+ model := c.Model
+ if options.Model != "" {
+ model = options.Model
+ }
+
+ promptText := prompt
+ if options.NegativePrompt != "" {
+ promptText += fmt.Sprintf(". Negative: %s", options.NegativePrompt)
+ }
+
+ size := options.Size
+ if size == "" {
+ if model == "doubao-seedream-4-5-251128" {
+ size = "2K"
+ } else {
+ size = "1K"
+ }
+ }
+
+ reqBody := VolcEngineImageRequest{
+ Model: model,
+ Prompt: promptText,
+ Image: options.ReferenceImages,
+ SequentialImageGeneration: "disabled",
+ Size: size,
+ Watermark: false,
+ }
+
+ jsonData, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+
+ url := c.BaseURL + c.Endpoint
+ fmt.Printf("[VolcEngine Image] Request URL: %s\n", url)
+ fmt.Printf("[VolcEngine Image] Request Body: %s\n", string(jsonData))
+
+ req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ fmt.Printf("VolcEngine Image API Response: %s\n", string(body))
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ var result VolcEngineImageResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse response: %w", err)
+ }
+
+ if result.Error != nil {
+ return nil, fmt.Errorf("volcengine error: %v", result.Error)
+ }
+
+ if len(result.Data) == 0 {
+ return nil, fmt.Errorf("no image generated")
+ }
+
+ return &ImageResult{
+ Status: "completed",
+ ImageURL: result.Data[0].URL,
+ Completed: true,
+ }, nil
+}
+
+func (c *VolcEngineImageClient) GetTaskStatus(taskID string) (*ImageResult, error) {
+ return nil, fmt.Errorf("not supported for VolcEngine Seedream (synchronous generation)")
+}
diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go
new file mode 100644
index 0000000..9c8dde9
--- /dev/null
+++ b/pkg/logger/logger.go
@@ -0,0 +1,35 @@
+package logger
+
+import (
+ "go.uber.org/zap"
+ "go.uber.org/zap/zapcore"
+)
+
+type Logger struct {
+ *zap.SugaredLogger
+}
+
+func NewLogger(debug bool) *Logger {
+ var config zap.Config
+
+ if debug {
+ config = zap.NewDevelopmentConfig()
+ config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
+ // 在开发模式下,禁用时间戳和调用者信息,使输出更简洁
+ config.EncoderConfig.TimeKey = ""
+ config.EncoderConfig.CallerKey = ""
+ } else {
+ config = zap.NewProductionConfig()
+ config.EncoderConfig.TimeKey = "timestamp"
+ config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
+ }
+
+ logger, err := config.Build()
+ if err != nil {
+ panic(err)
+ }
+
+ return &Logger{
+ SugaredLogger: logger.Sugar(),
+ }
+}
diff --git a/pkg/response/response.go b/pkg/response/response.go
new file mode 100644
index 0000000..6dbf48d
--- /dev/null
+++ b/pkg/response/response.go
@@ -0,0 +1,119 @@
+package response
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+type Response struct {
+ Success bool `json:"success"`
+ Data interface{} `json:"data,omitempty"`
+ Error *ErrorInfo `json:"error,omitempty"`
+ Message string `json:"message,omitempty"`
+ Timestamp string `json:"timestamp"`
+}
+
+type ErrorInfo struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ Details interface{} `json:"details,omitempty"`
+}
+
+type PaginationData struct {
+ Items interface{} `json:"items"`
+ Pagination Pagination `json:"pagination"`
+}
+
+type Pagination struct {
+ Page int `json:"page"`
+ PageSize int `json:"page_size"`
+ Total int64 `json:"total"`
+ TotalPages int64 `json:"total_pages"`
+}
+
+func Success(c *gin.Context, data interface{}) {
+ c.JSON(http.StatusOK, Response{
+ Success: true,
+ Data: data,
+ Timestamp: time.Now().UTC().Format(time.RFC3339),
+ })
+}
+
+func SuccessWithMessage(c *gin.Context, message string, data interface{}) {
+ c.JSON(http.StatusOK, Response{
+ Success: true,
+ Data: data,
+ Message: message,
+ Timestamp: time.Now().UTC().Format(time.RFC3339),
+ })
+}
+
+func Created(c *gin.Context, data interface{}) {
+ c.JSON(http.StatusCreated, Response{
+ Success: true,
+ Data: data,
+ Timestamp: time.Now().UTC().Format(time.RFC3339),
+ })
+}
+
+func SuccessWithPagination(c *gin.Context, items interface{}, total int64, page int, pageSize int) {
+ totalPages := (total + int64(pageSize) - 1) / int64(pageSize)
+ c.JSON(http.StatusOK, Response{
+ Success: true,
+ Data: PaginationData{
+ Items: items,
+ Pagination: Pagination{
+ Page: page,
+ PageSize: pageSize,
+ Total: total,
+ TotalPages: totalPages,
+ },
+ },
+ Timestamp: time.Now().UTC().Format(time.RFC3339),
+ })
+}
+
+func Error(c *gin.Context, statusCode int, errCode string, message string) {
+ c.JSON(statusCode, Response{
+ Success: false,
+ Error: &ErrorInfo{
+ Code: errCode,
+ Message: message,
+ },
+ Timestamp: time.Now().UTC().Format(time.RFC3339),
+ })
+}
+
+func ErrorWithDetails(c *gin.Context, statusCode int, errCode string, message string, details interface{}) {
+ c.JSON(statusCode, Response{
+ Success: false,
+ Error: &ErrorInfo{
+ Code: errCode,
+ Message: message,
+ Details: details,
+ },
+ Timestamp: time.Now().UTC().Format(time.RFC3339),
+ })
+}
+
+func BadRequest(c *gin.Context, message string) {
+ Error(c, http.StatusBadRequest, "BAD_REQUEST", message)
+}
+
+func Unauthorized(c *gin.Context, message string) {
+ Error(c, http.StatusUnauthorized, "UNAUTHORIZED", message)
+}
+
+func Forbidden(c *gin.Context, message string) {
+ Error(c, http.StatusForbidden, "FORBIDDEN", message)
+}
+
+func NotFound(c *gin.Context, message string) {
+ Error(c, http.StatusNotFound, "NOT_FOUND", message)
+}
+
+func InternalError(c *gin.Context, message string) {
+ Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", message)
+}
diff --git a/pkg/utils/json_parser.go b/pkg/utils/json_parser.go
new file mode 100644
index 0000000..bb08195
--- /dev/null
+++ b/pkg/utils/json_parser.go
@@ -0,0 +1,153 @@
+package utils
+
+import (
+ "encoding/json"
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// SafeParseAIJSON 安全地解析AI返回的JSON,处理常见的格式问题
+// 包括:
+// 1. 移除Markdown代码块标记
+// 2. 提取JSON对象
+// 3. 清理多余的空白和换行
+// 4. 尝试修复截断的JSON
+// 5. 提供详细的错误信息
+func SafeParseAIJSON(aiResponse string, v interface{}) error {
+ if aiResponse == "" {
+ return fmt.Errorf("AI返回内容为空")
+ }
+
+ // 1. 移除可能的Markdown代码块标记
+ cleaned := strings.TrimSpace(aiResponse)
+ cleaned = regexp.MustCompile("(?m)^```json\\s*").ReplaceAllString(cleaned, "")
+ cleaned = regexp.MustCompile("(?m)^```\\s*").ReplaceAllString(cleaned, "")
+ cleaned = strings.TrimSpace(cleaned)
+
+ // 2. 提取JSON对象 (查找第一个 { 到最后一个 })
+ jsonRegex := regexp.MustCompile(`(?s)\{.*\}`)
+ jsonMatch := jsonRegex.FindString(cleaned)
+
+ if jsonMatch == "" {
+ return fmt.Errorf("响应中未找到有效的JSON对象,原始响应: %s", truncateString(aiResponse, 200))
+ }
+
+ // 3. 尝试解析JSON
+ err := json.Unmarshal([]byte(jsonMatch), v)
+ if err == nil {
+ return nil // 解析成功
+ }
+
+ // 4. 如果解析失败,尝试修复截断的JSON
+ fixedJSON := attemptJSONRepair(jsonMatch)
+ if fixedJSON != jsonMatch {
+ if err := json.Unmarshal([]byte(fixedJSON), v); err == nil {
+ return nil // 修复后解析成功
+ }
+ }
+
+ // 5. 提供详细的错误上下文
+ if jsonErr, ok := err.(*json.SyntaxError); ok {
+ errorPos := int(jsonErr.Offset)
+ start := maxInt(0, errorPos-100)
+ end := minInt(len(jsonMatch), errorPos+100)
+
+ context := jsonMatch[start:end]
+ marker := strings.Repeat(" ", errorPos-start) + "^"
+
+ return fmt.Errorf(
+ "JSON解析失败: %s\n错误位置附近:\n%s\n%s",
+ jsonErr.Error(),
+ context,
+ marker,
+ )
+ }
+
+ return fmt.Errorf("JSON解析失败: %w\n原始响应: %s", err, truncateString(jsonMatch, 300))
+}
+
+// attemptJSONRepair 尝试修复常见的JSON问题
+func attemptJSONRepair(jsonStr string) string {
+ // 1. 处理未闭合的字符串
+ // 如果最后一个字符不是 },尝试补全
+ trimmed := strings.TrimSpace(jsonStr)
+
+ // 2. 检查是否有未闭合的引号
+ if strings.Count(trimmed, `"`)%2 != 0 {
+ // 有奇数个引号,尝试补全最后一个引号
+ trimmed += `"`
+ }
+
+ // 3. 统计括号
+ openBraces := strings.Count(trimmed, "{")
+ closeBraces := strings.Count(trimmed, "}")
+ openBrackets := strings.Count(trimmed, "[")
+ closeBrackets := strings.Count(trimmed, "]")
+
+ // 4. 补全未闭合的数组
+ for i := 0; i < openBrackets-closeBrackets; i++ {
+ trimmed += "]"
+ }
+
+ // 5. 补全未闭合的对象
+ for i := 0; i < openBraces-closeBraces; i++ {
+ trimmed += "}"
+ }
+
+ return trimmed
+}
+
+// ExtractJSONFromText 从文本中提取JSON对象或数组
+func ExtractJSONFromText(text string) string {
+ text = strings.TrimSpace(text)
+
+ // 移除Markdown代码块
+ text = regexp.MustCompile("(?m)^```json\\s*").ReplaceAllString(text, "")
+ text = regexp.MustCompile("(?m)^```\\s*").ReplaceAllString(text, "")
+ text = strings.TrimSpace(text)
+
+ // 查找JSON对象
+ if idx := strings.Index(text, "{"); idx != -1 {
+ if lastIdx := strings.LastIndex(text, "}"); lastIdx != -1 && lastIdx > idx {
+ return text[idx : lastIdx+1]
+ }
+ }
+
+ // 查找JSON数组
+ if idx := strings.Index(text, "["); idx != -1 {
+ if lastIdx := strings.LastIndex(text, "]"); lastIdx != -1 && lastIdx > idx {
+ return text[idx : lastIdx+1]
+ }
+ }
+
+ return text
+}
+
+// ValidateJSON 验证JSON字符串是否有效
+func ValidateJSON(jsonStr string) error {
+ var js json.RawMessage
+ return json.Unmarshal([]byte(jsonStr), &js)
+}
+
+// Helper functions
+func truncateString(s string, maxLen int) string {
+ if len(s) <= maxLen {
+ return s
+ }
+ return s[:maxLen] + "..."
+}
+
+func maxInt(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
+
+func minInt(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/pkg/video/chatfire_client.go b/pkg/video/chatfire_client.go
new file mode 100644
index 0000000..78a7a85
--- /dev/null
+++ b/pkg/video/chatfire_client.go
@@ -0,0 +1,419 @@
+package video
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// ChatfireClient Chatfire 视频生成客户端
+type ChatfireClient struct {
+ BaseURL string
+ APIKey string
+ Model string
+ Endpoint string
+ QueryEndpoint string
+ HTTPClient *http.Client
+}
+
+type ChatfireRequest struct {
+ Model string `json:"model"`
+ Prompt string `json:"prompt"`
+ ImageURL string `json:"image_url,omitempty"`
+ Duration int `json:"duration,omitempty"`
+ Size string `json:"size,omitempty"`
+}
+
+// ChatfireSoraRequest Sora 模型请求格式
+type ChatfireSoraRequest struct {
+ Model string `json:"model"`
+ Prompt string `json:"prompt"`
+ Seconds string `json:"seconds,omitempty"`
+ Size string `json:"size,omitempty"`
+ InputReference string `json:"input_reference,omitempty"`
+}
+
+// ChatfireDoubaoRequest 豆包/火山模型请求格式
+type ChatfireDoubaoRequest struct {
+ Model string `json:"model"`
+ Content []struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ ImageURL map[string]interface{} `json:"image_url,omitempty"`
+ Role string `json:"role,omitempty"`
+ } `json:"content"`
+}
+
+type ChatfireResponse struct {
+ ID string `json:"id"`
+ TaskID string `json:"task_id,omitempty"`
+ Status string `json:"status,omitempty"`
+ Error json.RawMessage `json:"error,omitempty"`
+ Data struct {
+ ID string `json:"id,omitempty"`
+ Status string `json:"status,omitempty"`
+ VideoURL string `json:"video_url,omitempty"`
+ } `json:"data,omitempty"`
+}
+
+type ChatfireTaskResponse struct {
+ ID string `json:"id,omitempty"`
+ TaskID string `json:"task_id,omitempty"`
+ Status string `json:"status,omitempty"`
+ VideoURL string `json:"video_url,omitempty"`
+ Error json.RawMessage `json:"error,omitempty"`
+ Data struct {
+ ID string `json:"id,omitempty"`
+ Status string `json:"status,omitempty"`
+ VideoURL string `json:"video_url,omitempty"`
+ } `json:"data,omitempty"`
+ Content struct {
+ VideoURL string `json:"video_url,omitempty"`
+ } `json:"content,omitempty"`
+}
+
+// getErrorMessage 从 error 字段提取错误信息(支持字符串或对象)
+func getErrorMessage(errorData json.RawMessage) string {
+ if len(errorData) == 0 {
+ return ""
+ }
+
+ // 尝试解析为字符串
+ var errStr string
+ if err := json.Unmarshal(errorData, &errStr); err == nil {
+ return errStr
+ }
+
+ // 尝试解析为对象
+ var errObj struct {
+ Message string `json:"message"`
+ Code string `json:"code"`
+ }
+ if err := json.Unmarshal(errorData, &errObj); err == nil {
+ if errObj.Message != "" {
+ return errObj.Message
+ }
+ }
+
+ // 返回原始 JSON 字符串
+ return string(errorData)
+}
+
+func NewChatfireClient(baseURL, apiKey, model, endpoint, queryEndpoint string) *ChatfireClient {
+ if endpoint == "" {
+ endpoint = "/video/generations"
+ }
+ if queryEndpoint == "" {
+ queryEndpoint = "/video/task/{taskId}"
+ }
+ return &ChatfireClient{
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ Model: model,
+ Endpoint: endpoint,
+ QueryEndpoint: queryEndpoint,
+ HTTPClient: &http.Client{
+ Timeout: 300 * time.Second,
+ },
+ }
+}
+
+func (c *ChatfireClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) {
+ options := &VideoOptions{
+ Duration: 5,
+ AspectRatio: "16:9",
+ }
+
+ for _, opt := range opts {
+ opt(options)
+ }
+
+ model := c.Model
+ if options.Model != "" {
+ model = options.Model
+ }
+
+ // 根据模型名称选择请求格式
+ var jsonData []byte
+ var err error
+
+ if strings.Contains(model, "doubao") || strings.Contains(model, "seedance") {
+ // 豆包/火山格式
+ reqBody := ChatfireDoubaoRequest{
+ Model: model,
+ }
+
+ // 构建prompt文本(包含duration和ratio参数)
+ promptText := prompt
+ if options.AspectRatio != "" {
+ promptText += fmt.Sprintf(" --ratio %s", options.AspectRatio)
+ }
+ if options.Duration > 0 {
+ promptText += fmt.Sprintf(" --dur %d", options.Duration)
+ }
+
+ // 添加文本内容
+ reqBody.Content = append(reqBody.Content, struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ ImageURL map[string]interface{} `json:"image_url,omitempty"`
+ Role string `json:"role,omitempty"`
+ }{Type: "text", Text: promptText})
+
+ // 处理不同的图片模式
+ // 1. 组图模式(多个reference_image)
+ if len(options.ReferenceImageURLs) > 0 {
+ for _, refURL := range options.ReferenceImageURLs {
+ reqBody.Content = append(reqBody.Content, struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ ImageURL map[string]interface{} `json:"image_url,omitempty"`
+ Role string `json:"role,omitempty"`
+ }{
+ Type: "image_url",
+ ImageURL: map[string]interface{}{
+ "url": refURL,
+ },
+ Role: "reference_image",
+ })
+ }
+ } else if options.FirstFrameURL != "" && options.LastFrameURL != "" {
+ // 2. 首尾帧模式
+ reqBody.Content = append(reqBody.Content, struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ ImageURL map[string]interface{} `json:"image_url,omitempty"`
+ Role string `json:"role,omitempty"`
+ }{
+ Type: "image_url",
+ ImageURL: map[string]interface{}{
+ "url": options.FirstFrameURL,
+ },
+ Role: "first_frame",
+ })
+ reqBody.Content = append(reqBody.Content, struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ ImageURL map[string]interface{} `json:"image_url,omitempty"`
+ Role string `json:"role,omitempty"`
+ }{
+ Type: "image_url",
+ ImageURL: map[string]interface{}{
+ "url": options.LastFrameURL,
+ },
+ Role: "last_frame",
+ })
+ } else if imageURL != "" {
+ // 3. 单图模式(默认)
+ reqBody.Content = append(reqBody.Content, struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ ImageURL map[string]interface{} `json:"image_url,omitempty"`
+ Role string `json:"role,omitempty"`
+ }{
+ Type: "image_url",
+ ImageURL: map[string]interface{}{
+ "url": imageURL,
+ },
+ // 单图模式不需要role
+ })
+ } else if options.FirstFrameURL != "" {
+ // 4. 只有首帧
+ reqBody.Content = append(reqBody.Content, struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ ImageURL map[string]interface{} `json:"image_url,omitempty"`
+ Role string `json:"role,omitempty"`
+ }{
+ Type: "image_url",
+ ImageURL: map[string]interface{}{
+ "url": options.FirstFrameURL,
+ },
+ Role: "first_frame",
+ })
+ }
+
+ jsonData, err = json.Marshal(reqBody)
+ } else if strings.Contains(model, "sora") {
+ // Sora 格式
+ seconds := fmt.Sprintf("%d", options.Duration)
+ size := options.AspectRatio
+ if size == "16:9" {
+ size = "1280x720"
+ } else if size == "9:16" {
+ size = "720x1280"
+ }
+
+ reqBody := ChatfireSoraRequest{
+ Model: model,
+ Prompt: prompt,
+ Seconds: seconds,
+ Size: size,
+ InputReference: imageURL,
+ }
+ jsonData, err = json.Marshal(reqBody)
+ } else {
+ // 默认格式
+ reqBody := ChatfireRequest{
+ Model: model,
+ Prompt: prompt,
+ ImageURL: imageURL,
+ Duration: options.Duration,
+ Size: options.AspectRatio,
+ }
+ jsonData, err = json.Marshal(reqBody)
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+
+ endpoint := c.BaseURL + c.Endpoint
+ req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ // 调试日志:打印响应内容
+ fmt.Printf("[Chatfire] Response body: %s\n", string(body))
+
+ var result ChatfireResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse response: %w, body: %s", err, string(body))
+ }
+
+ // 优先使用 id 字段,其次使用 task_id
+ taskID := result.ID
+ if taskID == "" {
+ taskID = result.TaskID
+ }
+
+ // 如果有 data 嵌套,优先使用 data 中的值
+ if result.Data.ID != "" {
+ taskID = result.Data.ID
+ }
+
+ status := result.Status
+ if status == "" && result.Data.Status != "" {
+ status = result.Data.Status
+ }
+
+ fmt.Printf("[Chatfire] Parsed result - TaskID: %s, Status: %s\n", taskID, status)
+
+ if errMsg := getErrorMessage(result.Error); errMsg != "" {
+ return nil, fmt.Errorf("chatfire error: %s", errMsg)
+ }
+
+ videoResult := &VideoResult{
+ TaskID: taskID,
+ Status: status,
+ Completed: status == "completed" || status == "succeeded",
+ Duration: options.Duration,
+ }
+
+ return videoResult, nil
+}
+
+func (c *ChatfireClient) GetTaskStatus(taskID string) (*VideoResult, error) {
+ queryPath := c.QueryEndpoint
+ if strings.Contains(queryPath, "{taskId}") {
+ queryPath = strings.ReplaceAll(queryPath, "{taskId}", taskID)
+ } else if strings.Contains(queryPath, "{task_id}") {
+ queryPath = strings.ReplaceAll(queryPath, "{task_id}", taskID)
+ } else {
+ queryPath = queryPath + "/" + taskID
+ }
+
+ endpoint := c.BaseURL + queryPath
+ req, err := http.NewRequest("GET", endpoint, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ // 调试日志:打印响应内容
+ fmt.Printf("[Chatfire] GetTaskStatus Response body: %s\n", string(body))
+
+ var result ChatfireTaskResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse response: %w, body: %s", err, string(body))
+ }
+
+ // 优先使用 id 字段,其次使用 task_id
+ responseTaskID := result.ID
+ if responseTaskID == "" {
+ responseTaskID = result.TaskID
+ }
+
+ // 如果有 data 嵌套,优先使用 data 中的值
+ if result.Data.ID != "" {
+ responseTaskID = result.Data.ID
+ }
+
+ status := result.Status
+ if status == "" && result.Data.Status != "" {
+ status = result.Data.Status
+ }
+
+ // 按优先级获取 video_url:VideoURL -> Data.VideoURL -> Content.VideoURL
+ videoURL := result.VideoURL
+ if videoURL == "" && result.Data.VideoURL != "" {
+ videoURL = result.Data.VideoURL
+ }
+ if videoURL == "" && result.Content.VideoURL != "" {
+ videoURL = result.Content.VideoURL
+ }
+
+ fmt.Printf("[Chatfire] Parsed result - TaskID: %s, Status: %s, VideoURL: %s\n", responseTaskID, status, videoURL)
+
+ videoResult := &VideoResult{
+ TaskID: responseTaskID,
+ Status: status,
+ Completed: status == "completed" || status == "succeeded",
+ }
+
+ if errMsg := getErrorMessage(result.Error); errMsg != "" {
+ videoResult.Error = errMsg
+ }
+
+ if videoURL != "" {
+ videoResult.VideoURL = videoURL
+ videoResult.Completed = true
+ }
+
+ return videoResult, nil
+}
diff --git a/pkg/video/minimax_client.go b/pkg/video/minimax_client.go
new file mode 100644
index 0000000..6b143d5
--- /dev/null
+++ b/pkg/video/minimax_client.go
@@ -0,0 +1,192 @@
+package video
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+// MinimaxClient Minimax视频生成客户端
+type MinimaxClient struct {
+ BaseURL string
+ APIKey string
+ Model string
+ HTTPClient *http.Client
+}
+
+type MinimaxSubjectReference struct {
+ Type string `json:"type"`
+ Image []string `json:"image"`
+}
+
+type MinimaxRequest struct {
+ Prompt string `json:"prompt"`
+ FirstFrameImage string `json:"first_frame_image,omitempty"`
+ LastFrameImage string `json:"last_frame_image,omitempty"`
+ SubjectReference []MinimaxSubjectReference `json:"subject_reference,omitempty"`
+ Model string `json:"model"`
+ Duration int `json:"duration,omitempty"`
+ Resolution string `json:"resolution,omitempty"`
+}
+
+type MinimaxResponse struct {
+ TaskID string `json:"task_id"`
+ Status string `json:"status"`
+ BaseResp struct {
+ StatusCode int `json:"status_code"`
+ StatusMsg string `json:"status_msg"`
+ } `json:"base_resp"`
+ Video struct {
+ URL string `json:"url"`
+ Duration int `json:"duration"`
+ } `json:"video"`
+ Error struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+}
+
+func NewMinimaxClient(baseURL, apiKey, model string) *MinimaxClient {
+ return &MinimaxClient{
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ Model: model,
+ HTTPClient: &http.Client{
+ Timeout: 300 * time.Second,
+ },
+ }
+}
+
+// GenerateVideo 生成视频(支持首尾帧和主体参考)
+func (c *MinimaxClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) {
+ options := &VideoOptions{
+ Duration: 6,
+ Resolution: "1080P",
+ }
+
+ for _, opt := range opts {
+ opt(options)
+ }
+
+ model := c.Model
+ if options.Model != "" {
+ model = options.Model
+ }
+
+ reqBody := MinimaxRequest{
+ Prompt: prompt,
+ Model: model,
+ Duration: options.Duration,
+ }
+
+ // 设置分辨率
+ if options.Resolution != "" {
+ reqBody.Resolution = options.Resolution
+ }
+
+ // 如果有首帧图片(从imageURL或FirstFrameURL)
+ if options.FirstFrameURL != "" {
+ reqBody.FirstFrameImage = options.FirstFrameURL
+ } else if imageURL != "" {
+ reqBody.FirstFrameImage = imageURL
+ }
+
+ jsonData, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+
+ endpoint := c.BaseURL + "/v1/video_generation"
+ req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ var result MinimaxResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse response: %w", err)
+ }
+
+ if result.Error.Message != "" {
+ return nil, fmt.Errorf("minimax error: %s", result.Error.Message)
+ }
+
+ videoResult := &VideoResult{
+ TaskID: result.TaskID,
+ Status: result.Status,
+ Completed: result.Status == "completed",
+ Duration: result.Video.Duration,
+ }
+
+ if result.Video.URL != "" {
+ videoResult.VideoURL = result.Video.URL
+ videoResult.Completed = true
+ }
+
+ return videoResult, nil
+}
+
+func (c *MinimaxClient) GetTaskStatus(taskID string) (*VideoResult, error) {
+ endpoint := c.BaseURL + "/v1/video_generation/" + taskID
+ req, err := http.NewRequest("GET", endpoint, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ var result MinimaxResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse response: %w", err)
+ }
+
+ videoResult := &VideoResult{
+ TaskID: result.TaskID,
+ Status: result.Status,
+ Completed: result.Status == "completed",
+ Duration: result.Video.Duration,
+ }
+
+ if result.Error.Message != "" {
+ videoResult.Error = result.Error.Message
+ }
+
+ if result.Video.URL != "" {
+ videoResult.VideoURL = result.Video.URL
+ videoResult.Completed = true
+ }
+
+ return videoResult, nil
+}
diff --git a/pkg/video/openai_sora_client.go b/pkg/video/openai_sora_client.go
new file mode 100644
index 0000000..027b603
--- /dev/null
+++ b/pkg/video/openai_sora_client.go
@@ -0,0 +1,178 @@
+package video
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "time"
+)
+
+type OpenAISoraClient struct {
+ BaseURL string
+ APIKey string
+ Model string
+ HTTPClient *http.Client
+}
+
+type OpenAISoraResponse struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ Model string `json:"model"`
+ Status string `json:"status"`
+ Progress int `json:"progress"`
+ CreatedAt int64 `json:"created_at"`
+ CompletedAt int64 `json:"completed_at"`
+ Size string `json:"size"`
+ Seconds string `json:"seconds"`
+ Quality string `json:"quality"`
+ VideoURL string `json:"video_url"` // 直接的video_url字段
+ Video struct {
+ URL string `json:"url"`
+ } `json:"video"` // 嵌套的video.url字段(兼容)
+ Error struct {
+ Message string `json:"message"`
+ Type string `json:"type"`
+ } `json:"error"`
+}
+
+func NewOpenAISoraClient(baseURL, apiKey, model string) *OpenAISoraClient {
+ return &OpenAISoraClient{
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ Model: model,
+ HTTPClient: &http.Client{
+ Timeout: 300 * time.Second,
+ },
+ }
+}
+
+func (c *OpenAISoraClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) {
+ options := &VideoOptions{
+ Duration: 4,
+ }
+
+ for _, opt := range opts {
+ opt(options)
+ }
+
+ model := c.Model
+ if options.Model != "" {
+ model = options.Model
+ }
+
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+
+ writer.WriteField("model", model)
+ writer.WriteField("prompt", prompt)
+
+ if imageURL != "" {
+ writer.WriteField("input_reference", imageURL)
+ }
+
+ if options.Duration > 0 {
+ writer.WriteField("seconds", fmt.Sprintf("%d", options.Duration))
+ }
+
+ if options.Resolution != "" {
+ writer.WriteField("size", options.Resolution)
+ }
+
+ writer.Close()
+
+ endpoint := c.BaseURL + "/videos"
+ req, err := http.NewRequest("POST", endpoint, body)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
+ }
+
+ var result OpenAISoraResponse
+ if err := json.Unmarshal(respBody, &result); err != nil {
+ return nil, fmt.Errorf("parse response: %w", err)
+ }
+
+ if result.Error.Message != "" {
+ return nil, fmt.Errorf("openai error: %s", result.Error.Message)
+ }
+
+ videoResult := &VideoResult{
+ TaskID: result.ID,
+ Status: result.Status,
+ Completed: result.Status == "completed",
+ }
+
+ // 优先使用video_url字段,兼容video.url嵌套结构
+ if result.VideoURL != "" {
+ videoResult.VideoURL = result.VideoURL
+ } else if result.Video.URL != "" {
+ videoResult.VideoURL = result.Video.URL
+ }
+
+ return videoResult, nil
+}
+
+func (c *OpenAISoraClient) GetTaskStatus(taskID string) (*VideoResult, error) {
+ endpoint := c.BaseURL + "/videos/" + taskID
+ req, err := http.NewRequest("GET", endpoint, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ var result OpenAISoraResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse response: %w", err)
+ }
+
+ videoResult := &VideoResult{
+ TaskID: result.ID,
+ Status: result.Status,
+ Completed: result.Status == "completed",
+ }
+
+ if result.Error.Message != "" {
+ videoResult.Error = result.Error.Message
+ }
+
+ // 优先使用video_url字段,兼容video.url嵌套结构
+ if result.VideoURL != "" {
+ videoResult.VideoURL = result.VideoURL
+ } else if result.Video.URL != "" {
+ videoResult.VideoURL = result.Video.URL
+ }
+
+ return videoResult, nil
+}
diff --git a/pkg/video/video_client.go b/pkg/video/video_client.go
new file mode 100644
index 0000000..a8fc25c
--- /dev/null
+++ b/pkg/video/video_client.go
@@ -0,0 +1,427 @@
+package video
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+type VideoClient interface {
+ GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error)
+ GetTaskStatus(taskID string) (*VideoResult, error)
+}
+
+type VideoResult struct {
+ TaskID string
+ Status string
+ VideoURL string
+ ThumbnailURL string
+ Duration int
+ Width int
+ Height int
+ Error string
+ Completed bool
+}
+
+type VideoOptions struct {
+ Model string
+ Duration int
+ FPS int
+ Resolution string
+ AspectRatio string
+ Style string
+ MotionLevel int
+ CameraMotion string
+ Seed int64
+ FirstFrameURL string
+ LastFrameURL string
+ ReferenceImageURLs []string
+}
+
+type VideoOption func(*VideoOptions)
+
+func WithModel(model string) VideoOption {
+ return func(o *VideoOptions) {
+ o.Model = model
+ }
+}
+
+func WithDuration(duration int) VideoOption {
+ return func(o *VideoOptions) {
+ o.Duration = duration
+ }
+}
+
+func WithFPS(fps int) VideoOption {
+ return func(o *VideoOptions) {
+ o.FPS = fps
+ }
+}
+
+func WithResolution(resolution string) VideoOption {
+ return func(o *VideoOptions) {
+ o.Resolution = resolution
+ }
+}
+
+func WithAspectRatio(ratio string) VideoOption {
+ return func(o *VideoOptions) {
+ o.AspectRatio = ratio
+ }
+}
+
+func WithStyle(style string) VideoOption {
+ return func(o *VideoOptions) {
+ o.Style = style
+ }
+}
+
+func WithMotionLevel(level int) VideoOption {
+ return func(o *VideoOptions) {
+ o.MotionLevel = level
+ }
+}
+
+func WithCameraMotion(motion string) VideoOption {
+ return func(o *VideoOptions) {
+ o.CameraMotion = motion
+ }
+}
+
+func WithSeed(seed int64) VideoOption {
+ return func(o *VideoOptions) {
+ o.Seed = seed
+ }
+}
+
+func WithFirstFrame(url string) VideoOption {
+ return func(o *VideoOptions) {
+ o.FirstFrameURL = url
+ }
+}
+
+func WithLastFrame(url string) VideoOption {
+ return func(o *VideoOptions) {
+ o.LastFrameURL = url
+ }
+}
+
+func WithReferenceImages(urls []string) VideoOption {
+ return func(o *VideoOptions) {
+ o.ReferenceImageURLs = urls
+ }
+}
+
+type RunwayClient struct {
+ BaseURL string
+ APIKey string
+ Model string
+ HTTPClient *http.Client
+}
+
+type RunwayRequest struct {
+ Model string `json:"model"`
+ PromptImage string `json:"prompt_image"`
+ PromptText string `json:"prompt_text"`
+ Duration int `json:"duration,omitempty"`
+ AspectRatio string `json:"aspect_ratio,omitempty"`
+ Seed int64 `json:"seed,omitempty"`
+}
+
+type RunwayResponse struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ Output struct {
+ URL string `json:"url"`
+ } `json:"output"`
+ Error string `json:"error,omitempty"`
+}
+
+func NewRunwayClient(baseURL, apiKey, model string) *RunwayClient {
+ return &RunwayClient{
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ Model: model,
+ HTTPClient: &http.Client{
+ Timeout: 180 * time.Second,
+ },
+ }
+}
+
+func (c *RunwayClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) {
+ options := &VideoOptions{
+ Duration: 5,
+ AspectRatio: "16:9",
+ }
+
+ for _, opt := range opts {
+ opt(options)
+ }
+
+ model := c.Model
+ if options.Model != "" {
+ model = options.Model
+ }
+
+ reqBody := RunwayRequest{
+ Model: model,
+ PromptImage: imageURL,
+ PromptText: prompt,
+ Duration: options.Duration,
+ AspectRatio: options.AspectRatio,
+ Seed: options.Seed,
+ }
+
+ jsonData, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+
+ endpoint := c.BaseURL + "/v1/video/generate"
+ req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ var result RunwayResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse response: %w", err)
+ }
+
+ if result.Error != "" {
+ return nil, fmt.Errorf("runway error: %s", result.Error)
+ }
+
+ videoResult := &VideoResult{
+ TaskID: result.ID,
+ Status: result.Status,
+ Completed: result.Status == "succeeded",
+ }
+
+ if result.Output.URL != "" {
+ videoResult.VideoURL = result.Output.URL
+ }
+
+ return videoResult, nil
+}
+
+func (c *RunwayClient) GetTaskStatus(taskID string) (*VideoResult, error) {
+ endpoint := c.BaseURL + "/v1/video/status/" + taskID
+ req, err := http.NewRequest("GET", endpoint, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ var result RunwayResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse response: %w", err)
+ }
+
+ videoResult := &VideoResult{
+ TaskID: result.ID,
+ Status: result.Status,
+ Completed: result.Status == "succeeded",
+ }
+
+ if result.Error != "" {
+ videoResult.Error = result.Error
+ }
+
+ if result.Output.URL != "" {
+ videoResult.VideoURL = result.Output.URL
+ }
+
+ return videoResult, nil
+}
+
+type PikaClient struct {
+ BaseURL string
+ APIKey string
+ Model string
+ HTTPClient *http.Client
+}
+
+type PikaRequest struct {
+ Model string `json:"model"`
+ Image string `json:"image"`
+ Prompt string `json:"prompt"`
+ Duration int `json:"duration,omitempty"`
+ AspectRatio string `json:"aspect_ratio,omitempty"`
+ Motion int `json:"motion,omitempty"`
+ CameraMotion string `json:"camera_motion,omitempty"`
+ Seed int64 `json:"seed,omitempty"`
+}
+
+type PikaResponse struct {
+ JobID string `json:"job_id"`
+ Status string `json:"status"`
+ Result struct {
+ VideoURL string `json:"video_url"`
+ } `json:"result"`
+ Error string `json:"error,omitempty"`
+}
+
+func NewPikaClient(baseURL, apiKey, model string) *PikaClient {
+ return &PikaClient{
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ Model: model,
+ HTTPClient: &http.Client{
+ Timeout: 180 * time.Second,
+ },
+ }
+}
+
+func (c *PikaClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) {
+ options := &VideoOptions{
+ Duration: 3,
+ AspectRatio: "16:9",
+ MotionLevel: 50,
+ }
+
+ for _, opt := range opts {
+ opt(options)
+ }
+
+ model := c.Model
+ if options.Model != "" {
+ model = options.Model
+ }
+
+ reqBody := PikaRequest{
+ Model: model,
+ Image: imageURL,
+ Prompt: prompt,
+ Duration: options.Duration,
+ AspectRatio: options.AspectRatio,
+ Motion: options.MotionLevel,
+ CameraMotion: options.CameraMotion,
+ Seed: options.Seed,
+ }
+
+ jsonData, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+
+ endpoint := c.BaseURL + "/v1/video/generate"
+ req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ var result PikaResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse response: %w", err)
+ }
+
+ if result.Error != "" {
+ return nil, fmt.Errorf("pika error: %s", result.Error)
+ }
+
+ videoResult := &VideoResult{
+ TaskID: result.JobID,
+ Status: result.Status,
+ Completed: result.Status == "completed",
+ }
+
+ if result.Result.VideoURL != "" {
+ videoResult.VideoURL = result.Result.VideoURL
+ }
+
+ return videoResult, nil
+}
+
+func (c *PikaClient) GetTaskStatus(taskID string) (*VideoResult, error) {
+ endpoint := c.BaseURL + "/v1/video/status/" + taskID
+ req, err := http.NewRequest("GET", endpoint, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ var result PikaResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse response: %w", err)
+ }
+
+ videoResult := &VideoResult{
+ TaskID: result.JobID,
+ Status: result.Status,
+ Completed: result.Status == "completed",
+ }
+
+ if result.Error != "" {
+ videoResult.Error = result.Error
+ }
+
+ if result.Result.VideoURL != "" {
+ videoResult.VideoURL = result.Result.VideoURL
+ }
+
+ return videoResult, nil
+}
diff --git a/pkg/video/volces_ark_client.go b/pkg/video/volces_ark_client.go
new file mode 100644
index 0000000..8844a0f
--- /dev/null
+++ b/pkg/video/volces_ark_client.go
@@ -0,0 +1,290 @@
+package video
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// VolcesArkClient 火山引擎ARK视频生成客户端
+type VolcesArkClient struct {
+ BaseURL string
+ APIKey string
+ Model string
+ Endpoint string
+ QueryEndpoint string
+ HTTPClient *http.Client
+}
+
+type VolcesArkContent struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ ImageURL map[string]interface{} `json:"image_url,omitempty"`
+ Role string `json:"role,omitempty"`
+}
+
+type VolcesArkRequest struct {
+ Model string `json:"model"`
+ Content []VolcesArkContent `json:"content"`
+ GenerateAudio bool `json:"generate_audio,omitempty"`
+}
+
+type VolcesArkResponse struct {
+ ID string `json:"id"`
+ Model string `json:"model"`
+ Status string `json:"status"`
+ Content struct {
+ VideoURL string `json:"video_url"`
+ } `json:"content"`
+ Usage struct {
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ } `json:"usage"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+ Seed int `json:"seed"`
+ Resolution string `json:"resolution"`
+ Ratio string `json:"ratio"`
+ Duration int `json:"duration"`
+ FramesPerSecond int `json:"framespersecond"`
+ ServiceTier string `json:"service_tier"`
+ ExecutionExpiresAfter int `json:"execution_expires_after"`
+ GenerateAudio bool `json:"generate_audio"`
+ Error interface{} `json:"error,omitempty"`
+}
+
+func NewVolcesArkClient(baseURL, apiKey, model, endpoint, queryEndpoint string) *VolcesArkClient {
+ if endpoint == "" {
+ endpoint = "/api/v3/contents/generations/tasks"
+ }
+ if queryEndpoint == "" {
+ queryEndpoint = endpoint
+ }
+ return &VolcesArkClient{
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ Model: model,
+ Endpoint: endpoint,
+ QueryEndpoint: queryEndpoint,
+ HTTPClient: &http.Client{
+ Timeout: 300 * time.Second,
+ },
+ }
+}
+
+// GenerateVideo 生成视频(支持首帧、首尾帧、参考图等多种模式)
+func (c *VolcesArkClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) {
+ options := &VideoOptions{
+ Duration: 5,
+ AspectRatio: "adaptive",
+ }
+
+ for _, opt := range opts {
+ opt(options)
+ }
+
+ model := c.Model
+ if options.Model != "" {
+ model = options.Model
+ }
+
+ // 构建prompt文本(包含duration和ratio参数)
+ promptText := prompt
+ if options.AspectRatio != "" {
+ promptText += fmt.Sprintf(" --ratio %s", options.AspectRatio)
+ }
+ if options.Duration > 0 {
+ promptText += fmt.Sprintf(" --dur %d", options.Duration)
+ }
+
+ content := []VolcesArkContent{
+ {
+ Type: "text",
+ Text: promptText,
+ },
+ }
+
+ // 处理不同的图片模式
+ // 1. 组图模式(多个reference_image)
+ if len(options.ReferenceImageURLs) > 0 {
+ for _, refURL := range options.ReferenceImageURLs {
+ content = append(content, VolcesArkContent{
+ Type: "image_url",
+ ImageURL: map[string]interface{}{
+ "url": refURL,
+ },
+ Role: "reference_image",
+ })
+ }
+ } else if options.FirstFrameURL != "" && options.LastFrameURL != "" {
+ // 2. 首尾帧模式
+ content = append(content, VolcesArkContent{
+ Type: "image_url",
+ ImageURL: map[string]interface{}{
+ "url": options.FirstFrameURL,
+ },
+ Role: "first_frame",
+ })
+ content = append(content, VolcesArkContent{
+ Type: "image_url",
+ ImageURL: map[string]interface{}{
+ "url": options.LastFrameURL,
+ },
+ Role: "last_frame",
+ })
+ } else if imageURL != "" {
+ // 3. 单图模式(默认)
+ content = append(content, VolcesArkContent{
+ Type: "image_url",
+ ImageURL: map[string]interface{}{
+ "url": imageURL,
+ },
+ // 单图模式不需要role
+ })
+ } else if options.FirstFrameURL != "" {
+ // 4. 只有首帧
+ content = append(content, VolcesArkContent{
+ Type: "image_url",
+ ImageURL: map[string]interface{}{
+ "url": options.FirstFrameURL,
+ },
+ Role: "first_frame",
+ })
+ }
+
+ // 只有 seedance-1-5-pro 模型支持 generate_audio 参数
+ generateAudio := false
+ if strings.Contains(strings.ToLower(model), "seedance-1-5-pro") {
+ generateAudio = true
+ }
+
+ reqBody := VolcesArkRequest{
+ Model: model,
+ Content: content,
+ GenerateAudio: generateAudio,
+ }
+
+ jsonData, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+
+ endpoint := c.BaseURL + c.Endpoint
+ fmt.Printf("[VolcesARK] Generating video - Endpoint: %s, FullURL: %s, Model: %s\n", c.Endpoint, endpoint, model)
+ fmt.Printf("[VolcesARK] Request body: %s\n", string(jsonData))
+
+ req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ fmt.Printf("[VolcesARK] Response status: %d, body: %s\n", resp.StatusCode, string(body))
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ var result VolcesArkResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse response: %w", err)
+ }
+
+ fmt.Printf("[VolcesARK] Video generation initiated - TaskID: %s, Status: %s\n", result.ID, result.Status)
+
+ if result.Error != nil {
+ errorMsg := fmt.Sprintf("%v", result.Error)
+ return nil, fmt.Errorf("volces error: %s", errorMsg)
+ }
+
+ videoResult := &VideoResult{
+ TaskID: result.ID,
+ Status: result.Status,
+ Completed: result.Status == "completed" || result.Status == "succeeded",
+ Duration: result.Duration,
+ }
+
+ if result.Content.VideoURL != "" {
+ videoResult.VideoURL = result.Content.VideoURL
+ videoResult.Completed = true
+ }
+
+ return videoResult, nil
+}
+
+func (c *VolcesArkClient) GetTaskStatus(taskID string) (*VideoResult, error) {
+ // 替换占位符{taskId}、{task_id}或直接拼接
+ queryPath := c.QueryEndpoint
+ if strings.Contains(queryPath, "{taskId}") {
+ queryPath = strings.ReplaceAll(queryPath, "{taskId}", taskID)
+ } else if strings.Contains(queryPath, "{task_id}") {
+ queryPath = strings.ReplaceAll(queryPath, "{task_id}", taskID)
+ } else {
+ queryPath = queryPath + "/" + taskID
+ }
+
+ endpoint := c.BaseURL + queryPath
+ fmt.Printf("[VolcesARK] Querying task status - TaskID: %s, QueryEndpoint: %s, FullURL: %s\n", taskID, c.QueryEndpoint, endpoint)
+
+ req, err := http.NewRequest("GET", endpoint, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ fmt.Printf("[VolcesARK] Response body: %s\n", string(body))
+
+ var result VolcesArkResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse response: %w", err)
+ }
+
+ fmt.Printf("[VolcesARK] Parsed result - ID: %s, Status: %s, VideoURL: %s\n", result.ID, result.Status, result.Content.VideoURL)
+
+ videoResult := &VideoResult{
+ TaskID: result.ID,
+ Status: result.Status,
+ Completed: result.Status == "completed" || result.Status == "succeeded",
+ Duration: result.Duration,
+ }
+
+ if result.Error != nil {
+ videoResult.Error = fmt.Sprintf("%v", result.Error)
+ }
+
+ if result.Content.VideoURL != "" {
+ videoResult.VideoURL = result.Content.VideoURL
+ videoResult.Completed = true
+ }
+
+ return videoResult, nil
+}
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..2f030a2
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,29 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# Environment
+.env
+.env.local
+.env.*.local
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..bbd8a61
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Drama Generator - AI 短剧生成平台
+
+
+
+
+
+
diff --git a/web/nginx.conf b/web/nginx.conf
new file mode 100644
index 0000000..398df76
--- /dev/null
+++ b/web/nginx.conf
@@ -0,0 +1,23 @@
+server {
+ listen 80;
+ server_name localhost;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ location /api {
+ proxy_pass http://api:8080;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ gzip on;
+ gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
+ gzip_comp_level 6;
+ gzip_min_length 1000;
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..650033f
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "drama-generator-frontend",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "build:check": "vue-tsc --noEmit --skipLibCheck && vite build",
+ "build:skip": "vite build",
+ "preview": "vite preview",
+ "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
+ },
+ "dependencies": {
+ "@element-plus/icons-vue": "^2.3.0",
+ "@ffmpeg/ffmpeg": "^0.12.15",
+ "@ffmpeg/util": "^0.12.2",
+ "axios": "^1.6.0",
+ "dayjs": "^1.11.10",
+ "element-plus": "^2.5.0",
+ "pinia": "^2.1.0",
+ "vue": "^3.4.0",
+ "vue-i18n": "^9.14.5",
+ "vue-router": "^4.2.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4.1.0",
+ "@types/node": "^20.10.0",
+ "@vitejs/plugin-vue": "^5.0.0",
+ "@vue/tsconfig": "^0.5.0",
+ "autoprefixer": "^10.4.0",
+ "postcss": "^8.4.0",
+ "sass-embedded": "^1.97.1",
+ "tailwindcss": "^4.1.0",
+ "typescript": "^5.3.0",
+ "vite": "^5.0.0",
+ "vue-tsc": "^2.2.12"
+ }
+}
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
new file mode 100644
index 0000000..51334e1
--- /dev/null
+++ b/web/pnpm-lock.yaml
@@ -0,0 +1,2317 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@element-plus/icons-vue':
+ specifier: ^2.3.0
+ version: 2.3.2(vue@3.5.26(typescript@5.9.3))
+ '@ffmpeg/ffmpeg':
+ specifier: ^0.12.15
+ version: 0.12.15
+ '@ffmpeg/util':
+ specifier: ^0.12.2
+ version: 0.12.2
+ axios:
+ specifier: ^1.6.0
+ version: 1.13.2
+ dayjs:
+ specifier: ^1.11.10
+ version: 1.11.19
+ element-plus:
+ specifier: ^2.5.0
+ version: 2.13.1(vue@3.5.26(typescript@5.9.3))
+ pinia:
+ specifier: ^2.1.0
+ version: 2.3.1(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
+ vue:
+ specifier: ^3.4.0
+ version: 3.5.26(typescript@5.9.3)
+ vue-i18n:
+ specifier: ^9.14.5
+ version: 9.14.5(vue@3.5.26(typescript@5.9.3))
+ vue-router:
+ specifier: ^4.2.0
+ version: 4.6.4(vue@3.5.26(typescript@5.9.3))
+ devDependencies:
+ '@tailwindcss/postcss':
+ specifier: ^4.1.0
+ version: 4.1.18
+ '@types/node':
+ specifier: ^20.10.0
+ version: 20.19.28
+ '@vitejs/plugin-vue':
+ specifier: ^5.0.0
+ version: 5.2.4(vite@5.4.21(@types/node@20.19.28)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2))(vue@3.5.26(typescript@5.9.3))
+ '@vue/tsconfig':
+ specifier: ^0.5.0
+ version: 0.5.1
+ autoprefixer:
+ specifier: ^10.4.0
+ version: 10.4.23(postcss@8.5.6)
+ postcss:
+ specifier: ^8.4.0
+ version: 8.5.6
+ sass-embedded:
+ specifier: ^1.97.1
+ version: 1.97.2
+ tailwindcss:
+ specifier: ^4.1.0
+ version: 4.1.18
+ typescript:
+ specifier: ^5.3.0
+ version: 5.9.3
+ vite:
+ specifier: ^5.0.0
+ version: 5.4.21(@types/node@20.19.28)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)
+ vue-tsc:
+ specifier: ^2.2.12
+ version: 2.2.12(typescript@5.9.3)
+
+packages:
+
+ '@alloc/quick-lru@5.2.0':
+ resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
+ engines: {node: '>=10'}
+
+ '@babel/helper-string-parser@7.27.1':
+ resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.28.6':
+ resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/types@7.28.6':
+ resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
+ engines: {node: '>=6.9.0'}
+
+ '@bufbuild/protobuf@2.10.2':
+ resolution: {integrity: sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==}
+
+ '@ctrl/tinycolor@3.6.1':
+ resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==}
+ engines: {node: '>=10'}
+
+ '@element-plus/icons-vue@2.3.2':
+ resolution: {integrity: sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==}
+ peerDependencies:
+ vue: ^3.2.0
+
+ '@esbuild/aix-ppc64@0.21.5':
+ resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.21.5':
+ resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.21.5':
+ resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.21.5':
+ resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.21.5':
+ resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.21.5':
+ resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.21.5':
+ resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.21.5':
+ resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.21.5':
+ resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.21.5':
+ resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.21.5':
+ resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.21.5':
+ resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.21.5':
+ resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.21.5':
+ resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.21.5':
+ resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.21.5':
+ resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.21.5':
+ resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-x64@0.21.5':
+ resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-x64@0.21.5':
+ resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/sunos-x64@0.21.5':
+ resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.21.5':
+ resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.21.5':
+ resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.21.5':
+ resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+
+ '@ffmpeg/ffmpeg@0.12.15':
+ resolution: {integrity: sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==}
+ engines: {node: '>=18.x'}
+
+ '@ffmpeg/types@0.12.4':
+ resolution: {integrity: sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==}
+ engines: {node: '>=16.x'}
+
+ '@ffmpeg/util@0.12.2':
+ resolution: {integrity: sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==}
+ engines: {node: '>=18.x'}
+
+ '@floating-ui/core@1.7.3':
+ resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
+
+ '@floating-ui/dom@1.7.4':
+ resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
+
+ '@floating-ui/utils@0.2.10':
+ resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
+
+ '@intlify/core-base@9.14.5':
+ resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==}
+ engines: {node: '>= 16'}
+
+ '@intlify/message-compiler@9.14.5':
+ resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==}
+ engines: {node: '>= 16'}
+
+ '@intlify/shared@9.14.5':
+ resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==}
+ engines: {node: '>= 16'}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@parcel/watcher-android-arm64@2.5.4':
+ resolution: {integrity: sha512-hoh0vx4v+b3BNI7Cjoy2/B0ARqcwVNrzN/n7DLq9ZB4I3lrsvhrkCViJyfTj/Qi5xM9YFiH4AmHGK6pgH1ss7g==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ '@parcel/watcher-darwin-arm64@2.5.4':
+ resolution: {integrity: sha512-kphKy377pZiWpAOyTgQYPE5/XEKVMaj6VUjKT5VkNyUJlr2qZAn8gIc7CPzx+kbhvqHDT9d7EqdOqRXT6vk0zw==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@parcel/watcher-darwin-x64@2.5.4':
+ resolution: {integrity: sha512-UKaQFhCtNJW1A9YyVz3Ju7ydf6QgrpNQfRZ35wNKUhTQ3dxJ/3MULXN5JN/0Z80V/KUBDGa3RZaKq1EQT2a2gg==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@parcel/watcher-freebsd-x64@2.5.4':
+ resolution: {integrity: sha512-Dib0Wv3Ow/m2/ttvLdeI2DBXloO7t3Z0oCp4bAb2aqyqOjKPPGrg10pMJJAQ7tt8P4V2rwYwywkDhUia/FgS+Q==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@parcel/watcher-linux-arm-glibc@2.5.4':
+ resolution: {integrity: sha512-I5Vb769pdf7Q7Sf4KNy8Pogl/URRCKu9ImMmnVKYayhynuyGYMzuI4UOWnegQNa2sGpsPSbzDsqbHNMyeyPCgw==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@parcel/watcher-linux-arm-musl@2.5.4':
+ resolution: {integrity: sha512-kGO8RPvVrcAotV4QcWh8kZuHr9bXi9a3bSZw7kFarYR0+fGliU7hd/zevhjw8fnvIKG3J9EO5G6sXNGCSNMYPQ==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@parcel/watcher-linux-arm64-glibc@2.5.4':
+ resolution: {integrity: sha512-KU75aooXhqGFY2W5/p8DYYHt4hrjHZod8AhcGAmhzPn/etTa+lYCDB2b1sJy3sWJ8ahFVTdy+EbqSBvMx3iFlw==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@parcel/watcher-linux-arm64-musl@2.5.4':
+ resolution: {integrity: sha512-Qx8uNiIekVutnzbVdrgSanM+cbpDD3boB1f8vMtnuG5Zau4/bdDbXyKwIn0ToqFhIuob73bcxV9NwRm04/hzHQ==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@parcel/watcher-linux-x64-glibc@2.5.4':
+ resolution: {integrity: sha512-UYBQvhYmgAv61LNUn24qGQdjtycFBKSK3EXr72DbJqX9aaLbtCOO8+1SkKhD/GNiJ97ExgcHBrukcYhVjrnogA==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@parcel/watcher-linux-x64-musl@2.5.4':
+ resolution: {integrity: sha512-YoRWCVgxv8akZrMhdyVi6/TyoeeMkQ0PGGOf2E4omODrvd1wxniXP+DBynKoHryStks7l+fDAMUBRzqNHrVOpg==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@parcel/watcher-win32-arm64@2.5.4':
+ resolution: {integrity: sha512-iby+D/YNXWkiQNYcIhg8P5hSjzXEHaQrk2SLrWOUD7VeC4Ohu0WQvmV+HDJokZVJ2UjJ4AGXW3bx7Lls9Ln4TQ==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@parcel/watcher-win32-ia32@2.5.4':
+ resolution: {integrity: sha512-vQN+KIReG0a2ZDpVv8cgddlf67J8hk1WfZMMP7sMeZmJRSmEax5xNDNWKdgqSe2brOKTQQAs3aCCUal2qBHAyg==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@parcel/watcher-win32-x64@2.5.4':
+ resolution: {integrity: sha512-3A6efb6BOKwyw7yk9ro2vus2YTt2nvcd56AuzxdMiVOxL9umDyN5PKkKfZ/gZ9row41SjVmTVQNWQhaRRGpOKw==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ '@parcel/watcher@2.5.4':
+ resolution: {integrity: sha512-WYa2tUVV5HiArWPB3ydlOc4R2ivq0IDrlqhMi3l7mVsFEXNcTfxYFPIHXHXIh/ca/y/V5N4E1zecyxdIBjYnkQ==}
+ engines: {node: '>= 10.0.0'}
+
+ '@rollup/rollup-android-arm-eabi@4.55.1':
+ resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.55.1':
+ resolution: {integrity: sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.55.1':
+ resolution: {integrity: sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.55.1':
+ resolution: {integrity: sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.55.1':
+ resolution: {integrity: sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.55.1':
+ resolution: {integrity: sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.55.1':
+ resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.55.1':
+ resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.55.1':
+ resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.55.1':
+ resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.55.1':
+ resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-loong64-musl@4.55.1':
+ resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.55.1':
+ resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-musl@4.55.1':
+ resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.55.1':
+ resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.55.1':
+ resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.55.1':
+ resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.55.1':
+ resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.55.1':
+ resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openbsd-x64@4.55.1':
+ resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.55.1':
+ resolution: {integrity: sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.55.1':
+ resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.55.1':
+ resolution: {integrity: sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.55.1':
+ resolution: {integrity: sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.55.1':
+ resolution: {integrity: sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==}
+ cpu: [x64]
+ os: [win32]
+
+ '@sxzz/popperjs-es@2.11.7':
+ resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==}
+
+ '@tailwindcss/node@4.1.18':
+ resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
+
+ '@tailwindcss/oxide-android-arm64@4.1.18':
+ resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.1.18':
+ resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.1.18':
+ resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.1.18':
+ resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
+ resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
+ resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
+ resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
+ resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.1.18':
+ resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.1.18':
+ resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
+ resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.18':
+ resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.1.18':
+ resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==}
+ engines: {node: '>= 10'}
+
+ '@tailwindcss/postcss@4.1.18':
+ resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@types/lodash-es@4.17.12':
+ resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
+
+ '@types/lodash@4.17.23':
+ resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==}
+
+ '@types/node@20.19.28':
+ resolution: {integrity: sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==}
+
+ '@types/web-bluetooth@0.0.20':
+ resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
+
+ '@vitejs/plugin-vue@5.2.4':
+ resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ peerDependencies:
+ vite: ^5.0.0 || ^6.0.0
+ vue: ^3.2.25
+
+ '@volar/language-core@2.4.15':
+ resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
+
+ '@volar/source-map@2.4.15':
+ resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==}
+
+ '@volar/typescript@2.4.15':
+ resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
+
+ '@vue/compiler-core@3.5.26':
+ resolution: {integrity: sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==}
+
+ '@vue/compiler-dom@3.5.26':
+ resolution: {integrity: sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==}
+
+ '@vue/compiler-sfc@3.5.26':
+ resolution: {integrity: sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==}
+
+ '@vue/compiler-ssr@3.5.26':
+ resolution: {integrity: sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==}
+
+ '@vue/compiler-vue2@2.7.16':
+ resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
+
+ '@vue/devtools-api@6.6.4':
+ resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
+
+ '@vue/language-core@2.2.12':
+ resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ '@vue/reactivity@3.5.26':
+ resolution: {integrity: sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==}
+
+ '@vue/runtime-core@3.5.26':
+ resolution: {integrity: sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==}
+
+ '@vue/runtime-dom@3.5.26':
+ resolution: {integrity: sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==}
+
+ '@vue/server-renderer@3.5.26':
+ resolution: {integrity: sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==}
+ peerDependencies:
+ vue: 3.5.26
+
+ '@vue/shared@3.5.26':
+ resolution: {integrity: sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==}
+
+ '@vue/tsconfig@0.5.1':
+ resolution: {integrity: sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==}
+
+ '@vueuse/core@10.11.1':
+ resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
+
+ '@vueuse/metadata@10.11.1':
+ resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
+
+ '@vueuse/shared@10.11.1':
+ resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
+
+ alien-signals@1.0.13:
+ resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
+
+ async-validator@4.2.5:
+ resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
+
+ asynckit@0.4.0:
+ resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+
+ autoprefixer@10.4.23:
+ resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==}
+ engines: {node: ^10 || ^12 || >=14}
+ hasBin: true
+ peerDependencies:
+ postcss: ^8.1.0
+
+ axios@1.13.2:
+ resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
+
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+ baseline-browser-mapping@2.9.14:
+ resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
+ hasBin: true
+
+ brace-expansion@2.0.2:
+ resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
+
+ browserslist@4.28.1:
+ resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ buffer-builder@0.2.0:
+ resolution: {integrity: sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==}
+
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ caniuse-lite@1.0.30001764:
+ resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==}
+
+ chokidar@4.0.3:
+ resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
+ engines: {node: '>= 14.16.0'}
+
+ colorjs.io@0.5.2:
+ resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
+
+ combined-stream@1.0.8:
+ resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+ engines: {node: '>= 0.8'}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ dayjs@1.11.19:
+ resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
+
+ de-indent@1.0.2:
+ resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
+
+ delayed-stream@1.0.0:
+ resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+ engines: {node: '>=0.4.0'}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
+ electron-to-chromium@1.5.267:
+ resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
+
+ element-plus@2.13.1:
+ resolution: {integrity: sha512-eG4BDBGdAsUGN6URH1PixzZb0ngdapLivIk1meghS1uEueLvQ3aljSKrCt5x6sYb6mUk8eGtzTQFgsPmLavQcA==}
+ peerDependencies:
+ vue: ^3.3.0
+
+ enhanced-resolve@5.18.4:
+ resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
+ engines: {node: '>=10.13.0'}
+
+ entities@7.0.0:
+ resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==}
+ engines: {node: '>=0.12'}
+
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ es-object-atoms@1.1.1:
+ resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
+ engines: {node: '>= 0.4'}
+
+ es-set-tostringtag@2.1.0:
+ resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+ engines: {node: '>= 0.4'}
+
+ esbuild@0.21.5:
+ resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
+ engines: {node: '>=12'}
+ hasBin: true
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ estree-walker@2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+ follow-redirects@1.15.11:
+ resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+
+ form-data@4.0.5:
+ resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
+ engines: {node: '>= 6'}
+
+ fraction.js@5.3.4:
+ resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
+ has-tostringtag@1.0.2:
+ resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.2:
+ resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
+ engines: {node: '>= 0.4'}
+
+ he@1.2.0:
+ resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
+ hasBin: true
+
+ immutable@5.1.4:
+ resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ jiti@2.6.1:
+ resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
+ hasBin: true
+
+ lightningcss-android-arm64@1.30.2:
+ resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.30.2:
+ resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.30.2:
+ resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.30.2:
+ resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.30.2:
+ resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.30.2:
+ resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-arm64-musl@1.30.2:
+ resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-x64-gnu@1.30.2:
+ resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-x64-musl@1.30.2:
+ resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-win32-arm64-msvc@1.30.2:
+ resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.30.2:
+ resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.30.2:
+ resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
+ engines: {node: '>= 12.0.0'}
+
+ lodash-es@4.17.22:
+ resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==}
+
+ lodash-unified@1.0.3:
+ resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
+ peerDependencies:
+ '@types/lodash-es': '*'
+ lodash: '*'
+ lodash-es: '*'
+
+ lodash@4.17.21:
+ resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
+ memoize-one@6.0.0:
+ resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
+
+ mime-db@1.52.0:
+ resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+ engines: {node: '>= 0.6'}
+
+ mime-types@2.1.35:
+ resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+ engines: {node: '>= 0.6'}
+
+ minimatch@9.0.5:
+ resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ muggle-string@0.4.1:
+ resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
+
+ nanoid@3.3.11:
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ node-addon-api@7.1.1:
+ resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
+
+ node-releases@2.0.27:
+ resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
+
+ normalize-wheel-es@1.2.0:
+ resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==}
+
+ path-browserify@1.0.1:
+ resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.3:
+ resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
+ engines: {node: '>=12'}
+
+ pinia@2.3.1:
+ resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==}
+ peerDependencies:
+ typescript: '>=4.4.4'
+ vue: ^2.7.0 || ^3.5.11
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ postcss-value-parser@4.2.0:
+ resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
+
+ postcss@8.5.6:
+ resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ proxy-from-env@1.1.0:
+ resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+
+ readdirp@4.1.2:
+ resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
+ engines: {node: '>= 14.18.0'}
+
+ rollup@4.55.1:
+ resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ rxjs@7.8.2:
+ resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
+
+ sass-embedded-all-unknown@1.97.2:
+ resolution: {integrity: sha512-Fj75+vOIDv1T/dGDwEpQ5hgjXxa2SmMeShPa8yrh2sUz1U44bbmY4YSWPCdg8wb7LnwiY21B2KRFM+HF42yO4g==}
+ cpu: ['!arm', '!arm64', '!riscv64', '!x64']
+
+ sass-embedded-android-arm64@1.97.2:
+ resolution: {integrity: sha512-pF6I+R5uThrscd3lo9B3DyNTPyGFsopycdx0tDAESN6s+dBbiRgNgE4Zlpv50GsLocj/lDLCZaabeTpL3ubhYA==}
+ engines: {node: '>=14.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ sass-embedded-android-arm@1.97.2:
+ resolution: {integrity: sha512-BPT9m19ttY0QVHYYXRa6bmqmS3Fa2EHByNUEtSVcbm5PkIk1ntmYkG9fn5SJpIMbNmFDGwHx+pfcZMmkldhnRg==}
+ engines: {node: '>=14.0.0'}
+ cpu: [arm]
+ os: [android]
+
+ sass-embedded-android-riscv64@1.97.2:
+ resolution: {integrity: sha512-fprI8ZTJdz+STgARhg8zReI2QhhGIT9G8nS7H21kc3IkqPRzhfaemSxEtCqZyvDbXPcgYiDLV7AGIReHCuATog==}
+ engines: {node: '>=14.0.0'}
+ cpu: [riscv64]
+ os: [android]
+
+ sass-embedded-android-x64@1.97.2:
+ resolution: {integrity: sha512-RswwSjURZxupsukEmNt2t6RGvuvIw3IAD5sDq1Pc65JFvWFY3eHqCmH0lG0oXqMg6KJcF0eOxHOp2RfmIm2+4w==}
+ engines: {node: '>=14.0.0'}
+ cpu: [x64]
+ os: [android]
+
+ sass-embedded-darwin-arm64@1.97.2:
+ resolution: {integrity: sha512-xcsZNnU1XZh21RE/71OOwNqPVcGBU0qT9A4k4QirdA34+ts9cDIaR6W6lgHOBR/Bnnu6w6hXJR4Xth7oFrefPA==}
+ engines: {node: '>=14.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ sass-embedded-darwin-x64@1.97.2:
+ resolution: {integrity: sha512-T/9DTMpychm6+H4slHCAsYJRJ6eM+9H9idKlBPliPrP4T8JdC2Cs+ZOsYqrObj6eOtAD0fGf+KgyNhnW3xVafA==}
+ engines: {node: '>=14.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ sass-embedded-linux-arm64@1.97.2:
+ resolution: {integrity: sha512-Wh+nQaFer9tyE5xBPv5murSUZE/+kIcg8MyL5uqww6be9Iq+UmZpcJM7LUk+q8klQ9LfTmoDSNFA74uBqxD6IA==}
+ engines: {node: '>=14.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: glibc
+
+ sass-embedded-linux-arm@1.97.2:
+ resolution: {integrity: sha512-yDRe1yifGHl6kibkDlRIJ2ZzAU03KJ1AIvsAh4dsIDgK5jx83bxZLV1ZDUv7a8KK/iV/80LZnxnu/92zp99cXQ==}
+ engines: {node: '>=14.0.0'}
+ cpu: [arm]
+ os: [linux]
+ libc: glibc
+
+ sass-embedded-linux-musl-arm64@1.97.2:
+ resolution: {integrity: sha512-NfUqZSjHwnHvpSa7nyNxbWfL5obDjNBqhHUYmqbHUcmqBpFfHIQsUPgXME9DKn1yBlBc3mWnzMxRoucdYTzd2Q==}
+ engines: {node: '>=14.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: musl
+
+ sass-embedded-linux-musl-arm@1.97.2:
+ resolution: {integrity: sha512-GIO6xfAtahJAWItvsXZ3MD1HM6s8cKtV1/HL088aUpKJaw/2XjTCveiOO2AdgMpLNztmq9DZ1lx5X5JjqhS45g==}
+ engines: {node: '>=14.0.0'}
+ cpu: [arm]
+ os: [linux]
+ libc: musl
+
+ sass-embedded-linux-musl-riscv64@1.97.2:
+ resolution: {integrity: sha512-qtM4dJ5gLfvyTZ3QencfNbsTEShIWImSEpkThz+Y2nsCMbcMP7/jYOA03UWgPfEOKSehQQ7EIau7ncbFNoDNPQ==}
+ engines: {node: '>=14.0.0'}
+ cpu: [riscv64]
+ os: [linux]
+ libc: musl
+
+ sass-embedded-linux-musl-x64@1.97.2:
+ resolution: {integrity: sha512-ZAxYOdmexcnxGnzdsDjYmNe3jGj+XW3/pF/n7e7r8y+5c6D2CQRrCUdapLgaqPt1edOPQIlQEZF8q5j6ng21yw==}
+ engines: {node: '>=14.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: musl
+
+ sass-embedded-linux-riscv64@1.97.2:
+ resolution: {integrity: sha512-reVwa9ZFEAOChXpDyNB3nNHHyAkPMD+FTctQKECqKiVJnIzv2EaFF6/t0wzyvPgBKeatA8jszAIeOkkOzbYVkQ==}
+ engines: {node: '>=14.0.0'}
+ cpu: [riscv64]
+ os: [linux]
+ libc: glibc
+
+ sass-embedded-linux-x64@1.97.2:
+ resolution: {integrity: sha512-bvAdZQsX3jDBv6m4emaU2OMTpN0KndzTAMgJZZrKUgiC0qxBmBqbJG06Oj/lOCoXGCxAvUOheVYpezRTF+Feog==}
+ engines: {node: '>=14.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: glibc
+
+ sass-embedded-unknown-all@1.97.2:
+ resolution: {integrity: sha512-86tcYwohjPgSZtgeU9K4LikrKBJNf8ZW/vfsFbdzsRlvc73IykiqanufwQi5qIul0YHuu9lZtDWyWxM2dH/Rsg==}
+ os: ['!android', '!darwin', '!linux', '!win32']
+
+ sass-embedded-win32-arm64@1.97.2:
+ resolution: {integrity: sha512-Cv28q8qNjAjZfqfzTrQvKf4JjsZ6EOQ5FxyHUQQeNzm73R86nd/8ozDa1Vmn79Hq0kwM15OCM9epanDuTG1ksA==}
+ engines: {node: '>=14.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ sass-embedded-win32-x64@1.97.2:
+ resolution: {integrity: sha512-DVxLxkeDCGIYeyHLAvWW3yy9sy5Ruk5p472QWiyfyyG1G1ASAR8fgfIY5pT0vE6Rv+VAKVLwF3WTspUYu7S1/Q==}
+ engines: {node: '>=14.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ sass-embedded@1.97.2:
+ resolution: {integrity: sha512-lKJcskySwAtJ4QRirKrikrWMFa2niAuaGenY2ElHjd55IwHUiur5IdKu6R1hEmGYMs4Qm+6rlRW0RvuAkmcryg==}
+ engines: {node: '>=16.0.0'}
+ hasBin: true
+
+ sass@1.97.2:
+ resolution: {integrity: sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==}
+ engines: {node: '>=14.0.0'}
+ hasBin: true
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ supports-color@8.1.1:
+ resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
+ engines: {node: '>=10'}
+
+ sync-child-process@1.0.2:
+ resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==}
+ engines: {node: '>=16.0.0'}
+
+ sync-message-port@1.1.3:
+ resolution: {integrity: sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==}
+ engines: {node: '>=16.0.0'}
+
+ tailwindcss@4.1.18:
+ resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
+
+ tapable@2.3.0:
+ resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
+ engines: {node: '>=6'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ varint@6.0.0:
+ resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==}
+
+ vite@5.4.21:
+ resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || >=20.0.0
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.4.0
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+
+ vscode-uri@3.1.0:
+ resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
+
+ vue-demi@0.14.10:
+ resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
+ engines: {node: '>=12'}
+ hasBin: true
+ peerDependencies:
+ '@vue/composition-api': ^1.0.0-rc.1
+ vue: ^3.0.0-0 || ^2.6.0
+ peerDependenciesMeta:
+ '@vue/composition-api':
+ optional: true
+
+ vue-i18n@9.14.5:
+ resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==}
+ engines: {node: '>= 16'}
+ deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html
+ peerDependencies:
+ vue: ^3.0.0
+
+ vue-router@4.6.4:
+ resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
+ peerDependencies:
+ vue: ^3.5.0
+
+ vue-tsc@2.2.12:
+ resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==}
+ hasBin: true
+ peerDependencies:
+ typescript: '>=5.0.0'
+
+ vue@3.5.26:
+ resolution: {integrity: sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+snapshots:
+
+ '@alloc/quick-lru@5.2.0': {}
+
+ '@babel/helper-string-parser@7.27.1': {}
+
+ '@babel/helper-validator-identifier@7.28.5': {}
+
+ '@babel/parser@7.28.6':
+ dependencies:
+ '@babel/types': 7.28.6
+
+ '@babel/types@7.28.6':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+
+ '@bufbuild/protobuf@2.10.2': {}
+
+ '@ctrl/tinycolor@3.6.1': {}
+
+ '@element-plus/icons-vue@2.3.2(vue@3.5.26(typescript@5.9.3))':
+ dependencies:
+ vue: 3.5.26(typescript@5.9.3)
+
+ '@esbuild/aix-ppc64@0.21.5':
+ optional: true
+
+ '@esbuild/android-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/android-arm@0.21.5':
+ optional: true
+
+ '@esbuild/android-x64@0.21.5':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/darwin-x64@0.21.5':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-arm@0.21.5':
+ optional: true
+
+ '@esbuild/linux-ia32@0.21.5':
+ optional: true
+
+ '@esbuild/linux-loong64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.21.5':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-s390x@0.21.5':
+ optional: true
+
+ '@esbuild/linux-x64@0.21.5':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.21.5':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.21.5':
+ optional: true
+
+ '@esbuild/sunos-x64@0.21.5':
+ optional: true
+
+ '@esbuild/win32-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/win32-ia32@0.21.5':
+ optional: true
+
+ '@esbuild/win32-x64@0.21.5':
+ optional: true
+
+ '@ffmpeg/ffmpeg@0.12.15':
+ dependencies:
+ '@ffmpeg/types': 0.12.4
+
+ '@ffmpeg/types@0.12.4': {}
+
+ '@ffmpeg/util@0.12.2': {}
+
+ '@floating-ui/core@1.7.3':
+ dependencies:
+ '@floating-ui/utils': 0.2.10
+
+ '@floating-ui/dom@1.7.4':
+ dependencies:
+ '@floating-ui/core': 1.7.3
+ '@floating-ui/utils': 0.2.10
+
+ '@floating-ui/utils@0.2.10': {}
+
+ '@intlify/core-base@9.14.5':
+ dependencies:
+ '@intlify/message-compiler': 9.14.5
+ '@intlify/shared': 9.14.5
+
+ '@intlify/message-compiler@9.14.5':
+ dependencies:
+ '@intlify/shared': 9.14.5
+ source-map-js: 1.2.1
+
+ '@intlify/shared@9.14.5': {}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@parcel/watcher-android-arm64@2.5.4':
+ optional: true
+
+ '@parcel/watcher-darwin-arm64@2.5.4':
+ optional: true
+
+ '@parcel/watcher-darwin-x64@2.5.4':
+ optional: true
+
+ '@parcel/watcher-freebsd-x64@2.5.4':
+ optional: true
+
+ '@parcel/watcher-linux-arm-glibc@2.5.4':
+ optional: true
+
+ '@parcel/watcher-linux-arm-musl@2.5.4':
+ optional: true
+
+ '@parcel/watcher-linux-arm64-glibc@2.5.4':
+ optional: true
+
+ '@parcel/watcher-linux-arm64-musl@2.5.4':
+ optional: true
+
+ '@parcel/watcher-linux-x64-glibc@2.5.4':
+ optional: true
+
+ '@parcel/watcher-linux-x64-musl@2.5.4':
+ optional: true
+
+ '@parcel/watcher-win32-arm64@2.5.4':
+ optional: true
+
+ '@parcel/watcher-win32-ia32@2.5.4':
+ optional: true
+
+ '@parcel/watcher-win32-x64@2.5.4':
+ optional: true
+
+ '@parcel/watcher@2.5.4':
+ dependencies:
+ detect-libc: 2.1.2
+ is-glob: 4.0.3
+ node-addon-api: 7.1.1
+ picomatch: 4.0.3
+ optionalDependencies:
+ '@parcel/watcher-android-arm64': 2.5.4
+ '@parcel/watcher-darwin-arm64': 2.5.4
+ '@parcel/watcher-darwin-x64': 2.5.4
+ '@parcel/watcher-freebsd-x64': 2.5.4
+ '@parcel/watcher-linux-arm-glibc': 2.5.4
+ '@parcel/watcher-linux-arm-musl': 2.5.4
+ '@parcel/watcher-linux-arm64-glibc': 2.5.4
+ '@parcel/watcher-linux-arm64-musl': 2.5.4
+ '@parcel/watcher-linux-x64-glibc': 2.5.4
+ '@parcel/watcher-linux-x64-musl': 2.5.4
+ '@parcel/watcher-win32-arm64': 2.5.4
+ '@parcel/watcher-win32-ia32': 2.5.4
+ '@parcel/watcher-win32-x64': 2.5.4
+ optional: true
+
+ '@rollup/rollup-android-arm-eabi@4.55.1':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.55.1':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.55.1':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.55.1':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.55.1':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.55.1':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.55.1':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.55.1':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.55.1':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.55.1':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.55.1':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.55.1':
+ optional: true
+
+ '@sxzz/popperjs-es@2.11.7': {}
+
+ '@tailwindcss/node@4.1.18':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.18.4
+ jiti: 2.6.1
+ lightningcss: 1.30.2
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.1.18
+
+ '@tailwindcss/oxide-android-arm64@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide@4.1.18':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.1.18
+ '@tailwindcss/oxide-darwin-arm64': 4.1.18
+ '@tailwindcss/oxide-darwin-x64': 4.1.18
+ '@tailwindcss/oxide-freebsd-x64': 4.1.18
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18
+ '@tailwindcss/oxide-linux-arm64-musl': 4.1.18
+ '@tailwindcss/oxide-linux-x64-gnu': 4.1.18
+ '@tailwindcss/oxide-linux-x64-musl': 4.1.18
+ '@tailwindcss/oxide-wasm32-wasi': 4.1.18
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
+ '@tailwindcss/oxide-win32-x64-msvc': 4.1.18
+
+ '@tailwindcss/postcss@4.1.18':
+ dependencies:
+ '@alloc/quick-lru': 5.2.0
+ '@tailwindcss/node': 4.1.18
+ '@tailwindcss/oxide': 4.1.18
+ postcss: 8.5.6
+ tailwindcss: 4.1.18
+
+ '@types/estree@1.0.8': {}
+
+ '@types/lodash-es@4.17.12':
+ dependencies:
+ '@types/lodash': 4.17.23
+
+ '@types/lodash@4.17.23': {}
+
+ '@types/node@20.19.28':
+ dependencies:
+ undici-types: 6.21.0
+
+ '@types/web-bluetooth@0.0.20': {}
+
+ '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@20.19.28)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2))(vue@3.5.26(typescript@5.9.3))':
+ dependencies:
+ vite: 5.4.21(@types/node@20.19.28)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)
+ vue: 3.5.26(typescript@5.9.3)
+
+ '@volar/language-core@2.4.15':
+ dependencies:
+ '@volar/source-map': 2.4.15
+
+ '@volar/source-map@2.4.15': {}
+
+ '@volar/typescript@2.4.15':
+ dependencies:
+ '@volar/language-core': 2.4.15
+ path-browserify: 1.0.1
+ vscode-uri: 3.1.0
+
+ '@vue/compiler-core@3.5.26':
+ dependencies:
+ '@babel/parser': 7.28.6
+ '@vue/shared': 3.5.26
+ entities: 7.0.0
+ estree-walker: 2.0.2
+ source-map-js: 1.2.1
+
+ '@vue/compiler-dom@3.5.26':
+ dependencies:
+ '@vue/compiler-core': 3.5.26
+ '@vue/shared': 3.5.26
+
+ '@vue/compiler-sfc@3.5.26':
+ dependencies:
+ '@babel/parser': 7.28.6
+ '@vue/compiler-core': 3.5.26
+ '@vue/compiler-dom': 3.5.26
+ '@vue/compiler-ssr': 3.5.26
+ '@vue/shared': 3.5.26
+ estree-walker: 2.0.2
+ magic-string: 0.30.21
+ postcss: 8.5.6
+ source-map-js: 1.2.1
+
+ '@vue/compiler-ssr@3.5.26':
+ dependencies:
+ '@vue/compiler-dom': 3.5.26
+ '@vue/shared': 3.5.26
+
+ '@vue/compiler-vue2@2.7.16':
+ dependencies:
+ de-indent: 1.0.2
+ he: 1.2.0
+
+ '@vue/devtools-api@6.6.4': {}
+
+ '@vue/language-core@2.2.12(typescript@5.9.3)':
+ dependencies:
+ '@volar/language-core': 2.4.15
+ '@vue/compiler-dom': 3.5.26
+ '@vue/compiler-vue2': 2.7.16
+ '@vue/shared': 3.5.26
+ alien-signals: 1.0.13
+ minimatch: 9.0.5
+ muggle-string: 0.4.1
+ path-browserify: 1.0.1
+ optionalDependencies:
+ typescript: 5.9.3
+
+ '@vue/reactivity@3.5.26':
+ dependencies:
+ '@vue/shared': 3.5.26
+
+ '@vue/runtime-core@3.5.26':
+ dependencies:
+ '@vue/reactivity': 3.5.26
+ '@vue/shared': 3.5.26
+
+ '@vue/runtime-dom@3.5.26':
+ dependencies:
+ '@vue/reactivity': 3.5.26
+ '@vue/runtime-core': 3.5.26
+ '@vue/shared': 3.5.26
+ csstype: 3.2.3
+
+ '@vue/server-renderer@3.5.26(vue@3.5.26(typescript@5.9.3))':
+ dependencies:
+ '@vue/compiler-ssr': 3.5.26
+ '@vue/shared': 3.5.26
+ vue: 3.5.26(typescript@5.9.3)
+
+ '@vue/shared@3.5.26': {}
+
+ '@vue/tsconfig@0.5.1': {}
+
+ '@vueuse/core@10.11.1(vue@3.5.26(typescript@5.9.3))':
+ dependencies:
+ '@types/web-bluetooth': 0.0.20
+ '@vueuse/metadata': 10.11.1
+ '@vueuse/shared': 10.11.1(vue@3.5.26(typescript@5.9.3))
+ vue-demi: 0.14.10(vue@3.5.26(typescript@5.9.3))
+ transitivePeerDependencies:
+ - '@vue/composition-api'
+ - vue
+
+ '@vueuse/metadata@10.11.1': {}
+
+ '@vueuse/shared@10.11.1(vue@3.5.26(typescript@5.9.3))':
+ dependencies:
+ vue-demi: 0.14.10(vue@3.5.26(typescript@5.9.3))
+ transitivePeerDependencies:
+ - '@vue/composition-api'
+ - vue
+
+ alien-signals@1.0.13: {}
+
+ async-validator@4.2.5: {}
+
+ asynckit@0.4.0: {}
+
+ autoprefixer@10.4.23(postcss@8.5.6):
+ dependencies:
+ browserslist: 4.28.1
+ caniuse-lite: 1.0.30001764
+ fraction.js: 5.3.4
+ picocolors: 1.1.1
+ postcss: 8.5.6
+ postcss-value-parser: 4.2.0
+
+ axios@1.13.2:
+ dependencies:
+ follow-redirects: 1.15.11
+ form-data: 4.0.5
+ proxy-from-env: 1.1.0
+ transitivePeerDependencies:
+ - debug
+
+ balanced-match@1.0.2: {}
+
+ baseline-browser-mapping@2.9.14: {}
+
+ brace-expansion@2.0.2:
+ dependencies:
+ balanced-match: 1.0.2
+
+ browserslist@4.28.1:
+ dependencies:
+ baseline-browser-mapping: 2.9.14
+ caniuse-lite: 1.0.30001764
+ electron-to-chromium: 1.5.267
+ node-releases: 2.0.27
+ update-browserslist-db: 1.2.3(browserslist@4.28.1)
+
+ buffer-builder@0.2.0: {}
+
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ caniuse-lite@1.0.30001764: {}
+
+ chokidar@4.0.3:
+ dependencies:
+ readdirp: 4.1.2
+ optional: true
+
+ colorjs.io@0.5.2: {}
+
+ combined-stream@1.0.8:
+ dependencies:
+ delayed-stream: 1.0.0
+
+ csstype@3.2.3: {}
+
+ dayjs@1.11.19: {}
+
+ de-indent@1.0.2: {}
+
+ delayed-stream@1.0.0: {}
+
+ detect-libc@2.1.2: {}
+
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ electron-to-chromium@1.5.267: {}
+
+ element-plus@2.13.1(vue@3.5.26(typescript@5.9.3)):
+ dependencies:
+ '@ctrl/tinycolor': 3.6.1
+ '@element-plus/icons-vue': 2.3.2(vue@3.5.26(typescript@5.9.3))
+ '@floating-ui/dom': 1.7.4
+ '@popperjs/core': '@sxzz/popperjs-es@2.11.7'
+ '@types/lodash': 4.17.23
+ '@types/lodash-es': 4.17.12
+ '@vueuse/core': 10.11.1(vue@3.5.26(typescript@5.9.3))
+ async-validator: 4.2.5
+ dayjs: 1.11.19
+ lodash: 4.17.21
+ lodash-es: 4.17.22
+ lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.22)(lodash@4.17.21)
+ memoize-one: 6.0.0
+ normalize-wheel-es: 1.2.0
+ vue: 3.5.26(typescript@5.9.3)
+ transitivePeerDependencies:
+ - '@vue/composition-api'
+
+ enhanced-resolve@5.18.4:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.0
+
+ entities@7.0.0: {}
+
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
+ es-object-atoms@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+
+ es-set-tostringtag@2.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.2
+
+ esbuild@0.21.5:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.21.5
+ '@esbuild/android-arm': 0.21.5
+ '@esbuild/android-arm64': 0.21.5
+ '@esbuild/android-x64': 0.21.5
+ '@esbuild/darwin-arm64': 0.21.5
+ '@esbuild/darwin-x64': 0.21.5
+ '@esbuild/freebsd-arm64': 0.21.5
+ '@esbuild/freebsd-x64': 0.21.5
+ '@esbuild/linux-arm': 0.21.5
+ '@esbuild/linux-arm64': 0.21.5
+ '@esbuild/linux-ia32': 0.21.5
+ '@esbuild/linux-loong64': 0.21.5
+ '@esbuild/linux-mips64el': 0.21.5
+ '@esbuild/linux-ppc64': 0.21.5
+ '@esbuild/linux-riscv64': 0.21.5
+ '@esbuild/linux-s390x': 0.21.5
+ '@esbuild/linux-x64': 0.21.5
+ '@esbuild/netbsd-x64': 0.21.5
+ '@esbuild/openbsd-x64': 0.21.5
+ '@esbuild/sunos-x64': 0.21.5
+ '@esbuild/win32-arm64': 0.21.5
+ '@esbuild/win32-ia32': 0.21.5
+ '@esbuild/win32-x64': 0.21.5
+
+ escalade@3.2.0: {}
+
+ estree-walker@2.0.2: {}
+
+ follow-redirects@1.15.11: {}
+
+ form-data@4.0.5:
+ dependencies:
+ asynckit: 0.4.0
+ combined-stream: 1.0.8
+ es-set-tostringtag: 2.1.0
+ hasown: 2.0.2
+ mime-types: 2.1.35
+
+ fraction.js@5.3.4: {}
+
+ fsevents@2.3.3:
+ optional: true
+
+ function-bind@1.1.2: {}
+
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.1
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.2
+ math-intrinsics: 1.1.0
+
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.1
+
+ gopd@1.2.0: {}
+
+ graceful-fs@4.2.11: {}
+
+ has-flag@4.0.0: {}
+
+ has-symbols@1.1.0: {}
+
+ has-tostringtag@1.0.2:
+ dependencies:
+ has-symbols: 1.1.0
+
+ hasown@2.0.2:
+ dependencies:
+ function-bind: 1.1.2
+
+ he@1.2.0: {}
+
+ immutable@5.1.4: {}
+
+ is-extglob@2.1.1:
+ optional: true
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+ optional: true
+
+ jiti@2.6.1: {}
+
+ lightningcss-android-arm64@1.30.2:
+ optional: true
+
+ lightningcss-darwin-arm64@1.30.2:
+ optional: true
+
+ lightningcss-darwin-x64@1.30.2:
+ optional: true
+
+ lightningcss-freebsd-x64@1.30.2:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.30.2:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.30.2:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.30.2:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.30.2:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.30.2:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.30.2:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.30.2:
+ optional: true
+
+ lightningcss@1.30.2:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.30.2
+ lightningcss-darwin-arm64: 1.30.2
+ lightningcss-darwin-x64: 1.30.2
+ lightningcss-freebsd-x64: 1.30.2
+ lightningcss-linux-arm-gnueabihf: 1.30.2
+ lightningcss-linux-arm64-gnu: 1.30.2
+ lightningcss-linux-arm64-musl: 1.30.2
+ lightningcss-linux-x64-gnu: 1.30.2
+ lightningcss-linux-x64-musl: 1.30.2
+ lightningcss-win32-arm64-msvc: 1.30.2
+ lightningcss-win32-x64-msvc: 1.30.2
+
+ lodash-es@4.17.22: {}
+
+ lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.22)(lodash@4.17.21):
+ dependencies:
+ '@types/lodash-es': 4.17.12
+ lodash: 4.17.21
+ lodash-es: 4.17.22
+
+ lodash@4.17.21: {}
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ math-intrinsics@1.1.0: {}
+
+ memoize-one@6.0.0: {}
+
+ mime-db@1.52.0: {}
+
+ mime-types@2.1.35:
+ dependencies:
+ mime-db: 1.52.0
+
+ minimatch@9.0.5:
+ dependencies:
+ brace-expansion: 2.0.2
+
+ muggle-string@0.4.1: {}
+
+ nanoid@3.3.11: {}
+
+ node-addon-api@7.1.1:
+ optional: true
+
+ node-releases@2.0.27: {}
+
+ normalize-wheel-es@1.2.0: {}
+
+ path-browserify@1.0.1: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.3:
+ optional: true
+
+ pinia@2.3.1(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)):
+ dependencies:
+ '@vue/devtools-api': 6.6.4
+ vue: 3.5.26(typescript@5.9.3)
+ vue-demi: 0.14.10(vue@3.5.26(typescript@5.9.3))
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - '@vue/composition-api'
+
+ postcss-value-parser@4.2.0: {}
+
+ postcss@8.5.6:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ proxy-from-env@1.1.0: {}
+
+ readdirp@4.1.2:
+ optional: true
+
+ rollup@4.55.1:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.55.1
+ '@rollup/rollup-android-arm64': 4.55.1
+ '@rollup/rollup-darwin-arm64': 4.55.1
+ '@rollup/rollup-darwin-x64': 4.55.1
+ '@rollup/rollup-freebsd-arm64': 4.55.1
+ '@rollup/rollup-freebsd-x64': 4.55.1
+ '@rollup/rollup-linux-arm-gnueabihf': 4.55.1
+ '@rollup/rollup-linux-arm-musleabihf': 4.55.1
+ '@rollup/rollup-linux-arm64-gnu': 4.55.1
+ '@rollup/rollup-linux-arm64-musl': 4.55.1
+ '@rollup/rollup-linux-loong64-gnu': 4.55.1
+ '@rollup/rollup-linux-loong64-musl': 4.55.1
+ '@rollup/rollup-linux-ppc64-gnu': 4.55.1
+ '@rollup/rollup-linux-ppc64-musl': 4.55.1
+ '@rollup/rollup-linux-riscv64-gnu': 4.55.1
+ '@rollup/rollup-linux-riscv64-musl': 4.55.1
+ '@rollup/rollup-linux-s390x-gnu': 4.55.1
+ '@rollup/rollup-linux-x64-gnu': 4.55.1
+ '@rollup/rollup-linux-x64-musl': 4.55.1
+ '@rollup/rollup-openbsd-x64': 4.55.1
+ '@rollup/rollup-openharmony-arm64': 4.55.1
+ '@rollup/rollup-win32-arm64-msvc': 4.55.1
+ '@rollup/rollup-win32-ia32-msvc': 4.55.1
+ '@rollup/rollup-win32-x64-gnu': 4.55.1
+ '@rollup/rollup-win32-x64-msvc': 4.55.1
+ fsevents: 2.3.3
+
+ rxjs@7.8.2:
+ dependencies:
+ tslib: 2.8.1
+
+ sass-embedded-all-unknown@1.97.2:
+ dependencies:
+ sass: 1.97.2
+ optional: true
+
+ sass-embedded-android-arm64@1.97.2:
+ optional: true
+
+ sass-embedded-android-arm@1.97.2:
+ optional: true
+
+ sass-embedded-android-riscv64@1.97.2:
+ optional: true
+
+ sass-embedded-android-x64@1.97.2:
+ optional: true
+
+ sass-embedded-darwin-arm64@1.97.2:
+ optional: true
+
+ sass-embedded-darwin-x64@1.97.2:
+ optional: true
+
+ sass-embedded-linux-arm64@1.97.2:
+ optional: true
+
+ sass-embedded-linux-arm@1.97.2:
+ optional: true
+
+ sass-embedded-linux-musl-arm64@1.97.2:
+ optional: true
+
+ sass-embedded-linux-musl-arm@1.97.2:
+ optional: true
+
+ sass-embedded-linux-musl-riscv64@1.97.2:
+ optional: true
+
+ sass-embedded-linux-musl-x64@1.97.2:
+ optional: true
+
+ sass-embedded-linux-riscv64@1.97.2:
+ optional: true
+
+ sass-embedded-linux-x64@1.97.2:
+ optional: true
+
+ sass-embedded-unknown-all@1.97.2:
+ dependencies:
+ sass: 1.97.2
+ optional: true
+
+ sass-embedded-win32-arm64@1.97.2:
+ optional: true
+
+ sass-embedded-win32-x64@1.97.2:
+ optional: true
+
+ sass-embedded@1.97.2:
+ dependencies:
+ '@bufbuild/protobuf': 2.10.2
+ buffer-builder: 0.2.0
+ colorjs.io: 0.5.2
+ immutable: 5.1.4
+ rxjs: 7.8.2
+ supports-color: 8.1.1
+ sync-child-process: 1.0.2
+ varint: 6.0.0
+ optionalDependencies:
+ sass-embedded-all-unknown: 1.97.2
+ sass-embedded-android-arm: 1.97.2
+ sass-embedded-android-arm64: 1.97.2
+ sass-embedded-android-riscv64: 1.97.2
+ sass-embedded-android-x64: 1.97.2
+ sass-embedded-darwin-arm64: 1.97.2
+ sass-embedded-darwin-x64: 1.97.2
+ sass-embedded-linux-arm: 1.97.2
+ sass-embedded-linux-arm64: 1.97.2
+ sass-embedded-linux-musl-arm: 1.97.2
+ sass-embedded-linux-musl-arm64: 1.97.2
+ sass-embedded-linux-musl-riscv64: 1.97.2
+ sass-embedded-linux-musl-x64: 1.97.2
+ sass-embedded-linux-riscv64: 1.97.2
+ sass-embedded-linux-x64: 1.97.2
+ sass-embedded-unknown-all: 1.97.2
+ sass-embedded-win32-arm64: 1.97.2
+ sass-embedded-win32-x64: 1.97.2
+
+ sass@1.97.2:
+ dependencies:
+ chokidar: 4.0.3
+ immutable: 5.1.4
+ source-map-js: 1.2.1
+ optionalDependencies:
+ '@parcel/watcher': 2.5.4
+ optional: true
+
+ source-map-js@1.2.1: {}
+
+ supports-color@8.1.1:
+ dependencies:
+ has-flag: 4.0.0
+
+ sync-child-process@1.0.2:
+ dependencies:
+ sync-message-port: 1.1.3
+
+ sync-message-port@1.1.3: {}
+
+ tailwindcss@4.1.18: {}
+
+ tapable@2.3.0: {}
+
+ tslib@2.8.1: {}
+
+ typescript@5.9.3: {}
+
+ undici-types@6.21.0: {}
+
+ update-browserslist-db@1.2.3(browserslist@4.28.1):
+ dependencies:
+ browserslist: 4.28.1
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ varint@6.0.0: {}
+
+ vite@5.4.21(@types/node@20.19.28)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2):
+ dependencies:
+ esbuild: 0.21.5
+ postcss: 8.5.6
+ rollup: 4.55.1
+ optionalDependencies:
+ '@types/node': 20.19.28
+ fsevents: 2.3.3
+ lightningcss: 1.30.2
+ sass: 1.97.2
+ sass-embedded: 1.97.2
+
+ vscode-uri@3.1.0: {}
+
+ vue-demi@0.14.10(vue@3.5.26(typescript@5.9.3)):
+ dependencies:
+ vue: 3.5.26(typescript@5.9.3)
+
+ vue-i18n@9.14.5(vue@3.5.26(typescript@5.9.3)):
+ dependencies:
+ '@intlify/core-base': 9.14.5
+ '@intlify/shared': 9.14.5
+ '@vue/devtools-api': 6.6.4
+ vue: 3.5.26(typescript@5.9.3)
+
+ vue-router@4.6.4(vue@3.5.26(typescript@5.9.3)):
+ dependencies:
+ '@vue/devtools-api': 6.6.4
+ vue: 3.5.26(typescript@5.9.3)
+
+ vue-tsc@2.2.12(typescript@5.9.3):
+ dependencies:
+ '@volar/typescript': 2.4.15
+ '@vue/language-core': 2.2.12(typescript@5.9.3)
+ typescript: 5.9.3
+
+ vue@3.5.26(typescript@5.9.3):
+ dependencies:
+ '@vue/compiler-dom': 3.5.26
+ '@vue/compiler-sfc': 3.5.26
+ '@vue/runtime-dom': 3.5.26
+ '@vue/server-renderer': 3.5.26(vue@3.5.26(typescript@5.9.3))
+ '@vue/shared': 3.5.26
+ optionalDependencies:
+ typescript: 5.9.3
diff --git a/web/public/ffmpeg/ffmpeg-core.js b/web/public/ffmpeg/ffmpeg-core.js
new file mode 100644
index 0000000..f027a2b
--- /dev/null
+++ b/web/public/ffmpeg/ffmpeg-core.js
@@ -0,0 +1,21 @@
+
+var createFFmpegCore = (() => {
+ var _scriptDir = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined;
+
+ return (
+function(createFFmpegCore = {}) {
+
+var 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{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>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=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=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=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;i0){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>>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;i0||position+length{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=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;i40){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>>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{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=""})},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{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{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{for(var i=0;i{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{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>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>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>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>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>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>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>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>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>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>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 numMAX_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>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.length0?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();
+
+
+ return createFFmpegCore.ready
+}
+
+);
+})();
+if (typeof exports === 'object' && typeof module === 'object')
+ module.exports = createFFmpegCore;
+else if (typeof define === 'function' && define['amd'])
+ define([], function() { return createFFmpegCore; });
+else if (typeof exports === 'object')
+ exports["createFFmpegCore"] = createFFmpegCore;
diff --git a/web/public/ffmpeg/ffmpeg-core.wasm b/web/public/ffmpeg/ffmpeg-core.wasm
new file mode 100644
index 0000000..246b0fe
Binary files /dev/null and b/web/public/ffmpeg/ffmpeg-core.wasm differ
diff --git a/web/src/App.vue b/web/src/App.vue
new file mode 100644
index 0000000..8bf1cfd
--- /dev/null
+++ b/web/src/App.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
diff --git a/web/src/api/ai.ts b/web/src/api/ai.ts
new file mode 100644
index 0000000..5fbaab9
--- /dev/null
+++ b/web/src/api/ai.ts
@@ -0,0 +1,36 @@
+import type {
+ AIServiceConfig,
+ AIServiceType,
+ CreateAIConfigRequest,
+ TestConnectionRequest,
+ UpdateAIConfigRequest
+} from '../types/ai'
+import request from '../utils/request'
+
+export const aiAPI = {
+ list(serviceType?: AIServiceType) {
+ return request.get('/ai-configs', {
+ params: { service_type: serviceType }
+ })
+ },
+
+ create(data: CreateAIConfigRequest) {
+ return request.post('/ai-configs', data)
+ },
+
+ get(id: number) {
+ return request.get(`/ai-configs/${id}`)
+ },
+
+ update(id: number, data: UpdateAIConfigRequest) {
+ return request.put(`/ai-configs/${id}`, data)
+ },
+
+ delete(id: number) {
+ return request.delete(`/ai-configs/${id}`)
+ },
+
+ testConnection(data: TestConnectionRequest) {
+ return request.post('/ai-configs/test', data)
+ }
+}
diff --git a/web/src/api/asset.ts b/web/src/api/asset.ts
new file mode 100644
index 0000000..3e86d87
--- /dev/null
+++ b/web/src/api/asset.ts
@@ -0,0 +1,47 @@
+import type {
+ Asset,
+ AssetCollection,
+ AssetTag,
+ CreateAssetRequest,
+ ListAssetsParams,
+ UpdateAssetRequest
+} from '../types/asset'
+import request from '../utils/request'
+
+export const assetAPI = {
+ createAsset(data: CreateAssetRequest) {
+ return request.post('/assets', data)
+ },
+
+ updateAsset(id: number, data: UpdateAssetRequest) {
+ return request.put(`/assets/${id}`, data)
+ },
+
+ getAsset(id: number) {
+ return request.get(`/assets/${id}`)
+ },
+
+ listAssets(params: ListAssetsParams) {
+ return request.get<{
+ items: Asset[]
+ pagination: {
+ page: number
+ page_size: number
+ total: number
+ total_pages: number
+ }
+ }>('/assets', { params })
+ },
+
+ deleteAsset(id: number) {
+ return request.delete(`/assets/${id}`)
+ },
+
+ importFromImage(imageGenId: number) {
+ return request.post(`/assets/import/image/${imageGenId}`)
+ },
+
+ importFromVideo(videoGenId: number) {
+ return request.post(`/assets/import/video/${videoGenId}`)
+ }
+}
diff --git a/web/src/api/character-library.ts b/web/src/api/character-library.ts
new file mode 100644
index 0000000..f3064e4
--- /dev/null
+++ b/web/src/api/character-library.ts
@@ -0,0 +1,109 @@
+import request from '../utils/request'
+
+export interface CharacterLibraryItem {
+ id: string
+ name: string
+ category?: string
+ image_url: string
+ description?: string
+ tags?: string
+ source_type: string
+ created_at: string
+ updated_at: string
+}
+
+export interface CreateLibraryItemRequest {
+ name: string
+ category?: string
+ image_url: string
+ description?: string
+ tags?: string
+ source_type?: string
+}
+
+export interface CharacterLibraryQuery {
+ page?: number
+ page_size?: number
+ category?: string
+ source_type?: string
+ keyword?: string
+}
+
+export const characterLibraryAPI = {
+ // 获取角色库列表
+ list(params?: CharacterLibraryQuery) {
+ return request.get<{
+ items: CharacterLibraryItem[]
+ pagination: {
+ page: number
+ page_size: number
+ total: number
+ total_pages: number
+ }
+ }>('/character-library', { params })
+ },
+
+ // 创建角色库项
+ create(data: CreateLibraryItemRequest) {
+ return request.post('/character-library', data)
+ },
+
+ // 获取角色库项详情
+ get(id: string) {
+ return request.get(`/character-library/${id}`)
+ },
+
+ // 删除角色库项
+ delete(id: string) {
+ return request.delete(`/character-library/${id}`)
+ },
+
+ // 上传角色图片
+ uploadCharacterImage(characterId: string, imageUrl: string) {
+ return request.put(`/characters/${characterId}/image`, { image_url: imageUrl })
+ },
+
+ // 从角色库应用形象
+ applyFromLibrary(characterId: string, libraryItemId: string) {
+ return request.put(`/characters/${characterId}/image-from-library`, {
+ library_item_id: libraryItemId
+ })
+ },
+
+ // 将角色添加到角色库
+ addCharacterToLibrary(characterId: string, category?: string) {
+ return request.post(`/characters/${characterId}/add-to-library`, {
+ category
+ })
+ },
+
+ // AI生成角色形象
+ generateCharacterImage(characterId: string, model?: string) {
+ return request.post<{ image_url: string }>(`/characters/${characterId}/generate-image`, {
+ model
+ })
+ },
+
+ // 批量生成角色形象
+ batchGenerateCharacterImages(characterIds: string[], model?: string) {
+ return request.post<{ message: string; count: number }>('/characters/batch-generate-images', {
+ character_ids: characterIds,
+ model
+ })
+ },
+
+ // 更新角色信息
+ updateCharacter(characterId: number, data: {
+ name?: string
+ appearance?: string
+ personality?: string
+ description?: string
+ }) {
+ return request.put(`/characters/${characterId}`, data)
+ },
+
+ // 删除角色
+ deleteCharacter(characterId: number) {
+ return request.delete(`/characters/${characterId}`)
+ }
+}
diff --git a/web/src/api/drama.ts b/web/src/api/drama.ts
new file mode 100644
index 0000000..53dccfe
--- /dev/null
+++ b/web/src/api/drama.ts
@@ -0,0 +1,119 @@
+import type {
+ CreateDramaRequest,
+ Drama,
+ DramaListQuery,
+ DramaStats,
+ UpdateDramaRequest
+} from '../types/drama'
+import request from '../utils/request'
+
+export const dramaAPI = {
+ list(params?: DramaListQuery) {
+ return request.get<{
+ items: Drama[]
+ pagination: {
+ page: number
+ page_size: number
+ total: number
+ total_pages: number
+ }
+ }>('/dramas', { params })
+ },
+
+ create(data: CreateDramaRequest) {
+ return request.post('/dramas', data)
+ },
+
+ get(id: string) {
+ return request.get(`/dramas/${id}`)
+ },
+
+ update(id: string, data: UpdateDramaRequest) {
+ return request.put(`/dramas/${id}`, data)
+ },
+
+ delete(id: string) {
+ return request.delete(`/dramas/${id}`)
+ },
+
+ getStats() {
+ return request.get('/dramas/stats')
+ },
+
+ saveOutline(id: string, data: { title: string; summary: string; genre?: string; tags?: string[] }) {
+ return request.put(`/dramas/${id}/outline`, data)
+ },
+
+ getCharacters(dramaId: string) {
+ return request.get(`/dramas/${dramaId}/characters`)
+ },
+
+ saveCharacters(id: string, data: any[], episodeId?: string) {
+ return request.put(`/dramas/${id}/characters`, {
+ characters: data,
+ episode_id: episodeId ? parseInt(episodeId) : undefined
+ })
+ },
+
+ saveEpisodes(id: string, data: any[]) {
+ return request.put(`/dramas/${id}/episodes`, { episodes: data })
+ },
+
+ saveProgress(id: string, data: { current_step: string; step_data?: any }) {
+ return request.put(`/dramas/${id}/progress`, data)
+ },
+
+ generateStoryboard(episodeId: string) {
+ return request.post(`/episodes/${episodeId}/storyboards`)
+ },
+
+ getBackgrounds(episodeId: string) {
+ return request.get(`/images/episode/${episodeId}/backgrounds`)
+ },
+
+ extractBackgrounds(episodeId: string) {
+ return request.post<{ task_id: string; status: string; message: string }>(`/images/episode/${episodeId}/backgrounds/extract`)
+ },
+
+ batchGenerateBackgrounds(episodeId: string) {
+ return request.post(`/images/episode/${episodeId}/batch`)
+ },
+
+ generateSingleBackground(backgroundId: number, dramaId: string, prompt: string) {
+ return request.post('/images', {
+ background_id: backgroundId,
+ drama_id: dramaId,
+ prompt: prompt
+ })
+ },
+
+ getStoryboards(episodeId: string) {
+ return request.get(`/episodes/${episodeId}/storyboards`)
+ },
+
+ updateStoryboard(storyboardId: string, data: any) {
+ return request.put(`/storyboards/${storyboardId}`, data)
+ },
+
+ updateScene(sceneId: string, data: {
+ background_id?: string;
+ characters?: string[];
+ location?: string;
+ time?: string;
+ action?: string;
+ dialogue?: string;
+ description?: string;
+ duration?: number;
+ }) {
+ return request.put(`/scenes/${sceneId}`, data)
+ },
+
+ generateSceneImage(data: { scene_id: string; prompt?: string; model?: string }) {
+ return request.post('/scenes/generate-image', data)
+ },
+
+ // 完成集数制作(触发视频合成)
+ finalizeEpisode(episodeId: string, timelineData?: any) {
+ return request.post(`/episodes/${episodeId}/finalize`, timelineData || {})
+ }
+}
diff --git a/web/src/api/frame.ts b/web/src/api/frame.ts
new file mode 100644
index 0000000..40ed525
--- /dev/null
+++ b/web/src/api/frame.ts
@@ -0,0 +1,99 @@
+import request from '../utils/request'
+
+// 帧类型
+export type FrameType = 'first' | 'key' | 'last' | 'panel' | 'action'
+
+// 单帧提示词
+export interface SingleFramePrompt {
+ prompt: string
+ description: string
+}
+
+// 多帧提示词
+export interface MultiFramePrompt {
+ layout: string // horizontal_3, grid_2x2 等
+ frames: SingleFramePrompt[]
+}
+
+// 帧提示词响应
+export interface FramePromptResponse {
+ frame_type: FrameType
+ single_frame?: SingleFramePrompt
+ multi_frame?: MultiFramePrompt
+}
+
+// 生成帧提示词请求
+export interface GenerateFramePromptRequest {
+ frame_type: FrameType
+ panel_count?: number // 分镜板格数,默认3
+}
+
+/**
+ * 生成指定类型的帧提示词
+ */
+export function generateFramePrompt(
+ storyboardId: number,
+ data: GenerateFramePromptRequest
+): Promise {
+ return request.post(`/storyboards/${storyboardId}/frame-prompt`, data)
+}
+
+/**
+ * 生成首帧提示词
+ */
+export function generateFirstFrame(storyboardId: number): Promise {
+ return generateFramePrompt(storyboardId, { frame_type: 'first' })
+}
+
+/**
+ * 生成关键帧提示词
+ */
+export function generateKeyFrame(storyboardId: number): Promise {
+ return generateFramePrompt(storyboardId, { frame_type: 'key' })
+}
+
+/**
+ * 生成尾帧提示词
+ */
+export function generateLastFrame(storyboardId: number): Promise {
+ return generateFramePrompt(storyboardId, { frame_type: 'last' })
+}
+
+/**
+ * 生成分镜板(3格组合)
+ */
+export function generatePanelFrames(
+ storyboardId: number,
+ panelCount: number = 3
+): Promise {
+ return generateFramePrompt(storyboardId, {
+ frame_type: 'panel',
+ panel_count: panelCount
+ })
+}
+
+/**
+ * 生成动作序列(5格)
+ */
+export function generateActionSequence(storyboardId: number): Promise {
+ return generateFramePrompt(storyboardId, { frame_type: 'action' })
+}
+
+// 帧提示词记录(从数据库查询)
+export interface FramePromptRecord {
+ id: number
+ storyboard_id: number
+ frame_type: FrameType
+ prompt: string
+ description?: string
+ layout?: string
+ created_at: string
+ updated_at: string
+}
+
+/**
+ * 查询镜头的所有已生成帧提示词
+ */
+export function getStoryboardFramePrompts(storyboardId: number): Promise<{ frame_prompts: FramePromptRecord[] }> {
+ return request.get<{ frame_prompts: FramePromptRecord[] }>(`/storyboards/${storyboardId}/frame-prompts`)
+}
diff --git a/web/src/api/generation.ts b/web/src/api/generation.ts
new file mode 100644
index 0000000..7a2fac9
--- /dev/null
+++ b/web/src/api/generation.ts
@@ -0,0 +1,42 @@
+import type { Character, Episode } from '../types/drama'
+import type {
+ GenerateCharactersRequest,
+ GenerateEpisodesRequest,
+ GenerateOutlineRequest,
+ OutlineResult
+} from '../types/generation'
+import request from '../utils/request'
+
+export const generationAPI = {
+ generateOutline(data: GenerateOutlineRequest) {
+ return request.post('/generation/outline', data)
+ },
+
+ generateCharacters(data: GenerateCharactersRequest) {
+ return request.post<{ task_id: string; status: string; message: string }>('/generation/characters', data)
+ },
+
+ generateEpisodes(data: GenerateEpisodesRequest) {
+ return request.post('/generation/episodes', data)
+ },
+
+ generateStoryboard(episodeId: string) {
+ return request.post<{ task_id: string; status: string; message: string }>(`/episodes/${episodeId}/storyboards`)
+ },
+
+ getTaskStatus(taskId: string) {
+ return request.get<{
+ id: string
+ type: string
+ status: string
+ progress: number
+ message?: string
+ error?: string
+ result?: string
+ created_at: string
+ updated_at: string
+ completed_at?: string
+ }>(`/tasks/${taskId}`)
+ }
+
+}
diff --git a/web/src/api/image.ts b/web/src/api/image.ts
new file mode 100644
index 0000000..4ceb507
--- /dev/null
+++ b/web/src/api/image.ts
@@ -0,0 +1,40 @@
+import type {
+ GenerateImageRequest,
+ ImageGeneration,
+ ImageGenerationListParams
+} from '../types/image'
+import request from '../utils/request'
+
+export const imageAPI = {
+ generateImage(data: GenerateImageRequest) {
+ return request.post('/images', data)
+ },
+
+ generateForScene(sceneId: number) {
+ return request.post(`/images/scene/${sceneId}`)
+ },
+
+ batchGenerateForEpisode(episodeId: number) {
+ return request.post(`/images/episode/${episodeId}/batch`)
+ },
+
+ getImage(id: number) {
+ return request.get(`/images/${id}`)
+ },
+
+ listImages(params: ImageGenerationListParams) {
+ return request.get<{
+ items: ImageGeneration[]
+ pagination: {
+ page: number
+ page_size: number
+ total: number
+ total_pages: number
+ }
+ }>('/images', { params })
+ },
+
+ deleteImage(id: number) {
+ return request.delete(`/images/${id}`)
+ }
+}
diff --git a/web/src/api/video.ts b/web/src/api/video.ts
new file mode 100644
index 0000000..b551855
--- /dev/null
+++ b/web/src/api/video.ts
@@ -0,0 +1,44 @@
+import type {
+ GenerateVideoRequest,
+ VideoGeneration,
+ VideoGenerationListParams
+} from '../types/video'
+import request from '../utils/request'
+
+export const videoAPI = {
+ generateVideo(data: GenerateVideoRequest) {
+ return request.post('/videos', data)
+ },
+
+ generateFromImage(imageGenId: number) {
+ return request.post(`/videos/image/${imageGenId}`)
+ },
+
+ batchGenerateForEpisode(episodeId: number) {
+ return request.post(`/videos/episode/${episodeId}/batch`)
+ },
+
+ getVideoGeneration(id: number) {
+ return request.get(`/videos/${id}`)
+ },
+
+ getVideo(id: number) {
+ return request.get(`/videos/${id}`)
+ },
+
+ listVideos(params: VideoGenerationListParams) {
+ return request.get<{
+ items: VideoGeneration[]
+ pagination: {
+ page: number
+ page_size: number
+ total: number
+ total_pages: number
+ }
+ }>('/videos', { params })
+ },
+
+ deleteVideo(id: number) {
+ return request.delete(`/videos/${id}`)
+ }
+}
diff --git a/web/src/api/videoMerge.ts b/web/src/api/videoMerge.ts
new file mode 100644
index 0000000..b9c808e
--- /dev/null
+++ b/web/src/api/videoMerge.ts
@@ -0,0 +1,65 @@
+import request from '../utils/request'
+
+export interface SceneClip {
+ scene_id: string
+ video_url: string
+ start_time: number
+ end_time: number
+ duration: number
+ order: number
+}
+
+export interface MergeVideoRequest {
+ episode_id: string
+ drama_id: string
+ title: string
+ scenes: SceneClip[]
+ provider?: string
+ model?: string
+}
+
+export interface VideoMerge {
+ id: number
+ episode_id: string
+ drama_id: string
+ title: string
+ provider: string
+ model?: string
+ status: 'pending' | 'processing' | 'completed' | 'failed'
+ scenes: SceneClip[]
+ merged_url?: string
+ duration?: number
+ task_id?: string
+ error_msg?: string
+ created_at: string
+ completed_at?: string
+}
+
+export const videoMergeAPI = {
+ async mergeVideos(data: MergeVideoRequest): Promise {
+ const response = await request.post<{ merge: VideoMerge }>('/video-merges', data)
+ return response.merge
+ },
+
+ async getMerge(mergeId: number): Promise {
+ const response = await request.get<{ merge: VideoMerge }>(`/video-merges/${mergeId}`)
+ return response.merge
+ },
+
+ async listMerges(params: {
+ episode_id?: string
+ status?: string
+ page?: number
+ page_size?: number
+ }): Promise<{ merges: VideoMerge[]; total: number }> {
+ const response = await request.get<{ merges: VideoMerge[]; total: number }>('/video-merges', { params })
+ return {
+ merges: response.merges || [],
+ total: response.total || 0
+ }
+ },
+
+ async deleteMerge(mergeId: number): Promise {
+ await request.delete(`/video-merges/${mergeId}`)
+ }
+}
diff --git a/web/src/assets/styles/element/index.scss b/web/src/assets/styles/element/index.scss
new file mode 100644
index 0000000..2cc8af0
--- /dev/null
+++ b/web/src/assets/styles/element/index.scss
@@ -0,0 +1,17 @@
+/*just override what you need*/
+@forward 'element-plus/theme-chalk/src/dark/var.scss' with (
+ $bg-color: (
+ 'page': #0a0a0a,
+ '': #141414,
+ 'overlay': #1d1e1f,
+ ),
+ $fill-color: (
+ '': #262727,
+ 'light': #1d1e1f,
+ 'lighter': #141414,
+ 'extra-light': #191919,
+ 'dark': #3a3a3a,
+ 'darker': #4a4a4a,
+ 'blank': #1a1a1a,
+ )
+);
\ No newline at end of file
diff --git a/web/src/assets/styles/main.css b/web/src/assets/styles/main.css
new file mode 100644
index 0000000..58c3cfa
--- /dev/null
+++ b/web/src/assets/styles/main.css
@@ -0,0 +1,1136 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* ========================================
+ CSS Variables for Theme / 主题 CSS 变量
+ Modern minimalist design system
+ ======================================== */
+:root {
+ /* Background colors / 背景色 */
+ --bg-primary: #f8fafc;
+ --bg-secondary: #ffffff;
+ --bg-card: #ffffff;
+ --bg-card-hover: #f1f5f9;
+ --bg-elevated: #ffffff;
+ --bg-overlay: rgba(15, 23, 42, 0.5);
+
+ /* Text colors / 文字色 */
+ --text-primary: #0f172a;
+ --text-secondary: #475569;
+ --text-muted: #94a3b8;
+ --text-inverse: #ffffff;
+
+ /* Border colors / 边框色 */
+ --border-primary: #e2e8f0;
+ --border-secondary: #cbd5e1;
+ --border-focus: #0ea5e9;
+
+ /* Primary accent / 主强调色 */
+ --accent: #0ea5e9;
+ --accent-hover: #0284c7;
+ --accent-light: #e0f2fe;
+ --accent-dark: #0369a1;
+
+ /* Status colors / 状态色 */
+ --success: #10b981;
+ --success-light: #d1fae5;
+ --warning: #f59e0b;
+ --warning-light: #fef3c7;
+ --error: #ef4444;
+ --error-light: #fee2e2;
+ --info: #3b82f6;
+ --info-light: #dbeafe;
+
+ /* Shadows / 阴影 - refined for depth */
+ --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.03);
+ --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.05), 0 1px 2px -1px rgb(0 0 0 / 0.05);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.05);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.05);
+ --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.05);
+ --shadow-glow: 0 0 20px rgba(14, 165, 233, 0.15);
+ --shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04);
+ --shadow-card-hover: 0 8px 16px -4px rgb(0 0 0 / 0.1), 0 4px 6px -2px rgb(0 0 0 / 0.05);
+
+ /* Transition / 过渡 */
+ --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-bounce: 500ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
+
+ /* Border radius / 圆角 */
+ --radius-xs: 0.25rem;
+ --radius-sm: 0.375rem;
+ --radius-md: 0.5rem;
+ --radius-lg: 0.75rem;
+ --radius-xl: 1rem;
+ --radius-2xl: 1.25rem;
+ --radius-full: 9999px;
+
+ /* Spacing scale / 间距比例 */
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+ --space-12: 3rem;
+}
+
+/* Dark mode theme / 深色模式主题 - 参考深色UI设计 */
+.dark {
+ /* Background - 深邃的蓝黑色调 */
+ --bg-primary: #0c1015;
+ --bg-secondary: #12181f;
+ --bg-card: #181f28;
+ --bg-card-hover: #1e2730;
+ --bg-elevated: #1a2129;
+ --bg-overlay: rgba(0, 0, 0, 0.8);
+
+ /* Text - 清晰的层次对比 */
+ --text-primary: #e8edf3;
+ --text-secondary: #8b9bb0;
+ --text-muted: #5a6a7e;
+ --text-inverse: #0c1015;
+
+ /* Border - 微妙的边框 */
+ --border-primary: #252d38;
+ --border-secondary: #323d4d;
+ --border-focus: #22d3ee;
+
+ /* Accent - 青色强调色 */
+ --accent: #22d3ee;
+ --accent-hover: #06b6d4;
+ --accent-light: rgba(34, 211, 238, 0.12);
+ --accent-dark: #67e8f9;
+
+ /* Status colors / 状态色 */
+ --success: #34d399;
+ --success-light: rgba(52, 211, 153, 0.12);
+ --warning: #fbbf24;
+ --warning-light: rgba(251, 191, 36, 0.12);
+ --error: #f87171;
+ --error-light: rgba(248, 113, 113, 0.12);
+ --info: #60a5fa;
+ --info-light: rgba(96, 165, 250, 0.12);
+
+ /* Shadows - 更深的阴影 */
+ --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.4);
+ --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.5), 0 1px 2px -1px rgb(0 0 0 / 0.5);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.6), 0 2px 4px -2px rgb(0 0 0 / 0.5);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.7), 0 4px 6px -4px rgb(0 0 0 / 0.6);
+ --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.8), 0 8px 10px -6px rgb(0 0 0 / 0.7);
+ --shadow-glow: 0 0 20px rgba(34, 211, 238, 0.25);
+ --shadow-card: 0 2px 4px 0 rgb(0 0 0 / 0.3);
+ --shadow-card-hover: 0 8px 20px -4px rgb(0 0 0 / 0.5), 0 0 0 1px rgba(34, 211, 238, 0.15);
+
+ --el-fill-color-blank: #181F28;
+ --el-border-color: #4d4d4d;
+ --el-border-color-light: #2a333d;
+ --el-fill-color-light: #2a333d;
+ --el-bg-color-overlay: #181F28;
+ --el-text-color-regular: #e8edf3;
+ --el-descriptions-table-border: #4d4d4d;
+ --el-border-color-lighter: #4d4d4d;
+ --el-text-color-primary: #e8edf3;
+
+}
+
+/* ========================================
+ Base Styles / 基础样式
+ ======================================== */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html,
+body {
+ width: 100%;
+ height: 100%;
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ background-color: var(--bg-primary);
+ color: var(--text-primary);
+ transition: background-color var(--transition-normal), color var(--transition-normal);
+}
+
+#app {
+ width: 100%;
+ height: 100%;
+}
+
+/* ========================================
+ Element Plus Overrides / Element Plus 样式覆盖
+ Modern minimalist design overrides
+ ======================================== */
+/* 单行打点 */
+.overflow-tooltip {
+ display: inline-block;
+ width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ vertical-align: middle;
+ cursor: pointer;
+}
+
+.el-select-dropdown__item {
+ line-height: unset;
+}
+/* Button overrides / 按钮样式覆盖 */
+
+.el-button {
+ --el-button-border-radius: var(--radius-lg);
+ font-weight: 500;
+ transition: all var(--transition-fast);
+ border: none;
+ letter-spacing: -0.01em;
+}
+
+.el-button--default {
+ background: var(--bg-card);
+ border: 1px solid var(--border-primary);
+ color: var(--text-primary);
+}
+
+.el-button--default:hover {
+ background: var(--bg-card-hover);
+ border-color: var(--border-secondary);
+ color: var(--text-primary);
+}
+
+.el-button--primary {
+ --el-button-bg-color: var(--accent);
+ --el-button-border-color: var(--accent);
+ --el-button-hover-bg-color: var(--accent-hover);
+ --el-button-hover-border-color: var(--accent-hover);
+ background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%);
+ box-shadow: 0 2px 8px rgba(14, 165, 233, 0.25);
+}
+
+.el-button--primary:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(14, 165, 233, 0.35);
+}
+
+.el-button--primary:active {
+ transform: translateY(0);
+}
+
+.el-button--danger {
+ background: linear-gradient(135deg, var(--error) 0%, #dc2626 100%);
+ box-shadow: 0 2px 8px rgba(239, 68, 68, 0.25);
+}
+
+.el-button--danger:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(239, 68, 68, 0.35);
+}
+
+.el-button--success {
+ background: linear-gradient(135deg, var(--success) 0%, #059669 100%);
+ box-shadow: 0 2px 8px rgba(16, 185, 129, 0.25);
+}
+
+.el-button.is-text {
+ color: var(--text-secondary);
+}
+
+.el-button.is-text:hover {
+ color: var(--accent);
+ background: var(--accent-light);
+}
+
+.el-button.is-circle {
+ background: var(--bg-card);
+ border: 1px solid var(--border-primary);
+ color: var(--text-secondary);
+}
+
+.el-button.is-circle:hover {
+ background: var(--bg-card-hover);
+ border-color: var(--border-secondary);
+ color: var(--text-primary);
+}
+
+/* Back button / 返回按钮 */
+.back-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.5rem 0.875rem;
+ background: var(--bg-card);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-lg);
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ white-space: nowrap;
+}
+
+.back-btn:hover {
+ background: var(--bg-card-hover);
+ color: var(--text-primary);
+ border-color: var(--border-secondary);
+}
+
+.back-btn:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+/* Card overrides / 卡片样式覆盖 */
+.el-card {
+ --el-card-bg-color: var(--bg-card);
+ --el-card-border-color: var(--border-primary);
+ --el-card-border-radius: var(--radius-xl);
+ border: 1px solid var(--border-primary);
+ box-shadow: var(--shadow-card);
+ transition: all var(--transition-normal);
+}
+
+.el-card:hover {
+ box-shadow: var(--shadow-card-hover);
+}
+
+.el-card__header {
+ border-bottom: 1px solid var(--border-primary);
+ padding: var(--space-4) var(--space-5);
+}
+
+.el-card__body {
+ padding: var(--space-5);
+}
+
+.dark .el-card {
+ --el-card-bg-color: var(--bg-card);
+ --el-card-border-color: var(--border-primary);
+}
+
+/* Dialog overrides / 对话框样式覆盖 */
+.el-dialog {
+ --el-dialog-bg-color: var(--bg-card);
+ --el-dialog-border-radius: var(--radius-2xl);
+ box-shadow: var(--shadow-xl);
+ border: 1px solid var(--border-primary);
+}
+
+.el-dialog__header {
+ padding: var(--space-5) var(--space-6);
+ border-bottom: 1px solid var(--border-primary);
+ margin-right: 0;
+}
+
+.el-dialog__title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ letter-spacing: -0.02em;
+}
+
+.el-dialog__body {
+ padding: var(--space-6);
+}
+
+.el-dialog__footer {
+ padding: var(--space-4) var(--space-6);
+ border-top: 1px solid var(--border-primary);
+}
+
+.dark .el-dialog {
+ --el-dialog-bg-color: var(--bg-card);
+}
+
+.dark .el-dialog__title {
+ color: var(--text-primary);
+}
+
+/* Input overrides / 输入框样式覆盖 */
+.el-input__wrapper {
+ --el-input-bg-color: var(--bg-secondary);
+ --el-input-border-color: var(--border-primary);
+ border-radius: var(--radius-lg) !important;
+ box-shadow: 0 0 0 1px var(--border-primary) inset !important;
+ transition: all var(--transition-fast);
+ padding: 0 var(--space-3);
+}
+
+.el-input__wrapper:hover {
+ box-shadow: 0 0 0 1px var(--border-secondary) inset !important;
+}
+
+.el-input__wrapper.is-focus {
+ box-shadow: 0 0 0 2px var(--accent) inset !important;
+}
+
+.el-input__inner {
+ color: var(--text-primary);
+ font-size: 0.875rem;
+}
+
+.el-input__inner::placeholder {
+ color: var(--text-muted);
+}
+
+.el-textarea__inner {
+ --el-input-bg-color: var(--bg-secondary);
+ border-radius: var(--radius-lg) !important;
+ box-shadow: 0 0 0 1px var(--border-primary) inset;
+ transition: all var(--transition-fast);
+ padding: var(--space-3);
+ color: var(--text-primary);
+ font-size: 0.875rem;
+}
+
+.el-textarea__inner:hover {
+ box-shadow: 0 0 0 1px var(--border-secondary) inset;
+}
+
+.el-textarea__inner:focus {
+ box-shadow: 0 0 0 2px var(--accent) inset;
+}
+
+.el-textarea__inner::placeholder {
+ color: var(--text-muted);
+}
+
+.dark .el-input__wrapper {
+ background-color: var(--bg-secondary);
+}
+
+.dark .el-input__inner {
+ color: var(--text-primary);
+}
+
+.dark .el-textarea__inner {
+ background-color: var(--bg-secondary);
+ color: var(--text-primary);
+}
+
+/* Select overrides / 选择器样式覆盖 */
+.el-select .el-input__wrapper {
+ background: var(--bg-secondary);
+}
+
+.el-select-dropdown {
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border-primary);
+ box-shadow: var(--shadow-lg);
+}
+
+.el-select-dropdown__item {
+ font-size: 0.875rem;
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-sm);
+ margin: 2px var(--space-1);
+}
+
+.el-select-dropdown__item.is-selected {
+ background: var(--accent-light);
+ color: var(--accent);
+ font-weight: 500;
+}
+
+.el-select-dropdown__item:hover {
+ background: var(--bg-card-hover);
+}
+
+.dark .el-select-dropdown {
+ background: var(--bg-elevated);
+ border-color: var(--border-primary);
+}
+
+.dark .el-select-dropdown__item:hover {
+ background: var(--bg-card-hover);
+}
+
+/* Tag overrides / 标签样式覆盖 */
+.el-tag {
+ --el-tag-border-radius: var(--radius-md);
+ font-weight: 500;
+ font-size: 0.75rem;
+ padding: 0 var(--space-2);
+ height: 1.5rem;
+ line-height: 1.5rem;
+ border: none;
+}
+
+.el-tag--info {
+ background: var(--bg-card-hover);
+ color: var(--text-secondary);
+}
+
+.el-tag--primary {
+ background: var(--accent-light);
+ color: var(--accent);
+}
+
+.el-tag--success {
+ background: var(--success-light);
+ color: var(--success);
+}
+
+.el-tag--warning {
+ background: var(--warning-light);
+ color: var(--warning);
+}
+
+.el-tag--danger {
+ background: var(--error-light);
+ color: var(--error);
+}
+
+/* Tabs overrides / 标签页样式覆盖 */
+.el-tabs__header {
+ margin-bottom: var(--space-6);
+}
+
+.el-tabs__nav-wrap::after {
+ display: none;
+}
+
+.el-tabs__item {
+ font-weight: 500;
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ padding: 0 var(--space-5);
+ height: 2.5rem;
+ line-height: 2.5rem;
+ transition: color var(--transition-fast);
+}
+
+.el-tabs__item:hover {
+ color: var(--text-primary);
+}
+
+.el-tabs__item.is-active {
+ color: var(--accent);
+ font-weight: 600;
+}
+
+.el-tabs__active-bar {
+ background: var(--accent);
+ height: 2px;
+ border-radius: var(--radius-full);
+}
+
+.dark .el-tabs__item {
+ color: var(--text-secondary);
+}
+
+.dark .el-tabs__item.is-active {
+ color: var(--accent);
+}
+
+/* Table overrides / 表格样式覆盖 */
+.el-table {
+ --el-table-bg-color: var(--bg-card);
+ --el-table-header-bg-color: var(--bg-secondary);
+ --el-table-tr-bg-color: var(--bg-card);
+ --el-table-row-hover-bg-color: var(--bg-card-hover);
+ --el-table-border-color: var(--border-primary);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+}
+
+.el-table th.el-table__cell {
+ font-weight: 600;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-muted);
+}
+
+.el-table td.el-table__cell {
+ font-size: 0.875rem;
+ color: var(--text-primary);
+}
+
+.dark .el-table {
+ --el-table-bg-color: var(--bg-card);
+ --el-table-header-bg-color: var(--bg-secondary);
+ --el-table-tr-bg-color: var(--bg-card);
+ --el-table-row-hover-bg-color: var(--bg-card-hover);
+}
+
+.dark .el-table th.el-table__cell,
+.dark .el-table td.el-table__cell {
+ border-color: var(--border-primary);
+}
+
+/* Pagination overrides / 分页样式覆盖 */
+.el-pagination {
+ --el-pagination-bg-color: transparent;
+ --el-pagination-button-bg-color: var(--bg-card);
+ gap: var(--space-1);
+}
+
+.el-pager li {
+ min-width: 2rem;
+ height: 2rem;
+ line-height: 2rem;
+ font-weight: 500;
+ font-size: 0.8125rem;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--text-secondary);
+ transition: all var(--transition-fast);
+}
+
+.el-pager li:hover {
+ color: var(--accent);
+ background: var(--accent-light);
+}
+
+.el-pager li.is-active {
+ background: var(--accent);
+ color: white;
+ font-weight: 600;
+}
+
+.el-pagination button {
+ min-width: 2rem;
+ height: 2rem;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--text-secondary);
+ transition: all var(--transition-fast);
+}
+
+.el-pagination button:hover:not(:disabled) {
+ color: var(--accent);
+ background: var(--accent-light);
+}
+
+.el-pagination button:disabled {
+ opacity: 0.4;
+}
+
+.dark .el-pagination {
+ --el-pagination-text-color: var(--text-secondary);
+ --el-pagination-button-color: var(--text-primary);
+}
+
+.dark .el-pagination button,
+.dark .el-pager li {
+ background-color: transparent;
+ color: var(--text-secondary);
+}
+
+.dark .el-pager li:hover {
+ background: var(--accent-light);
+}
+
+.dark .el-pager li.is-active {
+ background: var(--accent);
+ color: var(--text-inverse);
+}
+
+/* Empty state overrides / 空状态样式覆盖 */
+.el-empty {
+ padding: var(--space-12) var(--space-6);
+}
+
+.el-empty__description p {
+ color: var(--text-muted);
+ font-size: 0.875rem;
+}
+
+.dark .el-empty__description p {
+ color: var(--text-muted);
+}
+
+/* Alert overrides / 提示框样式覆盖 */
+.el-alert {
+ border-radius: var(--radius-lg);
+ border: none;
+ padding: var(--space-4);
+}
+
+.el-alert--info {
+ background: var(--info-light);
+}
+
+.el-alert--success {
+ background: var(--success-light);
+}
+
+.el-alert--warning {
+ background: var(--warning-light);
+}
+
+.el-alert--error {
+ background: var(--error-light);
+}
+
+.dark .el-alert--info {
+ --el-alert-bg-color: var(--info-light);
+}
+
+/* Form overrides / 表单样式覆盖 */
+.el-form-item__label {
+ font-weight: 500;
+ font-size: 0.875rem;
+ color: var(--text-primary);
+ margin-bottom: var(--space-2);
+}
+
+.dark .el-form-item__label {
+ color: var(--text-primary);
+}
+
+/* Descriptions overrides / 描述列表样式覆盖 */
+.el-descriptions {
+ --el-descriptions-item-bordered-label-background: var(--bg-secondary);
+}
+
+.el-descriptions__label {
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+.el-descriptions__content {
+ color: var(--text-primary);
+}
+
+.dark .el-descriptions__label,
+.dark .el-descriptions__content {
+ background: var(--bg-secondary);
+}
+
+/* Message Box overrides / 消息框样式覆盖 */
+.el-message-box {
+ border-radius: var(--radius-xl);
+ border: 1px solid var(--border-primary);
+ box-shadow: var(--shadow-xl);
+}
+
+.dark .el-message-box {
+ background: var(--bg-card);
+}
+
+/* Popconfirm overrides / 确认弹窗样式覆盖 */
+.el-popconfirm {
+ border-radius: var(--radius-lg);
+}
+
+.dark .el-popconfirm {
+ --el-popconfirm-bg-color: var(--bg-card);
+}
+
+/* Loading overrides / 加载样式覆盖 */
+.el-loading-mask {
+ background: var(--bg-overlay);
+ backdrop-filter: blur(4px);
+}
+
+.el-loading-spinner .circular {
+ width: 32px;
+ height: 32px;
+}
+
+.el-loading-spinner .path {
+ stroke: var(--accent);
+}
+
+/* Switch overrides / 开关样式覆盖 */
+.el-switch {
+ --el-switch-on-color: var(--accent);
+}
+
+.dark .el-switch__core {
+ background: var(--bg-secondary);
+ border-color: var(--border-primary);
+}
+
+/* Tooltip overrides / 提示框样式覆盖 */
+.el-tooltip__trigger {
+ outline: none;
+}
+
+/* Avatar overrides / 头像样式覆盖 */
+.el-avatar {
+ --el-avatar-bg-color: var(--accent);
+}
+
+/* Scrollbar overrides / 滚动条样式覆盖 */
+.el-scrollbar__thumb {
+ background: var(--border-secondary);
+ border-radius: var(--radius-full);
+}
+
+.el-scrollbar__thumb:hover {
+ background: var(--text-muted);
+}
+
+/* ========================================
+ Utility Classes / 工具类
+ ======================================== */
+.page-container {
+ min-height: 100vh;
+ background-color: var(--bg-primary);
+ padding: var(--space-2) var(--space-3);
+ transition: background-color var(--transition-normal);
+}
+
+@media (min-width: 768px) {
+ .page-container {
+ padding: var(--space-3) var(--space-4);
+ }
+}
+
+@media (min-width: 1024px) {
+ .page-container {
+ padding: var(--space-4) var(--space-5);
+ }
+}
+
+.content-wrapper {
+ margin: 0 auto;
+ width: 100%;
+}
+
+/* ========================================
+ Layout Components / 布局组件
+ ======================================== */
+
+/* Glass morphism card / 玻璃态卡片 */
+.glass-card {
+ background: rgba(255, 255, 255, 0.8);
+ backdrop-filter: blur(12px);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-card);
+ transition: all var(--transition-normal);
+}
+
+.dark .glass-card {
+ background: rgba(26, 35, 50, 0.8);
+}
+
+.glass-card:hover {
+ box-shadow: var(--shadow-card-hover);
+}
+
+/* Gradient backgrounds / 渐变背景 */
+.gradient-primary {
+ background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%);
+}
+
+.gradient-success {
+ background: linear-gradient(135deg, var(--success) 0%, #059669 100%);
+}
+
+.gradient-warning {
+ background: linear-gradient(135deg, var(--warning) 0%, #d97706 100%);
+}
+
+.gradient-error {
+ background: linear-gradient(135deg, var(--error) 0%, #dc2626 100%);
+}
+
+/* Animated gradient / 动画渐变 */
+.gradient-animated {
+ background: linear-gradient(-45deg, #0ea5e9, #06b6d4, #8b5cf6, #ec4899);
+ background-size: 400% 400%;
+ animation: gradient-shift 15s ease infinite;
+}
+
+@keyframes gradient-shift {
+ 0% {
+ background-position: 0% 50%;
+ }
+
+ 50% {
+ background-position: 100% 50%;
+ }
+
+ 100% {
+ background-position: 0% 50%;
+ }
+}
+
+/* Glow effect / 发光效果 */
+.glow-primary {
+ box-shadow: 0 0 20px rgba(14, 165, 233, 0.3);
+}
+
+.glow-success {
+ box-shadow: 0 0 20px rgba(16, 185, 129, 0.3);
+}
+
+/* Skeleton loading / 骨架屏加载 */
+.skeleton {
+ background: linear-gradient(90deg, var(--bg-card-hover) 25%, var(--bg-secondary) 50%, var(--bg-card-hover) 75%);
+ background-size: 200% 100%;
+ animation: skeleton-loading 1.5s infinite;
+ border-radius: var(--radius-md);
+}
+
+@keyframes skeleton-loading {
+ 0% {
+ background-position: 200% 0;
+ }
+
+ 100% {
+ background-position: -200% 0;
+ }
+}
+
+/* Hover lift effect / 悬停提升效果 */
+.hover-lift {
+ transition: transform var(--transition-normal), box-shadow var(--transition-normal);
+}
+
+.hover-lift:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-lg);
+}
+
+/* Interactive scale / 交互缩放 */
+.interactive-scale {
+ transition: transform var(--transition-fast);
+}
+
+.interactive-scale:hover {
+ transform: scale(1.02);
+}
+
+.interactive-scale:active {
+ transform: scale(0.98);
+}
+
+/* Focus ring / 焦点环 */
+.focus-ring:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+/* Text truncation / 文本截断 */
+.truncate-1 {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.truncate-2 {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.truncate-3 {
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+/* Scrollbar styling / 滚动条样式 */
+.custom-scrollbar::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+.custom-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb {
+ background: var(--border-secondary);
+ border-radius: var(--radius-full);
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background: var(--text-muted);
+}
+
+/* Hide scrollbar / 隐藏滚动条 */
+.hide-scrollbar {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+.hide-scrollbar::-webkit-scrollbar {
+ display: none;
+}
+
+/* ========================================
+ Animation Utilities / 动画工具
+ ======================================== */
+
+/* Fade in / 淡入 */
+.animate-fade-in {
+ animation: fade-in 0.3s ease-out;
+}
+
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+/* Slide up / 向上滑入 */
+.animate-slide-up {
+ animation: slide-up 0.3s ease-out;
+}
+
+@keyframes slide-up {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Scale in / 缩放进入 */
+.animate-scale-in {
+ animation: scale-in 0.2s ease-out;
+}
+
+@keyframes scale-in {
+ from {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+/* Pulse / 脉冲 */
+.animate-pulse {
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+@keyframes pulse {
+
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+/* Spin / 旋转 */
+.animate-spin {
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Bounce / 弹跳 */
+.animate-bounce {
+ animation: bounce 1s infinite;
+}
+
+@keyframes bounce {
+
+ 0%,
+ 100% {
+ transform: translateY(-5%);
+ animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
+ }
+
+ 50% {
+ transform: translateY(0);
+ animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
+ }
+}
+
+/* ========================================
+ Status Indicators / 状态指示器
+ ======================================== */
+.status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: var(--radius-full);
+ flex-shrink: 0;
+}
+
+.status-dot.success {
+ background: var(--success);
+}
+
+.status-dot.warning {
+ background: var(--warning);
+}
+
+.status-dot.error {
+ background: var(--error);
+}
+
+.status-dot.info {
+ background: var(--info);
+}
+
+.status-dot.muted {
+ background: var(--text-muted);
+}
+
+.status-dot.pulse {
+ animation: status-pulse 2s infinite;
+}
+
+@keyframes status-pulse {
+
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 0 currentColor;
+ opacity: 1;
+ }
+
+ 50% {
+ box-shadow: 0 0 0 4px currentColor;
+ opacity: 0.5;
+ }
+}
+
+/* ========================================
+ Typography Utilities / 排版工具
+ ======================================== */
+.text-gradient {
+ background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.font-display {
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+}
+
+.font-mono {
+ font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+}
\ No newline at end of file
diff --git a/web/src/components/LanguageSwitcher.vue b/web/src/components/LanguageSwitcher.vue
new file mode 100644
index 0000000..b002948
--- /dev/null
+++ b/web/src/components/LanguageSwitcher.vue
@@ -0,0 +1,64 @@
+
+
+
+
+ {{ currentLangText }}
+
+
+
+
+ 🇨🇳 简体中文
+
+
+ 🇺🇸 English
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/common/AIConfigDialog.vue b/web/src/components/common/AIConfigDialog.vue
new file mode 100644
index 0000000..35855bc
--- /dev/null
+++ b/web/src/components/common/AIConfigDialog.vue
@@ -0,0 +1,801 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
将自动创建以下配置:
+
+ 文本服务 : {{ providerConfigs.text[1].models[0] }}
+ 图片服务 : {{ providerConfigs.image[1].models[0] }}
+ 视频服务 : {{ providerConfigs.video[1].models[0] }}
+
+
Base URL: https://api.chatfire.site/v1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('aiConfig.form.providerTip') }}
+
+
+
+
+ {{ $t('aiConfig.form.priorityTip') }}
+
+
+
+
+
+
+ {{ $t('aiConfig.form.modelTip') }}
+
+
+
+
+
+ {{ $t('aiConfig.form.baseUrlTip') }}
+
+ {{ $t('aiConfig.form.fullEndpoint') }}: {{ fullEndpointExample }}
+
+
+
+
+
+ {{ $t('aiConfig.form.apiKeyTip') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/common/ActionButton.vue b/web/src/components/common/ActionButton.vue
new file mode 100644
index 0000000..c406e18
--- /dev/null
+++ b/web/src/components/common/ActionButton.vue
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/common/AppLayout.vue b/web/src/components/common/AppLayout.vue
new file mode 100644
index 0000000..3493391
--- /dev/null
+++ b/web/src/components/common/AppLayout.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
diff --git a/web/src/components/common/BaseCard.vue b/web/src/components/common/BaseCard.vue
new file mode 100644
index 0000000..47eac8c
--- /dev/null
+++ b/web/src/components/common/BaseCard.vue
@@ -0,0 +1,210 @@
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/common/CreateDramaDialog.vue b/web/src/components/common/CreateDramaDialog.vue
new file mode 100644
index 0000000..2917a40
--- /dev/null
+++ b/web/src/components/common/CreateDramaDialog.vue
@@ -0,0 +1,235 @@
+
+
+
+ {{ $t('drama.createDesc') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/common/EmptyState.vue b/web/src/components/common/EmptyState.vue
new file mode 100644
index 0000000..37d1e8b
--- /dev/null
+++ b/web/src/components/common/EmptyState.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
{{ title }}
+
{{ description }}
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/common/PageHeader.vue b/web/src/components/common/PageHeader.vue
new file mode 100644
index 0000000..678c734
--- /dev/null
+++ b/web/src/components/common/PageHeader.vue
@@ -0,0 +1,223 @@
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/common/ProjectCard.vue b/web/src/components/common/ProjectCard.vue
new file mode 100644
index 0000000..03e832c
--- /dev/null
+++ b/web/src/components/common/ProjectCard.vue
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+
+
+
+
{{ title }}
+
{{ description }}
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/common/StatCard.vue b/web/src/components/common/StatCard.vue
new file mode 100644
index 0000000..e50394c
--- /dev/null
+++ b/web/src/components/common/StatCard.vue
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+
+
+
+
+
{{ label }}
+
+ {{ formattedValue }}
+ {{ suffix }}
+
+
{{ description }}
+
+
+
+
+
+ {{ Math.abs(trend) }}%
+
+
+
+
+
+
+
diff --git a/web/src/components/common/ThemeToggle.vue b/web/src/components/common/ThemeToggle.vue
new file mode 100644
index 0000000..632d53c
--- /dev/null
+++ b/web/src/components/common/ThemeToggle.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/common/index.ts b/web/src/components/common/index.ts
new file mode 100644
index 0000000..a3a2abe
--- /dev/null
+++ b/web/src/components/common/index.ts
@@ -0,0 +1,22 @@
+/**
+ * Common UI Components barrel export
+ * 通用 UI 组件统一导出
+ */
+
+// Layout Components / 布局组件
+export { default as PageHeader } from './PageHeader.vue'
+export { default as BaseCard } from './BaseCard.vue'
+export { default as StatCard } from './StatCard.vue'
+export { default as EmptyState } from './EmptyState.vue'
+
+// Interactive Components / 交互组件
+export { default as ProjectCard } from './ProjectCard.vue'
+export { default as ThemeToggle } from './ThemeToggle.vue'
+export { default as ActionButton } from './ActionButton.vue'
+
+// Dialog Components / 弹窗组件
+export { default as CreateDramaDialog } from './CreateDramaDialog.vue'
+export { default as AIConfigDialog } from './AIConfigDialog.vue'
+
+// Layout Components / 布局组件
+export { default as AppLayout } from './AppLayout.vue'
diff --git a/web/src/components/editor/StoryboardEditor.vue b/web/src/components/editor/StoryboardEditor.vue
new file mode 100644
index 0000000..b280dc0
--- /dev/null
+++ b/web/src/components/editor/StoryboardEditor.vue
@@ -0,0 +1,1509 @@
+
+
+
+
+
+
+
+
{{ shot.shot_number }}
+
+
+ {{ shot.shot_type }}
+ {{ shot.time }} · {{ shot.location }}
+
+
{{ shot.action }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ shot.shot_number }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 镜号
+
+
+
+ 景别 (Scene)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 对角/旁白
+
+
+
+ 动作
+
+
+
+ 画面结果
+
+
+
+
+
+
+
情绪与强度
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
人物设置
+
+
场景角色
+
+
+
+
选择在此镜头中出现的角色
+
+
+
+
+
+
+
+
+
{{ getCharacterById(charId)?.name }}
+
+
+
+
+
+
+
背景设置
+
+
背景图片
+
+
+
+
+
+
+
+ 生成
+
+
+ 上传
+
+
+
+
+
+
+ 背景描述
+
+
+
+
+
+
+
场景合成
+
+
合成预览
+
+
+
+
+
+
+
未合成场景
+
需要先生成背景和选择人物
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 合成场景
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 生成视频
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/editor/VideoTimelineEditor.vue b/web/src/components/editor/VideoTimelineEditor.vue
new file mode 100644
index 0000000..bf94801
--- /dev/null
+++ b/web/src/components/editor/VideoTimelineEditor.vue
@@ -0,0 +1,2476 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ formatTime(tick.time) }}
+
+
+
+
+
+
+
+
+
+ {{ $t('video.videoTrack') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('storyboard.scene') }} {{ clip.storyboard_number }}
+
{{ clip.duration.toFixed(1) }}s
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getTransitionLabel(timelineClips[index]) }}
+
+
+
+
+
+
+
+ {{ $t('video.audioTrack') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('video.audio') }} {{ audio.order + 1 }}
+
{{ audio.duration.toFixed(1) }}s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 确定
+
+
+
+
+
+
+
+
+
+ {{ getPhaseText(mergeProgressDetail.phase) }}
+
+
+
{{ mergeProgressDetail.message }}
+
+
+
+
+
+
+
+ 正在加载FFmpeg引擎(首次需要下载约30MB)...
+
+
+
+ 正在处理视频文件,请稍候...
+
+
+
+ 正在编码合并视频,可能需要几分钟...
+
+
+
+ 合并完成!视频已自动下载。
+
+
+
+
+
+ 关闭
+
+
+
+
+
+
+
+
diff --git a/web/src/locales/en-US.ts b/web/src/locales/en-US.ts
new file mode 100644
index 0000000..6e47f79
--- /dev/null
+++ b/web/src/locales/en-US.ts
@@ -0,0 +1,711 @@
+export default {
+ nav: {
+ home: 'Home',
+ characters: 'Characters',
+ storyboard: 'Storyboard',
+ videos: 'Videos',
+ assets: 'Assets',
+ settings: 'Settings',
+ dramas: 'Drama Projects'
+ },
+ dashboard: {
+ title: '🎬 Drama Generator',
+ welcome: 'Welcome to AI Drama Generation Platform',
+ subtitle: 'One-stop drama creation tool from script to video',
+ stats: {
+ projects: 'Drama Projects',
+ images: 'Generated Images',
+ videos: 'Generated Videos',
+ tasks: 'Processing Tasks'
+ },
+ quickStart: 'Quick Start',
+ actions: {
+ newProject: 'Create New Project',
+ newProjectDesc: 'Start a brand new drama project',
+ myProjects: 'My Projects',
+ myProjectsDesc: 'View and manage existing projects'
+ }
+ },
+ common: {
+ create: 'Create',
+ edit: 'Edit',
+ delete: 'Delete',
+ save: 'Save',
+ cancel: 'Cancel',
+ confirm: 'Confirm',
+ search: 'Search',
+ filter: 'Filter',
+ reset: 'Reset',
+ submit: 'Submit',
+ close: 'Close',
+ back: 'Back',
+ next: 'Next',
+ previous: 'Previous',
+ selectAll: 'Select All',
+ loading: 'Loading...',
+ success: 'Success',
+ error: 'Error',
+ warning: 'Warning',
+ info: 'Info',
+ actions: 'Actions',
+ status: 'Status',
+ name: 'Name',
+ description: 'Description',
+ createdAt: 'Created At',
+ updatedAt: 'Updated At',
+ perPage: 'Per Page'
+ },
+ settings: {
+ title: 'Settings',
+ aiConfig: 'AI Configuration',
+ general: 'General',
+ language: 'Language',
+ theme: 'Theme'
+ },
+ aiConfig: {
+ title: 'AI Service Configuration',
+ addConfig: 'Add Configuration',
+ editConfig: 'Edit Configuration',
+ back: 'Back',
+ empty: 'No configurations yet, click Add Configuration to get started',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ enable: 'Enable',
+ disable: 'Disable',
+ endpoint: 'Endpoint',
+ queryEndpoint: 'Query Endpoint',
+ tabs: {
+ text: 'Text Generation',
+ image: 'Image Generation',
+ video: 'Video Generation'
+ },
+ form: {
+ name: 'Configuration Name',
+ namePlaceholder: 'e.g., OpenAI GPT-4',
+ provider: 'Provider',
+ providerPlaceholder: 'Select a provider',
+ providerTip: 'Select AI service provider',
+ priority: 'Priority',
+ priorityTip: 'Higher values have higher priority. For the same model, higher priority configurations are used first',
+ model: 'Model',
+ modelPlaceholder: 'Enter or select model name',
+ modelTip: 'Enter model name directly or select from list, supports multiple models',
+ baseUrl: 'Base URL',
+ baseUrlPlaceholder: 'https://api.openai.com',
+ baseUrlTip: 'API service base address, e.g., Chatfire: https://api.chatfire.site/v1, Gemini: https://generativelanguage.googleapis.com (no /v1 needed)',
+ fullEndpoint: 'Full endpoint path',
+ apiKey: 'API Key',
+ apiKeyPlaceholder: 'sk-...',
+ apiKeyTip: 'Your API key',
+ isActive: 'Active Status'
+ },
+ actions: {
+ test: 'Test Connection',
+ delete: 'Delete',
+ edit: 'Edit'
+ },
+ messages: {
+ deleteConfirm: 'Are you sure to delete this configuration?',
+ testSuccess: 'Connection test successful!',
+ testFailed: 'Connection test failed'
+ }
+ },
+ drama: {
+ title: 'My Drama Projects',
+ create: 'Create Project',
+ totalProjects: 'Total {count} projects',
+ createNew: 'Create Project',
+ createDesc: 'Fill in basic information to create your drama project',
+ aiConfig: 'AI Config',
+ aiConfigTip: 'Please configure AI service before creating a project',
+ empty: 'No projects yet',
+ emptyHint: 'Click "Create Project" button above to start your first drama',
+ editProject: 'Edit Project',
+ projectName: 'Project Name',
+ projectNamePlaceholder: 'Enter project name',
+ projectDesc: 'Project Description',
+ projectDescPlaceholder: 'Enter project description (optional)',
+ deleteConfirm: 'Are you sure you want to delete this project?',
+ noCover: 'No cover',
+ noDescription: 'No description',
+ status: {
+ draft: 'Draft',
+ production: 'In Production',
+ completed: 'Completed'
+ },
+ actions: {
+ edit: 'Edit',
+ view: 'View',
+ delete: 'Delete'
+ },
+ management: {
+ overview: 'Project Overview',
+ episodes: 'Episode Management',
+ characters: 'Character Management',
+ scenes: 'Scene Management',
+ projectInfo: 'Project Information',
+ projectName: 'Project Name',
+ projectDesc: 'Project Description',
+ noDescription: 'No description',
+ episodeStats: 'Episode Statistics',
+ characterStats: 'Character Statistics',
+ sceneStats: 'Scene Statistics',
+ episodesCreated: 'Episodes Created',
+ charactersCreated: 'Characters Created',
+ sceneLibraryCount: 'Scene Library Count',
+ startFirstEpisode: 'Start creating your first episode!',
+ noEpisodesYet: 'Your project has no episodes yet. Please create an episode to start production.',
+ createFirstEpisode: 'Create First Episode Now',
+ episodeList: 'Episode List',
+ createNewEpisode: 'Create New Episode',
+ noEpisodes: 'No episodes yet',
+ clickToCreate: 'Click the button above to create your first episode',
+ episodeNumber: 'Episode {number}',
+ goToEdit: 'Go to Edit',
+ characterList: 'Character List',
+ noCharacters: 'No characters yet',
+ charactersTip: 'Characters will be automatically created during script generation',
+ sceneList: 'Scene List',
+ noScenes: 'No scenes yet',
+ scenesTip: 'Scenes will be automatically created during storyboard generation'
+ }
+ },
+ character: {
+ title: 'Character Management',
+ create: 'Create Character',
+ edit: 'Edit Character',
+ add: 'Add Character',
+ list: 'Character List',
+ name: 'Character Name',
+ role: 'Role',
+ personality: 'Personality',
+ appearance: 'Appearance',
+ background: 'Background',
+ description: 'Description',
+ image: 'Character Image',
+ generate: 'Generate Character Image',
+ extracting: 'Extracting...',
+ generateImage: 'Generate Image',
+ batch: 'Batch Operations',
+ empty: 'Characters were created during script generation. You can view and edit them here',
+ backToProject: 'Back to Project',
+ saveChanges: 'Save Changes',
+ nextStep: 'Next Step: Generate Character Images'
+ },
+ scriptGenerationPage: {
+ prevStep: 'Previous',
+ characterList: 'Character List',
+ characterName: 'Character Name',
+ position: 'Position',
+ appearanceDesc: 'Appearance Description',
+ personality: 'Personality',
+ uploadScript: 'Upload Script',
+ uploadContent: 'Upload Content',
+ aiParse: 'AI Parse',
+ confirmSave: 'Confirm & Save',
+ uploadNotice: 'Paste or upload your script file, the system will automatically identify and split into episodes and scenes',
+ uploadMethod: 'Upload Method',
+ dragFilesHere: 'Drag files here or',
+ clickUpload: 'click to upload',
+ supportedFormats: 'Supports .txt, .md, .doc, .docx formats',
+ characterListEditable: 'Character List (Editable)',
+ addCharacter: '+ Add Character',
+ characterType: 'Character Type',
+ mainCharacter: 'Main Character',
+ supportingCharacter: 'Supporting Character',
+ minorCharacter: 'Minor Character',
+ characterDesc: 'Character Description',
+ appearanceFeatures: 'Appearance Features',
+ operations: 'Operations',
+ delete: 'Delete',
+ episodeCount: 'Episode Count',
+ generateFullScript: 'Generate complete episode scripts based on outline',
+ outlineCreatedEpisodes: 'The outline has created {count} episodes, but you can reset the episode count and regenerate',
+ episodePreview: 'Episode Preview (Total {count} episodes)',
+ regenerate: 'Regenerate',
+ episodeNumber: 'Episode',
+ title: 'Title',
+ summary: 'Summary',
+ durationSeconds: 'Duration (seconds)',
+ autoGenerateCharacters: 'Auto-generate character list from outline',
+ charactersCreatedInOutline: 'Characters have been created during outline generation, click "Next" to view and edit'
+ },
+ script: {
+ title: 'Script Generation',
+ backToProject: 'Back to Project',
+ aiGenerate: 'AI Generate Script',
+ uploadScript: 'Upload Script',
+ steps: {
+ outline: 'Generate Outline',
+ characters: 'Generate Characters',
+ episodes: 'Generate Episodes'
+ },
+ form: {
+ theme: 'Creative Theme',
+ themePlaceholder: 'Describe the theme and story concept of the drama you want to create',
+ genre: 'Genre Preference',
+ genrePlaceholder: 'Select a genre',
+ style: 'Style Requirements',
+ stylePlaceholder: 'e.g., Light and humorous, Tense and thrilling, Warm and healing',
+ episodeCount: 'Episode Count',
+ randomGenerate: 'Random Generate',
+ title: 'Title',
+ titlePlaceholder: 'Enter script title',
+ summary: 'Summary',
+ summaryPlaceholder: 'Enter script summary',
+ genreExample: 'e.g., Urban, Costume',
+ tags: 'Tags',
+ newTag: 'New Tag'
+ },
+ notice: 'Please enter the creative theme and requirements, AI will generate a script outline for you',
+ generateFailed: 'Generation Failed',
+ generating: 'Generating...',
+ nextStep: 'Next Step',
+ prevStep: 'Previous Step',
+ complete: 'Complete',
+ regenerate: 'Regenerate',
+ regenerateOutline: 'Regenerate Outline',
+ outlinePreview: 'Outline Preview (Editable)'
+ },
+ imageDialog: {
+ title: 'AI Image Generation',
+ selectDrama: 'Select Drama',
+ selectScene: 'Select Scene',
+ selectSceneOptional: 'Select Scene (Optional)',
+ sceneLabel: 'Scene {number}: {title}',
+ prompt: 'Prompt',
+ promptPlaceholder: 'Describe the image you want to generate\nFor example: A beautiful landscape with mountains and rivers at sunset, cinematic lighting, highly detailed',
+ negativePrompt: 'Negative Prompt',
+ negativePromptPlaceholder: 'Describe elements you don\'t want (optional)\nFor example: blurry, low quality, watermark',
+ aiService: 'AI Service',
+ selectService: 'Select service',
+ imageSize: 'Image Size',
+ selectSize: 'Select size',
+ square: 'Square',
+ landscape: 'Landscape',
+ portrait: 'Portrait',
+ imageQuality: 'Image Quality',
+ standard: 'Standard',
+ hd: 'HD',
+ style: 'Style',
+ vivid: 'Vivid',
+ natural: 'Natural',
+ advancedSettings: 'Advanced Settings',
+ samplingSteps: 'Sampling Steps',
+ promptRelevance: 'Prompt Relevance',
+ randomSeed: 'Random Seed',
+ leaveBlankRandom: 'Leave blank for random',
+ seedTip: 'Set the same seed to reproduce the image',
+ generate: 'Generate Image',
+ pleaseSelectDrama: 'Please select a drama',
+ pleaseEnterPrompt: 'Please enter a prompt',
+ promptMinLength: 'Prompt must be at least 5 characters',
+ taskSubmitted: 'Image generation task submitted, please check results later',
+ generateFailed: 'Generation failed',
+ weak: 'Weak',
+ moderate: 'Moderate',
+ strong: 'Strong',
+ veryStrong: 'Very Strong'
+ },
+ image: {
+ title: 'AI Image Generation',
+ generate: 'Generate Image',
+ loadFailed: 'Load Failed',
+ generating: 'Generating...',
+ generateFailed: 'Generation Failed'
+ },
+ dramaWorkflow: {
+ returnToList: 'Back',
+ episodeScript: 'Episode {number} Script',
+ storyboardBreakdown: 'Storyboard Breakdown',
+ characterImages: 'Character Images',
+ createChapterPrompt: 'Please create the first chapter to start production',
+ createChapter: 'Create Chapter {number}',
+ nextStepCharacterImages: 'Next: Character Images',
+ nextStep: 'Next',
+ reGenerateShots: 'Re-split',
+ reGenerateShotsConfirm: 'Re-splitting will overwrite existing shots, are you sure?',
+ pleaseWriteScript: 'Please write script content first',
+ splitStoryboardFirst: 'Please split storyboard first',
+ aiSplitting: 'AI Splitting...',
+ aiAutoSplit: 'AI Auto Split',
+ selected: 'Selected',
+ characterCount: 'Characters',
+ generated: 'Generated',
+ batchGenerate: 'Batch Generate'
+ },
+ workflow: {
+ backToProject: 'Back to Project',
+ episodeProduction: 'Episode {number} Production',
+ steps: {
+ content: 'Episode Content',
+ generateImages: 'Generate Images',
+ splitStoryboard: 'Split Storyboard'
+ },
+ scriptPlaceholder: 'Enter episode content...',
+ saveChapter: 'Save Chapter',
+ chapterContent: 'Chapter {number} Content',
+ saved: 'Saved',
+ extractedData: 'Extracted Data',
+ characters: 'Characters',
+ scenes: 'Scenes',
+ extractedCharacters: 'Extracted Characters (This Episode)',
+ extractedScenes: 'Extracted Scenes (This Episode)',
+ extractCharactersAndScenes: 'Extract Characters and Scenes',
+ reExtract: 'Re-extract Characters and Scenes',
+ nextStepGenerateImages: 'Next Step: Generate Images',
+ extractWarning: 'Please click "Extract Characters and Scenes" first, then you can generate images after extraction is complete',
+ characterImages: 'Character Images',
+ sceneImages: 'Scene Images',
+ characterCount: '{count} characters need to generate images',
+ sceneCount: '{count} scenes need to generate images',
+ selectAll: 'Select All',
+ batchGenerate: 'Batch Generate',
+ modelConfig: 'AI Model Configuration',
+ editPrompt: 'Edit Prompt',
+ aiGenerate: 'AI Generate',
+ uploadImage: 'Upload Image',
+ selectFromLibrary: 'Select from Library',
+ shotList: 'Shot List',
+ dragFilesHere: 'Drop files here, or',
+ clickToUpload: 'Click to Upload',
+ prevStep: 'Previous Step',
+ nextStepSplitShots: 'Next Step: Split Shots',
+ reExtractConfirmTitle: 'Re-extract Confirmation',
+ reExtractConfirmMessage: 'Re-extraction will overwrite extracted characters and scenes (including generated images). Continue?',
+ startReExtracting: 'Starting re-extraction, please wait...',
+ regenerateShots: 'Regenerate Shots',
+ batchGenerateSelected: 'Batch Generate Selected Scenes',
+ generateAllImagesFirst: 'Please generate all character and scene images before splitting shots',
+ sceneImageGenerating: 'Scene image generating, please wait...',
+ sceneImageComplete: 'Scene image generation completed!',
+ sceneImageStarted: 'Scene image generation started',
+ reSplitShots: 'Re-split Shots',
+ enterProfessional: 'Enter Professional Production',
+ editShot: 'Edit Shot',
+ splitSuccess: 'Shot splitting successful! Entering professional production interface...',
+ reSplitConfirm: 'Are you sure you want to re-split the shots?',
+ deleteCharacter: 'Delete Character',
+ splitStoryboardFirst: 'Please split the storyboard first',
+ aiSplitting: 'AI Splitting...',
+ aiAutoSplit: 'AI Auto Split',
+ batchTaskSubmitted: 'Batch generation task submitted!',
+ batchGenerateFailed: 'Batch generation failed',
+ batchCompleteSuccess: 'Batch generation completed! Successfully generated {count} scenes',
+ batchCompletePartial: 'Generation completed: {success} succeeded, {fail} failed',
+ addToLibrary: 'Add to Character Library',
+ 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.',
+ addedToLibrary: 'Added to character library!',
+ addFailed: 'Add failed',
+ shotTitle: 'Shot Title',
+ shotTitlePlaceholder: 'Enter shot title',
+ shotType: 'Shot Type',
+ selectShotType: 'Select shot type',
+ longShot: 'Long Shot',
+ fullShot: 'Full Shot',
+ mediumShot: 'Medium Shot',
+ closeUp: 'Close-up',
+ extremeCloseUp: 'Extreme Close-up',
+ cameraAngle: 'Camera Angle',
+ selectAngle: 'Select angle',
+ eyeLevel: 'Eye Level',
+ lowAngle: 'Low Angle',
+ highAngle: 'High Angle',
+ location: 'Location',
+ locationPlaceholder: 'Scene location',
+ shotDescription: 'Shot Description',
+ shotDescriptionPlaceholder: 'Overall shot description',
+ cameraMovement: 'Camera Movement',
+ selectMovement: 'Select movement',
+ staticShot: 'Static Shot',
+ pushIn: 'Push In',
+ pullOut: 'Pull Out',
+ followShot: 'Follow Shot',
+ sideView: 'Side View',
+ time: 'Time',
+ timeSetting: 'Time Setting',
+ actionDescription: 'Action Description',
+ detailedAction: 'Detailed action description',
+ dialogue: 'Dialogue',
+ characterDialogue: 'Character dialogue',
+ generateImageFirst: 'Please generate character images first',
+ saveAndGenerate: 'Save and Generate',
+ saveConfig: 'Save Configuration',
+ play: 'Play',
+ pause: 'Pause',
+ addAll: 'Add All',
+ addToTimeline: 'Add to Timeline',
+ deleteAsset: 'Delete Asset',
+ confirmDelete: 'Confirm Delete',
+ tip: 'Tip',
+ edit: 'Edit'
+ },
+ tooltip: {
+ editPrompt: 'Edit Prompt',
+ aiGenerate: 'AI Generate',
+ uploadImage: 'Upload Image',
+ selectFromLibrary: 'Select from Library',
+ shotList: 'Shot List',
+ dragFilesHere: 'Drop files here, or',
+ prevStep: 'Previous Step',
+ nextStepSplitShots: 'Next Step: Split Shots',
+ reExtractConfirmTitle: 'Re-extract Confirmation',
+ reExtractConfirmMessage: 'Re-extraction will overwrite extracted characters and scenes (including generated images). Continue?',
+ startReExtracting: 'Starting re-extraction, please wait...',
+ regenerateShots: 'Regenerate Shots',
+ batchGenerateSelected: 'Batch Generate Selected Scenes',
+ generateAllImagesFirst: 'Please generate all character and scene images before splitting shots',
+ sceneImageGenerating: 'Scene image generating, please wait...',
+ sceneImageComplete: 'Scene image generation completed!',
+ sceneImageStarted: 'Scene image generation started',
+ reSplitShots: 'Re-split Shots',
+ editShot: 'Edit Shot',
+ splitSuccess: 'Shot splitting successful! Entering professional production interface...',
+ reSplitConfirm: 'Are you sure you want to re-split the shots?',
+ deleteCharacter: 'Delete Character',
+ splitStoryboardFirst: 'Please split the storyboard first',
+ aiSplitting: 'AI Splitting...',
+ aiAutoSplit: 'AI Auto Split',
+ batchTaskSubmitted: 'Batch generation task submitted!',
+ batchGenerateFailed: 'Batch generation failed',
+ batchCompleteSuccess: 'Batch generation completed! Successfully generated {count} scenes',
+ batchCompletePartial: 'Generation completed: {success} succeeded, {fail} failed',
+ addToLibrary: 'Add to Character Library',
+ 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.',
+ addedToLibrary: 'Added to character library!',
+ addFailed: 'Add failed',
+ shotTitle: 'Shot Title',
+ shotTitlePlaceholder: 'Enter shot title',
+ shotType: 'Shot Type',
+ selectShotType: 'Select shot type',
+ longShot: 'Long Shot',
+ fullShot: 'Full Shot',
+ mediumShot: 'Medium Shot',
+ closeUp: 'Close-up',
+ extremeCloseUp: 'Extreme Close-up',
+ cameraAngle: 'Camera Angle',
+ selectAngle: 'Select angle',
+ eyeLevel: 'Eye Level',
+ lowAngle: 'Low Angle',
+ highAngle: 'High Angle',
+ location: 'Location',
+ locationPlaceholder: 'Scene location',
+ shotDescription: 'Shot Description',
+ shotDescriptionPlaceholder: 'Overall shot description',
+ cameraMovement: 'Camera Movement',
+ selectMovement: 'Select movement',
+ staticShot: 'Static Shot',
+ pushIn: 'Push In',
+ pullOut: 'Pull Out',
+ followShot: 'Follow Shot',
+ sideView: 'Side View',
+ time: 'Time',
+ timeSetting: 'Time setting',
+ actionDescription: 'Action Description',
+ detailedAction: 'Detailed action description',
+ dialogue: 'Dialogue',
+ characterDialogue: 'Character dialogue',
+ generateImageFirst: 'Please generate character image first',
+ result: 'Result',
+ actionResult: 'Action result',
+ atmosphere: 'Atmosphere',
+ atmosphereDescription: 'Atmosphere description',
+ loadLibraryFailed: 'Failed to load character library',
+ imagePrompt: 'Image Prompt',
+ imagePromptPlaceholder: 'Prompt for AI image generation',
+ videoPrompt: 'Video Prompt',
+ videoPromptPlaceholder: 'Prompt for AI video generation',
+ bgmHint: 'BGM Hint',
+ bgmAtmosphere: 'BGM atmosphere description',
+ soundEffect: 'Sound Effect',
+ soundEffectDescription: 'Sound effect description',
+ durationSeconds: 'Duration (seconds)',
+ emptyLibrary: 'Character library is empty, please generate or upload character images first',
+ textModelTip: 'Used to generate episode content, characters, scenes and other text',
+ uploadFormatTip: 'Supports jpg/png formats, file size should not exceed 10MB',
+ aiModelConfig: 'AI Model Configuration',
+ textGenModel: 'Text Generation Model',
+ imageGenModel: 'Image Generation Model',
+ selectTextModel: 'Select text generation model',
+ selectImageModel: 'Select image generation model',
+ modelConfigTip: 'For generating character and scene images',
+ modelConfigSaved: 'Model configuration saved',
+ pleaseSelectModels: 'Please select text and image generation models'
+ },
+ professionalEditor: {
+ duration: 'Duration',
+ seconds: 's',
+ videoDuration: 'Video Duration',
+ downloadVideo: 'Download Video'
+ },
+ storyboard: {
+ title: 'Storyboard',
+ edit: 'Storyboard Edit',
+ create: 'Create Storyboard',
+ script: 'Script',
+ scene: 'Scene',
+ shot: 'Shot',
+ shotNumber: 'Shot {number}',
+ untitled: 'Untitled Shot',
+ scriptStructure: 'Script Structure',
+ add: 'Add',
+ noStoryboard: 'No Storyboards',
+ shotProperties: 'Shot Properties',
+ selectScene: 'Select Scene',
+ inDevelopment: 'Feature under development...',
+ generateScript: 'Generate Script',
+ generateImage: 'Generate Image',
+ generateVideo: 'Generate Video',
+ table: {
+ number: 'No.',
+ title: 'Title',
+ shotType: 'Shot Type',
+ movement: 'Movement',
+ location: 'Location',
+ character: 'Character',
+ dialogue: 'Dialogue',
+ action: 'Action',
+ duration: 'Duration',
+ operations: 'Operations'
+ }
+ },
+ timeline: {
+ title: 'Timeline Editor',
+ backToEditor: 'Back',
+ noScenes: 'No available scenes',
+ loadFailed: 'Failed to load storyboards'
+ },
+ editor: {
+ backToEpisode: 'Back to Episode Edit',
+ episode: 'Episode {number}',
+ settings: 'Settings',
+ basicInfo: 'Basic Info',
+ sceneProduction: 'Scene Production',
+ sceneId: 'Scene ID',
+ sceneGenerating: 'Scene image generating...',
+ noBackground: 'No background linked',
+ cast: 'Cast',
+ addCharacter: 'Add Character',
+ removeCharacter: 'Remove Character',
+ noCharacters: 'No characters specified',
+ visualSettings: 'Visual Settings',
+ shotType: 'Shot Type',
+ shotTypePlaceholder: 'Select shot type',
+ movement: 'Camera Movement',
+ movementPlaceholder: 'Camera movement',
+ angle: 'Camera Angle',
+ anglePlaceholder: 'Camera angle',
+ action: 'Action',
+ actionPlaceholder: 'Describe the action...',
+ result: 'Result',
+ resultPlaceholder: 'Describe the result...',
+ dialogue: 'Dialogue',
+ dialoguePlaceholder: 'Enter dialogue...',
+ soundEffects: 'Sound Effects',
+ soundEffectsPlaceholder: 'Describe sound effects...',
+ transitions: 'Transitions',
+ transitionsPlaceholder: 'Select transition',
+ duration: 'Duration',
+ seconds: 's',
+ description: 'Description',
+ descriptionPlaceholder: 'Overall shot description...',
+ bgmPrompt: 'BGM Prompt',
+ bgmPromptPlaceholder: 'Describe BGM atmosphere, e.g., Intense background music',
+ atmosphere: 'Atmosphere',
+ atmospherePlaceholder: 'Describe environment atmosphere, e.g., Dark and oppressive, Bright and warm',
+ lightingEffect: 'Lighting Effect',
+ specialEffects: 'Special Effects',
+ props: 'Props',
+ emotionalTone: 'Emotional Tone',
+ shotImage: 'Shot Image',
+ noShotSelected: 'No shot selected',
+ selectFrameType: 'Select Frame Type',
+ firstFrame: 'First Frame',
+ lastFrame: 'Last Frame',
+ panelFrame: 'Panel',
+ actionSequence: 'Action Sequence',
+ keyFrame: 'Key Frame',
+ panelCount: 'Panel Count',
+ prompt: 'Prompt',
+ extractPrompt: 'Extract Prompt',
+ promptPlaceholder: 'Click Extract Prompt button, the system will generate image prompts based on storyboard content...',
+ generating: 'Generating...',
+ generateImage: 'Generate Image',
+ uploadImage: 'Upload Image',
+ generationResult: 'Generation Result'
+ },
+ video: {
+ title: 'AI Video Generation',
+ generate: 'Generate Video',
+ merge: 'Merge Video',
+ mediaLibrary: 'Video Media Library',
+ videoCount: '{count} videos',
+ dragToTimeline: 'Drag scenes to timeline to start editing',
+ videoTrack: 'Video Track',
+ audioTrack: 'Audio Track',
+ clearTrack: 'Clear Track',
+ soundAndMusic: 'Sound & Music',
+ soundMusicInDev: 'Sound & Music feature in development',
+ noMergeYet: 'No videos merged yet',
+ mergeInstructions: 'Arrange videos in the timeline editor and click "Merge Video" to proceed',
+ selectVideoModel: 'Please select a video model',
+ mergeComplete: 'Video merge completed and downloaded!',
+ mergeTaskSubmitted: 'Video merge task submitted, processing in background...',
+ audio: 'Audio',
+ extractAudio: 'Extract audio from all video clips',
+ model: 'Model',
+ videoGeneration: 'Video Generation',
+ soundAndMusicTab: 'Sound & Music',
+ videoMerge: 'Video Merge',
+ noMergeRecords: 'No merge records',
+ transitionType: 'Transition Type',
+ transitionDuration: 'Transition Duration',
+ selectTransition: 'Select transition',
+ filter: {
+ drama: 'Script',
+ allDramas: 'All Scripts',
+ status: 'Status',
+ allStatus: 'All Status',
+ query: 'Query',
+ reset: 'Reset'
+ },
+ status: {
+ pending: 'Pending',
+ processing: 'Processing',
+ completed: 'Completed',
+ failed: 'Failed'
+ },
+ prompt: 'Prompt',
+ duration: 'Duration',
+ createdAt: 'Created At',
+ actions: {
+ view: 'View Details',
+ download: 'Download',
+ delete: 'Delete'
+ }
+ },
+ asset: {
+ title: 'Asset Library',
+ type: 'Asset Type',
+ upload: 'Upload',
+ import: 'Import',
+ export: 'Export'
+ },
+ genres: {
+ urban: 'Urban',
+ costume: 'Costume',
+ mystery: 'Mystery',
+ romance: 'Romance',
+ comedy: 'Comedy'
+ },
+ message: {
+ deleteConfirm: 'Are you sure to delete?',
+ deleteSuccess: 'Deleted successfully',
+ createSuccess: 'Created successfully',
+ updateSuccess: 'Updated successfully',
+ operationSuccess: 'Operation successful',
+ operationFailed: 'Operation failed',
+ loadingFailed: 'Loading failed',
+ networkError: 'Network error'
+ }
+}
diff --git a/web/src/locales/index.ts b/web/src/locales/index.ts
new file mode 100644
index 0000000..a87a1d3
--- /dev/null
+++ b/web/src/locales/index.ts
@@ -0,0 +1,36 @@
+import { createI18n } from 'vue-i18n'
+import zhCN from './zh-CN'
+import enUS from './en-US'
+
+// 从 localStorage 获取保存的语言,默认为中文
+const getStoredLanguage = (): string => {
+ const stored = localStorage.getItem('language')
+ if (stored) return stored
+
+ // 自动检测浏览器语言
+ const browserLang = navigator.language.toLowerCase()
+ if (browserLang.startsWith('zh')) return 'zh-CN'
+ return 'en-US'
+}
+
+const i18n = createI18n({
+ legacy: false, // 使用 Composition API 模式
+ locale: getStoredLanguage(),
+ fallbackLocale: 'zh-CN',
+ messages: {
+ 'zh-CN': zhCN,
+ 'en-US': enUS
+ }
+})
+
+export default i18n
+
+// 导出语言切换函数
+export const setLanguage = (lang: string) => {
+ i18n.global.locale.value = lang as any
+ localStorage.setItem('language', lang)
+}
+
+export const getCurrentLanguage = () => {
+ return i18n.global.locale.value
+}
diff --git a/web/src/locales/zh-CN.ts b/web/src/locales/zh-CN.ts
new file mode 100644
index 0000000..df4c4d3
--- /dev/null
+++ b/web/src/locales/zh-CN.ts
@@ -0,0 +1,617 @@
+export default {
+ nav: {
+ home: '首页',
+ characters: '角色管理',
+ storyboard: '分镜制作',
+ videos: '视频管理',
+ assets: '资源库',
+ settings: '设置',
+ dramas: '短剧项目'
+ },
+ dashboard: {
+ title: '🎬 Drama Generator',
+ welcome: '欢迎使用 AI 短剧生成平台',
+ subtitle: '从剧本到视频,一站式短剧创作工具',
+ stats: {
+ projects: '短剧项目',
+ images: '生成图片',
+ videos: '生成视频',
+ tasks: '处理中任务'
+ },
+ quickStart: '快速开始',
+ actions: {
+ newProject: '创建新项目',
+ newProjectDesc: '开始一个全新的短剧项目',
+ myProjects: '我的项目',
+ myProjectsDesc: '查看和管理已有项目'
+ }
+ },
+ common: {
+ create: '创建',
+ edit: '编辑',
+ delete: '删除',
+ save: '保存',
+ cancel: '取消',
+ confirm: '确定',
+ search: '搜索',
+ filter: '筛选',
+ reset: '重置',
+ submit: '提交',
+ close: '关闭',
+ back: '返回',
+ next: '下一步',
+ previous: '上一步',
+ selectAll: '全选',
+ loading: '加载中...',
+ success: '成功',
+ failed: '失败',
+ noData: '暂无数据',
+ pleaseSelect: '请选择',
+ add: '添加',
+ view: '查看',
+ upload: '上传',
+ download: '下载',
+ generating: '生成中...',
+ notGenerated: '未生成',
+ generateFailed: '生成失败',
+ clickToRegenerate: '点击重新生成',
+ queuing: '排队中',
+ processing: '处理中',
+ saveAndGenerate: '保存并生成',
+ saveConfig: '保存配置',
+ play: '播放',
+ pause: '暂停',
+ addAll: '一键添加全部',
+ addToTimeline: '添加到时间线',
+ deleteAsset: '删除素材',
+ confirmDelete: '确认删除',
+ tip: '提示',
+ status: '状态',
+ createdAt: '创建时间',
+ updatedAt: '更新时间'
+ },
+ settings: {
+ title: '设置',
+ aiConfig: 'AI配置',
+ general: '通用设置',
+ language: '语言',
+ theme: '主题'
+ },
+ aiConfig: {
+ title: 'AI 服务配置',
+ addConfig: '添加配置',
+ editConfig: '编辑配置',
+ back: '返回',
+ empty: '暂无配置,点击添加配置开始使用',
+ enabled: '已启用',
+ disabled: '已禁用',
+ enable: '启用',
+ disable: '禁用',
+ endpoint: '端点',
+ queryEndpoint: '查询端点',
+ tabs: {
+ text: '文本生成',
+ image: '图片生成',
+ video: '视频生成'
+ },
+ form: {
+ name: '配置名称',
+ namePlaceholder: '例如:OpenAI GPT-4',
+ provider: '厂商',
+ providerPlaceholder: '请选择厂商',
+ providerTip: '选择AI服务提供商',
+ priority: '优先级',
+ priorityTip: '数值越大优先级越高,相同模型时优先使用高优先级配置',
+ model: '模型',
+ modelPlaceholder: '输入或选择模型名称',
+ modelTip: '可直接输入模型名称或从列表选择,支持多个模型',
+ baseUrl: 'Base URL',
+ baseUrlPlaceholder: 'https://api.openai.com',
+ baseUrlTip: 'API 服务的基础地址,如 Chatfire: https://api.chatfire.site/v1,Gemini: https://generativelanguage.googleapis.com(无需 /v1)',
+ fullEndpoint: '完整调用路径',
+ apiKey: 'API Key',
+ apiKeyPlaceholder: 'sk-...',
+ apiKeyTip: '您的 API 密钥',
+ isActive: '启用状态'
+ },
+ actions: {
+ test: '测试连接',
+ delete: '删除',
+ edit: '编辑'
+ },
+ messages: {
+ deleteConfirm: '确定要删除此配置吗?',
+ testSuccess: '连接测试成功!',
+ testFailed: '连接测试失败'
+ }
+ },
+ drama: {
+ title: '短剧管理',
+ create: '创建项目',
+ totalProjects: '共 {count} 个项目',
+ createNew: '创建新项目',
+ createDesc: '开始创作您的短剧项目',
+ aiConfig: 'AI配置',
+ aiConfigTip: '请先配置 AI 服务后再创建项目',
+ empty: '暂无项目,点击上方按钮创建新项目',
+ emptyHint: '点击上方"创建新项目"按钮开始您的第一部短剧',
+ editProject: '编辑项目',
+ projectName: '项目名称',
+ projectNamePlaceholder: '请输入项目名称',
+ projectDesc: '项目描述',
+ projectDescPlaceholder: '请输入项目描述(可选)',
+ deleteConfirm: '确定要删除这个项目吗?',
+ noCover: '暂无封面',
+ noDescription: '暂无描述',
+ status: {
+ draft: '草稿',
+ production: '制作中',
+ completed: '已完成'
+ },
+ actions: {
+ edit: '编辑',
+ view: '查看',
+ delete: '删除'
+ },
+ management: {
+ overview: '项目概览',
+ episodes: '章节管理',
+ characters: '角色管理',
+ scenes: '场景管理',
+ projectInfo: '项目信息',
+ projectName: '项目名称',
+ projectDesc: '项目描述',
+ noDescription: '暂无描述',
+ episodeStats: '章节统计',
+ characterStats: '角色统计',
+ sceneStats: '场景统计',
+ episodesCreated: '已创建章节',
+ charactersCreated: '已创建角色',
+ sceneLibraryCount: '场景库数量',
+ startFirstEpisode: '开始创作您的第一个章节!',
+ noEpisodesYet: '您的项目还没有章节。请先创建一个章节开始制作。',
+ createFirstEpisode: '立即创建第一个章节',
+ episodeList: '章节列表',
+ createNewEpisode: '创建新章节',
+ noEpisodes: '还没有章节',
+ clickToCreate: '点击上方按钮创建第一个章节',
+ episodeNumber: '第 {number} 章',
+ goToEdit: '进入编辑',
+ characterList: '角色列表',
+ noCharacters: '还没有角色',
+ charactersTip: '角色将在剧本生成阶段自动创建',
+ sceneList: '场景列表',
+ noScenes: '还没有场景',
+ scenesTip: '场景将在分镜生成阶段自动创建'
+ }
+ },
+ character: {
+ title: '角色管理',
+ create: '创建角色',
+ edit: '编辑角色',
+ add: '添加角色',
+ list: '角色列表',
+ name: '角色名称',
+ role: '角色',
+ personality: '性格',
+ appearance: '外貌',
+ background: '背景',
+ description: '角色描述',
+ image: '角色形象',
+ generate: '生成角色形象',
+ extracting: '提取中...',
+ generateImage: '生成形象',
+ batch: '批量操作',
+ empty: '角色已在剧本生成阶段创建,您可以在此查看和编辑',
+ backToProject: '返回项目',
+ saveChanges: '保存修改',
+ nextStep: '下一步:生成角色图片'
+ },
+ script: {
+ title: '剧本生成',
+ backToProject: '返回项目',
+ aiGenerate: 'AI 生成剧本',
+ uploadScript: '上传剧本',
+ steps: {
+ outline: '生成大纲',
+ characters: '生成角色',
+ episodes: '生成剧集'
+ },
+ form: {
+ theme: '创作主题',
+ themePlaceholder: '描述你想创作的短剧主题和故事概念',
+ genre: '类型偏好',
+ genrePlaceholder: '选择类型',
+ style: '风格要求',
+ stylePlaceholder: '例如:轻松幽默、紧张刺激、温馨治愈',
+ episodeCount: '剧集数量',
+ randomGenerate: '随机生成',
+ title: '标题',
+ titlePlaceholder: '请输入剧本标题',
+ summary: '概要',
+ summaryPlaceholder: '请输入剧本概要',
+ genreExample: '例如:都市、古装',
+ tags: '标签',
+ newTag: '新标签'
+ },
+ notice: '请输入创作主题和相关要求,AI将为您生成剧本大纲',
+ generateFailed: '生成失败',
+ generating: '生成中...',
+ nextStep: '下一步',
+ prevStep: '上一步',
+ complete: '完成',
+ regenerate: '重新生成',
+ regenerateOutline: '重新生成大纲',
+ outlinePreview: '大纲预览(可编辑)'
+ },
+ imageDialog: {
+ title: 'AI 图片生成',
+ selectDrama: '选择剧本',
+ selectScene: '选择场景',
+ selectSceneOptional: '选择场景(可选)',
+ sceneLabel: '场景{number}: {title}',
+ prompt: '提示词',
+ promptPlaceholder: '描述你想生成的图片\n例如:A beautiful landscape with mountains and rivers at sunset, cinematic lighting, highly detailed',
+ negativePrompt: '反向提示词',
+ negativePromptPlaceholder: '描述不希望出现的元素(可选)\n例如:blurry, low quality, watermark',
+ aiService: 'AI 服务',
+ selectService: '选择服务',
+ imageSize: '图片尺寸',
+ selectSize: '选择尺寸',
+ square: '正方形',
+ landscape: '横向',
+ portrait: '纵向',
+ imageQuality: '图片质量',
+ standard: '标准',
+ hd: '高清',
+ style: '风格',
+ vivid: '鲜艳',
+ natural: '自然',
+ advancedSettings: '高级设置',
+ samplingSteps: '采样步数',
+ promptRelevance: '提示词相关性',
+ randomSeed: '随机种子',
+ leaveBlankRandom: '留空随机',
+ seedTip: '设置相同种子可复现图片',
+ generate: '生成图片',
+ pleaseSelectDrama: '请选择剧本',
+ pleaseEnterPrompt: '请输入提示词',
+ promptMinLength: '提示词至少5个字符',
+ taskSubmitted: '图片生成任务已提交,请稍后查看结果',
+ generateFailed: '生成失败',
+ weak: '弱',
+ moderate: '适中',
+ strong: '强',
+ veryStrong: '很强'
+ },
+ image: {
+ title: 'AI 图片生成',
+ generate: '生成图片',
+ loadFailed: '加载失败',
+ generating: '生成中...',
+ generateFailed: '生成失败'
+ },
+ dramaWorkflow: {
+ returnToList: '返回',
+ episodeScript: '第{number}集剧本',
+ storyboardBreakdown: '分镜拆解',
+ characterImages: '角色图片',
+ createChapterPrompt: '请创建第一章开始制作',
+ createChapter: '创建第{number}章',
+ nextStepCharacterImages: '下一步:角色图片',
+ nextStep: '下一步',
+ reGenerateShots: '重新拆分',
+ reGenerateShotsConfirm: '重新拂分将覆盖现有镜头,确定继续吗?',
+ pleaseWriteScript: '请先创作剧本内容',
+ splitStoryboardFirst: '请先对剧本进行分镜拆解',
+ aiSplitting: 'AI拆分中...',
+ aiAutoSplit: 'AI自动拆分',
+ selected: '已选',
+ characterCount: '角色数',
+ generated: '已生成',
+ batchGenerate: '批量生成'
+ },
+ workflow: {
+ backToProject: '返回项目',
+ episodeProduction: '第{number}章制作',
+ steps: {
+ content: '章节内容',
+ generateImages: '生成图片',
+ splitStoryboard: '拆分分镜'
+ },
+ scriptPlaceholder: '请输入章节内容...',
+ saveChapter: '保存章节',
+ chapterContent: '第{number}章内容',
+ saved: '已保存',
+ extractedData: '已提取数据',
+ characters: '角色',
+ scenes: '场景',
+ extractedCharacters: '提取的角色(本集)',
+ extractedScenes: '提取的场景(本集)',
+ extractCharactersAndScenes: '提取角色和场景',
+ reExtract: '重新提取角色和场景',
+ nextStepGenerateImages: '下一步:生成图片',
+ extractWarning: '请先点击“提取角色和场景”按钮,完成提取后才能生成图片',
+ characterImages: '角色图片',
+ sceneImages: '场景图片',
+ characterCount: '共 {count} 个角色需要生成图片',
+ sceneCount: '共 {count} 个场景需要生成图片',
+ selectAll: '全选',
+ batchGenerate: '批量生成',
+ modelConfig: 'AI模型配置',
+ editPrompt: '修改提示词',
+ aiGenerate: 'AI生成',
+ uploadImage: '上传图片',
+ selectFromLibrary: '从角色库选择',
+ shotList: '镜头列表',
+ dragFilesHere: '将文件拖到此处,或',
+ clickToUpload: '点击上传',
+ prevStep: '上一步',
+ nextStepSplitShots: '下一步:拆分分镜',
+ reExtractConfirmTitle: '重新提取确认',
+ reExtractConfirmMessage: '重新提取将覆盖已提取的角色和场景(包括已生成的图片),确定继续吗?',
+ startReExtracting: '开始重新提取,请稍候...',
+ regenerateShots: '重新生成分镜',
+ batchGenerateSelected: '批量生成选中场景',
+ generateAllImagesFirst: '请先生成所有角色和场景图片后再进行分镜拆分',
+ sceneImageGenerating: '场景图片生成中,请稍候...',
+ sceneImageComplete: '场景图片生成完成!',
+ sceneImageStarted: '场景图片生成已启动',
+ reSplitShots: '重新拆分',
+ enterProfessional: '进入专业制作',
+ editShot: '编辑镜头',
+ splitSuccess: '分镜拆分成功!正在进入专业制作界面...',
+ reSplitConfirm: '确定要重新拂分分镜吗?',
+ deleteCharacter: '删除角色',
+ splitStoryboardFirst: '请先对章节进行分镜拆解',
+ aiSplitting: 'AI拆分中...',
+ aiAutoSplit: 'AI自动拆分',
+ batchTaskSubmitted: '批量生成任务已提交!',
+ batchGenerateFailed: '批量生成失败',
+ batchCompleteSuccess: '批量生成完成!成功生成 {count} 个场景',
+ batchCompletePartial: '生成完成:成功 {success} 个,失败 {fail} 个',
+ addToLibrary: '添加到角色库',
+ addToLibraryConfirm: '确定要将角色“{name}”添加到全局角色库吗?添加后可以在所有项目中使用该角色形象。',
+ addedToLibrary: '已添加到角色库!',
+ addFailed: '添加失败',
+ shotTitle: '镜头标题',
+ shotTitlePlaceholder: '请输入镜头标题',
+ shotType: '景别',
+ selectShotType: '选择景别',
+ longShot: '远景',
+ fullShot: '全景',
+ mediumShot: '中景',
+ closeUp: '近景',
+ extremeCloseUp: '特写',
+ cameraAngle: '镜头角度',
+ selectAngle: '选择角度',
+ eyeLevel: '平视',
+ lowAngle: '仰视',
+ highAngle: '俯视',
+ location: '地点',
+ locationPlaceholder: '场景地点',
+ shotDescription: '镜头描述',
+ shotDescriptionPlaceholder: '镜头整体描述',
+ cameraMovement: '运镜方式',
+ selectMovement: '选择运镜',
+ staticShot: '固定镜头',
+ pushIn: '推镜',
+ pullOut: '拉镜',
+ followShot: '跟镜',
+ sideView: '侧面',
+ time: '时间',
+ timeSetting: '时间设定',
+ actionDescription: '动作描述',
+ detailedAction: '详细动作描述',
+ dialogue: '对白',
+ characterDialogue: '角色对白',
+ generateImageFirst: '请先生成角色图片',
+ result: '画面结果',
+ actionResult: '动作结果',
+ atmosphere: '环境氛围',
+ atmosphereDescription: '环境氛围描述',
+ loadLibraryFailed: '获取角色库失败',
+ imagePrompt: '图片提示词',
+ imagePromptPlaceholder: '用于AI生成图片的提示词',
+ videoPrompt: '视频提示词',
+ videoPromptPlaceholder: '用于AI生成视频的提示词',
+ bgmHint: '配乐提示',
+ bgmAtmosphere: '配乐氛围描述',
+ soundEffect: '音效',
+ soundEffectDescription: '音效描述',
+ durationSeconds: '时长(秒)',
+ emptyLibrary: '角色库为空,请先生成或上传角色图片',
+ textModelTip: '用于生成章节内容、角色、场景等文本',
+ uploadFormatTip: '支持 jpg/png 格式,文件大小不超过 10MB',
+ aiModelConfig: 'AI模型配置',
+ textGenModel: '文本生成模型',
+ imageGenModel: '图片生成模型',
+ selectTextModel: '选择文本生成模型',
+ selectImageModel: '选择图片生成模型',
+ modelConfigTip: '用于生成角色和场景图片',
+ modelConfigSaved: '模型配置已保存',
+ pleaseSelectModels: '请选择文本和图片生成模型'
+ },
+ professionalEditor: {
+ duration: '时长',
+ seconds: '秒',
+ videoDuration: '视频时长',
+ downloadVideo: '下载视频'
+ },
+ storyboard: {
+ title: '分镜制作',
+ edit: '分镜编辑',
+ create: '创建分镜',
+ script: '剧本',
+ scene: '场景',
+ shot: '镜头',
+ shotNumber: '镜头 {number}',
+ untitled: '未命名镜头',
+ scriptStructure: '剧本结构',
+ add: '添加',
+ noStoryboard: '暂无分镜',
+ shotProperties: '镜头属性',
+ selectScene: '选择场景',
+ inDevelopment: '功能开发中...',
+ generateScript: '生成分镜脚本',
+ generateImage: '生成分镜图片',
+ generateVideo: '生成视频',
+ table: {
+ number: '编号',
+ title: '标题',
+ shotType: '景别',
+ movement: '运镜',
+ location: '地点',
+ character: '角色',
+ dialogue: '对白',
+ action: '动作',
+ duration: '时长',
+ operations: '操作'
+ }
+ },
+ timeline: {
+ title: '时间线编辑器',
+ backToEditor: '返回',
+ noScenes: '暂无可用场景',
+ loadFailed: '加载分镜失败'
+ },
+ editor: {
+ backToEpisode: '返回剧集编辑',
+ episode: '第{number}集',
+ settings: '设置',
+ basicInfo: '基础信息',
+ sceneProduction: '场景制作',
+ sceneId: '场景ID',
+ sceneGenerating: '场景图片生成中...',
+ noBackground: '未关联背景',
+ cast: '登场角色',
+ addCharacter: '添加角色',
+ removeCharacter: '移除角色',
+ noCharacters: '未指定角色',
+ visualSettings: '视效设置',
+ shotType: '景别',
+ shotTypePlaceholder: '选择景别',
+ movement: '运镜方式',
+ movementPlaceholder: '运镜方式',
+ angle: '镜头角度',
+ anglePlaceholder: '镜头角度',
+ action: '动作描述',
+ actionPlaceholder: '描述角色的动作过程...',
+ result: '动作结果',
+ resultPlaceholder: '描述动作的结果...',
+ dialogue: '对白',
+ dialoguePlaceholder: '输入角色对白...',
+ soundEffects: '音效',
+ soundEffectsPlaceholder: '描述音效...',
+ transitions: '转场效果',
+ transitionsPlaceholder: '选择转场',
+ duration: '时长',
+ seconds: '秒',
+ description: '镜头描述',
+ descriptionPlaceholder: '整体镜头描述...',
+ bgmPrompt: '配乐提示',
+ bgmPromptPlaceholder: '描述配乐氛围,如:紧张激烈的背景音乐',
+ atmosphere: '环境氛围',
+ atmospherePlaceholder: '描述环境氛围,如:昱暗压抑、明亮温馨',
+ lightingEffect: '光照效果',
+ specialEffects: '特效',
+ props: '道具',
+ emotionalTone: '情绪色调',
+ shotImage: '镜头图片',
+ noShotSelected: '未选择镜头',
+ selectFrameType: '选择帧类型',
+ firstFrame: '首帧',
+ lastFrame: '尾帧',
+ panelFrame: '分镜板',
+ actionSequence: '动作序列',
+ keyFrame: '关键帧',
+ panelCount: '格数',
+ prompt: '提示词',
+ extractPrompt: '提取提示词',
+ promptPlaceholder: '点击提取提示词按钮,系统将根据分镜内容生成图片提示词...',
+ generating: '生成中...',
+ generateImage: '生成图片',
+ uploadImage: '上传图片',
+ generationResult: '生成结果'
+ },
+ video: {
+ title: 'AI 视频生成',
+ generate: '生成视频',
+ merge: '合成视频',
+ mediaLibrary: '视频素材库',
+ videoCount: '{count} 个视频',
+ dragToTimeline: '将场景拖拽到时间线开始编辑',
+ videoTrack: '视频轨道',
+ audioTrack: '音频轨道',
+ clearTrack: '清空轨道',
+ soundAndMusic: '音效与配乐',
+ soundMusicInDev: '音效与配乐功能开发中',
+ noMergeYet: '还没有合成过视频',
+ mergeInstructions: '在时间线编辑器中排列好视频后点击“合成视频”即可',
+ selectVideoModel: '请选择视频模型',
+ mergeComplete: '视频合成完成并已下载!',
+ mergeTaskSubmitted: '视频合成任务已提交,正在后台处理...',
+ audio: '音频',
+ extractAudio: '从所有视频片段提取音频',
+ model: '模型',
+ videoGeneration: '视频生成',
+ soundAndMusicTab: '音效与配乐',
+ videoMerge: '视频合成',
+ noMergeRecords: '暂无视频合成记录',
+ transitionType: '转场类型',
+ transitionDuration: '转场时长',
+ selectTransition: '选择转场效果',
+ filter: {
+ drama: '剧本',
+ allDramas: '全部剧本',
+ status: '状态',
+ allStatus: '全部状态',
+ query: '查询',
+ reset: '重置'
+ },
+ status: {
+ pending: '等待中',
+ processing: '生成中',
+ completed: '已完成',
+ failed: '失败'
+ },
+ prompt: '提示词',
+ duration: '时长',
+ createdAt: '创建时间',
+ actions: {
+ view: '查看详情',
+ download: '下载',
+ delete: '删除'
+ }
+ },
+ asset: {
+ title: '资源库',
+ type: '资源类型',
+ upload: '上传',
+ import: '导入',
+ export: '导出'
+ },
+ genres: {
+ urban: '都市',
+ costume: '古装',
+ mystery: '悬疑',
+ romance: '爱情',
+ comedy: '喜剧'
+ },
+ tooltip: {
+ editPrompt: '修改提示词',
+ aiGenerate: 'AI生成',
+ uploadImage: '上传图片',
+ selectFromLibrary: '从角色库选择'
+ },
+ message: {
+ deleteConfirm: '确定要删除吗?',
+ deleteSuccess: '删除成功',
+ createSuccess: '创建成功',
+ updateSuccess: '更新成功',
+ operationSuccess: '操作成功',
+ operationFailed: '操作失败',
+ loadingFailed: '加载失败',
+ networkError: '网络错误'
+ }
+}
diff --git a/web/src/main.ts b/web/src/main.ts
new file mode 100644
index 0000000..fa42523
--- /dev/null
+++ b/web/src/main.ts
@@ -0,0 +1,32 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import './assets/styles/element/index.scss'
+
+import * as ElementPlusIconsVue from '@element-plus/icons-vue'
+
+import App from './App.vue'
+import router from './router'
+import i18n from './locales'
+import './assets/styles/main.css'
+
+// Apply theme before app mounts to prevent flash
+// 在应用挂载前应用主题,防止闪烁
+const savedTheme = localStorage.getItem('theme')
+if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
+ document.documentElement.classList.add('dark')
+}
+
+const app = createApp(App)
+
+app.use(createPinia())
+app.use(router)
+app.use(i18n)
+app.use(ElementPlus)
+
+for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
+ app.component(key, component)
+}
+
+app.mount('#app')
diff --git a/web/src/router/index.ts b/web/src/router/index.ts
new file mode 100644
index 0000000..e167126
--- /dev/null
+++ b/web/src/router/index.ts
@@ -0,0 +1,84 @@
+import type { RouteRecordRaw } from 'vue-router'
+import { createRouter, createWebHistory } from 'vue-router'
+
+const routes: RouteRecordRaw[] = [
+ {
+ path: '/',
+ name: 'DramaList',
+ component: () => import('../views/drama/DramaList.vue')
+ },
+ {
+ path: '/dramas/create',
+ name: 'DramaCreate',
+ component: () => import('../views/drama/DramaCreate.vue')
+ },
+ {
+ path: '/dramas/:id',
+ name: 'DramaManagement',
+ component: () => import('../views/drama/DramaManagement.vue')
+ },
+ {
+ path: '/dramas/:id/episode/:episodeNumber',
+ name: 'EpisodeWorkflowNew',
+ component: () => import('../views/drama/EpisodeWorkflow.vue')
+ },
+ {
+ path: '/dramas/:id/script',
+ name: 'ScriptGeneration',
+ component: () => import('../views/workflow/ScriptGeneration.vue')
+ },
+ {
+ path: '/dramas/:id/characters',
+ name: 'CharacterExtraction',
+ component: () => import('../views/workflow/CharacterExtraction.vue')
+ },
+ {
+ path: '/dramas/:id/images/characters',
+ name: 'CharacterImages',
+ component: () => import('../views/workflow/CharacterImages.vue')
+ },
+ {
+ path: '/dramas/:id/settings',
+ name: 'DramaSettings',
+ component: () => import('../views/workflow/DramaSettings.vue')
+ },
+ {
+ path: '/episodes/:id/edit',
+ name: 'ScriptEdit',
+ component: () => import('../views/script/ScriptEdit.vue')
+ },
+ {
+ path: '/episodes/:id/storyboard',
+ name: 'StoryboardEdit',
+ component: () => import('../views/storyboard/StoryboardEdit.vue')
+ },
+ {
+ path: '/episodes/:id/generate',
+ name: 'Generation',
+ component: () => import('../views/generation/ImageGeneration.vue')
+ },
+ {
+ path: '/timeline/:id',
+ name: 'TimelineEditor',
+ component: () => import('../views/editor/TimelineEditor.vue')
+ },
+ {
+ path: '/dramas/:dramaId/episode/:episodeNumber/professional',
+ name: 'ProfessionalEditor',
+ component: () => import('../views/drama/ProfessionalEditor.vue')
+ },
+ {
+ path: '/settings/ai-config',
+ name: 'AIConfig',
+ component: () => import('../views/settings/AIConfig.vue')
+ }
+]
+
+const router = createRouter({
+ history: createWebHistory(import.meta.env.BASE_URL),
+ routes
+})
+
+// 开源版本 - 无需认证
+
+export default router
diff --git a/web/src/stores/episode.ts b/web/src/stores/episode.ts
new file mode 100644
index 0000000..4bea741
--- /dev/null
+++ b/web/src/stores/episode.ts
@@ -0,0 +1,233 @@
+import { ref, computed, reactive } from 'vue'
+import { defineStore } from 'pinia'
+import { dramaAPI } from '@/api/drama'
+import type { Episode, Character, Scene } from '@/types/drama'
+
+interface EpisodeCache {
+ data: Episode
+ loading: boolean
+ error: string | null
+ lastFetch: number
+}
+
+interface EpisodeOperations {
+ refresh: () => Promise
+ set: (params: SetOperationParams) => Promise
+ del: (params: DeleteOperationParams) => Promise
+ saveScript: (content: string) => Promise
+ extractData: () => Promise
+ generateImages: (options?: GenerateImageOptions) => Promise
+ generateStoryboards: () => Promise
+}
+
+interface SetOperationParams {
+ type: 'character' | 'scene' | 'storyboard'
+ data: any
+}
+
+interface DeleteOperationParams {
+ type: 'character' | 'scene' | 'storyboard'
+ id: string | number
+}
+
+interface GenerateImageOptions {
+ characterIds?: number[]
+ sceneIds?: string[]
+}
+
+export interface CachedEpisode {
+ value: Episode
+ loading: boolean
+ error: string | null
+ refresh: () => Promise
+ set: (params: SetOperationParams) => Promise
+ del: (params: DeleteOperationParams) => Promise
+ saveScript: (content: string) => Promise
+ extractData: () => Promise
+ generateImages: (options?: GenerateImageOptions) => Promise
+ generateStoryboards: () => Promise
+}
+
+export const useEpisodeStore = defineStore('episode', () => {
+ const caches = reactive>(new Map())
+
+ const getCacheByEpisodeId = (episodeId: string): CachedEpisode => {
+ if (!caches.has(episodeId)) {
+ caches.set(episodeId, {
+ data: {} as Episode,
+ loading: false,
+ error: null,
+ lastFetch: 0
+ })
+ fetchEpisode(episodeId)
+ }
+
+ const cache = caches.get(episodeId)!
+
+ const operations: EpisodeOperations = {
+ async refresh() {
+ await fetchEpisode(episodeId, true)
+ },
+
+ async set(params: SetOperationParams) {
+ const { type, data } = params
+
+ switch (type) {
+ case 'character':
+ await dramaAPI.saveCharacters(cache.data.drama_id, [data], episodeId)
+ await fetchEpisode(episodeId, true)
+ break
+ case 'scene':
+ await dramaAPI.updateScene(data.id, data)
+ await fetchEpisode(episodeId, true)
+ break
+ case 'storyboard':
+ await dramaAPI.updateStoryboard(data.id, data)
+ await fetchEpisode(episodeId, true)
+ break
+ }
+ },
+
+ async del(params: DeleteOperationParams) {
+ const { type, id } = params
+
+ switch (type) {
+ case 'character':
+ const characters = cache.data.characters?.filter(c => c.id !== id) || []
+ await dramaAPI.saveCharacters(cache.data.drama_id, characters, episodeId)
+ await fetchEpisode(episodeId, true)
+ break
+ case 'scene':
+ break
+ case 'storyboard':
+ break
+ }
+ },
+
+ async saveScript(content: string) {
+ const parts = episodeId.split('-')
+ const dramaId = parts[0]
+ const episodeNumber = parseInt(parts.length > 1 ? parts[1] : cache.data.episode_number?.toString() || '1')
+
+ await dramaAPI.saveEpisodes(dramaId, [{
+ episode_number: episodeNumber,
+ script_content: content
+ }])
+
+ await fetchEpisode(episodeId, true)
+ },
+
+ async extractData() {
+ await dramaAPI.extractBackgrounds(episodeId)
+ await fetchEpisode(episodeId, true)
+ },
+
+ async generateImages(options?: GenerateImageOptions) {
+ const promises: Promise[] = []
+
+ if (options?.characterIds && options.characterIds.length > 0) {
+ options.characterIds.forEach(id => {
+ const character = cache.data.characters?.find(c => c.id === id)
+ if (character) {
+ promises.push(
+ dramaAPI.generateSceneImage({
+ scene_id: character.id.toString(),
+ prompt: character.appearance || character.description || character.name,
+ model: undefined
+ })
+ )
+ }
+ })
+ }
+
+ if (options?.sceneIds && options.sceneIds.length > 0) {
+ options.sceneIds.forEach(sceneId => {
+ promises.push(
+ dramaAPI.generateSceneImage({
+ scene_id: sceneId,
+ model: undefined
+ })
+ )
+ })
+ }
+
+ if (promises.length > 0) {
+ await Promise.allSettled(promises)
+ }
+
+ await fetchEpisode(episodeId, true)
+ },
+
+ async generateStoryboards() {
+ await dramaAPI.generateStoryboard(episodeId)
+ await fetchEpisode(episodeId, true)
+ }
+ }
+
+ return {
+ get value() {
+ return cache.data
+ },
+ get loading() {
+ return cache.loading
+ },
+ get error() {
+ return cache.error
+ },
+ ...operations
+ }
+ }
+
+ const fetchEpisode = async (episodeId: string, force = false) => {
+ const cache = caches.get(episodeId)
+ if (!cache) return
+
+ const now = Date.now()
+ if (!force && cache.lastFetch && (now - cache.lastFetch) < 3000) {
+ return
+ }
+
+ cache.loading = true
+ cache.error = null
+
+ try {
+ const parts = episodeId.split('-')
+ const dramaId = parts[0]
+ const episodeNumber = parts.length > 1 ? parseInt(parts[1]) : null
+
+ const drama = await dramaAPI.get(dramaId)
+
+ let episode: Episode | undefined
+ if (episodeNumber !== null) {
+ episode = drama.episodes?.find(e => e.episode_number === episodeNumber)
+ } else {
+ episode = drama.episodes?.find(e => e.id === episodeId)
+ }
+
+ if (episode) {
+ cache.data = episode
+ cache.lastFetch = now
+ } else {
+ cache.error = '未找到章节数据'
+ }
+ } catch (error: any) {
+ cache.error = error.message || '加载章节数据失败'
+ console.error('Failed to fetch episode:', error)
+ } finally {
+ cache.loading = false
+ }
+ }
+
+ const clearCache = (episodeId?: string) => {
+ if (episodeId) {
+ caches.delete(episodeId)
+ } else {
+ caches.clear()
+ }
+ }
+
+ return {
+ getCacheByEpisodeId,
+ clearCache
+ }
+})
diff --git a/web/src/types/ai.ts b/web/src/types/ai.ts
new file mode 100644
index 0000000..5de6168
--- /dev/null
+++ b/web/src/types/ai.ts
@@ -0,0 +1,65 @@
+export interface AIServiceConfig {
+ id: number
+ service_type: AIServiceType
+ provider?: string // 厂商标识
+ name: string
+ base_url: string
+ api_key: string
+ model: string | string[] // 支持单个或多个模型
+ endpoint: string
+ query_endpoint?: string // 异步查询端点(用于视频等异步任务)
+ priority: number // 优先级,数值越大优先级越高
+ is_active: boolean
+ settings?: string
+ created_at: string
+ updated_at: string
+}
+
+export type AIServiceType = 'text' | 'image' | 'video'
+
+export interface CreateAIConfigRequest {
+ service_type: AIServiceType
+ provider?: string // 厂商标识
+ name: string
+ base_url: string
+ api_key: string
+ model: string | string[] // 支持单个或多个模型
+ endpoint?: string
+ query_endpoint?: string // 异步查询端点(用于视频等异步任务)
+ priority?: number // 优先级,数值越大优先级越高
+ settings?: string
+}
+
+export interface UpdateAIConfigRequest {
+ name?: string
+ provider?: string // 厂商标识
+ base_url?: string
+ api_key?: string
+ model?: string | string[] // 支持单个或多个模型
+ endpoint?: string
+ query_endpoint?: string // 异步查询端点(用于视频等异步任务)
+ priority?: number // 优先级,数值越大优先级越高
+ is_active?: boolean
+ settings?: string
+}
+
+export interface TestConnectionRequest {
+ base_url: string
+ api_key: string
+ model: string | string[] // 支持单个或多个模型
+ provider?: string // 厂商标识
+ endpoint?: string
+ query_endpoint?: string // 异步查询端点(用于视频等异步任务)
+}
+
+export interface AIServiceProvider {
+ id: number
+ name: string
+ display_name: string
+ service_type: AIServiceType
+ default_url: string
+ description: string
+ is_active: boolean
+ created_at: string
+ updated_at: string
+}
diff --git a/web/src/types/asset.ts b/web/src/types/asset.ts
new file mode 100644
index 0000000..6162a22
--- /dev/null
+++ b/web/src/types/asset.ts
@@ -0,0 +1,94 @@
+export interface Asset {
+ id: number
+ drama_id?: number
+ episode_id?: number
+ storyboard_id?: number
+ storyboard_num?: number
+ name: string
+ description?: string
+ type: AssetType
+ category?: string
+ url: string
+ thumbnail_url?: string
+ local_path?: string
+ file_size?: number
+ mime_type?: string
+ width?: number
+ height?: number
+ duration?: number
+ format?: string
+ image_gen_id?: number
+ video_gen_id?: number
+ tags?: AssetTag[]
+ collections?: AssetCollection[]
+ is_favorite: boolean
+ view_count: number
+ created_at: string
+ updated_at: string
+}
+
+export type AssetType = 'image' | 'video' | 'audio'
+
+export interface AssetTag {
+ id: number
+ name: string
+ color?: string
+ created_at: string
+}
+
+export interface AssetCollection {
+ id: number
+ drama_id?: number
+ name: string
+ description?: string
+ assets?: Asset[]
+ created_at: string
+}
+
+export interface CreateAssetRequest {
+ drama_id?: number
+ name: string
+ description?: string
+ type: AssetType
+ category?: string
+ url: string
+ thumbnail_url?: string
+ local_path?: string
+ file_size?: number
+ mime_type?: string
+ width?: number
+ height?: number
+ duration?: number
+ format?: string
+ image_gen_id?: number
+ video_gen_id?: number
+ tag_ids?: number[]
+}
+
+export interface UpdateAssetRequest {
+ name?: string
+ description?: string
+ category?: string
+ thumbnail_url?: string
+ tag_ids?: number[]
+ is_favorite?: boolean
+}
+
+export interface ListAssetsParams {
+ drama_id?: string
+ episode_id?: number
+ storyboard_id?: number
+ type?: 'image' | 'video' | 'audio'
+ category?: string
+ tag_ids?: number[]
+ is_favorite?: boolean
+ search?: string
+ page?: number
+ page_size?: number
+}
+
+export const ASSET_CATEGORIES = {
+ image: ['角色', '场景', '道具', '背景', '其他'],
+ video: ['分镜', '特效', '片头', '片尾', '其他'],
+ audio: ['配音', '音效', '背景音乐', '片头曲', '片尾曲', '其他']
+}
diff --git a/web/src/types/drama.ts b/web/src/types/drama.ts
new file mode 100644
index 0000000..b9100f4
--- /dev/null
+++ b/web/src/types/drama.ts
@@ -0,0 +1,143 @@
+export interface Drama {
+ id: string
+
+ title: string
+ description?: string
+ genre?: string
+ style?: string
+ total_episodes: number
+ total_duration: number
+ total_scenes?: number
+ duration?: number
+ status: DramaStatus
+ thumbnail?: string
+ tags?: any
+ metadata?: any
+ created_at: string
+ updated_at: string
+ characters?: Character[]
+ episodes?: Episode[]
+ scenes?: Scene[]
+}
+
+export type DramaStatus = 'draft' | 'planning' | 'production' | 'completed' | 'archived' | 'generating' | 'error'
+
+export interface Character {
+ id: number
+ drama_id: string
+ name: string
+ role?: string
+ description?: string
+ appearance?: string
+ personality?: string
+ voice_style?: string
+ background?: string
+ reference_images?: any
+ seed_value?: string
+ sort_order?: number
+ image_url?: string
+ image_generation_status?: string
+ image_generation_error?: string
+ created_at: string
+ updated_at: string
+}
+
+export interface Episode {
+ id: string
+ drama_id: string
+ episode_number: number
+ title: string
+ content: string
+ description?: string
+ script_content?: string
+ duration?: number
+ status: string
+ video_url?: string
+ thumbnail?: string
+ storyboard_count?: number
+ scene_count?: number
+ composition_count?: number
+ video_count?: number
+ timeline_status?: string
+ storyboards?: Storyboard[]
+ scenes?: Scene[]
+ characters?: Character[]
+ shots?: any[]
+ created_at: string
+ updated_at: string
+}
+
+export interface Storyboard {
+ id: string
+ episode_id: string
+ storyboard_number: number
+ title?: string
+ description?: string
+ location?: string
+ time?: string
+ duration?: number
+ dialogue?: string
+ action?: string
+ atmosphere?: string
+ image_prompt?: string
+ video_prompt?: string
+ characters?: any
+ image_url?: string
+ video_url?: string
+ composed_image?: string
+ scene_id?: string
+ scene?: Scene
+ created_at: string
+ updated_at: string
+ [key: string]: any
+}
+
+export interface Scene {
+ id: string
+ drama_id: string
+ location: string
+ time: string
+ prompt: string
+ description?: string
+ title?: string
+ storyboard_number?: number
+ storyboard_count?: number
+ image_url?: string
+ video_url?: string
+ status: string
+ image_generation_status?: string
+ image_generation_error?: string
+ created_at: string
+ updated_at: string
+}
+
+export interface CreateDramaRequest {
+ title: string
+ description?: string
+ genre?: string
+ tags?: string
+}
+
+export interface UpdateDramaRequest {
+ title?: string
+ description?: string
+ genre?: string
+ tags?: string
+ status?: DramaStatus
+}
+
+export interface DramaListQuery {
+ page?: number
+ page_size?: number
+ status?: DramaStatus
+ genre?: string
+ keyword?: string
+}
+
+export interface DramaStats {
+ total: number
+ by_status: Array<{
+ status: string
+ count: number
+ }>
+}
diff --git a/web/src/types/generation.ts b/web/src/types/generation.ts
new file mode 100644
index 0000000..0102ccb
--- /dev/null
+++ b/web/src/types/generation.ts
@@ -0,0 +1,79 @@
+export interface GenerateOutlineRequest {
+ drama_id: string
+ theme: string
+ genre?: string
+ style?: string
+ length?: number
+ temperature?: number
+}
+
+export interface GenerateCharactersRequest {
+ drama_id: string
+ outline?: string
+ count?: number
+ temperature?: number
+}
+
+export interface GenerateEpisodesRequest {
+ drama_id: string
+ outline?: string
+ episode_count: number
+ temperature?: number
+}
+
+export interface OutlineResult {
+ title: string
+ summary: string
+ genre: string
+ tags: string[]
+ characters: CharacterOutline[]
+ episodes: EpisodeOutline[]
+ key_scenes: string[]
+}
+
+export interface CharacterOutline {
+ name: string
+ role: string
+ description: string
+ personality: string
+ appearance: string
+}
+
+export interface EpisodeOutline {
+ episode_number: number
+ title: string
+ summary: string
+ scenes: string[]
+ duration: number
+}
+
+export interface ParseScriptRequest {
+ drama_id: string
+ script_content: string
+ auto_split?: boolean
+}
+
+export interface ParseScriptResult {
+ episodes: ParsedEpisode[]
+ characters: ParsedCharacter[]
+ summary: string
+}
+
+export interface ParsedCharacter {
+ name: string
+ role: string
+ description: string
+ personality: string
+}
+
+export interface ParsedEpisode {
+ episode_number: number
+ title: string
+ description: string
+ script_content: string
+ duration: number
+ chapter_start?: number
+ chapter_end?: number
+ start_marker?: string
+ end_marker?: string
+}
diff --git a/web/src/types/image.ts b/web/src/types/image.ts
new file mode 100644
index 0000000..bc8b47a
--- /dev/null
+++ b/web/src/types/image.ts
@@ -0,0 +1,65 @@
+export interface ImageGeneration {
+ id: number
+ storyboard_id?: number
+ scene_id?: string
+ drama_id: string
+ character_id?: number
+ image_type?: string
+ frame_type?: string
+ provider: string
+ prompt: string
+ negative_prompt?: string
+ model: string
+ size?: string
+ quality?: string
+ style?: string
+ steps?: number
+ cfg_scale?: number
+ seed?: number
+ image_url?: string
+ image_generation?: any
+ local_path?: string
+ status: ImageStatus
+ task_id?: string
+ error_msg?: string
+ width?: number
+ height?: number
+ created_at: string
+ updated_at: string
+ completed_at?: string
+}
+
+export type ImageStatus = 'pending' | 'processing' | 'completed' | 'failed'
+
+export type ImageProvider = 'openai' | 'dalle' | 'midjourney' | 'stable_diffusion' | 'sd'
+
+export interface GenerateImageRequest {
+ scene_id?: number
+ storyboard_id?: number
+ drama_id: string
+ image_type?: string
+ frame_type?: string
+ prompt: string
+ negative_prompt?: string
+ reference_images?: string[]
+ provider?: string
+ model?: string
+ size?: string
+ quality?: string
+ style?: string
+ steps?: number
+ cfg_scale?: number
+ seed?: number
+ width?: number
+ height?: number
+}
+
+export interface ImageGenerationListParams {
+ drama_id?: string
+ scene_id?: string
+ storyboard_id?: number
+ frame_type?: string
+ status?: ImageStatus
+ page?: number
+ page_size?: number
+}
diff --git a/web/src/types/timeline.ts b/web/src/types/timeline.ts
new file mode 100644
index 0000000..b32acf8
--- /dev/null
+++ b/web/src/types/timeline.ts
@@ -0,0 +1,165 @@
+import type { Asset } from './asset'
+
+export interface Timeline {
+ id: number
+ drama_id: number
+ episode_id?: number
+ name: string
+ description?: string
+ duration: number
+ fps: number
+ resolution?: string
+ status: TimelineStatus
+ tracks?: TimelineTrack[]
+ created_at: string
+ updated_at: string
+}
+
+export type TimelineStatus = 'draft' | 'editing' | 'completed' | 'exporting'
+
+export interface TimelineTrack {
+ id: number
+ timeline_id: number
+ name: string
+ type: TrackType
+ order: number
+ is_locked: boolean
+ is_muted: boolean
+ volume?: number
+ clips?: TimelineClip[]
+ created_at: string
+}
+
+export type TrackType = 'video' | 'audio' | 'text'
+
+export interface TimelineClip {
+ id: number
+ track_id: number
+ asset_id?: number
+ asset?: Asset
+ scene_id?: number
+ name: string
+ start_time: number
+ end_time: number
+ duration: number
+ trim_start?: number
+ trim_end?: number
+ speed?: number
+ volume?: number
+ is_muted: boolean
+ fade_in?: number
+ fade_out?: number
+ transition_in_id?: number
+ transition_out_id?: number
+ in_transition?: ClipTransition
+ out_transition?: ClipTransition
+ effects?: ClipEffect[]
+ created_at: string
+}
+
+export interface ClipTransition {
+ id: number
+ type: TransitionType
+ duration: number
+ easing?: string
+ config?: Record
+}
+
+export type TransitionType = 'fade' | 'crossfade' | 'slide' | 'wipe' | 'zoom' | 'dissolve'
+
+export interface ClipEffect {
+ id: number
+ clip_id: number
+ type: EffectType
+ name: string
+ is_enabled: boolean
+ order: number
+ config?: Record
+}
+
+export type EffectType = 'filter' | 'color' | 'blur' | 'brightness' | 'contrast' | 'saturation'
+
+export interface CreateTimelineRequest {
+ drama_id: number
+ episode_id?: number
+ name: string
+ description?: string
+ fps?: number
+ resolution?: string
+}
+
+export interface UpdateTimelineRequest {
+ name?: string
+ description?: string
+ fps?: number
+ resolution?: string
+ status?: TimelineStatus
+}
+
+export interface CreateTrackRequest {
+ name: string
+ type: TrackType
+ order?: number
+ volume?: number
+}
+
+export interface UpdateTrackRequest {
+ name?: string
+ order?: number
+ is_locked?: boolean
+ is_muted?: boolean
+ volume?: number
+}
+
+export interface CreateClipRequest {
+ track_id: number
+ asset_id?: number
+ scene_id?: number
+ name?: string
+ start_time: number
+ duration: number
+ trim_start?: number
+ trim_end?: number
+ speed?: number
+ volume?: number
+ fade_in?: number
+ fade_out?: number
+}
+
+export interface UpdateClipRequest {
+ name?: string
+ start_time?: number
+ duration?: number
+ trim_start?: number
+ trim_end?: number
+ speed?: number
+ volume?: number
+ is_muted?: boolean
+ fade_in?: number
+ fade_out?: number
+}
+
+export interface CreateTransitionRequest {
+ type: TransitionType
+ duration: number
+ easing?: string
+ config?: Record
+}
+
+export const TRANSITION_TYPES = [
+ { label: '淡入淡出', value: 'fade' },
+ { label: '交叉淡化', value: 'crossfade' },
+ { label: '滑动', value: 'slide' },
+ { label: '擦除', value: 'wipe' },
+ { label: '缩放', value: 'zoom' },
+ { label: '溶解', value: 'dissolve' }
+]
+
+export const EFFECT_TYPES = [
+ { label: '滤镜', value: 'filter' },
+ { label: '色彩', value: 'color' },
+ { label: '模糊', value: 'blur' },
+ { label: '亮度', value: 'brightness' },
+ { label: '对比度', value: 'contrast' },
+ { label: '饱和度', value: 'saturation' }
+]
diff --git a/web/src/types/user.ts b/web/src/types/user.ts
new file mode 100644
index 0000000..c268534
--- /dev/null
+++ b/web/src/types/user.ts
@@ -0,0 +1,26 @@
+export interface User {
+ id: number
+ username: string
+ email: string
+ avatar?: string
+ nickname?: string
+ phone?: string
+ role: string
+ status: number
+ created_at: string
+}
+
+export interface UserConfig {
+ text_provider: string
+ text_model: string
+ text_api_key_set: boolean
+ image_provider: string
+ image_model: string
+ image_api_key_set: boolean
+ video_provider: string
+ video_model: string
+ video_api_key_set: boolean
+ default_style: string
+ default_resolution: string
+ default_fps: number
+}
diff --git a/web/src/types/video.ts b/web/src/types/video.ts
new file mode 100644
index 0000000..5077a53
--- /dev/null
+++ b/web/src/types/video.ts
@@ -0,0 +1,83 @@
+export interface VideoGeneration {
+ id: number
+ storyboard_id?: number
+ scene_id?: string // 已废弃,保留用于兼容
+ drama_id: string
+ image_gen_id?: number
+ provider: string
+ prompt: string
+ model?: string
+ image_url?: string
+ first_frame_url?: string
+ duration?: number
+ fps?: number
+ resolution?: string
+ aspect_ratio?: string
+ style?: string
+ motion_level?: number
+ camera_motion?: string
+ seed?: number
+ video_url?: string
+ local_path?: string
+ status: VideoStatus
+ task_id?: string
+ error_msg?: string
+ width?: number
+ height?: number
+ created_at: string
+ updated_at: string
+ completed_at?: string
+}
+
+export type VideoStatus = 'pending' | 'processing' | 'completed' | 'failed'
+
+export type VideoProvider = 'runway' | 'pika' | 'doubao' | 'openai'
+
+export interface GenerateVideoRequest {
+ storyboard_id?: number
+ scene_id?: string // 已废弃,保留用于兼容
+ drama_id: string
+ image_gen_id?: number
+ image_url?: string
+ prompt: string
+ provider?: string
+ model?: string
+ duration?: number
+ fps?: number
+ aspect_ratio?: string
+ style?: string
+ motion_level?: number
+ camera_motion?: string
+ seed?: number
+ reference_mode?: string // 参考图模式:single, first_last, multiple, none
+ first_frame_url?: string // 首帧图片URL
+ last_frame_url?: string // 尾帧图片URL
+ reference_image_urls?: string[] // 多图参考模式
+}
+
+export interface VideoGenerationListParams {
+ drama_id?: string
+ storyboard_id?: string
+ scene_id?: string // 已废弃,保留用于兼容
+ status?: string // 支持单个状态或逗号分隔的多个状态,如 "pending,processing"
+ page?: number
+ page_size?: number
+}
+
+export const VIDEO_ASPECT_RATIOS = [
+ { label: '16:9 (横屏)', value: '16:9' },
+ { label: '9:16 (竖屏)', value: '9:16' },
+ { label: '1:1 (正方形)', value: '1:1' },
+ { label: '4:3 (传统)', value: '4:3' }
+]
+
+export const CAMERA_MOTIONS = [
+ { label: '静止', value: 'static' },
+ { label: '推进', value: 'zoom_in' },
+ { label: '拉远', value: 'zoom_out' },
+ { label: '左移', value: 'pan_left' },
+ { label: '右移', value: 'pan_right' },
+ { label: '上移', value: 'tilt_up' },
+ { label: '下移', value: 'tilt_down' },
+ { label: '环绕', value: 'orbit' }
+]
diff --git a/web/src/utils/ffmpeg.ts b/web/src/utils/ffmpeg.ts
new file mode 100644
index 0000000..235c49c
--- /dev/null
+++ b/web/src/utils/ffmpeg.ts
@@ -0,0 +1,218 @@
+import { FFmpeg } from '@ffmpeg/ffmpeg'
+import { fetchFile, toBlobURL } from '@ffmpeg/util'
+
+let ffmpegInstance: FFmpeg | null = null
+let loadPromise: Promise | null = null
+
+export interface VideoTrimOptions {
+ startTime: number
+ endTime: number
+}
+
+export interface VideoMergeOptions {
+ clips: Array<{
+ url: string
+ startTime?: number
+ endTime?: number
+ }>
+}
+
+export interface ProgressCallback {
+ (progress: number): void
+}
+
+async function getFFmpeg(): Promise {
+ if (ffmpegInstance) {
+ return ffmpegInstance
+ }
+
+ if (loadPromise) {
+ return loadPromise
+ }
+
+ loadPromise = (async () => {
+ const ffmpeg = new FFmpeg()
+
+ ffmpeg.on('log', ({ message }) => {
+ console.log('[FFmpeg]', message)
+ })
+
+ const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd'
+ await ffmpeg.load({
+ coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
+ wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm')
+ })
+
+ ffmpegInstance = ffmpeg
+ return ffmpeg
+ })()
+
+ return loadPromise
+}
+
+export async function trimVideo(
+ videoUrl: string,
+ options: VideoTrimOptions,
+ onProgress?: ProgressCallback
+): Promise {
+ const ffmpeg = await getFFmpeg()
+
+ if (onProgress) onProgress(10)
+
+ const inputFileName = 'input.mp4'
+ const outputFileName = 'output.mp4'
+
+ await ffmpeg.writeFile(inputFileName, await fetchFile(videoUrl))
+
+ if (onProgress) onProgress(30)
+
+ const args = [
+ '-i', inputFileName,
+ '-ss', options.startTime.toString(),
+ '-to', options.endTime.toString(),
+ '-c', 'copy',
+ '-avoid_negative_ts', '1',
+ outputFileName
+ ]
+
+ await ffmpeg.exec(args)
+
+ if (onProgress) onProgress(80)
+
+ const data = await ffmpeg.readFile(outputFileName) as Uint8Array
+
+ await ffmpeg.deleteFile(inputFileName)
+ await ffmpeg.deleteFile(outputFileName)
+
+ if (onProgress) onProgress(100)
+
+ return new Blob([new Uint8Array(data)], { type: 'video/mp4' })
+}
+
+export async function mergeVideos(
+ options: VideoMergeOptions,
+ onProgress?: ProgressCallback
+): Promise {
+ const ffmpeg = await getFFmpeg()
+
+ if (onProgress) onProgress(5)
+
+ const tempFiles: string[] = []
+
+ for (let i = 0; i < options.clips.length; i++) {
+ const clip = options.clips[i]
+ const fileName = `clip_${i}.mp4`
+
+ await ffmpeg.writeFile(fileName, await fetchFile(clip.url))
+ tempFiles.push(fileName)
+
+ if (onProgress) {
+ onProgress(5 + (i + 1) / options.clips.length * 40)
+ }
+ }
+
+ const listContent = tempFiles.map(file => `file '${file}'`).join('\n')
+ await ffmpeg.writeFile('filelist.txt', new TextEncoder().encode(listContent))
+
+ if (onProgress) onProgress(50)
+
+ await ffmpeg.exec([
+ '-f', 'concat',
+ '-safe', '0',
+ '-i', 'filelist.txt',
+ '-c', 'copy',
+ 'output.mp4'
+ ])
+
+ if (onProgress) onProgress(90)
+
+ const data = await ffmpeg.readFile('output.mp4') as Uint8Array
+
+ for (const file of tempFiles) {
+ await ffmpeg.deleteFile(file)
+ }
+ await ffmpeg.deleteFile('filelist.txt')
+ await ffmpeg.deleteFile('output.mp4')
+
+ if (onProgress) onProgress(100)
+
+ return new Blob([new Uint8Array(data)], { type: 'video/mp4' })
+}
+
+export async function trimAndMergeVideos(
+ clips: Array<{
+ url: string
+ startTime: number
+ endTime: number
+ }>,
+ onProgress?: ProgressCallback
+): Promise {
+ const ffmpeg = await getFFmpeg()
+
+ if (onProgress) onProgress(5)
+
+ const trimmedFiles: string[] = []
+
+ for (let i = 0; i < clips.length; i++) {
+ const clip = clips[i]
+ const inputName = `input_${i}.mp4`
+ const outputName = `trimmed_${i}.mp4`
+
+ await ffmpeg.writeFile(inputName, await fetchFile(clip.url))
+
+ await ffmpeg.exec([
+ '-i', inputName,
+ '-ss', clip.startTime.toString(),
+ '-to', clip.endTime.toString(),
+ '-c', 'copy',
+ '-avoid_negative_ts', '1',
+ outputName
+ ])
+
+ await ffmpeg.deleteFile(inputName)
+ trimmedFiles.push(outputName)
+
+ if (onProgress) {
+ onProgress(5 + (i + 1) / clips.length * 60)
+ }
+ }
+
+ const listContent = trimmedFiles.map(file => `file '${file}'`).join('\n')
+ await ffmpeg.writeFile('filelist.txt', new TextEncoder().encode(listContent))
+
+ if (onProgress) onProgress(70)
+
+ await ffmpeg.exec([
+ '-f', 'concat',
+ '-safe', '0',
+ '-i', 'filelist.txt',
+ '-c', 'copy',
+ 'final.mp4'
+ ])
+
+ if (onProgress) onProgress(95)
+
+ const data = await ffmpeg.readFile('final.mp4') as Uint8Array
+
+ for (const file of trimmedFiles) {
+ await ffmpeg.deleteFile(file)
+ }
+ await ffmpeg.deleteFile('filelist.txt')
+ await ffmpeg.deleteFile('final.mp4')
+
+ if (onProgress) onProgress(100)
+
+ return new Blob([new Uint8Array(data)], { type: 'video/mp4' })
+}
+
+export async function isFFmpegLoaded(): Promise {
+ return ffmpegInstance !== null
+}
+
+export async function unloadFFmpeg(): Promise {
+ if (ffmpegInstance) {
+ await ffmpegInstance.terminate()
+ ffmpegInstance = null
+ loadPromise = null
+ }
+}
diff --git a/web/src/utils/request.ts b/web/src/utils/request.ts
new file mode 100644
index 0000000..4877a82
--- /dev/null
+++ b/web/src/utils/request.ts
@@ -0,0 +1,48 @@
+import type { AxiosError, AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'
+import axios from 'axios'
+import { ElMessage } from 'element-plus'
+
+interface CustomAxiosInstance extends Omit {
+ get(url: string, config?: AxiosRequestConfig): Promise
+ post(url: string, data?: any, config?: AxiosRequestConfig): Promise
+ put(url: string, data?: any, config?: AxiosRequestConfig): Promise
+ patch(url: string, data?: any, config?: AxiosRequestConfig): Promise
+ delete(url: string, config?: AxiosRequestConfig): Promise
+}
+
+const request = axios.create({
+ baseURL: '/api/v1',
+ timeout: 600000, // 10分钟超时,匹配后端AI生成接口
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+}) as CustomAxiosInstance
+
+// 开源版本 - 无需认证token
+request.interceptors.request.use(
+ (config: InternalAxiosRequestConfig) => {
+ return config
+ },
+ (error: AxiosError) => {
+ return Promise.reject(error)
+ }
+)
+
+request.interceptors.response.use(
+ (response) => {
+ const res = response.data
+ if (res.success) {
+ return res.data
+ } else {
+ // 不在这里显示错误提示,让业务代码自行处理
+ return Promise.reject(new Error(res.error?.message || '请求失败'))
+ }
+ },
+ (error: AxiosError) => {
+ // 不在拦截器中自动显示错误提示,让业务代码根据具体情况处理
+ // 只抛出错误供调用者捕获
+ return Promise.reject(error)
+ }
+)
+
+export default request
diff --git a/web/src/utils/videoMerger.ts b/web/src/utils/videoMerger.ts
new file mode 100644
index 0000000..0d4d230
--- /dev/null
+++ b/web/src/utils/videoMerger.ts
@@ -0,0 +1,328 @@
+import { FFmpeg } from '@ffmpeg/ffmpeg'
+import { fetchFile, toBlobURL } from '@ffmpeg/util'
+
+export interface VideoClip {
+ url: string
+ startTime: number
+ endTime: number
+ duration: number
+ transition?: TransitionEffect
+}
+
+export type TransitionType = 'fade' | 'fadeblack' | 'fadewhite' | 'slideleft' | 'slideright' | 'slideup' | 'slidedown' | 'wipeleft' | 'wiperight' | 'circleopen' | 'circleclose' | 'none'
+
+export interface TransitionEffect {
+ type: TransitionType
+ duration: number // 转场时长(秒)
+}
+
+export interface MergeProgress {
+ phase: 'loading' | 'processing' | 'encoding' | 'completed'
+ progress: number
+ message: string
+}
+
+class VideoMerger {
+ private ffmpeg: FFmpeg
+ private loaded: boolean = false
+ private onProgress?: (progress: MergeProgress) => void
+
+ constructor() {
+ this.ffmpeg = new FFmpeg()
+ }
+
+ async initialize(onProgress?: (progress: MergeProgress) => void) {
+ if (this.loaded) return
+
+ this.onProgress = onProgress
+
+ this.onProgress?.({
+ phase: 'loading',
+ progress: 0,
+ message: '正在加载FFmpeg引擎(首次需要下载约30MB)...'
+ })
+
+ // CDN列表(优先使用国内CDN)
+ const cdnList = [
+ 'https://unpkg.zhimg.com/@ffmpeg/core@0.12.6/dist/esm', // 知乎CDN镜像(国内)
+ 'https://npm.elemecdn.com/@ffmpeg/core@0.12.6/dist/esm', // 饿了么CDN(国内)
+ 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm', // jsDelivr(全球CDN,国内可用)
+ 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm', // unpkg(国外)
+ ]
+
+ this.ffmpeg.on('log', ({ message }) => {
+ console.log('[FFmpeg]', message)
+ })
+
+ this.ffmpeg.on('progress', ({ progress, time }) => {
+ this.onProgress?.({
+ phase: 'encoding',
+ progress: Math.round(progress * 100),
+ message: `正在合并视频... ${Math.round(progress * 100)}%`
+ })
+ })
+
+ // 尝试多个CDN源
+ let lastError: Error | null = null
+ for (let i = 0; i < cdnList.length; i++) {
+ const baseURL = cdnList[i]
+
+ try {
+ this.onProgress?.({
+ phase: 'loading',
+ progress: (i / cdnList.length) * 50,
+ message: `正在从CDN ${i + 1}/${cdnList.length} 加载FFmpeg...`
+ })
+
+ // 添加超时控制
+ const loadPromise = this.ffmpeg.load({
+ coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
+ wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
+ })
+
+ const timeoutPromise = new Promise((_, reject) => {
+ setTimeout(() => reject(new Error('加载超时')), 60000) // 60秒超时
+ })
+
+ await Promise.race([loadPromise, timeoutPromise])
+
+ // 加载成功
+ this.loaded = true
+
+ this.onProgress?.({
+ phase: 'loading',
+ progress: 100,
+ message: 'FFmpeg加载完成'
+ })
+
+ return
+ } catch (error) {
+ console.error(`CDN ${i + 1} 加载失败:`, error)
+ lastError = error as Error
+
+ if (i < cdnList.length - 1) {
+ this.onProgress?.({
+ phase: 'loading',
+ progress: ((i + 1) / cdnList.length) * 50,
+ message: `CDN ${i + 1} 失败,尝试备用源...`
+ })
+ }
+ }
+ }
+
+ // 所有CDN都失败
+ throw new Error(`FFmpeg加载失败: ${lastError?.message || '未知错误'}。请检查网络连接或稍后重试。`)
+ }
+
+ async mergeVideos(clips: VideoClip[]): Promise {
+ if (!this.loaded) {
+ await this.initialize(this.onProgress)
+ }
+
+ if (clips.length === 0) {
+ throw new Error('没有视频片段')
+ }
+
+ this.onProgress?.({
+ phase: 'processing',
+ progress: 0,
+ message: '正在下载视频片段...'
+ })
+
+ // 并行下载所有视频文件
+ this.onProgress?.({
+ phase: 'processing',
+ progress: 0,
+ message: `正在下载 ${clips.length} 个视频片段...`
+ })
+
+ const downloadPromises = clips.map((clip, i) =>
+ fetchFile(clip.url).then(data => ({ index: i, data }))
+ )
+
+ const downloads = await Promise.all(downloadPromises)
+
+ this.onProgress?.({
+ phase: 'processing',
+ progress: 30,
+ message: '下载完成,正在处理视频...'
+ })
+
+ // 写入文件系统并处理
+ const inputFiles: string[] = []
+ for (let i = 0; i < clips.length; i++) {
+ const clip = clips[i]
+ const download = downloads.find(d => d.index === i)!
+ const inputFileName = `input${i}.mp4`
+ const outputFileName = `clip${i}.mp4`
+
+ // 写入原始视频
+ await this.ffmpeg.writeFile(inputFileName, download.data)
+
+ // 如果需要裁剪,先裁剪视频
+ if (clip.startTime > 0 || clip.endTime < clip.duration) {
+ this.onProgress?.({
+ phase: 'processing',
+ progress: Math.round(30 + (i / clips.length) * 20),
+ message: `正在裁剪视频片段 ${i + 1}/${clips.length}...`
+ })
+
+ await this.ffmpeg.exec([
+ '-i', inputFileName,
+ '-ss', clip.startTime.toString(),
+ '-t', (clip.endTime - clip.startTime).toString(),
+ '-c', 'copy',
+ outputFileName
+ ])
+
+ inputFiles.push(outputFileName)
+ await this.ffmpeg.deleteFile(inputFileName)
+ } else {
+ inputFiles.push(inputFileName)
+ }
+ }
+
+ this.onProgress?.({
+ phase: 'processing',
+ progress: 50,
+ message: '正在准备合并...'
+ })
+
+ // 检查是否有转场效果
+ const hasTransitions = clips.some(clip => clip.transition && clip.transition.type !== 'none')
+
+ if (!hasTransitions || clips.length === 1) {
+ // 没有转场效果,使用简单的concat方式(更快)
+ const concatContent = inputFiles.map(f => `file '${f}'`).join('\n')
+ await this.ffmpeg.writeFile('concat.txt', concatContent)
+
+ this.onProgress?.({
+ phase: 'encoding',
+ progress: 0,
+ message: '正在合并视频...'
+ })
+
+ await this.ffmpeg.exec([
+ '-f', 'concat',
+ '-safe', '0',
+ '-i', 'concat.txt',
+ '-c', 'copy',
+ '-movflags', '+faststart',
+ 'output.mp4'
+ ])
+ } else {
+ // 有转场效果,使用filter_complex(需要重新编码)
+ this.onProgress?.({
+ phase: 'encoding',
+ progress: 0,
+ message: '正在添加转场效果并合并视频(这需要较长时间)...'
+ })
+
+ await this.mergeWithTransitions(inputFiles, clips)
+ }
+
+ this.onProgress?.({
+ phase: 'completed',
+ progress: 90,
+ message: '正在生成最终文件...'
+ })
+
+ // 读取输出文件
+ const data = await this.ffmpeg.readFile('output.mp4')
+ const blob = new Blob([data], { type: 'video/mp4' })
+
+ // 清理临时文件
+ for (const file of inputFiles) {
+ await this.ffmpeg.deleteFile(file)
+ }
+ await this.ffmpeg.deleteFile('concat.txt')
+ await this.ffmpeg.deleteFile('output.mp4')
+
+ this.onProgress?.({
+ phase: 'completed',
+ progress: 100,
+ message: '合并完成!'
+ })
+
+ return blob
+ }
+
+ private async mergeWithTransitions(inputFiles: string[], clips: VideoClip[]) {
+ // 构建FFmpeg filter_complex命令
+ const filterParts: string[] = []
+ const inputs: string[] = []
+
+ // 为每个输入添加标签
+ for (let i = 0; i < inputFiles.length; i++) {
+ inputs.push('-i', inputFiles[i])
+ filterParts.push(`[${i}:v]setpts=PTS-STARTPTS[v${i}]`)
+ filterParts.push(`[${i}:a]asetpts=PTS-STARTPTS[a${i}]`)
+ }
+
+ // 构建转场链
+ let videoChain = 'v0'
+ let audioChain = 'a0'
+
+ for (let i = 1; i < clips.length; i++) {
+ const transition = clips[i].transition
+ const transType = transition?.type || 'fade'
+ const transDuration = transition?.duration || 1.0
+
+ const offset = clips.slice(0, i).reduce((sum, c) => sum + c.duration, 0) - transDuration
+
+ // 视频转场
+ const xfadeFilter = this.getXfadeFilter(transType, transDuration, offset)
+ filterParts.push(`[${videoChain}][v${i}]${xfadeFilter}[v${i}out]`)
+ videoChain = `v${i}out`
+
+ // 音频交叉淡入淡出
+ filterParts.push(`[${audioChain}][a${i}]acrossfade=d=${transDuration}:c1=tri:c2=tri[a${i}out]`)
+ audioChain = `a${i}out`
+ }
+
+ const filterComplex = filterParts.join(';')
+
+ // 执行FFmpeg命令
+ await this.ffmpeg.exec([
+ ...inputs,
+ '-filter_complex', filterComplex,
+ '-map', `[${videoChain}]`,
+ '-map', `[${audioChain}]`,
+ '-c:v', 'libx264',
+ '-preset', 'ultrafast',
+ '-crf', '23',
+ '-c:a', 'aac',
+ '-b:a', '128k',
+ '-movflags', '+faststart',
+ 'output.mp4'
+ ])
+ }
+
+ private getXfadeFilter(type: TransitionType, duration: number, offset: number): string {
+ const xfadeTypes: Record = {
+ 'fade': 'fade',
+ 'fadeblack': 'fadeblack',
+ 'fadewhite': 'fadewhite',
+ 'slideleft': 'slideleft',
+ 'slideright': 'slideright',
+ 'slideup': 'slideup',
+ 'slidedown': 'slidedown',
+ 'wipeleft': 'wipeleft',
+ 'wiperight': 'wiperight',
+ 'circleopen': 'circleopen',
+ 'circleclose': 'circleclose'
+ }
+
+ const xfadeType = xfadeTypes[type] || 'fade'
+ return `xfade=transition=${xfadeType}:duration=${duration}:offset=${offset}`
+ }
+
+ async terminate() {
+ if (this.loaded) {
+ this.ffmpeg.terminate()
+ this.loaded = false
+ }
+ }
+}
+
+export const videoMerger = new VideoMerger()
diff --git a/web/src/views/dashboard/Dashboard.vue b/web/src/views/dashboard/Dashboard.vue
new file mode 100644
index 0000000..e29a362
--- /dev/null
+++ b/web/src/views/dashboard/Dashboard.vue
@@ -0,0 +1,191 @@
+
+
+
+
+
+
+
+
{{ $t('dashboard.welcome') }}
+
{{ $t('dashboard.subtitle') }}
+
+
+
+
+
+
+
+
0
+
{{ $t('dashboard.stats.projects') }}
+
+
+
+
+
+
+
+
+
0
+
{{ $t('dashboard.stats.images') }}
+
+
+
+
+
+
+
+
+
0
+
{{ $t('dashboard.stats.videos') }}
+
+
+
+
+
+
+
+
+
0
+
{{ $t('dashboard.stats.tasks') }}
+
+
+
+
+
+
+
{{ $t('dashboard.quickStart') }}
+
+
+
+
+ {{ $t('dashboard.actions.newProject') }}
+ {{ $t('dashboard.actions.newProjectDesc') }}
+
+
+
+
+
+
+ {{ $t('dashboard.actions.myProjects') }}
+ {{ $t('dashboard.actions.myProjectsDesc') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/drama/DramaCreate.vue b/web/src/views/drama/DramaCreate.vue
new file mode 100644
index 0000000..db83a38
--- /dev/null
+++ b/web/src/views/drama/DramaCreate.vue
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+
+
diff --git a/web/src/views/drama/DramaList.vue b/web/src/views/drama/DramaList.vue
new file mode 100644
index 0000000..2dc31ca
--- /dev/null
+++ b/web/src/views/drama/DramaList.vue
@@ -0,0 +1,514 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('drama.createNew') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/drama/DramaManagement.vue b/web/src/views/drama/DramaManagement.vue
new file mode 100644
index 0000000..ac37039
--- /dev/null
+++ b/web/src/views/drama/DramaManagement.vue
@@ -0,0 +1,798 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('drama.management.noEpisodesYet') }}
+
+ {{ $t('drama.management.createFirstEpisode') }}
+
+
+
+
+
+
+ {{ $t('drama.management.projectInfo') }}
+
+
+ {{ drama?.title }}
+ {{ formatDate(drama?.created_at) }}
+
+ {{ drama?.description || $t('drama.management.noDescription') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('drama.management.createFirstEpisode') }}
+
+
+
+
+
+
+
+
+ {{ getEpisodeStatusText(row) }}
+
+
+
+
+ {{ row.shots?.length || 0 }}
+
+
+
+
+ {{ formatDate(row.created_at) }}
+
+
+
+
+
+ {{ $t('drama.management.goToEdit') }}
+
+
+ {{ $t('common.delete') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ character.name[0] }}
+
+
+
+
{{ character.name }}
+
+ {{ character.role === 'main' ? 'Main' : character.role === 'supporting' ? 'Supporting' : 'Minor' }}
+
+
{{ character.appearance || character.description }}
+
+
+
+ {{ $t('common.edit') }}
+ {{ $t('common.delete') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ scene.name }}
+
{{ scene.description }}
+
+
+
+ {{ $t('common.edit') }}
+ {{ $t('common.delete') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.cancel') }}
+ {{ $t('common.confirm') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.cancel') }}
+ {{ $t('common.confirm') }}
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/drama/DramaWorkflow.vue b/web/src/views/drama/DramaWorkflow.vue
new file mode 100644
index 0000000..d38b844
--- /dev/null
+++ b/web/src/views/drama/DramaWorkflow.vue
@@ -0,0 +1,2066 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('dramaWorkflow.createChapter', { number: currentEpisodeNumber }) }}
+
+
+
+
+
+
+
+
+
+
+
+
第{{ currentEpisodeNumber }}集剧本内容
+ 当前正在制作
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.duration || '-' }}秒
+
+
+
+
+
+ 划分
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ generatingShots ? $t('dramaWorkflow.aiSplitting') : $t('dramaWorkflow.aiAutoSplit') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ character.name[0] }}
+
+
+
+
{{ character.name }}
+
+ {{ character.role === 'main' ? '主角' : character.role === 'supporting' ? '配角' : '次要' }}
+
+
{{ character.appearance || character.description }}
+
+ 编辑描述
+
+
+
+
+
+
+ 生成失败
+
+
+ {{ character.image_generation_error }}
+
+
+
+
+
+
+ 生成中...
+ 重新生成
+ AI生成形象
+
+
+ 上传图片
+
+
+ 从角色库选择
+
+
+ 添加到角色库
+
+
+ 删除角色
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
总剧集数
+
{{ drama?.episodes?.length || 0 }}
+
+
+
已完成
+
{{ completedEpisodesCount || 0 }}
+
+
+
总进度
+
{{ overallProgress }}%
+
+
+
+
+
+
剧集列表
+
+
+
+
+
+
+
+ {{ row.status === 'completed' ? '已完成' : '制作中' }}
+
+
+
+
+
+ {{ row.duration ? `${row.duration}秒` : '-' }}
+
+
+
+
+
+ 进入制作
+
+
+
+
+
+
+
操作
+
点击进入剧集列表,对每一集进行分镜、背景、合成、视频、剪辑
+
+
+ 进入剧集制作
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 添加
+
+
+
+
+
+
+
+
+
+
+
+
{{ item.name }}
+
{{ item.category || '未分类' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/drama/EpisodeWorkflow.vue b/web/src/views/drama/EpisodeWorkflow.vue
new file mode 100644
index 0000000..2fb91fb
--- /dev/null
+++ b/web/src/views/drama/EpisodeWorkflow.vue
@@ -0,0 +1,2329 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('workflow.chapterContent', { number: episodeNumber }) }}
+ {{ $t('workflow.saved') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.generating') }}
+ {{ char.image_generation_status === 'pending' ? $t('common.queuing') : $t('common.processing') }}
+
+
+
+ {{ $t('common.generateFailed') }}
+ {{ $t('common.clickToRegenerate') }}
+
+
+
+ {{ $t('common.notGenerated') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.generating') }}
+ {{ scene.image_generation_status === 'pending' ? $t('common.queuing') : $t('common.processing') }}
+
+
+
+ {{ $t('common.generateFailed') }}
+ {{ $t('common.clickToRegenerate') }}
+
+
+
+
{{ $t('common.notGenerated') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.title || '-' }}
+
+
+
+
+ {{ row.shot_type || '-' }}
+
+
+
+
+ {{ row.movement || '-' }}
+
+
+
+
+
+
+
+ {{ row.location || '-' }}
+
+
+
+
+
+
+
+ {{ row.characters.map(c => c.name || c).join(', ') }}
+
+ -
+
+
+
+
+
+
+
+ {{ row.action || '-' }}
+
+
+
+
+
+
+ {{ row.duration || '-' }}秒
+
+
+
+
+
+ {{ $t('common.edit') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ generatingShots ? $t('workflow.aiSplitting') : $t('workflow.aiAutoSplit') }}
+
+
+
+
+
+
+ {{ percentage }}%
+
+
+
+ {{ taskMessage }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.cancel') }}
+ {{ $t('common.save') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.cancel') }}
+ {{ $t('common.saveAndGenerate') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('workflow.textModelTip') }}
+
+
+
+
+
+
+
+
+ {{ $t('workflow.modelConfigTip') }}
+
+
+
+
+
+ {{ $t('common.cancel') }}
+ {{ $t('common.saveConfig') }}
+
+
+
+
+
+
+
+
+ {{ $t('workflow.dragFilesHere') }}{{ $t('workflow.clickToUpload') }}
+
+
+
+ {{ $t('workflow.uploadFormatTip') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/drama/ProfessionalEditor.vue b/web/src/views/drama/ProfessionalEditor.vue
new file mode 100644
index 0000000..eead78e
--- /dev/null
+++ b/web/src/views/drama/ProfessionalEditor.vue
@@ -0,0 +1,4271 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('storyboard.scene') }} (Scene)
+ {{ $t('storyboard.selectScene')
+ }}
+
+
+
+
+
{{ currentStoryboard.background.location }} · {{ currentStoryboard.background.time }}
+
{{ $t('editor.sceneId') }}: {{ currentStoryboard.scene_id || 'N/A' }}
+
+
+
+
+
+
+
{{ currentStoryboard.background ? $t('editor.sceneGenerating') : $t('editor.noBackground') }}
+
+
+
+
+
+
+
+ {{ $t('editor.cast') }} (Cast)
+ {{
+ $t('editor.addCharacter') }}
+
+
+
+
+
+
{{ char.name?.[0] || '?' }}
+
+
{{ char.name }}
+
+
+
+
+
+
+
+ {{ $t('editor.noCharacters') }}
+
+
+
+
+
+
+
{{ $t('editor.visualSettings') }}
+
+
+ {{ $t('editor.shotType') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('editor.movement') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('editor.angle') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('editor.action') }} (Action)
+
+
+
+
+
{{ $t('editor.result') }} (Result)
+
+
+
+
+
{{ $t('editor.dialogue') }} (Dialogue)
+
+
+
+
+
{{ $t('editor.description') }} (Description)
+
+
+
+
+
+
{{ $t('editor.soundEffects') }}
+
+
+
+
+
+
+
+
{{ $t('editor.bgmPrompt') }}
+
+
+
+
+
+
+
+
{{ $t('editor.atmosphere') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('editor.selectFrameType') }}
+
+ {{ $t('editor.firstFrame') }}
+ {{ $t('editor.lastFrame') }}
+ {{ $t('editor.panelFrame') }}
+ {{ $t('editor.actionSequence') }}
+ {{ $t('editor.keyFrame') }}
+
+
+
{{ $t('editor.panelCount')
+ }}
+
+
+
+
+
+ {{ $t('editor.prompt') }}
+
+ {{ $t('editor.extractPrompt') }}
+
+
+
+
+
+
+
+
+ {{ generatingImage ? $t('editor.generating') : $t('editor.generateImage') }}
+
+ {{ $t('editor.uploadImage') }}
+
+
+
+
+
{{ $t('editor.generationResult') }} ({{ generatedImages.length }})
+
+
+
+
+
+ {{ getStatusText(img.status) }}
+ {{ getFrameTypeText(img.frame_type) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ currentStoryboard.video_prompt || '暂无提示词' }}
+
+
+
+
+
+
{{ $t('video.model') }}
+
+
+
+
{{ model.name }}
+
+ 多图
+ 首尾帧
+ 最多{{ model.maxImages }}张
+
+
+
+
+
+
+
+
+
参考图
+
+
+
+ {{ mode.label }}
+ {{ mode.description }}
+
+
+
+
+
+
+
{{ $t('professionalEditor.duration') }}
+
+
+ {{ videoDuration }}{{
+ $t('professionalEditor.seconds') }}
+
+
+
+
+
+
+
+
+ 首帧
+ 尾帧
+ 分镜板
+ 动作序列
+ 关键帧
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
单图参考
+
+
0 && removeSelectedImage(selectedImagesForVideo[0])">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
首尾帧
+
+
+
首帧
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
尾帧
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 多图参考 ({{ selectedImagesForVideo.length }}/{{ currentModelCapability?.maxImages || 6 }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ generatingVideo ? '生成中...' : '生成视频' }}
+
+
+
+
+
+
+
+ 生成结果 ({{ generatedVideos.length }})
+
+
+
+
e.currentTarget.querySelector('.play-overlay').style.opacity = '1'"
+ @mouseleave="(e) => e.currentTarget.querySelector('.play-overlay').style.opacity = '0'"
+ @click="playVideo(video)">
+
+
+
+
+
+
+
+
+
+
+ {{ getStatusText(video.status)
+ }}
+
+
+
+ {{ addingToAssets.has(video.id) ? '添加中...' : '添加到素材库' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('video.noMergeYet') }}
+
{{ $t('video.mergeInstructions') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('professionalEditor.videoDuration') }}
+
{{ merge.duration ? `${merge.duration}
+ ${$t('professionalEditor.seconds')}` : '-'
+ }}
+
+
+
+
+
+
+
+
+
+
创建时间
+
{{ formatDateTime(merge.created_at) }}
+
+
+
+
+
+
+
+
+
+
完成时间
+
{{ formatDateTime(merge.completed_at) }}
+
+
+
+
+
+
+
+
+ {{ merge.error_msg }}
+
+
+
+
+
+
+
+
+ 下载视频
+
+
+ 在线预览
+
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ char.name?.[0] || '?' }}
+
+
+
{{ char.name }}
+
{{ char.role || '角色' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 关闭
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ scene.location }}
+
{{ scene.time }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/web/src/views/drama/components/GenerateDialog.vue b/web/src/views/drama/components/GenerateDialog.vue
new file mode 100644
index 0000000..086f565
--- /dev/null
+++ b/web/src/views/drama/components/GenerateDialog.vue
@@ -0,0 +1,425 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 建议3-10集
+
+
+
+
+ 数值越高,生成内容越有创意但可能不稳定
+
+
+
+
+
+
+
+
{{ outlineResult.title }}
+
{{ outlineResult.summary }}
+
+ {{ tag }}
+
+
+
+
+
+
+
+
+ 建议3-5个主要角色
+
+
+
+
+
+
+
+
+
+
+
+
已创建角色:
+
+
+ {{ char.name }} ({{ char.role }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查看剧本详情
+ 关闭
+
+
+
+
+
+ {{ outlineResult?.title }}
+
+
+ {{ outlineResult?.genre }}
+
+
+ {{ characters.length }}
+
+
+ {{ episodes.length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/drama/components/UploadScriptDialog.vue b/web/src/views/drama/components/UploadScriptDialog.vue
new file mode 100644
index 0000000..f4685d3
--- /dev/null
+++ b/web/src/views/drama/components/UploadScriptDialog.vue
@@ -0,0 +1,225 @@
+
+
+
+
+
+
+ 支持多种剧本格式,系统会智能识别剧集、场景、对话等内容
+
+
+
+
+ 自动拆分剧集
+
+ 启用后将自动识别剧集分界点,否则作为单集处理
+
+
+
+
+
+ 解析结果
+
+
+
+
+ 共识别 {{ parseResult.episodes.length }} 个剧集,
+ {{ totalScenes }} 个场景
+
+
+
+
+
剧本概要
+
{{ parseResult.summary }}
+
+
+
+
+
+
场景数: {{ episode.scenes.length }}
+
+
+
+
+
+
+
+
+
+ {{ row.dialogue }}
+
+
+
+
+
+
+
+
+
+
+ 取消
+
+ 解析剧本
+
+
+ 保存到项目
+
+
+
+
+
+
+
+
diff --git a/web/src/views/editor/TimelineEditor.vue b/web/src/views/editor/TimelineEditor.vue
new file mode 100644
index 0000000..7168dd5
--- /dev/null
+++ b/web/src/views/editor/TimelineEditor.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
diff --git a/web/src/views/generation/ImageGeneration.vue b/web/src/views/generation/ImageGeneration.vue
new file mode 100644
index 0000000..6b9196d
--- /dev/null
+++ b/web/src/views/generation/ImageGeneration.vue
@@ -0,0 +1,431 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('video.filter.query') }}
+ {{ $t('video.filter.reset') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('image.loadFailed') }}
+
+
+
+
+
+
+ {{ $t('image.generating') }}
+
+
+
+
+ {{ $t('image.generateFailed') }}
+
+
+
+
+
+
+ {{ getStatusText(image.status) }}
+
+
+
+
+
+
{{ truncateText(image.prompt, 60) }}
+
+
+ {{ image.provider }}
+
+ {{ formatTime(image.created_at) }}
+
+
+
+
+
+
+
+ 查看
+
+
+
+ 下载
+
+
+
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/generation/VideoGeneration.vue b/web/src/views/generation/VideoGeneration.vue
new file mode 100644
index 0000000..4114e65
--- /dev/null
+++ b/web/src/views/generation/VideoGeneration.vue
@@ -0,0 +1,477 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('video.filter.query') }}
+ {{ $t('video.filter.reset') }}
+
+
+
+
+
+
+
+
+
+ 您的浏览器不支持视频播放
+
+
+
+
+
生成中...
+
预计需要 1-3 分钟
+
+
+
+
+ 生成失败
+
+
+
+
+ 等待生成
+
+
+
+
+ {{ getStatusText(video.status) }}
+
+
+ {{ video.duration }}s
+
+
+
+
+
+
{{ truncateText(video.prompt, 60) }}
+
+
+ {{ video.provider }}
+
+ {{ formatTime(video.created_at) }}
+
+
+ {{ video.aspect_ratio }}
+ {{ video.resolution }}
+
+
+
+
+
+
+
+ 查看
+
+
+
+ 下载
+
+
+
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/generation/components/GenerateImageDialog.vue b/web/src/views/generation/components/GenerateImageDialog.vue
new file mode 100644
index 0000000..342b745
--- /dev/null
+++ b/web/src/views/generation/components/GenerateImageDialog.vue
@@ -0,0 +1,303 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('imageDialog.standard') }}
+ {{ $t('imageDialog.hd') }}
+
+
+
+
+
+ {{ $t('imageDialog.vivid') }}
+ {{ $t('imageDialog.natural') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('imageDialog.seedTip') }}
+
+
+
+
+
+
+ {{ $t('common.cancel') }}
+
+ {{ $t('imageDialog.generate') }}
+
+
+
+
+
+
+
+
diff --git a/web/src/views/generation/components/GenerateVideoDialog.vue b/web/src/views/generation/components/GenerateVideoDialog.vue
new file mode 100644
index 0000000..82c9bd0
--- /dev/null
+++ b/web/src/views/generation/components/GenerateVideoDialog.vue
@@ -0,0 +1,362 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ truncateText(image.prompt, 40) }}
+
+
+
+ 或直接输入图片 URL
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ form.duration }} 秒
+
+
+
+
+ 16:9 (横屏)
+ 9:16 (竖屏)
+ 1:1 (方形)
+
+
+
+
+
+
+
+ {{ form.motion_level }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 设置相同种子可复现视频
+
+
+
+
+
+
+ 取消
+
+ 生成视频
+
+
+
+
+
+
+
+
diff --git a/web/src/views/generation/components/ImageDetailDialog.vue b/web/src/views/generation/components/ImageDetailDialog.vue
new file mode 100644
index 0000000..f087576
--- /dev/null
+++ b/web/src/views/generation/components/ImageDetailDialog.vue
@@ -0,0 +1,294 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 生成中,请稍候...
+
+
+
+
+
生成失败
+
{{ image.error_msg }}
+
+
+
+
+
+
+
+
+
+ {{ getStatusText(image.status) }}
+
+
+
+
+ {{ image.provider }}
+
+
+
+ {{ image.model }}
+
+
+
+ {{ image.size }}
+
+
+
+ {{ image.width }} × {{ image.height }}
+
+
+
+ {{ image.quality }}
+
+
+
+ {{ image.style }}
+
+
+
+ {{ image.steps }}
+
+
+
+ {{ image.cfg_scale }}
+
+
+
+ {{ image.seed }}
+
+
+
+ {{ formatDateTime(image.created_at) }}
+
+
+
+ {{ formatDateTime(image.completed_at) }}
+
+
+
+
+
+
+
提示词
+
{{ image.prompt }}
+
+
+
+
反向提示词
+
{{ image.negative_prompt }}
+
+
+
+
+
+
+
+ 关闭
+
+
+ 下载图片
+
+
+
+ 重新生成
+
+
+
+
+
+
+
+
diff --git a/web/src/views/generation/components/VideoDetailDialog.vue b/web/src/views/generation/components/VideoDetailDialog.vue
new file mode 100644
index 0000000..5592174
--- /dev/null
+++ b/web/src/views/generation/components/VideoDetailDialog.vue
@@ -0,0 +1,312 @@
+
+
+
+
+
+
+
+ 您的浏览器不支持视频播放
+
+
+
+
+
生成中,请稍候...
+
预计需要 1-3 分钟
+
+
+
+
+
生成失败
+
{{ video.error_msg }}
+
+
+
+
+ 等待生成
+
+
+
+
+
+
+
+
+
+ {{ getStatusText(video.status) }}
+
+
+
+
+ {{ video.provider }}
+
+
+
+ {{ video.model }}
+
+
+
+ {{ video.duration }} 秒
+
+
+
+ {{ video.aspect_ratio }}
+
+
+
+ {{ video.width }} × {{ video.height }}
+
+
+
+ {{ video.fps }}
+
+
+
+ {{ video.motion_level }}
+
+
+
+ {{ getCameraMotionText(video.camera_motion) }}
+
+
+
+ {{ video.style }}
+
+
+
+ {{ video.seed }}
+
+
+
+ {{ formatDateTime(video.created_at) }}
+
+
+
+ {{ formatDateTime(video.completed_at) }}
+
+
+
+
+
+
+
视频提示词
+
{{ video.prompt }}
+
+
+
+
源图片
+
+
+
+
+
+
+
+
+ 关闭
+
+
+ 下载视频
+
+
+
+ 重新生成
+
+
+
+
+
+
+
+
diff --git a/web/src/views/script/ScriptEdit.vue b/web/src/views/script/ScriptEdit.vue
new file mode 100644
index 0000000..37da4b6
--- /dev/null
+++ b/web/src/views/script/ScriptEdit.vue
@@ -0,0 +1,26 @@
+
+
+
+
+ 剧本编辑
+
+
+
功能开发中...
+
+
+
+
+
+
diff --git a/web/src/views/settings/AIConfig.vue b/web/src/views/settings/AIConfig.vue
new file mode 100644
index 0000000..cedeeae
--- /dev/null
+++ b/web/src/views/settings/AIConfig.vue
@@ -0,0 +1,740 @@
+
+
+
+
+
+
+
+
+ {{ $t('aiConfig.addConfig') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('aiConfig.form.providerTip') }}
+
+
+
+
+ {{ $t('aiConfig.form.priorityTip') }}
+
+
+
+
+
+
+ {{ $t('aiConfig.form.modelTip') }}
+
+
+
+
+
+ {{ $t('aiConfig.form.baseUrlTip') }}
+
+ {{ $t('aiConfig.form.fullEndpoint') }}: {{ fullEndpointExample }}
+
+
+
+
+
+ {{ $t('aiConfig.form.apiKeyTip') }}
+
+
+
+
+
+
+
+
+ {{ $t('common.cancel') }}
+ {{ $t('aiConfig.actions.test') }}
+
+ {{ isEdit ? $t('common.save') : $t('common.create') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/settings/components/ConfigList.vue b/web/src/views/settings/components/ConfigList.vue
new file mode 100644
index 0000000..8e662e6
--- /dev/null
+++ b/web/src/views/settings/components/ConfigList.vue
@@ -0,0 +1,218 @@
+
+
+
+
+
+
+
+
+
+ Base URL:
+ {{ config.base_url }}
+
+
+
+ {{ $t('aiConfig.endpoint') }}:
+ {{ config.endpoint || '/v1/chat/completions' }}
+
+
+
+ {{ $t('aiConfig.queryEndpoint') }}:
+ {{ config.query_endpoint }}
+
+
+
+ 优先级:
+
+ {{ config.priority || 0 }}
+
+
+
+
+ 模型:
+
+
+ {{ model }}
+
+
+ {{ config.model }}
+
+
+
+ API Key:
+ {{ maskApiKey(config.api_key) }}
+
+
+
+ 创建时间:
+ {{ formatDate(config.created_at) }}
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/storyboard/StoryboardEdit.vue b/web/src/views/storyboard/StoryboardEdit.vue
new file mode 100644
index 0000000..e3a1228
--- /dev/null
+++ b/web/src/views/storyboard/StoryboardEdit.vue
@@ -0,0 +1,26 @@
+
+
+
+
+ {{ $t('storyboard.edit') }}
+
+
+
{{ $t('storyboard.inDevelopment') }}
+
+
+
+
+
+
diff --git a/web/src/views/workflow/CharacterExtraction.vue b/web/src/views/workflow/CharacterExtraction.vue
new file mode 100644
index 0000000..84ce262
--- /dev/null
+++ b/web/src/views/workflow/CharacterExtraction.vue
@@ -0,0 +1,210 @@
+
+
+
+
+
+
+
diff --git a/web/src/views/workflow/CharacterImages.vue b/web/src/views/workflow/CharacterImages.vue
new file mode 100644
index 0000000..fda3899
--- /dev/null
+++ b/web/src/views/workflow/CharacterImages.vue
@@ -0,0 +1,346 @@
+
+
+
+
+ 角色形象生成
+
+
+
+
+ 批量生成 ({{ selectedCharacters.length }})
+
+
+
+ 管理角色
+
+
+
+
+
+
+
+ 全选
+
+ 已选择 {{ selectedCharacters.length }} / {{ characters.length }} 个角色
+
+
+
+
+
+
+
+
+
+
{{ character.name[0] }}
+
+
+
+
{{ character.name }}
+
{{ character.role }}
+
{{ character.appearance }}
+
+
+
+ 生成中...
+ {{ character.image_url ? '重新生成' : '生成形象' }}
+
+
+
+
+
+
+
+
+ 完成并返回项目
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/workflow/DramaSettings.vue b/web/src/views/workflow/DramaSettings.vue
new file mode 100644
index 0000000..d39a45b
--- /dev/null
+++ b/web/src/views/workflow/DramaSettings.vue
@@ -0,0 +1,139 @@
+
+
+
+
+ 项目设置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 保存设置
+
+
+
+
+
+
+
+ 删除项目
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/workflow/SceneImages.vue b/web/src/views/workflow/SceneImages.vue
new file mode 100644
index 0000000..9bf345e
--- /dev/null
+++ b/web/src/views/workflow/SceneImages.vue
@@ -0,0 +1,199 @@
+
+
+
+
+ 场景图片生成
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ scene.title }}
+
{{ scene.description }}
+
+
+
+ {{ scene.image_url ? '重新生成' : '生成图片' }}
+
+
+
+
+
+
+
+
+
+ 下一步:视频生成
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/workflow/ScriptGeneration.vue b/web/src/views/workflow/ScriptGeneration.vue
new file mode 100644
index 0000000..d1ab1bf
--- /dev/null
+++ b/web/src/views/workflow/ScriptGeneration.vue
@@ -0,0 +1,1221 @@
+
+
+
+
+ {{ $t('script.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('script.form.randomGenerate') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('script.notice') }}
+
+
+
+ {{ $t('script.generateFailed') }}
+ {{ generationError }}
+
+
+ {{ $t('script.regenerate') }}
+
+
+
+
+
+
{{ $t('script.outlinePreview') }}
+
+
+ {{ $t('script.regenerateOutline') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tag }}
+
+
+ + {{ $t('script.form.newTag') }}
+
+
+
+
+
+
+
+
+
{{ $t('scriptGenerationPage.autoGenerateCharacters') }}
+
+ {{ $t('scriptGenerationPage.charactersCreatedInOutline') }}
+
+
+
+
+
{{ $t('scriptGenerationPage.characterListEditable') }}
+ {{ $t('scriptGenerationPage.addCharacter') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('scriptGenerationPage.delete') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('scriptGenerationPage.generateFullScript') }}
+
+ {{ $t('scriptGenerationPage.outlineCreatedEpisodes', { count: outlineResult.episodes.length }) }}
+
+
+
+
+
+
+
{{ $t('scriptGenerationPage.episodePreview', { count: episodesResult.length }) }}
+ {{ $t('scriptGenerationPage.regenerate') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('scriptGenerationPage.uploadNotice') }}
+
+
+
+
+
+
+
+
+ {{ $t('scriptGenerationPage.dragFilesHere') }} {{ $t('scriptGenerationPage.clickUpload') }}
+
+
+ {{ $t('scriptGenerationPage.supportedFormats') }}
+
+
+
+
+
+
+
+
+
+
+ 自动拆分剧集
+
+ 启用后将自动识别剧集分界点,否则作为单集处理
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 共识别 {{ parseResult.episodes.length }} 个剧集,
+ {{ totalCharacters }} 个角色
+
+
+
+
+
剧本概要
+
{{ parseResult.summary }}
+
+
+
+
剧集列表
+
+
+
+
+
+
+
+
+
+
{{ $t('scriptGenerationPage.characterList') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('scriptGenerationPage.prevStep') }}
+
+
+ {{ getNextButtonText() }}
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/workflow/StoryboardGeneration.vue b/web/src/views/workflow/StoryboardGeneration.vue
new file mode 100644
index 0000000..27cf224
--- /dev/null
+++ b/web/src/views/workflow/StoryboardGeneration.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
diff --git a/web/tailwind.config.js b/web/tailwind.config.js
new file mode 100644
index 0000000..abd3703
--- /dev/null
+++ b/web/tailwind.config.js
@@ -0,0 +1,67 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{vue,js,ts,jsx,tsx}",
+ ],
+ darkMode: 'class',
+ theme: {
+ extend: {
+ colors: {
+ // Primary brand colors / 主品牌色
+ primary: {
+ 50: '#f0f9ff',
+ 100: '#e0f2fe',
+ 200: '#bae6fd',
+ 300: '#7dd3fc',
+ 400: '#38bdf8',
+ 500: '#0ea5e9',
+ 600: '#0284c7',
+ 700: '#0369a1',
+ 800: '#075985',
+ 900: '#0c4a6e',
+ },
+ // Neutral colors for backgrounds / 中性色背景
+ surface: {
+ light: '#ffffff',
+ DEFAULT: '#f8fafc',
+ dark: '#0f172a',
+ },
+ // Card backgrounds / 卡片背景
+ card: {
+ light: '#ffffff',
+ dark: '#1e293b',
+ },
+ // Border colors / 边框色
+ border: {
+ light: '#e2e8f0',
+ dark: '#334155',
+ },
+ // Text colors / 文字色
+ content: {
+ primary: '#0f172a',
+ secondary: '#64748b',
+ muted: '#94a3b8',
+ 'primary-dark': '#f1f5f9',
+ 'secondary-dark': '#94a3b8',
+ 'muted-dark': '#64748b',
+ },
+ },
+ boxShadow: {
+ 'card': '0 1px 3px 0 rgb(0 0 0 / 0.05), 0 1px 2px -1px rgb(0 0 0 / 0.05)',
+ 'card-hover': '0 10px 15px -3px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.05)',
+ 'card-dark': '0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3)',
+ 'card-hover-dark': '0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3)',
+ },
+ borderRadius: {
+ 'xl': '0.875rem',
+ '2xl': '1rem',
+ '3xl': '1.5rem',
+ },
+ transitionTimingFunction: {
+ 'bounce-in': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
+ },
+ },
+ },
+ plugins: [],
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..6d440f1
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,40 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": [
+ "ES2020",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "preserve",
+ "strict": false,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": [
+ "./src/*"
+ ]
+ }
+ },
+ "include": [
+ "src/**/*.ts",
+ "src/**/*.d.ts",
+ "src/**/*.tsx",
+ "src/**/*.vue"
+ ],
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json
new file mode 100644
index 0000000..b940375
--- /dev/null
+++ b/web/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "types": ["node"]
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..fb9e861
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,22 @@
+import vue from '@vitejs/plugin-vue'
+import { fileURLToPath, URL } from 'node:url'
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ plugins: [vue()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url))
+ }
+ },
+ server: {
+ host: '0.0.0.0',
+ port: 3012,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:5678',
+ changeOrigin: true
+ }
+ }
+ }
+})