create project

This commit is contained in:
2026-01-16 17:30:40 +08:00
commit effac6b017
157 changed files with 45997 additions and 0 deletions

79
DOCKER_HOST_ACCESS.md Normal file
View File

@@ -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. **防火墙阻止** - 检查防火墙规则或临时关闭测试

93
Dockerfile Normal file
View File

@@ -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"]

53
LICENSE Normal file
View File

@@ -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.

535
README.md Normal file
View File

@@ -0,0 +1,535 @@
# 🎬 Huobao Drama - AI短剧生成平台
<div align="center">
**基于 Go + Vue3 的全栈AI短剧自动化生产平台**
[![Go Version](https://img.shields.io/badge/Go-1.23+-00ADD8?style=flat&logo=go)](https://golang.org)
[![Vue Version](https://img.shields.io/badge/Vue-3.x-4FC08D?style=flat&logo=vue.js)](https://vuejs.org)
[![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/)
[功能特性](#功能特性) • [快速开始](#快速开始) • [部署指南](#部署指南)
</div>
---
## 📖 项目简介 / 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 短剧生成效果:
<div align="center">
**示例作品 1**
<video src="https://ffile.chatfire.site/cf/public/20260114094337396.mp4" controls width="640"></video>
**示例作品 2**
<video src="https://ffile.chatfire.site/cf/public/fcede75e8aeafe22031dbf78f86285b8.mp4" controls width="640"></video>
[点击观看视频 1](https://ffile.chatfire.site/cf/public/20260114094337396.mp4) | [点击观看视频 2](https://ffile.chatfire.site/cf/public/fcede75e8aeafe22031dbf78f86285b8.mp4)
</div>
---
## ✨ 功能特性
### 🎭 角色管理
- ✅ 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会在首次启动时自动创建表检查日志确认迁移是否成功。
---
## <20> 更新日志 / 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)
## 📧 联系方式
商务联系Vdangbao1117
## 项目交流群
![项目交流群](drama.png)
- 提交 [Issue](../../issues)
- 发送邮件至项目维护者
---
<div align="center">
**⭐ 如果这个项目对你有帮助请给一个Star**
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=chatfire-AI/huobao-drama&type=date&legend=top-left)](https://www.star-history.com/#chatfire-AI/huobao-drama&type=date&legend=top-left)
Made with ❤️ by Huobao Team
</div>

136
api/handlers/ai_config.go Normal file
View File

@@ -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": "连接测试成功"})
}

220
api/handlers/asset.go Normal file
View File

@@ -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)
}

View File

@@ -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),
})
}

View File

@@ -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": "角色已删除"})
}

View File

@@ -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,
})
}

310
api/handlers/drama.go Normal file
View File

@@ -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,
})
}

View File

@@ -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)
}

View File

@@ -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,
})
}
}

View File

@@ -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)
}

75
api/handlers/scene.go Normal file
View File

@@ -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,
})
}

View File

@@ -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)
}

View File

@@ -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"})
}

57
api/handlers/task.go Normal file
View File

@@ -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)
}

142
api/handlers/upload.go Normal file
View File

@@ -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,
})
}

View File

@@ -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)
}

104
api/handlers/video_merge.go Normal file
View File

@@ -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"})
}

34
api/middlewares/cors.go Normal file
View File

@@ -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()
}
}

30
api/middlewares/logger.go Normal file
View File

@@ -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(),
)
}
}

View File

@@ -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()
}
}

212
api/routes/routes.go Normal file
View File

@@ -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
}

View File

@@ -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...)
}

View File

@@ -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
}

View File

@@ -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))
}

View File

@@ -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
}

View File

@@ -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, ", ")
}

View File

@@ -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
}

View File

@@ -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相关功能已移除
// 如需资源转存功能,请使用本地存储

View File

@@ -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 tokens20集约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
}

View File

@@ -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 ""
}

View File

@@ -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"` // 背景IDAI直接返回可为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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"` // 分镜IDfallback
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
}

View File

@@ -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"

40
docker-compose.yml Normal file
View File

@@ -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

124
domain/models/ai_config.go Normal file
View File

@@ -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")
}

57
domain/models/asset.go Normal file
View File

@@ -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"
}

View File

@@ -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"
}

148
domain/models/drama.go Normal file
View File

@@ -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"
}

View File

@@ -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"
)

View File

@@ -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" // 分镜图片
)

23
domain/models/task.go Normal file
View File

@@ -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:"-"`
}

178
domain/models/timeline.go Normal file
View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

BIN
drama.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

73
go.mod Normal file
View File

@@ -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
)

638
go.sum Normal file
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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{},
)
}

612
infrastructure/external/ffmpeg/ffmpeg.go vendored Normal file
View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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"
}
}

103
main.go Normal file
View File

@@ -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")
}

499
migrations/init.sql Normal file
View File

@@ -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视频生成');

7
pkg/ai/client.go Normal file
View File

@@ -0,0 +1,7 @@
package ai
// AIClient 定义文本生成客户端接口
type AIClient interface {
GenerateText(prompt string, systemPrompt string, options ...func(*ChatCompletionRequest)) (string, error)
TestConnection() error
}

195
pkg/ai/gemini_client.go Normal file
View File

@@ -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
}

227
pkg/ai/openai_client.go Normal file
View File

@@ -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
}

89
pkg/config/config.go Normal file
View File

@@ -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,
)
}

View File

@@ -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
}

93
pkg/image/image_client.go Normal file
View File

@@ -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
}
}

View File

@@ -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")
}

View File

@@ -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)")
}

35
pkg/logger/logger.go Normal file
View File

@@ -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(),
}
}

119
pkg/response/response.go Normal file
View File

@@ -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)
}

153
pkg/utils/json_parser.go Normal file
View File

@@ -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
}

View File

@@ -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_urlVideoURL -> 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
}

192
pkg/video/minimax_client.go Normal file
View File

@@ -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
}

View File

@@ -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
}

427
pkg/video/video_client.go Normal file
View File

@@ -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
}

View File

@@ -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
}

29
web/.gitignore vendored Normal file
View File

@@ -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

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/svg+xml" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Drama Generator - AI 短剧生成平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

23
web/nginx.conf Normal file
View File

@@ -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;
}

39
web/package.json Normal file
View File

@@ -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"
}
}

2317
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

17
web/src/App.vue Normal file
View File

@@ -0,0 +1,17 @@
<template>
<router-view />
<!-- <AppLayout>
<router-view />
</AppLayout> -->
</template>
<script setup lang="ts">
import AppLayout from '@/components/common/AppLayout.vue'
</script>
<style>
#app {
width: 100%;
min-height: 100vh;
}
</style>

36
web/src/api/ai.ts Normal file
View File

@@ -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<AIServiceConfig[]>('/ai-configs', {
params: { service_type: serviceType }
})
},
create(data: CreateAIConfigRequest) {
return request.post<AIServiceConfig>('/ai-configs', data)
},
get(id: number) {
return request.get<AIServiceConfig>(`/ai-configs/${id}`)
},
update(id: number, data: UpdateAIConfigRequest) {
return request.put<AIServiceConfig>(`/ai-configs/${id}`, data)
},
delete(id: number) {
return request.delete(`/ai-configs/${id}`)
},
testConnection(data: TestConnectionRequest) {
return request.post('/ai-configs/test', data)
}
}

47
web/src/api/asset.ts Normal file
View File

@@ -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<Asset>('/assets', data)
},
updateAsset(id: number, data: UpdateAssetRequest) {
return request.put<Asset>(`/assets/${id}`, data)
},
getAsset(id: number) {
return request.get<Asset>(`/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<Asset>(`/assets/import/image/${imageGenId}`)
},
importFromVideo(videoGenId: number) {
return request.post<Asset>(`/assets/import/video/${videoGenId}`)
}
}

View File

@@ -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<CharacterLibraryItem>('/character-library', data)
},
// 获取角色库项详情
get(id: string) {
return request.get<CharacterLibraryItem>(`/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<CharacterLibraryItem>(`/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}`)
}
}

119
web/src/api/drama.ts Normal file
View File

@@ -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<Drama>('/dramas', data)
},
get(id: string) {
return request.get<Drama>(`/dramas/${id}`)
},
update(id: string, data: UpdateDramaRequest) {
return request.put<Drama>(`/dramas/${id}`, data)
},
delete(id: string) {
return request.delete(`/dramas/${id}`)
},
getStats() {
return request.get<DramaStats>('/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 || {})
}
}

99
web/src/api/frame.ts Normal file
View File

@@ -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<FramePromptResponse> {
return request.post<FramePromptResponse>(`/storyboards/${storyboardId}/frame-prompt`, data)
}
/**
* 生成首帧提示词
*/
export function generateFirstFrame(storyboardId: number): Promise<FramePromptResponse> {
return generateFramePrompt(storyboardId, { frame_type: 'first' })
}
/**
* 生成关键帧提示词
*/
export function generateKeyFrame(storyboardId: number): Promise<FramePromptResponse> {
return generateFramePrompt(storyboardId, { frame_type: 'key' })
}
/**
* 生成尾帧提示词
*/
export function generateLastFrame(storyboardId: number): Promise<FramePromptResponse> {
return generateFramePrompt(storyboardId, { frame_type: 'last' })
}
/**
* 生成分镜板3格组合
*/
export function generatePanelFrames(
storyboardId: number,
panelCount: number = 3
): Promise<FramePromptResponse> {
return generateFramePrompt(storyboardId, {
frame_type: 'panel',
panel_count: panelCount
})
}
/**
* 生成动作序列5格
*/
export function generateActionSequence(storyboardId: number): Promise<FramePromptResponse> {
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`)
}

42
web/src/api/generation.ts Normal file
View File

@@ -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<OutlineResult>('/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<Episode[]>('/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}`)
}
}

40
web/src/api/image.ts Normal file
View File

@@ -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<ImageGeneration>('/images', data)
},
generateForScene(sceneId: number) {
return request.post<ImageGeneration[]>(`/images/scene/${sceneId}`)
},
batchGenerateForEpisode(episodeId: number) {
return request.post<ImageGeneration[]>(`/images/episode/${episodeId}/batch`)
},
getImage(id: number) {
return request.get<ImageGeneration>(`/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}`)
}
}

44
web/src/api/video.ts Normal file
View File

@@ -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<VideoGeneration>('/videos', data)
},
generateFromImage(imageGenId: number) {
return request.post<VideoGeneration>(`/videos/image/${imageGenId}`)
},
batchGenerateForEpisode(episodeId: number) {
return request.post<VideoGeneration[]>(`/videos/episode/${episodeId}/batch`)
},
getVideoGeneration(id: number) {
return request.get<VideoGeneration>(`/videos/${id}`)
},
getVideo(id: number) {
return request.get<VideoGeneration>(`/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}`)
}
}

65
web/src/api/videoMerge.ts Normal file
View File

@@ -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<VideoMerge> {
const response = await request.post<{ merge: VideoMerge }>('/video-merges', data)
return response.merge
},
async getMerge(mergeId: number): Promise<VideoMerge> {
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<void> {
await request.delete(`/video-merges/${mergeId}`)
}
}

View File

@@ -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,
)
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
<template>
<el-dropdown @command="handleCommand">
<span class="language-switcher">
<el-icon><Switch /></el-icon>
<span class="lang-text">{{ currentLangText }}</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-CN" :disabled="currentLang === 'zh-CN'">
🇨🇳 简体中文
</el-dropdown-item>
<el-dropdown-item command="en-US" :disabled="currentLang === 'en-US'">
🇺🇸 English
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { setLanguage } from '@/locales'
import { ElMessage } from 'element-plus'
const { locale } = useI18n()
const currentLang = ref(locale.value)
const currentLangText = computed(() => {
return currentLang.value === 'zh-CN' ? '中文' : 'English'
})
const handleCommand = (lang: string) => {
setLanguage(lang)
currentLang.value = lang
ElMessage.success(
lang === 'zh-CN'
? '语言已切换为中文'
: 'Language switched to English'
)
}
</script>
<style scoped>
.language-switcher {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 8px 12px;
border-radius: 6px;
transition: all 0.2s;
}
.language-switcher:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.lang-text {
font-size: 14px;
color: #606266;
}
</style>

View File

@@ -0,0 +1,801 @@
<template>
<el-dialog
v-model="visible"
:title="$t('aiConfig.title')"
width="900px"
:close-on-click-modal="false"
destroy-on-close
class="ai-config-dialog"
>
<!-- Dialog Header Actions -->
<template #header>
<div class="dialog-header">
<span class="dialog-title">{{ $t('aiConfig.title') }}</span>
<div class="header-actions">
<el-button type="success" size="small" @click="showQuickSetupDialog">
<el-icon><MagicStick /></el-icon>
<span>一键配置火宝</span>
</el-button>
<el-button type="primary" size="small" @click="showCreateDialog">
<el-icon><Plus /></el-icon>
<span>{{ $t('aiConfig.addConfig') }}</span>
</el-button>
</div>
</div>
</template>
<!-- Tabs -->
<el-tabs v-model="activeTab" @tab-change="handleTabChange" class="config-tabs">
<el-tab-pane :label="$t('aiConfig.tabs.text')" name="text">
<ConfigList
:configs="configs"
:loading="loading"
:show-test-button="true"
@edit="handleEdit"
@delete="handleDelete"
@toggle-active="handleToggleActive"
@test="handleTest"
/>
</el-tab-pane>
<el-tab-pane :label="$t('aiConfig.tabs.image')" name="image">
<ConfigList
:configs="configs"
:loading="loading"
:show-test-button="false"
@edit="handleEdit"
@delete="handleDelete"
@toggle-active="handleToggleActive"
/>
</el-tab-pane>
<el-tab-pane :label="$t('aiConfig.tabs.video')" name="video">
<ConfigList
:configs="configs"
:loading="loading"
:show-test-button="false"
@edit="handleEdit"
@delete="handleDelete"
@toggle-active="handleToggleActive"
/>
</el-tab-pane>
</el-tabs>
<!-- Quick Setup Dialog -->
<el-dialog
v-model="quickSetupVisible"
title="一键配置"
width="500px"
:close-on-click-modal="false"
append-to-body
>
<div class="quick-setup-info">
<p>将自动创建以下配置</p>
<ul>
<li><strong>文本服务</strong>: {{ providerConfigs.text[1].models[0] }}</li>
<li><strong>图片服务</strong>: {{ providerConfigs.image[1].models[0] }}</li>
<li><strong>视频服务</strong>: {{ providerConfigs.video[1].models[0] }}</li>
</ul>
<p class="quick-setup-tip">Base URL: https://api.chatfire.site/v1</p>
</div>
<el-form label-width="80px">
<el-form-item label="API Key" required>
<el-input
v-model="quickSetupApiKey"
type="password"
show-password
placeholder="请输入 ChatFire API Key"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="quick-setup-footer">
<a
href="https://api.chatfire.site/login?inviteCode=C4453345"
target="_blank"
class="register-link"
>
没有 API Key点击注册
</a>
<div class="footer-buttons">
<el-button @click="quickSetupVisible = false">取消</el-button>
<el-button type="primary" @click="handleQuickSetup" :loading="quickSetupLoading">
确认配置
</el-button>
</div>
</div>
</template>
</el-dialog>
<!-- Edit/Create Sub-Dialog -->
<el-dialog
v-model="editDialogVisible"
:title="isEdit ? $t('aiConfig.editConfig') : $t('aiConfig.addConfig')"
width="600px"
:close-on-click-modal="false"
append-to-body
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item :label="$t('aiConfig.form.name')" prop="name">
<el-input v-model="form.name" :placeholder="$t('aiConfig.form.namePlaceholder')" />
</el-form-item>
<el-form-item :label="$t('aiConfig.form.provider')" prop="provider">
<el-select
v-model="form.provider"
:placeholder="$t('aiConfig.form.providerPlaceholder')"
@change="handleProviderChange"
style="width: 100%"
>
<el-option
v-for="provider in availableProviders"
:key="provider.id"
:label="provider.name"
:value="provider.id"
:disabled="provider.disabled"
/>
</el-select>
<div class="form-tip">{{ $t('aiConfig.form.providerTip') }}</div>
</el-form-item>
<el-form-item :label="$t('aiConfig.form.priority')" prop="priority">
<el-input-number
v-model="form.priority"
:min="0"
:max="100"
:step="1"
style="width: 100%"
/>
<div class="form-tip">{{ $t('aiConfig.form.priorityTip') }}</div>
</el-form-item>
<el-form-item :label="$t('aiConfig.form.model')" prop="model">
<el-select
v-model="form.model"
:placeholder="$t('aiConfig.form.modelPlaceholder')"
multiple
filterable
allow-create
default-first-option
collapse-tags
collapse-tags-tooltip
style="width: 100%"
>
<el-option
v-for="model in availableModels"
:key="model"
:label="model"
:value="model"
/>
</el-select>
<div class="form-tip">{{ $t('aiConfig.form.modelTip') }}</div>
</el-form-item>
<el-form-item :label="$t('aiConfig.form.baseUrl')" prop="base_url">
<el-input v-model="form.base_url" :placeholder="$t('aiConfig.form.baseUrlPlaceholder')" />
<div class="form-tip">
{{ $t('aiConfig.form.baseUrlTip') }}
<br>
{{ $t('aiConfig.form.fullEndpoint') }}: {{ fullEndpointExample }}
</div>
</el-form-item>
<el-form-item :label="$t('aiConfig.form.apiKey')" prop="api_key">
<el-input
v-model="form.api_key"
type="password"
show-password
:placeholder="$t('aiConfig.form.apiKeyPlaceholder')"
/>
<div class="form-tip">{{ $t('aiConfig.form.apiKeyTip') }}</div>
</el-form-item>
<el-form-item v-if="isEdit" :label="$t('aiConfig.form.isActive')">
<el-switch v-model="form.is_active" />
</el-form-item>
</el-form>
<template #footer>
<div class="quick-setup-footer">
<a
href="https://api.chatfire.site/login?inviteCode=C4453345"
target="_blank"
class="register-link"
>
没有 API Key点击注册
</a>
<div class="footer-buttons">
<el-button @click="editDialogVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button v-if="form.service_type === 'text'" @click="testConnection" :loading="testing">{{ $t('aiConfig.actions.test') }}</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
{{ isEdit ? $t('common.save') : $t('common.create') }}
</el-button>
</div>
</div>
</template>
</el-dialog>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Plus, MagicStick } from '@element-plus/icons-vue'
import { aiAPI } from '@/api/ai'
import type { AIServiceConfig, AIServiceType, CreateAIConfigRequest, UpdateAIConfigRequest } from '@/types/ai'
import ConfigList from '@/views/settings/components/ConfigList.vue'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const activeTab = ref<AIServiceType>('text')
const loading = ref(false)
const configs = ref<AIServiceConfig[]>([])
const editDialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref<number>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const testing = ref(false)
const quickSetupVisible = ref(false)
const quickSetupApiKey = ref('')
const quickSetupLoading = ref(false)
const form = reactive<CreateAIConfigRequest & { is_active?: boolean, provider?: string }>({
service_type: 'text',
provider: '',
name: '',
base_url: '',
api_key: '',
model: [],
priority: 0,
is_active: true
})
// Provider configs
interface ProviderConfig {
id: string
name: string
models: string[]
disabled?: boolean
}
const providerConfigs: Record<AIServiceType, ProviderConfig[]> = {
text: [
{ id: 'openai', name: 'OpenAI', models: ['gpt-5.2', 'gemini-3-pro-preview'] },
{
id: 'chatfire',
name: 'Chatfire',
models: [
'gpt-4o',
'claude-sonnet-4-5-20250929',
'doubao-seed-1-8-251228',
'kimi-k2-thinking',
'gemini-3-pro',
'gemini-2.5-pro',
'gemini-3-pro-preview'
]
},
{
id: 'gemini',
name: 'Google Gemini',
models: ['gemini-2.5-pro', 'gemini-3-pro-preview']
}
],
image: [
{
id: 'volcengine',
name: '火山引擎',
models: ['doubao-seedream-4-5-251128', 'doubao-seedream-4-0-250828']
},
{
id: 'chatfire',
name: 'Chatfire',
models: ['doubao-seedream-4-5-251128', 'nano-banana-pro']
},
{
id: 'gemini',
name: 'Google Gemini',
models: ['gemini-3-pro-image-preview']
},
{ id: 'openai', name: 'OpenAI', models: ['dall-e-3', 'dall-e-2'] }
],
video: [
{
id: 'volces',
name: '火山引擎',
models: [
'doubao-seedance-1-5-pro-251215',
'doubao-seedance-1-0-lite-i2v-250428',
'doubao-seedance-1-0-lite-t2v-250428',
'doubao-seedance-1-0-pro-250528',
'doubao-seedance-1-0-pro-fast-251015'
]
},
{
id: 'chatfire',
name: 'Chatfire',
models: [
'doubao-seedance-1-5-pro-251215',
'doubao-seedance-1-0-lite-i2v-250428',
'doubao-seedance-1-0-lite-t2v-250428',
'doubao-seedance-1-0-pro-250528',
'doubao-seedance-1-0-pro-fast-251015',
'sora',
'sora-pro'
]
},
{ id: 'openai', name: 'OpenAI', models: ['sora-2', 'sora-2-pro'] }
]
}
const availableProviders = computed(() => {
return providerConfigs[form.service_type] || []
})
const availableModels = computed(() => {
if (!form.provider) return []
const provider = availableProviders.value.find(p => p.id === form.provider)
return provider?.models || []
})
const fullEndpointExample = computed(() => {
const baseUrl = form.base_url || 'https://api.example.com'
const provider = form.provider
const serviceType = form.service_type
let endpoint = ''
if (serviceType === 'text') {
if (provider === 'gemini' || provider === 'google') {
endpoint = '/v1beta/models/{model}:generateContent'
} else {
endpoint = '/chat/completions'
}
} else if (serviceType === 'image') {
if (provider === 'gemini' || provider === 'google') {
endpoint = '/v1beta/models/{model}:generateContent'
} else {
endpoint = '/images/generations'
}
} else if (serviceType === 'video') {
if (provider === 'chatfire') {
endpoint = '/video/generations'
} else if (provider === 'doubao' || provider === 'volcengine' || provider === 'volces') {
endpoint = '/contents/generations/tasks'
} else if (provider === 'openai') {
endpoint = '/videos'
} else {
endpoint = '/video/generations'
}
}
return baseUrl + endpoint
})
const rules: FormRules = {
name: [{ required: true, message: '请输入配置名称', trigger: 'blur' }],
provider: [{ required: true, message: '请选择厂商', trigger: 'change' }],
base_url: [
{ required: true, message: '请输入 Base URL', trigger: 'blur' },
{ type: 'url', message: '请输入正确的 URL 格式', trigger: 'blur' }
],
api_key: [{ required: true, message: '请输入 API Key', trigger: 'blur' }],
model: [{
required: true,
message: '请至少选择一个模型',
trigger: 'change',
validator: (rule: any, value: any, callback: any) => {
if (Array.isArray(value) && value.length > 0) {
callback()
} else if (typeof value === 'string' && value.length > 0) {
callback()
} else {
callback(new Error('请至少选择一个模型'))
}
}
}]
}
const loadConfigs = async () => {
loading.value = true
try {
configs.value = await aiAPI.list(activeTab.value)
} catch (error: any) {
ElMessage.error(error.message || '加载失败')
} finally {
loading.value = false
}
}
const generateConfigName = (provider: string, serviceType: AIServiceType): string => {
const providerNames: Record<string, string> = {
'chatfire': 'ChatFire',
'openai': 'OpenAI',
'gemini': 'Gemini',
'google': 'Google'
}
const serviceNames: Record<AIServiceType, string> = {
'text': '文本',
'image': '图片',
'video': '视频'
}
const randomNum = Math.floor(Math.random() * 10000).toString().padStart(4, '0')
const providerName = providerNames[provider] || provider
const serviceName = serviceNames[serviceType] || serviceType
return `${providerName}-${serviceName}-${randomNum}`
}
const showCreateDialog = () => {
isEdit.value = false
editingId.value = undefined
resetForm()
form.service_type = activeTab.value
form.provider = 'chatfire'
form.base_url = 'https://api.chatfire.site/v1'
form.name = generateConfigName('chatfire', activeTab.value)
editDialogVisible.value = true
}
const handleEdit = (config: AIServiceConfig) => {
isEdit.value = true
editingId.value = config.id
Object.assign(form, {
service_type: config.service_type,
provider: config.provider || 'chatfire',
name: config.name,
base_url: config.base_url,
api_key: config.api_key,
model: Array.isArray(config.model) ? config.model : [config.model],
priority: config.priority || 0,
is_active: config.is_active
})
editDialogVisible.value = true
}
const handleDelete = async (config: AIServiceConfig) => {
try {
await ElMessageBox.confirm('确定要删除该配置吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await aiAPI.delete(config.id)
ElMessage.success('删除成功')
loadConfigs()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '删除失败')
}
}
}
const handleToggleActive = async (config: AIServiceConfig) => {
try {
const newActiveState = !config.is_active
await aiAPI.update(config.id, { is_active: newActiveState })
ElMessage.success(newActiveState ? '已启用配置' : '已禁用配置')
await loadConfigs()
} catch (error: any) {
ElMessage.error(error.message || '操作失败')
}
}
const testConnection = async () => {
if (!formRef.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
testing.value = true
try {
await aiAPI.testConnection({
base_url: form.base_url,
api_key: form.api_key,
model: form.model,
provider: form.provider
})
ElMessage.success('连接测试成功!')
} catch (error: any) {
ElMessage.error(error.message || '连接测试失败')
} finally {
testing.value = false
}
}
const handleTest = async (config: AIServiceConfig) => {
testing.value = true
try {
await aiAPI.testConnection({
base_url: config.base_url,
api_key: config.api_key,
model: config.model,
provider: config.provider
})
ElMessage.success('连接测试成功!')
} catch (error: any) {
ElMessage.error(error.message || '连接测试失败')
} finally {
testing.value = false
}
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
if (isEdit.value && editingId.value) {
const updateData: UpdateAIConfigRequest = {
name: form.name,
provider: form.provider,
base_url: form.base_url,
api_key: form.api_key,
model: form.model,
priority: form.priority,
is_active: form.is_active
}
await aiAPI.update(editingId.value, updateData)
ElMessage.success('更新成功')
} else {
await aiAPI.create(form)
ElMessage.success('创建成功')
}
editDialogVisible.value = false
loadConfigs()
} catch (error: any) {
ElMessage.error(error.message || '操作失败')
} finally {
submitting.value = false
}
})
}
const handleTabChange = (tabName: string | number) => {
activeTab.value = tabName as AIServiceType
loadConfigs()
}
const handleProviderChange = () => {
form.model = []
if (form.provider === 'gemini' || form.provider === 'google') {
form.base_url = 'https://api.chatfire.site'
} else {
form.base_url = 'https://api.chatfire.site/v1'
}
if (!isEdit.value) {
form.name = generateConfigName(form.provider, form.service_type)
}
}
const resetForm = () => {
const serviceType = form.service_type || 'text'
Object.assign(form, {
service_type: serviceType,
provider: '',
name: '',
base_url: '',
api_key: '',
model: [],
priority: 0,
is_active: true
})
formRef.value?.resetFields()
}
const showQuickSetupDialog = () => {
quickSetupApiKey.value = ''
quickSetupVisible.value = true
}
const handleQuickSetup = async () => {
if (!quickSetupApiKey.value.trim()) {
ElMessage.warning('请输入 API Key')
return
}
quickSetupLoading.value = true
const baseUrl = 'https://api.chatfire.site/v1'
const apiKey = quickSetupApiKey.value.trim()
try {
// 创建文本配置
const textProvider = providerConfigs.text.find(p => p.id === 'chatfire')!
await aiAPI.create({
service_type: 'text',
provider: 'chatfire',
name: generateConfigName('chatfire', 'text'),
base_url: baseUrl,
api_key: apiKey,
model: [textProvider.models[0]],
priority: 0
})
// 创建图片配置
const imageProvider = providerConfigs.image.find(p => p.id === 'chatfire')!
await aiAPI.create({
service_type: 'image',
provider: 'chatfire',
name: generateConfigName('chatfire', 'image'),
base_url: baseUrl,
api_key: apiKey,
model: [imageProvider.models[0]],
priority: 0
})
// 创建视频配置
const videoProvider = providerConfigs.video.find(p => p.id === 'chatfire')!
await aiAPI.create({
service_type: 'video',
provider: 'chatfire',
name: generateConfigName('chatfire', 'video'),
base_url: baseUrl,
api_key: apiKey,
model: [videoProvider.models[0]],
priority: 0
})
ElMessage.success('一键配置成功!已创建文本、图片、视频三个服务配置')
quickSetupVisible.value = false
loadConfigs()
} catch (error: any) {
ElMessage.error(error.message || '配置失败')
} finally {
quickSetupLoading.value = false
}
}
// Load configs when dialog opens
watch(visible, (val) => {
if (val) {
loadConfigs()
}
})
</script>
<style scoped>
.ai-config-dialog :deep(.el-dialog__header) {
padding: 16px 20px;
border-bottom: 1px solid var(--border-primary);
margin-right: 0;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 32px;
}
.header-actions {
display: flex;
gap: 8px;
}
.quick-setup-info {
margin-bottom: 16px;
padding: 12px 16px;
background: var(--bg-secondary);
border-radius: 8px;
font-size: 14px;
color: var(--text-primary);
p {
margin: 0 0 8px 0;
}
ul {
margin: 8px 0;
padding-left: 20px;
}
li {
margin: 4px 0;
color: var(--text-secondary);
}
.quick-setup-tip {
margin-top: 12px;
font-size: 12px;
color: var(--text-muted);
}
}
.quick-setup-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.register-link {
font-size: 12px;
color: var(--text-muted);
text-decoration: none;
transition: color 0.2s;
&:hover {
color: var(--accent);
}
}
.footer-buttons {
display: flex;
gap: 8px;
}
.dialog-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.ai-config-dialog :deep(.el-dialog__body) {
padding: 20px;
max-height: 60vh;
overflow-y: auto;
}
.config-tabs {
margin: 0;
}
.form-tip {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
/* Dark mode */
.dark .ai-config-dialog :deep(.el-dialog) {
background: var(--bg-card);
}
.dark .ai-config-dialog :deep(.el-dialog__header) {
background: var(--bg-card);
}
.dark :deep(.el-form-item__label) {
color: var(--text-primary);
}
.dark :deep(.el-input__wrapper) {
background: var(--bg-secondary);
box-shadow: 0 0 0 1px var(--border-primary) inset;
}
.dark :deep(.el-input__inner) {
color: var(--text-primary);
}
.dark :deep(.el-select .el-input__wrapper) {
background: var(--bg-secondary);
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<!-- Minimalist action button with icon and optional tooltip -->
<!-- 简约操作按钮带图标和可选提示 -->
<el-tooltip
v-if="tooltip"
:content="tooltip"
placement="top"
:show-after="500"
>
<button
:class="['action-button', variant, { disabled }]"
:disabled="disabled"
@click="$emit('click')"
>
<el-icon :size="size">
<component :is="icon" />
</el-icon>
</button>
</el-tooltip>
<button
v-else
:class="['action-button', variant, { disabled }]"
:disabled="disabled"
@click="$emit('click')"
>
<el-icon :size="size">
<component :is="icon" />
</el-icon>
</button>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
/**
* ActionButton - Minimalist icon button for actions
* 操作按钮 - 简约图标按钮用于各种操作
*/
withDefaults(defineProps<{
icon: Component
tooltip?: string
variant?: 'default' | 'primary' | 'danger'
size?: number
disabled?: boolean
}>(), {
variant: 'default',
size: 16,
disabled: false
})
defineEmits<{
click: []
}>()
</script>
<style scoped>
.action-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.action-button:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.action-button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.action-button.primary:hover {
background: var(--accent-light);
color: var(--accent);
}
.action-button.danger:hover {
background: #fef2f2;
color: #ef4444;
}
.dark .action-button.danger:hover {
background: rgba(239, 68, 68, 0.15);
}
.action-button.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-button.disabled:hover {
background: transparent;
color: var(--text-muted);
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<div class="app-layout">
<!-- Global Header -->
<header class="app-header">
<div class="header-content">
<div class="header-left">
<router-link to="/" class="logo">
<span class="logo-text">🎬 AI Drama</span>
</router-link>
</div>
<div class="header-right">
<LanguageSwitcher />
<ThemeToggle />
<el-button @click="showAIConfig = true" class="header-btn">
<el-icon><Setting /></el-icon>
<span class="btn-text">{{ $t('drama.aiConfig') }}</span>
</el-button>
<!-- <el-button :icon="Setting" circle @click="showAIConfig = true" :title="$t('aiConfig.title')" /> -->
</div>
</div>
</header>
<!-- Main Content -->
<main class="app-main">
<slot />
</main>
<!-- AI Config Dialog -->
<AIConfigDialog v-model="showAIConfig" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Setting } from '@element-plus/icons-vue'
import ThemeToggle from './ThemeToggle.vue'
import AIConfigDialog from './AIConfigDialog.vue'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const showAIConfig = ref(false)
</script>
<style scoped>
.app-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
position: sticky;
top: 0;
z-index: 100;
background: var(--bg-card);
border-bottom: 1px solid var(--border-primary);
backdrop-filter: blur(8px);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-4);
height: 56px;
max-width: 100%;
margin: 0 auto;
}
.header-btn {
border-radius: var(--radius-lg);
font-weight: 500;
}
.header-btn.primary {
background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%);
border: none;
box-shadow: 0 4px 14px rgba(14, 165, 233, 0.35);
}
.header-btn.primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(14, 165, 233, 0.45);
}
.header-left {
display: flex;
align-items: center;
gap: var(--space-4);
}
.logo {
display: flex;
align-items: center;
gap: var(--space-2);
text-decoration: none;
color: var(--text-primary);
font-weight: 700;
font-size: 1.125rem;
transition: opacity var(--transition-fast);
}
.logo:hover {
opacity: 0.8;
}
.logo-text {
background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header-right {
display: flex;
align-items: center;
gap: var(--space-2);
}
.app-main {
flex: 1;
}
/* Dark mode adjustments */
.dark .app-header {
background: rgba(26, 33, 41, 0.95);
}
</style>

Some files were not shown because too many files have changed in this diff Show More