create project
This commit is contained in:
79
DOCKER_HOST_ACCESS.md
Normal file
79
DOCKER_HOST_ACCESS.md
Normal 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
93
Dockerfile
Normal 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
53
LICENSE
Normal 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
535
README.md
Normal file
@@ -0,0 +1,535 @@
|
||||
# 🎬 Huobao Drama - AI短剧生成平台
|
||||
|
||||
<div align="center">
|
||||
|
||||
**基于 Go + Vue3 的全栈AI短剧自动化生产平台**
|
||||
|
||||
|
||||
[](https://golang.org)
|
||||
[](https://vuejs.org)
|
||||
[](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)
|
||||
|
||||
## 📧 联系方式
|
||||
商务联系V:dangbao1117
|
||||
## 项目交流群
|
||||

|
||||
- 提交 [Issue](../../issues)
|
||||
- 发送邮件至项目维护者
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**⭐ 如果这个项目对你有帮助,请给一个Star!**
|
||||
## Star History
|
||||
|
||||
[](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
136
api/handlers/ai_config.go
Normal 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
220
api/handlers/asset.go
Normal 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)
|
||||
}
|
||||
34
api/handlers/character_batch.go
Normal file
34
api/handlers/character_batch.go
Normal 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),
|
||||
})
|
||||
}
|
||||
275
api/handlers/character_library.go
Normal file
275
api/handlers/character_library.go
Normal 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": "角色已删除"})
|
||||
}
|
||||
38
api/handlers/character_library_gen.go
Normal file
38
api/handlers/character_library_gen.go
Normal 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
310
api/handlers/drama.go
Normal 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,
|
||||
})
|
||||
}
|
||||
55
api/handlers/frame_prompt.go
Normal file
55
api/handlers/frame_prompt.go
Normal 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)
|
||||
}
|
||||
30
api/handlers/frame_prompt_query.go
Normal file
30
api/handlers/frame_prompt_query.go
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
224
api/handlers/image_generation.go
Normal file
224
api/handlers/image_generation.go
Normal 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
75
api/handlers/scene.go
Normal 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,
|
||||
})
|
||||
}
|
||||
118
api/handlers/script_generation.go
Normal file
118
api/handlers/script_generation.go
Normal 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)
|
||||
}
|
||||
96
api/handlers/storyboard.go
Normal file
96
api/handlers/storyboard.go
Normal 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
57
api/handlers/task.go
Normal 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
142
api/handlers/upload.go
Normal 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,
|
||||
})
|
||||
}
|
||||
149
api/handlers/video_generation.go
Normal file
149
api/handlers/video_generation.go
Normal 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
104
api/handlers/video_merge.go
Normal 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
34
api/middlewares/cors.go
Normal 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
30
api/middlewares/logger.go
Normal 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
52
api/middlewares/ratelimit.go
Normal file
52
api/middlewares/ratelimit.go
Normal 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
212
api/routes/routes.go
Normal 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
|
||||
}
|
||||
398
application/services/ai_service.go
Normal file
398
application/services/ai_service.go
Normal 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...)
|
||||
}
|
||||
287
application/services/asset_service.go
Normal file
287
application/services/asset_service.go
Normal 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
|
||||
}
|
||||
473
application/services/character_library_service.go
Normal file
473
application/services/character_library_service.go
Normal 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))
|
||||
}
|
||||
630
application/services/drama_service.go
Normal file
630
application/services/drama_service.go
Normal 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
|
||||
}
|
||||
428
application/services/frame_prompt_service.go
Normal file
428
application/services/frame_prompt_service.go
Normal 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, ", ")
|
||||
}
|
||||
984
application/services/image_generation_service.go
Normal file
984
application/services/image_generation_service.go
Normal 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
|
||||
}
|
||||
21
application/services/resource_transfer_service.go
Normal file
21
application/services/resource_transfer_service.go
Normal 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相关功能已移除
|
||||
// 如需资源转存功能,请使用本地存储
|
||||
511
application/services/script_generation_service.go
Normal file
511
application/services/script_generation_service.go
Normal 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 tokens,20集约21000 tokens,都在安全范围内
|
||||
if maxTokens > 32000 {
|
||||
maxTokens = 32000 // 保守限制在32k,留足够空间
|
||||
}
|
||||
|
||||
s.log.Infow("Generating episodes with token limit",
|
||||
"episode_count", req.EpisodeCount,
|
||||
"max_tokens", maxTokens,
|
||||
"estimated_per_episode", perEpisodeTokens)
|
||||
|
||||
text, err := s.aiService.GenerateText(
|
||||
userPrompt,
|
||||
systemPrompt,
|
||||
ai.WithTemperature(0.8),
|
||||
ai.WithMaxTokens(maxTokens),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
s.log.Errorw("Failed to generate episodes", "error", err)
|
||||
return nil, fmt.Errorf("生成失败: %w", err)
|
||||
}
|
||||
|
||||
s.log.Infow("AI response received", "length", len(text), "preview", text[:minInt(200, len(text))])
|
||||
|
||||
var result struct {
|
||||
Episodes []struct {
|
||||
EpisodeNumber int `json:"episode_number"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ScriptContent string `json:"script_content"`
|
||||
Duration int `json:"duration"`
|
||||
} `json:"episodes"`
|
||||
}
|
||||
|
||||
if err := utils.SafeParseAIJSON(text, &result); err != nil {
|
||||
s.log.Errorw("Failed to parse episodes JSON", "error", err, "raw_response", text[:minInt(500, len(text))])
|
||||
return nil, fmt.Errorf("解析 AI 返回结果失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查生成的集数是否符合要求
|
||||
if len(result.Episodes) < req.EpisodeCount {
|
||||
s.log.Warnw("AI generated fewer episodes than requested",
|
||||
"requested", req.EpisodeCount,
|
||||
"generated", len(result.Episodes))
|
||||
}
|
||||
|
||||
// 记录每集的详细信息
|
||||
for i, ep := range result.Episodes {
|
||||
s.log.Infow("Episode parsed from AI",
|
||||
"index", i,
|
||||
"episode_number", ep.EpisodeNumber,
|
||||
"title", ep.Title,
|
||||
"description_length", len(ep.Description),
|
||||
"script_content_length", len(ep.ScriptContent),
|
||||
"duration", ep.Duration)
|
||||
}
|
||||
|
||||
var episodes []models.Episode
|
||||
for _, ep := range result.Episodes {
|
||||
duration := ep.Duration
|
||||
if duration == 0 {
|
||||
// AI未返回时长时使用默认值
|
||||
duration = 180
|
||||
s.log.Warnw("Episode duration not provided by AI, using default",
|
||||
"episode_number", ep.EpisodeNumber,
|
||||
"default_duration", 180)
|
||||
} else {
|
||||
s.log.Infow("Episode duration from AI",
|
||||
"episode_number", ep.EpisodeNumber,
|
||||
"duration", duration)
|
||||
}
|
||||
|
||||
// 记录即将保存的数据
|
||||
s.log.Infow("Creating episode in database",
|
||||
"episode_number", ep.EpisodeNumber,
|
||||
"title", ep.Title,
|
||||
"script_content_length", len(ep.ScriptContent),
|
||||
"script_content_empty", ep.ScriptContent == "")
|
||||
|
||||
dramaID, err := strconv.ParseUint(req.DramaID, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid drama ID")
|
||||
}
|
||||
|
||||
episode := models.Episode{
|
||||
DramaID: uint(dramaID),
|
||||
EpisodeNum: ep.EpisodeNumber,
|
||||
Title: ep.Title,
|
||||
Description: &ep.Description,
|
||||
ScriptContent: &ep.ScriptContent,
|
||||
Duration: duration,
|
||||
Status: "draft",
|
||||
}
|
||||
|
||||
if err := s.db.Create(&episode).Error; err != nil {
|
||||
s.log.Errorw("Failed to create episode", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
episodes = append(episodes, episode)
|
||||
}
|
||||
|
||||
s.log.Infow("Episodes generated", "drama_id", req.DramaID, "count", len(episodes))
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
// GenerateScenesForEpisode 已废弃,使用 StoryboardService.GenerateStoryboard 替代
|
||||
// ParseScript 已废弃,使用 GenerateCharacters 替代
|
||||
|
||||
// minInt 返回两个整数中较小的一个
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
395
application/services/storyboard_composition_service.go
Normal file
395
application/services/storyboard_composition_service.go
Normal 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 ""
|
||||
}
|
||||
741
application/services/storyboard_service.go
Normal file
741
application/services/storyboard_service.go
Normal 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"` // 背景ID(AI直接返回,可为null)
|
||||
Movement string `json:"movement"` // 运镜
|
||||
Action string `json:"action"` // 动作
|
||||
Dialogue string `json:"dialogue"` // 对话/独白
|
||||
Result string `json:"result"` // 画面结果
|
||||
Atmosphere string `json:"atmosphere"` // 环境氛围
|
||||
Emotion string `json:"emotion"` // 情绪
|
||||
Duration int `json:"duration"` // 时长(秒)
|
||||
BgmPrompt string `json:"bgm_prompt"` // 配乐提示词
|
||||
SoundEffect string `json:"sound_effect"` // 音效描述
|
||||
Characters []uint `json:"characters"` // 涉及的角色ID列表
|
||||
IsPrimary bool `json:"is_primary"` // 是否主镜
|
||||
}
|
||||
|
||||
type GenerateStoryboardResult struct {
|
||||
Storyboards []Storyboard `json:"storyboards"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
func (s *StoryboardService) GenerateStoryboard(episodeID string) (*GenerateStoryboardResult, error) {
|
||||
// 从数据库获取剧集信息
|
||||
var episode struct {
|
||||
ID string
|
||||
ScriptContent *string
|
||||
Description *string
|
||||
DramaID string
|
||||
}
|
||||
|
||||
err := s.db.Table("episodes").
|
||||
Select("episodes.id, episodes.script_content, episodes.description, episodes.drama_id").
|
||||
Joins("INNER JOIN dramas ON dramas.id = episodes.drama_id").
|
||||
Where("episodes.id = ?", episodeID).
|
||||
First(&episode).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("剧集不存在或无权限访问")
|
||||
}
|
||||
|
||||
// 获取剧本内容
|
||||
var scriptContent string
|
||||
if episode.ScriptContent != nil && *episode.ScriptContent != "" {
|
||||
scriptContent = *episode.ScriptContent
|
||||
} else if episode.Description != nil && *episode.Description != "" {
|
||||
scriptContent = *episode.Description
|
||||
} else {
|
||||
return nil, fmt.Errorf("剧本内容为空,请先生成剧集内容")
|
||||
}
|
||||
|
||||
// 获取该剧本的所有角色
|
||||
var characters []models.Character
|
||||
if err := s.db.Where("drama_id = ?", episode.DramaID).Order("name ASC").Find(&characters).Error; err != nil {
|
||||
return nil, fmt.Errorf("获取角色列表失败: %w", err)
|
||||
}
|
||||
|
||||
// 构建角色列表字符串(包含ID和名称)
|
||||
characterList := "无角色"
|
||||
if len(characters) > 0 {
|
||||
var charInfoList []string
|
||||
for _, char := range characters {
|
||||
charInfoList = append(charInfoList, fmt.Sprintf(`{"id": %d, "name": "%s"}`, char.ID, char.Name))
|
||||
}
|
||||
characterList = fmt.Sprintf("[%s]", strings.Join(charInfoList, ", "))
|
||||
}
|
||||
|
||||
// 获取该项目已提取的场景列表(项目级)
|
||||
var scenes []models.Scene
|
||||
if err := s.db.Where("drama_id = ?", episode.DramaID).Order("location ASC, time ASC").Find(&scenes).Error; err != nil {
|
||||
s.log.Warnw("Failed to get scenes", "error", err)
|
||||
}
|
||||
|
||||
// 构建场景列表字符串(包含ID、地点、时间)
|
||||
sceneList := "无场景"
|
||||
if len(scenes) > 0 {
|
||||
var sceneInfoList []string
|
||||
for _, bg := range scenes {
|
||||
sceneInfoList = append(sceneInfoList, fmt.Sprintf(`{"id": %d, "location": "%s", "time": "%s"}`, bg.ID, bg.Location, bg.Time))
|
||||
}
|
||||
sceneList = fmt.Sprintf("[%s]", strings.Join(sceneInfoList, ", "))
|
||||
}
|
||||
|
||||
s.log.Infow("Generating storyboard",
|
||||
"episode_id", episodeID,
|
||||
"drama_id", episode.DramaID,
|
||||
"script_length", len(scriptContent),
|
||||
"character_count", len(characters),
|
||||
"characters", characterList,
|
||||
"scene_count", len(scenes),
|
||||
"scenes", sceneList)
|
||||
|
||||
// 构建分镜头生成提示词
|
||||
prompt := fmt.Sprintf(`【角色】你是一位资深影视分镜师,精通罗伯特·麦基的镜头拆解理论,擅长构建情绪节奏。
|
||||
|
||||
【任务】将小说剧本按**独立动作单元**拆解为分镜头方案。
|
||||
|
||||
【本剧可用角色列表】
|
||||
%s
|
||||
|
||||
**重要**:在characters字段中,只能使用上述角色列表中的角色ID(数字),不得自创角色或使用其他ID。
|
||||
|
||||
【本剧已提取的场景背景列表】
|
||||
%s
|
||||
|
||||
**重要**:在scene_id字段中,必须从上述背景列表中选择最匹配的背景ID(数字)。如果没有合适的背景,则填null。
|
||||
|
||||
【剧本原文】
|
||||
%s
|
||||
|
||||
【分镜要素】每个镜头聚焦单一动作,描述要详尽具体:
|
||||
1. **镜头标题(title)**:用3-5个字概括该镜头的核心内容或情绪
|
||||
- 例如:"噩梦惊醒"、"对视沉思"、"逃离现场"、"意外发现"
|
||||
2. **时间**:[清晨/午后/深夜/具体时分+详细光线描述]
|
||||
- 例如:"深夜22:30·月光从破窗斜射入室内,形成明暗分界"
|
||||
3. **地点**:[场景完整描述+空间布局+环境细节]
|
||||
- 例如:"废弃码头仓库·锈蚀货架林立,地面积水反射微弱灯光,墙角堆放腐朽木箱"
|
||||
4. **镜头设计**:
|
||||
- **景别(shot_type)**:[远景/全景/中景/近景/特写]
|
||||
- **镜头角度(angle)**:[平视/仰视/俯视/侧面/背面]
|
||||
- **运镜方式(movement)**:[固定镜头/推镜/拉镜/摇镜/跟镜/移镜]
|
||||
5. **人物行为**:**详细动作描述**,包含[谁+具体怎么做+肢体细节+表情状态]
|
||||
- 例如:"陈峥弯腰用撬棍撬动保险箱门,手臂青筋暴起,眉头紧锁,汗水滑落脸颊"
|
||||
6. **对话/独白**:提取该镜头中的完整对话或独白内容(如无对话则为空字符串)
|
||||
7. **画面结果**:动作的即时后果+视觉细节+氛围变化
|
||||
- 例如:"保险箱门弹开发出金属碰撞声,扬起灰尘在光束中飘散,箱内空无一物只有陈旧报纸,陈峥表情从期待转为失望"
|
||||
8. **环境氛围**:光线质感+色调+声音环境+整体氛围
|
||||
- 例如:"昏暗冷色调,只有手电筒光束晃动,远处传来海浪拍打声,压抑沉闷"
|
||||
9. **配乐提示(bgm_prompt)**:描述该镜头配乐的氛围、节奏、情绪(如无特殊要求则为空字符串)
|
||||
- 例如:"低沉紧张的弦乐,节奏缓慢,营造压抑氛围"
|
||||
10. **音效描述(sound_effect)**:描述该镜头的关键音效(如无特殊音效则为空字符串)
|
||||
- 例如:"金属碰撞声、脚步声、海浪拍打声"
|
||||
11. **观众情绪**:[情绪类型]([强度:↑↑↑/↑↑/↑/→/↓] + [落点:悬置/释放/反转])
|
||||
|
||||
【输出格式】请以JSON格式输出,每个镜头包含以下字段(**所有描述性字段都要详细完整**):
|
||||
{
|
||||
"storyboards": [
|
||||
{
|
||||
"shot_number": 1,
|
||||
"title": "噩梦惊醒",
|
||||
"shot_type": "全景",
|
||||
"angle": "俯视45度角",
|
||||
"time": "深夜22:30·月光从破窗斜射入仓库,在地面积水中形成银白色反光,墙角昏暗不清",
|
||||
"location": "废弃码头仓库·锈蚀货架林立,地面积水反射微弱灯光,墙角堆放腐朽木箱和渔网,空气中弥漫潮湿霉味",
|
||||
"scene_id": 1,
|
||||
"movement": "固定镜头",
|
||||
"action": "陈峥弯腰双手握住撬棍用力撬动保险箱门,手臂青筋暴起,眉头紧锁,汗水从额头滑落脸颊,呼吸急促",
|
||||
"dialogue": "(独白)这么多年了,里面到底藏着什么秘密?",
|
||||
"result": "保险箱门突然弹开发出刺耳金属声,扬起灰尘在手电筒光束中飘散,箱内空无一物只有几张发黄的旧报纸,陈峥表情从期待转为震惊和失望,瞳孔放大",
|
||||
"atmosphere": "昏暗冷色调·青灰色为主,只有手电筒光束在黑暗中晃动,远处传来海浪拍打码头的沉闷声,整体氛围压抑沉重",
|
||||
"emotion": "好奇感↑↑转失望↓(情绪反转)",
|
||||
"duration": 9,
|
||||
"bgm_prompt": "低沉紧张的弦乐,节奏缓慢,营造压抑悬疑氛围",
|
||||
"sound_effect": "金属碰撞声、灰尘飘散声、海浪拍打声",
|
||||
"characters": [159],
|
||||
"is_primary": true
|
||||
},
|
||||
{
|
||||
"shot_number": 2,
|
||||
"title": "对视沉思",
|
||||
"shot_type": "近景",
|
||||
"angle": "平视",
|
||||
"time": "深夜22:31·仓库内光线昏暗,只有手电筒光从侧面照亮两人脸部轮廓",
|
||||
"location": "废弃码头仓库·保险箱旁,背景是模糊的货架剪影",
|
||||
"scene_id": 1,
|
||||
"movement": "推镜",
|
||||
"action": "陈峥缓缓转身,目光与身后的李芳对视,李芳手握手电筒,光束在两人之间晃动,眼神中透露疑惑和警惕",
|
||||
"dialogue": "陈峥:\"我们被耍了,这里根本没有我们要找的东西。\" 李芳:\"现在怎么办?我们的时间不多了。\"",
|
||||
"result": "两人站在昏暗中陷入沉思,手电筒光束照在地面形成圆形光斑,背景传来微弱的金属摩擦声,气氛紧张凝重",
|
||||
"atmosphere": "低调光线·暗部占画面70%,侧面硬光勾勒人物轮廓,冷暖光对比强烈,海风吹过产生呼啸声,营造紧迫感",
|
||||
"emotion": "紧张感↑↑·警惕↑↑(悬置)",
|
||||
"duration": 7,
|
||||
"bgm_prompt": "紧张感逐渐升级的音效,低频持续音",
|
||||
"sound_effect": "呼吸声、金属摩擦声、海风呼啸声",
|
||||
"characters": [159, 160],
|
||||
"is_primary": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
**dialogue字段说明**:
|
||||
- 如果有对话,格式为:角色名:\"台词内容\"
|
||||
- 多人对话用空格分隔:角色A:\"...\" 角色B:\"...\"
|
||||
- 独白格式为:(独白)内容
|
||||
- 旁白格式为:(旁白)内容
|
||||
- 无对话时填写空字符串:""
|
||||
- **对话内容必须从原剧本中提取,保持原汁原味**
|
||||
|
||||
**角色和背景要求**:
|
||||
- characters字段必须包含该镜头中出现的所有角色ID(数字数组格式)
|
||||
- 只提取实际出现的角色ID,不出现角色则为空数组[]
|
||||
- **角色ID必须严格使用【本剧可用角色列表】中的id字段(数字),不得使用其他ID或自创角色**
|
||||
- 例如:如果镜头中出现李明(id:159)和王芳(id:160),则characters字段应为[159, 160]
|
||||
- scene_id字段必须从【本剧已提取的场景背景列表】中选择最匹配的背景ID(数字)
|
||||
- 如果列表中没有合适的背景,则scene_id填null
|
||||
- 例如:如果镜头发生在"城市公寓卧室·凌晨",应选择id为1的场景背景
|
||||
|
||||
**duration时长估算规则(秒)**:
|
||||
- **所有镜头时长必须在4-12秒范围内**,确保节奏合理流畅
|
||||
- **综合估算原则**:时长由对话内容、动作复杂度、情绪节奏三方面综合决定
|
||||
|
||||
**估算步骤**:
|
||||
1. **基础时长**(从场景内容判断):
|
||||
- 纯对话场景(无明显动作):基础4秒
|
||||
- 纯动作场景(无对话):基础5秒
|
||||
- 对话+动作混合场景:基础6秒
|
||||
|
||||
2. **对话调整**(根据台词字数增加时长):
|
||||
- 无对话:+0秒
|
||||
- 短对话(1-20字):+1-2秒
|
||||
- 中等对话(21-50字):+2-4秒
|
||||
- 长对话(51字以上):+4-6秒
|
||||
|
||||
3. **动作调整**(根据动作复杂度增加时长):
|
||||
- 无动作/静态:+0秒
|
||||
- 简单动作(表情、转身、拿物品):+0-1秒
|
||||
- 一般动作(走动、开门、坐下):+1-2秒
|
||||
- 复杂动作(打斗、追逐、大幅度移动):+2-4秒
|
||||
- 环境展示(全景扫描、氛围营造):+2-5秒
|
||||
|
||||
4. **最终时长** = 基础时长 + 对话调整 + 动作调整,确保结果在4-12秒范围内
|
||||
|
||||
**示例**:
|
||||
- "陈峥转身离开"(简单动作,无对话):5 + 0 + 1 = 6秒
|
||||
- "李芳:\"你要去哪里?\""(短对话,无动作):4 + 2 + 0 = 6秒
|
||||
- "陈峥推开房门,李芳:\"终于找到你了,这些年你去哪了?\""(一般动作+中等对话):6 + 3 + 2 = 11秒
|
||||
- "两人在雨中激烈搏斗,陈峥:\"住手!\""(复杂动作+短对话):6 + 2 + 4 = 12秒
|
||||
|
||||
**重要**:准确估算每个镜头时长,所有分镜时长之和将作为剧集总时长
|
||||
|
||||
**特别要求**:
|
||||
- **【极其重要】必须100%%完整拆解整个剧本,不得省略、跳过、压缩任何剧情内容**
|
||||
- **从剧本第一个字到最后一个字,逐句逐段转换为分镜**
|
||||
- **每个对话、每个动作、每个场景转换都必须有对应的分镜**
|
||||
- 剧本越长,分镜数量越多(短剧本15-30个,中等剧本30-60个,长剧本60-100个甚至更多)
|
||||
- **宁可分镜多,也不要遗漏剧情**:一个长场景可拆分为多个连续分镜
|
||||
- 每个镜头只描述一个主要动作
|
||||
- 区分主镜(is_primary: true)和链接镜(is_primary: false)
|
||||
- 确保情绪节奏有变化
|
||||
- **duration字段至关重要**:准确估算每个镜头时长,这将用于计算整集时长
|
||||
- 严格按照JSON格式输出
|
||||
|
||||
**【禁止行为】**:
|
||||
- ❌ 禁止用一个镜头概括多个场景
|
||||
- ❌ 禁止跳过任何对话或独白
|
||||
- ❌ 禁止省略剧情发展过程
|
||||
- ❌ 禁止合并本应分开的镜头
|
||||
- ✅ 正确做法:剧本有多少内容,就拆解出对应数量的分镜,确保观众看完所有分镜能完整了解剧情
|
||||
|
||||
**【关键】场景描述详细度要求**(这些描述将直接用于视频生成模型):
|
||||
1. **时间(time)字段**:必须包含≥15字的详细描述
|
||||
- ✓ 好例子:"深夜22:30·月光从破窗斜射入仓库,在地面积水中形成银白色反光,墙角昏暗不清"
|
||||
- ✗ 差例子:"深夜"
|
||||
|
||||
2. **地点(location)字段**:必须包含≥20字的详细场景描述
|
||||
- ✓ 好例子:"废弃码头仓库·锈蚀货架林立,地面积水反射微弱灯光,墙角堆放腐朽木箱和渔网,空气中弥漫潮湿霉味"
|
||||
- ✗ 差例子:"仓库"
|
||||
|
||||
3. **动作(action)字段**:必须包含≥25字的详细动作描述,包括肢体细节和表情
|
||||
- ✓ 好例子:"陈峥弯腰双手握住撬棍用力撬动保险箱门,手臂青筋暴起,眉头紧锁,汗水从额头滑落脸颊,呼吸急促"
|
||||
- ✗ 差例子:"陈峥打开保险箱"
|
||||
|
||||
4. **结果(result)字段**:必须包含≥25字的详细视觉结果描述
|
||||
- ✓ 好例子:"保险箱门突然弹开发出刺耳金属声,扬起灰尘在手电筒光束中飘散,箱内空无一物只有几张发黄的旧报纸,陈峥表情从期待转为震惊和失望,瞳孔放大"
|
||||
- ✗ 差例子:"门打开了"
|
||||
|
||||
5. **氛围(atmosphere)字段**:必须包含≥20字的环境氛围描述,包括光线、色调、声音
|
||||
- ✓ 好例子:"昏暗冷色调·青灰色为主,只有手电筒光束在黑暗中晃动,远处传来海浪拍打码头的沉闷声,整体氛围压抑沉重"
|
||||
- ✗ 差例子:"昏暗"
|
||||
|
||||
**描述原则**:
|
||||
- 所有描述性字段要像为盲人讲述画面一样详细
|
||||
- 包含感官细节:视觉、听觉、触觉、嗅觉
|
||||
- 描述光线、色彩、质感、动态
|
||||
- 为视频生成AI提供足够的画面构建信息
|
||||
- 避免抽象词汇,使用具象的视觉化描述`, characterList, sceneList, scriptContent)
|
||||
|
||||
// 调用AI服务生成
|
||||
text, err := s.aiService.GenerateText(prompt, "")
|
||||
if err != nil {
|
||||
s.log.Errorw("Failed to generate storyboard", "error", err)
|
||||
return nil, fmt.Errorf("生成分镜头失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析JSON结果
|
||||
var result GenerateStoryboardResult
|
||||
if err := utils.SafeParseAIJSON(text, &result); err != nil {
|
||||
s.log.Errorw("Failed to parse storyboard JSON", "error", err, "response", text[:min(500, len(text))])
|
||||
return nil, fmt.Errorf("解析分镜头结果失败: %w", err)
|
||||
}
|
||||
|
||||
result.Total = len(result.Storyboards)
|
||||
|
||||
// 计算总时长(所有分镜时长之和)
|
||||
totalDuration := 0
|
||||
for _, sb := range result.Storyboards {
|
||||
totalDuration += sb.Duration
|
||||
}
|
||||
|
||||
s.log.Infow("Storyboard generated",
|
||||
"episode_id", episodeID,
|
||||
"count", result.Total,
|
||||
"total_duration_seconds", totalDuration)
|
||||
|
||||
// 保存分镜头到数据库
|
||||
if err := s.saveStoryboards(episodeID, result.Storyboards); err != nil {
|
||||
s.log.Errorw("Failed to save storyboards", "error", err)
|
||||
return nil, fmt.Errorf("保存分镜头失败: %w", err)
|
||||
}
|
||||
|
||||
// 更新剧集时长(秒转分钟,向上取整)
|
||||
durationMinutes := (totalDuration + 59) / 60
|
||||
if err := s.db.Model(&models.Episode{}).Where("id = ?", episodeID).Update("duration", durationMinutes).Error; err != nil {
|
||||
s.log.Errorw("Failed to update episode duration", "error", err)
|
||||
// 不中断流程,只记录错误
|
||||
} else {
|
||||
s.log.Infow("Episode duration updated",
|
||||
"episode_id", episodeID,
|
||||
"duration_seconds", totalDuration,
|
||||
"duration_minutes", durationMinutes)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// generateImagePrompt 生成专门用于图片生成的提示词(首帧静态画面)
|
||||
func (s *StoryboardService) generateImagePrompt(sb Storyboard) string {
|
||||
var parts []string
|
||||
|
||||
// 1. 完整的场景背景描述
|
||||
if sb.Location != "" {
|
||||
locationDesc := sb.Location
|
||||
if sb.Time != "" {
|
||||
locationDesc += ", " + sb.Time
|
||||
}
|
||||
parts = append(parts, locationDesc)
|
||||
}
|
||||
|
||||
// 2. 角色初始静态姿态(去除动作过程,只保留起始状态)
|
||||
if sb.Action != "" {
|
||||
initialPose := extractInitialPose(sb.Action)
|
||||
if initialPose != "" {
|
||||
parts = append(parts, initialPose)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 情绪氛围
|
||||
if sb.Emotion != "" {
|
||||
parts = append(parts, sb.Emotion)
|
||||
}
|
||||
|
||||
// 4. 动漫风格
|
||||
parts = append(parts, "anime style, first frame")
|
||||
|
||||
if len(parts) > 0 {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
return "anime scene"
|
||||
}
|
||||
|
||||
// extractInitialPose 提取初始静态姿态(去除动作过程)
|
||||
func extractInitialPose(action string) string {
|
||||
// 去除动作过程关键词,保留初始状态描述
|
||||
processWords := []string{
|
||||
"然后", "接着", "接下来", "随后", "紧接着",
|
||||
"向下", "向上", "向前", "向后", "向左", "向右",
|
||||
"开始", "继续", "逐渐", "慢慢", "快速", "突然", "猛然",
|
||||
}
|
||||
|
||||
result := action
|
||||
for _, word := range processWords {
|
||||
if idx := strings.Index(result, word); idx > 0 {
|
||||
// 在动作过程词之前截断
|
||||
result = result[:idx]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 清理末尾标点
|
||||
result = strings.TrimRight(result, ",。,. ")
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
// extractSimpleLocation 提取简化的场景地点(去除详细描述)
|
||||
func extractSimpleLocation(location string) string {
|
||||
// 在"·"符号处截断,只保留主场景名称
|
||||
if idx := strings.Index(location, "·"); idx > 0 {
|
||||
return strings.TrimSpace(location[:idx])
|
||||
}
|
||||
|
||||
// 如果有逗号,只保留第一部分
|
||||
if idx := strings.Index(location, ","); idx > 0 {
|
||||
return strings.TrimSpace(location[:idx])
|
||||
}
|
||||
if idx := strings.Index(location, ","); idx > 0 {
|
||||
return strings.TrimSpace(location[:idx])
|
||||
}
|
||||
|
||||
// 限制长度不超过15个字符
|
||||
maxLen := 15
|
||||
if len(location) > maxLen {
|
||||
return strings.TrimSpace(location[:maxLen])
|
||||
}
|
||||
|
||||
return strings.TrimSpace(location)
|
||||
}
|
||||
|
||||
// extractSimplePose 提取简单的核心姿态关键词(不超过10个字)
|
||||
func extractSimplePose(action string) string {
|
||||
// 只提取前面最多10个字符作为核心姿态
|
||||
runes := []rune(action)
|
||||
maxLen := 10
|
||||
if len(runes) > maxLen {
|
||||
// 在标点符号处截断
|
||||
truncated := runes[:maxLen]
|
||||
for i := maxLen - 1; i >= 0; i-- {
|
||||
if truncated[i] == ',' || truncated[i] == '。' || truncated[i] == ',' || truncated[i] == '.' {
|
||||
truncated = runes[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(string(truncated))
|
||||
}
|
||||
return strings.TrimSpace(action)
|
||||
}
|
||||
|
||||
// extractFirstFramePose 从动作描述中提取首帧静态姿态
|
||||
func extractFirstFramePose(action string) string {
|
||||
// 去除表示动作过程的关键词,保留初始状态
|
||||
processWords := []string{
|
||||
"然后", "接着", "向下", "向前", "走向", "冲向", "转身",
|
||||
"开始", "继续", "逐渐", "慢慢", "快速", "突然",
|
||||
}
|
||||
|
||||
pose := action
|
||||
for _, word := range processWords {
|
||||
// 简单处理:在这些词之前截断
|
||||
if idx := strings.Index(pose, word); idx > 0 {
|
||||
pose = pose[:idx]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 清理末尾标点
|
||||
pose = strings.TrimRight(pose, ",。,.")
|
||||
return strings.TrimSpace(pose)
|
||||
}
|
||||
|
||||
// extractCompositionType 从镜头类型中提取构图类型(去除运镜)
|
||||
func extractCompositionType(shotType string) string {
|
||||
// 去除运镜相关描述
|
||||
cameraMovements := []string{
|
||||
"晃动", "摇晃", "推进", "拉远", "跟随", "环绕",
|
||||
"运镜", "摄影", "移动", "旋转",
|
||||
}
|
||||
|
||||
comp := shotType
|
||||
for _, movement := range cameraMovements {
|
||||
comp = strings.ReplaceAll(comp, movement, "")
|
||||
}
|
||||
|
||||
// 清理多余的标点和空格
|
||||
comp = strings.ReplaceAll(comp, "··", "·")
|
||||
comp = strings.ReplaceAll(comp, "·", " ")
|
||||
comp = strings.TrimSpace(comp)
|
||||
|
||||
return comp
|
||||
}
|
||||
|
||||
// generateVideoPrompt 生成专门用于视频生成的提示词(包含运镜和动态元素)
|
||||
func (s *StoryboardService) generateVideoPrompt(sb Storyboard) string {
|
||||
var parts []string
|
||||
|
||||
// 1. 人物动作
|
||||
if sb.Action != "" {
|
||||
parts = append(parts, fmt.Sprintf("Action: %s", sb.Action))
|
||||
}
|
||||
|
||||
// 2. 对话
|
||||
if sb.Dialogue != "" {
|
||||
parts = append(parts, fmt.Sprintf("Dialogue: %s", sb.Dialogue))
|
||||
}
|
||||
|
||||
// 3. 镜头运动(视频特有)
|
||||
if sb.Movement != "" {
|
||||
parts = append(parts, fmt.Sprintf("Camera movement: %s", sb.Movement))
|
||||
}
|
||||
|
||||
// 4. 镜头类型和角度
|
||||
if sb.ShotType != "" {
|
||||
parts = append(parts, fmt.Sprintf("Shot type: %s", sb.ShotType))
|
||||
}
|
||||
if sb.Angle != "" {
|
||||
parts = append(parts, fmt.Sprintf("Camera angle: %s", sb.Angle))
|
||||
}
|
||||
|
||||
// 5. 场景环境
|
||||
if sb.Location != "" {
|
||||
locationDesc := sb.Location
|
||||
if sb.Time != "" {
|
||||
locationDesc += ", " + sb.Time
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("Scene: %s", locationDesc))
|
||||
}
|
||||
|
||||
// 6. 环境氛围
|
||||
if sb.Atmosphere != "" {
|
||||
parts = append(parts, fmt.Sprintf("Atmosphere: %s", sb.Atmosphere))
|
||||
}
|
||||
|
||||
// 7. 情绪和结果
|
||||
if sb.Emotion != "" {
|
||||
parts = append(parts, fmt.Sprintf("Mood: %s", sb.Emotion))
|
||||
}
|
||||
if sb.Result != "" {
|
||||
parts = append(parts, fmt.Sprintf("Result: %s", sb.Result))
|
||||
}
|
||||
|
||||
// 8. 音频元素
|
||||
if sb.BgmPrompt != "" {
|
||||
parts = append(parts, fmt.Sprintf("BGM: %s", sb.BgmPrompt))
|
||||
}
|
||||
if sb.SoundEffect != "" {
|
||||
parts = append(parts, fmt.Sprintf("Sound effects: %s", sb.SoundEffect))
|
||||
}
|
||||
|
||||
// 9. 视频风格要求
|
||||
parts = append(parts, "Style: cinematic anime style, smooth camera motion, natural character movement")
|
||||
|
||||
if len(parts) > 0 {
|
||||
return strings.Join(parts, ". ")
|
||||
}
|
||||
return "Anime style video scene"
|
||||
}
|
||||
|
||||
func (s *StoryboardService) saveStoryboards(episodeID string, storyboards []Storyboard) error {
|
||||
// 开启事务
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 获取该剧集所有的分镜ID
|
||||
var storyboardIDs []uint
|
||||
if err := tx.Model(&models.Storyboard{}).
|
||||
Where("episode_id = ?", episodeID).
|
||||
Pluck("id", &storyboardIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果有分镜,先清理关联的image_generations的storyboard_id
|
||||
if len(storyboardIDs) > 0 {
|
||||
if err := tx.Model(&models.ImageGeneration{}).
|
||||
Where("storyboard_id IN ?", storyboardIDs).
|
||||
Update("storyboard_id", nil).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 删除该剧集已有的分镜头
|
||||
if err := tx.Where("episode_id = ?", episodeID).Delete(&models.Storyboard{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 注意:不删除背景,因为背景是在分镜拆解前就提取好的
|
||||
// AI会直接返回scene_id,不需要在这里做字符串匹配
|
||||
|
||||
// 保存新的分镜头
|
||||
for _, sb := range storyboards {
|
||||
// 构建描述信息,包含对话
|
||||
description := fmt.Sprintf("【镜头类型】%s\n【运镜】%s\n【动作】%s\n【对话】%s\n【结果】%s\n【情绪】%s",
|
||||
sb.ShotType, sb.Movement, sb.Action, sb.Dialogue, sb.Result, sb.Emotion)
|
||||
|
||||
// 生成两种专用提示词
|
||||
imagePrompt := s.generateImagePrompt(sb) // 专用于图片生成
|
||||
videoPrompt := s.generateVideoPrompt(sb) // 专用于视频生成
|
||||
|
||||
// 处理 dialogue 字段
|
||||
var dialoguePtr *string
|
||||
if sb.Dialogue != "" {
|
||||
dialoguePtr = &sb.Dialogue
|
||||
}
|
||||
|
||||
// 使用AI直接返回的SceneID
|
||||
if sb.SceneID != nil {
|
||||
s.log.Infow("Background ID from AI",
|
||||
"shot_number", sb.ShotNumber,
|
||||
"scene_id", *sb.SceneID)
|
||||
}
|
||||
|
||||
epID, _ := strconv.ParseUint(episodeID, 10, 32)
|
||||
|
||||
// 处理 title 字段
|
||||
var titlePtr *string
|
||||
if sb.Title != "" {
|
||||
titlePtr = &sb.Title
|
||||
}
|
||||
|
||||
// 处理shot_type、angle、movement字段
|
||||
var shotTypePtr, anglePtr, movementPtr *string
|
||||
if sb.ShotType != "" {
|
||||
shotTypePtr = &sb.ShotType
|
||||
}
|
||||
if sb.Angle != "" {
|
||||
anglePtr = &sb.Angle
|
||||
}
|
||||
if sb.Movement != "" {
|
||||
movementPtr = &sb.Movement
|
||||
}
|
||||
|
||||
// 处理bgm_prompt、sound_effect字段
|
||||
var bgmPromptPtr, soundEffectPtr *string
|
||||
if sb.BgmPrompt != "" {
|
||||
bgmPromptPtr = &sb.BgmPrompt
|
||||
}
|
||||
if sb.SoundEffect != "" {
|
||||
soundEffectPtr = &sb.SoundEffect
|
||||
}
|
||||
|
||||
// 处理result、atmosphere字段
|
||||
var resultPtr, atmospherePtr *string
|
||||
if sb.Result != "" {
|
||||
resultPtr = &sb.Result
|
||||
}
|
||||
if sb.Atmosphere != "" {
|
||||
atmospherePtr = &sb.Atmosphere
|
||||
}
|
||||
|
||||
scene := models.Storyboard{
|
||||
EpisodeID: uint(epID),
|
||||
SceneID: sb.SceneID,
|
||||
StoryboardNumber: sb.ShotNumber,
|
||||
Title: titlePtr,
|
||||
Location: &sb.Location,
|
||||
Time: &sb.Time,
|
||||
ShotType: shotTypePtr,
|
||||
Angle: anglePtr,
|
||||
Movement: movementPtr,
|
||||
Description: &description,
|
||||
Action: &sb.Action,
|
||||
Result: resultPtr,
|
||||
Atmosphere: atmospherePtr,
|
||||
Dialogue: dialoguePtr,
|
||||
ImagePrompt: &imagePrompt,
|
||||
VideoPrompt: &videoPrompt,
|
||||
BgmPrompt: bgmPromptPtr,
|
||||
SoundEffect: soundEffectPtr,
|
||||
Duration: sb.Duration,
|
||||
}
|
||||
|
||||
if err := tx.Create(&scene).Error; err != nil {
|
||||
s.log.Errorw("Failed to create scene", "error", err, "shot_number", sb.ShotNumber)
|
||||
return err
|
||||
}
|
||||
|
||||
// 关联角色
|
||||
if len(sb.Characters) > 0 {
|
||||
var characters []models.Character
|
||||
if err := tx.Where("id IN ?", sb.Characters).Find(&characters).Error; err != nil {
|
||||
s.log.Warnw("Failed to load characters for association", "error", err, "character_ids", sb.Characters)
|
||||
} else if len(characters) > 0 {
|
||||
if err := tx.Model(&scene).Association("Characters").Append(characters); err != nil {
|
||||
s.log.Warnw("Failed to associate characters", "error", err, "shot_number", sb.ShotNumber)
|
||||
} else {
|
||||
s.log.Infow("Characters associated successfully",
|
||||
"shot_number", sb.ShotNumber,
|
||||
"character_ids", sb.Characters,
|
||||
"count", len(characters))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.log.Infow("Storyboards saved successfully", "episode_id", episodeID, "count", len(storyboards))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateStoryboardCharacters 更新分镜的角色关联
|
||||
func (s *StoryboardService) UpdateStoryboardCharacters(storyboardID string, characterIDs []uint) error {
|
||||
// 查找分镜
|
||||
var storyboard models.Storyboard
|
||||
if err := s.db.First(&storyboard, storyboardID).Error; err != nil {
|
||||
return fmt.Errorf("storyboard not found: %w", err)
|
||||
}
|
||||
|
||||
// 清除现有的角色关联
|
||||
if err := s.db.Model(&storyboard).Association("Characters").Clear(); err != nil {
|
||||
return fmt.Errorf("failed to clear characters: %w", err)
|
||||
}
|
||||
|
||||
// 如果有新的角色ID,加载并关联
|
||||
if len(characterIDs) > 0 {
|
||||
var characters []models.Character
|
||||
if err := s.db.Where("id IN ?", characterIDs).Find(&characters).Error; err != nil {
|
||||
return fmt.Errorf("failed to find characters: %w", err)
|
||||
}
|
||||
|
||||
if err := s.db.Model(&storyboard).Association("Characters").Append(characters); err != nil {
|
||||
return fmt.Errorf("failed to associate characters: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.log.Infow("Storyboard characters updated", "storyboard_id", storyboardID, "character_count", len(characterIDs))
|
||||
return nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
138
application/services/storyboard_update_full.go
Normal file
138
application/services/storyboard_update_full.go
Normal 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
|
||||
}
|
||||
113
application/services/task_service.go
Normal file
113
application/services/task_service.go
Normal 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
|
||||
}
|
||||
109
application/services/upload_service.go
Normal file
109
application/services/upload_service.go
Normal 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
|
||||
}
|
||||
566
application/services/video_generation_service.go
Normal file
566
application/services/video_generation_service.go
Normal 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
|
||||
}
|
||||
557
application/services/video_merge_service.go
Normal file
557
application/services/video_merge_service.go
Normal 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"` // 分镜ID(fallback)
|
||||
Order int `json:"order"`
|
||||
StartTime float64 `json:"start_time"`
|
||||
EndTime float64 `json:"end_time"`
|
||||
Duration float64 `json:"duration"`
|
||||
Transition map[string]interface{} `json:"transition"`
|
||||
}
|
||||
|
||||
// FinalizeEpisodeRequest 完成剧集制作请求
|
||||
type FinalizeEpisodeRequest struct {
|
||||
EpisodeID string `json:"episode_id"`
|
||||
Clips []TimelineClip `json:"clips"`
|
||||
}
|
||||
|
||||
// FinalizeEpisode 完成集数制作,根据时间线场景顺序合成最终视频
|
||||
func (s *VideoMergeService) FinalizeEpisode(episodeID string, timelineData *FinalizeEpisodeRequest) (map[string]interface{}, error) {
|
||||
// 验证episode存在且属于该用户
|
||||
var episode models.Episode
|
||||
if err := s.db.Preload("Drama").Preload("Storyboards").Where("id = ?", episodeID).First(&episode).Error; err != nil {
|
||||
return nil, fmt.Errorf("episode not found")
|
||||
}
|
||||
|
||||
// 构建分镜ID映射
|
||||
sceneMap := make(map[string]models.Storyboard)
|
||||
for _, scene := range episode.Storyboards {
|
||||
sceneMap[fmt.Sprintf("%d", scene.ID)] = scene
|
||||
}
|
||||
|
||||
// 根据时间线数据构建场景片段
|
||||
var sceneClips []models.SceneClip
|
||||
var skippedScenes []int
|
||||
|
||||
if timelineData != nil && len(timelineData.Clips) > 0 {
|
||||
// 使用前端提供的时间线数据
|
||||
for _, clip := range timelineData.Clips {
|
||||
// 优先使用素材库中的视频(通过AssetID)
|
||||
var videoURL string
|
||||
var sceneID uint
|
||||
|
||||
if clip.AssetID != "" {
|
||||
// 从素材库获取视频URL
|
||||
var asset models.Asset
|
||||
if err := s.db.Where("id = ? AND type = ?", clip.AssetID, models.AssetTypeVideo).First(&asset).Error; err == nil {
|
||||
videoURL = asset.URL
|
||||
// 如果asset关联了storyboard,使用关联的storyboard_id
|
||||
if asset.StoryboardID != nil {
|
||||
sceneID = *asset.StoryboardID
|
||||
}
|
||||
s.log.Infow("Using video from asset library", "asset_id", clip.AssetID, "video_url", videoURL)
|
||||
} else {
|
||||
s.log.Warnw("Asset not found, will try storyboard video", "asset_id", clip.AssetID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有从素材库获取到视频,尝试从storyboard获取
|
||||
if videoURL == "" && clip.StoryboardID != "" {
|
||||
scene, exists := sceneMap[clip.StoryboardID]
|
||||
if !exists {
|
||||
s.log.Warnw("Storyboard not found in episode, skipping", "storyboard_id", clip.StoryboardID)
|
||||
continue
|
||||
}
|
||||
|
||||
if scene.VideoURL != nil && *scene.VideoURL != "" {
|
||||
videoURL = *scene.VideoURL
|
||||
sceneID = scene.ID
|
||||
s.log.Infow("Using video from storyboard", "storyboard_id", clip.StoryboardID, "video_url", videoURL)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果仍然没有视频URL,跳过该片段
|
||||
if videoURL == "" {
|
||||
s.log.Warnw("No video available for clip, skipping", "clip", clip)
|
||||
if clip.StoryboardID != "" {
|
||||
if scene, exists := sceneMap[clip.StoryboardID]; exists {
|
||||
skippedScenes = append(skippedScenes, scene.StoryboardNumber)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
sceneClip := models.SceneClip{
|
||||
SceneID: sceneID,
|
||||
VideoURL: videoURL,
|
||||
Duration: clip.Duration,
|
||||
Order: clip.Order,
|
||||
StartTime: clip.StartTime,
|
||||
EndTime: clip.EndTime,
|
||||
Transition: clip.Transition,
|
||||
}
|
||||
s.log.Infow("Adding scene clip with transition",
|
||||
"scene_id", sceneID,
|
||||
"order", clip.Order,
|
||||
"transition", clip.Transition)
|
||||
sceneClips = append(sceneClips, sceneClip)
|
||||
}
|
||||
} else {
|
||||
// 没有时间线数据,使用默认场景顺序
|
||||
if len(episode.Storyboards) == 0 {
|
||||
return nil, fmt.Errorf("no scenes found for this episode")
|
||||
}
|
||||
|
||||
order := 0
|
||||
for _, scene := range episode.Storyboards {
|
||||
// 优先从素材库查找该分镜关联的视频
|
||||
var videoURL string
|
||||
var asset models.Asset
|
||||
if err := s.db.Where("storyboard_id = ? AND type = ? AND episode_id = ?",
|
||||
scene.ID, models.AssetTypeVideo, episode.ID).
|
||||
Order("created_at DESC").
|
||||
First(&asset).Error; err == nil {
|
||||
videoURL = asset.URL
|
||||
s.log.Infow("Using video from asset library for storyboard",
|
||||
"storyboard_id", scene.ID,
|
||||
"asset_id", asset.ID,
|
||||
"video_url", videoURL)
|
||||
} else if scene.VideoURL != nil && *scene.VideoURL != "" {
|
||||
// 如果素材库没有,使用storyboard的video_url作为fallback
|
||||
videoURL = *scene.VideoURL
|
||||
s.log.Infow("Using fallback video from storyboard",
|
||||
"storyboard_id", scene.ID,
|
||||
"video_url", videoURL)
|
||||
}
|
||||
|
||||
// 跳过没有视频的场景
|
||||
if videoURL == "" {
|
||||
s.log.Warnw("Scene has no video, skipping", "storyboard_number", scene.StoryboardNumber)
|
||||
skippedScenes = append(skippedScenes, scene.StoryboardNumber)
|
||||
continue
|
||||
}
|
||||
|
||||
clip := models.SceneClip{
|
||||
SceneID: scene.ID,
|
||||
VideoURL: videoURL,
|
||||
Duration: float64(scene.Duration),
|
||||
Order: order,
|
||||
}
|
||||
sceneClips = append(sceneClips, clip)
|
||||
order++
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否至少有一个场景可以合成
|
||||
if len(sceneClips) == 0 {
|
||||
return nil, fmt.Errorf("no scenes with videos available for merging")
|
||||
}
|
||||
|
||||
// 创建视频合成任务
|
||||
title := fmt.Sprintf("%s - 第%d集", episode.Drama.Title, episode.EpisodeNum)
|
||||
|
||||
finalReq := &MergeVideoRequest{
|
||||
EpisodeID: episodeID,
|
||||
DramaID: fmt.Sprintf("%d", episode.DramaID),
|
||||
Title: title,
|
||||
Scenes: sceneClips,
|
||||
Provider: "doubao", // 默认使用doubao
|
||||
}
|
||||
|
||||
// 执行视频合成
|
||||
videoMerge, err := s.MergeVideos(finalReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start video merge: %w", err)
|
||||
}
|
||||
|
||||
// 更新episode状态为processing
|
||||
s.db.Model(&episode).Updates(map[string]interface{}{
|
||||
"status": "processing",
|
||||
})
|
||||
|
||||
result := map[string]interface{}{
|
||||
"message": "视频合成任务已创建,正在后台处理",
|
||||
"merge_id": videoMerge.ID,
|
||||
"episode_id": episodeID,
|
||||
"scenes_count": len(sceneClips),
|
||||
}
|
||||
|
||||
// 如果有跳过的场景,添加提示信息
|
||||
if len(skippedScenes) > 0 {
|
||||
result["skipped_scenes"] = skippedScenes
|
||||
result["warning"] = fmt.Sprintf("已跳过 %d 个未生成视频的场景(场景编号:%v)", len(skippedScenes), skippedScenes)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
28
configs/config.example.yaml
Normal file
28
configs/config.example.yaml
Normal 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
40
docker-compose.yml
Normal 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
124
domain/models/ai_config.go
Normal 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
57
domain/models/asset.go
Normal 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"
|
||||
}
|
||||
25
domain/models/character_library.go
Normal file
25
domain/models/character_library.go
Normal 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
148
domain/models/drama.go
Normal 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"
|
||||
}
|
||||
28
domain/models/frame_prompt.go
Normal file
28
domain/models/frame_prompt.go
Normal 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"
|
||||
)
|
||||
75
domain/models/image_generation.go
Normal file
75
domain/models/image_generation.go
Normal 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
23
domain/models/task.go
Normal 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
178
domain/models/timeline.go
Normal 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"
|
||||
}
|
||||
79
domain/models/video_generation.go
Normal file
79
domain/models/video_generation.go
Normal 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"
|
||||
}
|
||||
52
domain/models/video_merge.go
Normal file
52
domain/models/video_merge.go
Normal 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"
|
||||
}
|
||||
73
go.mod
Normal file
73
go.mod
Normal 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
638
go.sum
Normal 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=
|
||||
103
infrastructure/database/custom_logger.go
Normal file
103
infrastructure/database/custom_logger.go
Normal 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
|
||||
}
|
||||
97
infrastructure/database/database.go
Normal file
97
infrastructure/database/database.go
Normal 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
612
infrastructure/external/ffmpeg/ffmpeg.go
vendored
Normal 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)
|
||||
}
|
||||
240
infrastructure/scheduler/resource_transfer_scheduler.go
Normal file
240
infrastructure/scheduler/resource_transfer_scheduler.go
Normal 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()
|
||||
}
|
||||
137
infrastructure/storage/local_storage.go
Normal file
137
infrastructure/storage/local_storage.go
Normal 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
103
main.go
Normal 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
499
migrations/init.sql
Normal 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
7
pkg/ai/client.go
Normal 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
195
pkg/ai/gemini_client.go
Normal 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
227
pkg/ai/openai_client.go
Normal 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
89
pkg/config/config.go
Normal 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,
|
||||
)
|
||||
}
|
||||
277
pkg/image/gemini_image_client.go
Normal file
277
pkg/image/gemini_image_client.go
Normal 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
93
pkg/image/image_client.go
Normal 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
|
||||
}
|
||||
}
|
||||
128
pkg/image/openai_image_client.go
Normal file
128
pkg/image/openai_image_client.go
Normal 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")
|
||||
}
|
||||
158
pkg/image/volcengine_image_client.go
Normal file
158
pkg/image/volcengine_image_client.go
Normal 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
35
pkg/logger/logger.go
Normal 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
119
pkg/response/response.go
Normal 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
153
pkg/utils/json_parser.go
Normal 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
|
||||
}
|
||||
419
pkg/video/chatfire_client.go
Normal file
419
pkg/video/chatfire_client.go
Normal 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_url:VideoURL -> Data.VideoURL -> Content.VideoURL
|
||||
videoURL := result.VideoURL
|
||||
if videoURL == "" && result.Data.VideoURL != "" {
|
||||
videoURL = result.Data.VideoURL
|
||||
}
|
||||
if videoURL == "" && result.Content.VideoURL != "" {
|
||||
videoURL = result.Content.VideoURL
|
||||
}
|
||||
|
||||
fmt.Printf("[Chatfire] Parsed result - TaskID: %s, Status: %s, VideoURL: %s\n", responseTaskID, status, videoURL)
|
||||
|
||||
videoResult := &VideoResult{
|
||||
TaskID: responseTaskID,
|
||||
Status: status,
|
||||
Completed: status == "completed" || status == "succeeded",
|
||||
}
|
||||
|
||||
if errMsg := getErrorMessage(result.Error); errMsg != "" {
|
||||
videoResult.Error = errMsg
|
||||
}
|
||||
|
||||
if videoURL != "" {
|
||||
videoResult.VideoURL = videoURL
|
||||
videoResult.Completed = true
|
||||
}
|
||||
|
||||
return videoResult, nil
|
||||
}
|
||||
192
pkg/video/minimax_client.go
Normal file
192
pkg/video/minimax_client.go
Normal 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
|
||||
}
|
||||
178
pkg/video/openai_sora_client.go
Normal file
178
pkg/video/openai_sora_client.go
Normal 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
427
pkg/video/video_client.go
Normal 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
|
||||
}
|
||||
290
pkg/video/volces_ark_client.go
Normal file
290
pkg/video/volces_ark_client.go
Normal 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
29
web/.gitignore
vendored
Normal 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
13
web/index.html
Normal 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
23
web/nginx.conf
Normal 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
39
web/package.json
Normal 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
2317
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
web/public/ffmpeg/ffmpeg-core.js
Normal file
21
web/public/ffmpeg/ffmpeg-core.js
Normal file
File diff suppressed because one or more lines are too long
BIN
web/public/ffmpeg/ffmpeg-core.wasm
Normal file
BIN
web/public/ffmpeg/ffmpeg-core.wasm
Normal file
Binary file not shown.
17
web/src/App.vue
Normal file
17
web/src/App.vue
Normal 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
36
web/src/api/ai.ts
Normal 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
47
web/src/api/asset.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
109
web/src/api/character-library.ts
Normal file
109
web/src/api/character-library.ts
Normal 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
119
web/src/api/drama.ts
Normal 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
99
web/src/api/frame.ts
Normal 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
42
web/src/api/generation.ts
Normal 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
40
web/src/api/image.ts
Normal 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
44
web/src/api/video.ts
Normal 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
65
web/src/api/videoMerge.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
17
web/src/assets/styles/element/index.scss
Normal file
17
web/src/assets/styles/element/index.scss
Normal 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,
|
||||
)
|
||||
);
|
||||
1136
web/src/assets/styles/main.css
Normal file
1136
web/src/assets/styles/main.css
Normal file
File diff suppressed because it is too large
Load Diff
64
web/src/components/LanguageSwitcher.vue
Normal file
64
web/src/components/LanguageSwitcher.vue
Normal 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>
|
||||
801
web/src/components/common/AIConfigDialog.vue
Normal file
801
web/src/components/common/AIConfigDialog.vue
Normal 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>
|
||||
103
web/src/components/common/ActionButton.vue
Normal file
103
web/src/components/common/ActionButton.vue
Normal 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>
|
||||
125
web/src/components/common/AppLayout.vue
Normal file
125
web/src/components/common/AppLayout.vue
Normal 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
Reference in New Issue
Block a user