diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9250e78 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Binaries +bin/ +drama-generator +backend +*.exe +*.dll +*.so +*.dylib +drama-generator.exe + +# Test binary +*.test + +# Output of the go coverage tool +*.out + +# Dependency directories +vendor/ + +# Go workspace file +go.work + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Environment +.env +.env.local + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db + +# Build +dist/ +build/ + +# Temporary files +tmp/ +temp/ + +# Data (database and uploaded files) +../../GolandProjects/huobao-drama/data/drama_generator.db +../../GolandProjects/huobao-drama/data/storage/videos/* +!../../GolandProjects/huobao-drama/data/storage/videos/.gitkeep + +# Frontend build output +../../GolandProjects/huobao-drama/web/dist/ +../../GolandProjects/huobao-drama/web/node_modules/ +../../GolandProjects/huobao-drama/web/.vite/ +../../GolandProjects/huobao-drama/web/.env.local diff --git a/README.md b/README.md index 8fcb10a..f22fd36 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,560 @@ -# huobao-drama -AI火宝动漫短剧,免费开源,前后端代码一起 +# 🎬 Chatfire Anime - AI短剧生成平台 -## 项目介绍 -本项目是一个基于AI技术的动漫短剧生成系统,旨在为用户提供高质量、有趣的动漫短剧内容。 +
+**基于 Go + Vue3 的全栈AI短剧自动化生产平台** + +[![Go Version](https://img.shields.io/badge/Go-1.23+-00ADD8?style=flat&logo=go)](https://golang.org) +[![Vue Version](https://img.shields.io/badge/Vue-3.x-4FC08D?style=flat&logo=vue.js)](https://vuejs.org) +[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) + +[功能特性](#功能特性) • [快速开始](#快速开始) • [部署指南](#部署指南) + +
+ +--- + +## 📖 项目简介 + +Chatfire Anime 是一个基于AI的短剧自动化生产平台,实现从剧本生成、角色设计、分镜制作到视频合成的全流程自动化。 + +### 🎯 核心价值 + +- **🤖 AI驱动**:使用大语言模型自动生成剧本、角色设定和分镜脚本 +- **🎨 智能创作**:AI绘图生成角色形象和场景背景 +- **📹 视频生成**:基于文生视频模型自动生成分镜视频 +- **⚡ 批量处理**:支持批量生成和异步任务处理 +- **🔄 工作流**:完整的短剧制作工作流,从创意到成片一站式完成 + +### 🛠️ 技术架构 + +采用**DDD领域驱动设计**,清晰分层: + +``` +├── API层 (Gin HTTP) +├── 应用服务层 (Business Logic) +├── 领域层 (Domain Models) +└── 基础设施层 (Database, External Services) +``` + +--- + +## ✨ 功能特性 + +### 📝 剧本创作 +- ✅ AI自动生成剧本大纲 +- ✅ 智能角色设定和关系图谱 +- ✅ 分集剧情自动拆分 +- ✅ 剧本编辑和版本管理 + +### 🎭 角色管理 +- ✅ AI生成角色形象 +- ✅ 角色库复用 +- ✅ 批量角色生成 +- ✅ 角色图片上传和管理 + +### 🎬 分镜制作 +- ✅ 自动生成分镜脚本 +- ✅ 场景描述和镜头设计 +- ✅ 分镜图片生成(文生图) +- ✅ 帧类型选择(首帧/关键帧/尾帧/分镜板) + +### 🎥 视频生成 +- ✅ 图生视频自动生成 +- ✅ 视频合成和剪辑 +- ✅ 转场效果 +- ✅ 批量视频处理 + +### 📦 资源管理 +- ✅ 素材库统一管理 +- ✅ 本地存储支持 +- ✅ 资源导入导出 +- ✅ 任务进度追踪 + +--- + +## 🏗️ 项目结构 + +``` +chatfire-anime/ +├── api/ # API 层 +│ ├── handlers/ # HTTP 请求处理器 +│ ├── middlewares/ # 中间件(CORS、日志等) +│ └── routes/ # 路由定义和注册 +├── application/ # 应用服务层 +│ └── services/ # 业务逻辑服务 +│ ├── ai_service.go # AI服务管理 +│ ├── drama_service.go # 剧本服务 +│ ├── image_generation_service.go # 图片生成 +│ ├── video_generation_service.go # 视频生成 +│ └── ... +├── domain/ # 领域层 +│ └── models/ # 数据模型定义 +│ ├── drama.go # 剧本模型 +│ ├── character.go # 角色模型 +│ ├── scene.go # 场景模型 +│ └── ... +├── infrastructure/ # 基础设施层 +│ ├── database/ # 数据库连接和迁移 +│ ├── external/ # 外部服务封装 +│ │ └── ffmpeg/ # FFmpeg视频处理 +│ └── storage/ # 文件存储 +│ └── local_storage.go +├── pkg/ # 公共包 +│ ├── ai/ # AI客户端封装 +│ │ └── doubao/ # 豆包AI客户端 +│ ├── config/ # 配置管理 +│ ├── logger/ # 日志工具 +│ ├── response/ # HTTP响应封装 +│ └── video/ # 视频处理工具 +├── web/ # 前端项目 (Vue3) +│ ├── src/ +│ │ ├── api/ # API调用封装 +│ │ ├── components/ # Vue组件 +│ │ ├── views/ # 页面视图 +│ │ ├── router/ # 路由配置 +│ │ └── types/ # TypeScript类型定义 +│ ├── package.json +│ └── vite.config.ts +├── configs/ # 配置文件目录 +│ └── config.yaml # 主配置文件 +├── data/ # 数据目录 +│ ├── drama_generator.db # SQLite 数据库 +│ └── storage/ # 上传文件存储 +├── migrations/ # 数据库迁移脚本 +├── main.go # 程序入口 +├── go.mod # Go 模块定义 +└── README.md # 项目文档 +``` + +--- + +## 🚀 快速开始 + +### 📋 环境要求 + +| 软件 | 版本要求 | 说明 | +|------|---------|------| +| **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 +``` + +### ⚙️ 配置文件 + +编辑 `configs/config.yaml`: + +```yaml +app: + name: "Chatfire Anime" + mode: "development" # development / production + port: 5678 + +database: + type: "sqlite" + path: "./data/drama_generator.db" + +storage: + type: "local" + local_path: "./data/storage" + base_url: "http://localhost:5678/static" + +ai: + doubao: + base_url: "https://ark.cn-beijing.volces.com/api/v3" + api_key: "YOUR_DOUBAO_API_KEY" # 替换为你的豆包API Key + default_model: "ep-20241206xxxxx" + +logging: + level: "info" + output: "stdout" +``` + +**重要配置项:** +- `ai.doubao.api_key`: 豆包AI的API密钥(**必需**) +- `storage.local_path`: 本地文件存储路径 +- `app.port`: 服务运行端口 + +### 📥 安装依赖 + +```bash +# 克隆项目 +git clone +cd chatfire-anime + +# 安装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),无需手动迁移。 + +--- + +## 📦 部署指南 + +### 🏭 生产环境部署 + +#### 1. 编译构建 + +```bash +# 1. 构建前端 +cd web +npm run build +cd .. + +# 2. 编译后端 +go build -o chatfire-anime . +``` + +生成文件: +- `chatfire-anime` - 后端可执行文件 +- `web/dist/` - 前端静态文件(已嵌入后端) + +#### 2. 准备部署文件 + +需要上传到服务器的文件: +``` +chatfire-anime # 后端可执行文件 +configs/config.yaml # 配置文件 +data/ # 数据目录(可选,首次运行自动创建) +``` + +#### 3. 服务器配置 + +```bash +# 上传文件到服务器 +scp chatfire-anime user@server:/opt/chatfire-anime/ +scp configs/config.yaml user@server:/opt/chatfire-anime/configs/ + +# SSH登录服务器 +ssh user@server + +# 修改配置文件 +cd /opt/chatfire-anime +vim configs/config.yaml +# 设置mode为production +# 配置域名和存储路径 + +# 赋予执行权限 +chmod +x chatfire-anime + +# 启动服务 +./chatfire-anime +``` + +#### 4. 使用 systemd 管理服务 + +创建服务文件 `/etc/systemd/system/chatfire-anime.service`: + +```ini +[Unit] +Description=Chatfire Anime Service +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/opt/chatfire-anime +ExecStart=/opt/chatfire-anime/chatfire-anime +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +启动服务: +```bash +sudo systemctl daemon-reload +sudo systemctl enable chatfire-anime +sudo systemctl start chatfire-anime +sudo systemctl status chatfire-anime +``` + +#### 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/chatfire-anime/data/storage/; + } +} +``` + +--- + +## 🔧 开发指南 + +### 添加新功能 + +#### 1. 添加API接口 + +```bash +# 创建Handler +vim api/handlers/your_handler.go + +# 注册路由 +vim api/routes/routes.go +``` + +示例: +```go +// api/handlers/your_handler.go +func (h *YourHandler) YourMethod(c *gin.Context) { + // 处理逻辑 + response.Success(c, data) +} + +// api/routes/routes.go +your := api.Group("/your") +{ + your.GET("", yourHandler.List) + your.POST("", yourHandler.Create) +} +``` + +#### 2. 添加业务服务 + +```go +// application/services/your_service.go +type YourService struct { + db *gorm.DB + log *logger.Logger +} + +func NewYourService(db *gorm.DB, log *logger.Logger) *YourService { + return &YourService{db: db, log: log} +} + +func (s *YourService) YourMethod() error { + // 业务逻辑 + return nil +} +``` + +#### 3. 添加前端页面 + +```bash +# 创建页面组件 +vim web/src/views/YourPage.vue + +# 注册路由 +vim web/src/router/index.ts + +# 添加API调用 +vim web/src/api/your-api.ts +``` + +### 调试技巧 + +**后端调试:** +```bash +# 启用详细日志 +export LOG_LEVEL=debug +go run main.go + +# 使用dlv调试器 +dlv debug main.go +``` + +**前端调试:** +```bash +cd web +npm run dev +# 打开浏览器 DevTools +``` + +**数据库查询:** +```bash +sqlite3 data/drama_generator.db +.tables +.schema dramas +SELECT * FROM dramas; +``` + +--- + +## 🛠️ 常用命令 + +```bash +# 开发模式 +go run main.go # 启动后端服务 +cd web && npm run dev # 启动前端开发服务器 + +# 编译构建 +cd web && npm run build && cd .. # 构建前端 +go build -o chatfire-anime . # 编译后端 + +# 依赖管理 +go mod download # 下载Go依赖 +go mod tidy # 清理Go依赖 +cd web && npm install && cd .. # 安装前端依赖 + +# 代码检查 +go fmt ./... # 格式化代码 +go vet ./... # 代码检查 +cd web && npm run lint && cd .. # 前端代码检查 + +# 清理 +go clean # 清理Go构建缓存 +rm -rf web/dist # 清理前端构建产物 +rm -f chatfire-anime # 删除可执行文件 + +# 测试 +go test ./... # 运行Go测试 +cd web && npm run test && cd .. # 运行前端测试 +``` + +--- + +## 🎨 技术栈 + +### 后端技术 +- **语言**: Go 1.23+ +- **Web框架**: Gin 1.9+ +- **ORM**: GORM +- **数据库**: SQLite +- **日志**: Zap +- **视频处理**: FFmpeg +- **AI服务**: 豆包 Doubao API + +### 前端技术 +- **框架**: Vue 3.4+ +- **语言**: TypeScript 5+ +- **构建工具**: Vite 5 +- **UI组件**: Element Plus +- **CSS框架**: TailwindCSS +- **状态管理**: Pinia +- **路由**: Vue Router 4 + +### 开发工具 +- **包管理**: Go Modules, npm +- **代码规范**: ESLint, Prettier +- **版本控制**: Git + +--- + +## 📝 常见问题 + +### Q: FFmpeg未安装或找不到? +A: 确保FFmpeg已安装并在PATH环境变量中。运行 `ffmpeg -version` 验证。 + +### Q: 前端无法连接后端API? +A: 检查后端是否启动,端口是否正确。开发模式下前端代理配置在 `web/vite.config.ts`。 + +### Q: 豆包AI调用失败? +A: 检查 `configs/config.yaml` 中的 `api_key` 是否正确,网络是否通畅。 + +### Q: 数据库表未创建? +A: GORM会在首次启动时自动创建表,检查日志确认迁移是否成功。 + +--- + +## 📄 许可证 + +本项目采用 [MIT License](LICENSE) 开源协议。 + +--- + +## 🤝 贡献指南 + +欢迎提交 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 + +--- + +## 📧 联系方式 ## 项目交流群 ![项目交流群](./drama.png) -目前项目还没发布,本周尽量发布出来 +- 提交 [Issue](../../issues) +- 发送邮件至项目维护者 +--- + +
+ +**⭐ 如果这个项目对你有帮助,请给一个Star!** + +Made with ❤️ by Chatfire Team + +
diff --git a/api/handlers/ai_config.go b/api/handlers/ai_config.go new file mode 100644 index 0000000..f1207df --- /dev/null +++ b/api/handlers/ai_config.go @@ -0,0 +1,136 @@ +package handlers + +import ( + "strconv" + + "github.com/drama-generator/backend/application/services" + "github.com/drama-generator/backend/pkg/config" + "github.com/drama-generator/backend/pkg/logger" + "github.com/drama-generator/backend/pkg/response" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type AIConfigHandler struct { + aiService *services.AIService + log *logger.Logger +} + +func NewAIConfigHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *AIConfigHandler { + return &AIConfigHandler{ + aiService: services.NewAIService(db, log), + log: log, + } +} + +func (h *AIConfigHandler) CreateConfig(c *gin.Context) { + var req services.CreateAIConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + config, err := h.aiService.CreateConfig(&req) + if err != nil { + response.InternalError(c, "创建失败") + return + } + + response.Created(c, config) +} + +func (h *AIConfigHandler) GetConfig(c *gin.Context) { + + configID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的配置ID") + return + } + + config, err := h.aiService.GetConfig(uint(configID)) + if err != nil { + if err.Error() == "config not found" { + response.NotFound(c, "配置不存在") + return + } + response.InternalError(c, "获取失败") + return + } + + response.Success(c, config) +} + +func (h *AIConfigHandler) ListConfigs(c *gin.Context) { + + serviceType := c.Query("service_type") + + configs, err := h.aiService.ListConfigs(serviceType) + if err != nil { + response.InternalError(c, "获取列表失败") + return + } + + response.Success(c, configs) +} + +func (h *AIConfigHandler) UpdateConfig(c *gin.Context) { + + configID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的配置ID") + return + } + + var req services.UpdateAIConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + config, err := h.aiService.UpdateConfig(uint(configID), &req) + if err != nil { + if err.Error() == "config not found" { + response.NotFound(c, "配置不存在") + return + } + response.InternalError(c, "更新失败") + return + } + + response.Success(c, config) +} + +func (h *AIConfigHandler) DeleteConfig(c *gin.Context) { + + configID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的配置ID") + return + } + + if err := h.aiService.DeleteConfig(uint(configID)); err != nil { + if err.Error() == "config not found" { + response.NotFound(c, "配置不存在") + return + } + response.InternalError(c, "删除失败") + return + } + + response.Success(c, gin.H{"message": "删除成功"}) +} + +func (h *AIConfigHandler) TestConnection(c *gin.Context) { + var req services.TestConnectionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + if err := h.aiService.TestConnection(&req); err != nil { + response.BadRequest(c, "连接测试失败: "+err.Error()) + return + } + + response.Success(c, gin.H{"message": "连接测试成功"}) +} diff --git a/api/handlers/asset.go b/api/handlers/asset.go new file mode 100644 index 0000000..baa1cd5 --- /dev/null +++ b/api/handlers/asset.go @@ -0,0 +1,220 @@ +package handlers + +import ( + "strconv" + "strings" + + "github.com/drama-generator/backend/application/services" + "github.com/drama-generator/backend/domain/models" + "github.com/drama-generator/backend/pkg/config" + "github.com/drama-generator/backend/pkg/logger" + "github.com/drama-generator/backend/pkg/response" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type AssetHandler struct { + assetService *services.AssetService + log *logger.Logger +} + +func NewAssetHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *AssetHandler { + return &AssetHandler{ + assetService: services.NewAssetService(db, log), + log: log, + } +} + +func (h *AssetHandler) CreateAsset(c *gin.Context) { + + var req services.CreateAssetRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + asset, err := h.assetService.CreateAsset(&req) + if err != nil { + h.log.Errorw("Failed to create asset", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, asset) +} + +func (h *AssetHandler) UpdateAsset(c *gin.Context) { + + assetID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的ID") + return + } + + var req services.UpdateAssetRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + asset, err := h.assetService.UpdateAsset(uint(assetID), &req) + if err != nil { + h.log.Errorw("Failed to update asset", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, asset) +} + +func (h *AssetHandler) GetAsset(c *gin.Context) { + + assetID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的ID") + return + } + + asset, err := h.assetService.GetAsset(uint(assetID)) + if err != nil { + response.NotFound(c, "素材不存在") + return + } + + response.Success(c, asset) +} + +func (h *AssetHandler) ListAssets(c *gin.Context) { + + var dramaID *string + if dramaIDStr := c.Query("drama_id"); dramaIDStr != "" { + dramaID = &dramaIDStr + } + + var episodeID *uint + if episodeIDStr := c.Query("episode_id"); episodeIDStr != "" { + if id, err := strconv.ParseUint(episodeIDStr, 10, 32); err == nil { + uid := uint(id) + episodeID = &uid + } + } + + var storyboardID *uint + if storyboardIDStr := c.Query("storyboard_id"); storyboardIDStr != "" { + if id, err := strconv.ParseUint(storyboardIDStr, 10, 32); err == nil { + uid := uint(id) + storyboardID = &uid + } + } + + var assetType *models.AssetType + if typeStr := c.Query("type"); typeStr != "" { + t := models.AssetType(typeStr) + assetType = &t + } + + var isFavorite *bool + if favoriteStr := c.Query("is_favorite"); favoriteStr != "" { + if favoriteStr == "true" { + fav := true + isFavorite = &fav + } else if favoriteStr == "false" { + fav := false + isFavorite = &fav + } + } + + var tagIDs []uint + if tagIDsStr := c.Query("tag_ids"); tagIDsStr != "" { + for _, idStr := range strings.Split(tagIDsStr, ",") { + if id, err := strconv.ParseUint(strings.TrimSpace(idStr), 10, 32); err == nil { + tagIDs = append(tagIDs, uint(id)) + } + } + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + req := &services.ListAssetsRequest{ + DramaID: dramaID, + EpisodeID: episodeID, + StoryboardID: storyboardID, + Type: assetType, + Category: c.Query("category"), + TagIDs: tagIDs, + IsFavorite: isFavorite, + Search: c.Query("search"), + Page: page, + PageSize: pageSize, + } + + assets, total, err := h.assetService.ListAssets(req) + if err != nil { + h.log.Errorw("Failed to list assets", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.SuccessWithPagination(c, assets, total, page, pageSize) +} + +func (h *AssetHandler) DeleteAsset(c *gin.Context) { + + assetID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的ID") + return + } + + if err := h.assetService.DeleteAsset(uint(assetID)); err != nil { + h.log.Errorw("Failed to delete asset", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, nil) +} + +func (h *AssetHandler) ImportFromImageGen(c *gin.Context) { + + imageGenID, err := strconv.ParseUint(c.Param("image_gen_id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的ID") + return + } + + asset, err := h.assetService.ImportFromImageGen(uint(imageGenID)) + if err != nil { + h.log.Errorw("Failed to import from image gen", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, asset) +} + +func (h *AssetHandler) ImportFromVideoGen(c *gin.Context) { + + videoGenID, err := strconv.ParseUint(c.Param("video_gen_id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的ID") + return + } + + asset, err := h.assetService.ImportFromVideoGen(uint(videoGenID)) + if err != nil { + h.log.Errorw("Failed to import from video gen", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, asset) +} diff --git a/api/handlers/character_batch.go b/api/handlers/character_batch.go new file mode 100644 index 0000000..7a75820 --- /dev/null +++ b/api/handlers/character_batch.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "github.com/drama-generator/backend/pkg/response" + "github.com/gin-gonic/gin" +) + +// BatchGenerateCharacterImages 批量生成角色图片 +func (h *CharacterLibraryHandler) BatchGenerateCharacterImages(c *gin.Context) { + + var req struct { + CharacterIDs []string `json:"character_ids" binding:"required,min=1"` + Model string `json:"model"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + // 限制批量生成数量 + if len(req.CharacterIDs) > 10 { + response.BadRequest(c, "单次最多生成10个角色") + return + } + + // 异步批量生成 + go h.libraryService.BatchGenerateCharacterImages(req.CharacterIDs, h.imageService, req.Model) + + response.Success(c, gin.H{ + "message": "批量生成任务已提交", + "count": len(req.CharacterIDs), + }) +} diff --git a/api/handlers/character_library.go b/api/handlers/character_library.go new file mode 100644 index 0000000..3ffeee5 --- /dev/null +++ b/api/handlers/character_library.go @@ -0,0 +1,274 @@ +package handlers + +import ( + "strconv" + + 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" + "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) *CharacterLibraryHandler { + return &CharacterLibraryHandler{ + libraryService: services2.NewCharacterLibraryService(db, log), + imageService: services2.NewImageGenerationService(db, transferService, log), + log: log, + } +} + +// ListLibraryItems 获取角色库列表 +func (h *CharacterLibraryHandler) ListLibraryItems(c *gin.Context) { + + var query services2.CharacterLibraryQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, err.Error()) + return + } + + if query.Page < 1 { + query.Page = 1 + } + if query.PageSize < 1 || query.PageSize > 100 { + query.PageSize = 20 + } + + items, total, err := h.libraryService.ListLibraryItems(&query) + if err != nil { + h.log.Errorw("Failed to list library items", "error", err) + response.InternalError(c, "获取角色库失败") + return + } + + response.SuccessWithPagination(c, items, total, query.Page, query.PageSize) +} + +// CreateLibraryItem 添加到角色库 +func (h *CharacterLibraryHandler) CreateLibraryItem(c *gin.Context) { + + var req services2.CreateLibraryItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + item, err := h.libraryService.CreateLibraryItem(&req) + if err != nil { + h.log.Errorw("Failed to create library item", "error", err) + response.InternalError(c, "添加到角色库失败") + return + } + + response.Created(c, item) +} + +// GetLibraryItem 获取角色库项详情 +func (h *CharacterLibraryHandler) GetLibraryItem(c *gin.Context) { + + itemID := c.Param("id") + + item, err := h.libraryService.GetLibraryItem(itemID) + if err != nil { + if err.Error() == "library item not found" { + response.NotFound(c, "角色库项不存在") + return + } + h.log.Errorw("Failed to get library item", "error", err) + response.InternalError(c, "获取失败") + return + } + + response.Success(c, item) +} + +// DeleteLibraryItem 删除角色库项 +func (h *CharacterLibraryHandler) DeleteLibraryItem(c *gin.Context) { + + itemID := c.Param("id") + + if err := h.libraryService.DeleteLibraryItem(itemID); err != nil { + if err.Error() == "library item not found" { + response.NotFound(c, "角色库项不存在") + return + } + h.log.Errorw("Failed to delete library item", "error", err) + response.InternalError(c, "删除失败") + return + } + + response.Success(c, gin.H{"message": "删除成功"}) +} + +// UploadCharacterImage 上传角色图片 +func (h *CharacterLibraryHandler) UploadCharacterImage(c *gin.Context) { + + characterID := c.Param("id") + + // TODO: 处理文件上传 + // 这里需要实现文件上传逻辑,保存到OSS或本地 + // 暂时使用简单的实现 + var req struct { + ImageURL string `json:"image_url" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + if err := h.libraryService.UploadCharacterImage(characterID, req.ImageURL); err != nil { + if err.Error() == "character not found" { + response.NotFound(c, "角色不存在") + return + } + if err.Error() == "unauthorized" { + response.Forbidden(c, "无权限") + return + } + h.log.Errorw("Failed to upload character image", "error", err) + response.InternalError(c, "上传失败") + return + } + + response.Success(c, gin.H{"message": "上传成功"}) +} + +// ApplyLibraryItemToCharacter 从角色库应用形象 +func (h *CharacterLibraryHandler) ApplyLibraryItemToCharacter(c *gin.Context) { + + characterID := c.Param("id") + + var req struct { + LibraryItemID string `json:"library_item_id" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + if err := h.libraryService.ApplyLibraryItemToCharacter(characterID, req.LibraryItemID); err != nil { + if err.Error() == "library item not found" { + response.NotFound(c, "角色库项不存在") + return + } + if err.Error() == "character not found" { + response.NotFound(c, "角色不存在") + return + } + if err.Error() == "unauthorized" { + response.Forbidden(c, "无权限") + return + } + h.log.Errorw("Failed to apply library item", "error", err) + response.InternalError(c, "应用失败") + return + } + + response.Success(c, gin.H{"message": "应用成功"}) +} + +// AddCharacterToLibrary 将角色添加到角色库 +func (h *CharacterLibraryHandler) AddCharacterToLibrary(c *gin.Context) { + + characterID := c.Param("id") + + var req struct { + Category *string `json:"category"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + // 允许空body + req.Category = nil + } + + item, err := h.libraryService.AddCharacterToLibrary(characterID, req.Category) + if err != nil { + if err.Error() == "character not found" { + response.NotFound(c, "角色不存在") + return + } + if err.Error() == "unauthorized" { + response.Forbidden(c, "无权限") + return + } + if err.Error() == "character has no image" { + response.BadRequest(c, "角色还没有形象图片") + return + } + h.log.Errorw("Failed to add character to library", "error", err) + response.InternalError(c, "添加失败") + return + } + + response.Created(c, item) +} + +// UpdateCharacter 更新角色信息 +func (h *CharacterLibraryHandler) UpdateCharacter(c *gin.Context) { + + characterID := c.Param("id") + + var req struct { + Name *string `json:"name"` + Appearance *string `json:"appearance"` + Personality *string `json:"personality"` + Description *string `json:"description"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + if err := h.libraryService.UpdateCharacter(characterID, &req); err != nil { + if err.Error() == "character not found" { + response.NotFound(c, "角色不存在") + return + } + if err.Error() == "unauthorized" { + response.Forbidden(c, "无权限") + return + } + h.log.Errorw("Failed to update character", "error", err) + response.InternalError(c, "更新失败") + return + } + + response.Success(c, gin.H{"message": "更新成功"}) +} + +// DeleteCharacter 删除单个角色 +func (h *CharacterLibraryHandler) DeleteCharacter(c *gin.Context) { + + characterIDStr := c.Param("id") + characterID, err := strconv.ParseUint(characterIDStr, 10, 32) + if err != nil { + response.BadRequest(c, "无效的角色ID") + return + } + + if err := h.libraryService.DeleteCharacter(uint(characterID)); err != nil { + h.log.Errorw("Failed to delete character", "error", err, "id", characterID) + if err.Error() == "character not found" { + response.NotFound(c, "角色不存在") + return + } + if err.Error() == "unauthorized" { + response.Forbidden(c, "无权删除此角色") + return + } + response.InternalError(c, "删除失败") + return + } + + response.Success(c, gin.H{"message": "角色已删除"}) +} diff --git a/api/handlers/character_library_gen.go b/api/handlers/character_library_gen.go new file mode 100644 index 0000000..b94ddf6 --- /dev/null +++ b/api/handlers/character_library_gen.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "github.com/drama-generator/backend/pkg/response" + "github.com/gin-gonic/gin" +) + +// GenerateCharacterImage AI生成角色形象 +func (h *CharacterLibraryHandler) GenerateCharacterImage(c *gin.Context) { + + characterID := c.Param("id") + + // 获取请求体中的model参数 + var req struct { + Model string `json:"model"` + } + c.ShouldBindJSON(&req) + + imageGen, err := h.libraryService.GenerateCharacterImage(characterID, h.imageService, req.Model) + if err != nil { + if err.Error() == "character not found" { + response.NotFound(c, "角色不存在") + return + } + if err.Error() == "unauthorized" { + response.Forbidden(c, "无权限") + return + } + h.log.Errorw("Failed to generate character image", "error", err) + response.InternalError(c, "生成失败") + return + } + + response.Success(c, gin.H{ + "message": "角色图片生成已启动", + "image_generation": imageGen, + }) +} diff --git a/api/handlers/drama.go b/api/handlers/drama.go new file mode 100644 index 0000000..e45d329 --- /dev/null +++ b/api/handlers/drama.go @@ -0,0 +1,310 @@ +package handlers + +import ( + "github.com/drama-generator/backend/application/services" + "github.com/drama-generator/backend/domain/models" + "github.com/drama-generator/backend/pkg/config" + "github.com/drama-generator/backend/pkg/logger" + "github.com/drama-generator/backend/pkg/response" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type DramaHandler struct { + db *gorm.DB + dramaService *services.DramaService + videoMergeService *services.VideoMergeService + log *logger.Logger +} + +func NewDramaHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, transferService *services.ResourceTransferService) *DramaHandler { + return &DramaHandler{ + db: db, + dramaService: services.NewDramaService(db, log), + videoMergeService: services.NewVideoMergeService(db, transferService, cfg.Storage.LocalPath, cfg.Storage.BaseURL, log), + log: log, + } +} + +func (h *DramaHandler) CreateDrama(c *gin.Context) { + + var req services.CreateDramaRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + drama, err := h.dramaService.CreateDrama(&req) + if err != nil { + response.InternalError(c, "创建失败") + return + } + + response.Created(c, drama) +} + +func (h *DramaHandler) GetDrama(c *gin.Context) { + + dramaID := c.Param("id") + + drama, err := h.dramaService.GetDrama(dramaID) + if err != nil { + if err.Error() == "drama not found" { + response.NotFound(c, "剧本不存在") + return + } + response.InternalError(c, "获取失败") + return + } + + response.Success(c, drama) +} + +func (h *DramaHandler) ListDramas(c *gin.Context) { + + var query services.DramaListQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, err.Error()) + return + } + + if query.Page < 1 { + query.Page = 1 + } + if query.PageSize < 1 || query.PageSize > 100 { + query.PageSize = 20 + } + + dramas, total, err := h.dramaService.ListDramas(&query) + if err != nil { + response.InternalError(c, "获取列表失败") + return + } + + response.SuccessWithPagination(c, dramas, total, query.Page, query.PageSize) +} + +func (h *DramaHandler) UpdateDrama(c *gin.Context) { + + dramaID := c.Param("id") + + var req services.UpdateDramaRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + drama, err := h.dramaService.UpdateDrama(dramaID, &req) + if err != nil { + if err.Error() == "drama not found" { + response.NotFound(c, "剧本不存在") + return + } + response.InternalError(c, "更新失败") + return + } + + response.Success(c, drama) +} + +func (h *DramaHandler) DeleteDrama(c *gin.Context) { + + dramaID := c.Param("id") + + if err := h.dramaService.DeleteDrama(dramaID); err != nil { + if err.Error() == "drama not found" { + response.NotFound(c, "剧本不存在") + return + } + response.InternalError(c, "删除失败") + return + } + + response.Success(c, gin.H{"message": "删除成功"}) +} + +func (h *DramaHandler) GetDramaStats(c *gin.Context) { + + stats, err := h.dramaService.GetDramaStats() + if err != nil { + response.InternalError(c, "获取统计失败") + return + } + + response.Success(c, stats) +} + +func (h *DramaHandler) SaveOutline(c *gin.Context) { + + dramaID := c.Param("id") + + var req services.SaveOutlineRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + if err := h.dramaService.SaveOutline(dramaID, &req); err != nil { + if err.Error() == "drama not found" { + response.NotFound(c, "剧本不存在") + return + } + response.InternalError(c, "保存失败") + return + } + + response.Success(c, gin.H{"message": "保存成功"}) +} + +func (h *DramaHandler) GetCharacters(c *gin.Context) { + + dramaID := c.Param("id") + episodeID := c.Query("episode_id") // 可选:如果提供则只返回该章节的角色 + + var episodeIDPtr *string + if episodeID != "" { + episodeIDPtr = &episodeID + } + + characters, err := h.dramaService.GetCharacters(dramaID, episodeIDPtr) + if err != nil { + if err.Error() == "drama not found" { + response.NotFound(c, "剧本不存在") + return + } + if err.Error() == "episode not found" { + response.NotFound(c, "章节不存在") + return + } + response.InternalError(c, "获取角色失败") + return + } + + response.Success(c, characters) +} + +func (h *DramaHandler) SaveCharacters(c *gin.Context) { + + dramaID := c.Param("id") + + var req services.SaveCharactersRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + if err := h.dramaService.SaveCharacters(dramaID, &req); err != nil { + if err.Error() == "drama not found" { + response.NotFound(c, "剧本不存在") + return + } + response.InternalError(c, "保存失败") + return + } + + response.Success(c, gin.H{"message": "保存成功"}) +} + +func (h *DramaHandler) SaveEpisodes(c *gin.Context) { + + dramaID := c.Param("id") + + var req services.SaveEpisodesRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + if err := h.dramaService.SaveEpisodes(dramaID, &req); err != nil { + if err.Error() == "drama not found" { + response.NotFound(c, "剧本不存在") + return + } + response.InternalError(c, "保存失败") + return + } + + response.Success(c, gin.H{"message": "保存成功"}) +} + +func (h *DramaHandler) SaveProgress(c *gin.Context) { + + dramaID := c.Param("id") + + var req services.SaveProgressRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + if err := h.dramaService.SaveProgress(dramaID, &req); err != nil { + if err.Error() == "drama not found" { + response.NotFound(c, "剧本不存在") + return + } + response.InternalError(c, "保存失败") + return + } + + response.Success(c, gin.H{"message": "保存成功"}) +} + +// FinalizeEpisode 完成集数制作(触发视频合成) +func (h *DramaHandler) FinalizeEpisode(c *gin.Context) { + + episodeID := c.Param("episode_id") + if episodeID == "" { + response.BadRequest(c, "episode_id不能为空") + return + } + + // 尝试读取时间线数据(可选) + var timelineData *services.FinalizeEpisodeRequest + if err := c.ShouldBindJSON(&timelineData); err != nil { + // 如果没有请求体或解析失败,使用nil(将使用默认场景顺序) + h.log.Warnw("No timeline data provided, will use default scene order", "error", err) + timelineData = nil + } else if timelineData != nil { + h.log.Infow("Received timeline data", "clips_count", len(timelineData.Clips), "episode_id", episodeID) + } + + // 触发视频合成任务 + result, err := h.videoMergeService.FinalizeEpisode(episodeID, timelineData) + if err != nil { + h.log.Errorw("Failed to finalize episode", "error", err, "episode_id", episodeID) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, result) +} + +// DownloadEpisodeVideo 下载剧集视频 +func (h *DramaHandler) DownloadEpisodeVideo(c *gin.Context) { + + episodeID := c.Param("episode_id") + if episodeID == "" { + response.BadRequest(c, "episode_id不能为空") + return + } + + // 查询episode + var episode models.Episode + if err := h.db.Preload("Drama").Where("id = ?", episodeID).First(&episode).Error; err != nil { + response.NotFound(c, "剧集不存在") + return + } + + // 检查是否有视频 + if episode.VideoURL == nil || *episode.VideoURL == "" { + response.BadRequest(c, "该剧集还没有生成视频") + return + } + + // 返回视频URL,让前端重定向下载 + c.JSON(200, gin.H{ + "video_url": *episode.VideoURL, + "title": episode.Title, + "episode_number": episode.EpisodeNum, + }) +} diff --git a/api/handlers/frame_prompt.go b/api/handlers/frame_prompt.go new file mode 100644 index 0000000..33ced9d --- /dev/null +++ b/api/handlers/frame_prompt.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "github.com/drama-generator/backend/application/services" + "github.com/drama-generator/backend/pkg/logger" + "github.com/drama-generator/backend/pkg/response" + "github.com/gin-gonic/gin" +) + +// FramePromptHandler 处理帧提示词生成请求 +type FramePromptHandler struct { + framePromptService *services.FramePromptService + log *logger.Logger +} + +// NewFramePromptHandler 创建帧提示词处理器 +func NewFramePromptHandler(framePromptService *services.FramePromptService, log *logger.Logger) *FramePromptHandler { + return &FramePromptHandler{ + framePromptService: framePromptService, + log: log, + } +} + +// GenerateFramePrompt 生成指定类型的帧提示词 +// POST /api/v1/storyboards/:id/frame-prompt +func (h *FramePromptHandler) GenerateFramePrompt(c *gin.Context) { + storyboardID := c.Param("id") + + var req struct { + FrameType string `json:"frame_type" binding:"required"` // first, key, last, panel, action + PanelCount int `json:"panel_count"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request body") + return + } + + // 构建请求 + serviceReq := services.GenerateFramePromptRequest{ + StoryboardID: storyboardID, + FrameType: services.FrameType(req.FrameType), + PanelCount: req.PanelCount, + } + + // 生成提示词 + result, err := h.framePromptService.GenerateFramePrompt(serviceReq) + if err != nil { + h.log.Errorw("Failed to generate frame prompt", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, result) +} diff --git a/api/handlers/frame_prompt_query.go b/api/handlers/frame_prompt_query.go new file mode 100644 index 0000000..70cd407 --- /dev/null +++ b/api/handlers/frame_prompt_query.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "github.com/drama-generator/backend/domain/models" + "github.com/drama-generator/backend/pkg/logger" + "github.com/drama-generator/backend/pkg/response" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// GetStoryboardFramePrompts 查询镜头的所有帧提示词 +// GET /api/v1/storyboards/:id/frame-prompts +func GetStoryboardFramePrompts(db *gorm.DB, log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + storyboardID := c.Param("id") + + var framePrompts []models.FramePrompt + if err := db.Where("storyboard_id = ?", storyboardID). + Order("created_at DESC"). + Find(&framePrompts).Error; err != nil { + log.Errorw("Failed to query frame prompts", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, gin.H{ + "frame_prompts": framePrompts, + }) + } +} diff --git a/api/handlers/image_generation.go b/api/handlers/image_generation.go new file mode 100644 index 0000000..93d3ec3 --- /dev/null +++ b/api/handlers/image_generation.go @@ -0,0 +1,182 @@ +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 ImageGenerationHandler struct { + imageService *services.ImageGenerationService + log *logger.Logger +} + +func NewImageGenerationHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, transferService *services.ResourceTransferService) *ImageGenerationHandler { + return &ImageGenerationHandler{ + imageService: services.NewImageGenerationService(db, transferService, 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") + + // 同步执行场景提取 + backgrounds, err := h.imageService.ExtractBackgroundsForEpisode(episodeID) + if err != nil { + h.log.Errorw("Failed to extract backgrounds", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, backgrounds) +} + +func (h *ImageGenerationHandler) BatchGenerateForEpisode(c *gin.Context) { + + episodeID := c.Param("episode_id") + + images, err := h.imageService.BatchGenerateImagesForEpisode(episodeID) + if err != nil { + h.log.Errorw("Failed to batch generate images", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, images) +} + +func (h *ImageGenerationHandler) GetImageGeneration(c *gin.Context) { + + imageGenID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的ID") + return + } + + imageGen, err := h.imageService.GetImageGeneration(uint(imageGenID)) + if err != nil { + response.NotFound(c, "图片生成记录不存在") + return + } + + response.Success(c, imageGen) +} + +func (h *ImageGenerationHandler) ListImageGenerations(c *gin.Context) { + var sceneID *uint + if sceneIDStr := c.Query("scene_id"); sceneIDStr != "" { + id, err := strconv.ParseUint(sceneIDStr, 10, 32) + if err == nil { + uid := uint(id) + sceneID = &uid + } + } + + var storyboardID *uint + if storyboardIDStr := c.Query("storyboard_id"); storyboardIDStr != "" { + id, err := strconv.ParseUint(storyboardIDStr, 10, 32) + if err == nil { + uid := uint(id) + storyboardID = &uid + } + } + + frameType := c.Query("frame_type") + status := c.Query("status") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + var dramaIDUint *uint + if dramaIDStr := c.Query("drama_id"); dramaIDStr != "" { + did, _ := strconv.ParseUint(dramaIDStr, 10, 32) + didUint := uint(did) + dramaIDUint = &didUint + } + + images, total, err := h.imageService.ListImageGenerations(dramaIDUint, sceneID, storyboardID, frameType, status, page, pageSize) + + if err != nil { + h.log.Errorw("Failed to list images", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.SuccessWithPagination(c, images, total, page, pageSize) +} + +func (h *ImageGenerationHandler) DeleteImageGeneration(c *gin.Context) { + + imageGenID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的ID") + return + } + + if err := h.imageService.DeleteImageGeneration(uint(imageGenID)); err != nil { + h.log.Errorw("Failed to delete image", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, nil) +} diff --git a/api/handlers/scene.go b/api/handlers/scene.go new file mode 100644 index 0000000..968cd80 --- /dev/null +++ b/api/handlers/scene.go @@ -0,0 +1,75 @@ +package handlers + +import ( + services2 "github.com/drama-generator/backend/application/services" + "github.com/drama-generator/backend/pkg/logger" + "github.com/drama-generator/backend/pkg/response" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type SceneHandler struct { + sceneService *services2.StoryboardCompositionService + log *logger.Logger +} + +func NewSceneHandler(db *gorm.DB, log *logger.Logger, imageGenService *services2.ImageGenerationService) *SceneHandler { + return &SceneHandler{ + sceneService: services2.NewStoryboardCompositionService(db, log, imageGenService), + log: log, + } +} + +func (h *SceneHandler) GetStoryboardsForEpisode(c *gin.Context) { + episodeID := c.Param("episode_id") + + storyboards, err := h.sceneService.GetScenesForEpisode(episodeID) + if err != nil { + h.log.Errorw("Failed to get storyboards for episode", "error", err, "episode_id", episodeID) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, gin.H{ + "storyboards": storyboards, + "total": len(storyboards), + }) +} + +func (h *SceneHandler) UpdateScene(c *gin.Context) { + sceneID := c.Param("scene_id") + + var req services2.UpdateSceneRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request") + return + } + + if err := h.sceneService.UpdateScene(sceneID, &req); err != nil { + h.log.Errorw("Failed to update scene", "error", err, "scene_id", sceneID) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, gin.H{"message": "Scene updated successfully"}) +} + +func (h *SceneHandler) GenerateSceneImage(c *gin.Context) { + var req services2.GenerateSceneImageRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request") + return + } + + imageGen, err := h.sceneService.GenerateSceneImage(&req) + if err != nil { + h.log.Errorw("Failed to generate scene image", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, gin.H{ + "message": "Scene image generation started", + "image_generation": imageGen, + }) +} diff --git a/api/handlers/script_generation.go b/api/handlers/script_generation.go new file mode 100644 index 0000000..1d5fd60 --- /dev/null +++ b/api/handlers/script_generation.go @@ -0,0 +1,76 @@ +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 + log *logger.Logger +} + +func NewScriptGenerationHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *ScriptGenerationHandler { + return &ScriptGenerationHandler{ + scriptService: services.NewScriptGenerationService(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 + } + + // 同步执行角色生成 + characters, err := h.scriptService.GenerateCharacters(&req) + if err != nil { + h.log.Errorw("Failed to generate characters", "error", err) + response.InternalError(c, "生成角色失败") + return + } + + response.Success(c, characters) +} + +func (h *ScriptGenerationHandler) GenerateEpisodes(c *gin.Context) { + + var req services.GenerateEpisodesRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + episodes, err := h.scriptService.GenerateEpisodes(&req) + if err != nil { + h.log.Errorw("Failed to generate episodes", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, episodes) +} diff --git a/api/handlers/storyboard.go b/api/handlers/storyboard.go new file mode 100644 index 0000000..2dc1fda --- /dev/null +++ b/api/handlers/storyboard.go @@ -0,0 +1,96 @@ +package handlers + +import ( + "github.com/drama-generator/backend/application/services" + "github.com/drama-generator/backend/pkg/config" + "github.com/drama-generator/backend/pkg/logger" + "github.com/drama-generator/backend/pkg/response" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type StoryboardHandler struct { + storyboardService *services.StoryboardService + taskService *services.TaskService + log *logger.Logger +} + +func NewStoryboardHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *StoryboardHandler { + return &StoryboardHandler{ + storyboardService: services.NewStoryboardService(db, log), + taskService: services.NewTaskService(db, log), + log: log, + } +} + +// GenerateStoryboard 生成分镜头(异步) +func (h *StoryboardHandler) GenerateStoryboard(c *gin.Context) { + episodeID := c.Param("episode_id") + + // 创建异步任务 + task, err := h.taskService.CreateTask("storyboard_generation", episodeID) + if err != nil { + h.log.Errorw("Failed to create task", "error", err) + response.InternalError(c, err.Error()) + return + } + + // 启动后台goroutine处理 + go h.processStoryboardGeneration(task.ID, episodeID) + + // 立即返回任务ID + response.Success(c, gin.H{ + "task_id": task.ID, + "status": "pending", + "message": "分镜生成任务已创建,正在后台处理...", + }) +} + +// processStoryboardGeneration 后台处理分镜生成 +func (h *StoryboardHandler) processStoryboardGeneration(taskID, episodeID string) { + h.log.Infow("Starting storyboard generation", "task_id", taskID, "episode_id", episodeID) + + // 更新任务状态为处理中 + if err := h.taskService.UpdateTaskStatus(taskID, "processing", 10, "开始生成分镜..."); err != nil { + h.log.Errorw("Failed to update task status", "error", err) + } + + // 调用实际的生成逻辑 + result, err := h.storyboardService.GenerateStoryboard(episodeID) + if err != nil { + h.log.Errorw("Failed to generate storyboard", "error", err, "task_id", taskID) + if updateErr := h.taskService.UpdateTaskError(taskID, err); updateErr != nil { + h.log.Errorw("Failed to update task error", "error", updateErr) + } + return + } + + // 更新任务结果 + if err := h.taskService.UpdateTaskResult(taskID, result); err != nil { + h.log.Errorw("Failed to update task result", "error", err) + return + } + + h.log.Infow("Storyboard generation completed", "task_id", taskID, "total", result.Total) +} + +// UpdateStoryboard 更新分镜 +func (h *StoryboardHandler) UpdateStoryboard(c *gin.Context) { + storyboardID := c.Param("id") + + var req map[string]interface{} + + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request body") + return + } + + err := h.storyboardService.UpdateStoryboard(storyboardID, req) + if err != nil { + h.log.Errorw("Failed to update storyboard", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, gin.H{"message": "Storyboard updated successfully"}) +} diff --git a/api/handlers/task.go b/api/handlers/task.go new file mode 100644 index 0000000..3e38ae0 --- /dev/null +++ b/api/handlers/task.go @@ -0,0 +1,57 @@ +package handlers + +import ( + "github.com/drama-generator/backend/application/services" + "github.com/drama-generator/backend/pkg/logger" + "github.com/drama-generator/backend/pkg/response" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type TaskHandler struct { + taskService *services.TaskService + log *logger.Logger +} + +func NewTaskHandler(db *gorm.DB, log *logger.Logger) *TaskHandler { + return &TaskHandler{ + taskService: services.NewTaskService(db, log), + log: log, + } +} + +// GetTaskStatus 获取任务状态 +func (h *TaskHandler) GetTaskStatus(c *gin.Context) { + taskID := c.Param("task_id") + + task, err := h.taskService.GetTask(taskID) + if err != nil { + if err == gorm.ErrRecordNotFound { + response.NotFound(c, "任务不存在") + return + } + h.log.Errorw("Failed to get task", "error", err, "task_id", taskID) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, task) +} + +// GetResourceTasks 获取资源相关的所有任务 +func (h *TaskHandler) GetResourceTasks(c *gin.Context) { + resourceID := c.Query("resource_id") + if resourceID == "" { + response.BadRequest(c, "缺少resource_id参数") + return + } + + tasks, err := h.taskService.GetTasksByResource(resourceID) + if err != nil { + h.log.Errorw("Failed to get resource tasks", "error", err, "resource_id", resourceID) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, tasks) +} diff --git a/api/handlers/upload.go b/api/handlers/upload.go new file mode 100644 index 0000000..84de200 --- /dev/null +++ b/api/handlers/upload.go @@ -0,0 +1,142 @@ +package handlers + +import ( + services2 "github.com/drama-generator/backend/application/services" + "github.com/drama-generator/backend/pkg/config" + "github.com/drama-generator/backend/pkg/logger" + "github.com/drama-generator/backend/pkg/response" + "github.com/gin-gonic/gin" +) + +type UploadHandler struct { + uploadService *services2.UploadService + characterLibraryService *services2.CharacterLibraryService + log *logger.Logger +} + +func NewUploadHandler(cfg *config.Config, log *logger.Logger, characterLibraryService *services2.CharacterLibraryService) (*UploadHandler, error) { + uploadService, err := services2.NewUploadService(cfg, log) + if err != nil { + return nil, err + } + + return &UploadHandler{ + uploadService: uploadService, + characterLibraryService: characterLibraryService, + log: log, + }, nil +} + +// UploadImage 上传图片 +func (h *UploadHandler) UploadImage(c *gin.Context) { + // 获取上传的文件 + file, header, err := c.Request.FormFile("file") + if err != nil { + response.BadRequest(c, "请选择文件") + return + } + defer file.Close() + + // 检查文件类型 + contentType := header.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + + // 验证是图片类型 + allowedTypes := map[string]bool{ + "image/jpeg": true, + "image/jpg": true, + "image/png": true, + "image/gif": true, + "image/webp": true, + } + + if !allowedTypes[contentType] { + response.BadRequest(c, "只支持图片格式 (jpg, png, gif, webp)") + return + } + + // 检查文件大小 (10MB) + if header.Size > 10*1024*1024 { + response.BadRequest(c, "文件大小不能超过10MB") + return + } + + // 上传到MinIO + fileURL, err := h.uploadService.UploadCharacterImage(file, header.Filename, contentType) + if err != nil { + h.log.Errorw("Failed to upload image", "error", err) + response.InternalError(c, "上传失败") + return + } + + response.Success(c, gin.H{ + "url": fileURL, + "filename": header.Filename, + "size": header.Size, + }) +} + +// UploadCharacterImage 上传角色图片(带角色ID) +func (h *UploadHandler) UploadCharacterImage(c *gin.Context) { + characterID := c.Param("id") + + // 获取上传的文件 + file, header, err := c.Request.FormFile("file") + if err != nil { + response.BadRequest(c, "请选择文件") + return + } + defer file.Close() + + // 检查文件类型 + contentType := header.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + + // 验证是图片类型 + allowedTypes := map[string]bool{ + "image/jpeg": true, + "image/jpg": true, + "image/png": true, + "image/gif": true, + "image/webp": true, + } + + if !allowedTypes[contentType] { + response.BadRequest(c, "只支持图片格式 (jpg, png, gif, webp)") + return + } + + // 检查文件大小 (10MB) + if header.Size > 10*1024*1024 { + response.BadRequest(c, "文件大小不能超过10MB") + return + } + + // 上传到MinIO + fileURL, err := h.uploadService.UploadCharacterImage(file, header.Filename, contentType) + if err != nil { + h.log.Errorw("Failed to upload character image", "error", err) + response.InternalError(c, "上传失败") + return + } + + // 更新角色的image_url字段到数据库 + err = h.characterLibraryService.UploadCharacterImage(characterID, fileURL) + if err != nil { + h.log.Errorw("Failed to update character image_url", "error", err, "character_id", characterID) + response.InternalError(c, "更新角色图片失败") + return + } + + h.log.Infow("Character image uploaded and saved", "character_id", characterID, "url", fileURL) + + response.Success(c, gin.H{ + "url": fileURL, + "filename": header.Filename, + "size": header.Size, + }) +} diff --git a/api/handlers/video_generation.go b/api/handlers/video_generation.go new file mode 100644 index 0000000..8a89a05 --- /dev/null +++ b/api/handlers/video_generation.go @@ -0,0 +1,149 @@ +package handlers + +import ( + "strconv" + + "github.com/drama-generator/backend/application/services" + "github.com/drama-generator/backend/infrastructure/storage" + "github.com/drama-generator/backend/pkg/logger" + "github.com/drama-generator/backend/pkg/response" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type VideoGenerationHandler struct { + videoService *services.VideoGenerationService + log *logger.Logger +} + +func NewVideoGenerationHandler(db *gorm.DB, transferService *services.ResourceTransferService, localStorage *storage.LocalStorage, aiService *services.AIService, log *logger.Logger) *VideoGenerationHandler { + return &VideoGenerationHandler{ + videoService: services.NewVideoGenerationService(db, transferService, localStorage, aiService, log), + log: log, + } +} + +func (h *VideoGenerationHandler) GenerateVideo(c *gin.Context) { + + var req services.GenerateVideoRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + videoGen, err := h.videoService.GenerateVideo(&req) + if err != nil { + h.log.Errorw("Failed to generate video", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, videoGen) +} + +func (h *VideoGenerationHandler) GenerateVideoFromImage(c *gin.Context) { + + imageGenID, err := strconv.ParseUint(c.Param("image_gen_id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的图片ID") + return + } + + videoGen, err := h.videoService.GenerateVideoFromImage(uint(imageGenID)) + if err != nil { + h.log.Errorw("Failed to generate video from image", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, videoGen) +} + +func (h *VideoGenerationHandler) BatchGenerateForEpisode(c *gin.Context) { + + episodeID := c.Param("episode_id") + + videos, err := h.videoService.BatchGenerateVideosForEpisode(episodeID) + if err != nil { + h.log.Errorw("Failed to batch generate videos", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, videos) +} + +func (h *VideoGenerationHandler) GetVideoGeneration(c *gin.Context) { + + videoGenID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的ID") + return + } + + videoGen, err := h.videoService.GetVideoGeneration(uint(videoGenID)) + if err != nil { + response.NotFound(c, "视频生成记录不存在") + return + } + + response.Success(c, videoGen) +} + +func (h *VideoGenerationHandler) ListVideoGenerations(c *gin.Context) { + var storyboardID *uint + // 优先使用storyboard_id参数 + if storyboardIDStr := c.Query("storyboard_id"); storyboardIDStr != "" { + id, err := strconv.ParseUint(storyboardIDStr, 10, 32) + if err == nil { + uid := uint(id) + storyboardID = &uid + } + } + status := c.Query("status") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + var dramaIDUint *uint + if dramaIDStr := c.Query("drama_id"); dramaIDStr != "" { + did, _ := strconv.ParseUint(dramaIDStr, 10, 32) + didUint := uint(did) + dramaIDUint = &didUint + } + + // 计算offset:(page - 1) * pageSize + offset := (page - 1) * pageSize + videos, total, err := h.videoService.ListVideoGenerations(dramaIDUint, storyboardID, status, pageSize, offset) + + if err != nil { + h.log.Errorw("Failed to list videos", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.SuccessWithPagination(c, videos, total, page, pageSize) +} + +func (h *VideoGenerationHandler) DeleteVideoGeneration(c *gin.Context) { + + videoGenID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.BadRequest(c, "无效的ID") + return + } + + if err := h.videoService.DeleteVideoGeneration(uint(videoGenID)); err != nil { + h.log.Errorw("Failed to delete video", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, nil) +} diff --git a/api/handlers/video_merge.go b/api/handlers/video_merge.go new file mode 100644 index 0000000..7350b6b --- /dev/null +++ b/api/handlers/video_merge.go @@ -0,0 +1,104 @@ +package handlers + +import ( + "strconv" + + services2 "github.com/drama-generator/backend/application/services" + "github.com/drama-generator/backend/pkg/logger" + "github.com/drama-generator/backend/pkg/response" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type VideoMergeHandler struct { + mergeService *services2.VideoMergeService + log *logger.Logger +} + +func NewVideoMergeHandler(db *gorm.DB, transferService *services2.ResourceTransferService, storagePath, baseURL string, log *logger.Logger) *VideoMergeHandler { + return &VideoMergeHandler{ + mergeService: services2.NewVideoMergeService(db, transferService, storagePath, baseURL, log), + log: log, + } +} + +func (h *VideoMergeHandler) MergeVideos(c *gin.Context) { + var req services2.MergeVideoRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request") + return + } + + merge, err := h.mergeService.MergeVideos(&req) + if err != nil { + h.log.Errorw("Failed to merge videos", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, gin.H{ + "message": "Video merge task created", + "merge": merge, + }) +} + +func (h *VideoMergeHandler) GetMerge(c *gin.Context) { + mergeIDStr := c.Param("merge_id") + mergeID, err := strconv.ParseUint(mergeIDStr, 10, 32) + if err != nil { + response.BadRequest(c, "Invalid merge ID") + return + } + + merge, err := h.mergeService.GetMerge(uint(mergeID)) + if err != nil { + h.log.Errorw("Failed to get merge", "error", err) + response.NotFound(c, "Merge not found") + return + } + + response.Success(c, gin.H{"merge": merge}) +} + +func (h *VideoMergeHandler) ListMerges(c *gin.Context) { + episodeID := c.Query("episode_id") + status := c.Query("status") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + var episodeIDPtr *string + if episodeID != "" { + episodeIDPtr = &episodeID + } + + merges, total, err := h.mergeService.ListMerges(episodeIDPtr, status, page, pageSize) + if err != nil { + h.log.Errorw("Failed to list merges", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, gin.H{ + "merges": merges, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +func (h *VideoMergeHandler) DeleteMerge(c *gin.Context) { + mergeIDStr := c.Param("merge_id") + mergeID, err := strconv.ParseUint(mergeIDStr, 10, 32) + if err != nil { + response.BadRequest(c, "Invalid merge ID") + return + } + + if err := h.mergeService.DeleteMerge(uint(mergeID)); err != nil { + h.log.Errorw("Failed to delete merge", "error", err) + response.InternalError(c, err.Error()) + return + } + + response.Success(c, gin.H{"message": "Merge deleted successfully"}) +} diff --git a/api/middlewares/cors.go b/api/middlewares/cors.go new file mode 100644 index 0000000..b5c1bd1 --- /dev/null +++ b/api/middlewares/cors.go @@ -0,0 +1,34 @@ +package middlewares + +import ( + "github.com/gin-gonic/gin" +) + +func CORSMiddleware(allowedOrigins []string) gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.Request.Header.Get("Origin") + + allowed := false + for _, o := range allowedOrigins { + if o == "*" || o == origin { + allowed = true + break + } + } + + if allowed { + c.Writer.Header().Set("Access-Control-Allow-Origin", origin) + } + + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + } +} diff --git a/api/middlewares/logger.go b/api/middlewares/logger.go new file mode 100644 index 0000000..04dc9d2 --- /dev/null +++ b/api/middlewares/logger.go @@ -0,0 +1,30 @@ +package middlewares + +import ( + "time" + + "github.com/drama-generator/backend/pkg/logger" + "github.com/gin-gonic/gin" +) + +func LoggerMiddleware(log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + + c.Next() + + duration := time.Since(start) + + log.Infow("HTTP Request", + "method", c.Request.Method, + "path", path, + "query", query, + "status", c.Writer.Status(), + "duration", duration.Milliseconds(), + "ip", c.ClientIP(), + "user_agent", c.Request.UserAgent(), + ) + } +} diff --git a/api/middlewares/ratelimit.go b/api/middlewares/ratelimit.go new file mode 100644 index 0000000..c4cdfbc --- /dev/null +++ b/api/middlewares/ratelimit.go @@ -0,0 +1,52 @@ +package middlewares + +import ( + "sync" + "time" + + "github.com/drama-generator/backend/pkg/response" + "github.com/gin-gonic/gin" +) + +type rateLimiter struct { + mu sync.Mutex + requests map[string][]time.Time + limit int + window time.Duration +} + +var limiter = &rateLimiter{ + requests: make(map[string][]time.Time), + limit: 100, + window: time.Minute, +} + +func RateLimitMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + + limiter.mu.Lock() + defer limiter.mu.Unlock() + + now := time.Now() + requests := limiter.requests[ip] + + var validRequests []time.Time + for _, t := range requests { + if now.Sub(t) < limiter.window { + validRequests = append(validRequests, t) + } + } + + if len(validRequests) >= limiter.limit { + response.Error(c, 429, "RATE_LIMIT_EXCEEDED", "请求过于频繁,请稍后再试") + c.Abort() + return + } + + validRequests = append(validRequests, now) + limiter.requests[ip] = validRequests + + c.Next() + } +} diff --git a/api/routes/routes.go b/api/routes/routes.go new file mode 100644 index 0000000..d786ae7 --- /dev/null +++ b/api/routes/routes.go @@ -0,0 +1,212 @@ +package routes + +import ( + handlers2 "github.com/drama-generator/backend/api/handlers" + middlewares2 "github.com/drama-generator/backend/api/middlewares" + services2 "github.com/drama-generator/backend/application/services" + storage2 "github.com/drama-generator/backend/infrastructure/storage" + "github.com/drama-generator/backend/pkg/config" + "github.com/drama-generator/backend/pkg/logger" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func SetupRouter(cfg *config.Config, db *gorm.DB, log *logger.Logger, localStorage interface{}) *gin.Engine { + r := gin.New() + + r.Use(gin.Recovery()) + r.Use(middlewares2.LoggerMiddleware(log)) + r.Use(middlewares2.CORSMiddleware(cfg.Server.CORSOrigins)) + + // 静态文件服务(用户上传的文件) + r.Static("/static", cfg.Storage.LocalPath) + + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{ + "status": "ok", + "app": cfg.App.Name, + "version": cfg.App.Version, + }) + }) + + aiService := services2.NewAIService(db, log) + dramaHandler := handlers2.NewDramaHandler(db, cfg, log, nil) + aiConfigHandler := handlers2.NewAIConfigHandler(db, cfg, log) + scriptGenHandler := handlers2.NewScriptGenerationHandler(db, cfg, log) + imageGenService := services2.NewImageGenerationService(db, nil, log) + imageGenHandler := handlers2.NewImageGenerationHandler(db, cfg, log, nil) + localStoragePtr := localStorage.(*storage2.LocalStorage) + transferService := services2.NewResourceTransferService(db, log) + 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, nil) + uploadHandler, err := handlers2.NewUploadHandler(cfg, log, characterLibraryService) + if err != nil { + log.Fatalw("Failed to create upload handler", "error", err) + } + storyboardHandler := handlers2.NewStoryboardHandler(db, cfg, log) + sceneHandler := handlers2.NewSceneHandler(db, log, imageGenService) + taskHandler := handlers2.NewTaskHandler(db, log) + framePromptService := services2.NewFramePromptService(db, log) + framePromptHandler := handlers2.NewFramePromptHandler(framePromptService, log) + + api := r.Group("/api/v1") + { + api.Use(middlewares2.RateLimitMiddleware()) + + dramas := api.Group("/dramas") + { + dramas.GET("", dramaHandler.ListDramas) + dramas.POST("", dramaHandler.CreateDrama) + dramas.GET("/stats", dramaHandler.GetDramaStats) + dramas.GET("/:id/characters", dramaHandler.GetCharacters) + dramas.PUT("/:id/characters", dramaHandler.SaveCharacters) + dramas.PUT("/:id/outline", dramaHandler.SaveOutline) + dramas.PUT("/:id/episodes", dramaHandler.SaveEpisodes) + dramas.PUT("/:id/progress", dramaHandler.SaveProgress) + dramas.GET("/:id", dramaHandler.GetDrama) + dramas.PUT("/:id", dramaHandler.UpdateDrama) + dramas.DELETE("/:id", dramaHandler.DeleteDrama) + } + + aiConfigs := api.Group("/ai-configs") + { + aiConfigs.GET("", aiConfigHandler.ListConfigs) + aiConfigs.POST("", aiConfigHandler.CreateConfig) + aiConfigs.POST("/test", aiConfigHandler.TestConnection) + aiConfigs.GET("/:id", aiConfigHandler.GetConfig) + aiConfigs.PUT("/:id", aiConfigHandler.UpdateConfig) + aiConfigs.DELETE("/:id", aiConfigHandler.DeleteConfig) + } + + generation := api.Group("/generation") + { + generation.POST("/outline", scriptGenHandler.GenerateOutline) + generation.POST("/characters", scriptGenHandler.GenerateCharacters) + generation.POST("/episodes", scriptGenHandler.GenerateEpisodes) + } + + // 角色库路由 + characterLibrary := api.Group("/character-library") + { + characterLibrary.GET("", characterLibraryHandler.ListLibraryItems) + characterLibrary.POST("", characterLibraryHandler.CreateLibraryItem) + characterLibrary.GET("/:id", characterLibraryHandler.GetLibraryItem) + characterLibrary.DELETE("/:id", characterLibraryHandler.DeleteLibraryItem) + } + + // 角色图片相关路由 + characters := api.Group("/characters") + { + characters.PUT("/:id", characterLibraryHandler.UpdateCharacter) + characters.DELETE("/:id", characterLibraryHandler.DeleteCharacter) + characters.POST("/batch-generate-images", characterLibraryHandler.BatchGenerateCharacterImages) + characters.POST("/:id/generate-image", characterLibraryHandler.GenerateCharacterImage) + characters.POST("/:id/upload-image", uploadHandler.UploadCharacterImage) + characters.PUT("/:id/image", characterLibraryHandler.UploadCharacterImage) + characters.PUT("/:id/image-from-library", characterLibraryHandler.ApplyLibraryItemToCharacter) + characters.POST("/:id/add-to-library", characterLibraryHandler.AddCharacterToLibrary) + } + + // 文件上传路由 + upload := api.Group("/upload") + { + upload.POST("/image", uploadHandler.UploadImage) + } + + // 分镜头路由 + episodes := api.Group("/episodes") + { + // 分镜头 + episodes.POST("/:episode_id/storyboards", storyboardHandler.GenerateStoryboard) + episodes.GET("/:episode_id/storyboards", sceneHandler.GetStoryboardsForEpisode) + episodes.POST("/:episode_id/finalize", dramaHandler.FinalizeEpisode) + episodes.GET("/:episode_id/download", dramaHandler.DownloadEpisodeVideo) + } + + // 任务路由 + tasks := api.Group("/tasks") + { + tasks.GET("/:task_id", taskHandler.GetTaskStatus) + tasks.GET("", taskHandler.GetResourceTasks) + } + + // 场景路由 + scenes := api.Group("/scenes") + { + scenes.PUT("/:scene_id", sceneHandler.UpdateScene) + scenes.POST("/generate-image", sceneHandler.GenerateSceneImage) + } + + images := api.Group("/images") + { + images.GET("", imageGenHandler.ListImageGenerations) + images.POST("", imageGenHandler.GenerateImage) + images.GET("/:id", imageGenHandler.GetImageGeneration) + images.DELETE("/:id", imageGenHandler.DeleteImageGeneration) + images.POST("/scene/:scene_id", imageGenHandler.GenerateImagesForScene) + images.GET("/episode/:episode_id/backgrounds", imageGenHandler.GetBackgroundsForEpisode) + images.POST("/episode/:episode_id/backgrounds/extract", imageGenHandler.ExtractBackgroundsForEpisode) + images.POST("/episode/:episode_id/batch", imageGenHandler.BatchGenerateForEpisode) + } + + videos := api.Group("/videos") + { + videos.GET("", videoGenHandler.ListVideoGenerations) + videos.POST("", videoGenHandler.GenerateVideo) + videos.GET("/:id", videoGenHandler.GetVideoGeneration) + videos.DELETE("/:id", videoGenHandler.DeleteVideoGeneration) + videos.POST("/image/:image_gen_id", videoGenHandler.GenerateVideoFromImage) + videos.POST("/episode/:episode_id/batch", videoGenHandler.BatchGenerateForEpisode) + } + + videoMerges := api.Group("/video-merges") + { + videoMerges.GET("", videoMergeHandler.ListMerges) + videoMerges.POST("", videoMergeHandler.MergeVideos) + videoMerges.GET("/:merge_id", videoMergeHandler.GetMerge) + videoMerges.DELETE("/:merge_id", videoMergeHandler.DeleteMerge) + } + + assets := api.Group("/assets") + { + assets.GET("", assetHandler.ListAssets) + assets.POST("", assetHandler.CreateAsset) + assets.GET("/:id", assetHandler.GetAsset) + assets.PUT("/:id", assetHandler.UpdateAsset) + assets.DELETE("/:id", assetHandler.DeleteAsset) + assets.POST("/import/image/:image_gen_id", assetHandler.ImportFromImageGen) + assets.POST("/import/video/:video_gen_id", assetHandler.ImportFromVideoGen) + } + + storyboards := api.Group("/storyboards") + { + storyboards.PUT("/:id", storyboardHandler.UpdateStoryboard) + storyboards.POST("/:id/frame-prompt", framePromptHandler.GenerateFramePrompt) + storyboards.GET("/:id/frame-prompts", handlers2.GetStoryboardFramePrompts(db, log)) + } + } + + // 前端静态文件服务(放在API路由之后,避免冲突) + // 服务前端构建产物 + r.Static("/assets", "./web/dist/assets") + r.StaticFile("/favicon.ico", "./web/dist/favicon.ico") + + // NoRoute处理:对于所有未匹配的路由 + r.NoRoute(func(c *gin.Context) { + path := c.Request.URL.Path + + // 如果是API路径,返回404 + if len(path) >= 4 && path[:4] == "/api" { + c.JSON(404, gin.H{"error": "API endpoint not found"}) + return + } + + // SPA fallback - 返回index.html + c.File("./web/dist/index.html") + }) + + return r +} diff --git a/application/services/ai_service.go b/application/services/ai_service.go new file mode 100644 index 0000000..31d4f6a --- /dev/null +++ b/application/services/ai_service.go @@ -0,0 +1,253 @@ +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"` + 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"` + 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"` + Endpoint string `json:"endpoint"` +} + +func (s *AIService) CreateConfig(req *CreateAIConfigRequest) (*models.AIServiceConfig, error) { + config := &models.AIServiceConfig{ + ServiceType: req.ServiceType, + Name: req.Name, + BaseURL: req.BaseURL, + APIKey: req.APIKey, + Model: req.Model, + Endpoint: req.Endpoint, + QueryEndpoint: req.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) + 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.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 + } + 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 { + // 使用第一个模型进行测试 + model := "" + if len(req.Model) > 0 { + model = req.Model[0] + } + client := ai.NewOpenAIClient(req.BaseURL, req.APIKey, model, req.Endpoint) + return client.TestConnection() +} + +func (s *AIService) GetDefaultConfig(serviceType string) (*models.AIServiceConfig, error) { + var config models.AIServiceConfig + // 按优先级降序获取第一个启用的配置 + err := s.db.Where("service_type = ? AND is_active = ?", serviceType, true). + Order("priority DESC, created_at DESC"). + First(&config).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("no active 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 = ? AND is_active = ?", serviceType, true). + 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.OpenAIClient, error) { + config, err := s.GetDefaultConfig(serviceType) + if err != nil { + return nil, err + } + + // 使用第一个模型 + model := "" + if len(config.Model) > 0 { + model = config.Model[0] + } + + return ai.NewOpenAIClient(config.BaseURL, config.APIKey, model, config.Endpoint), nil +} + +func (s *AIService) GenerateText(prompt string, systemPrompt string, options ...func(*ai.ChatCompletionRequest)) (string, error) { + client, err := s.GetAIClient("text") + if err != nil { + return "", fmt.Errorf("failed to get AI client: %w", err) + } + + return client.GenerateText(prompt, systemPrompt, options...) +} diff --git a/application/services/asset_service.go b/application/services/asset_service.go new file mode 100644 index 0000000..3171bee --- /dev/null +++ b/application/services/asset_service.go @@ -0,0 +1,287 @@ +package services + +import ( + "fmt" + "strconv" + "strings" + + models "github.com/drama-generator/backend/domain/models" + "github.com/drama-generator/backend/pkg/logger" + "gorm.io/gorm" +) + +type AssetService struct { + db *gorm.DB + log *logger.Logger +} + +func NewAssetService(db *gorm.DB, log *logger.Logger) *AssetService { + return &AssetService{ + db: db, + log: log, + } +} + +type CreateAssetRequest struct { + DramaID *string `json:"drama_id"` + Name string `json:"name" binding:"required"` + Description *string `json:"description"` + Type models.AssetType `json:"type" binding:"required"` + Category *string `json:"category"` + URL string `json:"url" binding:"required"` + ThumbnailURL *string `json:"thumbnail_url"` + LocalPath *string `json:"local_path"` + FileSize *int64 `json:"file_size"` + MimeType *string `json:"mime_type"` + Width *int `json:"width"` + Height *int `json:"height"` + Duration *int `json:"duration"` + Format *string `json:"format"` + ImageGenID *uint `json:"image_gen_id"` + VideoGenID *uint `json:"video_gen_id"` + TagIDs []uint `json:"tag_ids"` +} + +type UpdateAssetRequest struct { + Name *string `json:"name"` + Description *string `json:"description"` + Category *string `json:"category"` + ThumbnailURL *string `json:"thumbnail_url"` + TagIDs []uint `json:"tag_ids"` + IsFavorite *bool `json:"is_favorite"` +} + +type ListAssetsRequest struct { + DramaID *string `json:"drama_id"` + EpisodeID *uint `json:"episode_id"` + StoryboardID *uint `json:"storyboard_id"` + Type *models.AssetType `json:"type"` + Category string `json:"category"` + TagIDs []uint `json:"tag_ids"` + IsFavorite *bool `json:"is_favorite"` + Search string `json:"search"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +func (s *AssetService) CreateAsset(req *CreateAssetRequest) (*models.Asset, error) { + var dramaID *uint + if req.DramaID != nil && *req.DramaID != "" { + id, err := strconv.ParseUint(*req.DramaID, 10, 32) + if err == nil { + uid := uint(id) + dramaID = &uid + } + } + + if dramaID != nil { + var drama models.Drama + if err := s.db.Where("id = ?", *dramaID).First(&drama).Error; err != nil { + return nil, fmt.Errorf("drama not found") + } + } + + asset := &models.Asset{ + DramaID: dramaID, + Name: req.Name, + Description: req.Description, + Type: req.Type, + Category: req.Category, + URL: req.URL, + ThumbnailURL: req.ThumbnailURL, + LocalPath: req.LocalPath, + FileSize: req.FileSize, + MimeType: req.MimeType, + Width: req.Width, + Height: req.Height, + Duration: req.Duration, + Format: req.Format, + ImageGenID: req.ImageGenID, + VideoGenID: req.VideoGenID, + } + + if err := s.db.Create(asset).Error; err != nil { + return nil, fmt.Errorf("failed to create asset: %w", err) + } + + return asset, nil +} + +func (s *AssetService) UpdateAsset(assetID uint, req *UpdateAssetRequest) (*models.Asset, error) { + var asset models.Asset + if err := s.db.Where("id = ?", assetID).First(&asset).Error; err != nil { + return nil, fmt.Errorf("asset not found") + } + + updates := make(map[string]interface{}) + if req.Name != nil { + updates["name"] = *req.Name + } + if req.Description != nil { + updates["description"] = *req.Description + } + if req.Category != nil { + updates["category"] = *req.Category + } + if req.ThumbnailURL != nil { + updates["thumbnail_url"] = *req.ThumbnailURL + } + if req.IsFavorite != nil { + updates["is_favorite"] = *req.IsFavorite + } + + if len(updates) > 0 { + if err := s.db.Model(&asset).Updates(updates).Error; err != nil { + return nil, fmt.Errorf("failed to update asset: %w", err) + } + } + + if err := s.db.First(&asset, assetID).Error; err != nil { + return nil, err + } + + return &asset, nil +} + +func (s *AssetService) GetAsset(assetID uint) (*models.Asset, error) { + var asset models.Asset + if err := s.db.Where("id = ? ", assetID).First(&asset).Error; err != nil { + return nil, err + } + + s.db.Model(&asset).UpdateColumn("view_count", gorm.Expr("view_count + ?", 1)) + + return &asset, nil +} + +func (s *AssetService) ListAssets(req *ListAssetsRequest) ([]models.Asset, int64, error) { + query := s.db.Model(&models.Asset{}) + + if req.DramaID != nil { + var dramaID uint64 + dramaID, _ = strconv.ParseUint(*req.DramaID, 10, 32) + query = query.Where("drama_id = ?", uint(dramaID)) + } + + if req.EpisodeID != nil { + query = query.Where("episode_id = ?", *req.EpisodeID) + } + + if req.StoryboardID != nil { + query = query.Where("storyboard_id = ?", *req.StoryboardID) + } + + if req.Type != nil { + query = query.Where("type = ?", *req.Type) + } + + if req.Category != "" { + query = query.Where("category = ?", req.Category) + } + + if req.IsFavorite != nil { + query = query.Where("is_favorite = ?", *req.IsFavorite) + } + + if req.Search != "" { + searchTerm := "%" + strings.ToLower(req.Search) + "%" + query = query.Where("LOWER(name) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var assets []models.Asset + offset := (req.Page - 1) * req.PageSize + if err := query.Order("created_at DESC"). + Offset(offset).Limit(req.PageSize).Find(&assets).Error; err != nil { + return nil, 0, err + } + + return assets, total, nil +} + +func (s *AssetService) DeleteAsset(assetID uint) error { + result := s.db.Where("id = ?", assetID).Delete(&models.Asset{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("asset not found") + } + return nil +} + +func (s *AssetService) ImportFromImageGen(imageGenID uint) (*models.Asset, error) { + var imageGen models.ImageGeneration + if err := s.db.Where("id = ? ", imageGenID).First(&imageGen).Error; err != nil { + return nil, fmt.Errorf("image generation not found") + } + + if imageGen.Status != models.ImageStatusCompleted || imageGen.ImageURL == nil { + return nil, fmt.Errorf("image is not ready") + } + + dramaID := imageGen.DramaID + asset := &models.Asset{ + Name: fmt.Sprintf("Image_%d", imageGen.ID), + Type: models.AssetTypeImage, + URL: *imageGen.ImageURL, + DramaID: &dramaID, + ImageGenID: &imageGenID, + Width: imageGen.Width, + Height: imageGen.Height, + } + + if err := s.db.Create(asset).Error; err != nil { + return nil, fmt.Errorf("failed to create asset: %w", err) + } + + return asset, nil +} + +func (s *AssetService) ImportFromVideoGen(videoGenID uint) (*models.Asset, error) { + var videoGen models.VideoGeneration + if err := s.db.Preload("Storyboard.Episode").Where("id = ? ", videoGenID).First(&videoGen).Error; err != nil { + return nil, fmt.Errorf("video generation not found") + } + + if videoGen.Status != models.VideoStatusCompleted || videoGen.VideoURL == nil { + return nil, fmt.Errorf("video is not ready") + } + + dramaID := videoGen.DramaID + + var episodeID *uint + var storyboardNum *int + if videoGen.Storyboard != nil { + episodeID = &videoGen.Storyboard.Episode.ID + storyboardNum = &videoGen.Storyboard.StoryboardNumber + } + + asset := &models.Asset{ + Name: fmt.Sprintf("Video_%d", videoGen.ID), + Type: models.AssetTypeVideo, + URL: *videoGen.VideoURL, + DramaID: &dramaID, + EpisodeID: episodeID, + StoryboardID: videoGen.StoryboardID, + StoryboardNum: storyboardNum, + VideoGenID: &videoGenID, + Duration: videoGen.Duration, + Width: videoGen.Width, + Height: videoGen.Height, + } + + if videoGen.FirstFrameURL != nil { + asset.ThumbnailURL = videoGen.FirstFrameURL + } + + if err := s.db.Create(asset).Error; err != nil { + return nil, fmt.Errorf("failed to create asset: %w", err) + } + + return asset, nil +} diff --git a/application/services/character_library_service.go b/application/services/character_library_service.go new file mode 100644 index 0000000..a7850b8 --- /dev/null +++ b/application/services/character_library_service.go @@ -0,0 +1,473 @@ +package services + +import ( + "errors" + "fmt" + "time" + + models "github.com/drama-generator/backend/domain/models" + "github.com/drama-generator/backend/pkg/logger" + "gorm.io/gorm" +) + +type CharacterLibraryService struct { + db *gorm.DB + log *logger.Logger +} + +func NewCharacterLibraryService(db *gorm.DB, log *logger.Logger) *CharacterLibraryService { + return &CharacterLibraryService{ + db: db, + log: log, + } +} + +type CreateLibraryItemRequest struct { + Name string `json:"name" binding:"required,min=1,max=100"` + Category *string `json:"category"` + ImageURL string `json:"image_url" binding:"required"` + Description *string `json:"description"` + Tags *string `json:"tags"` + SourceType string `json:"source_type"` +} + +type CharacterLibraryQuery struct { + Page int `form:"page,default=1"` + PageSize int `form:"page_size,default=20"` + Category string `form:"category"` + SourceType string `form:"source_type"` + Keyword string `form:"keyword"` +} + +// ListLibraryItems 获取用户角色库列表 +func (s *CharacterLibraryService) ListLibraryItems(query *CharacterLibraryQuery) ([]models.CharacterLibrary, int64, error) { + var items []models.CharacterLibrary + var total int64 + + db := s.db.Model(&models.CharacterLibrary{}) + + // 筛选条件 + if query.Category != "" { + db = db.Where("category = ?", query.Category) + } + + if query.SourceType != "" { + db = db.Where("source_type = ?", query.SourceType) + } + + if query.Keyword != "" { + db = db.Where("name LIKE ? OR description LIKE ?", "%"+query.Keyword+"%", "%"+query.Keyword+"%") + } + + // 获取总数 + if err := db.Count(&total).Error; err != nil { + s.log.Errorw("Failed to count character library", "error", err) + return nil, 0, err + } + + // 分页查询 + offset := (query.Page - 1) * query.PageSize + err := db.Order("created_at DESC"). + Offset(offset). + Limit(query.PageSize). + Find(&items).Error + + if err != nil { + s.log.Errorw("Failed to list character library", "error", err) + return nil, 0, err + } + + return items, total, nil +} + +// CreateLibraryItem 添加到角色库 +func (s *CharacterLibraryService) CreateLibraryItem(req *CreateLibraryItemRequest) (*models.CharacterLibrary, error) { + sourceType := req.SourceType + if sourceType == "" { + sourceType = "generated" + } + + item := &models.CharacterLibrary{ + Name: req.Name, + Category: req.Category, + ImageURL: req.ImageURL, + Description: req.Description, + Tags: req.Tags, + SourceType: sourceType, + } + + if err := s.db.Create(item).Error; err != nil { + s.log.Errorw("Failed to create library item", "error", err) + return nil, err + } + + s.log.Infow("Library item created", "item_id", item.ID) + return item, nil +} + +// GetLibraryItem 获取角色库项 +func (s *CharacterLibraryService) GetLibraryItem(itemID string) (*models.CharacterLibrary, error) { + var item models.CharacterLibrary + err := s.db.Where("id = ? ", itemID).First(&item).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("library item not found") + } + s.log.Errorw("Failed to get library item", "error", err) + return nil, err + } + + return &item, nil +} + +// DeleteLibraryItem 删除角色库项 +func (s *CharacterLibraryService) DeleteLibraryItem(itemID string) error { + result := s.db.Where("id = ? ", itemID).Delete(&models.CharacterLibrary{}) + + if result.Error != nil { + s.log.Errorw("Failed to delete library item", "error", result.Error) + return result.Error + } + + if result.RowsAffected == 0 { + return errors.New("library item not found") + } + + s.log.Infow("Library item deleted", "item_id", itemID) + return nil +} + +// ApplyLibraryItemToCharacter 将角色库形象应用到角色 +func (s *CharacterLibraryService) ApplyLibraryItemToCharacter(characterID string, libraryItemID string) error { + // 验证角色库项存在且属于该用户 + var libraryItem models.CharacterLibrary + if err := s.db.Where("id = ? ", libraryItemID).First(&libraryItem).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("library item not found") + } + return err + } + + // 查找角色 + var character models.Character + if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("character not found") + } + return err + } + + // 查询Drama验证权限 + var drama models.Drama + if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("unauthorized") + } + return err + } + + // 更新角色的image_url + if err := s.db.Model(&character).Update("image_url", libraryItem.ImageURL).Error; err != nil { + s.log.Errorw("Failed to update character image", "error", err) + return err + } + + s.log.Infow("Library item applied to character", "character_id", characterID, "library_item_id", libraryItemID) + return nil +} + +// UploadCharacterImage 上传角色图片 +func (s *CharacterLibraryService) UploadCharacterImage(characterID string, imageURL string) error { + // 查找角色 + var character models.Character + if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("character not found") + } + return err + } + + // 查询Drama验证权限 + var drama models.Drama + if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("unauthorized") + } + return err + } + + // 更新图片URL + if err := s.db.Model(&character).Update("image_url", imageURL).Error; err != nil { + s.log.Errorw("Failed to update character image", "error", err) + return err + } + + s.log.Infow("Character image uploaded", "character_id", characterID) + return nil +} + +// AddCharacterToLibrary 将角色添加到角色库 +func (s *CharacterLibraryService) AddCharacterToLibrary(characterID string, category *string) (*models.CharacterLibrary, error) { + // 查找角色 + var character models.Character + if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("character not found") + } + return nil, err + } + + // 查询Drama验证权限 + var drama models.Drama + if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("unauthorized") + } + return nil, err + } + + // 检查是否有图片 + if character.ImageURL == nil || *character.ImageURL == "" { + return nil, fmt.Errorf("角色还没有形象图片") + } + + // 创建角色库项 + charLibrary := &models.CharacterLibrary{ + Name: character.Name, + ImageURL: *character.ImageURL, + Description: character.Description, + SourceType: "character", + } + + if err := s.db.Create(charLibrary).Error; err != nil { + s.log.Errorw("Failed to add character to library", "error", err) + return nil, err + } + + s.log.Infow("Character added to library", "character_id", characterID, "library_item_id", charLibrary.ID) + return charLibrary, nil +} + +// DeleteCharacter 删除单个角色 +func (s *CharacterLibraryService) DeleteCharacter(characterID uint) error { + // 查找角色 + var character models.Character + if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("character not found") + } + return err + } + + // 验证权限:检查角色所属的drama是否属于当前用户 + var drama models.Drama + if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("unauthorized") + } + return err + } + + // 删除角色 + if err := s.db.Delete(&character).Error; err != nil { + s.log.Errorw("Failed to delete character", "error", err, "id", characterID) + return err + } + + s.log.Infow("Character deleted", "id", characterID) + return nil +} + +// GenerateCharacterImage AI生成角色形象 +func (s *CharacterLibraryService) GenerateCharacterImage(characterID string, imageService *ImageGenerationService, modelName string) (*models.ImageGeneration, error) { + // 查找角色 + var character models.Character + if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("character not found") + } + return nil, err + } + + // 查询Drama验证权限 + var drama models.Drama + if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("unauthorized") + } + return nil, err + } + + // 构建生成提示词 - 使用详细的外貌描述,添加干净背景要求 + prompt := "" + + // 优先使用appearance字段,它包含了最详细的外貌描述 + if character.Appearance != nil && *character.Appearance != "" { + prompt = *character.Appearance + } else if character.Description != nil && *character.Description != "" { + prompt = *character.Description + } else { + prompt = character.Name + } + + // 添加角色画像和风格要求 + prompt += ", character portrait, full body or upper body shot" + + // 添加干净背景要求 - 确保背景简洁不干扰主体 + prompt += ", simple clean background, plain solid color background, white or light gray background" + prompt += ", studio lighting, professional photography" + + // 添加质量和风格要求 + prompt += ", high quality, detailed, anime style, character design" + prompt += ", no complex background, no scenery, focus on character" + + // 调用图片生成服务 + dramaIDStr := fmt.Sprintf("%d", character.DramaID) + imageType := "character" + req := &GenerateImageRequest{ + DramaID: dramaIDStr, + CharacterID: &character.ID, + ImageType: imageType, + Prompt: prompt, + Provider: "openai", // 或从配置读取 + Model: modelName, // 使用用户指定的模型 + Size: "2560x1440", // 3,686,400像素,满足API最低要求(16:9比例) + Quality: "standard", + } + + imageGen, err := imageService.GenerateImage(req) + if err != nil { + s.log.Errorw("Failed to generate character image", "error", err) + return nil, fmt.Errorf("图片生成失败: %w", err) + } + + // 异步处理:在后台监听图片生成完成,然后更新角色image_url + go s.waitAndUpdateCharacterImage(character.ID, imageGen.ID) + + // 立即返回ImageGeneration对象,让前端可以轮询状态 + s.log.Infow("Character image generation started", "character_id", characterID, "image_gen_id", imageGen.ID) + return imageGen, nil +} + +// waitAndUpdateCharacterImage 后台异步等待图片生成完成并更新角色image_url +func (s *CharacterLibraryService) waitAndUpdateCharacterImage(characterID uint, imageGenID uint) { + maxAttempts := 60 + pollInterval := 5 * time.Second + + for i := 0; i < maxAttempts; i++ { + time.Sleep(pollInterval) + + // 查询图片生成状态 + var imageGen models.ImageGeneration + if err := s.db.First(&imageGen, imageGenID).Error; err != nil { + s.log.Errorw("Failed to query image generation status", "error", err, "image_gen_id", imageGenID) + continue + } + + // 检查是否完成 + if imageGen.Status == models.ImageStatusCompleted && imageGen.ImageURL != nil && *imageGen.ImageURL != "" { + // 更新角色的image_url + if err := s.db.Model(&models.Character{}).Where("id = ?", characterID).Update("image_url", *imageGen.ImageURL).Error; err != nil { + s.log.Errorw("Failed to update character image_url", "error", err, "character_id", characterID) + return + } + s.log.Infow("Character image updated successfully", "character_id", characterID, "image_url", *imageGen.ImageURL) + return + } + + // 检查是否失败 + if imageGen.Status == models.ImageStatusFailed { + s.log.Errorw("Character image generation failed", "character_id", characterID, "image_gen_id", imageGenID, "error", imageGen.ErrorMsg) + return + } + } + + s.log.Warnw("Character image generation timeout", "character_id", characterID, "image_gen_id", imageGenID) +} + +// UpdateCharacter 更新角色信息 +func (s *CharacterLibraryService) UpdateCharacter(characterID string, req interface{}) error { + // 查找角色 + var character models.Character + if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("character not found") + } + return err + } + + // 验证权限:查询角色所属的drama是否属于该用户 + var drama models.Drama + if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("unauthorized") + } + return err + } + + // 构建更新数据 + updates := make(map[string]interface{}) + + // 使用类型断言获取请求数据 + if reqMap, ok := req.(*struct { + Name *string `json:"name"` + Appearance *string `json:"appearance"` + Personality *string `json:"personality"` + Description *string `json:"description"` + }); ok { + if reqMap.Name != nil && *reqMap.Name != "" { + updates["name"] = *reqMap.Name + } + if reqMap.Appearance != nil { + updates["appearance"] = *reqMap.Appearance + } + if reqMap.Personality != nil { + updates["personality"] = *reqMap.Personality + } + if reqMap.Description != nil { + updates["description"] = *reqMap.Description + } + } + + if len(updates) == 0 { + return errors.New("no fields to update") + } + + // 更新角色信息 + if err := s.db.Model(&character).Updates(updates).Error; err != nil { + s.log.Errorw("Failed to update character", "error", err, "character_id", characterID) + return err + } + + s.log.Infow("Character updated", "character_id", characterID) + return nil +} + +// BatchGenerateCharacterImages 批量生成角色图片(并发执行) +func (s *CharacterLibraryService) BatchGenerateCharacterImages(characterIDs []string, imageService *ImageGenerationService, modelName string) { + s.log.Infow("Starting batch character image generation", + "count", len(characterIDs), + "model", modelName) + + // 使用 goroutine 并发生成所有角色图片 + for _, characterID := range characterIDs { + // 为每个角色启动单独的 goroutine + go func(charID string) { + imageGen, err := s.GenerateCharacterImage(charID, imageService, modelName) + if err != nil { + s.log.Errorw("Failed to generate character image in batch", + "character_id", charID, + "error", err) + return + } + + s.log.Infow("Character image generated in batch", + "character_id", charID, + "image_gen_id", imageGen.ID) + }(characterID) + } + + s.log.Infow("Batch character image generation tasks submitted", + "total", len(characterIDs)) +} diff --git a/application/services/drama_service.go b/application/services/drama_service.go new file mode 100644 index 0000000..f585d00 --- /dev/null +++ b/application/services/drama_service.go @@ -0,0 +1,630 @@ +package services + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + "github.com/drama-generator/backend/domain/models" + "github.com/drama-generator/backend/pkg/logger" + "gorm.io/gorm" +) + +type DramaService struct { + db *gorm.DB + log *logger.Logger +} + +func NewDramaService(db *gorm.DB, log *logger.Logger) *DramaService { + return &DramaService{ + db: db, + log: log, + } +} + +type CreateDramaRequest struct { + Title string `json:"title" binding:"required,min=1,max=100"` + Description string `json:"description"` + Genre string `json:"genre"` + Tags string `json:"tags"` +} + +type UpdateDramaRequest struct { + Title string `json:"title" binding:"omitempty,min=1,max=100"` + Description string `json:"description"` + Genre string `json:"genre"` + Tags string `json:"tags"` + Status string `json:"status" binding:"omitempty,oneof=draft planning production completed archived"` +} + +type DramaListQuery struct { + Page int `form:"page,default=1"` + PageSize int `form:"page_size,default=20"` + Status string `form:"status"` + Genre string `form:"genre"` + Keyword string `form:"keyword"` +} + +func (s *DramaService) CreateDrama(req *CreateDramaRequest) (*models.Drama, error) { + drama := &models.Drama{ + Title: req.Title, + Status: "draft", + } + + if req.Description != "" { + drama.Description = &req.Description + } + if req.Genre != "" { + drama.Genre = &req.Genre + } + + if err := s.db.Create(drama).Error; err != nil { + s.log.Errorw("Failed to create drama", "error", err) + return nil, err + } + + s.log.Infow("Drama created", "drama_id", drama.ID) + return drama, nil +} + +func (s *DramaService) GetDrama(dramaID string) (*models.Drama, error) { + var drama models.Drama + err := s.db.Where("id = ? ", dramaID). + Preload("Characters"). // 加载Drama级别的角色 + Preload("Scenes"). // 加载Drama级别的场景 + Preload("Episodes.Characters"). // 加载每个章节关联的角色 + Preload("Episodes.Scenes"). // 加载每个章节关联的场景 + Preload("Episodes.Storyboards", func(db *gorm.DB) *gorm.DB { + return db.Order("storyboards.storyboard_number ASC") + }). + First(&drama).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("drama not found") + } + s.log.Errorw("Failed to get drama", "error", err) + return nil, err + } + + // 统计每个剧集的时长(基于场景时长之和) + for i := range drama.Episodes { + totalDuration := 0 + for _, scene := range drama.Episodes[i].Storyboards { + totalDuration += scene.Duration + } + // 更新剧集时长(秒转分钟,向上取整) + durationMinutes := (totalDuration + 59) / 60 + drama.Episodes[i].Duration = durationMinutes + + // 如果数据库中的时长与计算的不一致,更新数据库 + if drama.Episodes[i].Duration != durationMinutes { + s.db.Model(&models.Episode{}).Where("id = ?", drama.Episodes[i].ID).Update("duration", durationMinutes) + } + + // 查询角色的图片生成状态 + for j := range drama.Episodes[i].Characters { + var imageGen models.ImageGeneration + err := s.db.Where("character_id = ? AND (status = ? OR status = ?)", + drama.Episodes[i].Characters[j].ID, "pending", "processing"). + Order("created_at DESC"). + First(&imageGen).Error + + if err == nil { + // 找到生成中的记录,设置状态 + statusStr := string(imageGen.Status) + drama.Episodes[i].Characters[j].ImageGenerationStatus = &statusStr + if imageGen.ErrorMsg != nil { + drama.Episodes[i].Characters[j].ImageGenerationError = imageGen.ErrorMsg + } + } else if errors.Is(err, gorm.ErrRecordNotFound) { + // 检查是否有失败的记录 + err := s.db.Where("character_id = ? AND status = ?", + drama.Episodes[i].Characters[j].ID, "failed"). + Order("created_at DESC"). + First(&imageGen).Error + + if err == nil { + statusStr := string(imageGen.Status) + drama.Episodes[i].Characters[j].ImageGenerationStatus = &statusStr + if imageGen.ErrorMsg != nil { + drama.Episodes[i].Characters[j].ImageGenerationError = imageGen.ErrorMsg + } + } + } + } + + // 查询场景的图片生成状态 + for j := range drama.Episodes[i].Scenes { + var imageGen models.ImageGeneration + err := s.db.Where("scene_id = ? AND (status = ? OR status = ?)", + drama.Episodes[i].Scenes[j].ID, "pending", "processing"). + Order("created_at DESC"). + First(&imageGen).Error + + if err == nil { + // 找到生成中的记录,设置状态 + statusStr := string(imageGen.Status) + drama.Episodes[i].Scenes[j].ImageGenerationStatus = &statusStr + if imageGen.ErrorMsg != nil { + drama.Episodes[i].Scenes[j].ImageGenerationError = imageGen.ErrorMsg + } + } else if errors.Is(err, gorm.ErrRecordNotFound) { + // 检查是否有失败的记录 + err := s.db.Where("scene_id = ? AND status = ?", + drama.Episodes[i].Scenes[j].ID, "failed"). + Order("created_at DESC"). + First(&imageGen).Error + + if err == nil { + statusStr := string(imageGen.Status) + drama.Episodes[i].Scenes[j].ImageGenerationStatus = &statusStr + if imageGen.ErrorMsg != nil { + drama.Episodes[i].Scenes[j].ImageGenerationError = imageGen.ErrorMsg + } + } + } + } + } + + // 整合所有剧集的场景到Drama级别的Scenes字段 + sceneMap := make(map[uint]*models.Scene) // 用于去重 + for i := range drama.Episodes { + for j := range drama.Episodes[i].Scenes { + scene := &drama.Episodes[i].Scenes[j] + sceneMap[scene.ID] = scene + } + } + + // 将整合的场景添加到drama.Scenes + drama.Scenes = make([]models.Scene, 0, len(sceneMap)) + for _, scene := range sceneMap { + drama.Scenes = append(drama.Scenes, *scene) + } + + return &drama, nil +} + +func (s *DramaService) ListDramas(query *DramaListQuery) ([]models.Drama, int64, error) { + var dramas []models.Drama + var total int64 + + db := s.db.Model(&models.Drama{}) + + if query.Status != "" { + db = db.Where("status = ?", query.Status) + } + + if query.Genre != "" { + db = db.Where("genre = ?", query.Genre) + } + + if query.Keyword != "" { + db = db.Where("title LIKE ? OR description LIKE ?", "%"+query.Keyword+"%", "%"+query.Keyword+"%") + } + + if err := db.Count(&total).Error; err != nil { + s.log.Errorw("Failed to count dramas", "error", err) + return nil, 0, err + } + + offset := (query.Page - 1) * query.PageSize + err := db.Order("updated_at DESC"). + Offset(offset). + Limit(query.PageSize). + Preload("Episodes.Storyboards", func(db *gorm.DB) *gorm.DB { + return db.Order("storyboards.storyboard_number ASC") + }). + Find(&dramas).Error + + if err != nil { + s.log.Errorw("Failed to list dramas", "error", err) + return nil, 0, err + } + + // 统计每个剧本的每个剧集的时长(基于场景时长之和) + for i := range dramas { + for j := range dramas[i].Episodes { + totalDuration := 0 + for _, scene := range dramas[i].Episodes[j].Storyboards { + totalDuration += scene.Duration + } + // 更新剧集时长(秒转分钟,向上取整) + durationMinutes := (totalDuration + 59) / 60 + dramas[i].Episodes[j].Duration = durationMinutes + } + } + + return dramas, total, nil +} + +func (s *DramaService) UpdateDrama(dramaID string, req *UpdateDramaRequest) (*models.Drama, error) { + var drama models.Drama + if err := s.db.Where("id = ? ", dramaID).First(&drama).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("drama not found") + } + return nil, err + } + + updates := make(map[string]interface{}) + + if req.Title != "" { + updates["title"] = req.Title + } + if req.Description != "" { + updates["description"] = req.Description + } + if req.Genre != "" { + updates["genre"] = req.Genre + } + if req.Tags != "" { + updates["tags"] = req.Tags + } + if req.Status != "" { + updates["status"] = req.Status + } + + updates["updated_at"] = time.Now() + + if err := s.db.Model(&drama).Updates(updates).Error; err != nil { + s.log.Errorw("Failed to update drama", "error", err) + return nil, err + } + + s.log.Infow("Drama updated", "drama_id", dramaID) + return &drama, nil +} + +func (s *DramaService) DeleteDrama(dramaID string) error { + result := s.db.Where("id = ? ", dramaID).Delete(&models.Drama{}) + + if result.Error != nil { + s.log.Errorw("Failed to delete drama", "error", result.Error) + return result.Error + } + + if result.RowsAffected == 0 { + return errors.New("drama not found") + } + + s.log.Infow("Drama deleted", "drama_id", dramaID) + return nil +} + +func (s *DramaService) GetDramaStats() (map[string]interface{}, error) { + var total int64 + var byStatus []struct { + Status string + Count int64 + } + + if err := s.db.Model(&models.Drama{}).Count(&total).Error; err != nil { + return nil, err + } + + if err := s.db.Model(&models.Drama{}). + Select("status, count(*) as count"). + Group("status"). + Scan(&byStatus).Error; err != nil { + return nil, err + } + + stats := map[string]interface{}{ + "total": total, + "by_status": byStatus, + } + + return stats, nil +} + +type SaveOutlineRequest struct { + Title string `json:"title" binding:"required"` + Summary string `json:"summary" binding:"required"` + Genre string `json:"genre"` + Tags []string `json:"tags"` +} + +type SaveCharactersRequest struct { + Characters []models.Character `json:"characters" binding:"required"` + EpisodeID *uint `json:"episode_id"` // 可选:如果提供则关联到指定章节 +} + +type SaveProgressRequest struct { + CurrentStep string `json:"current_step" binding:"required"` + StepData map[string]interface{} `json:"step_data"` +} + +type SaveEpisodesRequest struct { + Episodes []models.Episode `json:"episodes" binding:"required"` +} + +func (s *DramaService) SaveOutline(dramaID string, req *SaveOutlineRequest) error { + var drama models.Drama + if err := s.db.Where("id = ? ", dramaID).First(&drama).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("drama not found") + } + return err + } + + updates := map[string]interface{}{ + "title": req.Title, + "description": req.Summary, + "updated_at": time.Now(), + } + + if req.Genre != "" { + updates["genre"] = req.Genre + } + + if len(req.Tags) > 0 { + tagsJSON, err := json.Marshal(req.Tags) + if err != nil { + s.log.Errorw("Failed to marshal tags", "error", err) + return err + } + updates["tags"] = tagsJSON + } + + if err := s.db.Model(&drama).Updates(updates).Error; err != nil { + s.log.Errorw("Failed to save outline", "error", err) + return err + } + + s.log.Infow("Outline saved", "drama_id", dramaID) + return nil +} + +func (s *DramaService) GetCharacters(dramaID string, episodeID *string) ([]models.Character, error) { + var drama models.Drama + if err := s.db.Where("id = ? ", dramaID).First(&drama).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("drama not found") + } + return nil, err + } + + var characters []models.Character + + // 如果指定了episodeID,只获取该章节关联的角色 + if episodeID != nil { + var episode models.Episode + if err := s.db.Preload("Characters").Where("id = ? AND drama_id = ?", *episodeID, dramaID).First(&episode).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("episode not found") + } + return nil, err + } + characters = episode.Characters + } else { + // 如果没有指定episodeID,获取项目的所有角色 + if err := s.db.Where("drama_id = ?", dramaID).Find(&characters).Error; err != nil { + s.log.Errorw("Failed to get characters", "error", err) + return nil, err + } + } + + // 查询每个角色的图片生成任务状态 + for i := range characters { + // 查询该角色最新的图片生成任务 + var imageGen models.ImageGeneration + err := s.db.Where("character_id = ?", characters[i].ID). + Order("created_at DESC"). + First(&imageGen).Error + + if err == nil { + // 如果有进行中的任务,填充状态信息 + if imageGen.Status == models.ImageStatusPending || imageGen.Status == models.ImageStatusProcessing { + statusStr := string(imageGen.Status) + characters[i].ImageGenerationStatus = &statusStr + } else if imageGen.Status == models.ImageStatusFailed { + statusStr := "failed" + characters[i].ImageGenerationStatus = &statusStr + if imageGen.ErrorMsg != nil { + characters[i].ImageGenerationError = imageGen.ErrorMsg + } + } + } + } + + return characters, nil +} + +func (s *DramaService) SaveCharacters(dramaID string, req *SaveCharactersRequest) error { + // 转换dramaID + id, err := strconv.ParseUint(dramaID, 10, 32) + if err != nil { + return fmt.Errorf("invalid drama ID") + } + dramaIDUint := uint(id) + + var drama models.Drama + if err := s.db.Where("id = ? ", dramaIDUint).First(&drama).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("drama not found") + } + return err + } + + // 如果指定了EpisodeID,验证章节存在性 + if req.EpisodeID != nil { + var episode models.Episode + if err := s.db.Where("id = ? AND drama_id = ?", *req.EpisodeID, dramaIDUint).First(&episode).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("episode not found") + } + return err + } + } + + // 获取该项目已存在的所有角色 + var existingCharacters []models.Character + if err := s.db.Where("drama_id = ?", dramaIDUint).Find(&existingCharacters).Error; err != nil { + s.log.Errorw("Failed to get existing characters", "error", err) + return err + } + + // 创建角色名称到角色的映射 + existingCharMap := make(map[string]*models.Character) + for i := range existingCharacters { + existingCharMap[existingCharacters[i].Name] = &existingCharacters[i] + } + + // 收集需要关联到章节的角色ID + var characterIDs []uint + + // 创建新角色或复用已有角色 + for _, char := range req.Characters { + if existingChar, exists := existingCharMap[char.Name]; exists { + // 角色已存在,直接复用 + s.log.Infow("Character already exists, reusing", "name", char.Name, "character_id", existingChar.ID) + characterIDs = append(characterIDs, existingChar.ID) + continue + } + + // 角色不存在,创建新角色 + character := models.Character{ + DramaID: dramaIDUint, + Name: char.Name, + Role: char.Role, + Description: char.Description, + Personality: char.Personality, + Appearance: char.Appearance, + } + + if err := s.db.Create(&character).Error; err != nil { + s.log.Errorw("Failed to create character", "error", err, "name", char.Name) + continue + } + + s.log.Infow("New character created", "character_id", character.ID, "name", char.Name) + characterIDs = append(characterIDs, character.ID) + } + + // 如果指定了EpisodeID,建立角色与章节的关联 + if req.EpisodeID != nil && len(characterIDs) > 0 { + var episode models.Episode + if err := s.db.First(&episode, *req.EpisodeID).Error; err != nil { + return err + } + + // 获取角色对象 + var characters []models.Character + if err := s.db.Where("id IN ?", characterIDs).Find(&characters).Error; err != nil { + s.log.Errorw("Failed to get characters", "error", err) + return err + } + + // 使用GORM的Association API建立多对多关系(会自动去重) + if err := s.db.Model(&episode).Association("Characters").Append(&characters); err != nil { + s.log.Errorw("Failed to associate characters with episode", "error", err) + return err + } + + s.log.Infow("Characters associated with episode", "episode_id", *req.EpisodeID, "character_count", len(characterIDs)) + } + + if err := s.db.Model(&drama).Update("updated_at", time.Now()).Error; err != nil { + s.log.Errorw("Failed to update drama timestamp", "error", err) + } + + s.log.Infow("Characters saved", "drama_id", dramaID, "count", len(req.Characters)) + return nil +} + +func (s *DramaService) SaveEpisodes(dramaID string, req *SaveEpisodesRequest) error { + // 转换dramaID + id, err := strconv.ParseUint(dramaID, 10, 32) + if err != nil { + return fmt.Errorf("invalid drama ID") + } + dramaIDUint := uint(id) + + var drama models.Drama + if err := s.db.Where("id = ? ", dramaIDUint).First(&drama).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("drama not found") + } + return err + } + + // 删除旧剧集 + if err := s.db.Where("drama_id = ?", dramaIDUint).Delete(&models.Episode{}).Error; err != nil { + s.log.Errorw("Failed to delete old episodes", "error", err) + return err + } + + // 创建新剧集(不包含场景,场景由后续步骤生成) + for _, ep := range req.Episodes { + episode := models.Episode{ + DramaID: dramaIDUint, + EpisodeNum: ep.EpisodeNum, + Title: ep.Title, + Description: ep.Description, + ScriptContent: ep.ScriptContent, + Duration: ep.Duration, + Status: "draft", + } + + if err := s.db.Create(&episode).Error; err != nil { + s.log.Errorw("Failed to create episode", "error", err, "episode", ep.EpisodeNum) + continue + } + } + + if err := s.db.Model(&drama).Update("updated_at", time.Now()).Error; err != nil { + s.log.Errorw("Failed to update drama timestamp", "error", err) + } + + s.log.Infow("Episodes saved", "drama_id", dramaID, "count", len(req.Episodes)) + return nil +} + +func (s *DramaService) SaveProgress(dramaID string, req *SaveProgressRequest) error { + var drama models.Drama + if err := s.db.Where("id = ? ", dramaID).First(&drama).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("drama not found") + } + return err + } + + // 构建metadata对象 + metadata := make(map[string]interface{}) + + // 保留现有metadata + if drama.Metadata != nil { + if err := json.Unmarshal(drama.Metadata, &metadata); err != nil { + s.log.Warnw("Failed to unmarshal existing metadata", "error", err) + } + } + + // 更新progress信息 + metadata["current_step"] = req.CurrentStep + if req.StepData != nil { + metadata["step_data"] = req.StepData + } + + // 序列化metadata + metadataJSON, err := json.Marshal(metadata) + if err != nil { + s.log.Errorw("Failed to marshal metadata", "error", err) + return err + } + + updates := map[string]interface{}{ + "metadata": metadataJSON, + "updated_at": time.Now(), + } + + if err := s.db.Model(&drama).Updates(updates).Error; err != nil { + s.log.Errorw("Failed to save progress", "error", err) + return err + } + + s.log.Infow("Progress saved", "drama_id", dramaID, "step", req.CurrentStep) + return nil +} diff --git a/application/services/frame_prompt_service.go b/application/services/frame_prompt_service.go new file mode 100644 index 0000000..fe384ce --- /dev/null +++ b/application/services/frame_prompt_service.go @@ -0,0 +1,428 @@ +package services + +import ( + "fmt" + "strings" + + "github.com/drama-generator/backend/domain/models" + "github.com/drama-generator/backend/pkg/logger" + "gorm.io/gorm" +) + +// FramePromptService 处理帧提示词生成 +type FramePromptService struct { + db *gorm.DB + aiService *AIService + log *logger.Logger +} + +// NewFramePromptService 创建帧提示词服务 +func NewFramePromptService(db *gorm.DB, log *logger.Logger) *FramePromptService { + return &FramePromptService{ + db: db, + aiService: NewAIService(db, log), + log: log, + } +} + +// FrameType 帧类型 +type FrameType string + +const ( + FrameTypeFirst FrameType = "first" // 首帧 + FrameTypeKey FrameType = "key" // 关键帧 + FrameTypeLast FrameType = "last" // 尾帧 + FrameTypePanel FrameType = "panel" // 分镜板(3格组合) + FrameTypeAction FrameType = "action" // 动作序列(5格) +) + +// GenerateFramePromptRequest 生成帧提示词请求 +type GenerateFramePromptRequest struct { + StoryboardID string `json:"storyboard_id"` + FrameType FrameType `json:"frame_type"` + // 可选参数 + PanelCount int `json:"panel_count,omitempty"` // 分镜板格数,默认3 +} + +// FramePromptResponse 帧提示词响应 +type FramePromptResponse struct { + FrameType FrameType `json:"frame_type"` + SingleFrame *SingleFramePrompt `json:"single_frame,omitempty"` // 单帧提示词 + MultiFrame *MultiFramePrompt `json:"multi_frame,omitempty"` // 多帧提示词 +} + +// SingleFramePrompt 单帧提示词 +type SingleFramePrompt struct { + Prompt string `json:"prompt"` + Description string `json:"description"` +} + +// MultiFramePrompt 多帧提示词 +type MultiFramePrompt struct { + Layout string `json:"layout"` // horizontal_3, grid_2x2 等 + Frames []SingleFramePrompt `json:"frames"` +} + +// GenerateFramePrompt 生成指定类型的帧提示词并保存到frame_prompts表 +func (s *FramePromptService) GenerateFramePrompt(req GenerateFramePromptRequest) (*FramePromptResponse, error) { + // 查询分镜信息 + var storyboard models.Storyboard + if err := s.db.Preload("Characters").First(&storyboard, req.StoryboardID).Error; err != nil { + return nil, fmt.Errorf("storyboard not found: %w", err) + } + + // 获取场景信息 + var scene *models.Scene + if storyboard.SceneID != nil { + scene = &models.Scene{} + if err := s.db.First(scene, *storyboard.SceneID).Error; err != nil { + s.log.Warnw("Scene not found", "scene_id", *storyboard.SceneID) + scene = nil + } + } + + response := &FramePromptResponse{ + FrameType: req.FrameType, + } + + // 生成提示词 + switch req.FrameType { + case FrameTypeFirst: + response.SingleFrame = s.generateFirstFrame(storyboard, scene) + // 保存单帧提示词 + s.saveFramePrompt(req.StoryboardID, string(req.FrameType), response.SingleFrame.Prompt, response.SingleFrame.Description, "") + case FrameTypeKey: + response.SingleFrame = s.generateKeyFrame(storyboard, scene) + s.saveFramePrompt(req.StoryboardID, string(req.FrameType), response.SingleFrame.Prompt, response.SingleFrame.Description, "") + case FrameTypeLast: + response.SingleFrame = s.generateLastFrame(storyboard, scene) + s.saveFramePrompt(req.StoryboardID, string(req.FrameType), response.SingleFrame.Prompt, response.SingleFrame.Description, "") + case FrameTypePanel: + count := req.PanelCount + if count == 0 { + count = 3 + } + response.MultiFrame = s.generatePanelFrames(storyboard, scene, count) + // 保存多帧提示词(合并为一条记录) + var prompts []string + for _, frame := range response.MultiFrame.Frames { + prompts = append(prompts, frame.Prompt) + } + combinedPrompt := strings.Join(prompts, "\n---\n") + s.saveFramePrompt(req.StoryboardID, string(req.FrameType), combinedPrompt, "分镜板组合提示词", response.MultiFrame.Layout) + case FrameTypeAction: + response.MultiFrame = s.generateActionSequence(storyboard, scene) + var prompts []string + for _, frame := range response.MultiFrame.Frames { + prompts = append(prompts, frame.Prompt) + } + combinedPrompt := strings.Join(prompts, "\n---\n") + s.saveFramePrompt(req.StoryboardID, string(req.FrameType), combinedPrompt, "动作序列组合提示词", response.MultiFrame.Layout) + default: + return nil, fmt.Errorf("unsupported frame type: %s", req.FrameType) + } + + return response, nil +} + +// saveFramePrompt 保存帧提示词到数据库 +func (s *FramePromptService) saveFramePrompt(storyboardID, frameType, prompt, description, layout string) { + framePrompt := models.FramePrompt{ + StoryboardID: uint(mustParseUint(storyboardID)), + FrameType: frameType, + Prompt: prompt, + } + + if description != "" { + framePrompt.Description = &description + } + if layout != "" { + framePrompt.Layout = &layout + } + + // 先删除同类型的旧记录(保持最新) + s.db.Where("storyboard_id = ? AND frame_type = ?", storyboardID, frameType).Delete(&models.FramePrompt{}) + + // 插入新记录 + if err := s.db.Create(&framePrompt).Error; err != nil { + s.log.Warnw("Failed to save frame prompt", "error", err, "storyboard_id", storyboardID, "frame_type", frameType) + } +} + +// mustParseUint 辅助函数 +func mustParseUint(s string) uint64 { + var result uint64 + fmt.Sscanf(s, "%d", &result) + return result +} + +// generateFirstFrame 生成首帧提示词 +func (s *FramePromptService) generateFirstFrame(sb models.Storyboard, scene *models.Scene) *SingleFramePrompt { + // 构建上下文信息 + contextInfo := s.buildStoryboardContext(sb, scene) + + // 构建AI提示词 + systemPrompt := `你是一个专业的图像生成提示词专家。请根据提供的镜头信息,生成适合用于AI图像生成的提示词。 + +重要:这是镜头的首帧 - 一个完全静态的画面,展示动作发生之前的初始状态。 + +要求: +1. 直接输出提示词,不要任何解释说明 +2. 可以使用中文或英文,用逗号分隔关键词 +3. 只描述静态视觉元素:场景环境、角色姿态、表情、氛围、光线 +4. 不要包含任何动作动词(如:猛然、弹起、坐直、抓住等) +5. 描述角色处于动作发生前的状态(如:躺在床上、站立、坐着等静态姿态) +6. 适合动画风格(anime style) + +示例格式: +Anime style, 城市公寓卧室, 凌晨, 昏暗房间, 床上, 年轻男子躺着, 表情平静, 闭眼睡眠, 柔和光线, 静谧氛围, 中景, 平视` + + userPrompt := fmt.Sprintf(`镜头信息: +%s + +请直接生成首帧的图像提示词,不要任何解释:`, contextInfo) + + // 调用AI生成 + prompt, err := s.aiService.GenerateText(userPrompt, systemPrompt) + if err != nil { + s.log.Warnw("AI generation failed, using fallback", "error", err) + // 降级方案:使用简单拼接 + prompt = s.buildFallbackPrompt(sb, scene, "first frame, static shot") + } + + // 如果AI返回空字符串,使用降级方案 + prompt = strings.TrimSpace(prompt) + if prompt == "" { + s.log.Warnw("AI returned empty prompt, using fallback", "storyboard_id", sb.ID) + prompt = s.buildFallbackPrompt(sb, scene, "first frame, static shot") + } + + return &SingleFramePrompt{ + Prompt: prompt, + Description: "镜头开始的静态画面,展示初始状态", + } +} + +// generateKeyFrame 生成关键帧提示词 +func (s *FramePromptService) generateKeyFrame(sb models.Storyboard, scene *models.Scene) *SingleFramePrompt { + // 构建上下文信息 + contextInfo := s.buildStoryboardContext(sb, scene) + + // 构建AI提示词 + systemPrompt := `你是一个专业的图像生成提示词专家。请根据提供的镜头信息,生成适合用于AI图像生成的提示词。 + +重要:这是镜头的关键帧 - 捕捉动作最激烈、最精彩的瞬间。 + +要求: +1. 直接输出提示词,不要任何解释说明 +2. 可以使用中文或英文,用逗号分隔关键词 +3. 重点描述动作的高潮瞬间:身体姿态、运动轨迹、力量感 +4. 包含动态元素:动作模糊、速度线、冲击感 +5. 强调表情和情绪的极致状态 +6. 适合动画风格(anime style) + +示例格式: +Anime style, 城市街道, 白天, 男子全力冲刺, 身体前倾, 动作模糊, 速度线, 汗水飞溅, 表情坚毅, 紧张氛围, 动态镜头, 中景` + + userPrompt := fmt.Sprintf(`镜头信息: +%s + +请直接生成关键帧的图像提示词,不要任何解释:`, contextInfo) + + // 调用AI生成 + prompt, err := s.aiService.GenerateText(userPrompt, systemPrompt) + if err != nil { + s.log.Warnw("AI generation failed, using fallback", "error", err) + prompt = s.buildFallbackPrompt(sb, scene, "key frame, dynamic action") + } + + // 如果AI返回空字符串,使用降级方案 + prompt = strings.TrimSpace(prompt) + if prompt == "" { + s.log.Warnw("AI returned empty prompt, using fallback", "storyboard_id", sb.ID) + prompt = s.buildFallbackPrompt(sb, scene, "key frame, dynamic action") + } + + return &SingleFramePrompt{ + Prompt: prompt, + Description: "动作高潮瞬间,展示关键动作", + } +} + +// generateLastFrame 生成尾帧提示词 +func (s *FramePromptService) generateLastFrame(sb models.Storyboard, scene *models.Scene) *SingleFramePrompt { + // 构建上下文信息 + contextInfo := s.buildStoryboardContext(sb, scene) + + // 构建AI提示词 + systemPrompt := `你是一个专业的图像生成提示词专家。请根据提供的镜头信息,生成适合用于AI图像生成的提示词。 + +重要:这是镜头的尾帧 - 一个静态画面,展示动作结束后的最终状态和结果。 + +要求: +1. 直接输出提示词,不要任何解释说明 +2. 可以使用中文或英文,用逗号分隔关键词 +3. 只描述静态的最终状态:角色姿态、表情、环境变化 +4. 不要包含动作过程,只展示动作的结果和余韵 +5. 强调情绪的余波和氛围的沉淀 +6. 适合动画风格(anime style) + +示例格式: +Anime style, 房间内, 黄昏, 男子坐在椅子上, 身体放松, 表情疲惫, 长出一口气, 汗水滴落, 平静氛围, 静态镜头, 中景` + + userPrompt := fmt.Sprintf(`镜头信息: +%s + +请直接生成尾帧的图像提示词,不要任何解释:`, contextInfo) + + // 调用AI生成 + prompt, err := s.aiService.GenerateText(userPrompt, systemPrompt) + if err != nil { + s.log.Warnw("AI generation failed, using fallback", "error", err) + prompt = s.buildFallbackPrompt(sb, scene, "last frame, final state") + } + + // 如果AI返回空字符串,使用降级方案 + prompt = strings.TrimSpace(prompt) + if prompt == "" { + s.log.Warnw("AI returned empty prompt, using fallback", "storyboard_id", sb.ID) + prompt = s.buildFallbackPrompt(sb, scene, "last frame, final state") + } + + return &SingleFramePrompt{ + Prompt: prompt, + Description: "镜头结束画面,展示最终状态和结果", + } +} + +// generatePanelFrames 生成分镜板(多格组合) +func (s *FramePromptService) generatePanelFrames(sb models.Storyboard, scene *models.Scene, count int) *MultiFramePrompt { + layout := fmt.Sprintf("horizontal_%d", count) + + frames := make([]SingleFramePrompt, count) + + // 固定生成:首帧 -> 关键帧 -> 尾帧 + if count == 3 { + frames[0] = *s.generateFirstFrame(sb, scene) + frames[0].Description = "第1格:初始状态" + + frames[1] = *s.generateKeyFrame(sb, scene) + frames[1].Description = "第2格:动作高潮" + + frames[2] = *s.generateLastFrame(sb, scene) + frames[2].Description = "第3格:最终状态" + } else if count == 4 { + // 4格:首帧 -> 中间帧1 -> 中间帧2 -> 尾帧 + frames[0] = *s.generateFirstFrame(sb, scene) + frames[1] = *s.generateKeyFrame(sb, scene) + frames[2] = *s.generateKeyFrame(sb, scene) + frames[3] = *s.generateLastFrame(sb, scene) + } + + return &MultiFramePrompt{ + Layout: layout, + Frames: frames, + } +} + +// generateActionSequence 生成动作序列(5-8格) +func (s *FramePromptService) generateActionSequence(sb models.Storyboard, scene *models.Scene) *MultiFramePrompt { + // 将动作分解为5个步骤 + frames := make([]SingleFramePrompt, 5) + + // 简化实现:均匀分布从首帧到尾帧 + frames[0] = *s.generateFirstFrame(sb, scene) + frames[1] = *s.generateKeyFrame(sb, scene) + frames[2] = *s.generateKeyFrame(sb, scene) + frames[3] = *s.generateKeyFrame(sb, scene) + frames[4] = *s.generateLastFrame(sb, scene) + + return &MultiFramePrompt{ + Layout: "horizontal_5", + Frames: frames, + } +} + +// buildStoryboardContext 构建镜头上下文信息 +func (s *FramePromptService) buildStoryboardContext(sb models.Storyboard, scene *models.Scene) string { + var parts []string + + // 镜头描述(最重要) + if sb.Description != nil && *sb.Description != "" { + parts = append(parts, fmt.Sprintf("镜头描述: %s", *sb.Description)) + } + + // 场景信息 + if scene != nil { + parts = append(parts, fmt.Sprintf("场景: %s, %s", scene.Location, scene.Time)) + } else if sb.Location != nil && sb.Time != nil { + parts = append(parts, fmt.Sprintf("场景: %s, %s", *sb.Location, *sb.Time)) + } + + // 角色 + if len(sb.Characters) > 0 { + var charNames []string + for _, char := range sb.Characters { + charNames = append(charNames, char.Name) + } + parts = append(parts, fmt.Sprintf("角色: %s", strings.Join(charNames, ", "))) + } + + // 动作 + if sb.Action != nil && *sb.Action != "" { + parts = append(parts, fmt.Sprintf("动作: %s", *sb.Action)) + } + + // 结果 + if sb.Result != nil && *sb.Result != "" { + parts = append(parts, fmt.Sprintf("结果: %s", *sb.Result)) + } + + // 对白 + if sb.Dialogue != nil && *sb.Dialogue != "" { + parts = append(parts, fmt.Sprintf("对白: %s", *sb.Dialogue)) + } + + // 氛围 + if sb.Atmosphere != nil && *sb.Atmosphere != "" { + parts = append(parts, fmt.Sprintf("氛围: %s", *sb.Atmosphere)) + } + + // 镜头参数 + if sb.ShotType != nil { + parts = append(parts, fmt.Sprintf("景别: %s", *sb.ShotType)) + } + if sb.Angle != nil { + parts = append(parts, fmt.Sprintf("角度: %s", *sb.Angle)) + } + if sb.Movement != nil { + parts = append(parts, fmt.Sprintf("运镜: %s", *sb.Movement)) + } + + return strings.Join(parts, "\n") +} + +// buildFallbackPrompt 构建降级提示词(AI失败时使用) +func (s *FramePromptService) buildFallbackPrompt(sb models.Storyboard, scene *models.Scene, suffix string) string { + var parts []string + + // 场景 + if scene != nil { + parts = append(parts, fmt.Sprintf("%s, %s", scene.Location, scene.Time)) + } + + // 角色 + if len(sb.Characters) > 0 { + for _, char := range sb.Characters { + parts = append(parts, char.Name) + } + } + + // 氛围 + if sb.Atmosphere != nil { + parts = append(parts, *sb.Atmosphere) + } + + parts = append(parts, "anime style", suffix) + return strings.Join(parts, ", ") +} diff --git a/application/services/image_generation_service.go b/application/services/image_generation_service.go new file mode 100644 index 0000000..4f4d8d9 --- /dev/null +++ b/application/services/image_generation_service.go @@ -0,0 +1,909 @@ +package services + +import ( + "encoding/json" + "fmt" + "strconv" + "time" + + models "github.com/drama-generator/backend/domain/models" + "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 + log *logger.Logger +} + +func NewImageGenerationService(db *gorm.DB, transferService *ResourceTransferService, log *logger.Logger) *ImageGenerationService { + return &ImageGenerationService{ + db: db, + aiService: NewAIService(db, log), + transferService: transferService, + 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() + 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", 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", 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", 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] + } + + switch provider { + case "openai", "dalle": + return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model), nil + case "stable_diffusion", "sd": + return image.NewStableDiffusionClient(config.BaseURL, config.APIKey, model), nil + default: + return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model), 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] + } + + switch provider { + case "openai", "dalle": + return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model), nil + case "stable_diffusion", "sd": + return image.NewStableDiffusionClient(config.BaseURL, config.APIKey, model), nil + default: + return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model), 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) + + messages := []ai.ChatMessage{ + {Role: "user", Content: prompt}, + } + + resp, err := client.ChatCompletion(messages, 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) + } + + if len(resp.Choices) == 0 { + return nil, fmt.Errorf("AI未返回有效响应") + } + + response := resp.Choices[0].Message.Content + s.log.Infow("AI backgrounds extraction response", "length", len(response)) + + // 解析JSON响应 + var result struct { + Backgrounds []BackgroundInfo `json:"backgrounds"` + } + if err := utils.SafeParseAIJSON(response, &result); err != nil { + s.log.Errorw("Failed to parse AI response", "error", err, "response", response[:minInt(500, len(response))]) + return nil, fmt.Errorf("解析AI响应失败: %w", err) + } + + s.log.Infow("Extracted backgrounds from script", + "drama_id", dramaID, + "backgrounds_count", len(result.Backgrounds)) + + return result.Backgrounds, nil +} + +// extractBackgroundsWithAI 使用AI智能分析场景并提取唯一背景 +func (s *ImageGenerationService) extractBackgroundsWithAI(storyboards []models.Storyboard) ([]BackgroundInfo, error) { + if len(storyboards) == 0 { + return []BackgroundInfo{}, nil + } + + // 构建场景列表文本,使用SceneNumber而不是索引 + var scenesText string + for _, storyboard := range storyboards { + location := "" + if storyboard.Location != nil { + location = *storyboard.Location + } + time := "" + if storyboard.Time != nil { + time = *storyboard.Time + } + action := "" + if storyboard.Action != nil { + action = *storyboard.Action + } + description := "" + if storyboard.Description != nil { + description = *storyboard.Description + } + + scenesText += fmt.Sprintf("镜头%d:\n地点: %s\n时间: %s\n动作: %s\n描述: %s\n\n", + storyboard.StoryboardNumber, location, time, action, description) + } + + // 构建AI提示词 + prompt := fmt.Sprintf(`【任务】分析以下分镜头场景,提取出所有需要生成的唯一背景,并返回每个背景对应的场景编号。 + +【分镜头列表】 +%s + +【要求】 +1. 合并相同或相似的场景背景(地点和时间相同或相近) +2. 为每个唯一背景生成**中文**图片生成提示词(Prompt) +3. Prompt要求: + - **必须使用中文**,不能包含英文字符 + - 详细描述场景、时间、氛围、风格 + - 适合AI图片生成模型使用 + - 风格统一为:电影感、细节丰富、动漫风格、高质量 +4. **重要**:必须返回使用该背景的场景编号数组(scene_numbers) +5. location、time和prompt字段都使用中文 +6. 每个场景都必须分配到某个背景,确保所有场景编号都被包含 + +【输出JSON格式】 +{ + "backgrounds": [ + { + "location": "地点名称(中文)", + "time": "时间描述(中文)", + "prompt": "一个电影感的动漫风格背景,展现[地点描述]在[时间]的场景。画面呈现[细节描述]。风格:细节丰富,高质量,氛围光照。情绪:[情绪描述]。", + "scene_numbers": [1, 2, 3] + } + ] +} + +【示例】 +正确示例: +{ + "backgrounds": [ + { + "location": "维修店", + "time": "深夜", + "prompt": "一个电影感的动漫风格背景,展现凌乱的维修店内部在深夜的场景。昏暗的灯光下,工作台上散落着各种工具和零件,墙上挂着油污的海报。风格:细节丰富,高质量,昏暗氛围。情绪:孤独、工业感。", + "scene_numbers": [1, 5, 6, 10, 15] + }, + { + "location": "城市全景", + "time": "深夜·酸雨", + "prompt": "一个电影感的动漫风格背景,展现沿海城市全景在深夜酸雨中的场景。霓虹灯在雨中模糊,高楼大厦笼罩在灰绿色的雨幕中,街道反射着五颜六色的光。风格:细节丰富,高质量,赛博朋克氛围。情绪:压抑、科幻、末世感。", + "scene_numbers": [2, 7] + } + ] +} + +请严格按照JSON格式输出,确保: +1. prompt字段使用中文 +2. scene_numbers包含所有使用该背景的场景编号 +3. 所有场景都被分配到某个背景`, scenesText) + + // 调用AI服务 + text, err := s.aiService.GenerateText(prompt, "") + if err != nil { + return nil, fmt.Errorf("AI analysis failed: %w", err) + } + + // 解析AI返回的JSON + var result struct { + Scenes []struct { + Location string `json:"location"` + Time string `json:"time"` + Prompt string `json:"prompt"` + StoryboardNumber []int `json:"storyboard_number"` + } `json:"backgrounds"` + } + + if err := utils.SafeParseAIJSON(text, &result); err != nil { + return nil, fmt.Errorf("failed to parse AI response: %w", err) + } + + // 构建场景编号到场景ID的映射 + storyboardNumberToID := make(map[int]uint) + for _, scene := range storyboards { + storyboardNumberToID[scene.StoryboardNumber] = scene.ID + } + + // 转换为BackgroundInfo + var backgrounds []BackgroundInfo + for _, bg := range result.Scenes { + // 将场景编号转换为场景ID + var sceneIDs []uint + for _, storyboardNum := range bg.StoryboardNumber { + if storyboardID, ok := storyboardNumberToID[storyboardNum]; ok { + sceneIDs = append(sceneIDs, storyboardID) + } + } + + backgrounds = append(backgrounds, BackgroundInfo{ + Location: bg.Location, + Time: bg.Time, + Prompt: bg.Prompt, + StoryboardNumbers: bg.StoryboardNumber, + SceneIDs: sceneIDs, + StoryboardCount: len(sceneIDs), + }) + } + + s.log.Infow("AI extracted backgrounds", + "total_scenes", len(storyboards), + "extracted_backgrounds", len(backgrounds)) + + return backgrounds, nil +} + +// extractUniqueBackgrounds 从分镜头中提取唯一背景(代码逻辑,作为AI提取的备份) +func (s *ImageGenerationService) extractUniqueBackgrounds(scenes []models.Storyboard) []BackgroundInfo { + backgroundMap := make(map[string]*BackgroundInfo) + + for _, scene := range scenes { + if scene.Location == nil || scene.Time == nil { + continue + } + + // 使用 location + time 作为唯一标识 + key := *scene.Location + "|" + *scene.Time + + if bg, exists := backgroundMap[key]; exists { + // 背景已存在,添加scene ID + bg.SceneIDs = append(bg.SceneIDs, scene.ID) + bg.StoryboardCount++ + } else { + // 新背景 - 使用ImagePrompt构建背景提示词 + prompt := "" + if scene.ImagePrompt != nil { + prompt = *scene.ImagePrompt + } + backgroundMap[key] = &BackgroundInfo{ + Location: *scene.Location, + Time: *scene.Time, + Prompt: prompt, + SceneIDs: []uint{scene.ID}, + StoryboardCount: 1, + } + } + } + + // 转换为切片 + var backgrounds []BackgroundInfo + for _, bg := range backgroundMap { + backgrounds = append(backgrounds, *bg) + } + + return backgrounds +} diff --git a/application/services/resource_transfer_service.go b/application/services/resource_transfer_service.go new file mode 100644 index 0000000..401d019 --- /dev/null +++ b/application/services/resource_transfer_service.go @@ -0,0 +1,21 @@ +package services + +import ( + "github.com/drama-generator/backend/pkg/logger" + "gorm.io/gorm" +) + +type ResourceTransferService struct { + db *gorm.DB + log *logger.Logger +} + +func NewResourceTransferService(db *gorm.DB, log *logger.Logger) *ResourceTransferService { + return &ResourceTransferService{ + db: db, + log: log, + } +} + +// ResourceTransferService 现在只保留基本结构,MinIO相关功能已移除 +// 如需资源转存功能,请使用本地存储 diff --git a/application/services/script_generation_service.go b/application/services/script_generation_service.go new file mode 100644 index 0000000..8b45b19 --- /dev/null +++ b/application/services/script_generation_service.go @@ -0,0 +1,511 @@ +package services + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/drama-generator/backend/domain/models" + "github.com/drama-generator/backend/pkg/ai" + "github.com/drama-generator/backend/pkg/logger" + "github.com/drama-generator/backend/pkg/utils" + "gorm.io/gorm" +) + +type ScriptGenerationService struct { + db *gorm.DB + aiService *AIService + log *logger.Logger +} + +func NewScriptGenerationService(db *gorm.DB, log *logger.Logger) *ScriptGenerationService { + return &ScriptGenerationService{ + db: db, + aiService: NewAIService(db, log), + log: log, + } +} + +type GenerateOutlineRequest struct { + DramaID string `json:"drama_id" binding:"required"` + Theme string `json:"theme" binding:"required,min=2,max=500"` + Genre string `json:"genre"` + Style string `json:"style"` + Length int `json:"length"` + Temperature float64 `json:"temperature"` +} + +type GenerateCharactersRequest struct { + DramaID string `json:"drama_id" binding:"required"` + Outline string `json:"outline"` + Count int `json:"count"` + Temperature float64 `json:"temperature"` +} + +type GenerateEpisodesRequest struct { + DramaID string `json:"drama_id" binding:"required"` + Outline string `json:"outline"` + EpisodeCount int `json:"episode_count" binding:"required,min=1,max=100"` + Temperature float64 `json:"temperature"` +} + +type OutlineResult struct { + Title string `json:"title"` + Summary string `json:"summary"` + Genre string `json:"genre"` + Tags []string `json:"tags"` + Characters []CharacterOutline `json:"characters"` + Episodes []EpisodeOutline `json:"episodes"` + KeyScenes []string `json:"key_scenes"` +} + +type CharacterOutline struct { + Name string `json:"name"` + Role string `json:"role"` + Description string `json:"description"` + Personality string `json:"personality"` + Appearance string `json:"appearance"` +} + +type EpisodeOutline struct { + EpisodeNumber int `json:"episode_number"` + Title string `json:"title"` + Summary string `json:"summary"` + Scenes []string `json:"scenes"` + Duration int `json:"duration"` +} + +func (s *ScriptGenerationService) GenerateOutline(req *GenerateOutlineRequest) (*OutlineResult, error) { + var drama models.Drama + if err := s.db.Where("id = ?", req.DramaID).First(&drama).Error; err != nil { + return nil, fmt.Errorf("drama not found") + } + + systemPrompt := `你是专业短剧编剧。根据主题和剧集数量,创作完整的短剧大纲,规划好每一集的剧情走向。 + +要求: +1. 剧情紧凑,矛盾冲突强烈,节奏快 +2. 必须规划好每一集的核心剧情 +3. 每集有明确冲突和转折点,集与集之间有连贯性和悬念 + +**重要:必须输出完整有效的JSON,确保所有字段完整,特别是episodes数组必须完整闭合!** + +JSON格式(紧凑,summary和episodes字段必须完整): +{"title":"剧名","summary":"200-250字剧情概述,包含故事背景、主要矛盾、核心冲突、完整走向","genre":"类型","tags":["标签1","标签2","标签3"],"episodes":[{"episode_number":1,"title":"标题","summary":"80字剧情概要"},{"episode_number":2,"title":"标题","summary":"80字剧情概要"}],"key_scenes":["场景1","场景2","场景3"]} + +关键要求: +- summary控制在200-250字,简洁清晰 +- episodes必须生成用户要求的完整集数 +- 每集summary控制在80字左右 +- 确保JSON完整闭合,不要截断 +- 不要添加任何JSON外的文字说明` + + userPrompt := fmt.Sprintf(`请为以下主题创作短剧大纲: + +主题:%s`, req.Theme) + + if req.Genre != "" { + userPrompt += fmt.Sprintf("\n类型偏好:%s", req.Genre) + } + + if req.Style != "" { + userPrompt += fmt.Sprintf("\n风格要求:%s", req.Style) + } + + length := req.Length + if length == 0 { + length = 5 + } + userPrompt += fmt.Sprintf("\n剧集数量:%d集", length) + userPrompt += fmt.Sprintf("\n\n**重要:必须在episodes数组中规划完整的%d集剧情,每集都要有明确的故事内容!**", length) + + temperature := req.Temperature + if temperature == 0 { + temperature = 0.8 + } + + // 调整token限制:基础2000 + 每集约150 tokens(包含80-100字概要) + maxTokens := 2000 + (length * 150) + if maxTokens > 8000 { + maxTokens = 8000 + } + + s.log.Infow("Generating outline with episodes", + "episode_count", length, + "max_tokens", maxTokens) + + text, err := s.aiService.GenerateText( + userPrompt, + systemPrompt, + ai.WithTemperature(temperature), + ai.WithMaxTokens(maxTokens), + ) + + if err != nil { + s.log.Errorw("Failed to generate outline", "error", err) + return nil, fmt.Errorf("生成失败: %w", err) + } + + s.log.Infow("AI response received", "length", len(text), "preview", text[:minInt(200, len(text))]) + + var result OutlineResult + if err := utils.SafeParseAIJSON(text, &result); err != nil { + s.log.Errorw("Failed to parse outline JSON", "error", err, "raw_response", text[:minInt(500, len(text))]) + return nil, fmt.Errorf("解析 AI 返回结果失败: %w", err) + } + + // 将Tags转换为JSON格式存储 + tagsJSON, err := json.Marshal(result.Tags) + if err != nil { + s.log.Errorw("Failed to marshal tags", "error", err) + tagsJSON = []byte("[]") + } + + if err := s.db.Model(&drama).Updates(map[string]interface{}{ + "title": result.Title, + "description": result.Summary, + "genre": result.Genre, + "tags": tagsJSON, + }).Error; err != nil { + s.log.Errorw("Failed to update drama", "error", err) + } + + s.log.Infow("Outline generated", "drama_id", req.DramaID) + return &result, nil +} + +func (s *ScriptGenerationService) GenerateCharacters(req *GenerateCharactersRequest) ([]models.Character, error) { + var drama models.Drama + if err := s.db.Where("id = ? ", req.DramaID).First(&drama).Error; err != nil { + return nil, fmt.Errorf("drama not found") + } + + count := req.Count + if count == 0 { + count = 5 + } + + systemPrompt := `你是一个专业的角色设计师,擅长创作立体丰富的剧中角色。 + +你的任务是根据提供的剧本大纲,创作符合故事需求的角色设定。 + +要求: +1. 角色必须服务于大纲中的故事情节和冲突 +2. 角色性格鲜明,有辨识度,符合故事类型 +3. 每个角色都有清晰的动机和目标,与大纲中的矛盾冲突相关 +4. 角色之间有合理的关系和联系 +5. 外貌描述必须极其详细,便于AI绘画生成角色形象 +6. 根据大纲的关键场景,合理设置角色数量(通常3-6个主要角色) + +请严格按照以下 JSON 格式输出,不要添加任何其他文字: + +{ + "characters": [ + { + "name": "角色名", + "role": "主角/重要配角/配角", + "description": "角色背景和简介(200-300字,包括:出身背景、成长经历、核心动机、与其他角色的关系、在故事中的作用)", + "personality": "性格特点(详细描述,100-150字,包括:主要性格特征、行为习惯、价值观、优点缺点、情绪表达方式、对待他人的态度等)", + "appearance": "外貌描述(极其详细,150-200字,必须包括:确切年龄、精确身高、体型身材、肤色质感、发型发色发长、眼睛颜色形状、面部特征(如眉毛、鼻子、嘴唇)、着装风格、服装颜色材质、配饰细节、标志性特征、整体气质风格等,描述要具体到可以直接用于AI绘画)", + "voice_style": "说话风格和语气特点(详细描述,50-80字,包括:语速语调、用词习惯、口头禅、说话时的情绪特征等)" + } + ] +} + +注意: +- 角色数量根据故事复杂度确定,不要过多 +- 每个角色都要与大纲中的故事线有明确关联 +- description、personality、appearance、voice_style都必须详细描述,字数要充足 +- appearance外貌描述是重中之重,必须极其详细具体,要能让AI准确生成角色形象 +- 避免模糊描述,多用具体的视觉特征和细节` + + outlineText := req.Outline + if outlineText == "" { + outlineText = fmt.Sprintf("剧名:%s\n简介:%s\n类型:%s", drama.Title, drama.Description, drama.Genre) + } + + userPrompt := fmt.Sprintf(`剧本大纲: +%s + +请创作 %d 个角色的详细设定。`, outlineText, count) + + temperature := req.Temperature + if temperature == 0 { + temperature = 0.7 + } + + text, err := s.aiService.GenerateText( + userPrompt, + systemPrompt, + ai.WithTemperature(temperature), + ai.WithMaxTokens(3000), + ) + + if err != nil { + s.log.Errorw("Failed to generate characters", "error", err) + return nil, fmt.Errorf("生成失败: %w", err) + } + + s.log.Infow("AI response received", "length", len(text), "preview", text[:minInt(200, len(text))]) + + var result struct { + Characters []struct { + Name string `json:"name"` + Role string `json:"role"` + Description string `json:"description"` + Personality string `json:"personality"` + Appearance string `json:"appearance"` + VoiceStyle string `json:"voice_style"` + } `json:"characters"` + } + + if err := utils.SafeParseAIJSON(text, &result); err != nil { + s.log.Errorw("Failed to parse characters JSON", "error", err, "raw_response", text[:minInt(500, len(text))]) + return nil, fmt.Errorf("解析 AI 返回结果失败: %w", err) + } + + var characters []models.Character + for _, char := range result.Characters { + // 检查角色是否已存在 + var existingChar models.Character + err := s.db.Where("drama_id = ? AND name = ?", req.DramaID, char.Name).First(&existingChar).Error + if err == nil { + // 角色已存在,直接使用已存在的角色,不覆盖 + s.log.Infow("Character already exists, skipping", "drama_id", req.DramaID, "name", char.Name) + characters = append(characters, existingChar) + continue + } + + // 角色不存在,创建新角色 + dramaID, _ := strconv.ParseUint(req.DramaID, 10, 32) + character := models.Character{ + DramaID: uint(dramaID), + Name: char.Name, + Role: &char.Role, + Description: &char.Description, + Personality: &char.Personality, + Appearance: &char.Appearance, + VoiceStyle: &char.VoiceStyle, + } + + if err := s.db.Create(&character).Error; err != nil { + s.log.Errorw("Failed to create character", "error", err) + continue + } + + characters = append(characters, character) + } + + s.log.Infow("Characters generated", "drama_id", req.DramaID, "total_count", len(characters), "new_count", len(characters)) + return characters, nil +} + +func (s *ScriptGenerationService) GenerateEpisodes(req *GenerateEpisodesRequest) ([]models.Episode, error) { + var drama models.Drama + if err := s.db.Where("id = ? ", req.DramaID).First(&drama).Error; err != nil { + return nil, fmt.Errorf("drama not found") + } + + // 获取角色信息 + var characters []models.Character + s.db.Where("drama_id = ?", req.DramaID).Find(&characters) + + var characterList string + if len(characters) > 0 { + characterList = "\n角色设定:\n" + for _, char := range characters { + characterList += fmt.Sprintf("- %s", char.Name) + if char.Role != nil { + characterList += fmt.Sprintf("(%s)", *char.Role) + } + if char.Description != nil { + characterList += fmt.Sprintf(":%s", *char.Description) + } + if char.Personality != nil { + characterList += fmt.Sprintf(" | 性格:%s", *char.Personality) + } + characterList += "\n" + } + } else { + characterList = "\n(注意:尚未设定角色,请根据大纲创作合理的角色出场)\n" + } + + systemPrompt := `你是一个专业的短剧编剧。你擅长根据分集规划创作详细的剧情内容。 + +你的任务是根据大纲中的分集规划,将每一集的概要扩展为详细的剧情叙述。每集约180秒(3分钟),需要充实的内容。 + +工作流程: +1. 大纲中已提供每集的剧情规划(80-100字概要) +2. 你需要将每集概要扩展为400-500字的详细剧情叙述 +3. 严格按照分集规划的数量和走向展开,不能遗漏任何一集 + +详细要求: +1. script_content用400-500字详细叙述,包括: + - 具体场景和环境描写 + - 角色的行动、对话要点、情绪变化 + - 冲突的产生过程和激化细节 + - 关键情节点和转折 + - 为下一集埋下的伏笔 +2. 每集有明确的冲突和转折点 +3. 集与集之间有连贯性和悬念 +4. 充分展现角色性格和关系演变 +5. 内容详实,足以支撑180秒时长 + +JSON格式(紧凑): +{"episodes":[{"episode_number":1,"title":"标题","description":"简短梗概","script_content":"400-500字详细剧情叙述","duration":210}]} + +格式说明: +1. script_content为叙述文,不是场景对话格式 +2. 每集包含开场铺垫、冲突发展、高潮转折、结局悬念 +3. duration根据剧情复杂度设置在150-300秒 + +关键要求: +- 大纲规划了几集就必须生成几集 +- 严格按照分集规划的故事线展开 +- 每一集都要有完整的400-500字详细内容 +- 绝对不能遗漏任何一集` + + outlineText := req.Outline + if outlineText == "" { + outlineText = fmt.Sprintf("剧名:%s\n简介:%s\n类型:%s", drama.Title, drama.Description, drama.Genre) + } + + userPrompt := fmt.Sprintf(`剧本大纲: +%s +%s +请基于以上大纲和角色,创作 %d 集的详细剧本。 + +**重要要求:** +- 必须生成完整的 %d 集,从第1集到第%d集,不能遗漏 +- 每集约3-5分钟(150-300秒) +- 每集的duration字段要根据剧本内容长度合理设置,不要都设置为同一个值 +- 返回的JSON中episodes数组必须包含 %d 个元素`, outlineText, characterList, req.EpisodeCount, req.EpisodeCount, req.EpisodeCount, req.EpisodeCount) + + temperature := req.Temperature + if temperature == 0 { + temperature = 0.7 + } + + // 根据剧集数量调整token限制 + // 模型支持128k上下文,每集400-500字约需800-1000 tokens(包含JSON结构) + baseTokens := 3000 // 基础(系统提示+角色列表+大纲) + perEpisodeTokens := 900 // 每集约900 tokens(支持400-500字详细内容) + maxTokens := baseTokens + (req.EpisodeCount * perEpisodeTokens) + + // 128k上下文,可以设置较大的token限制 + // 10集约12000 tokens,20集约21000 tokens,都在安全范围内 + if maxTokens > 32000 { + maxTokens = 32000 // 保守限制在32k,留足够空间 + } + + s.log.Infow("Generating episodes with token limit", + "episode_count", req.EpisodeCount, + "max_tokens", maxTokens, + "estimated_per_episode", perEpisodeTokens) + + text, err := s.aiService.GenerateText( + userPrompt, + systemPrompt, + ai.WithTemperature(0.8), + ai.WithMaxTokens(maxTokens), + ) + + if err != nil { + s.log.Errorw("Failed to generate episodes", "error", err) + return nil, fmt.Errorf("生成失败: %w", err) + } + + s.log.Infow("AI response received", "length", len(text), "preview", text[:minInt(200, len(text))]) + + var result struct { + Episodes []struct { + EpisodeNumber int `json:"episode_number"` + Title string `json:"title"` + Description string `json:"description"` + ScriptContent string `json:"script_content"` + Duration int `json:"duration"` + } `json:"episodes"` + } + + if err := utils.SafeParseAIJSON(text, &result); err != nil { + s.log.Errorw("Failed to parse episodes JSON", "error", err, "raw_response", text[:minInt(500, len(text))]) + return nil, fmt.Errorf("解析 AI 返回结果失败: %w", err) + } + + // 检查生成的集数是否符合要求 + if len(result.Episodes) < req.EpisodeCount { + s.log.Warnw("AI generated fewer episodes than requested", + "requested", req.EpisodeCount, + "generated", len(result.Episodes)) + } + + // 记录每集的详细信息 + for i, ep := range result.Episodes { + s.log.Infow("Episode parsed from AI", + "index", i, + "episode_number", ep.EpisodeNumber, + "title", ep.Title, + "description_length", len(ep.Description), + "script_content_length", len(ep.ScriptContent), + "duration", ep.Duration) + } + + var episodes []models.Episode + for _, ep := range result.Episodes { + duration := ep.Duration + if duration == 0 { + // AI未返回时长时使用默认值 + duration = 180 + s.log.Warnw("Episode duration not provided by AI, using default", + "episode_number", ep.EpisodeNumber, + "default_duration", 180) + } else { + s.log.Infow("Episode duration from AI", + "episode_number", ep.EpisodeNumber, + "duration", duration) + } + + // 记录即将保存的数据 + s.log.Infow("Creating episode in database", + "episode_number", ep.EpisodeNumber, + "title", ep.Title, + "script_content_length", len(ep.ScriptContent), + "script_content_empty", ep.ScriptContent == "") + + dramaID, err := strconv.ParseUint(req.DramaID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid drama ID") + } + + episode := models.Episode{ + DramaID: uint(dramaID), + EpisodeNum: ep.EpisodeNumber, + Title: ep.Title, + Description: &ep.Description, + ScriptContent: &ep.ScriptContent, + Duration: duration, + Status: "draft", + } + + if err := s.db.Create(&episode).Error; err != nil { + s.log.Errorw("Failed to create episode", "error", err) + continue + } + + episodes = append(episodes, episode) + } + + s.log.Infow("Episodes generated", "drama_id", req.DramaID, "count", len(episodes)) + return episodes, nil +} + +// GenerateScenesForEpisode 已废弃,使用 StoryboardService.GenerateStoryboard 替代 +// ParseScript 已废弃,使用 GenerateCharacters 替代 + +// minInt 返回两个整数中较小的一个 +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/application/services/storyboard_composition_service.go b/application/services/storyboard_composition_service.go new file mode 100644 index 0000000..9dc9626 --- /dev/null +++ b/application/services/storyboard_composition_service.go @@ -0,0 +1,395 @@ +package services + +import ( + "encoding/json" + "fmt" + + models "github.com/drama-generator/backend/domain/models" + "github.com/drama-generator/backend/pkg/logger" + "gorm.io/gorm" +) + +type StoryboardCompositionService struct { + db *gorm.DB + log *logger.Logger + imageGen *ImageGenerationService +} + +func NewStoryboardCompositionService(db *gorm.DB, log *logger.Logger, imageGen *ImageGenerationService) *StoryboardCompositionService { + return &StoryboardCompositionService{ + db: db, + log: log, + imageGen: imageGen, + } +} + +type SceneCharacterInfo struct { + ID uint `json:"id"` + Name string `json:"name"` + ImageURL *string `json:"image_url,omitempty"` +} + +type SceneBackgroundInfo struct { + ID uint `json:"id"` + Location string `json:"location"` + Time string `json:"time"` + ImageURL *string `json:"image_url,omitempty"` + Status string `json:"status"` +} + +type SceneCompositionInfo struct { + ID uint `json:"id"` + StoryboardNumber int `json:"storyboard_number"` + Title *string `json:"title"` + Description *string `json:"description"` + Location *string `json:"location"` + Time *string `json:"time"` + Duration int `json:"duration"` + Dialogue *string `json:"dialogue"` + Action *string `json:"action"` + Atmosphere *string `json:"atmosphere"` + ImagePrompt *string `json:"image_prompt,omitempty"` + VideoPrompt *string `json:"video_prompt,omitempty"` + Characters []SceneCharacterInfo `json:"characters"` + Background *SceneBackgroundInfo `json:"background"` + SceneID *uint `json:"scene_id"` + ComposedImage *string `json:"composed_image,omitempty"` + VideoURL *string `json:"video_url,omitempty"` + ImageGenerationID *uint `json:"image_generation_id,omitempty"` + ImageGenerationStatus *string `json:"image_generation_status,omitempty"` + VideoGenerationID *uint `json:"video_generation_id,omitempty"` + VideoGenerationStatus *string `json:"video_generation_status,omitempty"` +} + +func (s *StoryboardCompositionService) GetScenesForEpisode(episodeID string) ([]SceneCompositionInfo, error) { + // 验证权限 + var episode models.Episode + err := s.db.Preload("Drama").Where("id = ?", episodeID).First(&episode).Error + if err != nil { + s.log.Errorw("Episode not found", "episode_id", episodeID, "error", err) + return nil, fmt.Errorf("episode not found") + } + + s.log.Infow("GetScenesForEpisode auth check", + "episode_id", episodeID, + "drama_id", episode.DramaID) + + // 获取分镜列表 + var storyboards []models.Storyboard + if err := s.db.Where("episode_id = ?", episodeID). + Preload("Characters"). + Order("storyboard_number ASC"). + Find(&storyboards).Error; err != nil { + return nil, fmt.Errorf("failed to load storyboards: %w", err) + } + + // 获取所有角色(用于匹配角色信息) + var characters []models.Character + if err := s.db.Where("drama_id = ?", episode.DramaID).Find(&characters).Error; err != nil { + s.log.Warnw("Failed to load characters", "error", err) + } + + // 创建角色ID到角色信息的映射 + charIDToInfo := make(map[uint]*models.Character) + for i := range characters { + charIDToInfo[characters[i].ID] = &characters[i] + } + + // 获取所有场景ID + var sceneIDs []uint + for _, storyboard := range storyboards { + if storyboard.SceneID != nil { + sceneIDs = append(sceneIDs, *storyboard.SceneID) + } + } + + // 批量获取场景信息 + var scenes []models.Scene + sceneMap := make(map[uint]*models.Scene) + if len(sceneIDs) > 0 { + if err := s.db.Where("id IN ?", sceneIDs).Find(&scenes).Error; err == nil { + for i := range scenes { + sceneMap[scenes[i].ID] = &scenes[i] + } + } + } + + // 获取分镜的合成图片(从 image_generations 表) + storyboardIDs := make([]uint, len(storyboards)) + for i, storyboard := range storyboards { + storyboardIDs[i] = storyboard.ID + } + + imageGenMap := make(map[uint]string) // storyboard_id -> image_url + imageGenTaskMap := make(map[uint]*models.ImageGeneration) // storyboard_id -> processing task + if len(storyboardIDs) > 0 { + var imageGens []models.ImageGeneration + // 查询已完成的图片生成记录,每个镜头只取最新的一条 + if err := s.db.Where("storyboard_id IN ? AND status = ?", storyboardIDs, models.ImageStatusCompleted). + Order("created_at DESC"). + Find(&imageGens).Error; err == nil { + // 为每个镜头保留最新的一条记录 + for _, ig := range imageGens { + if ig.StoryboardID != nil { + if _, exists := imageGenMap[*ig.StoryboardID]; !exists { + if ig.ImageURL != nil { + imageGenMap[*ig.StoryboardID] = *ig.ImageURL + } + } + } + } + } + + // 查询进行中的图片生成任务 + var processingImageGens []models.ImageGeneration + if err := s.db.Where("storyboard_id IN ? AND status = ?", storyboardIDs, models.ImageStatusProcessing). + Order("created_at DESC"). + Find(&processingImageGens).Error; err == nil { + for _, ig := range processingImageGens { + if ig.StoryboardID != nil { + if _, exists := imageGenTaskMap[*ig.StoryboardID]; !exists { + igCopy := ig + imageGenTaskMap[*ig.StoryboardID] = &igCopy + } + } + } + } + } + + // 批量查询进行中的视频生成任务 + videoGenTaskMap := make(map[uint]*models.VideoGeneration) // storyboard_id -> processing task + if len(storyboardIDs) > 0 { + var processingVideoGens []models.VideoGeneration + if err := s.db.Where("scene_id IN ? AND status = ?", storyboardIDs, models.VideoStatusProcessing). + Order("created_at DESC"). + Find(&processingVideoGens).Error; err == nil { + for _, vg := range processingVideoGens { + if vg.StoryboardID != nil { + if _, exists := videoGenTaskMap[*vg.StoryboardID]; !exists { + vgCopy := vg + videoGenTaskMap[*vg.StoryboardID] = &vgCopy + } + } + } + } + } + + // 构建返回结果 + var result []SceneCompositionInfo + for _, storyboard := range storyboards { + storyboardInfo := SceneCompositionInfo{ + ID: storyboard.ID, + StoryboardNumber: storyboard.StoryboardNumber, + Title: storyboard.Title, + Description: storyboard.Description, + Location: storyboard.Location, + Time: storyboard.Time, + Duration: storyboard.Duration, + Action: storyboard.Action, + Dialogue: storyboard.Dialogue, + Atmosphere: storyboard.Atmosphere, + ImagePrompt: storyboard.ImagePrompt, + VideoPrompt: storyboard.VideoPrompt, + SceneID: storyboard.SceneID, + } + + // 直接使用关联的角色信息 + if len(storyboard.Characters) > 0 { + for _, char := range storyboard.Characters { + storyboardChar := SceneCharacterInfo{ + ID: char.ID, + Name: char.Name, + ImageURL: char.ImageURL, + } + storyboardInfo.Characters = append(storyboardInfo.Characters, storyboardChar) + } + } + + // 添加场景信息 + if storyboard.SceneID != nil { + if scene, ok := sceneMap[*storyboard.SceneID]; ok { + storyboardInfo.Background = &SceneBackgroundInfo{ + ID: scene.ID, + Location: scene.Location, + Time: scene.Time, + ImageURL: scene.ImageURL, + Status: scene.Status, + } + } + } + + // 添加合成图片 + if imageURL, ok := imageGenMap[storyboard.ID]; ok { + storyboardInfo.ComposedImage = &imageURL + } + + // 添加视频URL + if storyboard.VideoURL != nil { + storyboardInfo.VideoURL = storyboard.VideoURL + } + + // 添加进行中的图片生成任务信息 + if imageTask, ok := imageGenTaskMap[storyboard.ID]; ok { + storyboardInfo.ImageGenerationID = &imageTask.ID + statusStr := string(imageTask.Status) + storyboardInfo.ImageGenerationStatus = &statusStr + } + + // 添加进行中的视频生成任务信息 + if videoTask, ok := videoGenTaskMap[storyboard.ID]; ok { + storyboardInfo.VideoGenerationID = &videoTask.ID + statusStr := string(videoTask.Status) + storyboardInfo.VideoGenerationStatus = &statusStr + } + + result = append(result, storyboardInfo) + } + + return result, nil +} + +type UpdateSceneRequest struct { + SceneID *uint `json:"scene_id"` + Characters []uint `json:"characters"` // 改为存储角色ID数组 + Location *string `json:"location"` + Time *string `json:"time"` + Action *string `json:"action"` + Dialogue *string `json:"dialogue"` + Description *string `json:"description"` + Duration *int `json:"duration"` + ImagePrompt *string `json:"image_prompt"` + VideoPrompt *string `json:"video_prompt"` +} + +func (s *StoryboardCompositionService) UpdateScene(sceneID string, req *UpdateSceneRequest) error { + // 获取分镜并验证权限 + var storyboard models.Storyboard + err := s.db.Preload("Episode.Drama").Where("id = ?", sceneID).First(&storyboard).Error + if err != nil { + return fmt.Errorf("scene not found") + } + + // 构建更新数据 + updates := make(map[string]interface{}) + + // 更新背景ID + if req.SceneID != nil { + updates["scene_id"] = req.SceneID + } + + // 更新角色列表(直接存储ID数组) + if req.Characters != nil { + charactersJSON, err := json.Marshal(req.Characters) + if err != nil { + return fmt.Errorf("failed to serialize characters: %w", err) + } + updates["characters"] = charactersJSON + } + + // 更新场景信息字段 + if req.Location != nil { + updates["location"] = req.Location + } + if req.Time != nil { + updates["time"] = req.Time + } + if req.Action != nil { + updates["action"] = req.Action + } + if req.Dialogue != nil { + updates["dialogue"] = req.Dialogue + } + if req.Description != nil { + updates["description"] = req.Description + } + if req.Duration != nil { + updates["duration"] = *req.Duration + } + if req.ImagePrompt != nil { + updates["image_prompt"] = req.ImagePrompt + } + if req.VideoPrompt != nil { + updates["video_prompt"] = req.VideoPrompt + } + + // 执行更新 + if len(updates) > 0 { + if err := s.db.Model(&models.Storyboard{}).Where("id = ?", sceneID).Updates(updates).Error; err != nil { + return fmt.Errorf("failed to update scene: %w", err) + } + } + + s.log.Infow("Scene updated", "scene_id", sceneID, "updates", updates) + return nil +} + +type GenerateSceneImageRequest struct { + SceneID uint `json:"scene_id"` + Prompt string `json:"prompt"` + Model string `json:"model"` +} + +func (s *StoryboardCompositionService) GenerateSceneImage(req *GenerateSceneImageRequest) (*models.ImageGeneration, error) { + // 获取场景并验证权限 + var scene models.Scene + err := s.db.Where("id = ?", req.SceneID).First(&scene).Error + if err != nil { + return nil, fmt.Errorf("scene not found") + } + + // 验证权限:通过DramaID查询Drama + var drama models.Drama + if err := s.db.Where("id = ? ", scene.DramaID).First(&drama).Error; err != nil { + return nil, fmt.Errorf("unauthorized") + } + + // 构建场景图片生成提示词 + prompt := req.Prompt + if prompt == "" { + // 使用场景的Prompt字段 + prompt = scene.Prompt + if prompt == "" { + // 如果Prompt为空,使用Location和Time构建 + prompt = fmt.Sprintf("%s场景,%s", scene.Location, scene.Time) + } + s.log.Infow("Using scene prompt", "scene_id", req.SceneID, "prompt", prompt) + } + + // 使用imageGen服务直接生成 + if s.imageGen != nil { + genReq := &GenerateImageRequest{ + SceneID: &req.SceneID, + DramaID: fmt.Sprintf("%d", scene.DramaID), + ImageType: string(models.ImageTypeScene), + Prompt: prompt, + Model: req.Model, // 使用用户指定的模型 + Size: "2560x1440", // 3,686,400像素,满足doubao模型最低要求(16:9比例) + Quality: "standard", + } + imageGen, err := s.imageGen.GenerateImage(genReq) + if err != nil { + return nil, fmt.Errorf("failed to generate image: %w", err) + } + + // 更新场景的image_url + if imageGen.ImageURL != nil { + scene.ImageURL = imageGen.ImageURL + scene.Status = "generated" + if err := s.db.Save(&scene).Error; err != nil { + s.log.Errorw("Failed to update scene image url", "error", err) + } + } + + s.log.Infow("Scene image generation created", "scene_id", req.SceneID, "image_gen_id", imageGen.ID) + return imageGen, nil + } + + return nil, fmt.Errorf("image generation service not available") +} + +func getStringValue(s *string) string { + if s != nil { + return *s + } + return "" +} diff --git a/application/services/storyboard_service.go b/application/services/storyboard_service.go new file mode 100644 index 0000000..0d8f491 --- /dev/null +++ b/application/services/storyboard_service.go @@ -0,0 +1,741 @@ +package services + +import ( + "strconv" + + "fmt" + "strings" + + models "github.com/drama-generator/backend/domain/models" + "github.com/drama-generator/backend/pkg/logger" + "github.com/drama-generator/backend/pkg/utils" + "gorm.io/gorm" +) + +type StoryboardService struct { + db *gorm.DB + aiService *AIService + log *logger.Logger +} + +func NewStoryboardService(db *gorm.DB, log *logger.Logger) *StoryboardService { + return &StoryboardService{ + db: db, + aiService: NewAIService(db, log), + log: log, + } +} + +type Storyboard struct { + ShotNumber int `json:"shot_number"` + Title string `json:"title"` // 镜头标题 + ShotType string `json:"shot_type"` // 景别 + Angle string `json:"angle"` // 镜头角度 + Time string `json:"time"` // 时间 + Location string `json:"location"` // 地点 + SceneID *uint `json:"scene_id"` // 背景ID(AI直接返回,可为null) + Movement string `json:"movement"` // 运镜 + Action string `json:"action"` // 动作 + Dialogue string `json:"dialogue"` // 对话/独白 + Result string `json:"result"` // 画面结果 + Atmosphere string `json:"atmosphere"` // 环境氛围 + Emotion string `json:"emotion"` // 情绪 + Duration int `json:"duration"` // 时长(秒) + BgmPrompt string `json:"bgm_prompt"` // 配乐提示词 + SoundEffect string `json:"sound_effect"` // 音效描述 + Characters []uint `json:"characters"` // 涉及的角色ID列表 + IsPrimary bool `json:"is_primary"` // 是否主镜 +} + +type GenerateStoryboardResult struct { + Storyboards []Storyboard `json:"storyboards"` + Total int `json:"total"` +} + +func (s *StoryboardService) GenerateStoryboard(episodeID string) (*GenerateStoryboardResult, error) { + // 从数据库获取剧集信息 + var episode struct { + ID string + ScriptContent *string + Description *string + DramaID string + } + + err := s.db.Table("episodes"). + Select("episodes.id, episodes.script_content, episodes.description, episodes.drama_id"). + Joins("INNER JOIN dramas ON dramas.id = episodes.drama_id"). + Where("episodes.id = ?", episodeID). + First(&episode).Error + + if err != nil { + return nil, fmt.Errorf("剧集不存在或无权限访问") + } + + // 获取剧本内容 + var scriptContent string + if episode.ScriptContent != nil && *episode.ScriptContent != "" { + scriptContent = *episode.ScriptContent + } else if episode.Description != nil && *episode.Description != "" { + scriptContent = *episode.Description + } else { + return nil, fmt.Errorf("剧本内容为空,请先生成剧集内容") + } + + // 获取该剧本的所有角色 + var characters []models.Character + if err := s.db.Where("drama_id = ?", episode.DramaID).Order("name ASC").Find(&characters).Error; err != nil { + return nil, fmt.Errorf("获取角色列表失败: %w", err) + } + + // 构建角色列表字符串(包含ID和名称) + characterList := "无角色" + if len(characters) > 0 { + var charInfoList []string + for _, char := range characters { + charInfoList = append(charInfoList, fmt.Sprintf(`{"id": %d, "name": "%s"}`, char.ID, char.Name)) + } + characterList = fmt.Sprintf("[%s]", strings.Join(charInfoList, ", ")) + } + + // 获取该项目已提取的场景列表(项目级) + var scenes []models.Scene + if err := s.db.Where("drama_id = ?", episode.DramaID).Order("location ASC, time ASC").Find(&scenes).Error; err != nil { + s.log.Warnw("Failed to get scenes", "error", err) + } + + // 构建场景列表字符串(包含ID、地点、时间) + sceneList := "无场景" + if len(scenes) > 0 { + var sceneInfoList []string + for _, bg := range scenes { + sceneInfoList = append(sceneInfoList, fmt.Sprintf(`{"id": %d, "location": "%s", "time": "%s"}`, bg.ID, bg.Location, bg.Time)) + } + sceneList = fmt.Sprintf("[%s]", strings.Join(sceneInfoList, ", ")) + } + + s.log.Infow("Generating storyboard", + "episode_id", episodeID, + "drama_id", episode.DramaID, + "script_length", len(scriptContent), + "character_count", len(characters), + "characters", characterList, + "scene_count", len(scenes), + "scenes", sceneList) + + // 构建分镜头生成提示词 + prompt := fmt.Sprintf(`【角色】你是一位资深影视分镜师,精通罗伯特·麦基的镜头拆解理论,擅长构建情绪节奏。 + +【任务】将小说剧本按**独立动作单元**拆解为分镜头方案。 + +【本剧可用角色列表】 +%s + +**重要**:在characters字段中,只能使用上述角色列表中的角色ID(数字),不得自创角色或使用其他ID。 + +【本剧已提取的场景背景列表】 +%s + +**重要**:在scene_id字段中,必须从上述背景列表中选择最匹配的背景ID(数字)。如果没有合适的背景,则填null。 + +【剧本原文】 +%s + +【分镜要素】每个镜头聚焦单一动作,描述要详尽具体: +1. **镜头标题(title)**:用3-5个字概括该镜头的核心内容或情绪 + - 例如:"噩梦惊醒"、"对视沉思"、"逃离现场"、"意外发现" +2. **时间**:[清晨/午后/深夜/具体时分+详细光线描述] + - 例如:"深夜22:30·月光从破窗斜射入室内,形成明暗分界" +3. **地点**:[场景完整描述+空间布局+环境细节] + - 例如:"废弃码头仓库·锈蚀货架林立,地面积水反射微弱灯光,墙角堆放腐朽木箱" +4. **镜头设计**: + - **景别(shot_type)**:[远景/全景/中景/近景/特写] + - **镜头角度(angle)**:[平视/仰视/俯视/侧面/背面] + - **运镜方式(movement)**:[固定镜头/推镜/拉镜/摇镜/跟镜/移镜] +5. **人物行为**:**详细动作描述**,包含[谁+具体怎么做+肢体细节+表情状态] + - 例如:"陈峥弯腰用撬棍撬动保险箱门,手臂青筋暴起,眉头紧锁,汗水滑落脸颊" +6. **对话/独白**:提取该镜头中的完整对话或独白内容(如无对话则为空字符串) +7. **画面结果**:动作的即时后果+视觉细节+氛围变化 + - 例如:"保险箱门弹开发出金属碰撞声,扬起灰尘在光束中飘散,箱内空无一物只有陈旧报纸,陈峥表情从期待转为失望" +8. **环境氛围**:光线质感+色调+声音环境+整体氛围 + - 例如:"昏暗冷色调,只有手电筒光束晃动,远处传来海浪拍打声,压抑沉闷" +9. **配乐提示(bgm_prompt)**:描述该镜头配乐的氛围、节奏、情绪(如无特殊要求则为空字符串) + - 例如:"低沉紧张的弦乐,节奏缓慢,营造压抑氛围" +10. **音效描述(sound_effect)**:描述该镜头的关键音效(如无特殊音效则为空字符串) + - 例如:"金属碰撞声、脚步声、海浪拍打声" +11. **观众情绪**:[情绪类型]([强度:↑↑↑/↑↑/↑/→/↓] + [落点:悬置/释放/反转]) + +【输出格式】请以JSON格式输出,每个镜头包含以下字段(**所有描述性字段都要详细完整**): +{ + "storyboards": [ + { + "shot_number": 1, + "title": "噩梦惊醒", + "shot_type": "全景", + "angle": "俯视45度角", + "time": "深夜22:30·月光从破窗斜射入仓库,在地面积水中形成银白色反光,墙角昏暗不清", + "location": "废弃码头仓库·锈蚀货架林立,地面积水反射微弱灯光,墙角堆放腐朽木箱和渔网,空气中弥漫潮湿霉味", + "scene_id": 1, + "movement": "固定镜头", + "action": "陈峥弯腰双手握住撬棍用力撬动保险箱门,手臂青筋暴起,眉头紧锁,汗水从额头滑落脸颊,呼吸急促", + "dialogue": "(独白)这么多年了,里面到底藏着什么秘密?", + "result": "保险箱门突然弹开发出刺耳金属声,扬起灰尘在手电筒光束中飘散,箱内空无一物只有几张发黄的旧报纸,陈峥表情从期待转为震惊和失望,瞳孔放大", + "atmosphere": "昏暗冷色调·青灰色为主,只有手电筒光束在黑暗中晃动,远处传来海浪拍打码头的沉闷声,整体氛围压抑沉重", + "emotion": "好奇感↑↑转失望↓(情绪反转)", + "duration": 9, + "bgm_prompt": "低沉紧张的弦乐,节奏缓慢,营造压抑悬疑氛围", + "sound_effect": "金属碰撞声、灰尘飘散声、海浪拍打声", + "characters": [159], + "is_primary": true + }, + { + "shot_number": 2, + "title": "对视沉思", + "shot_type": "近景", + "angle": "平视", + "time": "深夜22:31·仓库内光线昏暗,只有手电筒光从侧面照亮两人脸部轮廓", + "location": "废弃码头仓库·保险箱旁,背景是模糊的货架剪影", + "scene_id": 1, + "movement": "推镜", + "action": "陈峥缓缓转身,目光与身后的李芳对视,李芳手握手电筒,光束在两人之间晃动,眼神中透露疑惑和警惕", + "dialogue": "陈峥:\"我们被耍了,这里根本没有我们要找的东西。\" 李芳:\"现在怎么办?我们的时间不多了。\"", + "result": "两人站在昏暗中陷入沉思,手电筒光束照在地面形成圆形光斑,背景传来微弱的金属摩擦声,气氛紧张凝重", + "atmosphere": "低调光线·暗部占画面70%,侧面硬光勾勒人物轮廓,冷暖光对比强烈,海风吹过产生呼啸声,营造紧迫感", + "emotion": "紧张感↑↑·警惕↑↑(悬置)", + "duration": 7, + "bgm_prompt": "紧张感逐渐升级的音效,低频持续音", + "sound_effect": "呼吸声、金属摩擦声、海风呼啸声", + "characters": [159, 160], + "is_primary": true + } + ] +} + +**dialogue字段说明**: +- 如果有对话,格式为:角色名:\"台词内容\" +- 多人对话用空格分隔:角色A:\"...\" 角色B:\"...\" +- 独白格式为:(独白)内容 +- 旁白格式为:(旁白)内容 +- 无对话时填写空字符串:"" +- **对话内容必须从原剧本中提取,保持原汁原味** + +**角色和背景要求**: +- characters字段必须包含该镜头中出现的所有角色ID(数字数组格式) +- 只提取实际出现的角色ID,不出现角色则为空数组[] +- **角色ID必须严格使用【本剧可用角色列表】中的id字段(数字),不得使用其他ID或自创角色** +- 例如:如果镜头中出现李明(id:159)和王芳(id:160),则characters字段应为[159, 160] +- scene_id字段必须从【本剧已提取的场景背景列表】中选择最匹配的背景ID(数字) +- 如果列表中没有合适的背景,则scene_id填null +- 例如:如果镜头发生在"城市公寓卧室·凌晨",应选择id为1的场景背景 + +**duration时长估算规则(秒)**: +- **所有镜头时长必须在4-12秒范围内**,确保节奏合理流畅 +- **综合估算原则**:时长由对话内容、动作复杂度、情绪节奏三方面综合决定 + +**估算步骤**: +1. **基础时长**(从场景内容判断): + - 纯对话场景(无明显动作):基础4秒 + - 纯动作场景(无对话):基础5秒 + - 对话+动作混合场景:基础6秒 + +2. **对话调整**(根据台词字数增加时长): + - 无对话:+0秒 + - 短对话(1-20字):+1-2秒 + - 中等对话(21-50字):+2-4秒 + - 长对话(51字以上):+4-6秒 + +3. **动作调整**(根据动作复杂度增加时长): + - 无动作/静态:+0秒 + - 简单动作(表情、转身、拿物品):+0-1秒 + - 一般动作(走动、开门、坐下):+1-2秒 + - 复杂动作(打斗、追逐、大幅度移动):+2-4秒 + - 环境展示(全景扫描、氛围营造):+2-5秒 + +4. **最终时长** = 基础时长 + 对话调整 + 动作调整,确保结果在4-12秒范围内 + +**示例**: +- "陈峥转身离开"(简单动作,无对话):5 + 0 + 1 = 6秒 +- "李芳:\"你要去哪里?\""(短对话,无动作):4 + 2 + 0 = 6秒 +- "陈峥推开房门,李芳:\"终于找到你了,这些年你去哪了?\""(一般动作+中等对话):6 + 3 + 2 = 11秒 +- "两人在雨中激烈搏斗,陈峥:\"住手!\""(复杂动作+短对话):6 + 2 + 4 = 12秒 + +**重要**:准确估算每个镜头时长,所有分镜时长之和将作为剧集总时长 + +**特别要求**: +- **【极其重要】必须100%%完整拆解整个剧本,不得省略、跳过、压缩任何剧情内容** +- **从剧本第一个字到最后一个字,逐句逐段转换为分镜** +- **每个对话、每个动作、每个场景转换都必须有对应的分镜** +- 剧本越长,分镜数量越多(短剧本15-30个,中等剧本30-60个,长剧本60-100个甚至更多) +- **宁可分镜多,也不要遗漏剧情**:一个长场景可拆分为多个连续分镜 +- 每个镜头只描述一个主要动作 +- 区分主镜(is_primary: true)和链接镜(is_primary: false) +- 确保情绪节奏有变化 +- **duration字段至关重要**:准确估算每个镜头时长,这将用于计算整集时长 +- 严格按照JSON格式输出 + +**【禁止行为】**: +- ❌ 禁止用一个镜头概括多个场景 +- ❌ 禁止跳过任何对话或独白 +- ❌ 禁止省略剧情发展过程 +- ❌ 禁止合并本应分开的镜头 +- ✅ 正确做法:剧本有多少内容,就拆解出对应数量的分镜,确保观众看完所有分镜能完整了解剧情 + +**【关键】场景描述详细度要求**(这些描述将直接用于视频生成模型): +1. **时间(time)字段**:必须包含≥15字的详细描述 + - ✓ 好例子:"深夜22:30·月光从破窗斜射入仓库,在地面积水中形成银白色反光,墙角昏暗不清" + - ✗ 差例子:"深夜" + +2. **地点(location)字段**:必须包含≥20字的详细场景描述 + - ✓ 好例子:"废弃码头仓库·锈蚀货架林立,地面积水反射微弱灯光,墙角堆放腐朽木箱和渔网,空气中弥漫潮湿霉味" + - ✗ 差例子:"仓库" + +3. **动作(action)字段**:必须包含≥25字的详细动作描述,包括肢体细节和表情 + - ✓ 好例子:"陈峥弯腰双手握住撬棍用力撬动保险箱门,手臂青筋暴起,眉头紧锁,汗水从额头滑落脸颊,呼吸急促" + - ✗ 差例子:"陈峥打开保险箱" + +4. **结果(result)字段**:必须包含≥25字的详细视觉结果描述 + - ✓ 好例子:"保险箱门突然弹开发出刺耳金属声,扬起灰尘在手电筒光束中飘散,箱内空无一物只有几张发黄的旧报纸,陈峥表情从期待转为震惊和失望,瞳孔放大" + - ✗ 差例子:"门打开了" + +5. **氛围(atmosphere)字段**:必须包含≥20字的环境氛围描述,包括光线、色调、声音 + - ✓ 好例子:"昏暗冷色调·青灰色为主,只有手电筒光束在黑暗中晃动,远处传来海浪拍打码头的沉闷声,整体氛围压抑沉重" + - ✗ 差例子:"昏暗" + +**描述原则**: +- 所有描述性字段要像为盲人讲述画面一样详细 +- 包含感官细节:视觉、听觉、触觉、嗅觉 +- 描述光线、色彩、质感、动态 +- 为视频生成AI提供足够的画面构建信息 +- 避免抽象词汇,使用具象的视觉化描述`, characterList, sceneList, scriptContent) + + // 调用AI服务生成 + text, err := s.aiService.GenerateText(prompt, "") + if err != nil { + s.log.Errorw("Failed to generate storyboard", "error", err) + return nil, fmt.Errorf("生成分镜头失败: %w", err) + } + + // 解析JSON结果 + var result GenerateStoryboardResult + if err := utils.SafeParseAIJSON(text, &result); err != nil { + s.log.Errorw("Failed to parse storyboard JSON", "error", err, "response", text[:min(500, len(text))]) + return nil, fmt.Errorf("解析分镜头结果失败: %w", err) + } + + result.Total = len(result.Storyboards) + + // 计算总时长(所有分镜时长之和) + totalDuration := 0 + for _, sb := range result.Storyboards { + totalDuration += sb.Duration + } + + s.log.Infow("Storyboard generated", + "episode_id", episodeID, + "count", result.Total, + "total_duration_seconds", totalDuration) + + // 保存分镜头到数据库 + if err := s.saveStoryboards(episodeID, result.Storyboards); err != nil { + s.log.Errorw("Failed to save storyboards", "error", err) + return nil, fmt.Errorf("保存分镜头失败: %w", err) + } + + // 更新剧集时长(秒转分钟,向上取整) + durationMinutes := (totalDuration + 59) / 60 + if err := s.db.Model(&models.Episode{}).Where("id = ?", episodeID).Update("duration", durationMinutes).Error; err != nil { + s.log.Errorw("Failed to update episode duration", "error", err) + // 不中断流程,只记录错误 + } else { + s.log.Infow("Episode duration updated", + "episode_id", episodeID, + "duration_seconds", totalDuration, + "duration_minutes", durationMinutes) + } + + return &result, nil +} + +// generateImagePrompt 生成专门用于图片生成的提示词(首帧静态画面) +func (s *StoryboardService) generateImagePrompt(sb Storyboard) string { + var parts []string + + // 1. 完整的场景背景描述 + if sb.Location != "" { + locationDesc := sb.Location + if sb.Time != "" { + locationDesc += ", " + sb.Time + } + parts = append(parts, locationDesc) + } + + // 2. 角色初始静态姿态(去除动作过程,只保留起始状态) + if sb.Action != "" { + initialPose := extractInitialPose(sb.Action) + if initialPose != "" { + parts = append(parts, initialPose) + } + } + + // 3. 情绪氛围 + if sb.Emotion != "" { + parts = append(parts, sb.Emotion) + } + + // 4. 动漫风格 + parts = append(parts, "anime style, first frame") + + if len(parts) > 0 { + return strings.Join(parts, ", ") + } + return "anime scene" +} + +// extractInitialPose 提取初始静态姿态(去除动作过程) +func extractInitialPose(action string) string { + // 去除动作过程关键词,保留初始状态描述 + processWords := []string{ + "然后", "接着", "接下来", "随后", "紧接着", + "向下", "向上", "向前", "向后", "向左", "向右", + "开始", "继续", "逐渐", "慢慢", "快速", "突然", "猛然", + } + + result := action + for _, word := range processWords { + if idx := strings.Index(result, word); idx > 0 { + // 在动作过程词之前截断 + result = result[:idx] + break + } + } + + // 清理末尾标点 + result = strings.TrimRight(result, ",。,. ") + return strings.TrimSpace(result) +} + +// extractSimpleLocation 提取简化的场景地点(去除详细描述) +func extractSimpleLocation(location string) string { + // 在"·"符号处截断,只保留主场景名称 + if idx := strings.Index(location, "·"); idx > 0 { + return strings.TrimSpace(location[:idx]) + } + + // 如果有逗号,只保留第一部分 + if idx := strings.Index(location, ","); idx > 0 { + return strings.TrimSpace(location[:idx]) + } + if idx := strings.Index(location, ","); idx > 0 { + return strings.TrimSpace(location[:idx]) + } + + // 限制长度不超过15个字符 + maxLen := 15 + if len(location) > maxLen { + return strings.TrimSpace(location[:maxLen]) + } + + return strings.TrimSpace(location) +} + +// extractSimplePose 提取简单的核心姿态关键词(不超过10个字) +func extractSimplePose(action string) string { + // 只提取前面最多10个字符作为核心姿态 + runes := []rune(action) + maxLen := 10 + if len(runes) > maxLen { + // 在标点符号处截断 + truncated := runes[:maxLen] + for i := maxLen - 1; i >= 0; i-- { + if truncated[i] == ',' || truncated[i] == '。' || truncated[i] == ',' || truncated[i] == '.' { + truncated = runes[:i] + break + } + } + return strings.TrimSpace(string(truncated)) + } + return strings.TrimSpace(action) +} + +// extractFirstFramePose 从动作描述中提取首帧静态姿态 +func extractFirstFramePose(action string) string { + // 去除表示动作过程的关键词,保留初始状态 + processWords := []string{ + "然后", "接着", "向下", "向前", "走向", "冲向", "转身", + "开始", "继续", "逐渐", "慢慢", "快速", "突然", + } + + pose := action + for _, word := range processWords { + // 简单处理:在这些词之前截断 + if idx := strings.Index(pose, word); idx > 0 { + pose = pose[:idx] + break + } + } + + // 清理末尾标点 + pose = strings.TrimRight(pose, ",。,.") + return strings.TrimSpace(pose) +} + +// extractCompositionType 从镜头类型中提取构图类型(去除运镜) +func extractCompositionType(shotType string) string { + // 去除运镜相关描述 + cameraMovements := []string{ + "晃动", "摇晃", "推进", "拉远", "跟随", "环绕", + "运镜", "摄影", "移动", "旋转", + } + + comp := shotType + for _, movement := range cameraMovements { + comp = strings.ReplaceAll(comp, movement, "") + } + + // 清理多余的标点和空格 + comp = strings.ReplaceAll(comp, "··", "·") + comp = strings.ReplaceAll(comp, "·", " ") + comp = strings.TrimSpace(comp) + + return comp +} + +// generateVideoPrompt 生成专门用于视频生成的提示词(包含运镜和动态元素) +func (s *StoryboardService) generateVideoPrompt(sb Storyboard) string { + var parts []string + + // 1. 人物动作 + if sb.Action != "" { + parts = append(parts, fmt.Sprintf("Action: %s", sb.Action)) + } + + // 2. 对话 + if sb.Dialogue != "" { + parts = append(parts, fmt.Sprintf("Dialogue: %s", sb.Dialogue)) + } + + // 3. 镜头运动(视频特有) + if sb.Movement != "" { + parts = append(parts, fmt.Sprintf("Camera movement: %s", sb.Movement)) + } + + // 4. 镜头类型和角度 + if sb.ShotType != "" { + parts = append(parts, fmt.Sprintf("Shot type: %s", sb.ShotType)) + } + if sb.Angle != "" { + parts = append(parts, fmt.Sprintf("Camera angle: %s", sb.Angle)) + } + + // 5. 场景环境 + if sb.Location != "" { + locationDesc := sb.Location + if sb.Time != "" { + locationDesc += ", " + sb.Time + } + parts = append(parts, fmt.Sprintf("Scene: %s", locationDesc)) + } + + // 6. 环境氛围 + if sb.Atmosphere != "" { + parts = append(parts, fmt.Sprintf("Atmosphere: %s", sb.Atmosphere)) + } + + // 7. 情绪和结果 + if sb.Emotion != "" { + parts = append(parts, fmt.Sprintf("Mood: %s", sb.Emotion)) + } + if sb.Result != "" { + parts = append(parts, fmt.Sprintf("Result: %s", sb.Result)) + } + + // 8. 音频元素 + if sb.BgmPrompt != "" { + parts = append(parts, fmt.Sprintf("BGM: %s", sb.BgmPrompt)) + } + if sb.SoundEffect != "" { + parts = append(parts, fmt.Sprintf("Sound effects: %s", sb.SoundEffect)) + } + + // 9. 视频风格要求 + parts = append(parts, "Style: cinematic anime style, smooth camera motion, natural character movement") + + if len(parts) > 0 { + return strings.Join(parts, ". ") + } + return "Anime style video scene" +} + +func (s *StoryboardService) saveStoryboards(episodeID string, storyboards []Storyboard) error { + // 开启事务 + return s.db.Transaction(func(tx *gorm.DB) error { + // 获取该剧集所有的分镜ID + var storyboardIDs []uint + if err := tx.Model(&models.Storyboard{}). + Where("episode_id = ?", episodeID). + Pluck("id", &storyboardIDs).Error; err != nil { + return err + } + + // 如果有分镜,先清理关联的image_generations的storyboard_id + if len(storyboardIDs) > 0 { + if err := tx.Model(&models.ImageGeneration{}). + Where("storyboard_id IN ?", storyboardIDs). + Update("storyboard_id", nil).Error; err != nil { + return err + } + } + + // 删除该剧集已有的分镜头 + if err := tx.Where("episode_id = ?", episodeID).Delete(&models.Storyboard{}).Error; err != nil { + return err + } + + // 注意:不删除背景,因为背景是在分镜拆解前就提取好的 + // AI会直接返回scene_id,不需要在这里做字符串匹配 + + // 保存新的分镜头 + for _, sb := range storyboards { + // 构建描述信息,包含对话 + description := fmt.Sprintf("【镜头类型】%s\n【运镜】%s\n【动作】%s\n【对话】%s\n【结果】%s\n【情绪】%s", + sb.ShotType, sb.Movement, sb.Action, sb.Dialogue, sb.Result, sb.Emotion) + + // 生成两种专用提示词 + imagePrompt := s.generateImagePrompt(sb) // 专用于图片生成 + videoPrompt := s.generateVideoPrompt(sb) // 专用于视频生成 + + // 处理 dialogue 字段 + var dialoguePtr *string + if sb.Dialogue != "" { + dialoguePtr = &sb.Dialogue + } + + // 使用AI直接返回的SceneID + if sb.SceneID != nil { + s.log.Infow("Background ID from AI", + "shot_number", sb.ShotNumber, + "scene_id", *sb.SceneID) + } + + epID, _ := strconv.ParseUint(episodeID, 10, 32) + + // 处理 title 字段 + var titlePtr *string + if sb.Title != "" { + titlePtr = &sb.Title + } + + // 处理shot_type、angle、movement字段 + var shotTypePtr, anglePtr, movementPtr *string + if sb.ShotType != "" { + shotTypePtr = &sb.ShotType + } + if sb.Angle != "" { + anglePtr = &sb.Angle + } + if sb.Movement != "" { + movementPtr = &sb.Movement + } + + // 处理bgm_prompt、sound_effect字段 + var bgmPromptPtr, soundEffectPtr *string + if sb.BgmPrompt != "" { + bgmPromptPtr = &sb.BgmPrompt + } + if sb.SoundEffect != "" { + soundEffectPtr = &sb.SoundEffect + } + + // 处理result、atmosphere字段 + var resultPtr, atmospherePtr *string + if sb.Result != "" { + resultPtr = &sb.Result + } + if sb.Atmosphere != "" { + atmospherePtr = &sb.Atmosphere + } + + scene := models.Storyboard{ + EpisodeID: uint(epID), + SceneID: sb.SceneID, + StoryboardNumber: sb.ShotNumber, + Title: titlePtr, + Location: &sb.Location, + Time: &sb.Time, + ShotType: shotTypePtr, + Angle: anglePtr, + Movement: movementPtr, + Description: &description, + Action: &sb.Action, + Result: resultPtr, + Atmosphere: atmospherePtr, + Dialogue: dialoguePtr, + ImagePrompt: &imagePrompt, + VideoPrompt: &videoPrompt, + BgmPrompt: bgmPromptPtr, + SoundEffect: soundEffectPtr, + Duration: sb.Duration, + } + + if err := tx.Create(&scene).Error; err != nil { + s.log.Errorw("Failed to create scene", "error", err, "shot_number", sb.ShotNumber) + return err + } + + // 关联角色 + if len(sb.Characters) > 0 { + var characters []models.Character + if err := tx.Where("id IN ?", sb.Characters).Find(&characters).Error; err != nil { + s.log.Warnw("Failed to load characters for association", "error", err, "character_ids", sb.Characters) + } else if len(characters) > 0 { + if err := tx.Model(&scene).Association("Characters").Append(characters); err != nil { + s.log.Warnw("Failed to associate characters", "error", err, "shot_number", sb.ShotNumber) + } else { + s.log.Infow("Characters associated successfully", + "shot_number", sb.ShotNumber, + "character_ids", sb.Characters, + "count", len(characters)) + } + } + } + } + + s.log.Infow("Storyboards saved successfully", "episode_id", episodeID, "count", len(storyboards)) + return nil + }) +} + +// UpdateStoryboardCharacters 更新分镜的角色关联 +func (s *StoryboardService) UpdateStoryboardCharacters(storyboardID string, characterIDs []uint) error { + // 查找分镜 + var storyboard models.Storyboard + if err := s.db.First(&storyboard, storyboardID).Error; err != nil { + return fmt.Errorf("storyboard not found: %w", err) + } + + // 清除现有的角色关联 + if err := s.db.Model(&storyboard).Association("Characters").Clear(); err != nil { + return fmt.Errorf("failed to clear characters: %w", err) + } + + // 如果有新的角色ID,加载并关联 + if len(characterIDs) > 0 { + var characters []models.Character + if err := s.db.Where("id IN ?", characterIDs).Find(&characters).Error; err != nil { + return fmt.Errorf("failed to find characters: %w", err) + } + + if err := s.db.Model(&storyboard).Association("Characters").Append(characters); err != nil { + return fmt.Errorf("failed to associate characters: %w", err) + } + } + + s.log.Infow("Storyboard characters updated", "storyboard_id", storyboardID, "character_count", len(characterIDs)) + return nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/application/services/storyboard_update_full.go b/application/services/storyboard_update_full.go new file mode 100644 index 0000000..d2e1b55 --- /dev/null +++ b/application/services/storyboard_update_full.go @@ -0,0 +1,138 @@ +package services + +import ( + "fmt" + + "github.com/drama-generator/backend/domain/models" +) + +// UpdateStoryboard 更新分镜的所有字段,并重新生成提示词 +func (s *StoryboardService) UpdateStoryboard(storyboardID string, updates map[string]interface{}) error { + // 查找分镜 + var storyboard models.Storyboard + if err := s.db.First(&storyboard, storyboardID).Error; err != nil { + return fmt.Errorf("storyboard not found: %w", err) + } + + // 构建用于重新生成提示词的Storyboard结构 + sb := Storyboard{ + ShotNumber: storyboard.StoryboardNumber, + } + + // 从updates中提取字段并更新 + updateData := make(map[string]interface{}) + + if val, ok := updates["title"].(string); ok && val != "" { + updateData["title"] = val + sb.Title = val + } + if val, ok := updates["shot_type"].(string); ok && val != "" { + updateData["shot_type"] = val + sb.ShotType = val + } + if val, ok := updates["angle"].(string); ok && val != "" { + updateData["angle"] = val + sb.Angle = val + } + if val, ok := updates["movement"].(string); ok && val != "" { + updateData["movement"] = val + sb.Movement = val + } + if val, ok := updates["location"].(string); ok && val != "" { + updateData["location"] = val + sb.Location = val + } + if val, ok := updates["time"].(string); ok && val != "" { + updateData["time"] = val + sb.Time = val + } + if val, ok := updates["action"].(string); ok && val != "" { + updateData["action"] = val + sb.Action = val + } + if val, ok := updates["dialogue"].(string); ok && val != "" { + updateData["dialogue"] = val + sb.Dialogue = val + } + if val, ok := updates["result"].(string); ok && val != "" { + updateData["result"] = val + sb.Result = val + } + if val, ok := updates["atmosphere"].(string); ok && val != "" { + updateData["atmosphere"] = val + sb.Atmosphere = val + } + if val, ok := updates["description"].(string); ok && val != "" { + updateData["description"] = val + } + if val, ok := updates["bgm_prompt"].(string); ok && val != "" { + updateData["bgm_prompt"] = val + sb.BgmPrompt = val + } + if val, ok := updates["sound_effect"].(string); ok && val != "" { + updateData["sound_effect"] = val + sb.SoundEffect = val + } + if val, ok := updates["duration"].(float64); ok { + updateData["duration"] = int(val) + sb.Duration = int(val) + } + + // 使用当前数据库值填充缺失字段(用于生成提示词) + if sb.Title == "" && storyboard.Title != nil { + sb.Title = *storyboard.Title + } + if sb.ShotType == "" && storyboard.ShotType != nil { + sb.ShotType = *storyboard.ShotType + } + if sb.Angle == "" && storyboard.Angle != nil { + sb.Angle = *storyboard.Angle + } + if sb.Movement == "" && storyboard.Movement != nil { + sb.Movement = *storyboard.Movement + } + if sb.Location == "" && storyboard.Location != nil { + sb.Location = *storyboard.Location + } + if sb.Time == "" && storyboard.Time != nil { + sb.Time = *storyboard.Time + } + if sb.Action == "" && storyboard.Action != nil { + sb.Action = *storyboard.Action + } + if sb.Dialogue == "" && storyboard.Dialogue != nil { + sb.Dialogue = *storyboard.Dialogue + } + if sb.Result == "" && storyboard.Result != nil { + sb.Result = *storyboard.Result + } + if sb.Atmosphere == "" && storyboard.Atmosphere != nil { + sb.Atmosphere = *storyboard.Atmosphere + } + if sb.BgmPrompt == "" && storyboard.BgmPrompt != nil { + sb.BgmPrompt = *storyboard.BgmPrompt + } + if sb.SoundEffect == "" && storyboard.SoundEffect != nil { + sb.SoundEffect = *storyboard.SoundEffect + } + if sb.Duration == 0 { + sb.Duration = storyboard.Duration + } + + // 只重新生成video_prompt + // image_prompt不自动更新,因为可能对应多张已生成的帧图片 + videoPrompt := s.generateVideoPrompt(sb) + + updateData["video_prompt"] = videoPrompt + + // 更新数据库 + if err := s.db.Model(&storyboard).Updates(updateData).Error; err != nil { + return fmt.Errorf("failed to update storyboard: %w", err) + } + + s.log.Infow("Storyboard updated successfully", + "storyboard_id", storyboardID, + "fields_updated", len(updateData)) + + return nil +} diff --git a/application/services/task_service.go b/application/services/task_service.go new file mode 100644 index 0000000..41be4c0 --- /dev/null +++ b/application/services/task_service.go @@ -0,0 +1,113 @@ +package services + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/drama-generator/backend/domain/models" + "github.com/drama-generator/backend/pkg/logger" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type TaskService struct { + db *gorm.DB + log *logger.Logger +} + +func NewTaskService(db *gorm.DB, log *logger.Logger) *TaskService { + return &TaskService{ + db: db, + log: log, + } +} + +// CreateTask 创建新任务 +func (s *TaskService) CreateTask(taskType, resourceID string) (*models.AsyncTask, error) { + task := &models.AsyncTask{ + ID: uuid.New().String(), + Type: taskType, + Status: "pending", + Progress: 0, + ResourceID: resourceID, + } + + if err := s.db.Create(task).Error; err != nil { + return nil, fmt.Errorf("failed to create task: %w", err) + } + + return task, nil +} + +// UpdateTaskStatus 更新任务状态 +func (s *TaskService) UpdateTaskStatus(taskID, status string, progress int, message string) error { + updates := map[string]interface{}{ + "status": status, + "progress": progress, + "message": message, + "updated_at": time.Now(), + } + + if status == "completed" || status == "failed" { + now := time.Now() + updates["completed_at"] = &now + } + + return s.db.Model(&models.AsyncTask{}). + Where("id = ?", taskID). + Updates(updates).Error +} + +// UpdateTaskError 更新任务错误 +func (s *TaskService) UpdateTaskError(taskID string, err error) error { + now := time.Now() + return s.db.Model(&models.AsyncTask{}). + Where("id = ?", taskID). + Updates(map[string]interface{}{ + "status": "failed", + "error": err.Error(), + "progress": 0, + "completed_at": &now, + "updated_at": time.Now(), + }).Error +} + +// UpdateTaskResult 更新任务结果 +func (s *TaskService) UpdateTaskResult(taskID string, result interface{}) error { + resultJSON, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("failed to marshal result: %w", err) + } + + now := time.Now() + return s.db.Model(&models.AsyncTask{}). + Where("id = ?", taskID). + Updates(map[string]interface{}{ + "status": "completed", + "progress": 100, + "result": string(resultJSON), + "completed_at": &now, + "updated_at": time.Now(), + }).Error +} + +// GetTask 获取任务信息 +func (s *TaskService) GetTask(taskID string) (*models.AsyncTask, error) { + var task models.AsyncTask + if err := s.db.Where("id = ?", taskID).First(&task).Error; err != nil { + return nil, err + } + return &task, nil +} + +// GetTasksByResource 获取资源相关的所有任务 +func (s *TaskService) GetTasksByResource(resourceID string) ([]*models.AsyncTask, error) { + var tasks []*models.AsyncTask + if err := s.db.Where("resource_id = ?", resourceID). + Order("created_at DESC"). + Find(&tasks).Error; err != nil { + return nil, err + } + return tasks, nil +} diff --git a/application/services/upload_service.go b/application/services/upload_service.go new file mode 100644 index 0000000..5789c69 --- /dev/null +++ b/application/services/upload_service.go @@ -0,0 +1,109 @@ +package services + +import ( + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/drama-generator/backend/pkg/config" + "github.com/drama-generator/backend/pkg/logger" + "github.com/google/uuid" +) + +type UploadService struct { + storagePath string + baseURL string + log *logger.Logger +} + +func NewUploadService(cfg *config.Config, log *logger.Logger) (*UploadService, error) { + // 确保存储目录存在 + if err := os.MkdirAll(cfg.Storage.LocalPath, 0755); err != nil { + return nil, fmt.Errorf("failed to create storage directory: %w", err) + } + + return &UploadService{ + storagePath: cfg.Storage.LocalPath, + baseURL: cfg.Storage.BaseURL, + log: log, + }, nil +} + +// UploadFile 上传文件到本地存储 +func (s *UploadService) UploadFile(file io.Reader, fileName, contentType string, category string) (string, error) { + // 创建分类目录 + categoryPath := filepath.Join(s.storagePath, category) + if err := os.MkdirAll(categoryPath, 0755); err != nil { + return "", fmt.Errorf("failed to create category directory: %w", err) + } + + // 生成唯一文件名 + ext := filepath.Ext(fileName) + uniqueID := uuid.New().String() + timestamp := time.Now().Format("20060102_150405") + newFileName := fmt.Sprintf("%s_%s%s", timestamp, uniqueID, ext) + filePath := filepath.Join(categoryPath, newFileName) + + // 创建文件 + dst, err := os.Create(filePath) + if err != nil { + s.log.Errorw("Failed to create file", "error", err, "path", filePath) + return "", fmt.Errorf("创建文件失败: %w", err) + } + defer dst.Close() + + // 写入文件 + if _, err := io.Copy(dst, file); err != nil { + s.log.Errorw("Failed to write file", "error", err, "path", filePath) + return "", fmt.Errorf("写入文件失败: %w", err) + } + + // 构建访问URL + fileURL := fmt.Sprintf("%s/%s/%s", s.baseURL, category, newFileName) + + s.log.Infow("File uploaded successfully", "path", filePath, "url", fileURL) + return fileURL, nil +} + +// UploadCharacterImage 上传角色图片 +func (s *UploadService) UploadCharacterImage(file io.Reader, fileName, contentType string) (string, error) { + return s.UploadFile(file, fileName, contentType, "characters") +} + +// DeleteFile 删除本地文件 +func (s *UploadService) DeleteFile(fileURL string) error { + // 从URL中提取相对路径 + // URL格式: http://localhost:8080/static/characters/20060102_150405_uuid.jpg + relPath := s.extractRelativePathFromURL(fileURL) + if relPath == "" { + return fmt.Errorf("invalid file URL") + } + + filePath := filepath.Join(s.storagePath, relPath) + err := os.Remove(filePath) + if err != nil { + s.log.Errorw("Failed to delete file", "error", err, "path", filePath) + return fmt.Errorf("删除文件失败: %w", err) + } + + s.log.Infow("File deleted successfully", "path", filePath) + return nil +} + +// extractRelativePathFromURL 从URL中提取相对路径 +func (s *UploadService) extractRelativePathFromURL(fileURL string) string { + // 从baseURL后面提取路径 + // 例如: http://localhost:8080/static/characters/xxx.jpg -> characters/xxx.jpg + if len(fileURL) <= len(s.baseURL) { + return "" + } + return fileURL[len(s.baseURL)+1:] // +1 for the '/' +} + +// GetPresignedURL 本地存储不需要预签名URL,直接返回原URL +func (s *UploadService) GetPresignedURL(objectName string, expiry time.Duration) (string, error) { + // 本地存储通过静态文件服务直接访问,不需要预签名 + return fmt.Sprintf("%s/%s", s.baseURL, objectName), nil +} diff --git a/application/services/video_generation_service.go b/application/services/video_generation_service.go new file mode 100644 index 0000000..8169222 --- /dev/null +++ b/application/services/video_generation_service.go @@ -0,0 +1,513 @@ +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) { + 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 + endpoint := config.Endpoint + queryEndpoint := config.QueryEndpoint + model := modelName + if model == "" && len(config.Model) > 0 { + model = config.Model[0] + } + + switch provider { + case "doubao": + return video.NewVolcesArkClient(baseURL, apiKey, model, endpoint, queryEndpoint), 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 + case "openai": + return video.NewOpenAISoraClient(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") + } + + req := &GenerateVideoRequest{ + DramaID: fmt.Sprintf("%d", imageGen.DramaID), + StoryboardID: imageGen.StoryboardID, + ImageGenID: &imageGenID, + ImageURL: *imageGen.ImageURL, + Prompt: imageGen.Prompt, + Provider: "doubao", + } + + return s.GenerateVideo(req) +} + +func (s *VideoGenerationService) BatchGenerateVideosForEpisode(episodeID string) ([]*models.VideoGeneration, error) { + var episode models.Episode + if err := s.db.Preload("Storyboards").Where("id = ?", episodeID).First(&episode).Error; err != nil { + return nil, fmt.Errorf("episode not found") + } + + var results []*models.VideoGeneration + for _, storyboard := range episode.Storyboards { + if storyboard.ImagePrompt == nil { + continue + } + + var imageGen models.ImageGeneration + if err := s.db.Where("storyboard_id = ? AND status = ?", storyboard.ID, models.ImageStatusCompleted). + Order("created_at DESC").First(&imageGen).Error; err != nil { + s.log.Warnw("No completed image for storyboard", "storyboard_id", storyboard.ID) + continue + } + + videoGen, err := s.GenerateVideoFromImage(imageGen.ID) + if err != nil { + s.log.Errorw("Failed to generate video", "storyboard_id", storyboard.ID, "error", err) + continue + } + + results = append(results, videoGen) + } + + return results, nil +} + +func (s *VideoGenerationService) DeleteVideoGeneration(id uint) error { + return s.db.Delete(&models.VideoGeneration{}, id).Error +} diff --git a/application/services/video_merge_service.go b/application/services/video_merge_service.go new file mode 100644 index 0000000..0cabcf0 --- /dev/null +++ b/application/services/video_merge_service.go @@ -0,0 +1,545 @@ +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] + } + + switch 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 "doubao", "volces", "ark": + return video.NewVolcesArkClient(config.BaseURL, config.APIKey, model, config.Endpoint, config.QueryEndpoint), nil + default: + return video.NewVolcesArkClient(config.BaseURL, config.APIKey, model, config.Endpoint, config.QueryEndpoint), nil + } +} + +func (s *VideoMergeService) GetMerge(mergeID uint) (*models.VideoMerge, error) { + var merge models.VideoMerge + if err := s.db.Where("id = ? ", mergeID).First(&merge).Error; err != nil { + return nil, err + } + return &merge, nil +} + +func (s *VideoMergeService) ListMerges(episodeID *string, status string, page, pageSize int) ([]models.VideoMerge, int64, error) { + query := s.db.Model(&models.VideoMerge{}) + + if episodeID != nil && *episodeID != "" { + query = query.Where("episode_id = ?", *episodeID) + } + + if status != "" { + query = query.Where("status = ?", status) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var merges []models.VideoMerge + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&merges).Error; err != nil { + return nil, 0, err + } + + return merges, total, nil +} + +func (s *VideoMergeService) DeleteMerge(mergeID uint) error { + result := s.db.Where("id = ? ", mergeID).Delete(&models.VideoMerge{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("merge not found") + } + return nil +} + +// TimelineClip 时间线片段数据 +type TimelineClip struct { + AssetID string `json:"asset_id"` // 素材库视频ID(优先使用) + StoryboardID string `json:"storyboard_id"` // 分镜ID(fallback) + Order int `json:"order"` + StartTime float64 `json:"start_time"` + EndTime float64 `json:"end_time"` + Duration float64 `json:"duration"` + Transition map[string]interface{} `json:"transition"` +} + +// FinalizeEpisodeRequest 完成剧集制作请求 +type FinalizeEpisodeRequest struct { + EpisodeID string `json:"episode_id"` + Clips []TimelineClip `json:"clips"` +} + +// FinalizeEpisode 完成集数制作,根据时间线场景顺序合成最终视频 +func (s *VideoMergeService) FinalizeEpisode(episodeID string, timelineData *FinalizeEpisodeRequest) (map[string]interface{}, error) { + // 验证episode存在且属于该用户 + var episode models.Episode + if err := s.db.Preload("Drama").Preload("Storyboards").Where("id = ?", episodeID).First(&episode).Error; err != nil { + return nil, fmt.Errorf("episode not found") + } + + // 构建分镜ID映射 + sceneMap := make(map[string]models.Storyboard) + for _, scene := range episode.Storyboards { + sceneMap[fmt.Sprintf("%d", scene.ID)] = scene + } + + // 根据时间线数据构建场景片段 + var sceneClips []models.SceneClip + var skippedScenes []int + + if timelineData != nil && len(timelineData.Clips) > 0 { + // 使用前端提供的时间线数据 + for _, clip := range timelineData.Clips { + // 优先使用素材库中的视频(通过AssetID) + var videoURL string + var sceneID uint + + if clip.AssetID != "" { + // 从素材库获取视频URL + var asset models.Asset + if err := s.db.Where("id = ? AND type = ?", clip.AssetID, models.AssetTypeVideo).First(&asset).Error; err == nil { + videoURL = asset.URL + // 如果asset关联了storyboard,使用关联的storyboard_id + if asset.StoryboardID != nil { + sceneID = *asset.StoryboardID + } + s.log.Infow("Using video from asset library", "asset_id", clip.AssetID, "video_url", videoURL) + } else { + s.log.Warnw("Asset not found, will try storyboard video", "asset_id", clip.AssetID, "error", err) + } + } + + // 如果没有从素材库获取到视频,尝试从storyboard获取 + if videoURL == "" && clip.StoryboardID != "" { + scene, exists := sceneMap[clip.StoryboardID] + if !exists { + s.log.Warnw("Storyboard not found in episode, skipping", "storyboard_id", clip.StoryboardID) + continue + } + + if scene.VideoURL != nil && *scene.VideoURL != "" { + videoURL = *scene.VideoURL + sceneID = scene.ID + s.log.Infow("Using video from storyboard", "storyboard_id", clip.StoryboardID, "video_url", videoURL) + } + } + + // 如果仍然没有视频URL,跳过该片段 + if videoURL == "" { + s.log.Warnw("No video available for clip, skipping", "clip", clip) + if clip.StoryboardID != "" { + if scene, exists := sceneMap[clip.StoryboardID]; exists { + skippedScenes = append(skippedScenes, scene.StoryboardNumber) + } + } + continue + } + + sceneClip := models.SceneClip{ + SceneID: sceneID, + VideoURL: videoURL, + Duration: clip.Duration, + Order: clip.Order, + StartTime: clip.StartTime, + EndTime: clip.EndTime, + Transition: clip.Transition, + } + s.log.Infow("Adding scene clip with transition", + "scene_id", sceneID, + "order", clip.Order, + "transition", clip.Transition) + sceneClips = append(sceneClips, sceneClip) + } + } else { + // 没有时间线数据,使用默认场景顺序 + if len(episode.Storyboards) == 0 { + return nil, fmt.Errorf("no scenes found for this episode") + } + + order := 0 + for _, scene := range episode.Storyboards { + // 优先从素材库查找该分镜关联的视频 + var videoURL string + var asset models.Asset + if err := s.db.Where("storyboard_id = ? AND type = ? AND episode_id = ?", + scene.ID, models.AssetTypeVideo, episode.ID). + Order("created_at DESC"). + First(&asset).Error; err == nil { + videoURL = asset.URL + s.log.Infow("Using video from asset library for storyboard", + "storyboard_id", scene.ID, + "asset_id", asset.ID, + "video_url", videoURL) + } else if scene.VideoURL != nil && *scene.VideoURL != "" { + // 如果素材库没有,使用storyboard的video_url作为fallback + videoURL = *scene.VideoURL + s.log.Infow("Using fallback video from storyboard", + "storyboard_id", scene.ID, + "video_url", videoURL) + } + + // 跳过没有视频的场景 + if videoURL == "" { + s.log.Warnw("Scene has no video, skipping", "storyboard_number", scene.StoryboardNumber) + skippedScenes = append(skippedScenes, scene.StoryboardNumber) + continue + } + + clip := models.SceneClip{ + SceneID: scene.ID, + VideoURL: videoURL, + Duration: float64(scene.Duration), + Order: order, + } + sceneClips = append(sceneClips, clip) + order++ + } + } + + // 检查是否至少有一个场景可以合成 + if len(sceneClips) == 0 { + return nil, fmt.Errorf("no scenes with videos available for merging") + } + + // 创建视频合成任务 + title := fmt.Sprintf("%s - 第%d集", episode.Drama.Title, episode.EpisodeNum) + + finalReq := &MergeVideoRequest{ + EpisodeID: episodeID, + DramaID: fmt.Sprintf("%d", episode.DramaID), + Title: title, + Scenes: sceneClips, + Provider: "doubao", // 默认使用doubao + } + + // 执行视频合成 + videoMerge, err := s.MergeVideos(finalReq) + if err != nil { + return nil, fmt.Errorf("failed to start video merge: %w", err) + } + + // 更新episode状态为processing + s.db.Model(&episode).Updates(map[string]interface{}{ + "status": "processing", + }) + + result := map[string]interface{}{ + "message": "视频合成任务已创建,正在后台处理", + "merge_id": videoMerge.ID, + "episode_id": episodeID, + "scenes_count": len(sceneClips), + } + + // 如果有跳过的场景,添加提示信息 + if len(skippedScenes) > 0 { + result["skipped_scenes"] = skippedScenes + result["warning"] = fmt.Sprintf("已跳过 %d 个未生成视频的场景(场景编号:%v)", len(skippedScenes), skippedScenes) + } + + return result, nil +} diff --git a/configs/config.example.yaml b/configs/config.example.yaml new file mode 100644 index 0000000..ae972ac --- /dev/null +++ b/configs/config.example.yaml @@ -0,0 +1,28 @@ +app: + name: "Drama Generator" + version: "1.0.0" + debug: false + +server: + port: 8080 + host: "0.0.0.0" + cors_origins: + - "http://localhost:8080" + read_timeout: 600 + write_timeout: 600 + +database: + type: "sqlite" + path: "/data/drama.db" + max_idle: 10 + max_open: 100 + +storage: + type: "local" + local_path: "/data/storage" + base_url: "http://localhost:8080/static" + +ai: + default_text_provider: "openai" + default_image_provider: "openai" + default_video_provider: "doubao" diff --git a/configs/config.yaml b/configs/config.yaml new file mode 100644 index 0000000..e3e56a4 --- /dev/null +++ b/configs/config.yaml @@ -0,0 +1,30 @@ +app: + name: "Drama Generator API" + version: "1.0.0" + debug: true + +server: + port: 5678 + host: "0.0.0.0" + cors_origins: + - "http://localhost:3000" + - "http://localhost:5173" + - "http://localhost:3012" + read_timeout: 600 + write_timeout: 600 + +database: + type: "sqlite" + path: "./data/drama_generator.db" + max_idle: 10 + max_open: 100 + +storage: + type: "local" + local_path: "./data/storage" + base_url: "http://localhost:5678/static" + +ai: + default_text_provider: "openai" + default_image_provider: "openai" + default_video_provider: "doubao" diff --git a/domain/models/ai_config.go b/domain/models/ai_config.go new file mode 100644 index 0000000..2aade18 --- /dev/null +++ b/domain/models/ai_config.go @@ -0,0 +1,123 @@ +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 + Name string `gorm:"type:varchar(100);not null" json:"name"` + BaseURL string `gorm:"type:varchar(255);not null" json:"base_url"` + APIKey string `gorm:"type:varchar(255);not null" json:"api_key"` + Model ModelField `gorm:"type:text" json:"model"` + Endpoint string `gorm:"type:varchar(255)" json:"endpoint"` + QueryEndpoint string `gorm:"type:varchar(255)" json:"query_endpoint"` + Priority int `gorm:"default:0" json:"priority"` // 优先级,数值越大优先级越高 + IsDefault bool `gorm:"default:false" json:"is_default"` + IsActive bool `gorm:"default:true" json:"is_active"` + Settings string `gorm:"type:text" json:"settings"` + CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"` +} + +func (c *AIServiceConfig) TableName() string { + return "ai_service_configs" +} + +type AIServiceProvider struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"type:varchar(100);not null;uniqueIndex" json:"name"` + DisplayName string `gorm:"type:varchar(100);not null" json:"display_name"` + ServiceType string `gorm:"type:varchar(50);not null" json:"service_type"` + DefaultURL string `gorm:"type:varchar(255)" json:"default_url"` + Description string `gorm:"type:text" json:"description"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"` +} + +func (p *AIServiceProvider) TableName() string { + return "ai_service_providers" +} + +// ModelField 自定义类型,支持字符串或字符串数组 +type ModelField []string + +// Value 实现 driver.Valuer 接口,用于存储到数据库 +func (m ModelField) Value() (driver.Value, error) { + if len(m) == 0 { + return nil, nil + } + data, err := json.Marshal(m) + if err != nil { + return nil, err + } + return string(data), nil +} + +// Scan 实现 sql.Scanner 接口,用于从数据库读取 +func (m *ModelField) Scan(value interface{}) error { + if value == nil { + *m = []string{} + return nil + } + + var data []byte + switch v := value.(type) { + case []byte: + data = v + case string: + data = []byte(v) + default: + return errors.New("unsupported type for ModelField") + } + + // 尝试解析为数组 + var arr []string + if err := json.Unmarshal(data, &arr); err == nil { + *m = arr + return nil + } + + // 如果解析失败,尝试作为单个字符串处理 + var str string + if err := json.Unmarshal(data, &str); err == nil { + *m = []string{str} + return nil + } + + // 兼容旧数据:直接作为字符串 + *m = []string{string(data)} + return nil +} + +// MarshalJSON 实现 json.Marshaler 接口 +func (m ModelField) MarshalJSON() ([]byte, error) { + if len(m) == 0 { + return json.Marshal([]string{}) + } + return json.Marshal([]string(m)) +} + +// UnmarshalJSON 实现 json.Unmarshaler 接口,支持字符串或数组 +func (m *ModelField) UnmarshalJSON(data []byte) error { + // 尝试解析为数组 + var arr []string + if err := json.Unmarshal(data, &arr); err == nil { + *m = arr + return nil + } + + // 尝试解析为单个字符串 + var str string + if err := json.Unmarshal(data, &str); err == nil { + *m = []string{str} + return nil + } + + return errors.New("model field must be string or array of strings") +} diff --git a/domain/models/asset.go b/domain/models/asset.go new file mode 100644 index 0000000..dc5f5dd --- /dev/null +++ b/domain/models/asset.go @@ -0,0 +1,57 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type Asset struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + DramaID *uint `gorm:"index" json:"drama_id,omitempty"` + Drama *Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"` + + EpisodeID *uint `gorm:"index" json:"episode_id,omitempty"` + StoryboardID *uint `gorm:"index" json:"storyboard_id,omitempty"` + StoryboardNum *int `json:"storyboard_num,omitempty"` + + Name string `gorm:"type:varchar(200);not null" json:"name"` + Description *string `gorm:"type:text" json:"description,omitempty"` + Type AssetType `gorm:"type:varchar(20);not null;index" json:"type"` + Category *string `gorm:"type:varchar(50);index" json:"category,omitempty"` + URL string `gorm:"type:varchar(1000);not null" json:"url"` + ThumbnailURL *string `gorm:"type:varchar(1000)" json:"thumbnail_url,omitempty"` + LocalPath *string `gorm:"type:varchar(500)" json:"local_path,omitempty"` + + FileSize *int64 `json:"file_size,omitempty"` + MimeType *string `gorm:"type:varchar(100)" json:"mime_type,omitempty"` + Width *int `json:"width,omitempty"` + Height *int `json:"height,omitempty"` + Duration *int `json:"duration,omitempty"` + Format *string `gorm:"type:varchar(50)" json:"format,omitempty"` + + ImageGenID *uint `gorm:"index" json:"image_gen_id,omitempty"` + ImageGen ImageGeneration `gorm:"foreignKey:ImageGenID" json:"image_gen,omitempty"` + + VideoGenID *uint `gorm:"index" json:"video_gen_id,omitempty"` + VideoGen VideoGeneration `gorm:"foreignKey:VideoGenID" json:"video_gen,omitempty"` + + IsFavorite bool `gorm:"default:false" json:"is_favorite"` + ViewCount int `gorm:"default:0" json:"view_count"` +} + +type AssetType string + +const ( + AssetTypeImage AssetType = "image" + AssetTypeVideo AssetType = "video" + AssetTypeAudio AssetType = "audio" +) + +func (Asset) TableName() string { + return "assets" +} diff --git a/domain/models/character_library.go b/domain/models/character_library.go new file mode 100644 index 0000000..0d037d0 --- /dev/null +++ b/domain/models/character_library.go @@ -0,0 +1,25 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// CharacterLibrary 角色库模型 +type CharacterLibrary struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"type:varchar(100);not null" json:"name"` + Category *string `gorm:"type:varchar(50)" json:"category"` + ImageURL string `gorm:"type:varchar(500);not null" json:"image_url"` + Description *string `gorm:"type:text" json:"description"` + Tags *string `gorm:"type:varchar(500)" json:"tags"` + SourceType string `gorm:"type:varchar(20);default:'generated'" json:"source_type"` // generated, uploaded + CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (c *CharacterLibrary) TableName() string { + return "character_libraries" +} diff --git a/domain/models/drama.go b/domain/models/drama.go new file mode 100644 index 0000000..15b3c72 --- /dev/null +++ b/domain/models/drama.go @@ -0,0 +1,148 @@ +package models + +import ( + "time" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +type Drama struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Title string `gorm:"type:varchar(200);not null" json:"title"` + Description *string `gorm:"type:text" json:"description"` + Genre *string `gorm:"type:varchar(50)" json:"genre"` + Style string `gorm:"type:varchar(50);default:'realistic'" json:"style"` + TotalEpisodes int `gorm:"default:1" json:"total_episodes"` + TotalDuration int `gorm:"default:0" json:"total_duration"` + Status string `gorm:"type:varchar(20);default:'draft';not null" json:"status"` + Thumbnail *string `gorm:"type:varchar(500)" json:"thumbnail"` + Tags datatypes.JSON `gorm:"type:json" json:"tags"` + Metadata datatypes.JSON `gorm:"type:json" json:"metadata"` + CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Episodes []Episode `gorm:"foreignKey:DramaID" json:"episodes,omitempty"` + Characters []Character `gorm:"foreignKey:DramaID" json:"characters,omitempty"` + Scenes []Scene `gorm:"foreignKey:DramaID" json:"scenes,omitempty"` +} + +func (d *Drama) TableName() string { + return "dramas" +} + +type Character struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + DramaID uint `gorm:"not null;index" json:"drama_id"` + Name string `gorm:"type:varchar(100);not null" json:"name"` + Role *string `gorm:"type:varchar(50)" json:"role"` + Description *string `gorm:"type:text" json:"description"` + Appearance *string `gorm:"type:text" json:"appearance"` + Personality *string `gorm:"type:text" json:"personality"` + VoiceStyle *string `gorm:"type:varchar(200)" json:"voice_style"` + ImageURL *string `gorm:"type:varchar(500)" json:"image_url"` + ReferenceImages datatypes.JSON `gorm:"type:json" json:"reference_images"` + SeedValue *string `gorm:"type:varchar(100)" json:"seed_value"` + SortOrder int `gorm:"default:0" json:"sort_order"` + CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // 多对多关系:角色可以属于多个章节 + Episodes []Episode `gorm:"many2many:episode_characters;" json:"episodes,omitempty"` + + // 运行时字段(不存储到数据库) + ImageGenerationStatus *string `gorm:"-" json:"image_generation_status,omitempty"` + ImageGenerationError *string `gorm:"-" json:"image_generation_error,omitempty"` +} + +func (c *Character) TableName() string { + return "characters" +} + +type Episode struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + DramaID uint `gorm:"not null;index" json:"drama_id"` + EpisodeNum int `gorm:"column:episode_number;not null" json:"episode_number"` + Title string `gorm:"type:varchar(200);not null" json:"title"` + ScriptContent *string `gorm:"type:longtext" json:"script_content"` + Description *string `gorm:"type:text" json:"description"` + Duration int `gorm:"default:0" json:"duration"` // 总时长(秒) + Status string `gorm:"type:varchar(20);default:'draft'" json:"status"` + VideoURL *string `gorm:"type:varchar(500)" json:"video_url"` + Thumbnail *string `gorm:"type:varchar(500)" json:"thumbnail"` + CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // 关联 + Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"` + Storyboards []Storyboard `gorm:"foreignKey:EpisodeID" json:"storyboards,omitempty"` + Characters []Character `gorm:"many2many:episode_characters;" json:"characters,omitempty"` + Scenes []Scene `gorm:"foreignKey:EpisodeID" json:"scenes,omitempty"` +} + +func (e *Episode) TableName() string { + return "episodes" +} + +type Storyboard struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + EpisodeID uint `gorm:"not null;index:idx_storyboards_episode_id" json:"episode_id"` + SceneID *uint `gorm:"index:idx_storyboards_scene_id;column:scene_id" json:"scene_id"` + StoryboardNumber int `gorm:"not null;column:storyboard_number" json:"storyboard_number"` + Title *string `gorm:"size:255" json:"title"` + Location *string `gorm:"size:255" json:"location"` + Time *string `gorm:"size:255" json:"time"` + ShotType *string `gorm:"size:100" json:"shot_type"` + Angle *string `gorm:"size:100" json:"angle"` + Movement *string `gorm:"size:100" json:"movement"` + Action *string `gorm:"type:text" json:"action"` + Result *string `gorm:"type:text" json:"result"` + Atmosphere *string `gorm:"type:text" json:"atmosphere"` + ImagePrompt *string `gorm:"type:text" json:"image_prompt"` + VideoPrompt *string `gorm:"type:text" json:"video_prompt"` + BgmPrompt *string `gorm:"type:text" json:"bgm_prompt"` + SoundEffect *string `gorm:"size:255" json:"sound_effect"` + Dialogue *string `gorm:"type:text" json:"dialogue"` + Description *string `gorm:"type:text" json:"description"` + Duration int `gorm:"default:5" json:"duration"` + ComposedImage *string `gorm:"type:text" json:"composed_image"` + VideoURL *string `gorm:"type:text" json:"video_url"` + Status string `gorm:"type:varchar(20);default:'pending'" json:"status"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Episode Episode `gorm:"foreignKey:EpisodeID;constraint:OnDelete:CASCADE" json:"episode,omitempty"` + Background *Scene `gorm:"foreignKey:SceneID" json:"background,omitempty"` + Characters []Character `gorm:"many2many:storyboard_characters;" json:"characters,omitempty"` +} + +func (s *Storyboard) TableName() string { + return "storyboards" +} + +type Scene struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + DramaID uint `gorm:"not null;index:idx_scenes_drama_id" json:"drama_id"` + EpisodeID *uint `gorm:"index:idx_scenes_episode_id" json:"episode_id"` // 场景所属章节 + Location string `gorm:"type:varchar(200);not null" json:"location"` + Time string `gorm:"type:varchar(100);not null" json:"time"` + Prompt string `gorm:"type:text;not null" json:"prompt"` + StoryboardCount int `gorm:"default:1" json:"storyboard_count"` + ImageURL *string `gorm:"type:varchar(500)" json:"image_url"` + Status string `gorm:"type:varchar(20);default:'pending'" json:"status"` // pending, generated, failed + CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // 运行时字段(不存储到数据库) + ImageGenerationStatus *string `gorm:"-" json:"image_generation_status,omitempty"` + ImageGenerationError *string `gorm:"-" json:"image_generation_error,omitempty"` +} + +func (s *Scene) TableName() string { + return "scenes" +} diff --git a/domain/models/frame_prompt.go b/domain/models/frame_prompt.go new file mode 100644 index 0000000..8ea547e --- /dev/null +++ b/domain/models/frame_prompt.go @@ -0,0 +1,28 @@ +package models + +import "time" + +// FramePrompt 帧提示词存储表 +type FramePrompt struct { + ID uint `gorm:"primarykey" json:"id"` + StoryboardID uint `gorm:"not null;index:idx_frame_prompts_storyboard" json:"storyboard_id"` + FrameType string `gorm:"size:20;not null;index:idx_frame_prompts_type" json:"frame_type"` // first, key, last, panel, action + Prompt string `gorm:"type:text;not null" json:"prompt"` + Description *string `gorm:"type:text" json:"description,omitempty"` + Layout *string `gorm:"size:50" json:"layout,omitempty"` // 仅用于panel/action类型,如 horizontal_3 + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (FramePrompt) TableName() string { + return "frame_prompts" +} + +// FrameType 帧类型常量 +const ( + FrameTypeFirst = "first" + FrameTypeKey = "key" + FrameTypeLast = "last" + FrameTypePanel = "panel" + FrameTypeAction = "action" +) diff --git a/domain/models/image_generation.go b/domain/models/image_generation.go new file mode 100644 index 0000000..cd37ef2 --- /dev/null +++ b/domain/models/image_generation.go @@ -0,0 +1,75 @@ +package models + +import ( + "time" + + "gorm.io/datatypes" +) + +type ImageGeneration struct { + ID uint `gorm:"primarykey" json:"id"` + StoryboardID *uint `gorm:"index" json:"storyboard_id,omitempty"` + DramaID uint `gorm:"not null;index" json:"drama_id"` + SceneID *uint `gorm:"index" json:"scene_id,omitempty"` + CharacterID *uint `gorm:"index" json:"character_id,omitempty"` + ImageType string `gorm:"size:20;index;default:'storyboard'" json:"image_type"` + FrameType *string `gorm:"size:20" json:"frame_type,omitempty"` + Provider string `gorm:"size:50;not null" json:"provider"` + Prompt string `gorm:"type:text;not null" json:"prompt"` + NegPrompt *string `gorm:"column:negative_prompt;type:text" json:"negative_prompt,omitempty"` + Model string `gorm:"size:100" json:"model"` + Size string `gorm:"size:20" json:"size"` + Quality string `gorm:"size:20" json:"quality"` + Style *string `gorm:"size:50" json:"style,omitempty"` + Steps *int `json:"steps,omitempty"` + CfgScale *float64 `json:"cfg_scale,omitempty"` + Seed *int64 `json:"seed,omitempty"` + ImageURL *string `gorm:"type:text" json:"image_url,omitempty"` + MinioURL *string `gorm:"type:text" json:"minio_url,omitempty"` + LocalPath *string `gorm:"type:text" json:"local_path,omitempty"` + Status ImageGenerationStatus `gorm:"size:20;not null;default:'pending'" json:"status"` + TaskID *string `gorm:"size:200" json:"task_id,omitempty"` + ErrorMsg *string `gorm:"type:text" json:"error_msg,omitempty"` + Width *int `json:"width,omitempty"` + Height *int `json:"height,omitempty"` + ReferenceImages datatypes.JSON `gorm:"type:json" json:"reference_images,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + + Storyboard *Storyboard `gorm:"foreignKey:StoryboardID" json:"storyboard,omitempty"` + Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"` + Scene *Scene `gorm:"foreignKey:SceneID" json:"scene,omitempty"` + Character *Character `gorm:"foreignKey:CharacterID" json:"character,omitempty"` +} + +func (ImageGeneration) TableName() string { + return "image_generations" +} + +type ImageGenerationStatus string + +const ( + ImageStatusPending ImageGenerationStatus = "pending" + ImageStatusProcessing ImageGenerationStatus = "processing" + ImageStatusCompleted ImageGenerationStatus = "completed" + ImageStatusFailed ImageGenerationStatus = "failed" +) + +type ImageProvider string + +const ( + ProviderOpenAI ImageProvider = "openai" + ProviderMidjourney ImageProvider = "midjourney" + ProviderStableDiffusion ImageProvider = "stable_diffusion" + ProviderDALLE ImageProvider = "dalle" +) + +// ImageType 图片类型 +type ImageType string + +const ( + ImageTypeCharacter ImageType = "character" // 角色图片 + ImageTypeScene ImageType = "scene" // 场景图片 + ImageTypeStoryboard ImageType = "storyboard" // 分镜图片 +) diff --git a/domain/models/task.go b/domain/models/task.go new file mode 100644 index 0000000..489e880 --- /dev/null +++ b/domain/models/task.go @@ -0,0 +1,23 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// AsyncTask 异步任务模型 +type AsyncTask struct { + ID string `gorm:"primaryKey;size:36" json:"id"` + Type string `gorm:"size:50;not null;index" json:"type"` // 任务类型:storyboard_generation + Status string `gorm:"size:20;not null;index" json:"status"` // pending, processing, completed, failed + Progress int `gorm:"default:0" json:"progress"` // 0-100 + Message string `gorm:"size:500" json:"message,omitempty"` // 当前状态消息 + Error string `gorm:"type:text" json:"error,omitempty"` // 错误信息 + Result string `gorm:"type:text" json:"result,omitempty"` // JSON格式的结果数据 + ResourceID string `gorm:"size:36;index" json:"resource_id"` // 关联资源ID(如episode_id) + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} diff --git a/domain/models/timeline.go b/domain/models/timeline.go new file mode 100644 index 0000000..20af9af --- /dev/null +++ b/domain/models/timeline.go @@ -0,0 +1,178 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type Timeline struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + DramaID uint `gorm:"not null;index" json:"drama_id"` + Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"` + + EpisodeID *uint `gorm:"index" json:"episode_id,omitempty"` + Episode *Episode `gorm:"foreignKey:EpisodeID" json:"episode,omitempty"` + + Name string `gorm:"type:varchar(200);not null" json:"name"` + Description *string `gorm:"type:text" json:"description,omitempty"` + + Duration int `gorm:"default:0" json:"duration"` + FPS int `gorm:"default:30" json:"fps"` + Resolution *string `gorm:"type:varchar(50)" json:"resolution,omitempty"` + + Status TimelineStatus `gorm:"type:varchar(20);not null;default:'draft';index" json:"status"` + + Tracks []TimelineTrack `gorm:"foreignKey:TimelineID" json:"tracks,omitempty"` +} + +type TimelineStatus string + +const ( + TimelineStatusDraft TimelineStatus = "draft" + TimelineStatusEditing TimelineStatus = "editing" + TimelineStatusCompleted TimelineStatus = "completed" + TimelineStatusExporting TimelineStatus = "exporting" +) + +func (Timeline) TableName() string { + return "timelines" +} + +type TimelineTrack struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + TimelineID uint `gorm:"not null;index" json:"timeline_id"` + Timeline Timeline `gorm:"foreignKey:TimelineID" json:"-"` + + Name string `gorm:"type:varchar(100);not null" json:"name"` + Type TrackType `gorm:"type:varchar(20);not null" json:"type"` + Order int `gorm:"not null;default:0" json:"order"` + IsLocked bool `gorm:"default:false" json:"is_locked"` + IsMuted bool `gorm:"default:false" json:"is_muted"` + Volume *int `gorm:"default:100" json:"volume,omitempty"` + + Clips []TimelineClip `gorm:"foreignKey:TrackID" json:"clips,omitempty"` +} + +type TrackType string + +const ( + TrackTypeVideo TrackType = "video" + TrackTypeAudio TrackType = "audio" + TrackTypeText TrackType = "text" +) + +func (TimelineTrack) TableName() string { + return "timeline_tracks" +} + +type TimelineClip struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + TrackID uint `gorm:"not null;index" json:"track_id"` + Track TimelineTrack `gorm:"foreignKey:TrackID" json:"-"` + + AssetID *uint `gorm:"index" json:"asset_id,omitempty"` + Asset Asset `gorm:"foreignKey:AssetID" json:"asset,omitempty"` + + StoryboardID *uint `gorm:"index" json:"storyboard_id,omitempty"` + Storyboard *Storyboard `gorm:"foreignKey:StoryboardID" json:"storyboard,omitempty"` + + Name string `gorm:"type:varchar(200)" json:"name"` + + StartTime int `gorm:"not null" json:"start_time"` + EndTime int `gorm:"not null" json:"end_time"` + Duration int `gorm:"not null" json:"duration"` + + TrimStart *int `json:"trim_start,omitempty"` + TrimEnd *int `json:"trim_end,omitempty"` + + Speed *float64 `gorm:"default:1.0" json:"speed,omitempty"` + + Volume *int `json:"volume,omitempty"` + IsMuted bool `gorm:"default:false" json:"is_muted"` + FadeIn *int `json:"fade_in,omitempty"` + FadeOut *int `json:"fade_out,omitempty"` + + TransitionIn *uint `gorm:"index" json:"transition_in_id,omitempty"` + TransitionOut *uint `gorm:"index" json:"transition_out_id,omitempty"` + InTransition ClipTransition `gorm:"foreignKey:TransitionIn" json:"in_transition,omitempty"` + OutTransition ClipTransition `gorm:"foreignKey:TransitionOut" json:"out_transition,omitempty"` + + Effects []ClipEffect `gorm:"foreignKey:ClipID" json:"effects,omitempty"` +} + +func (TimelineClip) TableName() string { + return "timeline_clips" +} + +type ClipTransition struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Type TransitionType `gorm:"type:varchar(50);not null" json:"type"` + Duration int `gorm:"not null;default:500" json:"duration"` + Easing *string `gorm:"type:varchar(50)" json:"easing,omitempty"` + + Config map[string]interface{} `gorm:"serializer:json" json:"config,omitempty"` +} + +type TransitionType string + +const ( + TransitionTypeFade TransitionType = "fade" + TransitionTypeCrossFade TransitionType = "crossfade" + TransitionTypeSlide TransitionType = "slide" + TransitionTypeWipe TransitionType = "wipe" + TransitionTypeZoom TransitionType = "zoom" + TransitionTypeDissolve TransitionType = "dissolve" +) + +func (ClipTransition) TableName() string { + return "clip_transitions" +} + +type ClipEffect struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + ClipID uint `gorm:"not null;index" json:"clip_id"` + Clip TimelineClip `gorm:"foreignKey:ClipID" json:"-"` + + Type EffectType `gorm:"type:varchar(50);not null" json:"type"` + Name string `gorm:"type:varchar(100)" json:"name"` + IsEnabled bool `gorm:"default:true" json:"is_enabled"` + Order int `gorm:"default:0" json:"order"` + + Config map[string]interface{} `gorm:"serializer:json" json:"config,omitempty"` +} + +type EffectType string + +const ( + EffectTypeFilter EffectType = "filter" + EffectTypeColor EffectType = "color" + EffectTypeBlur EffectType = "blur" + EffectTypeBrightness EffectType = "brightness" + EffectTypeContrast EffectType = "contrast" + EffectTypeSaturation EffectType = "saturation" +) + +func (ClipEffect) TableName() string { + return "clip_effects" +} diff --git a/domain/models/video_generation.go b/domain/models/video_generation.go new file mode 100644 index 0000000..03f8d59 --- /dev/null +++ b/domain/models/video_generation.go @@ -0,0 +1,79 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type VideoGeneration struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + StoryboardID *uint `gorm:"index" json:"storyboard_id,omitempty"` + Storyboard *Storyboard `gorm:"foreignKey:StoryboardID" json:"storyboard,omitempty"` + + DramaID uint `gorm:"not null;index" json:"drama_id"` + Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"` + + Provider string `gorm:"type:varchar(50);not null;index" json:"provider"` + Prompt string `gorm:"type:text;not null" json:"prompt"` + Model string `gorm:"type:varchar(100)" json:"model,omitempty"` + + ImageGenID *uint `gorm:"index" json:"image_gen_id,omitempty"` + ImageGen ImageGeneration `gorm:"foreignKey:ImageGenID" json:"image_gen,omitempty"` + + // 参考图模式:single(单图), first_last(首尾帧), multiple(多图), none(无) + ReferenceMode *string `gorm:"type:varchar(20)" json:"reference_mode,omitempty"` + + ImageURL *string `gorm:"type:varchar(1000)" json:"image_url,omitempty"` + FirstFrameURL *string `gorm:"type:varchar(1000)" json:"first_frame_url,omitempty"` + LastFrameURL *string `gorm:"type:varchar(1000)" json:"last_frame_url,omitempty"` + ReferenceImageURLs *string `gorm:"type:text" json:"reference_image_urls,omitempty"` // JSON数组存储多张参考图 + + Duration *int `json:"duration,omitempty"` + FPS *int `json:"fps,omitempty"` + Resolution *string `gorm:"type:varchar(50)" json:"resolution,omitempty"` + AspectRatio *string `gorm:"type:varchar(20)" json:"aspect_ratio,omitempty"` + Style *string `gorm:"type:varchar(100)" json:"style,omitempty"` + MotionLevel *int `json:"motion_level,omitempty"` + CameraMotion *string `gorm:"type:varchar(100)" json:"camera_motion,omitempty"` + Seed *int64 `json:"seed,omitempty"` + + VideoURL *string `gorm:"type:varchar(1000)" json:"video_url,omitempty"` + MinioURL *string `gorm:"type:varchar(1000)" json:"minio_url,omitempty"` + LocalPath *string `gorm:"type:varchar(500)" json:"local_path,omitempty"` + + Status VideoStatus `gorm:"type:varchar(20);not null;default:'pending';index" json:"status"` + TaskID *string `gorm:"type:varchar(200);index" json:"task_id,omitempty"` + + ErrorMsg *string `gorm:"type:text" json:"error_msg,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + + Width *int `json:"width,omitempty"` + Height *int `json:"height,omitempty"` +} + +type VideoStatus string + +const ( + VideoStatusPending VideoStatus = "pending" + VideoStatusProcessing VideoStatus = "processing" + VideoStatusCompleted VideoStatus = "completed" + VideoStatusFailed VideoStatus = "failed" +) + +type VideoProvider string + +const ( + VideoProviderRunway VideoProvider = "runway" + VideoProviderPika VideoProvider = "pika" + VideoProviderDoubao VideoProvider = "doubao" + VideoProviderOpenAI VideoProvider = "openai" +) + +func (VideoGeneration) TableName() string { + return "video_generations" +} diff --git a/domain/models/video_merge.go b/domain/models/video_merge.go new file mode 100644 index 0000000..0f44f2f --- /dev/null +++ b/domain/models/video_merge.go @@ -0,0 +1,52 @@ +package models + +import ( + "time" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +type VideoMergeStatus string + +const ( + VideoMergeStatusPending VideoMergeStatus = "pending" + VideoMergeStatusProcessing VideoMergeStatus = "processing" + VideoMergeStatusCompleted VideoMergeStatus = "completed" + VideoMergeStatusFailed VideoMergeStatus = "failed" +) + +type VideoMerge struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + EpisodeID uint `gorm:"not null;index" json:"episode_id"` + DramaID uint `gorm:"not null;index" json:"drama_id"` + Title string `gorm:"type:varchar(200)" json:"title"` + Provider string `gorm:"type:varchar(50);not null" json:"provider"` + Model *string `gorm:"type:varchar(100)" json:"model,omitempty"` + Status VideoMergeStatus `gorm:"type:varchar(20);not null;default:'pending'" json:"status"` + Scenes datatypes.JSON `gorm:"type:json;not null" json:"scenes"` + MergedURL *string `gorm:"type:varchar(500)" json:"merged_url,omitempty"` + Duration *int `gorm:"type:int" json:"duration,omitempty"` + TaskID *string `gorm:"type:varchar(100)" json:"task_id,omitempty"` + ErrorMsg *string `gorm:"type:text" json:"error_msg,omitempty"` + CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Episode Episode `gorm:"foreignKey:EpisodeID" json:"episode,omitempty"` + Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"` +} + +type SceneClip struct { + SceneID uint `json:"scene_id"` + VideoURL string `json:"video_url"` + StartTime float64 `json:"start_time"` + EndTime float64 `json:"end_time"` + Duration float64 `json:"duration"` + Order int `json:"order"` + Transition map[string]interface{} `json:"transition"` +} + +func (v *VideoMerge) TableName() string { + return "video_merges" +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7d91b00 --- /dev/null +++ b/go.mod @@ -0,0 +1,71 @@ +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/minio/minio-go/v7 v7.0.97 + 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 +) + +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-ini/ini v1.67.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/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/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // 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.19 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/minio/crc64nvme v1.1.0 // indirect + github.com/minio/md5-simd v1.1.2 // 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/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/rs/xid v1.6.0 // 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/subosito/gotenv v1.6.0 // indirect + github.com/tinylib/msgp v1.3.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-20230905200255-921286631fa9 // 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..437b3cf --- /dev/null +++ b/go.sum @@ -0,0 +1,617 @@ +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-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +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/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/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/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +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/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +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.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/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/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q= +github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ= +github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk= +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/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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +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/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/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +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/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= +github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +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-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +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/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/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/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= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/infrastructure/database/database.go b/infrastructure/database/database.go new file mode 100644 index 0000000..c326d94 --- /dev/null +++ b/infrastructure/database/database.go @@ -0,0 +1,76 @@ +package database + +import ( + "fmt" + "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" + "gorm.io/gorm/logger" +) + +func NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) { + dsn := cfg.DSN() + + gormConfig := &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + } + + var db *gorm.DB + var err error + + if cfg.Type == "sqlite" { + db, err = gorm.Open(sqlite.Open(dsn), 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) + } + + sqlDB.SetMaxIdleConns(cfg.MaxIdle) + sqlDB.SetMaxOpenConns(cfg.MaxOpen) + sqlDB.SetConnMaxLifetime(time.Hour) + + if err := sqlDB.Ping(); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + return db, nil +} + +func AutoMigrate(db *gorm.DB) error { + return db.AutoMigrate( + // 核心模型 + &models.Drama{}, + &models.Episode{}, + &models.Character{}, + &models.Scene{}, + &models.Storyboard{}, + + // 生成相关 + &models.ImageGeneration{}, + &models.VideoGeneration{}, + &models.VideoMerge{}, + + // AI配置 + &models.AIServiceConfig{}, + &models.AIServiceProvider{}, + + // 资源管理 + &models.Asset{}, + &models.CharacterLibrary{}, + + // 任务管理 + &models.AsyncTask{}, + ) +} diff --git a/infrastructure/external/ffmpeg/ffmpeg.go b/infrastructure/external/ffmpeg/ffmpeg.go new file mode 100644 index 0000000..dca98b6 --- /dev/null +++ b/infrastructure/external/ffmpeg/ffmpeg.go @@ -0,0 +1,462 @@ +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,直接复制整个视频 + if (startTime == 0 && endTime == 0) || endTime <= startTime { + f.log.Infow("No valid trim range, copying entire video") + + cmd := exec.Command("ffmpeg", + "-i", inputPath, + "-c", "copy", + "-y", + outputPath, + ) + + output, err := cmd.CombinedOutput() + if err != nil { + f.log.Errorw("FFmpeg copy failed", "error", err, "output", string(output)) + return fmt.Errorf("ffmpeg copy failed: %w, output: %s", err, string(output)) + } + + f.log.Infow("Video copied successfully", "output", outputPath) + return nil + } + + // 使用FFmpeg裁剪视频 + // -i: 输入文件 + // -ss: 开始时间(秒) + // -to: 结束时间(秒)或使用-t指定持续时间 + // -c copy: 直接复制流,不重新编码(速度快) + // -avoid_negative_ts 1: 避免负时间戳问题 + var cmd *exec.Cmd + if endTime > 0 { + // 有明确的结束时间 + cmd = exec.Command("ffmpeg", + "-i", inputPath, + "-ss", fmt.Sprintf("%.2f", startTime), + "-to", fmt.Sprintf("%.2f", endTime), + "-c", "copy", + "-avoid_negative_ts", "1", + "-y", // 覆盖输出文件 + outputPath, + ) + } else { + // 只有开始时间,裁剪到视频末尾 + cmd = exec.Command("ffmpeg", + "-i", inputPath, + "-ss", fmt.Sprintf("%.2f", startTime), + "-c", "copy", + "-avoid_negative_ts", "1", + "-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) + } + + // 构建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 filterParts []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("[0:v][1:v]") + } else { + inputLabel = fmt.Sprintf("[v%02d][%d:v]", i-1, i+1) + } + + if i == len(inputPaths)-2 { + outputLabel = "[outv]" + } else { + outputLabel = fmt.Sprintf("[v%02d]", i) + } + + filterPart := fmt.Sprintf("%sxfade=transition=%s:duration=%.1f:offset=%.1f%s", + inputLabel, transitionType, transitionDuration, offset, outputLabel) + filterParts = append(filterParts, filterPart) + } + + filterComplex := strings.Join(filterParts, ";") + + // 音频处理:直接concat连接,不做交叉淡入淡出 + // 这样可以避免音频提前播放的问题 + var audioConcat strings.Builder + for i := 0; i < len(inputPaths); i++ { + audioConcat.WriteString(fmt.Sprintf("[%d:a]", i)) + } + audioConcat.WriteString(fmt.Sprintf("concat=n=%d:v=0:a=1[outa]", len(inputPaths))) + + fullFilter := filterComplex + ";" + audioConcat.String() + + // 构建完整命令 + args = append(args, + "-filter_complex", fullFilter, + "-map", "[outv]", + "-map", "[outa]", + "-c:v", "libx264", + "-preset", "medium", + "-crf", "23", + "-c:a", "aac", + "-b:a", "128k", + "-y", + outputPath, + ) + + f.log.Infow("Running FFmpeg with transitions", "filter", fullFilter) + + 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) copyFile(src, dst string) error { + cmd := exec.Command("cp", src, dst) + output, err := cmd.CombinedOutput() + if err != nil { + f.log.Errorw("File copy failed", "error", err, "output", string(output)) + return fmt.Errorf("copy failed: %w", err) + } + return nil +} + +func (f *FFmpeg) cleanup(paths []string) { + for _, path := range paths { + if err := os.Remove(path); err != nil { + f.log.Warnw("Failed to cleanup file", "path", path, "error", err) + } + } +} + +func (f *FFmpeg) CleanupTempDir() error { + return os.RemoveAll(f.tempDir) +} diff --git a/infrastructure/scheduler/resource_transfer_scheduler.go b/infrastructure/scheduler/resource_transfer_scheduler.go new file mode 100644 index 0000000..64795ea --- /dev/null +++ b/infrastructure/scheduler/resource_transfer_scheduler.go @@ -0,0 +1,240 @@ +package scheduler + +import ( + "time" + + "github.com/drama-generator/backend/application/services" + "github.com/drama-generator/backend/pkg/logger" + "github.com/robfig/cron/v3" + "gorm.io/gorm" +) + +type ResourceTransferScheduler struct { + cron *cron.Cron + transferService *services.ResourceTransferService + db *gorm.DB + log *logger.Logger + running bool +} + +func NewResourceTransferScheduler( + transferService *services.ResourceTransferService, + db *gorm.DB, + log *logger.Logger, +) *ResourceTransferScheduler { + return &ResourceTransferScheduler{ + cron: cron.New(cron.WithSeconds()), + transferService: transferService, + db: db, + log: log, + running: false, + } +} + +// Start 启动定时任务 +func (s *ResourceTransferScheduler) Start() error { + if s.running { + s.log.Warn("Resource transfer scheduler already running") + return nil + } + + s.log.Info("Starting resource transfer scheduler...") + + // 每小时执行一次资源转存任务 + _, err := s.cron.AddFunc("0 0 * * * *", func() { + s.log.Info("Starting scheduled resource transfer task") + s.transferPendingResources() + }) + if err != nil { + return err + } + + // 每天凌晨2点执行完整扫描 + _, err = s.cron.AddFunc("0 0 2 * * *", func() { + s.log.Info("Starting daily full resource scan and transfer") + s.transferAllPendingResources() + }) + if err != nil { + return err + } + + s.cron.Start() + s.running = true + s.log.Info("Resource transfer scheduler started successfully") + + return nil +} + +// Stop 停止定时任务 +func (s *ResourceTransferScheduler) Stop() { + if !s.running { + return + } + + s.log.Info("Stopping resource transfer scheduler...") + ctx := s.cron.Stop() + <-ctx.Done() + s.running = false + s.log.Info("Resource transfer scheduler stopped") +} + +// transferPendingResources 转存最近生成的待转存资源(最近24小时) +func (s *ResourceTransferScheduler) transferPendingResources() { + s.log.Info("Scanning for pending resources to transfer (last 24 hours)...") + + // 查找最近24小时内完成的、还未转存的图片和视频 + type DramaCount struct { + DramaID string + Count int64 + } + + // 统计每个剧本的待转存图片数量 + var imageDramas []DramaCount + s.db.Raw(` + SELECT drama_id, COUNT(*) as count + FROM image_generations + WHERE status = 'completed' + AND image_url IS NOT NULL + AND image_url != '' + AND (minio_url IS NULL OR minio_url = '') + AND completed_at >= ? + GROUP BY drama_id + `, time.Now().Add(-24*time.Hour)).Scan(&imageDramas) + + // 转存图片 + imageCount := 0 + for _, drama := range imageDramas { + count, err := s.transferService.BatchTransferImagesToMinio(drama.DramaID, 50) // 每个剧本最多转50个 + if err != nil { + s.log.Errorw("Failed to transfer images for drama", + "drama_id", drama.DramaID, + "error", err) + continue + } + imageCount += count + s.log.Infow("Transferred images for drama", + "drama_id", drama.DramaID, + "count", count) + } + + // 统计每个剧本的待转存视频数量 + var videoDramas []DramaCount + s.db.Raw(` + SELECT drama_id, COUNT(*) as count + FROM video_generations + WHERE status = 'completed' + AND video_url IS NOT NULL + AND video_url != '' + AND (minio_url IS NULL OR minio_url = '') + AND completed_at >= ? + GROUP BY drama_id + `, time.Now().Add(-24*time.Hour)).Scan(&videoDramas) + + // 转存视频 + videoCount := 0 + for _, drama := range videoDramas { + count, err := s.transferService.BatchTransferVideosToMinio(drama.DramaID, 50) // 每个剧本最多转50个 + if err != nil { + s.log.Errorw("Failed to transfer videos for drama", + "drama_id", drama.DramaID, + "error", err) + continue + } + videoCount += count + s.log.Infow("Transferred videos for drama", + "drama_id", drama.DramaID, + "count", count) + } + + s.log.Infow("Scheduled resource transfer task completed", + "images", imageCount, + "videos", videoCount) +} + +// transferAllPendingResources 转存所有待转存的资源(全量扫描) +func (s *ResourceTransferScheduler) transferAllPendingResources() { + s.log.Info("Starting full scan for all pending resources...") + + // 查找所有待转存的资源 + type DramaCount struct { + DramaID string + Count int64 + } + + // 统计所有剧本的待转存图片 + var imageDramas []DramaCount + s.db.Raw(` + SELECT drama_id, COUNT(*) as count + FROM image_generations + WHERE status = 'completed' + AND image_url IS NOT NULL + AND image_url != '' + AND (minio_url IS NULL OR minio_url = '') + GROUP BY drama_id + `).Scan(&imageDramas) + + s.log.Infow("Found dramas with pending images", "count", len(imageDramas)) + + // 转存所有待转存图片 + totalImageCount := 0 + for _, drama := range imageDramas { + count, err := s.transferService.BatchTransferImagesToMinio(drama.DramaID, 0) // 0表示全部转存 + if err != nil { + s.log.Errorw("Failed to transfer images for drama", + "drama_id", drama.DramaID, + "error", err) + continue + } + totalImageCount += count + s.log.Infow("Transferred all images for drama", + "drama_id", drama.DramaID, + "count", count) + } + + // 统计所有剧本的待转存视频 + var videoDramas []DramaCount + s.db.Raw(` + SELECT drama_id, COUNT(*) as count + FROM video_generations + WHERE status = 'completed' + AND video_url IS NOT NULL + AND video_url != '' + AND (minio_url IS NULL OR minio_url = '') + GROUP BY drama_id + `).Scan(&videoDramas) + + s.log.Infow("Found dramas with pending videos", "count", len(videoDramas)) + + // 转存所有待转存视频 + totalVideoCount := 0 + for _, drama := range videoDramas { + count, err := s.transferService.BatchTransferVideosToMinio(drama.DramaID, 0) // 0表示全部转存 + if err != nil { + s.log.Errorw("Failed to transfer videos for drama", + "drama_id", drama.DramaID, + "error", err) + continue + } + totalVideoCount += count + s.log.Infow("Transferred all videos for drama", + "drama_id", drama.DramaID, + "count", count) + } + + s.log.Infow("Full resource scan and transfer completed", + "total_images", totalImageCount, + "total_videos", totalVideoCount, + "drama_count", len(imageDramas)+len(videoDramas)) +} + +// RunNow 立即执行一次转存任务(用于手动触发) +func (s *ResourceTransferScheduler) RunNow() { + s.log.Info("Manually triggering resource transfer task...") + go s.transferPendingResources() +} + +// RunFullScan 立即执行一次全量扫描(用于手动触发) +func (s *ResourceTransferScheduler) RunFullScan() { + s.log.Info("Manually triggering full resource scan...") + go s.transferAllPendingResources() +} diff --git a/infrastructure/storage/local_storage.go b/infrastructure/storage/local_storage.go new file mode 100644 index 0000000..d75da66 --- /dev/null +++ b/infrastructure/storage/local_storage.go @@ -0,0 +1,57 @@ +package storage + +import ( + "fmt" + "io" + "os" + "path/filepath" + "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) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..3c6669f --- /dev/null +++ b/main.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/drama-generator/backend/api/routes" + "github.com/drama-generator/backend/infrastructure/database" + "github.com/drama-generator/backend/infrastructure/storage" + "github.com/drama-generator/backend/pkg/config" + "github.com/drama-generator/backend/pkg/logger" + "github.com/gin-gonic/gin" +) + +func main() { + cfg, err := config.LoadConfig() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + logr := logger.NewLogger(cfg.App.Debug) + defer logr.Sync() + + logr.Info("Starting Drama Generator API Server...") + + db, err := database.NewDatabase(cfg.Database) + if err != nil { + logr.Fatal("Failed to connect to database", "error", err) + } + logr.Info("Database connected successfully") + + // 自动迁移数据库表结构 + if err := database.AutoMigrate(db); err != nil { + logr.Fatal("Failed to migrate database", "error", err) + } + logr.Info("Database tables migrated successfully") + + // 初始化本地存储 + var localStorage *storage.LocalStorage + if cfg.Storage.Type == "local" { + localStorage, err = storage.NewLocalStorage(cfg.Storage.LocalPath, cfg.Storage.BaseURL) + if err != nil { + logr.Fatal("Failed to initialize local storage", "error", err) + } + logr.Info("Local storage initialized successfully", "path", cfg.Storage.LocalPath) + } + + if cfg.App.Debug { + gin.SetMode(gin.DebugMode) + } else { + gin.SetMode(gin.ReleaseMode) + } + + router := routes.SetupRouter(cfg, db, logr, localStorage) + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Server.Port), + Handler: router, + ReadTimeout: 10 * time.Minute, + WriteTimeout: 10 * time.Minute, + } + + go func() { + logr.Infow("🚀 Server starting...", + "port", cfg.Server.Port, + "mode", gin.Mode()) + logr.Info("📍 Access URLs:") + logr.Info(fmt.Sprintf(" Frontend: http://localhost:%d", cfg.Server.Port)) + logr.Info(fmt.Sprintf(" API: http://localhost:%d/api/v1", cfg.Server.Port)) + logr.Info(fmt.Sprintf(" Health: http://localhost:%d/health", cfg.Server.Port)) + logr.Info("📁 Static files:") + logr.Info(fmt.Sprintf(" Uploads: http://localhost:%d/static", cfg.Server.Port)) + logr.Info(fmt.Sprintf(" Assets: http://localhost:%d/assets", cfg.Server.Port)) + logr.Info("✅ Server is ready!") + + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logr.Fatal("Failed to start server", "error", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + logr.Info("Shutting down server...") + + // 清理资源 + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + logr.Fatal("Server forced to shutdown", "error", err) + } + + logr.Info("Server exited") +} diff --git a/migrations/init.sql b/migrations/init.sql new file mode 100644 index 0000000..e27b564 --- /dev/null +++ b/migrations/init.sql @@ -0,0 +1,496 @@ +-- 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 + name TEXT NOT NULL, + base_url TEXT NOT NULL, + api_key TEXT NOT NULL, + model TEXT, + endpoint TEXT, + query_endpoint TEXT, + 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图片生成'), +('stable-diffusion', 'Stable Diffusion', 'image', '', 'Stable Diffusion图片生成'), +('runway', 'Runway', 'video', '', 'Runway视频生成'), +('pika', 'Pika Labs', 'video', '', 'Pika视频生成'), +('doubao', '豆包(火山引擎)', 'video', 'https://ark.cn-beijing.volces.com', '火山引擎豆包视频生成'), +('minimax', 'MiniMax', 'video', '', 'MiniMax视频生成'); diff --git a/pkg/ai/openai_client.go b/pkg/ai/openai_client.go new file mode 100644 index 0000000..987b0ef --- /dev/null +++ b/pkg/ai/openai_client.go @@ -0,0 +1,188 @@ +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 { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := c.BaseURL + c.Endpoint + httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+c.APIKey) + + resp, err := c.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + 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) + } + + var chatResp ChatCompletionResponse + if err := json.Unmarshal(body, &chatResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + 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 { + messages := []ChatMessage{ + { + Role: "user", + Content: "Hello", + }, + } + + _, err := c.ChatCompletion(messages, WithMaxTokens(10)) + return err +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..cbe3fca --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,89 @@ +package config + +import ( + "fmt" + + "github.com/spf13/viper" +) + +type Config struct { + App AppConfig `mapstructure:"app"` + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + Storage StorageConfig `mapstructure:"storage"` + AI AIConfig `mapstructure:"ai"` +} + +type AppConfig struct { + Name string `mapstructure:"name"` + Version string `mapstructure:"version"` + Debug bool `mapstructure:"debug"` +} + +type ServerConfig struct { + Port int `mapstructure:"port"` + Host string `mapstructure:"host"` + CORSOrigins []string `mapstructure:"cors_origins"` + ReadTimeout int `mapstructure:"read_timeout"` + WriteTimeout int `mapstructure:"write_timeout"` +} + +type DatabaseConfig struct { + Type string `mapstructure:"type"` // sqlite, mysql + Path string `mapstructure:"path"` // SQLite数据库文件路径 + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + Database string `mapstructure:"database"` + Charset string `mapstructure:"charset"` + MaxIdle int `mapstructure:"max_idle"` + MaxOpen int `mapstructure:"max_open"` +} + +type StorageConfig struct { + Type string `mapstructure:"type"` // local, minio + LocalPath string `mapstructure:"local_path"` // 本地存储路径 + BaseURL string `mapstructure:"base_url"` // 访问URL前缀 +} + +type AIConfig struct { + DefaultTextProvider string `mapstructure:"default_text_provider"` + DefaultImageProvider string `mapstructure:"default_image_provider"` + DefaultVideoProvider string `mapstructure:"default_video_provider"` +} + +func LoadConfig() (*Config, error) { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath("./configs") + viper.AddConfigPath(".") + + viper.AutomaticEnv() + + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("failed to read config: %w", err) + } + + var config Config + if err := viper.Unmarshal(&config); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + return &config, nil +} + +func (c *DatabaseConfig) DSN() string { + if c.Type == "sqlite" { + return c.Path + } + // MySQL DSN + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local", + c.User, + c.Password, + c.Host, + c.Port, + c.Database, + c.Charset, + ) +} diff --git a/pkg/image/image_client.go b/pkg/image/image_client.go new file mode 100644 index 0000000..8d575e5 --- /dev/null +++ b/pkg/image/image_client.go @@ -0,0 +1,384 @@ +package image + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +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 + } +} + +type OpenAIImageClient struct { + BaseURL string + APIKey string + Model 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"` // 参考图片URL列表 +} + +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 string) *OpenAIImageClient { + return &OpenAIImageClient{ + BaseURL: baseURL, + APIKey: apiKey, + Model: model, + 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) + } + + endpoint := c.BaseURL + "/v1/images/generations" + 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 { + 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") +} + +type StableDiffusionClient struct { + BaseURL string + APIKey string + Model string + HTTPClient *http.Client +} + +type SDRequest struct { + Prompt string `json:"prompt"` + NegativePrompt string `json:"negative_prompt,omitempty"` + Model string `json:"model,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Steps int `json:"steps,omitempty"` + CfgScale float64 `json:"cfg_scale,omitempty"` + Seed int64 `json:"seed,omitempty"` + Samples int `json:"samples"` + Image []string `json:"image,omitempty"` // 参考图片URL列表 +} + +type SDResponse struct { + Status string `json:"status"` + TaskID string `json:"task_id,omitempty"` + Output []struct { + URL string `json:"url"` + } `json:"output,omitempty"` + Error string `json:"error,omitempty"` +} + +func NewStableDiffusionClient(baseURL, apiKey, model string) *StableDiffusionClient { + return &StableDiffusionClient{ + BaseURL: baseURL, + APIKey: apiKey, + Model: model, + HTTPClient: &http.Client{ + Timeout: 10 * time.Minute, + }, + } +} + +func (c *StableDiffusionClient) GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error) { + options := &ImageOptions{ + Width: 1024, + Height: 1024, + Steps: 30, + CfgScale: 7.5, + } + + for _, opt := range opts { + opt(options) + } + + model := c.Model + if options.Model != "" { + model = options.Model + } + + reqBody := SDRequest{ + Prompt: prompt, + NegativePrompt: options.NegativePrompt, + Model: model, + Width: options.Width, + Height: options.Height, + Steps: options.Steps, + CfgScale: options.CfgScale, + Seed: options.Seed, + Samples: 1, + Image: options.ReferenceImages, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + endpoint := c.BaseURL + "/v1/images/generations" + 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 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var result SDResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + if result.Error != "" { + return nil, fmt.Errorf("SD error: %s", result.Error) + } + + if result.Status == "processing" { + return &ImageResult{ + TaskID: result.TaskID, + Status: "processing", + Completed: false, + }, nil + } + + if len(result.Output) == 0 { + return nil, fmt.Errorf("no image generated") + } + + return &ImageResult{ + Status: "completed", + ImageURL: result.Output[0].URL, + Width: options.Width, + Height: options.Height, + Completed: true, + }, nil +} + +func (c *StableDiffusionClient) GetTaskStatus(taskID string) (*ImageResult, error) { + endpoint := c.BaseURL + "/v1/images/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 SDResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + imageResult := &ImageResult{ + TaskID: taskID, + Status: result.Status, + Completed: result.Status == "completed", + } + + if result.Error != "" { + imageResult.Error = result.Error + } + + if len(result.Output) > 0 { + imageResult.ImageURL = result.Output[0].URL + } + + return imageResult, nil +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..9c8dde9 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,35 @@ +package logger + +import ( + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type Logger struct { + *zap.SugaredLogger +} + +func NewLogger(debug bool) *Logger { + var config zap.Config + + if debug { + config = zap.NewDevelopmentConfig() + config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + // 在开发模式下,禁用时间戳和调用者信息,使输出更简洁 + config.EncoderConfig.TimeKey = "" + config.EncoderConfig.CallerKey = "" + } else { + config = zap.NewProductionConfig() + config.EncoderConfig.TimeKey = "timestamp" + config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + } + + logger, err := config.Build() + if err != nil { + panic(err) + } + + return &Logger{ + SugaredLogger: logger.Sugar(), + } +} diff --git a/pkg/response/response.go b/pkg/response/response.go new file mode 100644 index 0000000..6dbf48d --- /dev/null +++ b/pkg/response/response.go @@ -0,0 +1,119 @@ +package response + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +type Response struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error *ErrorInfo `json:"error,omitempty"` + Message string `json:"message,omitempty"` + Timestamp string `json:"timestamp"` +} + +type ErrorInfo struct { + Code string `json:"code"` + Message string `json:"message"` + Details interface{} `json:"details,omitempty"` +} + +type PaginationData struct { + Items interface{} `json:"items"` + Pagination Pagination `json:"pagination"` +} + +type Pagination struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int64 `json:"total"` + TotalPages int64 `json:"total_pages"` +} + +func Success(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Response{ + Success: true, + Data: data, + Timestamp: time.Now().UTC().Format(time.RFC3339), + }) +} + +func SuccessWithMessage(c *gin.Context, message string, data interface{}) { + c.JSON(http.StatusOK, Response{ + Success: true, + Data: data, + Message: message, + Timestamp: time.Now().UTC().Format(time.RFC3339), + }) +} + +func Created(c *gin.Context, data interface{}) { + c.JSON(http.StatusCreated, Response{ + Success: true, + Data: data, + Timestamp: time.Now().UTC().Format(time.RFC3339), + }) +} + +func SuccessWithPagination(c *gin.Context, items interface{}, total int64, page int, pageSize int) { + totalPages := (total + int64(pageSize) - 1) / int64(pageSize) + c.JSON(http.StatusOK, Response{ + Success: true, + Data: PaginationData{ + Items: items, + Pagination: Pagination{ + Page: page, + PageSize: pageSize, + Total: total, + TotalPages: totalPages, + }, + }, + Timestamp: time.Now().UTC().Format(time.RFC3339), + }) +} + +func Error(c *gin.Context, statusCode int, errCode string, message string) { + c.JSON(statusCode, Response{ + Success: false, + Error: &ErrorInfo{ + Code: errCode, + Message: message, + }, + Timestamp: time.Now().UTC().Format(time.RFC3339), + }) +} + +func ErrorWithDetails(c *gin.Context, statusCode int, errCode string, message string, details interface{}) { + c.JSON(statusCode, Response{ + Success: false, + Error: &ErrorInfo{ + Code: errCode, + Message: message, + Details: details, + }, + Timestamp: time.Now().UTC().Format(time.RFC3339), + }) +} + +func BadRequest(c *gin.Context, message string) { + Error(c, http.StatusBadRequest, "BAD_REQUEST", message) +} + +func Unauthorized(c *gin.Context, message string) { + Error(c, http.StatusUnauthorized, "UNAUTHORIZED", message) +} + +func Forbidden(c *gin.Context, message string) { + Error(c, http.StatusForbidden, "FORBIDDEN", message) +} + +func NotFound(c *gin.Context, message string) { + Error(c, http.StatusNotFound, "NOT_FOUND", message) +} + +func InternalError(c *gin.Context, message string) { + Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", message) +} diff --git a/pkg/utils/json_parser.go b/pkg/utils/json_parser.go new file mode 100644 index 0000000..bb08195 --- /dev/null +++ b/pkg/utils/json_parser.go @@ -0,0 +1,153 @@ +package utils + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" +) + +// SafeParseAIJSON 安全地解析AI返回的JSON,处理常见的格式问题 +// 包括: +// 1. 移除Markdown代码块标记 +// 2. 提取JSON对象 +// 3. 清理多余的空白和换行 +// 4. 尝试修复截断的JSON +// 5. 提供详细的错误信息 +func SafeParseAIJSON(aiResponse string, v interface{}) error { + if aiResponse == "" { + return fmt.Errorf("AI返回内容为空") + } + + // 1. 移除可能的Markdown代码块标记 + cleaned := strings.TrimSpace(aiResponse) + cleaned = regexp.MustCompile("(?m)^```json\\s*").ReplaceAllString(cleaned, "") + cleaned = regexp.MustCompile("(?m)^```\\s*").ReplaceAllString(cleaned, "") + cleaned = strings.TrimSpace(cleaned) + + // 2. 提取JSON对象 (查找第一个 { 到最后一个 }) + jsonRegex := regexp.MustCompile(`(?s)\{.*\}`) + jsonMatch := jsonRegex.FindString(cleaned) + + if jsonMatch == "" { + return fmt.Errorf("响应中未找到有效的JSON对象,原始响应: %s", truncateString(aiResponse, 200)) + } + + // 3. 尝试解析JSON + err := json.Unmarshal([]byte(jsonMatch), v) + if err == nil { + return nil // 解析成功 + } + + // 4. 如果解析失败,尝试修复截断的JSON + fixedJSON := attemptJSONRepair(jsonMatch) + if fixedJSON != jsonMatch { + if err := json.Unmarshal([]byte(fixedJSON), v); err == nil { + return nil // 修复后解析成功 + } + } + + // 5. 提供详细的错误上下文 + if jsonErr, ok := err.(*json.SyntaxError); ok { + errorPos := int(jsonErr.Offset) + start := maxInt(0, errorPos-100) + end := minInt(len(jsonMatch), errorPos+100) + + context := jsonMatch[start:end] + marker := strings.Repeat(" ", errorPos-start) + "^" + + return fmt.Errorf( + "JSON解析失败: %s\n错误位置附近:\n%s\n%s", + jsonErr.Error(), + context, + marker, + ) + } + + return fmt.Errorf("JSON解析失败: %w\n原始响应: %s", err, truncateString(jsonMatch, 300)) +} + +// attemptJSONRepair 尝试修复常见的JSON问题 +func attemptJSONRepair(jsonStr string) string { + // 1. 处理未闭合的字符串 + // 如果最后一个字符不是 },尝试补全 + trimmed := strings.TrimSpace(jsonStr) + + // 2. 检查是否有未闭合的引号 + if strings.Count(trimmed, `"`)%2 != 0 { + // 有奇数个引号,尝试补全最后一个引号 + trimmed += `"` + } + + // 3. 统计括号 + openBraces := strings.Count(trimmed, "{") + closeBraces := strings.Count(trimmed, "}") + openBrackets := strings.Count(trimmed, "[") + closeBrackets := strings.Count(trimmed, "]") + + // 4. 补全未闭合的数组 + for i := 0; i < openBrackets-closeBrackets; i++ { + trimmed += "]" + } + + // 5. 补全未闭合的对象 + for i := 0; i < openBraces-closeBraces; i++ { + trimmed += "}" + } + + return trimmed +} + +// ExtractJSONFromText 从文本中提取JSON对象或数组 +func ExtractJSONFromText(text string) string { + text = strings.TrimSpace(text) + + // 移除Markdown代码块 + text = regexp.MustCompile("(?m)^```json\\s*").ReplaceAllString(text, "") + text = regexp.MustCompile("(?m)^```\\s*").ReplaceAllString(text, "") + text = strings.TrimSpace(text) + + // 查找JSON对象 + if idx := strings.Index(text, "{"); idx != -1 { + if lastIdx := strings.LastIndex(text, "}"); lastIdx != -1 && lastIdx > idx { + return text[idx : lastIdx+1] + } + } + + // 查找JSON数组 + if idx := strings.Index(text, "["); idx != -1 { + if lastIdx := strings.LastIndex(text, "]"); lastIdx != -1 && lastIdx > idx { + return text[idx : lastIdx+1] + } + } + + return text +} + +// ValidateJSON 验证JSON字符串是否有效 +func ValidateJSON(jsonStr string) error { + var js json.RawMessage + return json.Unmarshal([]byte(jsonStr), &js) +} + +// Helper functions +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/pkg/utils/random.go b/pkg/utils/random.go new file mode 100644 index 0000000..c6d4110 --- /dev/null +++ b/pkg/utils/random.go @@ -0,0 +1,28 @@ +package utils + +import ( + "math/rand" + "time" +) + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +func GenerateVerificationCode(length int) string { + digits := "0123456789" + code := make([]byte, length) + for i := range code { + code[i] = digits[rand.Intn(len(digits))] + } + return string(code) +} + +func GenerateRandomString(length int) string { + chars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + result := make([]byte, length) + for i := range result { + result[i] = chars[rand.Intn(len(chars))] + } + return string(result) +} diff --git a/pkg/video/minimax_client.go b/pkg/video/minimax_client.go new file mode 100644 index 0000000..6b143d5 --- /dev/null +++ b/pkg/video/minimax_client.go @@ -0,0 +1,192 @@ +package video + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// MinimaxClient Minimax视频生成客户端 +type MinimaxClient struct { + BaseURL string + APIKey string + Model string + HTTPClient *http.Client +} + +type MinimaxSubjectReference struct { + Type string `json:"type"` + Image []string `json:"image"` +} + +type MinimaxRequest struct { + Prompt string `json:"prompt"` + FirstFrameImage string `json:"first_frame_image,omitempty"` + LastFrameImage string `json:"last_frame_image,omitempty"` + SubjectReference []MinimaxSubjectReference `json:"subject_reference,omitempty"` + Model string `json:"model"` + Duration int `json:"duration,omitempty"` + Resolution string `json:"resolution,omitempty"` +} + +type MinimaxResponse struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + BaseResp struct { + StatusCode int `json:"status_code"` + StatusMsg string `json:"status_msg"` + } `json:"base_resp"` + Video struct { + URL string `json:"url"` + Duration int `json:"duration"` + } `json:"video"` + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +func NewMinimaxClient(baseURL, apiKey, model string) *MinimaxClient { + return &MinimaxClient{ + BaseURL: baseURL, + APIKey: apiKey, + Model: model, + HTTPClient: &http.Client{ + Timeout: 300 * time.Second, + }, + } +} + +// GenerateVideo 生成视频(支持首尾帧和主体参考) +func (c *MinimaxClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) { + options := &VideoOptions{ + Duration: 6, + Resolution: "1080P", + } + + for _, opt := range opts { + opt(options) + } + + model := c.Model + if options.Model != "" { + model = options.Model + } + + reqBody := MinimaxRequest{ + Prompt: prompt, + Model: model, + Duration: options.Duration, + } + + // 设置分辨率 + if options.Resolution != "" { + reqBody.Resolution = options.Resolution + } + + // 如果有首帧图片(从imageURL或FirstFrameURL) + if options.FirstFrameURL != "" { + reqBody.FirstFrameImage = options.FirstFrameURL + } else if imageURL != "" { + reqBody.FirstFrameImage = imageURL + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + endpoint := c.BaseURL + "/v1/video_generation" + req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var result MinimaxResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + if result.Error.Message != "" { + return nil, fmt.Errorf("minimax error: %s", result.Error.Message) + } + + videoResult := &VideoResult{ + TaskID: result.TaskID, + Status: result.Status, + Completed: result.Status == "completed", + Duration: result.Video.Duration, + } + + if result.Video.URL != "" { + videoResult.VideoURL = result.Video.URL + videoResult.Completed = true + } + + return videoResult, nil +} + +func (c *MinimaxClient) GetTaskStatus(taskID string) (*VideoResult, error) { + endpoint := c.BaseURL + "/v1/video_generation/" + taskID + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.APIKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + var result MinimaxResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + videoResult := &VideoResult{ + TaskID: result.TaskID, + Status: result.Status, + Completed: result.Status == "completed", + Duration: result.Video.Duration, + } + + if result.Error.Message != "" { + videoResult.Error = result.Error.Message + } + + if result.Video.URL != "" { + videoResult.VideoURL = result.Video.URL + videoResult.Completed = true + } + + return videoResult, nil +} diff --git a/pkg/video/openai_sora_client.go b/pkg/video/openai_sora_client.go new file mode 100644 index 0000000..39aae3b --- /dev/null +++ b/pkg/video/openai_sora_client.go @@ -0,0 +1,178 @@ +package video + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "time" +) + +type OpenAISoraClient struct { + BaseURL string + APIKey string + Model string + HTTPClient *http.Client +} + +type OpenAISoraResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Model string `json:"model"` + Status string `json:"status"` + Progress int `json:"progress"` + CreatedAt int64 `json:"created_at"` + CompletedAt int64 `json:"completed_at"` + Size string `json:"size"` + Seconds string `json:"seconds"` + Quality string `json:"quality"` + VideoURL string `json:"video_url"` // 直接的video_url字段 + Video struct { + URL string `json:"url"` + } `json:"video"` // 嵌套的video.url字段(兼容) + Error struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` +} + +func NewOpenAISoraClient(baseURL, apiKey, model string) *OpenAISoraClient { + return &OpenAISoraClient{ + BaseURL: baseURL, + APIKey: apiKey, + Model: model, + HTTPClient: &http.Client{ + Timeout: 300 * time.Second, + }, + } +} + +func (c *OpenAISoraClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) { + options := &VideoOptions{ + Duration: 4, + } + + for _, opt := range opts { + opt(options) + } + + model := c.Model + if options.Model != "" { + model = options.Model + } + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + writer.WriteField("model", model) + writer.WriteField("prompt", prompt) + + if imageURL != "" { + writer.WriteField("input_reference", imageURL) + } + + if options.Duration > 0 { + writer.WriteField("seconds", fmt.Sprintf("%d", options.Duration)) + } + + if options.Resolution != "" { + writer.WriteField("size", options.Resolution) + } + + writer.Close() + + endpoint := c.BaseURL + "/v1/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 + "/v1/videos/" + taskID + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.APIKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + var result OpenAISoraResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + videoResult := &VideoResult{ + TaskID: result.ID, + Status: result.Status, + Completed: result.Status == "completed", + } + + if result.Error.Message != "" { + videoResult.Error = result.Error.Message + } + + // 优先使用video_url字段,兼容video.url嵌套结构 + if result.VideoURL != "" { + videoResult.VideoURL = result.VideoURL + } else if result.Video.URL != "" { + videoResult.VideoURL = result.Video.URL + } + + return videoResult, nil +} diff --git a/pkg/video/video_client.go b/pkg/video/video_client.go new file mode 100644 index 0000000..a8fc25c --- /dev/null +++ b/pkg/video/video_client.go @@ -0,0 +1,427 @@ +package video + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type VideoClient interface { + GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) + GetTaskStatus(taskID string) (*VideoResult, error) +} + +type VideoResult struct { + TaskID string + Status string + VideoURL string + ThumbnailURL string + Duration int + Width int + Height int + Error string + Completed bool +} + +type VideoOptions struct { + Model string + Duration int + FPS int + Resolution string + AspectRatio string + Style string + MotionLevel int + CameraMotion string + Seed int64 + FirstFrameURL string + LastFrameURL string + ReferenceImageURLs []string +} + +type VideoOption func(*VideoOptions) + +func WithModel(model string) VideoOption { + return func(o *VideoOptions) { + o.Model = model + } +} + +func WithDuration(duration int) VideoOption { + return func(o *VideoOptions) { + o.Duration = duration + } +} + +func WithFPS(fps int) VideoOption { + return func(o *VideoOptions) { + o.FPS = fps + } +} + +func WithResolution(resolution string) VideoOption { + return func(o *VideoOptions) { + o.Resolution = resolution + } +} + +func WithAspectRatio(ratio string) VideoOption { + return func(o *VideoOptions) { + o.AspectRatio = ratio + } +} + +func WithStyle(style string) VideoOption { + return func(o *VideoOptions) { + o.Style = style + } +} + +func WithMotionLevel(level int) VideoOption { + return func(o *VideoOptions) { + o.MotionLevel = level + } +} + +func WithCameraMotion(motion string) VideoOption { + return func(o *VideoOptions) { + o.CameraMotion = motion + } +} + +func WithSeed(seed int64) VideoOption { + return func(o *VideoOptions) { + o.Seed = seed + } +} + +func WithFirstFrame(url string) VideoOption { + return func(o *VideoOptions) { + o.FirstFrameURL = url + } +} + +func WithLastFrame(url string) VideoOption { + return func(o *VideoOptions) { + o.LastFrameURL = url + } +} + +func WithReferenceImages(urls []string) VideoOption { + return func(o *VideoOptions) { + o.ReferenceImageURLs = urls + } +} + +type RunwayClient struct { + BaseURL string + APIKey string + Model string + HTTPClient *http.Client +} + +type RunwayRequest struct { + Model string `json:"model"` + PromptImage string `json:"prompt_image"` + PromptText string `json:"prompt_text"` + Duration int `json:"duration,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + Seed int64 `json:"seed,omitempty"` +} + +type RunwayResponse struct { + ID string `json:"id"` + Status string `json:"status"` + Output struct { + URL string `json:"url"` + } `json:"output"` + Error string `json:"error,omitempty"` +} + +func NewRunwayClient(baseURL, apiKey, model string) *RunwayClient { + return &RunwayClient{ + BaseURL: baseURL, + APIKey: apiKey, + Model: model, + HTTPClient: &http.Client{ + Timeout: 180 * time.Second, + }, + } +} + +func (c *RunwayClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) { + options := &VideoOptions{ + Duration: 5, + AspectRatio: "16:9", + } + + for _, opt := range opts { + opt(options) + } + + model := c.Model + if options.Model != "" { + model = options.Model + } + + reqBody := RunwayRequest{ + Model: model, + PromptImage: imageURL, + PromptText: prompt, + Duration: options.Duration, + AspectRatio: options.AspectRatio, + Seed: options.Seed, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + endpoint := c.BaseURL + "/v1/video/generate" + req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var result RunwayResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + if result.Error != "" { + return nil, fmt.Errorf("runway error: %s", result.Error) + } + + videoResult := &VideoResult{ + TaskID: result.ID, + Status: result.Status, + Completed: result.Status == "succeeded", + } + + if result.Output.URL != "" { + videoResult.VideoURL = result.Output.URL + } + + return videoResult, nil +} + +func (c *RunwayClient) GetTaskStatus(taskID string) (*VideoResult, error) { + endpoint := c.BaseURL + "/v1/video/status/" + taskID + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.APIKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + var result RunwayResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + videoResult := &VideoResult{ + TaskID: result.ID, + Status: result.Status, + Completed: result.Status == "succeeded", + } + + if result.Error != "" { + videoResult.Error = result.Error + } + + if result.Output.URL != "" { + videoResult.VideoURL = result.Output.URL + } + + return videoResult, nil +} + +type PikaClient struct { + BaseURL string + APIKey string + Model string + HTTPClient *http.Client +} + +type PikaRequest struct { + Model string `json:"model"` + Image string `json:"image"` + Prompt string `json:"prompt"` + Duration int `json:"duration,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + Motion int `json:"motion,omitempty"` + CameraMotion string `json:"camera_motion,omitempty"` + Seed int64 `json:"seed,omitempty"` +} + +type PikaResponse struct { + JobID string `json:"job_id"` + Status string `json:"status"` + Result struct { + VideoURL string `json:"video_url"` + } `json:"result"` + Error string `json:"error,omitempty"` +} + +func NewPikaClient(baseURL, apiKey, model string) *PikaClient { + return &PikaClient{ + BaseURL: baseURL, + APIKey: apiKey, + Model: model, + HTTPClient: &http.Client{ + Timeout: 180 * time.Second, + }, + } +} + +func (c *PikaClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) { + options := &VideoOptions{ + Duration: 3, + AspectRatio: "16:9", + MotionLevel: 50, + } + + for _, opt := range opts { + opt(options) + } + + model := c.Model + if options.Model != "" { + model = options.Model + } + + reqBody := PikaRequest{ + Model: model, + Image: imageURL, + Prompt: prompt, + Duration: options.Duration, + AspectRatio: options.AspectRatio, + Motion: options.MotionLevel, + CameraMotion: options.CameraMotion, + Seed: options.Seed, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + endpoint := c.BaseURL + "/v1/video/generate" + req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var result PikaResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + if result.Error != "" { + return nil, fmt.Errorf("pika error: %s", result.Error) + } + + videoResult := &VideoResult{ + TaskID: result.JobID, + Status: result.Status, + Completed: result.Status == "completed", + } + + if result.Result.VideoURL != "" { + videoResult.VideoURL = result.Result.VideoURL + } + + return videoResult, nil +} + +func (c *PikaClient) GetTaskStatus(taskID string) (*VideoResult, error) { + endpoint := c.BaseURL + "/v1/video/status/" + taskID + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.APIKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + var result PikaResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + videoResult := &VideoResult{ + TaskID: result.JobID, + Status: result.Status, + Completed: result.Status == "completed", + } + + if result.Error != "" { + videoResult.Error = result.Error + } + + if result.Result.VideoURL != "" { + videoResult.VideoURL = result.Result.VideoURL + } + + return videoResult, nil +} diff --git a/pkg/video/volces_ark_client.go b/pkg/video/volces_ark_client.go new file mode 100644 index 0000000..cfd5c8a --- /dev/null +++ b/pkg/video/volces_ark_client.go @@ -0,0 +1,288 @@ +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}或直接拼接 + queryPath := c.QueryEndpoint + if contains := bytes.Contains([]byte(queryPath), []byte("{taskId}")); contains { + queryPath = string(bytes.ReplaceAll([]byte(queryPath), []byte("{taskId}"), []byte(taskID))) + } else { + queryPath = queryPath + "/" + taskID + } + + endpoint := c.BaseURL + queryPath + fmt.Printf("[VolcesARK] Querying task status - TaskID: %s, QueryEndpoint: %s, FullURL: %s\n", taskID, c.QueryEndpoint, endpoint) + + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.APIKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + fmt.Printf("[VolcesARK] Response body: %s\n", string(body)) + + var result VolcesArkResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + fmt.Printf("[VolcesARK] Parsed result - ID: %s, Status: %s, VideoURL: %s\n", result.ID, result.Status, result.Content.VideoURL) + + videoResult := &VideoResult{ + TaskID: result.ID, + Status: result.Status, + Completed: result.Status == "completed" || result.Status == "succeeded", + Duration: result.Duration, + } + + if result.Error != nil { + videoResult.Error = fmt.Sprintf("%v", result.Error) + } + + if result.Content.VideoURL != "" { + videoResult.VideoURL = result.Content.VideoURL + videoResult.Completed = true + } + + return videoResult, nil +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..2f030a2 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,29 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Environment +.env +.env.local +.env.*.local diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..bbd8a61 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Drama Generator - AI 短剧生成平台 + + +
+ + + diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..398df76 --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://api:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + gzip_comp_level 6; + gzip_min_length 1000; +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..dacc0d1 --- /dev/null +++ b/web/package.json @@ -0,0 +1,37 @@ +{ + "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-router": "^4.2.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.0", + "@vitejs/plugin-vue": "^5.0.0", + "@vue/tsconfig": "^0.5.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "sass-embedded": "^1.97.1", + "tailwindcss": "^4.1.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "vue-tsc": "^2.2.12" + } +} diff --git a/web/public/ffmpeg/ffmpeg-core.js b/web/public/ffmpeg/ffmpeg-core.js new file mode 100644 index 0000000..f027a2b --- /dev/null +++ b/web/public/ffmpeg/ffmpeg-core.js @@ -0,0 +1,21 @@ + +var createFFmpegCore = (() => { + var _scriptDir = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined; + + return ( +function(createFFmpegCore = {}) { + +var Module=typeof createFFmpegCore!="undefined"?createFFmpegCore:{};var readyPromiseResolve,readyPromiseReject;Module["ready"]=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});const NULL=0;const SIZE_I32=Uint32Array.BYTES_PER_ELEMENT;const DEFAULT_ARGS=["./ffmpeg","-nostdin","-y"];const DEFAULT_ARGS_FFPROBE=["./ffprobe"];Module["NULL"]=NULL;Module["SIZE_I32"]=SIZE_I32;Module["DEFAULT_ARGS"]=DEFAULT_ARGS;Module["DEFAULT_ARGS_FFPROBE"]=DEFAULT_ARGS_FFPROBE;Module["ret"]=-1;Module["timeout"]=-1;Module["logger"]=()=>{};Module["progress"]=()=>{};function stringToPtr(str){const len=Module["lengthBytesUTF8"](str)+1;const ptr=Module["_malloc"](len);Module["stringToUTF8"](str,ptr,len);return ptr}function stringsToPtr(strs){const len=strs.length;const ptr=Module["_malloc"](len*SIZE_I32);for(let i=0;i{throw toThrow};var ENVIRONMENT_IS_WEB=false;var ENVIRONMENT_IS_WORKER=true;var ENVIRONMENT_IS_NODE=false;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var read_,readAsync,readBinary,setWindowTitle;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptDir){scriptDirectory=_scriptDir}if(scriptDirectory.indexOf("blob:")!==0){scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}else{scriptDirectory=""}{read_=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.send(null);return xhr.responseText};if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=(url,onload,onerror)=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=()=>{if(xhr.status==200||xhr.status==0&&xhr.response){onload(xhr.response);return}onerror()};xhr.onerror=onerror;xhr.send(null)}}setWindowTitle=title=>document.title=title}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["quit"])quit_=Module["quit"];var wasmBinary;if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];var noExitRuntime=Module["noExitRuntime"]||true;if(typeof WebAssembly!="object"){abort("no native wasm support detected")}var wasmMemory;var ABORT=false;var EXITSTATUS;function assert(condition,text){if(!condition){abort(text)}}var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAP64,HEAPU64,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b);Module["HEAP64"]=HEAP64=new BigInt64Array(b);Module["HEAPU64"]=HEAPU64=new BigUint64Array(b)}var wasmTable;var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;var runtimeKeepaliveCounter=0;function keepRuntimeAlive(){return noExitRuntime||runtimeKeepaliveCounter>0}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;if(!Module["noFSInit"]&&!FS.init.initialized)FS.init();FS.ignorePermissions=false;TTY.init();SOCKFS.root=FS.mount(SOCKFS,{},null);callRuntimeCallbacks(__ATINIT__)}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function getUniqueRunDependency(id){return id}function addRunDependency(id){runDependencies++;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}}function removeRunDependency(id){runDependencies--;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){if(Module["onAbort"]){Module["onAbort"](what)}what="Aborted("+what+")";err(what);ABORT=true;EXITSTATUS=1;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";function isDataURI(filename){return filename.startsWith(dataURIPrefix)}var wasmBinaryFile;wasmBinaryFile="ffmpeg-core.wasm";if(!isDataURI(wasmBinaryFile)){wasmBinaryFile=locateFile(wasmBinaryFile)}function getBinary(file){try{if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}catch(err){abort(err)}}function getBinaryPromise(binaryFile){if(!wasmBinary&&(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER)){if(typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{if(!response["ok"]){throw"failed to load wasm binary file at '"+binaryFile+"'"}return response["arrayBuffer"]()}).catch(()=>getBinary(binaryFile))}}return Promise.resolve().then(()=>getBinary(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>{return WebAssembly.instantiate(binary,imports)}).then(instance=>{return instance}).then(receiver,reason=>{err("failed to asynchronously prepare wasm: "+reason);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err("wasm streaming compile failed: "+reason);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}else{return instantiateArrayBuffer(binaryFile,imports,callback)}}function createWasm(){var info={"a":wasmImports};function receiveInstance(instance,module){var exports=instance.exports;Module["asm"]=exports;wasmMemory=Module["asm"]["ra"];updateMemoryViews();wasmTable=Module["asm"]["ua"];addOnInit(Module["asm"]["sa"]);removeRunDependency("wasm-instantiate");return exports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err("Module.instantiateWasm callback failed with error: "+e);readyPromiseReject(e)}}instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}var ASM_CONSTS={6077464:$0=>{Module.ret=$0}};function send_progress(progress,time){Module.receiveProgress(progress,time)}function is_timeout(diff){if(Module.timeout===-1)return 0;else{return Module.timeout<=diff}}function ExitStatus(status){this.name="ExitStatus";this.message=`Program terminated with exit(${status})`;this.status=status}function callRuntimeCallbacks(callbacks){while(callbacks.length>0){callbacks.shift()(Module)}}var wasmTableMirror=[];function getWasmTableEntry(funcPtr){var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}return func}function getValue(ptr,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":return HEAP8[ptr>>0];case"i8":return HEAP8[ptr>>0];case"i16":return HEAP16[ptr>>1];case"i32":return HEAP32[ptr>>2];case"i64":return HEAP64[ptr>>3];case"float":return HEAPF32[ptr>>2];case"double":return HEAPF64[ptr>>3];case"*":return HEAPU32[ptr>>2];default:abort(`invalid type for getValue: ${type}`)}}function setValue(ptr,value,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":HEAP8[ptr>>0]=value;break;case"i8":HEAP8[ptr>>0]=value;break;case"i16":HEAP16[ptr>>1]=value;break;case"i32":HEAP32[ptr>>2]=value;break;case"i64":HEAP64[ptr>>3]=BigInt(value);break;case"float":HEAPF32[ptr>>2]=value;break;case"double":HEAPF64[ptr>>3]=value;break;case"*":HEAPU32[ptr>>2]=value;break;default:abort(`invalid type for setValue: ${type}`)}}var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder("utf8"):undefined;function UTF8ArrayToString(heapOrArray,idx,maxBytesToRead){var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str}function UTF8ToString(ptr,maxBytesToRead){return ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):""}function ___assert_fail(condition,filename,line,func){abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):"unknown filename",line,func?UTF8ToString(func):"unknown function"])}function ExceptionInfo(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24;this.set_type=function(type){HEAPU32[this.ptr+4>>2]=type};this.get_type=function(){return HEAPU32[this.ptr+4>>2]};this.set_destructor=function(destructor){HEAPU32[this.ptr+8>>2]=destructor};this.get_destructor=function(){return HEAPU32[this.ptr+8>>2]};this.set_caught=function(caught){caught=caught?1:0;HEAP8[this.ptr+12>>0]=caught};this.get_caught=function(){return HEAP8[this.ptr+12>>0]!=0};this.set_rethrown=function(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13>>0]=rethrown};this.get_rethrown=function(){return HEAP8[this.ptr+13>>0]!=0};this.init=function(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)};this.set_adjusted_ptr=function(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr};this.get_adjusted_ptr=function(){return HEAPU32[this.ptr+16>>2]};this.get_exception_ptr=function(){var isPointer=___cxa_is_pointer_type(this.get_type());if(isPointer){return HEAPU32[this.excPtr>>2]}var adjusted=this.get_adjusted_ptr();if(adjusted!==0)return adjusted;return this.excPtr}}var exceptionLast=0;var uncaughtExceptionCount=0;function ___cxa_throw(ptr,type,destructor){var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast}var dlopenMissingError="To use dlopen, you need enable dynamic linking, see https://emscripten.org/docs/compiling/Dynamic-Linking.html";function ___dlsym(handle,symbol){abort(dlopenMissingError)}var PATH={isAbs:path=>path.charAt(0)==="/",splitPath:filename=>{var splitPathRe=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;return splitPathRe.exec(filename).slice(1)},normalizeArray:(parts,allowAboveRoot)=>{var up=0;for(var i=parts.length-1;i>=0;i--){var last=parts[i];if(last==="."){parts.splice(i,1)}else if(last===".."){parts.splice(i,1);up++}else if(up){parts.splice(i,1);up--}}if(allowAboveRoot){for(;up;up--){parts.unshift("..")}}return parts},normalize:path=>{var isAbsolute=PATH.isAbs(path),trailingSlash=path.substr(-1)==="/";path=PATH.normalizeArray(path.split("/").filter(p=>!!p),!isAbsolute).join("/");if(!path&&!isAbsolute){path="."}if(path&&trailingSlash){path+="/"}return(isAbsolute?"/":"")+path},dirname:path=>{var result=PATH.splitPath(path),root=result[0],dir=result[1];if(!root&&!dir){return"."}if(dir){dir=dir.substr(0,dir.length-1)}return root+dir},basename:path=>{if(path==="/")return"/";path=PATH.normalize(path);path=path.replace(/\/$/,"");var lastSlash=path.lastIndexOf("/");if(lastSlash===-1)return path;return path.substr(lastSlash+1)},join:function(){var paths=Array.prototype.slice.call(arguments);return PATH.normalize(paths.join("/"))},join2:(l,r)=>{return PATH.normalize(l+"/"+r)}};function initRandomFill(){if(typeof crypto=="object"&&typeof crypto["getRandomValues"]=="function"){return view=>crypto.getRandomValues(view)}else abort("initRandomDevice")}function randomFill(view){return(randomFill=initRandomFill())(view)}var PATH_FS={resolve:function(){var resolvedPath="",resolvedAbsolute=false;for(var i=arguments.length-1;i>=-1&&!resolvedAbsolute;i--){var path=i>=0?arguments[i]:FS.cwd();if(typeof path!="string"){throw new TypeError("Arguments to path.resolve must be strings")}else if(!path){return""}resolvedPath=path+"/"+resolvedPath;resolvedAbsolute=PATH.isAbs(path)}resolvedPath=PATH.normalizeArray(resolvedPath.split("/").filter(p=>!!p),!resolvedAbsolute).join("/");return(resolvedAbsolute?"/":"")+resolvedPath||"."},relative:(from,to)=>{from=PATH_FS.resolve(from).substr(1);to=PATH_FS.resolve(to).substr(1);function trim(arr){var start=0;for(;start=0;end--){if(arr[end]!=="")break}if(start>end)return[];return arr.slice(start,end-start+1)}var fromParts=trim(from.split("/"));var toParts=trim(to.split("/"));var length=Math.min(fromParts.length,toParts.length);var samePartsLength=length;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len}function stringToUTF8Array(str,heap,outIdx,maxBytesToWrite){if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx}function intArrayFromString(stringy,dontAddNull,length){var len=length>0?length:lengthBytesUTF8(stringy)+1;var u8array=new Array(len);var numBytesWritten=stringToUTF8Array(stringy,u8array,0,u8array.length);if(dontAddNull)u8array.length=numBytesWritten;return u8array}var TTY={ttys:[],init:function(){},shutdown:function(){},register:function(dev,ops){TTY.ttys[dev]={input:[],output:[],ops:ops};FS.registerDevice(dev,TTY.stream_ops)},stream_ops:{open:function(stream){var tty=TTY.ttys[stream.node.rdev];if(!tty){throw new FS.ErrnoError(43)}stream.tty=tty;stream.seekable=false},close:function(stream){stream.tty.ops.fsync(stream.tty)},fsync:function(stream){stream.tty.ops.fsync(stream.tty)},read:function(stream,buffer,offset,length,pos){if(!stream.tty||!stream.tty.ops.get_char){throw new FS.ErrnoError(60)}var bytesRead=0;for(var i=0;i0){out(UTF8ArrayToString(tty.output,0));tty.output=[]}}},default_tty1_ops:{put_char:function(tty,val){if(val===null||val===10){err(UTF8ArrayToString(tty.output,0));tty.output=[]}else{if(val!=0)tty.output.push(val)}},fsync:function(tty){if(tty.output&&tty.output.length>0){err(UTF8ArrayToString(tty.output,0));tty.output=[]}}}};function zeroMemory(address,size){HEAPU8.fill(0,address,address+size);return address}function alignMemory(size,alignment){return Math.ceil(size/alignment)*alignment}function mmapAlloc(size){size=alignMemory(size,65536);var ptr=_emscripten_builtin_memalign(65536,size);if(!ptr)return 0;return zeroMemory(ptr,size)}var MEMFS={ops_table:null,mount:function(mount){return MEMFS.createNode(null,"/",16384|511,0)},createNode:function(parent,name,mode,dev){if(FS.isBlkdev(mode)||FS.isFIFO(mode)){throw new FS.ErrnoError(63)}if(!MEMFS.ops_table){MEMFS.ops_table={dir:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,lookup:MEMFS.node_ops.lookup,mknod:MEMFS.node_ops.mknod,rename:MEMFS.node_ops.rename,unlink:MEMFS.node_ops.unlink,rmdir:MEMFS.node_ops.rmdir,readdir:MEMFS.node_ops.readdir,symlink:MEMFS.node_ops.symlink},stream:{llseek:MEMFS.stream_ops.llseek}},file:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:{llseek:MEMFS.stream_ops.llseek,read:MEMFS.stream_ops.read,write:MEMFS.stream_ops.write,allocate:MEMFS.stream_ops.allocate,mmap:MEMFS.stream_ops.mmap,msync:MEMFS.stream_ops.msync}},link:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,readlink:MEMFS.node_ops.readlink},stream:{}},chrdev:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:FS.chrdev_stream_ops}}}var node=FS.createNode(parent,name,mode,dev);if(FS.isDir(node.mode)){node.node_ops=MEMFS.ops_table.dir.node;node.stream_ops=MEMFS.ops_table.dir.stream;node.contents={}}else if(FS.isFile(node.mode)){node.node_ops=MEMFS.ops_table.file.node;node.stream_ops=MEMFS.ops_table.file.stream;node.usedBytes=0;node.contents=null}else if(FS.isLink(node.mode)){node.node_ops=MEMFS.ops_table.link.node;node.stream_ops=MEMFS.ops_table.link.stream}else if(FS.isChrdev(node.mode)){node.node_ops=MEMFS.ops_table.chrdev.node;node.stream_ops=MEMFS.ops_table.chrdev.stream}node.timestamp=Date.now();if(parent){parent.contents[name]=node;parent.timestamp=node.timestamp}return node},getFileDataAsTypedArray:function(node){if(!node.contents)return new Uint8Array(0);if(node.contents.subarray)return node.contents.subarray(0,node.usedBytes);return new Uint8Array(node.contents)},expandFileStorage:function(node,newCapacity){var prevCapacity=node.contents?node.contents.length:0;if(prevCapacity>=newCapacity)return;var CAPACITY_DOUBLING_MAX=1024*1024;newCapacity=Math.max(newCapacity,prevCapacity*(prevCapacity>>0);if(prevCapacity!=0)newCapacity=Math.max(newCapacity,256);var oldContents=node.contents;node.contents=new Uint8Array(newCapacity);if(node.usedBytes>0)node.contents.set(oldContents.subarray(0,node.usedBytes),0)},resizeFileStorage:function(node,newSize){if(node.usedBytes==newSize)return;if(newSize==0){node.contents=null;node.usedBytes=0}else{var oldContents=node.contents;node.contents=new Uint8Array(newSize);if(oldContents){node.contents.set(oldContents.subarray(0,Math.min(newSize,node.usedBytes)))}node.usedBytes=newSize}},node_ops:{getattr:function(node){var attr={};attr.dev=FS.isChrdev(node.mode)?node.id:1;attr.ino=node.id;attr.mode=node.mode;attr.nlink=1;attr.uid=0;attr.gid=0;attr.rdev=node.rdev;if(FS.isDir(node.mode)){attr.size=4096}else if(FS.isFile(node.mode)){attr.size=node.usedBytes}else if(FS.isLink(node.mode)){attr.size=node.link.length}else{attr.size=0}attr.atime=new Date(node.timestamp);attr.mtime=new Date(node.timestamp);attr.ctime=new Date(node.timestamp);attr.blksize=4096;attr.blocks=Math.ceil(attr.size/attr.blksize);return attr},setattr:function(node,attr){if(attr.mode!==undefined){node.mode=attr.mode}if(attr.timestamp!==undefined){node.timestamp=attr.timestamp}if(attr.size!==undefined){MEMFS.resizeFileStorage(node,attr.size)}},lookup:function(parent,name){throw FS.genericErrors[44]},mknod:function(parent,name,mode,dev){return MEMFS.createNode(parent,name,mode,dev)},rename:function(old_node,new_dir,new_name){if(FS.isDir(old_node.mode)){var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(new_node){for(var i in new_node.contents){throw new FS.ErrnoError(55)}}}delete old_node.parent.contents[old_node.name];old_node.parent.timestamp=Date.now();old_node.name=new_name;new_dir.contents[new_name]=old_node;new_dir.timestamp=old_node.parent.timestamp;old_node.parent=new_dir},unlink:function(parent,name){delete parent.contents[name];parent.timestamp=Date.now()},rmdir:function(parent,name){var node=FS.lookupNode(parent,name);for(var i in node.contents){throw new FS.ErrnoError(55)}delete parent.contents[name];parent.timestamp=Date.now()},readdir:function(node){var entries=[".",".."];for(var key in node.contents){if(!node.contents.hasOwnProperty(key)){continue}entries.push(key)}return entries},symlink:function(parent,newname,oldpath){var node=MEMFS.createNode(parent,newname,511|40960,0);node.link=oldpath;return node},readlink:function(node){if(!FS.isLink(node.mode)){throw new FS.ErrnoError(28)}return node.link}},stream_ops:{read:function(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=stream.node.usedBytes)return 0;var size=Math.min(stream.node.usedBytes-position,length);if(size>8&&contents.subarray){buffer.set(contents.subarray(position,position+size),offset)}else{for(var i=0;i0||position+length{assert(arrayBuffer,`Loading data file "${url}" failed (no arrayBuffer).`);onload(new Uint8Array(arrayBuffer));if(dep)removeRunDependency(dep)},event=>{if(onerror){onerror()}else{throw`Loading data file "${url}" failed.`}});if(dep)addRunDependency(dep)}var preloadPlugins=Module["preloadPlugins"]||[];function FS_handledByPreloadPlugin(byteArray,fullname,finish,onerror){if(typeof Browser!="undefined")Browser.init();var handled=false;preloadPlugins.forEach(function(plugin){if(handled)return;if(plugin["canHandle"](fullname)){plugin["handle"](byteArray,fullname,finish,onerror);handled=true}});return handled}function FS_createPreloadedFile(parent,name,url,canRead,canWrite,onload,onerror,dontCreateFile,canOwn,preFinish){var fullname=name?PATH_FS.resolve(PATH.join2(parent,name)):parent;var dep=getUniqueRunDependency(`cp ${fullname}`);function processData(byteArray){function finish(byteArray){if(preFinish)preFinish();if(!dontCreateFile){FS.createDataFile(parent,name,byteArray,canRead,canWrite,canOwn)}if(onload)onload();removeRunDependency(dep)}if(FS_handledByPreloadPlugin(byteArray,fullname,finish,()=>{if(onerror)onerror();removeRunDependency(dep)})){return}finish(byteArray)}addRunDependency(dep);if(typeof url=="string"){asyncLoad(url,byteArray=>processData(byteArray),onerror)}else{processData(url)}}function FS_modeStringToFlags(str){var flagModes={"r":0,"r+":2,"w":512|64|1,"w+":512|64|2,"a":1024|64|1,"a+":1024|64|2};var flags=flagModes[str];if(typeof flags=="undefined"){throw new Error(`Unknown file open mode: ${str}`)}return flags}function FS_getMode(canRead,canWrite){var mode=0;if(canRead)mode|=292|73;if(canWrite)mode|=146;return mode}var WORKERFS={DIR_MODE:16895,FILE_MODE:33279,reader:null,mount:function(mount){assert(ENVIRONMENT_IS_WORKER);if(!WORKERFS.reader)WORKERFS.reader=new FileReaderSync;var root=WORKERFS.createNode(null,"/",WORKERFS.DIR_MODE,0);var createdParents={};function ensureParent(path){var parts=path.split("/");var parent=root;for(var i=0;i=stream.node.size)return 0;var chunk=stream.node.contents.slice(position,position+length);var ab=WORKERFS.reader.readAsArrayBuffer(chunk);buffer.set(new Uint8Array(ab),offset);return chunk.size},write:function(stream,buffer,offset,length,position){throw new FS.ErrnoError(29)},llseek:function(stream,offset,whence){var position=offset;if(whence===1){position+=stream.position}else if(whence===2){if(FS.isFile(stream.node.mode)){position+=stream.node.size}}if(position<0){throw new FS.ErrnoError(28)}return position}}};var FS={root:null,mounts:[],devices:{},streams:[],nextInode:1,nameTable:null,currentPath:"/",initialized:false,ignorePermissions:true,ErrnoError:null,genericErrors:{},filesystems:null,syncFSRequests:0,lookupPath:(path,opts={})=>{path=PATH_FS.resolve(path);if(!path)return{path:"",node:null};var defaults={follow_mount:true,recurse_count:0};opts=Object.assign(defaults,opts);if(opts.recurse_count>8){throw new FS.ErrnoError(32)}var parts=path.split("/").filter(p=>!!p);var current=FS.root;var current_path="/";for(var i=0;i40){throw new FS.ErrnoError(32)}}}}return{path:current_path,node:current}},getPath:node=>{var path;while(true){if(FS.isRoot(node)){var mount=node.mount.mountpoint;if(!path)return mount;return mount[mount.length-1]!=="/"?`${mount}/${path}`:mount+path}path=path?`${node.name}/${path}`:node.name;node=node.parent}},hashName:(parentid,name)=>{var hash=0;for(var i=0;i>>0)%FS.nameTable.length},hashAddNode:node=>{var hash=FS.hashName(node.parent.id,node.name);node.name_next=FS.nameTable[hash];FS.nameTable[hash]=node},hashRemoveNode:node=>{var hash=FS.hashName(node.parent.id,node.name);if(FS.nameTable[hash]===node){FS.nameTable[hash]=node.name_next}else{var current=FS.nameTable[hash];while(current){if(current.name_next===node){current.name_next=node.name_next;break}current=current.name_next}}},lookupNode:(parent,name)=>{var errCode=FS.mayLookup(parent);if(errCode){throw new FS.ErrnoError(errCode,parent)}var hash=FS.hashName(parent.id,name);for(var node=FS.nameTable[hash];node;node=node.name_next){var nodeName=node.name;if(node.parent.id===parent.id&&nodeName===name){return node}}return FS.lookup(parent,name)},createNode:(parent,name,mode,rdev)=>{var node=new FS.FSNode(parent,name,mode,rdev);FS.hashAddNode(node);return node},destroyNode:node=>{FS.hashRemoveNode(node)},isRoot:node=>{return node===node.parent},isMountpoint:node=>{return!!node.mounted},isFile:mode=>{return(mode&61440)===32768},isDir:mode=>{return(mode&61440)===16384},isLink:mode=>{return(mode&61440)===40960},isChrdev:mode=>{return(mode&61440)===8192},isBlkdev:mode=>{return(mode&61440)===24576},isFIFO:mode=>{return(mode&61440)===4096},isSocket:mode=>{return(mode&49152)===49152},flagsToPermissionString:flag=>{var perms=["r","w","rw"][flag&3];if(flag&512){perms+="w"}return perms},nodePermissions:(node,perms)=>{if(FS.ignorePermissions){return 0}if(perms.includes("r")&&!(node.mode&292)){return 2}else if(perms.includes("w")&&!(node.mode&146)){return 2}else if(perms.includes("x")&&!(node.mode&73)){return 2}return 0},mayLookup:dir=>{var errCode=FS.nodePermissions(dir,"x");if(errCode)return errCode;if(!dir.node_ops.lookup)return 2;return 0},mayCreate:(dir,name)=>{try{var node=FS.lookupNode(dir,name);return 20}catch(e){}return FS.nodePermissions(dir,"wx")},mayDelete:(dir,name,isdir)=>{var node;try{node=FS.lookupNode(dir,name)}catch(e){return e.errno}var errCode=FS.nodePermissions(dir,"wx");if(errCode){return errCode}if(isdir){if(!FS.isDir(node.mode)){return 54}if(FS.isRoot(node)||FS.getPath(node)===FS.cwd()){return 10}}else{if(FS.isDir(node.mode)){return 31}}return 0},mayOpen:(node,flags)=>{if(!node){return 44}if(FS.isLink(node.mode)){return 32}else if(FS.isDir(node.mode)){if(FS.flagsToPermissionString(flags)!=="r"||flags&512){return 31}}return FS.nodePermissions(node,FS.flagsToPermissionString(flags))},MAX_OPEN_FDS:4096,nextfd:()=>{for(var fd=0;fd<=FS.MAX_OPEN_FDS;fd++){if(!FS.streams[fd]){return fd}}throw new FS.ErrnoError(33)},getStream:fd=>FS.streams[fd],createStream:(stream,fd=-1)=>{if(!FS.FSStream){FS.FSStream=function(){this.shared={}};FS.FSStream.prototype={};Object.defineProperties(FS.FSStream.prototype,{object:{get:function(){return this.node},set:function(val){this.node=val}},isRead:{get:function(){return(this.flags&2097155)!==1}},isWrite:{get:function(){return(this.flags&2097155)!==0}},isAppend:{get:function(){return this.flags&1024}},flags:{get:function(){return this.shared.flags},set:function(val){this.shared.flags=val}},position:{get:function(){return this.shared.position},set:function(val){this.shared.position=val}}})}stream=Object.assign(new FS.FSStream,stream);if(fd==-1){fd=FS.nextfd()}stream.fd=fd;FS.streams[fd]=stream;return stream},closeStream:fd=>{FS.streams[fd]=null},chrdev_stream_ops:{open:stream=>{var device=FS.getDevice(stream.node.rdev);stream.stream_ops=device.stream_ops;if(stream.stream_ops.open){stream.stream_ops.open(stream)}},llseek:()=>{throw new FS.ErrnoError(70)}},major:dev=>dev>>8,minor:dev=>dev&255,makedev:(ma,mi)=>ma<<8|mi,registerDevice:(dev,ops)=>{FS.devices[dev]={stream_ops:ops}},getDevice:dev=>FS.devices[dev],getMounts:mount=>{var mounts=[];var check=[mount];while(check.length){var m=check.pop();mounts.push(m);check.push.apply(check,m.mounts)}return mounts},syncfs:(populate,callback)=>{if(typeof populate=="function"){callback=populate;populate=false}FS.syncFSRequests++;if(FS.syncFSRequests>1){err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`)}var mounts=FS.getMounts(FS.root.mount);var completed=0;function doCallback(errCode){FS.syncFSRequests--;return callback(errCode)}function done(errCode){if(errCode){if(!done.errored){done.errored=true;return doCallback(errCode)}return}if(++completed>=mounts.length){doCallback(null)}}mounts.forEach(mount=>{if(!mount.type.syncfs){return done(null)}mount.type.syncfs(mount,populate,done)})},mount:(type,opts,mountpoint)=>{var root=mountpoint==="/";var pseudo=!mountpoint;var node;if(root&&FS.root){throw new FS.ErrnoError(10)}else if(!root&&!pseudo){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});mountpoint=lookup.path;node=lookup.node;if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}if(!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}}var mount={type:type,opts:opts,mountpoint:mountpoint,mounts:[]};var mountRoot=type.mount(mount);mountRoot.mount=mount;mount.root=mountRoot;if(root){FS.root=mountRoot}else if(node){node.mounted=mount;if(node.mount){node.mount.mounts.push(mount)}}return mountRoot},unmount:mountpoint=>{var lookup=FS.lookupPath(mountpoint,{follow_mount:false});if(!FS.isMountpoint(lookup.node)){throw new FS.ErrnoError(28)}var node=lookup.node;var mount=node.mounted;var mounts=FS.getMounts(mount);Object.keys(FS.nameTable).forEach(hash=>{var current=FS.nameTable[hash];while(current){var next=current.name_next;if(mounts.includes(current.mount)){FS.destroyNode(current)}current=next}});node.mounted=null;var idx=node.mount.mounts.indexOf(mount);node.mount.mounts.splice(idx,1)},lookup:(parent,name)=>{return parent.node_ops.lookup(parent,name)},mknod:(path,mode,dev)=>{var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);if(!name||name==="."||name===".."){throw new FS.ErrnoError(28)}var errCode=FS.mayCreate(parent,name);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.mknod){throw new FS.ErrnoError(63)}return parent.node_ops.mknod(parent,name,mode,dev)},create:(path,mode)=>{mode=mode!==undefined?mode:438;mode&=4095;mode|=32768;return FS.mknod(path,mode,0)},mkdir:(path,mode)=>{mode=mode!==undefined?mode:511;mode&=511|512;mode|=16384;return FS.mknod(path,mode,0)},mkdirTree:(path,mode)=>{var dirs=path.split("/");var d="";for(var i=0;i{if(typeof dev=="undefined"){dev=mode;mode=438}mode|=8192;return FS.mknod(path,mode,dev)},symlink:(oldpath,newpath)=>{if(!PATH_FS.resolve(oldpath)){throw new FS.ErrnoError(44)}var lookup=FS.lookupPath(newpath,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var newname=PATH.basename(newpath);var errCode=FS.mayCreate(parent,newname);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.symlink){throw new FS.ErrnoError(63)}return parent.node_ops.symlink(parent,newname,oldpath)},rename:(old_path,new_path)=>{var old_dirname=PATH.dirname(old_path);var new_dirname=PATH.dirname(new_path);var old_name=PATH.basename(old_path);var new_name=PATH.basename(new_path);var lookup,old_dir,new_dir;lookup=FS.lookupPath(old_path,{parent:true});old_dir=lookup.node;lookup=FS.lookupPath(new_path,{parent:true});new_dir=lookup.node;if(!old_dir||!new_dir)throw new FS.ErrnoError(44);if(old_dir.mount!==new_dir.mount){throw new FS.ErrnoError(75)}var old_node=FS.lookupNode(old_dir,old_name);var relative=PATH_FS.relative(old_path,new_dirname);if(relative.charAt(0)!=="."){throw new FS.ErrnoError(28)}relative=PATH_FS.relative(new_path,old_dirname);if(relative.charAt(0)!=="."){throw new FS.ErrnoError(55)}var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(old_node===new_node){return}var isdir=FS.isDir(old_node.mode);var errCode=FS.mayDelete(old_dir,old_name,isdir);if(errCode){throw new FS.ErrnoError(errCode)}errCode=new_node?FS.mayDelete(new_dir,new_name,isdir):FS.mayCreate(new_dir,new_name);if(errCode){throw new FS.ErrnoError(errCode)}if(!old_dir.node_ops.rename){throw new FS.ErrnoError(63)}if(FS.isMountpoint(old_node)||new_node&&FS.isMountpoint(new_node)){throw new FS.ErrnoError(10)}if(new_dir!==old_dir){errCode=FS.nodePermissions(old_dir,"w");if(errCode){throw new FS.ErrnoError(errCode)}}FS.hashRemoveNode(old_node);try{old_dir.node_ops.rename(old_node,new_dir,new_name)}catch(e){throw e}finally{FS.hashAddNode(old_node)}},rmdir:path=>{var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,true);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.rmdir){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.rmdir(parent,name);FS.destroyNode(node)},readdir:path=>{var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;if(!node.node_ops.readdir){throw new FS.ErrnoError(54)}return node.node_ops.readdir(node)},unlink:path=>{var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,false);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.unlink){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.unlink(parent,name);FS.destroyNode(node)},readlink:path=>{var lookup=FS.lookupPath(path);var link=lookup.node;if(!link){throw new FS.ErrnoError(44)}if(!link.node_ops.readlink){throw new FS.ErrnoError(28)}return PATH_FS.resolve(FS.getPath(link.parent),link.node_ops.readlink(link))},stat:(path,dontFollow)=>{var lookup=FS.lookupPath(path,{follow:!dontFollow});var node=lookup.node;if(!node){throw new FS.ErrnoError(44)}if(!node.node_ops.getattr){throw new FS.ErrnoError(63)}return node.node_ops.getattr(node)},lstat:path=>{return FS.stat(path,true)},chmod:(path,mode,dontFollow)=>{var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}if(!node.node_ops.setattr){throw new FS.ErrnoError(63)}node.node_ops.setattr(node,{mode:mode&4095|node.mode&~4095,timestamp:Date.now()})},lchmod:(path,mode)=>{FS.chmod(path,mode,true)},fchmod:(fd,mode)=>{var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}FS.chmod(stream.node,mode)},chown:(path,uid,gid,dontFollow)=>{var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}if(!node.node_ops.setattr){throw new FS.ErrnoError(63)}node.node_ops.setattr(node,{timestamp:Date.now()})},lchown:(path,uid,gid)=>{FS.chown(path,uid,gid,true)},fchown:(fd,uid,gid)=>{var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}FS.chown(stream.node,uid,gid)},truncate:(path,len)=>{if(len<0){throw new FS.ErrnoError(28)}var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:true});node=lookup.node}else{node=path}if(!node.node_ops.setattr){throw new FS.ErrnoError(63)}if(FS.isDir(node.mode)){throw new FS.ErrnoError(31)}if(!FS.isFile(node.mode)){throw new FS.ErrnoError(28)}var errCode=FS.nodePermissions(node,"w");if(errCode){throw new FS.ErrnoError(errCode)}node.node_ops.setattr(node,{size:len,timestamp:Date.now()})},ftruncate:(fd,len)=>{var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(28)}FS.truncate(stream.node,len)},utime:(path,atime,mtime)=>{var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;node.node_ops.setattr(node,{timestamp:Math.max(atime,mtime)})},open:(path,flags,mode)=>{if(path===""){throw new FS.ErrnoError(44)}flags=typeof flags=="string"?FS_modeStringToFlags(flags):flags;mode=typeof mode=="undefined"?438:mode;if(flags&64){mode=mode&4095|32768}else{mode=0}var node;if(typeof path=="object"){node=path}else{path=PATH.normalize(path);try{var lookup=FS.lookupPath(path,{follow:!(flags&131072)});node=lookup.node}catch(e){}}var created=false;if(flags&64){if(node){if(flags&128){throw new FS.ErrnoError(20)}}else{node=FS.mknod(path,mode,0);created=true}}if(!node){throw new FS.ErrnoError(44)}if(FS.isChrdev(node.mode)){flags&=~512}if(flags&65536&&!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}if(!created){var errCode=FS.mayOpen(node,flags);if(errCode){throw new FS.ErrnoError(errCode)}}if(flags&512&&!created){FS.truncate(node,0)}flags&=~(128|512|131072);var stream=FS.createStream({node:node,path:FS.getPath(node),flags:flags,seekable:true,position:0,stream_ops:node.stream_ops,ungotten:[],error:false});if(stream.stream_ops.open){stream.stream_ops.open(stream)}if(Module["logReadFiles"]&&!(flags&1)){if(!FS.readFiles)FS.readFiles={};if(!(path in FS.readFiles)){FS.readFiles[path]=1}}return stream},close:stream=>{if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(stream.getdents)stream.getdents=null;try{if(stream.stream_ops.close){stream.stream_ops.close(stream)}}catch(e){throw e}finally{FS.closeStream(stream.fd)}stream.fd=null},isClosed:stream=>{return stream.fd===null},llseek:(stream,offset,whence)=>{if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(!stream.seekable||!stream.stream_ops.llseek){throw new FS.ErrnoError(70)}if(whence!=0&&whence!=1&&whence!=2){throw new FS.ErrnoError(28)}stream.position=stream.stream_ops.llseek(stream,offset,whence);stream.ungotten=[];return stream.position},read:(stream,buffer,offset,length,position)=>{if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.read){throw new FS.ErrnoError(28)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesRead=stream.stream_ops.read(stream,buffer,offset,length,position);if(!seeking)stream.position+=bytesRead;return bytesRead},write:(stream,buffer,offset,length,position,canOwn)=>{if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.write){throw new FS.ErrnoError(28)}if(stream.seekable&&stream.flags&1024){FS.llseek(stream,0,2)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesWritten=stream.stream_ops.write(stream,buffer,offset,length,position,canOwn);if(!seeking)stream.position+=bytesWritten;return bytesWritten},allocate:(stream,offset,length)=>{if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(offset<0||length<=0){throw new FS.ErrnoError(28)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(!FS.isFile(stream.node.mode)&&!FS.isDir(stream.node.mode)){throw new FS.ErrnoError(43)}if(!stream.stream_ops.allocate){throw new FS.ErrnoError(138)}stream.stream_ops.allocate(stream,offset,length)},mmap:(stream,length,position,prot,flags)=>{if((prot&2)!==0&&(flags&2)===0&&(stream.flags&2097155)!==2){throw new FS.ErrnoError(2)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(2)}if(!stream.stream_ops.mmap){throw new FS.ErrnoError(43)}return stream.stream_ops.mmap(stream,length,position,prot,flags)},msync:(stream,buffer,offset,length,mmapFlags)=>{if(!stream.stream_ops.msync){return 0}return stream.stream_ops.msync(stream,buffer,offset,length,mmapFlags)},munmap:stream=>0,ioctl:(stream,cmd,arg)=>{if(!stream.stream_ops.ioctl){throw new FS.ErrnoError(59)}return stream.stream_ops.ioctl(stream,cmd,arg)},readFile:(path,opts={})=>{opts.flags=opts.flags||0;opts.encoding=opts.encoding||"binary";if(opts.encoding!=="utf8"&&opts.encoding!=="binary"){throw new Error(`Invalid encoding type "${opts.encoding}"`)}var ret;var stream=FS.open(path,opts.flags);var stat=FS.stat(path);var length=stat.size;var buf=new Uint8Array(length);FS.read(stream,buf,0,length,0);if(opts.encoding==="utf8"){ret=UTF8ArrayToString(buf,0)}else if(opts.encoding==="binary"){ret=buf}FS.close(stream);return ret},writeFile:(path,data,opts={})=>{opts.flags=opts.flags||577;var stream=FS.open(path,opts.flags,opts.mode);if(typeof data=="string"){var buf=new Uint8Array(lengthBytesUTF8(data)+1);var actualNumBytes=stringToUTF8Array(data,buf,0,buf.length);FS.write(stream,buf,0,actualNumBytes,undefined,opts.canOwn)}else if(ArrayBuffer.isView(data)){FS.write(stream,data,0,data.byteLength,undefined,opts.canOwn)}else{throw new Error("Unsupported data type")}FS.close(stream)},cwd:()=>FS.currentPath,chdir:path=>{var lookup=FS.lookupPath(path,{follow:true});if(lookup.node===null){throw new FS.ErrnoError(44)}if(!FS.isDir(lookup.node.mode)){throw new FS.ErrnoError(54)}var errCode=FS.nodePermissions(lookup.node,"x");if(errCode){throw new FS.ErrnoError(errCode)}FS.currentPath=lookup.path},createDefaultDirectories:()=>{FS.mkdir("/tmp");FS.mkdir("/home");FS.mkdir("/home/web_user")},createDefaultDevices:()=>{FS.mkdir("/dev");FS.registerDevice(FS.makedev(1,3),{read:()=>0,write:(stream,buffer,offset,length,pos)=>length});FS.mkdev("/dev/null",FS.makedev(1,3));TTY.register(FS.makedev(5,0),TTY.default_tty_ops);TTY.register(FS.makedev(6,0),TTY.default_tty1_ops);FS.mkdev("/dev/tty",FS.makedev(5,0));FS.mkdev("/dev/tty1",FS.makedev(6,0));var randomBuffer=new Uint8Array(1024),randomLeft=0;var randomByte=()=>{if(randomLeft===0){randomLeft=randomFill(randomBuffer).byteLength}return randomBuffer[--randomLeft]};FS.createDevice("/dev","random",randomByte);FS.createDevice("/dev","urandom",randomByte);FS.mkdir("/dev/shm");FS.mkdir("/dev/shm/tmp")},createSpecialDirectories:()=>{FS.mkdir("/proc");var proc_self=FS.mkdir("/proc/self");FS.mkdir("/proc/self/fd");FS.mount({mount:()=>{var node=FS.createNode(proc_self,"fd",16384|511,73);node.node_ops={lookup:(parent,name)=>{var fd=+name;var stream=FS.getStream(fd);if(!stream)throw new FS.ErrnoError(8);var ret={parent:null,mount:{mountpoint:"fake"},node_ops:{readlink:()=>stream.path}};ret.parent=ret;return ret}};return node}},{},"/proc/self/fd")},createStandardStreams:()=>{if(Module["stdin"]){FS.createDevice("/dev","stdin",Module["stdin"])}else{FS.symlink("/dev/tty","/dev/stdin")}if(Module["stdout"]){FS.createDevice("/dev","stdout",null,Module["stdout"])}else{FS.symlink("/dev/tty","/dev/stdout")}if(Module["stderr"]){FS.createDevice("/dev","stderr",null,Module["stderr"])}else{FS.symlink("/dev/tty1","/dev/stderr")}var stdin=FS.open("/dev/stdin",0);var stdout=FS.open("/dev/stdout",1);var stderr=FS.open("/dev/stderr",1)},ensureErrnoError:()=>{if(FS.ErrnoError)return;FS.ErrnoError=function ErrnoError(errno,node){this.name="ErrnoError";this.node=node;this.setErrno=function(errno){this.errno=errno};this.setErrno(errno);this.message="FS error"};FS.ErrnoError.prototype=new Error;FS.ErrnoError.prototype.constructor=FS.ErrnoError;[44].forEach(code=>{FS.genericErrors[code]=new FS.ErrnoError(code);FS.genericErrors[code].stack=""})},staticInit:()=>{FS.ensureErrnoError();FS.nameTable=new Array(4096);FS.mount(MEMFS,{},"/");FS.createDefaultDirectories();FS.createDefaultDevices();FS.createSpecialDirectories();FS.filesystems={"MEMFS":MEMFS,"WORKERFS":WORKERFS}},init:(input,output,error)=>{FS.init.initialized=true;FS.ensureErrnoError();Module["stdin"]=input||Module["stdin"];Module["stdout"]=output||Module["stdout"];Module["stderr"]=error||Module["stderr"];FS.createStandardStreams()},quit:()=>{FS.init.initialized=false;for(var i=0;i{var ret=FS.analyzePath(path,dontResolveLastLink);if(!ret.exists){return null}return ret.object},analyzePath:(path,dontResolveLastLink)=>{try{var lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});path=lookup.path}catch(e){}var ret={isRoot:false,exists:false,error:0,name:null,path:null,object:null,parentExists:false,parentPath:null,parentObject:null};try{var lookup=FS.lookupPath(path,{parent:true});ret.parentExists=true;ret.parentPath=lookup.path;ret.parentObject=lookup.node;ret.name=PATH.basename(path);lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});ret.exists=true;ret.path=lookup.path;ret.object=lookup.node;ret.name=lookup.node.name;ret.isRoot=lookup.path==="/"}catch(e){ret.error=e.errno}return ret},createPath:(parent,path,canRead,canWrite)=>{parent=typeof parent=="string"?parent:FS.getPath(parent);var parts=path.split("/").reverse();while(parts.length){var part=parts.pop();if(!part)continue;var current=PATH.join2(parent,part);try{FS.mkdir(current)}catch(e){}parent=current}return current},createFile:(parent,name,properties,canRead,canWrite)=>{var path=PATH.join2(typeof parent=="string"?parent:FS.getPath(parent),name);var mode=FS_getMode(canRead,canWrite);return FS.create(path,mode)},createDataFile:(parent,name,data,canRead,canWrite,canOwn)=>{var path=name;if(parent){parent=typeof parent=="string"?parent:FS.getPath(parent);path=name?PATH.join2(parent,name):parent}var mode=FS_getMode(canRead,canWrite);var node=FS.create(path,mode);if(data){if(typeof data=="string"){var arr=new Array(data.length);for(var i=0,len=data.length;i{var path=PATH.join2(typeof parent=="string"?parent:FS.getPath(parent),name);var mode=FS_getMode(!!input,!!output);if(!FS.createDevice.major)FS.createDevice.major=64;var dev=FS.makedev(FS.createDevice.major++,0);FS.registerDevice(dev,{open:stream=>{stream.seekable=false},close:stream=>{if(output&&output.buffer&&output.buffer.length){output(10)}},read:(stream,buffer,offset,length,pos)=>{var bytesRead=0;for(var i=0;i{for(var i=0;i{if(obj.isDevice||obj.isFolder||obj.link||obj.contents)return true;if(typeof XMLHttpRequest!="undefined"){throw new Error("Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread.")}else if(read_){try{obj.contents=intArrayFromString(read_(obj.url),true);obj.usedBytes=obj.contents.length}catch(e){throw new FS.ErrnoError(29)}}else{throw new Error("Cannot load without read() or XMLHttpRequest.")}},createLazyFile:(parent,name,url,canRead,canWrite)=>{function LazyUint8Array(){this.lengthKnown=false;this.chunks=[]}LazyUint8Array.prototype.get=function LazyUint8Array_get(idx){if(idx>this.length-1||idx<0){return undefined}var chunkOffset=idx%this.chunkSize;var chunkNum=idx/this.chunkSize|0;return this.getter(chunkNum)[chunkOffset]};LazyUint8Array.prototype.setDataGetter=function LazyUint8Array_setDataGetter(getter){this.getter=getter};LazyUint8Array.prototype.cacheLength=function LazyUint8Array_cacheLength(){var xhr=new XMLHttpRequest;xhr.open("HEAD",url,false);xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error("Couldn't load "+url+". Status: "+xhr.status);var datalength=Number(xhr.getResponseHeader("Content-length"));var header;var hasByteServing=(header=xhr.getResponseHeader("Accept-Ranges"))&&header==="bytes";var usesGzip=(header=xhr.getResponseHeader("Content-Encoding"))&&header==="gzip";var chunkSize=1024*1024;if(!hasByteServing)chunkSize=datalength;var doXHR=(from,to)=>{if(from>to)throw new Error("invalid range ("+from+", "+to+") or no bytes requested!");if(to>datalength-1)throw new Error("only "+datalength+" bytes available! programmer error!");var xhr=new XMLHttpRequest;xhr.open("GET",url,false);if(datalength!==chunkSize)xhr.setRequestHeader("Range","bytes="+from+"-"+to);xhr.responseType="arraybuffer";if(xhr.overrideMimeType){xhr.overrideMimeType("text/plain; charset=x-user-defined")}xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error("Couldn't load "+url+". Status: "+xhr.status);if(xhr.response!==undefined){return new Uint8Array(xhr.response||[])}return intArrayFromString(xhr.responseText||"",true)};var lazyArray=this;lazyArray.setDataGetter(chunkNum=>{var start=chunkNum*chunkSize;var end=(chunkNum+1)*chunkSize-1;end=Math.min(end,datalength-1);if(typeof lazyArray.chunks[chunkNum]=="undefined"){lazyArray.chunks[chunkNum]=doXHR(start,end)}if(typeof lazyArray.chunks[chunkNum]=="undefined")throw new Error("doXHR failed!");return lazyArray.chunks[chunkNum]});if(usesGzip||!datalength){chunkSize=datalength=1;datalength=this.getter(0).length;chunkSize=datalength;out("LazyFiles on gzip forces download of the whole file when length is accessed")}this._length=datalength;this._chunkSize=chunkSize;this.lengthKnown=true};if(typeof XMLHttpRequest!="undefined"){if(!ENVIRONMENT_IS_WORKER)throw"Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc";var lazyArray=new LazyUint8Array;Object.defineProperties(lazyArray,{length:{get:function(){if(!this.lengthKnown){this.cacheLength()}return this._length}},chunkSize:{get:function(){if(!this.lengthKnown){this.cacheLength()}return this._chunkSize}}});var properties={isDevice:false,contents:lazyArray}}else{var properties={isDevice:false,url:url}}var node=FS.createFile(parent,name,properties,canRead,canWrite);if(properties.contents){node.contents=properties.contents}else if(properties.url){node.contents=null;node.url=properties.url}Object.defineProperties(node,{usedBytes:{get:function(){return this.contents.length}}});var stream_ops={};var keys=Object.keys(node.stream_ops);keys.forEach(key=>{var fn=node.stream_ops[key];stream_ops[key]=function forceLoadLazyFile(){FS.forceLoadFile(node);return fn.apply(null,arguments)}});function writeChunks(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=contents.length)return 0;var size=Math.min(contents.length-position,length);if(contents.slice){for(var i=0;i{FS.forceLoadFile(node);return writeChunks(stream,buffer,offset,length,position)};stream_ops.mmap=(stream,length,position,prot,flags)=>{FS.forceLoadFile(node);var ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}writeChunks(stream,HEAP8,ptr,length,position);return{ptr:ptr,allocated:true}};node.stream_ops=stream_ops;return node}};var SYSCALLS={DEFAULT_POLLMASK:5,calculateAt:function(dirfd,path,allowEmpty){if(PATH.isAbs(path)){return path}var dir;if(dirfd===-100){dir=FS.cwd()}else{var dirstream=SYSCALLS.getStreamFromFD(dirfd);dir=dirstream.path}if(path.length==0){if(!allowEmpty){throw new FS.ErrnoError(44)}return dir}return PATH.join2(dir,path)},doStat:function(func,path,buf){try{var stat=func(path)}catch(e){if(e&&e.node&&PATH.normalize(path)!==PATH.normalize(FS.getPath(e.node))){return-54}throw e}HEAP32[buf>>2]=stat.dev;HEAP32[buf+8>>2]=stat.ino;HEAP32[buf+12>>2]=stat.mode;HEAPU32[buf+16>>2]=stat.nlink;HEAP32[buf+20>>2]=stat.uid;HEAP32[buf+24>>2]=stat.gid;HEAP32[buf+28>>2]=stat.rdev;HEAP64[buf+40>>3]=BigInt(stat.size);HEAP32[buf+48>>2]=4096;HEAP32[buf+52>>2]=stat.blocks;var atime=stat.atime.getTime();var mtime=stat.mtime.getTime();var ctime=stat.ctime.getTime();HEAP64[buf+56>>3]=BigInt(Math.floor(atime/1e3));HEAPU32[buf+64>>2]=atime%1e3*1e3;HEAP64[buf+72>>3]=BigInt(Math.floor(mtime/1e3));HEAPU32[buf+80>>2]=mtime%1e3*1e3;HEAP64[buf+88>>3]=BigInt(Math.floor(ctime/1e3));HEAPU32[buf+96>>2]=ctime%1e3*1e3;HEAP64[buf+104>>3]=BigInt(stat.ino);return 0},doMsync:function(addr,stream,len,flags,offset){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}if(flags&2){return 0}var buffer=HEAPU8.slice(addr,addr+len);FS.msync(stream,buffer,offset,len,flags)},varargs:undefined,get:function(){SYSCALLS.varargs+=4;var ret=HEAP32[SYSCALLS.varargs-4>>2];return ret},getStr:function(ptr){var ret=UTF8ToString(ptr);return ret},getStreamFromFD:function(fd){var stream=FS.getStream(fd);if(!stream)throw new FS.ErrnoError(8);return stream}};function ___syscall__newselect(nfds,readfds,writefds,exceptfds,timeout){try{var total=0;var srcReadLow=readfds?HEAP32[readfds>>2]:0,srcReadHigh=readfds?HEAP32[readfds+4>>2]:0;var srcWriteLow=writefds?HEAP32[writefds>>2]:0,srcWriteHigh=writefds?HEAP32[writefds+4>>2]:0;var srcExceptLow=exceptfds?HEAP32[exceptfds>>2]:0,srcExceptHigh=exceptfds?HEAP32[exceptfds+4>>2]:0;var dstReadLow=0,dstReadHigh=0;var dstWriteLow=0,dstWriteHigh=0;var dstExceptLow=0,dstExceptHigh=0;var allLow=(readfds?HEAP32[readfds>>2]:0)|(writefds?HEAP32[writefds>>2]:0)|(exceptfds?HEAP32[exceptfds>>2]:0);var allHigh=(readfds?HEAP32[readfds+4>>2]:0)|(writefds?HEAP32[writefds+4>>2]:0)|(exceptfds?HEAP32[exceptfds+4>>2]:0);var check=function(fd,low,high,val){return fd<32?low&val:high&val};for(var fd=0;fd>2]=dstReadLow;HEAP32[readfds+4>>2]=dstReadHigh}if(writefds){HEAP32[writefds>>2]=dstWriteLow;HEAP32[writefds+4>>2]=dstWriteHigh}if(exceptfds){HEAP32[exceptfds>>2]=dstExceptLow;HEAP32[exceptfds+4>>2]=dstExceptHigh}return total}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var SOCKFS={mount:function(mount){Module["websocket"]=Module["websocket"]&&"object"===typeof Module["websocket"]?Module["websocket"]:{};Module["websocket"]._callbacks={};Module["websocket"]["on"]=function(event,callback){if("function"===typeof callback){this._callbacks[event]=callback}return this};Module["websocket"].emit=function(event,param){if("function"===typeof this._callbacks[event]){this._callbacks[event].call(this,param)}};return FS.createNode(null,"/",16384|511,0)},createSocket:function(family,type,protocol){type&=~526336;var streaming=type==1;if(streaming&&protocol&&protocol!=6){throw new FS.ErrnoError(66)}var sock={family:family,type:type,protocol:protocol,server:null,error:null,peers:{},pending:[],recv_queue:[],sock_ops:SOCKFS.websocket_sock_ops};var name=SOCKFS.nextname();var node=FS.createNode(SOCKFS.root,name,49152,0);node.sock=sock;var stream=FS.createStream({path:name,node:node,flags:2,seekable:false,stream_ops:SOCKFS.stream_ops});sock.stream=stream;return sock},getSocket:function(fd){var stream=FS.getStream(fd);if(!stream||!FS.isSocket(stream.node.mode)){return null}return stream.node.sock},stream_ops:{poll:function(stream){var sock=stream.node.sock;return sock.sock_ops.poll(sock)},ioctl:function(stream,request,varargs){var sock=stream.node.sock;return sock.sock_ops.ioctl(sock,request,varargs)},read:function(stream,buffer,offset,length,position){var sock=stream.node.sock;var msg=sock.sock_ops.recvmsg(sock,length);if(!msg){return 0}buffer.set(msg.buffer,offset);return msg.buffer.length},write:function(stream,buffer,offset,length,position){var sock=stream.node.sock;return sock.sock_ops.sendmsg(sock,buffer,offset,length)},close:function(stream){var sock=stream.node.sock;sock.sock_ops.close(sock)}},nextname:function(){if(!SOCKFS.nextname.current){SOCKFS.nextname.current=0}return"socket["+SOCKFS.nextname.current+++"]"},websocket_sock_ops:{createPeer:function(sock,addr,port){var ws;if(typeof addr=="object"){ws=addr;addr=null;port=null}if(ws){if(ws._socket){addr=ws._socket.remoteAddress;port=ws._socket.remotePort}else{var result=/ws[s]?:\/\/([^:]+):(\d+)/.exec(ws.url);if(!result){throw new Error("WebSocket URL must be in the format ws(s)://address:port")}addr=result[1];port=parseInt(result[2],10)}}else{try{var runtimeConfig=Module["websocket"]&&"object"===typeof Module["websocket"];var url="ws:#".replace("#","//");if(runtimeConfig){if("string"===typeof Module["websocket"]["url"]){url=Module["websocket"]["url"]}}if(url==="ws://"||url==="wss://"){var parts=addr.split("/");url=url+parts[0]+":"+port+"/"+parts.slice(1).join("/")}var subProtocols="binary";if(runtimeConfig){if("string"===typeof Module["websocket"]["subprotocol"]){subProtocols=Module["websocket"]["subprotocol"]}}var opts=undefined;if(subProtocols!=="null"){subProtocols=subProtocols.replace(/^ +| +$/g,"").split(/ *, */);opts=subProtocols}if(runtimeConfig&&null===Module["websocket"]["subprotocol"]){subProtocols="null";opts=undefined}var WebSocketConstructor;{WebSocketConstructor=WebSocket}ws=new WebSocketConstructor(url,opts);ws.binaryType="arraybuffer"}catch(e){throw new FS.ErrnoError(23)}}var peer={addr:addr,port:port,socket:ws,dgram_send_queue:[]};SOCKFS.websocket_sock_ops.addPeer(sock,peer);SOCKFS.websocket_sock_ops.handlePeerEvents(sock,peer);if(sock.type===2&&typeof sock.sport!="undefined"){peer.dgram_send_queue.push(new Uint8Array([255,255,255,255,"p".charCodeAt(0),"o".charCodeAt(0),"r".charCodeAt(0),"t".charCodeAt(0),(sock.sport&65280)>>8,sock.sport&255]))}return peer},getPeer:function(sock,addr,port){return sock.peers[addr+":"+port]},addPeer:function(sock,peer){sock.peers[peer.addr+":"+peer.port]=peer},removePeer:function(sock,peer){delete sock.peers[peer.addr+":"+peer.port]},handlePeerEvents:function(sock,peer){var first=true;var handleOpen=function(){Module["websocket"].emit("open",sock.stream.fd);try{var queued=peer.dgram_send_queue.shift();while(queued){peer.socket.send(queued);queued=peer.dgram_send_queue.shift()}}catch(e){peer.socket.close()}};function handleMessage(data){if(typeof data=="string"){var encoder=new TextEncoder;data=encoder.encode(data)}else{assert(data.byteLength!==undefined);if(data.byteLength==0){return}data=new Uint8Array(data)}var wasfirst=first;first=false;if(wasfirst&&data.length===10&&data[0]===255&&data[1]===255&&data[2]===255&&data[3]===255&&data[4]==="p".charCodeAt(0)&&data[5]==="o".charCodeAt(0)&&data[6]==="r".charCodeAt(0)&&data[7]==="t".charCodeAt(0)){var newport=data[8]<<8|data[9];SOCKFS.websocket_sock_ops.removePeer(sock,peer);peer.port=newport;SOCKFS.websocket_sock_ops.addPeer(sock,peer);return}sock.recv_queue.push({addr:peer.addr,port:peer.port,data:data});Module["websocket"].emit("message",sock.stream.fd)}if(ENVIRONMENT_IS_NODE){peer.socket.on("open",handleOpen);peer.socket.on("message",function(data,isBinary){if(!isBinary){return}handleMessage(new Uint8Array(data).buffer)});peer.socket.on("close",function(){Module["websocket"].emit("close",sock.stream.fd)});peer.socket.on("error",function(error){sock.error=14;Module["websocket"].emit("error",[sock.stream.fd,sock.error,"ECONNREFUSED: Connection refused"])})}else{peer.socket.onopen=handleOpen;peer.socket.onclose=function(){Module["websocket"].emit("close",sock.stream.fd)};peer.socket.onmessage=function peer_socket_onmessage(event){handleMessage(event.data)};peer.socket.onerror=function(error){sock.error=14;Module["websocket"].emit("error",[sock.stream.fd,sock.error,"ECONNREFUSED: Connection refused"])}}},poll:function(sock){if(sock.type===1&&sock.server){return sock.pending.length?64|1:0}var mask=0;var dest=sock.type===1?SOCKFS.websocket_sock_ops.getPeer(sock,sock.daddr,sock.dport):null;if(sock.recv_queue.length||!dest||dest&&dest.socket.readyState===dest.socket.CLOSING||dest&&dest.socket.readyState===dest.socket.CLOSED){mask|=64|1}if(!dest||dest&&dest.socket.readyState===dest.socket.OPEN){mask|=4}if(dest&&dest.socket.readyState===dest.socket.CLOSING||dest&&dest.socket.readyState===dest.socket.CLOSED){mask|=16}return mask},ioctl:function(sock,request,arg){switch(request){case 21531:var bytes=0;if(sock.recv_queue.length){bytes=sock.recv_queue[0].data.length}HEAP32[arg>>2]=bytes;return 0;default:return 28}},close:function(sock){if(sock.server){try{sock.server.close()}catch(e){}sock.server=null}var peers=Object.keys(sock.peers);for(var i=0;i>2]=value;return value}function inetPton4(str){var b=str.split(".");for(var i=0;i<4;i++){var tmp=Number(b[i]);if(isNaN(tmp))return null;b[i]=tmp}return(b[0]|b[1]<<8|b[2]<<16|b[3]<<24)>>>0}function jstoi_q(str){return parseInt(str)}function inetPton6(str){var words;var w,offset,z;var valid6regx=/^((?=.*::)(?!.*::.+::)(::)?([\dA-F]{1,4}:(:|\b)|){5}|([\dA-F]{1,4}:){6})((([\dA-F]{1,4}((?!\3)::|:\b|$))|(?!\2\3)){2}|(((2[0-4]|1\d|[1-9])?\d|25[0-5])\.?\b){4})$/i;var parts=[];if(!valid6regx.test(str)){return null}if(str==="::"){return[0,0,0,0,0,0,0,0]}if(str.startsWith("::")){str=str.replace("::","Z:")}else{str=str.replace("::",":Z:")}if(str.indexOf(".")>0){str=str.replace(new RegExp("[.]","g"),":");words=str.split(":");words[words.length-4]=jstoi_q(words[words.length-4])+jstoi_q(words[words.length-3])*256;words[words.length-3]=jstoi_q(words[words.length-2])+jstoi_q(words[words.length-1])*256;words=words.slice(0,words.length-2)}else{words=str.split(":")}offset=0;z=0;for(w=0;w>2]=16}HEAP16[sa>>1]=family;HEAP32[sa+4>>2]=addr;HEAP16[sa+2>>1]=_htons(port);break;case 10:addr=inetPton6(addr);zeroMemory(sa,28);if(addrlen){HEAP32[addrlen>>2]=28}HEAP32[sa>>2]=family;HEAP32[sa+8>>2]=addr[0];HEAP32[sa+12>>2]=addr[1];HEAP32[sa+16>>2]=addr[2];HEAP32[sa+20>>2]=addr[3];HEAP16[sa+2>>1]=_htons(port);break;default:return 5}return 0}var DNS={address_map:{id:1,addrs:{},names:{}},lookup_name:function(name){var res=inetPton4(name);if(res!==null){return name}res=inetPton6(name);if(res!==null){return name}var addr;if(DNS.address_map.addrs[name]){addr=DNS.address_map.addrs[name]}else{var id=DNS.address_map.id++;assert(id<65535,"exceeded max address mappings of 65535");addr="172.29."+(id&255)+"."+(id&65280);DNS.address_map.names[addr]=name;DNS.address_map.addrs[name]=addr}return addr},lookup_addr:function(addr){if(DNS.address_map.names[addr]){return DNS.address_map.names[addr]}return null}};function ___syscall_accept4(fd,addr,addrlen,flags,d1,d2){try{var sock=getSocketFromFD(fd);var newsock=sock.sock_ops.accept(sock);if(addr){var errno=writeSockaddr(addr,newsock.family,DNS.lookup_name(newsock.daddr),newsock.dport,addrlen)}return newsock.stream.fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function inetNtop4(addr){return(addr&255)+"."+(addr>>8&255)+"."+(addr>>16&255)+"."+(addr>>24&255)}function inetNtop6(ints){var str="";var word=0;var longest=0;var lastzero=0;var zstart=0;var len=0;var i=0;var parts=[ints[0]&65535,ints[0]>>16,ints[1]&65535,ints[1]>>16,ints[2]&65535,ints[2]>>16,ints[3]&65535,ints[3]>>16];var hasipv4=true;var v4part="";for(i=0;i<5;i++){if(parts[i]!==0){hasipv4=false;break}}if(hasipv4){v4part=inetNtop4(parts[6]|parts[7]<<16);if(parts[5]===-1){str="::ffff:";str+=v4part;return str}if(parts[5]===0){str="::";if(v4part==="0.0.0.0")v4part="";if(v4part==="0.0.0.1")v4part="1";str+=v4part;return str}}for(word=0;word<8;word++){if(parts[word]===0){if(word-lastzero>1){len=0}lastzero=word;len++}if(len>longest){longest=len;zstart=word-longest+1}}for(word=0;word<8;word++){if(longest>1){if(parts[word]===0&&word>=zstart&&word>1];var port=_ntohs(HEAPU16[sa+2>>1]);var addr;switch(family){case 2:if(salen!==16){return{errno:28}}addr=HEAP32[sa+4>>2];addr=inetNtop4(addr);break;case 10:if(salen!==28){return{errno:28}}addr=[HEAP32[sa+8>>2],HEAP32[sa+12>>2],HEAP32[sa+16>>2],HEAP32[sa+20>>2]];addr=inetNtop6(addr);break;default:return{errno:5}}return{family:family,addr:addr,port:port}}function getSocketAddress(addrp,addrlen,allowNull){if(allowNull&&addrp===0)return null;var info=readSockaddr(addrp,addrlen);if(info.errno)throw new FS.ErrnoError(info.errno);info.addr=DNS.lookup_addr(info.addr)||info.addr;return info}function ___syscall_bind(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);var info=getSocketAddress(addr,addrlen);sock.sock_ops.bind(sock,info.addr,info.port);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_connect(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);var info=getSocketAddress(addr,addrlen);sock.sock_ops.connect(sock,info.addr,info.port);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_faccessat(dirfd,path,amode,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(amode&~7){return-28}var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;if(!node){return-44}var perms="";if(amode&4)perms+="r";if(amode&2)perms+="w";if(amode&1)perms+="x";if(perms&&FS.nodePermissions(node,perms)){return-2}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_fcntl64(fd,cmd,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(cmd){case 0:{var arg=SYSCALLS.get();if(arg<0){return-28}var newStream;newStream=FS.createStream(stream,arg);return newStream.fd}case 1:case 2:return 0;case 3:return stream.flags;case 4:{var arg=SYSCALLS.get();stream.flags|=arg;return 0}case 5:{var arg=SYSCALLS.get();var offset=0;HEAP16[arg+offset>>1]=2;return 0}case 6:case 7:return 0;case 16:case 8:return-28;case 9:setErrNo(28);return-1;default:{return-28}}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_fstat64(fd,buf){try{var stream=SYSCALLS.getStreamFromFD(fd);return SYSCALLS.doStat(FS.stat,stream.path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function stringToUTF8(str,outPtr,maxBytesToWrite){return stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite)}function ___syscall_getdents64(fd,dirp,count){try{var stream=SYSCALLS.getStreamFromFD(fd);if(!stream.getdents){stream.getdents=FS.readdir(stream.path)}var struct_size=280;var pos=0;var off=FS.llseek(stream,0,1);var idx=Math.floor(off/struct_size);while(idx>3]=BigInt(id);HEAP64[dirp+pos+8>>3]=BigInt((idx+1)*struct_size);HEAP16[dirp+pos+16>>1]=280;HEAP8[dirp+pos+18>>0]=type;stringToUTF8(name,dirp+pos+19,256);pos+=struct_size;idx+=1}FS.llseek(stream,idx*struct_size,0);return pos}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_getpeername(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);if(!sock.daddr){return-53}var errno=writeSockaddr(addr,sock.family,DNS.lookup_name(sock.daddr),sock.dport,addrlen);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_getsockname(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);var errno=writeSockaddr(addr,sock.family,DNS.lookup_name(sock.saddr||"0.0.0.0"),sock.sport,addrlen);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_getsockopt(fd,level,optname,optval,optlen,d1){try{var sock=getSocketFromFD(fd);if(level===1){if(optname===4){HEAP32[optval>>2]=sock.error;HEAP32[optlen>>2]=4;sock.error=null;return 0}}return-50}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_ioctl(fd,op,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(op){case 21509:case 21505:{if(!stream.tty)return-59;return 0}case 21510:case 21511:case 21512:case 21506:case 21507:case 21508:{if(!stream.tty)return-59;return 0}case 21519:{if(!stream.tty)return-59;var argp=SYSCALLS.get();HEAP32[argp>>2]=0;return 0}case 21520:{if(!stream.tty)return-59;return-28}case 21531:{var argp=SYSCALLS.get();return FS.ioctl(stream,op,argp)}case 21523:{if(!stream.tty)return-59;return 0}case 21524:{if(!stream.tty)return-59;return 0}default:return-28}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_listen(fd,backlog){try{var sock=getSocketFromFD(fd);sock.sock_ops.listen(sock,backlog);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_lstat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.lstat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_mkdirat(dirfd,path,mode){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);path=PATH.normalize(path);if(path[path.length-1]==="/")path=path.substr(0,path.length-1);FS.mkdir(path,mode,0);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_newfstatat(dirfd,path,buf,flags){try{path=SYSCALLS.getStr(path);var nofollow=flags&256;var allowEmpty=flags&4096;flags=flags&~6400;path=SYSCALLS.calculateAt(dirfd,path,allowEmpty);return SYSCALLS.doStat(nofollow?FS.lstat:FS.stat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs;try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);var mode=varargs?SYSCALLS.get():0;return FS.open(path,flags,mode).fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_poll(fds,nfds,timeout){try{var nonzero=0;for(var i=0;i>2];var events=HEAP16[pollfd+4>>1];var mask=32;var stream=FS.getStream(fd);if(stream){mask=SYSCALLS.DEFAULT_POLLMASK;if(stream.stream_ops.poll){mask=stream.stream_ops.poll(stream)}}mask&=events|8|16;if(mask)nonzero++;HEAP16[pollfd+6>>1]=mask}return nonzero}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_recvfrom(fd,buf,len,flags,addr,addrlen){try{var sock=getSocketFromFD(fd);var msg=sock.sock_ops.recvmsg(sock,len);if(!msg)return 0;if(addr){var errno=writeSockaddr(addr,sock.family,DNS.lookup_name(msg.addr),msg.port,addrlen)}HEAPU8.set(msg.buffer,buf);return msg.buffer.byteLength}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_renameat(olddirfd,oldpath,newdirfd,newpath){try{oldpath=SYSCALLS.getStr(oldpath);newpath=SYSCALLS.getStr(newpath);oldpath=SYSCALLS.calculateAt(olddirfd,oldpath);newpath=SYSCALLS.calculateAt(newdirfd,newpath);FS.rename(oldpath,newpath);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_rmdir(path){try{path=SYSCALLS.getStr(path);FS.rmdir(path);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_sendto(fd,message,length,flags,addr,addr_len){try{var sock=getSocketFromFD(fd);var dest=getSocketAddress(addr,addr_len,true);if(!dest){return FS.write(sock.stream,HEAP8,message,length)}return sock.sock_ops.sendmsg(sock,HEAP8,message,length,dest.addr,dest.port)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_socket(domain,type,protocol){try{var sock=SOCKFS.createSocket(domain,type,protocol);return sock.stream.fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_stat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.stat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_unlinkat(dirfd,path,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(flags===0){FS.unlink(path)}else if(flags===512){FS.rmdir(path)}else{abort("Invalid flags passed to unlinkat")}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var nowIsMonotonic=true;function __emscripten_get_now_is_monotonic(){return nowIsMonotonic}function __emscripten_throw_longjmp(){throw Infinity}function readI53FromI64(ptr){return HEAPU32[ptr>>2]+HEAP32[ptr+4>>2]*4294967296}function __gmtime_js(time,tmPtr){var date=new Date(readI53FromI64(time)*1e3);HEAP32[tmPtr>>2]=date.getUTCSeconds();HEAP32[tmPtr+4>>2]=date.getUTCMinutes();HEAP32[tmPtr+8>>2]=date.getUTCHours();HEAP32[tmPtr+12>>2]=date.getUTCDate();HEAP32[tmPtr+16>>2]=date.getUTCMonth();HEAP32[tmPtr+20>>2]=date.getUTCFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getUTCDay();var start=Date.UTC(date.getUTCFullYear(),0,1,0,0,0,0);var yday=(date.getTime()-start)/(1e3*60*60*24)|0;HEAP32[tmPtr+28>>2]=yday}function isLeapYear(year){return year%4===0&&(year%100!==0||year%400===0)}var MONTH_DAYS_LEAP_CUMULATIVE=[0,31,60,91,121,152,182,213,244,274,305,335];var MONTH_DAYS_REGULAR_CUMULATIVE=[0,31,59,90,120,151,181,212,243,273,304,334];function ydayFromDate(date){var leap=isLeapYear(date.getFullYear());var monthDaysCumulative=leap?MONTH_DAYS_LEAP_CUMULATIVE:MONTH_DAYS_REGULAR_CUMULATIVE;var yday=monthDaysCumulative[date.getMonth()]+date.getDate()-1;return yday}function __localtime_js(time,tmPtr){var date=new Date(readI53FromI64(time)*1e3);HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr+36>>2]=-(date.getTimezoneOffset()*60);var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dst=(summerOffset!=winterOffset&&date.getTimezoneOffset()==Math.min(winterOffset,summerOffset))|0;HEAP32[tmPtr+32>>2]=dst}function __mktime_js(tmPtr){var date=new Date(HEAP32[tmPtr+20>>2]+1900,HEAP32[tmPtr+16>>2],HEAP32[tmPtr+12>>2],HEAP32[tmPtr+8>>2],HEAP32[tmPtr+4>>2],HEAP32[tmPtr>>2],0);var dst=HEAP32[tmPtr+32>>2];var guessedOffset=date.getTimezoneOffset();var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dstOffset=Math.min(winterOffset,summerOffset);if(dst<0){HEAP32[tmPtr+32>>2]=Number(summerOffset!=winterOffset&&dstOffset==guessedOffset)}else if(dst>0!=(dstOffset==guessedOffset)){var nonDstOffset=Math.max(winterOffset,summerOffset);var trueOffset=dst>0?dstOffset:nonDstOffset;date.setTime(date.getTime()+(trueOffset-guessedOffset)*6e4)}HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getYear();return date.getTime()/1e3|0}function __mmap_js(len,prot,flags,fd,off,allocated,addr){try{var stream=SYSCALLS.getStreamFromFD(fd);var res=FS.mmap(stream,len,off,prot,flags);var ptr=res.ptr;HEAP32[allocated>>2]=res.allocated;HEAPU32[addr>>2]=ptr;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function __munmap_js(addr,len,prot,flags,fd,offset){try{var stream=SYSCALLS.getStreamFromFD(fd);if(prot&2){SYSCALLS.doMsync(addr,stream,len,flags,offset)}FS.munmap(stream)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function stringToNewUTF8(str){var size=lengthBytesUTF8(str)+1;var ret=_malloc(size);if(ret)stringToUTF8(str,ret,size);return ret}function __tzset_js(timezone,daylight,tzname){var currentYear=(new Date).getFullYear();var winter=new Date(currentYear,0,1);var summer=new Date(currentYear,6,1);var winterOffset=winter.getTimezoneOffset();var summerOffset=summer.getTimezoneOffset();var stdTimezoneOffset=Math.max(winterOffset,summerOffset);HEAPU32[timezone>>2]=stdTimezoneOffset*60;HEAP32[daylight>>2]=Number(winterOffset!=summerOffset);function extractZone(date){var match=date.toTimeString().match(/\(([A-Za-z ]+)\)$/);return match?match[1]:"GMT"}var winterName=extractZone(winter);var summerName=extractZone(summer);var winterNamePtr=stringToNewUTF8(winterName);var summerNamePtr=stringToNewUTF8(summerName);if(summerOffset>2]=winterNamePtr;HEAPU32[tzname+4>>2]=summerNamePtr}else{HEAPU32[tzname>>2]=summerNamePtr;HEAPU32[tzname+4>>2]=winterNamePtr}}function _abort(){abort("")}Module["_abort"]=_abort;function _dlopen(handle){abort(dlopenMissingError)}var readEmAsmArgsArray=[];function readEmAsmArgs(sigPtr,buf){readEmAsmArgsArray.length=0;var ch;buf>>=2;while(ch=HEAPU8[sigPtr++]){buf+=ch!=105&buf;readEmAsmArgsArray.push(ch==105?HEAP32[buf]:(ch==106?HEAP64:HEAPF64)[buf++>>1]);++buf}return readEmAsmArgsArray}function runEmAsmFunction(code,sigPtr,argbuf){var args=readEmAsmArgs(sigPtr,argbuf);return ASM_CONSTS[code].apply(null,args)}function _emscripten_asm_const_int(code,sigPtr,argbuf){return runEmAsmFunction(code,sigPtr,argbuf)}function _emscripten_date_now(){return Date.now()}function getHeapMax(){return 2147483648}function _emscripten_get_heap_max(){return getHeapMax()}var _emscripten_get_now;_emscripten_get_now=()=>performance.now();function _emscripten_memcpy_big(dest,src,num){HEAPU8.copyWithin(dest,src,src+num)}function emscripten_realloc_buffer(size){var b=wasmMemory.buffer;try{wasmMemory.grow(size-b.byteLength+65535>>>16);updateMemoryViews();return 1}catch(e){}}function _emscripten_resize_heap(requestedSize){var oldSize=HEAPU8.length;requestedSize=requestedSize>>>0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}var alignUp=(x,multiple)=>x+(multiple-x%multiple)%multiple;for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignUp(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=emscripten_realloc_buffer(newSize);if(replacement){return true}}return false}var ENV={};function getExecutableName(){return thisProgram||"./this.program"}function getEnvStrings(){if(!getEnvStrings.strings){var lang=(typeof navigator=="object"&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8";var env={"USER":"web_user","LOGNAME":"web_user","PATH":"/","PWD":"/","HOME":"/home/web_user","LANG":lang,"_":getExecutableName()};for(var x in ENV){if(ENV[x]===undefined)delete env[x];else env[x]=ENV[x]}var strings=[];for(var x in env){strings.push(`${x}=${env[x]}`)}getEnvStrings.strings=strings}return getEnvStrings.strings}function stringToAscii(str,buffer){for(var i=0;i>0]=str.charCodeAt(i)}HEAP8[buffer>>0]=0}function _environ_get(__environ,environ_buf){var bufSize=0;getEnvStrings().forEach(function(string,i){var ptr=environ_buf+bufSize;HEAPU32[__environ+i*4>>2]=ptr;stringToAscii(string,ptr);bufSize+=string.length+1});return 0}function _environ_sizes_get(penviron_count,penviron_buf_size){var strings=getEnvStrings();HEAPU32[penviron_count>>2]=strings.length;var bufSize=0;strings.forEach(function(string){bufSize+=string.length+1});HEAPU32[penviron_buf_size>>2]=bufSize;return 0}function _proc_exit(code){EXITSTATUS=code;if(!keepRuntimeAlive()){if(Module["onExit"])Module["onExit"](code);ABORT=true}quit_(code,new ExitStatus(code))}function exitJS(status,implicit){EXITSTATUS=status;_proc_exit(status)}var _exit=exitJS;function _fd_close(fd){try{var stream=SYSCALLS.getStreamFromFD(fd);FS.close(stream);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_fdstat_get(fd,pbuf){try{var rightsBase=0;var rightsInheriting=0;var flags=0;{var stream=SYSCALLS.getStreamFromFD(fd);var type=stream.tty?2:FS.isDir(stream.mode)?3:FS.isLink(stream.mode)?7:4}HEAP8[pbuf>>0]=type;HEAP16[pbuf+2>>1]=flags;HEAP64[pbuf+8>>3]=BigInt(rightsBase);HEAP64[pbuf+16>>3]=BigInt(rightsInheriting);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function doReadv(stream,iov,iovcnt,offset){var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.read(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr>2]=num;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var MAX_INT53=9007199254740992;var MIN_INT53=-9007199254740992;function bigintToI53Checked(num){return numMAX_INT53?NaN:Number(num)}function _fd_seek(fd,offset,whence,newOffset){try{offset=bigintToI53Checked(offset);if(isNaN(offset))return 61;var stream=SYSCALLS.getStreamFromFD(fd);FS.llseek(stream,offset,whence);HEAP64[newOffset>>3]=BigInt(stream.position);if(stream.getdents&&offset===0&&whence===0)stream.getdents=null;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function doWritev(stream,iov,iovcnt,offset){var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.write(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(typeof offset!=="undefined"){offset+=curr}}return ret}function _fd_write(fd,iov,iovcnt,pnum){try{var stream=SYSCALLS.getStreamFromFD(fd);var num=doWritev(stream,iov,iovcnt);HEAPU32[pnum>>2]=num;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _getaddrinfo(node,service,hint,out){var addr=0;var port=0;var flags=0;var family=0;var type=0;var proto=0;var ai;function allocaddrinfo(family,type,proto,canon,addr,port){var sa,salen,ai;var errno;salen=family===10?28:16;addr=family===10?inetNtop6(addr):inetNtop4(addr);sa=_malloc(salen);errno=writeSockaddr(sa,family,addr,port);assert(!errno);ai=_malloc(32);HEAP32[ai+4>>2]=family;HEAP32[ai+8>>2]=type;HEAP32[ai+12>>2]=proto;HEAPU32[ai+24>>2]=canon;HEAPU32[ai+20>>2]=sa;if(family===10){HEAP32[ai+16>>2]=28}else{HEAP32[ai+16>>2]=16}HEAP32[ai+28>>2]=0;return ai}if(hint){flags=HEAP32[hint>>2];family=HEAP32[hint+4>>2];type=HEAP32[hint+8>>2];proto=HEAP32[hint+12>>2]}if(type&&!proto){proto=type===2?17:6}if(!type&&proto){type=proto===17?2:1}if(proto===0){proto=6}if(type===0){type=1}if(!node&&!service){return-2}if(flags&~(1|2|4|1024|8|16|32)){return-1}if(hint!==0&&HEAP32[hint>>2]&2&&!node){return-1}if(flags&32){return-2}if(type!==0&&type!==1&&type!==2){return-7}if(family!==0&&family!==2&&family!==10){return-6}if(service){service=UTF8ToString(service);port=parseInt(service,10);if(isNaN(port)){if(flags&1024){return-2}return-8}}if(!node){if(family===0){family=2}if((flags&1)===0){if(family===2){addr=_htonl(2130706433)}else{addr=[0,0,0,1]}}ai=allocaddrinfo(family,type,proto,null,addr,port);HEAPU32[out>>2]=ai;return 0}node=UTF8ToString(node);addr=inetPton4(node);if(addr!==null){if(family===0||family===2){family=2}else if(family===10&&flags&8){addr=[0,0,_htonl(65535),addr];family=10}else{return-2}}else{addr=inetPton6(node);if(addr!==null){if(family===0||family===10){family=10}else{return-2}}}if(addr!=null){ai=allocaddrinfo(family,type,proto,node,addr,port);HEAPU32[out>>2]=ai;return 0}if(flags&4){return-2}node=DNS.lookup_name(node);addr=inetPton4(node);if(family===0){family=2}else if(family===10){addr=[0,0,_htonl(65535),addr]}ai=allocaddrinfo(family,type,proto,null,addr,port);HEAPU32[out>>2]=ai;return 0}function _getnameinfo(sa,salen,node,nodelen,serv,servlen,flags){var info=readSockaddr(sa,salen);if(info.errno){return-6}var port=info.port;var addr=info.addr;var overflowed=false;if(node&&nodelen){var lookup;if(flags&1||!(lookup=DNS.lookup_addr(addr))){if(flags&8){return-2}}else{addr=lookup}var numBytesWrittenExclNull=stringToUTF8(addr,node,nodelen);if(numBytesWrittenExclNull+1>=nodelen){overflowed=true}}if(serv&&servlen){port=""+port;var numBytesWrittenExclNull=stringToUTF8(port,serv,servlen);if(numBytesWrittenExclNull+1>=servlen){overflowed=true}}if(overflowed){return-12}return 0}function arraySum(array,index){var sum=0;for(var i=0;i<=index;sum+=array[i++]){}return sum}var MONTH_DAYS_LEAP=[31,29,31,30,31,30,31,31,30,31,30,31];var MONTH_DAYS_REGULAR=[31,28,31,30,31,30,31,31,30,31,30,31];function addDays(date,days){var newDate=new Date(date.getTime());while(days>0){var leap=isLeapYear(newDate.getFullYear());var currentMonth=newDate.getMonth();var daysInCurrentMonth=(leap?MONTH_DAYS_LEAP:MONTH_DAYS_REGULAR)[currentMonth];if(days>daysInCurrentMonth-newDate.getDate()){days-=daysInCurrentMonth-newDate.getDate()+1;newDate.setDate(1);if(currentMonth<11){newDate.setMonth(currentMonth+1)}else{newDate.setMonth(0);newDate.setFullYear(newDate.getFullYear()+1)}}else{newDate.setDate(newDate.getDate()+days);return newDate}}return newDate}function writeArrayToMemory(array,buffer){HEAP8.set(array,buffer)}function _strftime(s,maxsize,format,tm){var tm_zone=HEAP32[tm+40>>2];var date={tm_sec:HEAP32[tm>>2],tm_min:HEAP32[tm+4>>2],tm_hour:HEAP32[tm+8>>2],tm_mday:HEAP32[tm+12>>2],tm_mon:HEAP32[tm+16>>2],tm_year:HEAP32[tm+20>>2],tm_wday:HEAP32[tm+24>>2],tm_yday:HEAP32[tm+28>>2],tm_isdst:HEAP32[tm+32>>2],tm_gmtoff:HEAP32[tm+36>>2],tm_zone:tm_zone?UTF8ToString(tm_zone):""};var pattern=UTF8ToString(format);var EXPANSION_RULES_1={"%c":"%a %b %d %H:%M:%S %Y","%D":"%m/%d/%y","%F":"%Y-%m-%d","%h":"%b","%r":"%I:%M:%S %p","%R":"%H:%M","%T":"%H:%M:%S","%x":"%m/%d/%y","%X":"%H:%M:%S","%Ec":"%c","%EC":"%C","%Ex":"%m/%d/%y","%EX":"%H:%M:%S","%Ey":"%y","%EY":"%Y","%Od":"%d","%Oe":"%e","%OH":"%H","%OI":"%I","%Om":"%m","%OM":"%M","%OS":"%S","%Ou":"%u","%OU":"%U","%OV":"%V","%Ow":"%w","%OW":"%W","%Oy":"%y"};for(var rule in EXPANSION_RULES_1){pattern=pattern.replace(new RegExp(rule,"g"),EXPANSION_RULES_1[rule])}var WEEKDAYS=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];var MONTHS=["January","February","March","April","May","June","July","August","September","October","November","December"];function leadingSomething(value,digits,character){var str=typeof value=="number"?value.toString():value||"";while(str.length0?1:0}var compare;if((compare=sgn(date1.getFullYear()-date2.getFullYear()))===0){if((compare=sgn(date1.getMonth()-date2.getMonth()))===0){compare=sgn(date1.getDate()-date2.getDate())}}return compare}function getFirstWeekStartDate(janFourth){switch(janFourth.getDay()){case 0:return new Date(janFourth.getFullYear()-1,11,29);case 1:return janFourth;case 2:return new Date(janFourth.getFullYear(),0,3);case 3:return new Date(janFourth.getFullYear(),0,2);case 4:return new Date(janFourth.getFullYear(),0,1);case 5:return new Date(janFourth.getFullYear()-1,11,31);case 6:return new Date(janFourth.getFullYear()-1,11,30)}}function getWeekBasedYear(date){var thisDate=addDays(new Date(date.tm_year+1900,0,1),date.tm_yday);var janFourthThisYear=new Date(thisDate.getFullYear(),0,4);var janFourthNextYear=new Date(thisDate.getFullYear()+1,0,4);var firstWeekStartThisYear=getFirstWeekStartDate(janFourthThisYear);var firstWeekStartNextYear=getFirstWeekStartDate(janFourthNextYear);if(compareByDay(firstWeekStartThisYear,thisDate)<=0){if(compareByDay(firstWeekStartNextYear,thisDate)<=0){return thisDate.getFullYear()+1}return thisDate.getFullYear()}return thisDate.getFullYear()-1}var EXPANSION_RULES_2={"%a":function(date){return WEEKDAYS[date.tm_wday].substring(0,3)},"%A":function(date){return WEEKDAYS[date.tm_wday]},"%b":function(date){return MONTHS[date.tm_mon].substring(0,3)},"%B":function(date){return MONTHS[date.tm_mon]},"%C":function(date){var year=date.tm_year+1900;return leadingNulls(year/100|0,2)},"%d":function(date){return leadingNulls(date.tm_mday,2)},"%e":function(date){return leadingSomething(date.tm_mday,2," ")},"%g":function(date){return getWeekBasedYear(date).toString().substring(2)},"%G":function(date){return getWeekBasedYear(date)},"%H":function(date){return leadingNulls(date.tm_hour,2)},"%I":function(date){var twelveHour=date.tm_hour;if(twelveHour==0)twelveHour=12;else if(twelveHour>12)twelveHour-=12;return leadingNulls(twelveHour,2)},"%j":function(date){return leadingNulls(date.tm_mday+arraySum(isLeapYear(date.tm_year+1900)?MONTH_DAYS_LEAP:MONTH_DAYS_REGULAR,date.tm_mon-1),3)},"%m":function(date){return leadingNulls(date.tm_mon+1,2)},"%M":function(date){return leadingNulls(date.tm_min,2)},"%n":function(){return"\n"},"%p":function(date){if(date.tm_hour>=0&&date.tm_hour<12){return"AM"}return"PM"},"%S":function(date){return leadingNulls(date.tm_sec,2)},"%t":function(){return"\t"},"%u":function(date){return date.tm_wday||7},"%U":function(date){var days=date.tm_yday+7-date.tm_wday;return leadingNulls(Math.floor(days/7),2)},"%V":function(date){var val=Math.floor((date.tm_yday+7-(date.tm_wday+6)%7)/7);if((date.tm_wday+371-date.tm_yday-2)%7<=2){val++}if(!val){val=52;var dec31=(date.tm_wday+7-date.tm_yday-1)%7;if(dec31==4||dec31==5&&isLeapYear(date.tm_year%400-1)){val++}}else if(val==53){var jan1=(date.tm_wday+371-date.tm_yday)%7;if(jan1!=4&&(jan1!=3||!isLeapYear(date.tm_year)))val=1}return leadingNulls(val,2)},"%w":function(date){return date.tm_wday},"%W":function(date){var days=date.tm_yday+7-(date.tm_wday+6)%7;return leadingNulls(Math.floor(days/7),2)},"%y":function(date){return(date.tm_year+1900).toString().substring(2)},"%Y":function(date){return date.tm_year+1900},"%z":function(date){var off=date.tm_gmtoff;var ahead=off>=0;off=Math.abs(off)/60;off=off/60*100+off%60;return(ahead?"+":"-")+String("0000"+off).slice(-4)},"%Z":function(date){return date.tm_zone},"%%":function(){return"%"}};pattern=pattern.replace(/%%/g,"\0\0");for(var rule in EXPANSION_RULES_2){if(pattern.includes(rule)){pattern=pattern.replace(new RegExp(rule,"g"),EXPANSION_RULES_2[rule](date))}}pattern=pattern.replace(/\0\0/g,"%");var bytes=intArrayFromString(pattern,false);if(bytes.length>maxsize){return 0}writeArrayToMemory(bytes,s);return bytes.length-1}var FSNode=function(parent,name,mode,rdev){if(!parent){parent=this}this.parent=parent;this.mount=parent.mount;this.mounted=null;this.id=FS.nextInode++;this.name=name;this.mode=mode;this.node_ops={};this.stream_ops={};this.rdev=rdev};var readMode=292|73;var writeMode=146;Object.defineProperties(FSNode.prototype,{read:{get:function(){return(this.mode&readMode)===readMode},set:function(val){val?this.mode|=readMode:this.mode&=~readMode}},write:{get:function(){return(this.mode&writeMode)===writeMode},set:function(val){val?this.mode|=writeMode:this.mode&=~writeMode}},isFolder:{get:function(){return FS.isDir(this.mode)}},isDevice:{get:function(){return FS.isChrdev(this.mode)}}});FS.FSNode=FSNode;FS.createPreloadedFile=FS_createPreloadedFile;FS.staticInit();var wasmImports={"b":___assert_fail,"f":___cxa_throw,"ka":___dlsym,"R":___syscall__newselect,"L":___syscall_accept4,"K":___syscall_bind,"J":___syscall_connect,"la":___syscall_faccessat,"g":___syscall_fcntl64,"ha":___syscall_fstat64,"U":___syscall_getdents64,"I":___syscall_getpeername,"H":___syscall_getsockname,"G":___syscall_getsockopt,"y":___syscall_ioctl,"F":___syscall_listen,"ea":___syscall_lstat64,"$":___syscall_mkdirat,"fa":___syscall_newfstatat,"w":___syscall_openat,"V":___syscall_poll,"E":___syscall_recvfrom,"T":___syscall_renameat,"S":___syscall_rmdir,"D":___syscall_sendto,"v":___syscall_socket,"ga":___syscall_stat64,"O":___syscall_unlinkat,"ia":__emscripten_get_now_is_monotonic,"M":__emscripten_throw_longjmp,"Y":__gmtime_js,"Z":__localtime_js,"_":__mktime_js,"W":__mmap_js,"X":__munmap_js,"P":__tzset_js,"a":_abort,"t":_dlopen,"oa":_emscripten_asm_const_int,"m":_emscripten_date_now,"Q":_emscripten_get_heap_max,"p":_emscripten_get_now,"ja":_emscripten_memcpy_big,"N":_emscripten_resize_heap,"ca":_environ_get,"da":_environ_sizes_get,"l":_exit,"n":_fd_close,"ba":_fd_fdstat_get,"x":_fd_read,"aa":_fd_seek,"q":_fd_write,"k":_getaddrinfo,"i":_getnameinfo,"pa":invoke_i,"na":invoke_ii,"c":invoke_iii,"o":invoke_iiii,"s":invoke_iiiii,"z":invoke_iiiiii,"r":invoke_iiiiiiiii,"B":invoke_iiiijj,"qa":invoke_iij,"h":invoke_vi,"j":invoke_vii,"d":invoke_viiii,"ma":invoke_viiiiii,"A":invoke_viiiiiiii,"C":is_timeout,"u":send_progress,"e":_strftime};var asm=createWasm();var ___wasm_call_ctors=function(){return(___wasm_call_ctors=Module["asm"]["sa"]).apply(null,arguments)};var _malloc=Module["_malloc"]=function(){return(_malloc=Module["_malloc"]=Module["asm"]["ta"]).apply(null,arguments)};var ___errno_location=function(){return(___errno_location=Module["asm"]["va"]).apply(null,arguments)};var _ntohs=function(){return(_ntohs=Module["asm"]["wa"]).apply(null,arguments)};var _htons=function(){return(_htons=Module["asm"]["xa"]).apply(null,arguments)};var _ffmpeg=Module["_ffmpeg"]=function(){return(_ffmpeg=Module["_ffmpeg"]=Module["asm"]["ya"]).apply(null,arguments)};var _ffprobe=Module["_ffprobe"]=function(){return(_ffprobe=Module["_ffprobe"]=Module["asm"]["za"]).apply(null,arguments)};var _htonl=function(){return(_htonl=Module["asm"]["Aa"]).apply(null,arguments)};var _emscripten_builtin_memalign=function(){return(_emscripten_builtin_memalign=Module["asm"]["Ba"]).apply(null,arguments)};var _setThrew=function(){return(_setThrew=Module["asm"]["Ca"]).apply(null,arguments)};var stackSave=function(){return(stackSave=Module["asm"]["Da"]).apply(null,arguments)};var stackRestore=function(){return(stackRestore=Module["asm"]["Ea"]).apply(null,arguments)};var ___cxa_is_pointer_type=function(){return(___cxa_is_pointer_type=Module["asm"]["Fa"]).apply(null,arguments)};var _ff_h264_cabac_tables=Module["_ff_h264_cabac_tables"]=1546732;var ___start_em_js=Module["___start_em_js"]=6077485;var ___stop_em_js=Module["___stop_em_js"]=6077662;function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiijj(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iij(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_i(index){var sp=stackSave();try{return getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}Module["setValue"]=setValue;Module["getValue"]=getValue;Module["UTF8ToString"]=UTF8ToString;Module["stringToUTF8"]=stringToUTF8;Module["lengthBytesUTF8"]=lengthBytesUTF8;Module["FS"]=FS;var calledRun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}preRun();if(runDependencies>0){return}function doRun(){if(calledRun)return;calledRun=true;Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve(Module);if(Module["onRuntimeInitialized"])Module["onRuntimeInitialized"]();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(function(){setTimeout(function(){Module["setStatus"]("")},1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run(); + + + return createFFmpegCore.ready +} + +); +})(); +if (typeof exports === 'object' && typeof module === 'object') + module.exports = createFFmpegCore; +else if (typeof define === 'function' && define['amd']) + define([], function() { return createFFmpegCore; }); +else if (typeof exports === 'object') + exports["createFFmpegCore"] = createFFmpegCore; diff --git a/web/public/ffmpeg/ffmpeg-core.wasm b/web/public/ffmpeg/ffmpeg-core.wasm new file mode 100644 index 0000000..246b0fe Binary files /dev/null and b/web/public/ffmpeg/ffmpeg-core.wasm differ diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..c78b194 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,14 @@ + + + + + diff --git a/web/src/api/ai.ts b/web/src/api/ai.ts new file mode 100644 index 0000000..5fbaab9 --- /dev/null +++ b/web/src/api/ai.ts @@ -0,0 +1,36 @@ +import type { + AIServiceConfig, + AIServiceType, + CreateAIConfigRequest, + TestConnectionRequest, + UpdateAIConfigRequest +} from '../types/ai' +import request from '../utils/request' + +export const aiAPI = { + list(serviceType?: AIServiceType) { + return request.get('/ai-configs', { + params: { service_type: serviceType } + }) + }, + + create(data: CreateAIConfigRequest) { + return request.post('/ai-configs', data) + }, + + get(id: number) { + return request.get(`/ai-configs/${id}`) + }, + + update(id: number, data: UpdateAIConfigRequest) { + return request.put(`/ai-configs/${id}`, data) + }, + + delete(id: number) { + return request.delete(`/ai-configs/${id}`) + }, + + testConnection(data: TestConnectionRequest) { + return request.post('/ai-configs/test', data) + } +} diff --git a/web/src/api/asset.ts b/web/src/api/asset.ts new file mode 100644 index 0000000..3e86d87 --- /dev/null +++ b/web/src/api/asset.ts @@ -0,0 +1,47 @@ +import type { + Asset, + AssetCollection, + AssetTag, + CreateAssetRequest, + ListAssetsParams, + UpdateAssetRequest +} from '../types/asset' +import request from '../utils/request' + +export const assetAPI = { + createAsset(data: CreateAssetRequest) { + return request.post('/assets', data) + }, + + updateAsset(id: number, data: UpdateAssetRequest) { + return request.put(`/assets/${id}`, data) + }, + + getAsset(id: number) { + return request.get(`/assets/${id}`) + }, + + listAssets(params: ListAssetsParams) { + return request.get<{ + items: Asset[] + pagination: { + page: number + page_size: number + total: number + total_pages: number + } + }>('/assets', { params }) + }, + + deleteAsset(id: number) { + return request.delete(`/assets/${id}`) + }, + + importFromImage(imageGenId: number) { + return request.post(`/assets/import/image/${imageGenId}`) + }, + + importFromVideo(videoGenId: number) { + return request.post(`/assets/import/video/${videoGenId}`) + } +} diff --git a/web/src/api/character-library.ts b/web/src/api/character-library.ts new file mode 100644 index 0000000..f3064e4 --- /dev/null +++ b/web/src/api/character-library.ts @@ -0,0 +1,109 @@ +import request from '../utils/request' + +export interface CharacterLibraryItem { + id: string + name: string + category?: string + image_url: string + description?: string + tags?: string + source_type: string + created_at: string + updated_at: string +} + +export interface CreateLibraryItemRequest { + name: string + category?: string + image_url: string + description?: string + tags?: string + source_type?: string +} + +export interface CharacterLibraryQuery { + page?: number + page_size?: number + category?: string + source_type?: string + keyword?: string +} + +export const characterLibraryAPI = { + // 获取角色库列表 + list(params?: CharacterLibraryQuery) { + return request.get<{ + items: CharacterLibraryItem[] + pagination: { + page: number + page_size: number + total: number + total_pages: number + } + }>('/character-library', { params }) + }, + + // 创建角色库项 + create(data: CreateLibraryItemRequest) { + return request.post('/character-library', data) + }, + + // 获取角色库项详情 + get(id: string) { + return request.get(`/character-library/${id}`) + }, + + // 删除角色库项 + delete(id: string) { + return request.delete(`/character-library/${id}`) + }, + + // 上传角色图片 + uploadCharacterImage(characterId: string, imageUrl: string) { + return request.put(`/characters/${characterId}/image`, { image_url: imageUrl }) + }, + + // 从角色库应用形象 + applyFromLibrary(characterId: string, libraryItemId: string) { + return request.put(`/characters/${characterId}/image-from-library`, { + library_item_id: libraryItemId + }) + }, + + // 将角色添加到角色库 + addCharacterToLibrary(characterId: string, category?: string) { + return request.post(`/characters/${characterId}/add-to-library`, { + category + }) + }, + + // AI生成角色形象 + generateCharacterImage(characterId: string, model?: string) { + return request.post<{ image_url: string }>(`/characters/${characterId}/generate-image`, { + model + }) + }, + + // 批量生成角色形象 + batchGenerateCharacterImages(characterIds: string[], model?: string) { + return request.post<{ message: string; count: number }>('/characters/batch-generate-images', { + character_ids: characterIds, + model + }) + }, + + // 更新角色信息 + updateCharacter(characterId: number, data: { + name?: string + appearance?: string + personality?: string + description?: string + }) { + return request.put(`/characters/${characterId}`, data) + }, + + // 删除角色 + deleteCharacter(characterId: number) { + return request.delete(`/characters/${characterId}`) + } +} diff --git a/web/src/api/drama.ts b/web/src/api/drama.ts new file mode 100644 index 0000000..af335ca --- /dev/null +++ b/web/src/api/drama.ts @@ -0,0 +1,119 @@ +import type { + CreateDramaRequest, + Drama, + DramaListQuery, + DramaStats, + UpdateDramaRequest +} from '../types/drama' +import request from '../utils/request' + +export const dramaAPI = { + list(params?: DramaListQuery) { + return request.get<{ + items: Drama[] + pagination: { + page: number + page_size: number + total: number + total_pages: number + } + }>('/dramas', { params }) + }, + + create(data: CreateDramaRequest) { + return request.post('/dramas', data) + }, + + get(id: string) { + return request.get(`/dramas/${id}`) + }, + + update(id: string, data: UpdateDramaRequest) { + return request.put(`/dramas/${id}`, data) + }, + + delete(id: string) { + return request.delete(`/dramas/${id}`) + }, + + getStats() { + return request.get('/dramas/stats') + }, + + saveOutline(id: string, data: { title: string; summary: string; genre?: string; tags?: string[] }) { + return request.put(`/dramas/${id}/outline`, data) + }, + + getCharacters(dramaId: string) { + return request.get(`/dramas/${dramaId}/characters`) + }, + + saveCharacters(id: string, data: any[], episodeId?: string) { + return request.put(`/dramas/${id}/characters`, { + characters: data, + episode_id: episodeId ? parseInt(episodeId) : undefined + }) + }, + + saveEpisodes(id: string, data: any[]) { + return request.put(`/dramas/${id}/episodes`, { episodes: data }) + }, + + saveProgress(id: string, data: { current_step: string; step_data?: any }) { + return request.put(`/dramas/${id}/progress`, data) + }, + + generateStoryboard(episodeId: string) { + return request.post(`/episodes/${episodeId}/storyboards`) + }, + + getBackgrounds(episodeId: string) { + return request.get(`/images/episode/${episodeId}/backgrounds`) + }, + + extractBackgrounds(episodeId: string) { + return request.post(`/images/episode/${episodeId}/backgrounds/extract`) + }, + + batchGenerateBackgrounds(episodeId: string) { + return request.post(`/images/episode/${episodeId}/batch`) + }, + + generateSingleBackground(backgroundId: number, dramaId: string, prompt: string) { + return request.post('/images', { + background_id: backgroundId, + drama_id: dramaId, + prompt: prompt + }) + }, + + getStoryboards(episodeId: string) { + return request.get(`/episodes/${episodeId}/storyboards`) + }, + + updateStoryboard(storyboardId: string, data: any) { + return request.put(`/storyboards/${storyboardId}`, data) + }, + + updateScene(sceneId: string, data: { + background_id?: string; + characters?: string[]; + location?: string; + time?: string; + action?: string; + dialogue?: string; + description?: string; + duration?: number; + }) { + return request.put(`/scenes/${sceneId}`, data) + }, + + generateSceneImage(data: { scene_id: string; prompt?: string; model?: string }) { + return request.post('/scenes/generate-image', data) + }, + + // 完成集数制作(触发视频合成) + finalizeEpisode(episodeId: string, timelineData?: any) { + return request.post(`/episodes/${episodeId}/finalize`, timelineData || {}) + } +} diff --git a/web/src/api/frame.ts b/web/src/api/frame.ts new file mode 100644 index 0000000..40ed525 --- /dev/null +++ b/web/src/api/frame.ts @@ -0,0 +1,99 @@ +import request from '../utils/request' + +// 帧类型 +export type FrameType = 'first' | 'key' | 'last' | 'panel' | 'action' + +// 单帧提示词 +export interface SingleFramePrompt { + prompt: string + description: string +} + +// 多帧提示词 +export interface MultiFramePrompt { + layout: string // horizontal_3, grid_2x2 等 + frames: SingleFramePrompt[] +} + +// 帧提示词响应 +export interface FramePromptResponse { + frame_type: FrameType + single_frame?: SingleFramePrompt + multi_frame?: MultiFramePrompt +} + +// 生成帧提示词请求 +export interface GenerateFramePromptRequest { + frame_type: FrameType + panel_count?: number // 分镜板格数,默认3 +} + +/** + * 生成指定类型的帧提示词 + */ +export function generateFramePrompt( + storyboardId: number, + data: GenerateFramePromptRequest +): Promise { + return request.post(`/storyboards/${storyboardId}/frame-prompt`, data) +} + +/** + * 生成首帧提示词 + */ +export function generateFirstFrame(storyboardId: number): Promise { + return generateFramePrompt(storyboardId, { frame_type: 'first' }) +} + +/** + * 生成关键帧提示词 + */ +export function generateKeyFrame(storyboardId: number): Promise { + return generateFramePrompt(storyboardId, { frame_type: 'key' }) +} + +/** + * 生成尾帧提示词 + */ +export function generateLastFrame(storyboardId: number): Promise { + return generateFramePrompt(storyboardId, { frame_type: 'last' }) +} + +/** + * 生成分镜板(3格组合) + */ +export function generatePanelFrames( + storyboardId: number, + panelCount: number = 3 +): Promise { + return generateFramePrompt(storyboardId, { + frame_type: 'panel', + panel_count: panelCount + }) +} + +/** + * 生成动作序列(5格) + */ +export function generateActionSequence(storyboardId: number): Promise { + return generateFramePrompt(storyboardId, { frame_type: 'action' }) +} + +// 帧提示词记录(从数据库查询) +export interface FramePromptRecord { + id: number + storyboard_id: number + frame_type: FrameType + prompt: string + description?: string + layout?: string + created_at: string + updated_at: string +} + +/** + * 查询镜头的所有已生成帧提示词 + */ +export function getStoryboardFramePrompts(storyboardId: number): Promise<{ frame_prompts: FramePromptRecord[] }> { + return request.get<{ frame_prompts: FramePromptRecord[] }>(`/storyboards/${storyboardId}/frame-prompts`) +} diff --git a/web/src/api/generation.ts b/web/src/api/generation.ts new file mode 100644 index 0000000..49edd6a --- /dev/null +++ b/web/src/api/generation.ts @@ -0,0 +1,42 @@ +import type { Character, Episode } from '../types/drama' +import type { + GenerateCharactersRequest, + GenerateEpisodesRequest, + GenerateOutlineRequest, + OutlineResult +} from '../types/generation' +import request from '../utils/request' + +export const generationAPI = { + generateOutline(data: GenerateOutlineRequest) { + return request.post('/generation/outline', data) + }, + + generateCharacters(data: GenerateCharactersRequest) { + return request.post('/generation/characters', data) + }, + + generateEpisodes(data: GenerateEpisodesRequest) { + return request.post('/generation/episodes', data) + }, + + generateStoryboard(episodeId: string) { + return request.post<{ task_id: string; status: string; message: string }>(`/episodes/${episodeId}/storyboards`) + }, + + getTaskStatus(taskId: string) { + return request.get<{ + id: string + type: string + status: string + progress: number + message?: string + error?: string + result?: string + created_at: string + updated_at: string + completed_at?: string + }>(`/tasks/${taskId}`) + } + +} diff --git a/web/src/api/image.ts b/web/src/api/image.ts new file mode 100644 index 0000000..4ceb507 --- /dev/null +++ b/web/src/api/image.ts @@ -0,0 +1,40 @@ +import type { + GenerateImageRequest, + ImageGeneration, + ImageGenerationListParams +} from '../types/image' +import request from '../utils/request' + +export const imageAPI = { + generateImage(data: GenerateImageRequest) { + return request.post('/images', data) + }, + + generateForScene(sceneId: number) { + return request.post(`/images/scene/${sceneId}`) + }, + + batchGenerateForEpisode(episodeId: number) { + return request.post(`/images/episode/${episodeId}/batch`) + }, + + getImage(id: number) { + return request.get(`/images/${id}`) + }, + + listImages(params: ImageGenerationListParams) { + return request.get<{ + items: ImageGeneration[] + pagination: { + page: number + page_size: number + total: number + total_pages: number + } + }>('/images', { params }) + }, + + deleteImage(id: number) { + return request.delete(`/images/${id}`) + } +} diff --git a/web/src/api/video.ts b/web/src/api/video.ts new file mode 100644 index 0000000..b551855 --- /dev/null +++ b/web/src/api/video.ts @@ -0,0 +1,44 @@ +import type { + GenerateVideoRequest, + VideoGeneration, + VideoGenerationListParams +} from '../types/video' +import request from '../utils/request' + +export const videoAPI = { + generateVideo(data: GenerateVideoRequest) { + return request.post('/videos', data) + }, + + generateFromImage(imageGenId: number) { + return request.post(`/videos/image/${imageGenId}`) + }, + + batchGenerateForEpisode(episodeId: number) { + return request.post(`/videos/episode/${episodeId}/batch`) + }, + + getVideoGeneration(id: number) { + return request.get(`/videos/${id}`) + }, + + getVideo(id: number) { + return request.get(`/videos/${id}`) + }, + + listVideos(params: VideoGenerationListParams) { + return request.get<{ + items: VideoGeneration[] + pagination: { + page: number + page_size: number + total: number + total_pages: number + } + }>('/videos', { params }) + }, + + deleteVideo(id: number) { + return request.delete(`/videos/${id}`) + } +} diff --git a/web/src/api/videoMerge.ts b/web/src/api/videoMerge.ts new file mode 100644 index 0000000..b9c808e --- /dev/null +++ b/web/src/api/videoMerge.ts @@ -0,0 +1,65 @@ +import request from '../utils/request' + +export interface SceneClip { + scene_id: string + video_url: string + start_time: number + end_time: number + duration: number + order: number +} + +export interface MergeVideoRequest { + episode_id: string + drama_id: string + title: string + scenes: SceneClip[] + provider?: string + model?: string +} + +export interface VideoMerge { + id: number + episode_id: string + drama_id: string + title: string + provider: string + model?: string + status: 'pending' | 'processing' | 'completed' | 'failed' + scenes: SceneClip[] + merged_url?: string + duration?: number + task_id?: string + error_msg?: string + created_at: string + completed_at?: string +} + +export const videoMergeAPI = { + async mergeVideos(data: MergeVideoRequest): Promise { + const response = await request.post<{ merge: VideoMerge }>('/video-merges', data) + return response.merge + }, + + async getMerge(mergeId: number): Promise { + const response = await request.get<{ merge: VideoMerge }>(`/video-merges/${mergeId}`) + return response.merge + }, + + async listMerges(params: { + episode_id?: string + status?: string + page?: number + page_size?: number + }): Promise<{ merges: VideoMerge[]; total: number }> { + const response = await request.get<{ merges: VideoMerge[]; total: number }>('/video-merges', { params }) + return { + merges: response.merges || [], + total: response.total || 0 + } + }, + + async deleteMerge(mergeId: number): Promise { + await request.delete(`/video-merges/${mergeId}`) + } +} diff --git a/web/src/assets/styles/main.css b/web/src/assets/styles/main.css new file mode 100644 index 0000000..8a011d1 --- /dev/null +++ b/web/src/assets/styles/main.css @@ -0,0 +1,21 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +#app { + width: 100%; + height: 100%; +} diff --git a/web/src/components/editor/StoryboardEditor.vue b/web/src/components/editor/StoryboardEditor.vue new file mode 100644 index 0000000..cc325e6 --- /dev/null +++ b/web/src/components/editor/StoryboardEditor.vue @@ -0,0 +1,1465 @@ + + + + + diff --git a/web/src/components/editor/VideoTimelineEditor.vue b/web/src/components/editor/VideoTimelineEditor.vue new file mode 100644 index 0000000..87acc5a --- /dev/null +++ b/web/src/components/editor/VideoTimelineEditor.vue @@ -0,0 +1,2413 @@ + + + + + diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..f99aa92 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,21 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' + +import App from './App.vue' +import router from './router' +import './assets/styles/main.css' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus) + +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.mount('#app') diff --git a/web/src/router/index.ts b/web/src/router/index.ts new file mode 100644 index 0000000..e167126 --- /dev/null +++ b/web/src/router/index.ts @@ -0,0 +1,84 @@ +import type { RouteRecordRaw } from 'vue-router' +import { createRouter, createWebHistory } from 'vue-router' + +const routes: RouteRecordRaw[] = [ + { + path: '/', + name: 'DramaList', + component: () => import('../views/drama/DramaList.vue') + }, + { + path: '/dramas/create', + name: 'DramaCreate', + component: () => import('../views/drama/DramaCreate.vue') + }, + { + path: '/dramas/:id', + name: 'DramaManagement', + component: () => import('../views/drama/DramaManagement.vue') + }, + { + path: '/dramas/:id/episode/:episodeNumber', + name: 'EpisodeWorkflowNew', + component: () => import('../views/drama/EpisodeWorkflow.vue') + }, + { + path: '/dramas/:id/script', + name: 'ScriptGeneration', + component: () => import('../views/workflow/ScriptGeneration.vue') + }, + { + path: '/dramas/:id/characters', + name: 'CharacterExtraction', + component: () => import('../views/workflow/CharacterExtraction.vue') + }, + { + path: '/dramas/:id/images/characters', + name: 'CharacterImages', + component: () => import('../views/workflow/CharacterImages.vue') + }, + { + path: '/dramas/:id/settings', + name: 'DramaSettings', + component: () => import('../views/workflow/DramaSettings.vue') + }, + { + path: '/episodes/:id/edit', + name: 'ScriptEdit', + component: () => import('../views/script/ScriptEdit.vue') + }, + { + path: '/episodes/:id/storyboard', + name: 'StoryboardEdit', + component: () => import('../views/storyboard/StoryboardEdit.vue') + }, + { + path: '/episodes/:id/generate', + name: 'Generation', + component: () => import('../views/generation/ImageGeneration.vue') + }, + { + path: '/timeline/:id', + name: 'TimelineEditor', + component: () => import('../views/editor/TimelineEditor.vue') + }, + { + path: '/dramas/:dramaId/episode/:episodeNumber/professional', + name: 'ProfessionalEditor', + component: () => import('../views/drama/ProfessionalEditor.vue') + }, + { + path: '/settings/ai-config', + name: 'AIConfig', + component: () => import('../views/settings/AIConfig.vue') + } +] + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes +}) + +// 开源版本 - 无需认证 + +export default router diff --git a/web/src/types/ai.ts b/web/src/types/ai.ts new file mode 100644 index 0000000..fc29496 --- /dev/null +++ b/web/src/types/ai.ts @@ -0,0 +1,61 @@ +export interface AIServiceConfig { + id: number + service_type: AIServiceType + name: string + base_url: string + api_key: string + model: string | string[] // 支持单个或多个模型 + endpoint: string + query_endpoint?: string // 异步查询端点(用于视频等异步任务) + priority: number // 优先级,数值越大优先级越高 + is_active: boolean + settings?: string + created_at: string + updated_at: string +} + +export type AIServiceType = 'text' | 'image' | 'video' + +export interface CreateAIConfigRequest { + service_type: AIServiceType + name: string + base_url: string + api_key: string + model: string | string[] // 支持单个或多个模型 + endpoint?: string + query_endpoint?: string // 异步查询端点(用于视频等异步任务) + priority?: number // 优先级,数值越大优先级越高 + settings?: string +} + +export interface UpdateAIConfigRequest { + name?: string + base_url?: string + api_key?: string + model?: string | string[] // 支持单个或多个模型 + endpoint?: string + query_endpoint?: string // 异步查询端点(用于视频等异步任务) + priority?: number // 优先级,数值越大优先级越高 + is_active?: boolean + settings?: string +} + +export interface TestConnectionRequest { + base_url: string + api_key: string + model: string | string[] // 支持单个或多个模型 + endpoint?: string + query_endpoint?: string // 异步查询端点(用于视频等异步任务) +} + +export interface AIServiceProvider { + id: number + name: string + display_name: string + service_type: AIServiceType + default_url: string + description: string + is_active: boolean + created_at: string + updated_at: string +} diff --git a/web/src/types/asset.ts b/web/src/types/asset.ts new file mode 100644 index 0000000..6162a22 --- /dev/null +++ b/web/src/types/asset.ts @@ -0,0 +1,94 @@ +export interface Asset { + id: number + drama_id?: number + episode_id?: number + storyboard_id?: number + storyboard_num?: number + name: string + description?: string + type: AssetType + category?: string + url: string + thumbnail_url?: string + local_path?: string + file_size?: number + mime_type?: string + width?: number + height?: number + duration?: number + format?: string + image_gen_id?: number + video_gen_id?: number + tags?: AssetTag[] + collections?: AssetCollection[] + is_favorite: boolean + view_count: number + created_at: string + updated_at: string +} + +export type AssetType = 'image' | 'video' | 'audio' + +export interface AssetTag { + id: number + name: string + color?: string + created_at: string +} + +export interface AssetCollection { + id: number + drama_id?: number + name: string + description?: string + assets?: Asset[] + created_at: string +} + +export interface CreateAssetRequest { + drama_id?: number + name: string + description?: string + type: AssetType + category?: string + url: string + thumbnail_url?: string + local_path?: string + file_size?: number + mime_type?: string + width?: number + height?: number + duration?: number + format?: string + image_gen_id?: number + video_gen_id?: number + tag_ids?: number[] +} + +export interface UpdateAssetRequest { + name?: string + description?: string + category?: string + thumbnail_url?: string + tag_ids?: number[] + is_favorite?: boolean +} + +export interface ListAssetsParams { + drama_id?: string + episode_id?: number + storyboard_id?: number + type?: 'image' | 'video' | 'audio' + category?: string + tag_ids?: number[] + is_favorite?: boolean + search?: string + page?: number + page_size?: number +} + +export const ASSET_CATEGORIES = { + image: ['角色', '场景', '道具', '背景', '其他'], + video: ['分镜', '特效', '片头', '片尾', '其他'], + audio: ['配音', '音效', '背景音乐', '片头曲', '片尾曲', '其他'] +} diff --git a/web/src/types/drama.ts b/web/src/types/drama.ts new file mode 100644 index 0000000..b9100f4 --- /dev/null +++ b/web/src/types/drama.ts @@ -0,0 +1,143 @@ +export interface Drama { + id: string + + title: string + description?: string + genre?: string + style?: string + total_episodes: number + total_duration: number + total_scenes?: number + duration?: number + status: DramaStatus + thumbnail?: string + tags?: any + metadata?: any + created_at: string + updated_at: string + characters?: Character[] + episodes?: Episode[] + scenes?: Scene[] +} + +export type DramaStatus = 'draft' | 'planning' | 'production' | 'completed' | 'archived' | 'generating' | 'error' + +export interface Character { + id: number + drama_id: string + name: string + role?: string + description?: string + appearance?: string + personality?: string + voice_style?: string + background?: string + reference_images?: any + seed_value?: string + sort_order?: number + image_url?: string + image_generation_status?: string + image_generation_error?: string + created_at: string + updated_at: string +} + +export interface Episode { + id: string + drama_id: string + episode_number: number + title: string + content: string + description?: string + script_content?: string + duration?: number + status: string + video_url?: string + thumbnail?: string + storyboard_count?: number + scene_count?: number + composition_count?: number + video_count?: number + timeline_status?: string + storyboards?: Storyboard[] + scenes?: Scene[] + characters?: Character[] + shots?: any[] + created_at: string + updated_at: string +} + +export interface Storyboard { + id: string + episode_id: string + storyboard_number: number + title?: string + description?: string + location?: string + time?: string + duration?: number + dialogue?: string + action?: string + atmosphere?: string + image_prompt?: string + video_prompt?: string + characters?: any + image_url?: string + video_url?: string + composed_image?: string + scene_id?: string + scene?: Scene + created_at: string + updated_at: string + [key: string]: any +} + +export interface Scene { + id: string + drama_id: string + location: string + time: string + prompt: string + description?: string + title?: string + storyboard_number?: number + storyboard_count?: number + image_url?: string + video_url?: string + status: string + image_generation_status?: string + image_generation_error?: string + created_at: string + updated_at: string +} + +export interface CreateDramaRequest { + title: string + description?: string + genre?: string + tags?: string +} + +export interface UpdateDramaRequest { + title?: string + description?: string + genre?: string + tags?: string + status?: DramaStatus +} + +export interface DramaListQuery { + page?: number + page_size?: number + status?: DramaStatus + genre?: string + keyword?: string +} + +export interface DramaStats { + total: number + by_status: Array<{ + status: string + count: number + }> +} diff --git a/web/src/types/generation.ts b/web/src/types/generation.ts new file mode 100644 index 0000000..0102ccb --- /dev/null +++ b/web/src/types/generation.ts @@ -0,0 +1,79 @@ +export interface GenerateOutlineRequest { + drama_id: string + theme: string + genre?: string + style?: string + length?: number + temperature?: number +} + +export interface GenerateCharactersRequest { + drama_id: string + outline?: string + count?: number + temperature?: number +} + +export interface GenerateEpisodesRequest { + drama_id: string + outline?: string + episode_count: number + temperature?: number +} + +export interface OutlineResult { + title: string + summary: string + genre: string + tags: string[] + characters: CharacterOutline[] + episodes: EpisodeOutline[] + key_scenes: string[] +} + +export interface CharacterOutline { + name: string + role: string + description: string + personality: string + appearance: string +} + +export interface EpisodeOutline { + episode_number: number + title: string + summary: string + scenes: string[] + duration: number +} + +export interface ParseScriptRequest { + drama_id: string + script_content: string + auto_split?: boolean +} + +export interface ParseScriptResult { + episodes: ParsedEpisode[] + characters: ParsedCharacter[] + summary: string +} + +export interface ParsedCharacter { + name: string + role: string + description: string + personality: string +} + +export interface ParsedEpisode { + episode_number: number + title: string + description: string + script_content: string + duration: number + chapter_start?: number + chapter_end?: number + start_marker?: string + end_marker?: string +} diff --git a/web/src/types/image.ts b/web/src/types/image.ts new file mode 100644 index 0000000..bc8b47a --- /dev/null +++ b/web/src/types/image.ts @@ -0,0 +1,65 @@ +export interface ImageGeneration { + id: number + storyboard_id?: number + scene_id?: string + drama_id: string + character_id?: number + image_type?: string + frame_type?: string + provider: string + prompt: string + negative_prompt?: string + model: string + size?: string + quality?: string + style?: string + steps?: number + cfg_scale?: number + seed?: number + image_url?: string + image_generation?: any + local_path?: string + status: ImageStatus + task_id?: string + error_msg?: string + width?: number + height?: number + created_at: string + updated_at: string + completed_at?: string +} + +export type ImageStatus = 'pending' | 'processing' | 'completed' | 'failed' + +export type ImageProvider = 'openai' | 'dalle' | 'midjourney' | 'stable_diffusion' | 'sd' + +export interface GenerateImageRequest { + scene_id?: number + storyboard_id?: number + drama_id: string + image_type?: string + frame_type?: string + prompt: string + negative_prompt?: string + reference_images?: string[] + provider?: string + model?: string + size?: string + quality?: string + style?: string + steps?: number + cfg_scale?: number + seed?: number + width?: number + height?: number +} + +export interface ImageGenerationListParams { + drama_id?: string + scene_id?: string + storyboard_id?: number + frame_type?: string + status?: ImageStatus + page?: number + page_size?: number +} diff --git a/web/src/types/timeline.ts b/web/src/types/timeline.ts new file mode 100644 index 0000000..b32acf8 --- /dev/null +++ b/web/src/types/timeline.ts @@ -0,0 +1,165 @@ +import type { Asset } from './asset' + +export interface Timeline { + id: number + drama_id: number + episode_id?: number + name: string + description?: string + duration: number + fps: number + resolution?: string + status: TimelineStatus + tracks?: TimelineTrack[] + created_at: string + updated_at: string +} + +export type TimelineStatus = 'draft' | 'editing' | 'completed' | 'exporting' + +export interface TimelineTrack { + id: number + timeline_id: number + name: string + type: TrackType + order: number + is_locked: boolean + is_muted: boolean + volume?: number + clips?: TimelineClip[] + created_at: string +} + +export type TrackType = 'video' | 'audio' | 'text' + +export interface TimelineClip { + id: number + track_id: number + asset_id?: number + asset?: Asset + scene_id?: number + name: string + start_time: number + end_time: number + duration: number + trim_start?: number + trim_end?: number + speed?: number + volume?: number + is_muted: boolean + fade_in?: number + fade_out?: number + transition_in_id?: number + transition_out_id?: number + in_transition?: ClipTransition + out_transition?: ClipTransition + effects?: ClipEffect[] + created_at: string +} + +export interface ClipTransition { + id: number + type: TransitionType + duration: number + easing?: string + config?: Record +} + +export type TransitionType = 'fade' | 'crossfade' | 'slide' | 'wipe' | 'zoom' | 'dissolve' + +export interface ClipEffect { + id: number + clip_id: number + type: EffectType + name: string + is_enabled: boolean + order: number + config?: Record +} + +export type EffectType = 'filter' | 'color' | 'blur' | 'brightness' | 'contrast' | 'saturation' + +export interface CreateTimelineRequest { + drama_id: number + episode_id?: number + name: string + description?: string + fps?: number + resolution?: string +} + +export interface UpdateTimelineRequest { + name?: string + description?: string + fps?: number + resolution?: string + status?: TimelineStatus +} + +export interface CreateTrackRequest { + name: string + type: TrackType + order?: number + volume?: number +} + +export interface UpdateTrackRequest { + name?: string + order?: number + is_locked?: boolean + is_muted?: boolean + volume?: number +} + +export interface CreateClipRequest { + track_id: number + asset_id?: number + scene_id?: number + name?: string + start_time: number + duration: number + trim_start?: number + trim_end?: number + speed?: number + volume?: number + fade_in?: number + fade_out?: number +} + +export interface UpdateClipRequest { + name?: string + start_time?: number + duration?: number + trim_start?: number + trim_end?: number + speed?: number + volume?: number + is_muted?: boolean + fade_in?: number + fade_out?: number +} + +export interface CreateTransitionRequest { + type: TransitionType + duration: number + easing?: string + config?: Record +} + +export const TRANSITION_TYPES = [ + { label: '淡入淡出', value: 'fade' }, + { label: '交叉淡化', value: 'crossfade' }, + { label: '滑动', value: 'slide' }, + { label: '擦除', value: 'wipe' }, + { label: '缩放', value: 'zoom' }, + { label: '溶解', value: 'dissolve' } +] + +export const EFFECT_TYPES = [ + { label: '滤镜', value: 'filter' }, + { label: '色彩', value: 'color' }, + { label: '模糊', value: 'blur' }, + { label: '亮度', value: 'brightness' }, + { label: '对比度', value: 'contrast' }, + { label: '饱和度', value: 'saturation' } +] diff --git a/web/src/types/user.ts b/web/src/types/user.ts new file mode 100644 index 0000000..c268534 --- /dev/null +++ b/web/src/types/user.ts @@ -0,0 +1,26 @@ +export interface User { + id: number + username: string + email: string + avatar?: string + nickname?: string + phone?: string + role: string + status: number + created_at: string +} + +export interface UserConfig { + text_provider: string + text_model: string + text_api_key_set: boolean + image_provider: string + image_model: string + image_api_key_set: boolean + video_provider: string + video_model: string + video_api_key_set: boolean + default_style: string + default_resolution: string + default_fps: number +} diff --git a/web/src/types/video.ts b/web/src/types/video.ts new file mode 100644 index 0000000..455965e --- /dev/null +++ b/web/src/types/video.ts @@ -0,0 +1,81 @@ +export interface VideoGeneration { + id: number + storyboard_id?: number + scene_id?: string // 已废弃,保留用于兼容 + drama_id: string + image_gen_id?: number + provider: string + prompt: string + model?: string + image_url?: string + first_frame_url?: string + duration?: number + fps?: number + resolution?: string + aspect_ratio?: string + style?: string + motion_level?: number + camera_motion?: string + seed?: number + video_url?: string + local_path?: string + status: VideoStatus + task_id?: string + error_msg?: string + width?: number + height?: number + created_at: string + updated_at: string + completed_at?: string +} + +export type VideoStatus = 'pending' | 'processing' | 'completed' | 'failed' + +export type VideoProvider = 'runway' | 'pika' | 'doubao' | 'openai' + +export interface GenerateVideoRequest { + storyboard_id?: number + scene_id?: string // 已废弃,保留用于兼容 + drama_id: string + image_gen_id?: number + image_url: string + prompt: string + provider?: string + model?: string + duration?: number + fps?: number + aspect_ratio?: string + style?: string + motion_level?: number + camera_motion?: string + seed?: number + first_frame_url?: string // 首帧图片URL + last_frame_url?: string // 尾帧图片URL +} + +export interface VideoGenerationListParams { + drama_id?: string + storyboard_id?: string + scene_id?: string // 已废弃,保留用于兼容 + status?: string // 支持单个状态或逗号分隔的多个状态,如 "pending,processing" + page?: number + page_size?: number +} + +export const VIDEO_ASPECT_RATIOS = [ + { label: '16:9 (横屏)', value: '16:9' }, + { label: '9:16 (竖屏)', value: '9:16' }, + { label: '1:1 (正方形)', value: '1:1' }, + { label: '4:3 (传统)', value: '4:3' } +] + +export const CAMERA_MOTIONS = [ + { label: '静止', value: 'static' }, + { label: '推进', value: 'zoom_in' }, + { label: '拉远', value: 'zoom_out' }, + { label: '左移', value: 'pan_left' }, + { label: '右移', value: 'pan_right' }, + { label: '上移', value: 'tilt_up' }, + { label: '下移', value: 'tilt_down' }, + { label: '环绕', value: 'orbit' } +] diff --git a/web/src/utils/ffmpeg.ts b/web/src/utils/ffmpeg.ts new file mode 100644 index 0000000..235c49c --- /dev/null +++ b/web/src/utils/ffmpeg.ts @@ -0,0 +1,218 @@ +import { FFmpeg } from '@ffmpeg/ffmpeg' +import { fetchFile, toBlobURL } from '@ffmpeg/util' + +let ffmpegInstance: FFmpeg | null = null +let loadPromise: Promise | null = null + +export interface VideoTrimOptions { + startTime: number + endTime: number +} + +export interface VideoMergeOptions { + clips: Array<{ + url: string + startTime?: number + endTime?: number + }> +} + +export interface ProgressCallback { + (progress: number): void +} + +async function getFFmpeg(): Promise { + if (ffmpegInstance) { + return ffmpegInstance + } + + if (loadPromise) { + return loadPromise + } + + loadPromise = (async () => { + const ffmpeg = new FFmpeg() + + ffmpeg.on('log', ({ message }) => { + console.log('[FFmpeg]', message) + }) + + const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd' + await ffmpeg.load({ + coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), + wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm') + }) + + ffmpegInstance = ffmpeg + return ffmpeg + })() + + return loadPromise +} + +export async function trimVideo( + videoUrl: string, + options: VideoTrimOptions, + onProgress?: ProgressCallback +): Promise { + const ffmpeg = await getFFmpeg() + + if (onProgress) onProgress(10) + + const inputFileName = 'input.mp4' + const outputFileName = 'output.mp4' + + await ffmpeg.writeFile(inputFileName, await fetchFile(videoUrl)) + + if (onProgress) onProgress(30) + + const args = [ + '-i', inputFileName, + '-ss', options.startTime.toString(), + '-to', options.endTime.toString(), + '-c', 'copy', + '-avoid_negative_ts', '1', + outputFileName + ] + + await ffmpeg.exec(args) + + if (onProgress) onProgress(80) + + const data = await ffmpeg.readFile(outputFileName) as Uint8Array + + await ffmpeg.deleteFile(inputFileName) + await ffmpeg.deleteFile(outputFileName) + + if (onProgress) onProgress(100) + + return new Blob([new Uint8Array(data)], { type: 'video/mp4' }) +} + +export async function mergeVideos( + options: VideoMergeOptions, + onProgress?: ProgressCallback +): Promise { + const ffmpeg = await getFFmpeg() + + if (onProgress) onProgress(5) + + const tempFiles: string[] = [] + + for (let i = 0; i < options.clips.length; i++) { + const clip = options.clips[i] + const fileName = `clip_${i}.mp4` + + await ffmpeg.writeFile(fileName, await fetchFile(clip.url)) + tempFiles.push(fileName) + + if (onProgress) { + onProgress(5 + (i + 1) / options.clips.length * 40) + } + } + + const listContent = tempFiles.map(file => `file '${file}'`).join('\n') + await ffmpeg.writeFile('filelist.txt', new TextEncoder().encode(listContent)) + + if (onProgress) onProgress(50) + + await ffmpeg.exec([ + '-f', 'concat', + '-safe', '0', + '-i', 'filelist.txt', + '-c', 'copy', + 'output.mp4' + ]) + + if (onProgress) onProgress(90) + + const data = await ffmpeg.readFile('output.mp4') as Uint8Array + + for (const file of tempFiles) { + await ffmpeg.deleteFile(file) + } + await ffmpeg.deleteFile('filelist.txt') + await ffmpeg.deleteFile('output.mp4') + + if (onProgress) onProgress(100) + + return new Blob([new Uint8Array(data)], { type: 'video/mp4' }) +} + +export async function trimAndMergeVideos( + clips: Array<{ + url: string + startTime: number + endTime: number + }>, + onProgress?: ProgressCallback +): Promise { + const ffmpeg = await getFFmpeg() + + if (onProgress) onProgress(5) + + const trimmedFiles: string[] = [] + + for (let i = 0; i < clips.length; i++) { + const clip = clips[i] + const inputName = `input_${i}.mp4` + const outputName = `trimmed_${i}.mp4` + + await ffmpeg.writeFile(inputName, await fetchFile(clip.url)) + + await ffmpeg.exec([ + '-i', inputName, + '-ss', clip.startTime.toString(), + '-to', clip.endTime.toString(), + '-c', 'copy', + '-avoid_negative_ts', '1', + outputName + ]) + + await ffmpeg.deleteFile(inputName) + trimmedFiles.push(outputName) + + if (onProgress) { + onProgress(5 + (i + 1) / clips.length * 60) + } + } + + const listContent = trimmedFiles.map(file => `file '${file}'`).join('\n') + await ffmpeg.writeFile('filelist.txt', new TextEncoder().encode(listContent)) + + if (onProgress) onProgress(70) + + await ffmpeg.exec([ + '-f', 'concat', + '-safe', '0', + '-i', 'filelist.txt', + '-c', 'copy', + 'final.mp4' + ]) + + if (onProgress) onProgress(95) + + const data = await ffmpeg.readFile('final.mp4') as Uint8Array + + for (const file of trimmedFiles) { + await ffmpeg.deleteFile(file) + } + await ffmpeg.deleteFile('filelist.txt') + await ffmpeg.deleteFile('final.mp4') + + if (onProgress) onProgress(100) + + return new Blob([new Uint8Array(data)], { type: 'video/mp4' }) +} + +export async function isFFmpegLoaded(): Promise { + return ffmpegInstance !== null +} + +export async function unloadFFmpeg(): Promise { + if (ffmpegInstance) { + await ffmpegInstance.terminate() + ffmpegInstance = null + loadPromise = null + } +} diff --git a/web/src/utils/request.ts b/web/src/utils/request.ts new file mode 100644 index 0000000..4877a82 --- /dev/null +++ b/web/src/utils/request.ts @@ -0,0 +1,48 @@ +import type { AxiosError, AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios' +import axios from 'axios' +import { ElMessage } from 'element-plus' + +interface CustomAxiosInstance extends Omit { + get(url: string, config?: AxiosRequestConfig): Promise + post(url: string, data?: any, config?: AxiosRequestConfig): Promise + put(url: string, data?: any, config?: AxiosRequestConfig): Promise + patch(url: string, data?: any, config?: AxiosRequestConfig): Promise + delete(url: string, config?: AxiosRequestConfig): Promise +} + +const request = axios.create({ + baseURL: '/api/v1', + timeout: 600000, // 10分钟超时,匹配后端AI生成接口 + headers: { + 'Content-Type': 'application/json' + } +}) as CustomAxiosInstance + +// 开源版本 - 无需认证token +request.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + return config + }, + (error: AxiosError) => { + return Promise.reject(error) + } +) + +request.interceptors.response.use( + (response) => { + const res = response.data + if (res.success) { + return res.data + } else { + // 不在这里显示错误提示,让业务代码自行处理 + return Promise.reject(new Error(res.error?.message || '请求失败')) + } + }, + (error: AxiosError) => { + // 不在拦截器中自动显示错误提示,让业务代码根据具体情况处理 + // 只抛出错误供调用者捕获 + return Promise.reject(error) + } +) + +export default request diff --git a/web/src/utils/videoMerger.ts b/web/src/utils/videoMerger.ts new file mode 100644 index 0000000..0d4d230 --- /dev/null +++ b/web/src/utils/videoMerger.ts @@ -0,0 +1,328 @@ +import { FFmpeg } from '@ffmpeg/ffmpeg' +import { fetchFile, toBlobURL } from '@ffmpeg/util' + +export interface VideoClip { + url: string + startTime: number + endTime: number + duration: number + transition?: TransitionEffect +} + +export type TransitionType = 'fade' | 'fadeblack' | 'fadewhite' | 'slideleft' | 'slideright' | 'slideup' | 'slidedown' | 'wipeleft' | 'wiperight' | 'circleopen' | 'circleclose' | 'none' + +export interface TransitionEffect { + type: TransitionType + duration: number // 转场时长(秒) +} + +export interface MergeProgress { + phase: 'loading' | 'processing' | 'encoding' | 'completed' + progress: number + message: string +} + +class VideoMerger { + private ffmpeg: FFmpeg + private loaded: boolean = false + private onProgress?: (progress: MergeProgress) => void + + constructor() { + this.ffmpeg = new FFmpeg() + } + + async initialize(onProgress?: (progress: MergeProgress) => void) { + if (this.loaded) return + + this.onProgress = onProgress + + this.onProgress?.({ + phase: 'loading', + progress: 0, + message: '正在加载FFmpeg引擎(首次需要下载约30MB)...' + }) + + // CDN列表(优先使用国内CDN) + const cdnList = [ + 'https://unpkg.zhimg.com/@ffmpeg/core@0.12.6/dist/esm', // 知乎CDN镜像(国内) + 'https://npm.elemecdn.com/@ffmpeg/core@0.12.6/dist/esm', // 饿了么CDN(国内) + 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm', // jsDelivr(全球CDN,国内可用) + 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm', // unpkg(国外) + ] + + this.ffmpeg.on('log', ({ message }) => { + console.log('[FFmpeg]', message) + }) + + this.ffmpeg.on('progress', ({ progress, time }) => { + this.onProgress?.({ + phase: 'encoding', + progress: Math.round(progress * 100), + message: `正在合并视频... ${Math.round(progress * 100)}%` + }) + }) + + // 尝试多个CDN源 + let lastError: Error | null = null + for (let i = 0; i < cdnList.length; i++) { + const baseURL = cdnList[i] + + try { + this.onProgress?.({ + phase: 'loading', + progress: (i / cdnList.length) * 50, + message: `正在从CDN ${i + 1}/${cdnList.length} 加载FFmpeg...` + }) + + // 添加超时控制 + const loadPromise = this.ffmpeg.load({ + coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), + wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'), + }) + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('加载超时')), 60000) // 60秒超时 + }) + + await Promise.race([loadPromise, timeoutPromise]) + + // 加载成功 + this.loaded = true + + this.onProgress?.({ + phase: 'loading', + progress: 100, + message: 'FFmpeg加载完成' + }) + + return + } catch (error) { + console.error(`CDN ${i + 1} 加载失败:`, error) + lastError = error as Error + + if (i < cdnList.length - 1) { + this.onProgress?.({ + phase: 'loading', + progress: ((i + 1) / cdnList.length) * 50, + message: `CDN ${i + 1} 失败,尝试备用源...` + }) + } + } + } + + // 所有CDN都失败 + throw new Error(`FFmpeg加载失败: ${lastError?.message || '未知错误'}。请检查网络连接或稍后重试。`) + } + + async mergeVideos(clips: VideoClip[]): Promise { + if (!this.loaded) { + await this.initialize(this.onProgress) + } + + if (clips.length === 0) { + throw new Error('没有视频片段') + } + + this.onProgress?.({ + phase: 'processing', + progress: 0, + message: '正在下载视频片段...' + }) + + // 并行下载所有视频文件 + this.onProgress?.({ + phase: 'processing', + progress: 0, + message: `正在下载 ${clips.length} 个视频片段...` + }) + + const downloadPromises = clips.map((clip, i) => + fetchFile(clip.url).then(data => ({ index: i, data })) + ) + + const downloads = await Promise.all(downloadPromises) + + this.onProgress?.({ + phase: 'processing', + progress: 30, + message: '下载完成,正在处理视频...' + }) + + // 写入文件系统并处理 + const inputFiles: string[] = [] + for (let i = 0; i < clips.length; i++) { + const clip = clips[i] + const download = downloads.find(d => d.index === i)! + const inputFileName = `input${i}.mp4` + const outputFileName = `clip${i}.mp4` + + // 写入原始视频 + await this.ffmpeg.writeFile(inputFileName, download.data) + + // 如果需要裁剪,先裁剪视频 + if (clip.startTime > 0 || clip.endTime < clip.duration) { + this.onProgress?.({ + phase: 'processing', + progress: Math.round(30 + (i / clips.length) * 20), + message: `正在裁剪视频片段 ${i + 1}/${clips.length}...` + }) + + await this.ffmpeg.exec([ + '-i', inputFileName, + '-ss', clip.startTime.toString(), + '-t', (clip.endTime - clip.startTime).toString(), + '-c', 'copy', + outputFileName + ]) + + inputFiles.push(outputFileName) + await this.ffmpeg.deleteFile(inputFileName) + } else { + inputFiles.push(inputFileName) + } + } + + this.onProgress?.({ + phase: 'processing', + progress: 50, + message: '正在准备合并...' + }) + + // 检查是否有转场效果 + const hasTransitions = clips.some(clip => clip.transition && clip.transition.type !== 'none') + + if (!hasTransitions || clips.length === 1) { + // 没有转场效果,使用简单的concat方式(更快) + const concatContent = inputFiles.map(f => `file '${f}'`).join('\n') + await this.ffmpeg.writeFile('concat.txt', concatContent) + + this.onProgress?.({ + phase: 'encoding', + progress: 0, + message: '正在合并视频...' + }) + + await this.ffmpeg.exec([ + '-f', 'concat', + '-safe', '0', + '-i', 'concat.txt', + '-c', 'copy', + '-movflags', '+faststart', + 'output.mp4' + ]) + } else { + // 有转场效果,使用filter_complex(需要重新编码) + this.onProgress?.({ + phase: 'encoding', + progress: 0, + message: '正在添加转场效果并合并视频(这需要较长时间)...' + }) + + await this.mergeWithTransitions(inputFiles, clips) + } + + this.onProgress?.({ + phase: 'completed', + progress: 90, + message: '正在生成最终文件...' + }) + + // 读取输出文件 + const data = await this.ffmpeg.readFile('output.mp4') + const blob = new Blob([data], { type: 'video/mp4' }) + + // 清理临时文件 + for (const file of inputFiles) { + await this.ffmpeg.deleteFile(file) + } + await this.ffmpeg.deleteFile('concat.txt') + await this.ffmpeg.deleteFile('output.mp4') + + this.onProgress?.({ + phase: 'completed', + progress: 100, + message: '合并完成!' + }) + + return blob + } + + private async mergeWithTransitions(inputFiles: string[], clips: VideoClip[]) { + // 构建FFmpeg filter_complex命令 + const filterParts: string[] = [] + const inputs: string[] = [] + + // 为每个输入添加标签 + for (let i = 0; i < inputFiles.length; i++) { + inputs.push('-i', inputFiles[i]) + filterParts.push(`[${i}:v]setpts=PTS-STARTPTS[v${i}]`) + filterParts.push(`[${i}:a]asetpts=PTS-STARTPTS[a${i}]`) + } + + // 构建转场链 + let videoChain = 'v0' + let audioChain = 'a0' + + for (let i = 1; i < clips.length; i++) { + const transition = clips[i].transition + const transType = transition?.type || 'fade' + const transDuration = transition?.duration || 1.0 + + const offset = clips.slice(0, i).reduce((sum, c) => sum + c.duration, 0) - transDuration + + // 视频转场 + const xfadeFilter = this.getXfadeFilter(transType, transDuration, offset) + filterParts.push(`[${videoChain}][v${i}]${xfadeFilter}[v${i}out]`) + videoChain = `v${i}out` + + // 音频交叉淡入淡出 + filterParts.push(`[${audioChain}][a${i}]acrossfade=d=${transDuration}:c1=tri:c2=tri[a${i}out]`) + audioChain = `a${i}out` + } + + const filterComplex = filterParts.join(';') + + // 执行FFmpeg命令 + await this.ffmpeg.exec([ + ...inputs, + '-filter_complex', filterComplex, + '-map', `[${videoChain}]`, + '-map', `[${audioChain}]`, + '-c:v', 'libx264', + '-preset', 'ultrafast', + '-crf', '23', + '-c:a', 'aac', + '-b:a', '128k', + '-movflags', '+faststart', + 'output.mp4' + ]) + } + + private getXfadeFilter(type: TransitionType, duration: number, offset: number): string { + const xfadeTypes: Record = { + 'fade': 'fade', + 'fadeblack': 'fadeblack', + 'fadewhite': 'fadewhite', + 'slideleft': 'slideleft', + 'slideright': 'slideright', + 'slideup': 'slideup', + 'slidedown': 'slidedown', + 'wipeleft': 'wipeleft', + 'wiperight': 'wiperight', + 'circleopen': 'circleopen', + 'circleclose': 'circleclose' + } + + const xfadeType = xfadeTypes[type] || 'fade' + return `xfade=transition=${xfadeType}:duration=${duration}:offset=${offset}` + } + + async terminate() { + if (this.loaded) { + this.ffmpeg.terminate() + this.loaded = false + } + } +} + +export const videoMerger = new VideoMerger() diff --git a/web/src/views/dashboard/Dashboard.vue b/web/src/views/dashboard/Dashboard.vue new file mode 100644 index 0000000..a052560 --- /dev/null +++ b/web/src/views/dashboard/Dashboard.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/web/src/views/drama/DramaCreate.vue b/web/src/views/drama/DramaCreate.vue new file mode 100644 index 0000000..d5b7e8c --- /dev/null +++ b/web/src/views/drama/DramaCreate.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/web/src/views/drama/DramaList.vue b/web/src/views/drama/DramaList.vue new file mode 100644 index 0000000..2a083e5 --- /dev/null +++ b/web/src/views/drama/DramaList.vue @@ -0,0 +1,463 @@ + + + + + diff --git a/web/src/views/drama/DramaManagement.vue b/web/src/views/drama/DramaManagement.vue new file mode 100644 index 0000000..7b9e358 --- /dev/null +++ b/web/src/views/drama/DramaManagement.vue @@ -0,0 +1,667 @@ + + + + + diff --git a/web/src/views/drama/DramaWorkflow.vue b/web/src/views/drama/DramaWorkflow.vue new file mode 100644 index 0000000..5a73586 --- /dev/null +++ b/web/src/views/drama/DramaWorkflow.vue @@ -0,0 +1,2063 @@ + + + + + diff --git a/web/src/views/drama/EpisodeWorkflow.vue b/web/src/views/drama/EpisodeWorkflow.vue new file mode 100644 index 0000000..d101f32 --- /dev/null +++ b/web/src/views/drama/EpisodeWorkflow.vue @@ -0,0 +1,2105 @@ + + + + + diff --git a/web/src/views/drama/ProfessionalEditor-storyboard-item.scss b/web/src/views/drama/ProfessionalEditor-storyboard-item.scss new file mode 100644 index 0000000..ccc568e --- /dev/null +++ b/web/src/views/drama/ProfessionalEditor-storyboard-item.scss @@ -0,0 +1,87 @@ +// 镜头列表项样式 - 白色主题 +.storyboard-item { + padding: 8px; + cursor: pointer; + border-radius: 6px; + transition: all 0.2s; + border: 1px solid #e0e0e0; + margin-bottom: 8px; + display: flex; + gap: 10px; + align-items: center; + background: #fff; + + &:hover { + background: #f5f5f5; + border-color: #d0d0d0; + } + + &.active { + background: #409eff; + border-color: #409eff; + + .shot-number, + .shot-title { + color: #fff !important; + } + + .shot-duration { + background: rgba(255, 255, 255, 0.2); + color: #fff; + } + } + + .shot-thumbnail { + width: 80px; + height: 50px; + border-radius: 4px; + overflow: hidden; + background: #f0f0f0; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .shot-content { + flex: 1; + min-width: 0; + + .shot-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + + .shot-number { + font-size: 11px; + color: #666; + font-weight: 500; + } + + .shot-duration { + font-size: 11px; + color: #666; + background: #f0f0f0; + padding: 2px 6px; + border-radius: 3px; + } + } + + .shot-title { + font-size: 13px; + color: #333; + font-weight: 500; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} \ No newline at end of file diff --git a/web/src/views/drama/ProfessionalEditor-styles.scss b/web/src/views/drama/ProfessionalEditor-styles.scss new file mode 100644 index 0000000..17579af --- /dev/null +++ b/web/src/views/drama/ProfessionalEditor-styles.scss @@ -0,0 +1,444 @@ +// 视频合成列表样式 +.merges-list { + padding: 16px; + max-height: calc(100vh - 200px); + overflow-y: auto; + background: linear-gradient(to bottom, #f8f9fa 0%, #ffffff 100%); + + .merge-items { + display: flex; + flex-direction: column; + gap: 16px; + } + + .merge-item { + position: relative; + background: #fff; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.02); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + border: 1px solid transparent; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(64, 158, 255, 0.3); + border-color: rgba(64, 158, 255, 0.2); + } + + .status-indicator { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + transition: all 0.3s; + } + + &.merge-status-completed .status-indicator { + background: linear-gradient(to bottom, #67c23a, #85ce61); + } + + &.merge-status-processing .status-indicator { + background: linear-gradient(to bottom, #e6a23c, #f0c78a); + animation: pulse 2s ease-in-out infinite; + } + + &.merge-status-failed .status-indicator { + background: linear-gradient(to bottom, #f56c6c, #f89898); + } + + &.merge-status-pending .status-indicator { + background: linear-gradient(to bottom, #909399, #b1b3b8); + } + + .merge-content { + padding: 20px 24px; + padding-left: 28px; + } + + .merge-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + padding-bottom: 14px; + border-bottom: 1px solid #f0f2f5; + + .title-section { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + + .title-icon { + display: flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: 10px; + background: linear-gradient(135deg, #f5f7fa 0%, #e8eaf0 100%); + color: #606266; + transition: all 0.3s; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); + } + + .merge-title { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #303133; + line-height: 1.4; + } + } + + :deep(.el-tag) { + font-weight: 500; + padding: 4px 12px; + font-size: 12px; + } + } + + &.merge-status-completed .title-icon { + background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%); + color: #fff; + box-shadow: 0 2px 8px rgba(103, 194, 58, 0.25); + } + + &.merge-status-processing .title-icon { + background: linear-gradient(135deg, #e6a23c 0%, #f0c78a 100%); + color: #fff; + box-shadow: 0 2px 8px rgba(230, 162, 60, 0.25); + } + + &.merge-status-failed .title-icon { + background: linear-gradient(135deg, #f56c6c 0%, #f89898 100%); + color: #fff; + box-shadow: 0 2px 8px rgba(245, 108, 108, 0.25); + } + + .merge-details { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 16px; + + .detail-item { + display: flex; + gap: 10px; + padding: 12px 14px; + background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f5 100%); + border-radius: 8px; + border: 1px solid transparent; + transition: all 0.3s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + + &:hover { + background: linear-gradient(135deg, #e9ecef 0%, #dee2e6 100%); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + border-color: rgba(64, 158, 255, 0.15); + } + + .detail-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 6px; + background: #fff; + color: #409eff; + flex-shrink: 0; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + } + + .detail-content { + flex: 1; + min-width: 0; + + .detail-label { + font-size: 11px; + color: #909399; + margin-bottom: 3px; + font-weight: 500; + } + + .detail-value { + font-size: 13px; + color: #303133; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + } + + .merge-error { + margin-bottom: 12px; + + :deep(.el-alert) { + border-radius: 8px; + border: none; + box-shadow: 0 1px 4px rgba(245, 108, 108, 0.12); + padding: 8px 12px; + font-size: 12px; + } + } + + .merge-actions { + display: flex; + gap: 8px; + margin-top: 12px; + + :deep(.el-button) { + flex: 1; + max-width: 160px; + font-weight: 500; + padding: 8px 15px; + font-size: 13px; + } + } + } +} + +// 旋转动画 +@keyframes rotating { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.rotating { + animation: rotating 2s linear infinite; +} + +// 脉冲动画 +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } +} + +// 白色主题样式 +.shot-editor-new { + padding: 16px; + height: 100%; + overflow-y: auto; + background: #fff; + + .section-label { + font-size: 12px; + color: #666; + margin-bottom: 8px; + } + + // 场景预览 + .scene-section { + margin-bottom: 20px; + } + + .scene-preview { + width: 100%; + height: 80px; + border-radius: 6px; + overflow: hidden; + position: relative; + background: #f5f5f5; + border: 1px solid #e0e0e0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .scene-info { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 6px 8px; + background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent); + font-size: 11px; + color: #fff; + + .scene-id { + font-size: 10px; + color: #e0e0e0; + margin-top: 2px; + } + } + } + + .scene-preview-empty { + width: 100%; + height: 80px; + border-radius: 6px; + border: 1px dashed #d0d0d0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + background: #fafafa; + + .el-icon { + font-size: 32px !important; + color: #c0c0c0; + } + + div { + font-size: 11px; + color: #999; + } + } + + // 角色列表 + .cast-section { + margin-bottom: 20px; + } + + .cast-list { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 8px; + + .cast-item { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + .cast-avatar { + border-color: #409eff; + } + + .cast-remove { + opacity: 1; + visibility: visible; + } + } + + &.active { + .cast-avatar { + border-color: #409eff; + background: #409eff; + } + } + + .cast-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid #e0e0e0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: #f5f5f5; + font-size: 14px; + font-weight: 500; + color: #666; + transition: all 0.2s; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .cast-name { + font-size: 10px; + color: #666; + max-width: 36px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .cast-remove { + position: absolute; + top: -3px; + right: -3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #f56c6c; + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + z-index: 10; + opacity: 0; + visibility: hidden; + font-size: 12px; + + &:hover { + background: #f23030; + transform: scale(1.1); + } + } + } + + .cast-empty { + width: 100%; + text-align: center; + padding: 15px; + color: #999; + font-size: 11px; + } + } + + // 视效设置 + .settings-section { + margin-bottom: 16px; + + .settings-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 10px; + + .setting-item { + label { + display: block; + font-size: 11px; + color: #666; + margin-bottom: 6px; + } + } + } + + .audio-controls { + margin-top: 8px; + } + } + + // 叙事内容 + .narrative-section { + margin-bottom: 14px; + } + + .dialogue-section { + margin-bottom: 14px; + } +} \ No newline at end of file diff --git a/web/src/views/drama/ProfessionalEditor.vue b/web/src/views/drama/ProfessionalEditor.vue new file mode 100644 index 0000000..144b9df --- /dev/null +++ b/web/src/views/drama/ProfessionalEditor.vue @@ -0,0 +1,3651 @@ + + + + + diff --git a/web/src/views/drama/components/GenerateDialog.vue b/web/src/views/drama/components/GenerateDialog.vue new file mode 100644 index 0000000..086f565 --- /dev/null +++ b/web/src/views/drama/components/GenerateDialog.vue @@ -0,0 +1,425 @@ + + + + + diff --git a/web/src/views/drama/components/UploadScriptDialog.vue b/web/src/views/drama/components/UploadScriptDialog.vue new file mode 100644 index 0000000..f4685d3 --- /dev/null +++ b/web/src/views/drama/components/UploadScriptDialog.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/web/src/views/editor/TimelineEditor.vue b/web/src/views/editor/TimelineEditor.vue new file mode 100644 index 0000000..09fd7a9 --- /dev/null +++ b/web/src/views/editor/TimelineEditor.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/web/src/views/generation/ImageGeneration.vue b/web/src/views/generation/ImageGeneration.vue new file mode 100644 index 0000000..1d5415e --- /dev/null +++ b/web/src/views/generation/ImageGeneration.vue @@ -0,0 +1,431 @@ + + + + + diff --git a/web/src/views/generation/VideoGeneration.vue b/web/src/views/generation/VideoGeneration.vue new file mode 100644 index 0000000..b4c4c1c --- /dev/null +++ b/web/src/views/generation/VideoGeneration.vue @@ -0,0 +1,477 @@ + + + + + diff --git a/web/src/views/generation/components/GenerateImageDialog.vue b/web/src/views/generation/components/GenerateImageDialog.vue new file mode 100644 index 0000000..630d561 --- /dev/null +++ b/web/src/views/generation/components/GenerateImageDialog.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/web/src/views/generation/components/GenerateVideoDialog.vue b/web/src/views/generation/components/GenerateVideoDialog.vue new file mode 100644 index 0000000..139216a --- /dev/null +++ b/web/src/views/generation/components/GenerateVideoDialog.vue @@ -0,0 +1,337 @@ + + + + + diff --git a/web/src/views/generation/components/ImageDetailDialog.vue b/web/src/views/generation/components/ImageDetailDialog.vue new file mode 100644 index 0000000..f087576 --- /dev/null +++ b/web/src/views/generation/components/ImageDetailDialog.vue @@ -0,0 +1,294 @@ + + + + + diff --git a/web/src/views/generation/components/VideoDetailDialog.vue b/web/src/views/generation/components/VideoDetailDialog.vue new file mode 100644 index 0000000..5592174 --- /dev/null +++ b/web/src/views/generation/components/VideoDetailDialog.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/web/src/views/script/ScriptEdit.vue b/web/src/views/script/ScriptEdit.vue new file mode 100644 index 0000000..37da4b6 --- /dev/null +++ b/web/src/views/script/ScriptEdit.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/web/src/views/settings/AIConfig.vue b/web/src/views/settings/AIConfig.vue new file mode 100644 index 0000000..fa313da --- /dev/null +++ b/web/src/views/settings/AIConfig.vue @@ -0,0 +1,509 @@ + + + + + diff --git a/web/src/views/settings/components/ConfigList.vue b/web/src/views/settings/components/ConfigList.vue new file mode 100644 index 0000000..eb8716a --- /dev/null +++ b/web/src/views/settings/components/ConfigList.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/web/src/views/storyboard/StoryboardEdit.vue b/web/src/views/storyboard/StoryboardEdit.vue new file mode 100644 index 0000000..59832fe --- /dev/null +++ b/web/src/views/storyboard/StoryboardEdit.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/web/src/views/workflow/CharacterExtraction.vue b/web/src/views/workflow/CharacterExtraction.vue new file mode 100644 index 0000000..f375281 --- /dev/null +++ b/web/src/views/workflow/CharacterExtraction.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/web/src/views/workflow/CharacterImages.vue b/web/src/views/workflow/CharacterImages.vue new file mode 100644 index 0000000..fda3899 --- /dev/null +++ b/web/src/views/workflow/CharacterImages.vue @@ -0,0 +1,346 @@ + + + + + diff --git a/web/src/views/workflow/DramaSettings.vue b/web/src/views/workflow/DramaSettings.vue new file mode 100644 index 0000000..d39a45b --- /dev/null +++ b/web/src/views/workflow/DramaSettings.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/web/src/views/workflow/SceneImages.vue b/web/src/views/workflow/SceneImages.vue new file mode 100644 index 0000000..9bf345e --- /dev/null +++ b/web/src/views/workflow/SceneImages.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/web/src/views/workflow/ScriptGeneration.vue b/web/src/views/workflow/ScriptGeneration.vue new file mode 100644 index 0000000..30dfb9d --- /dev/null +++ b/web/src/views/workflow/ScriptGeneration.vue @@ -0,0 +1,1219 @@ + + + + + diff --git a/web/src/views/workflow/StoryboardGeneration.vue b/web/src/views/workflow/StoryboardGeneration.vue new file mode 100644 index 0000000..27cf224 --- /dev/null +++ b/web/src/views/workflow/StoryboardGeneration.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/web/tailwind.config.js b/web/tailwind.config.js new file mode 100644 index 0000000..bd3eb14 --- /dev/null +++ b/web/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{vue,js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..6d440f1 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts", + "src/**/*.tsx", + "src/**/*.vue" + ], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] +} \ No newline at end of file diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..fb9e861 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,22 @@ +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + host: '0.0.0.0', + port: 3012, + proxy: { + '/api': { + target: 'http://localhost:5678', + changeOrigin: true + } + } + } +})